Custom firmware for a CrowPanel round display with rotary encoder. One physical dial controls Sonos music, WiZ lights, weather, and a Discogs vinyl library — all through a unified local bridge. No cloud auth on the device.
IApp / AppManager frameworknode-sonos-http-api (local)library.jsonThe device never talks directly to cloud services. All external integrations live behind a single Node.js bridge on the home PC, which exposes three namespaces on one local port. The ESP32 makes one type of HTTP call — to one address — and the bridge handles credentials, protocols, and data normalization upstream.
The firmware is structured around a small IApp interface and an AppManager that owns the launcher, routes input events, and runs a non-blocking main loop: poll input → drain ISR-safe event queue → update active app → service Wi-Fi → render. Input is interrupt-driven with a quadrature decode table and debounced long-press detection. Apps are decoupled from networking — that separation is what makes each new capability an isolated addition rather than a cross-cutting change.
Four apps are running on hardware: Sonos (room wheel, now-playing with radial progress ring, album art), Weather (four pages — Today, Sun, Moon, Tides — cycled with the dial), WiZ (brightness, color temperature, scenes), and Vinyl (Discogs-backed library with queue, play-count tracking, and history, plus a companion desktop web UI on the bridge).
The ESP32 has the RAM and flash to call Sonos, Discogs, and WiZ directly, but room configuration, OAuth flows, and API credential management don't belong on a microcontroller. The bridge gives the device a single trusted local address. Cost: always-on home infrastructure. Mitigated with pm2 process supervision and a pinned static IP — the DHCP drift that killed Sonos in one session (see Challenges) made this discipline non-negotiable.
WiZ bulbs speak a trivial local UDP JSON protocol on port 38899, so the ESP32 can control them directly and the first implementation did. Discovery, room naming, and scene management don't fit on-device — those moved to /wiz/* bridge endpoints. The direct-UDP path stays as a fallback. One extra network hop in exchange for significantly simpler firmware.
LovyanGFX can't decode progressive JPEGs, which is what Discogs serves. The bridge intercepts cover URLs, converts them to baseline JPEGs at a dial-safe size, and caches them. The decode still happens on-device into PSRAM, but the format problem is eliminated upstream where a real CPU can handle it cleanly. On-device art caching is currently disabled pending a safe SPIFFS init path.
WiZ commands round-trip in 200–900 ms. Both the desktop UI and the dial patch local state and redraw immediately, then fire the command and reconcile with a single debounced probe. Blocking on a command-then-reprobe cycle would make every brightness adjustment feel broken.
-j 1The build host is RAM-constrained. Parallel cc1plus jobs each allocate ~8 MB and OOM the compile. The build runs serially. Oversized fixed arrays were replaced with std::vector to reduce generated constructor code and keep build memory flat.
Live Sonos control died mid-session with no code change. The instinct was to look at recent cleanup. The decisive move instead: the Weather app — a plain internet HTTP call — still worked while the Sonos bridge call failed. The same HTTP client reaching the internet but not a specific LAN address can only be a network problem. On-screen diagnostics (error code + Wi-Fi RSSI) and a Test-NetConnection to port 5005 confirmed it: the bridge host's IP had drifted via DHCP (.125 → .63) and the server wasn't running. Fix was pm2 persistence and a static IP, not a firmware change. Now a permanent entry in the debugging playbook: when a connected service fails, reproduce against the dependency before reading any code.
Long-press-to-go-back stopped working, but only when the bridge was unreachable. A failing HTTP poll was blocking the main loop for up to five seconds — during which the input driver never ran and the button-hold timer was never serviced. Fix: short connect timeout, back off polling to 10 s when the bridge is down. On a single-threaded microcontroller, every synchronous call in the per-frame path is a potential freeze.
The WiZ screen redrew the whole frame every tick because its dirty flag was set but never cleared — fixing that plus PSRAM double-buffering solved it. The animated "Buddy" screen couldn't afford a sprite, so the solution was repainting a fixed bounding box each frame with the backdrop pixels drawn as background rather than calling fillScreen — flicker-free at zero extra RAM. A third unrelated screen was flickering due to a sprite that exceeded PSRAM limits; profiling allocated memory per-frame caught it.
The board advertises 16 MB flash; 8 MB is usable. A 16 MB SPIFFS partition definition froze the Vinyl app on mount. Recovery: cap the partition inside 8 MB, switch to a non-auto-formatting mount so a cache-init failure can't block the input loop and read as broken navigation. Verify hardware specs against the actual device, not the datasheet.
The panel stayed black after firmware upload even though the backlight blinked and SPI transactions appeared to succeed. The GC9A01 init sequence looked correct. The actual problem: the CrowPanel routes LCD logic power through GPIO1/GPIO2; GPIO4/GPIO12 (which earlier code drove) are unrelated test I/O. Powering the wrong rails left the controller dark while everything else looked healthy. Getting the first real pixels on a round screen was the moment the project became buildable.
main.