An ESP32 based LED cube, inspired by this project.
Recorded from a running cube with scripts/capture_patterns.py and drawn as the cube looks from its corner.
![]() Snake Snakes of many kinds roam all three faces, crossing the seams, eating and growing. | ![]() Plasma A plasma glowing from the corner and fading out at the outer edges, flowing continuously across the seams. | ![]() Spotify Album art, scrolling track details and playback state, with the album's colours on top. See Spotify. |
![]() Clock An analog dial on top, the time on the right, the date on the left. | ![]() Game of Life Conway's Life on all three faces as one surface; gliders cross the seams. | ![]() Ripples Rings spreading from the corner, plus raindrops. |
![]() Matrix Rain Rain across the top, over the edges and down the sides. | ![]() Nebula A drifting 3D noise cloud the cube is cut out of. | ![]() Wireframes Rotating solids floating inside the cube. |
![]() Rubik's Cube Scrambles, then solves itself. | ![]() Falling Sand Sand poured from the top piles up down the sides. | ![]() Plane Sweep Coloured planes sweeping through the cube's volume. |
![]() Ticker Your message, the time and the date scrolling around the sides. | ![]() DVD The screensaver logo, cut out of a coloured block, gliding around the sides; the top counts perfect corner hits. | ![]() Hyperspace Stars streaming out of the corner, with a jump to light speed every so often. |
![]() Aurora Green curtains with violet tops rippling round the sides, ribbons and stars overhead. | ![]() Lava Lamp Blobs of wax rising and sinking through the cube, glowing where they meet the faces. | ![]() Fire Flames licking up the sides, embers drifting over the edge onto the top. |
![]() Fireworks Rockets climbing the sides and bursting, sparks spilling over the edges. | ![]() Aquarium Fish, seaweed and bubbles in the tank; caustics and ripples on the surface above. | ![]() Maze A maze grown across all three faces and over the seams, then solved. |
![]() Langton's Ant Ants and their multi-colour cousins building highways and blooms across the seams. | ![]() Pong The cube playing itself at Pong round the sides, the net down the corner, the score on top. | ![]() Breakout The cube playing Breakout round the sides; score, lives and level on top. |
![]() Word Clock The time in words, lit in a grid of letters round the sides, with a seconds ring on top. | ![]() Weather The sky outside round the sides -- sun, moon, clouds, rain, snow or storm -- and the forecast on top. |
To build firmware for this cube, all you need is an IDE and PlatformIO. I use VSCode, but you can use whatever you want. See here for more details on setting up PlatformIO in VSCode.
Clone this repo, and open it in your IDE. You should be able to move on to building.
This project uses ESPIDF with Arduino as a component, and PlatformIO to build and upload firmware. To build, just click the build button in your IDE. To upload, click the upload button. You can also use the PlatformIO CLI to build and upload. See here for more details.
Currently, the cubes support 4 different methods of getting new firmware. The first one, used exclusively in production, is downloading straight from Github releases. These builds are created with Github Actions and are not used for development; a cube running a local DEV build never replaces itself this way. The second is the Update Firmware card on the dashboard's System tab: upload esp32-s3-devkitc-1-<version>.bin from a release, or .pio/build/esp32-s3-devkitc-1/firmware.bin from a local build. The cube checks the image before writing it. The third method is using OTA updates (switch on "OTA Update Enabled" on the dashboard first). This is the easiest method of uploading new firmware during development. To upload using OTA, you need to have the cube connected to your network. Ensure that these two lines are uncommented in platformio.ini, and that the cube is on the same network as your computer:
upload_protocol = espota
upload_port = cube.localThen, click the upload button in your IDE. This will upload the firmware to the cube over the network. The cube will then reboot and start running the new firmware.
The fourth method is using a USB to serial adapter. This is mostly only useful if you have uploaded firmware that breaks OTA, bur can also be used for debugging. To upload using serial, you need to have the cube connected to your computer via USB.
Connect the Cube GND to the adaptor GND, the Cube TX to the adaptor RX, and the Cube RX to the adaptor TX, then comment out these two lines in platformio.ini:
;upload_protocol = espota
;upload_port = cube.localThen, click the upload button in your IDE. This will upload the firmware to the cube over serial. The cube will then reboot and start running the new firmware.
The Spotify pattern shows what is playing on your account: album art on one side face, the track, artist, a progress bar and playback state on the other. Each cube uses your own Spotify app, because Spotify's developer mode limits an app to its owner and a few allowlisted users.
- Sign in at developer.spotify.com/dashboard and create an app (any name). Development mode needs the app owner to have Spotify Premium.
- Under Redirect URIs, add exactly:
https://elliotmatson.github.io/LED_Cube/spotify/callback.html(Spotify only accepts HTTPS redirects, which the cube cannot serve itself; this page just forwards the login back to your cube on your network.) - Select Web API, save, then copy the app's Client ID and Client Secret.
- On the cube's dashboard, open the Spotify tab and paste them into Spotify Client ID and Spotify Client Secret.
- Use Log in to Spotify on that tab (or open
http://cube.local/spotify), and approve the app. The browser comes back to the cube, which finishes the login and switches to the Spotify pattern.
If something goes wrong, the Spotify card on that tab says what (also at /api/v1/spotify). Log out of Spotify forgets the linked account. To use another account in development mode, add its email under the app's User Management first.
For local builds you can instead put SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET in lib/cube/secrets.h (gitignored); they are used only when the dashboard fields are empty. Never put the secret in a build you publish.
The Weather pattern shows the conditions outside on the side faces (sun or moon, clouds, fog, rain, snow or a storm, by day or night) and the temperature, conditions and today's high and low on top. Forecasts come from Open-Meteo, which needs no account or key, every 15 minutes while the pattern runs.
By default the cube works out where it is from its IP address, which can be off by a town or two. To set it, open the dashboard's Weather tab and type a city or postcode into Weather Location; the status card shows the place it found. Clear the box to go back to the IP address. Weather in °C and km/h switches units.
The same settings are at /api/v1/weather: GET for what the pattern knows, POST location= and/or metric=true|false. POST preview=<WMO code> (and day=false for night) shows a kind of weather for two minutes, to try the animations: 0 clear, 2 partly cloudy, 3 overcast, 45 fog, 53 drizzle, 63 rain, 73 snow, 95 thunderstorm.
Release builds report to an MQTT broker, and take settings from it, so cubes in the field can be followed and managed. While the project is in testing this is always on, with no opt-out. A build reports only if it was given a broker (MQTT_URL, MQTT_USER, MQTT_PASSWORD in lib/cube/secrets.h, written by CI from repository secrets); other builds never connect.
Each cube picks a random ID once and publishes under cube/<id>/, as JSON:
| Topic | When | Contents |
|---|---|---|
status |
retained | online, or offline (the broker publishes that if the cube drops) |
boot |
each boot (retained) | firmware version and build, chip, MAC address, WiFi network (SSID, BSSID, signal, channel), local and public IP, the boot log (reset reasons, rollbacks, how far startup got) |
network |
each (re)connection (retained) | SSID, BSSID, signal, local and public IP, and whether it is on its own network or an open one |
health |
every interval | uptime, memory and its low-water marks, signal, current network (SSID, BSSID), pattern |
usage |
every interval | seconds per pattern, brightness, whether Spotify, a weather location and a ticker message are set |
perf |
every interval | the renderer's frame rate and tick/push times |
wifi |
boot, then hourly (retained) | a scan of the networks in range: SSID, BSSID, signal, channel, security |
location |
while lost: every 2 minutes (retained) | a fresh public IP, its network and access point, and a scan of what is in range |
lost |
when lost mode changes (retained) | whether it is on, silent, its message, whether a PIN is set |
crash |
once per crash | a summary (task, PC, cause, backtrace), then the core dump in base64 parts on crash/<n> |
settings |
retained | current settings |
ack |
per command | whether a command was applied, and why not |
The interval defaults to 5 minutes. No Spotify or WiFi credentials are ever sent.
Commands are messages to cube/<id>/set/<name>:
| Name | Value |
|---|---|
pattern |
a pattern id (see /api/v1/patterns) |
brightness |
0-255 |
ticker, timezone, weather_location |
text |
weather_metric, github_updates, development, ota |
true / false |
telemetry_interval |
seconds, 60-86400 |
report_health, report_usage, report_perf |
true / false |
lost_message |
text shown in lost mode (up to 120 characters) |
lost_pin |
4-12 digits that unlock it on its dashboard; empty removes it |
lost_silent |
true: in lost mode, keep looking normal |
lost_mode |
true / false |
restart, check_updates, resend_crash |
anything (actions) |
If a cube goes missing, set lost_message (and lost_pin, if whoever finds it should be able to unlock it), then lost_mode true:
- It shows the message, scrolling round the sides, with LOST and its ID on top -- unless
lost_silentis set, when it keeps showing patterns as usual. - Its dashboard and API refuse changes (status still reads), and the upload card and ArduinoOTA refuse firmware. GitHub updates still install.
- It reports a
locationevery two minutes, andnetworkwhenever it comes online anywhere. - WiFi setup stays open on purpose: someone setting it up on their own network is its likeliest way back online. If it is offline for a few minutes, it also tries nearby open networks, keeping one only if the broker can be reached through it; its saved network is never overwritten.
- It persists through restarts, power loss and WiFi resets.
lost_modefalse, or the PIN on the dashboard's System tab, turns it off. PIN guesses are limited: after five wrong ones, one every 15 minutes.
The firmware connects with wss:// (MQTT over WebSockets, TLS on a standard HTTPS port), checking the certificate against ESP-IDF's bundle, so the broker needs a publicly trusted certificate. Every cube shares one login, which is readable from any published image, so the broker must confine it. With EMQX:
{allow, {username, "admin"}, all, ["#"]}.
{deny, {username, "cube"}, publish, ["cube/${clientid}/set/#"]}.
{allow, {username, "cube"}, publish, ["cube/${clientid}/#"]}.
{allow, {username, "cube"}, subscribe, ["cube/${clientid}/set/#"]}.
{deny, all}.
A cube can then only publish its own reports and read its own commands; commands come from a separate admin login that never goes in firmware.
The animations above come from the cube itself: GET /api/v1/frame returns the frame it is showing, and the script switches through every pattern and records each one.
uv run --with pillow scripts/capture_patterns.py --host cube.localPass pattern ids to record only some of them, e.g. ... --host cube.local clock nebula.


























