Rust firmware for the M5StickC Plus (ESP32-PICO-D4, Xtensa LX6, 4 MB flash,
520 KB SRAM, no PSRAM), built std on ESP-IDF. Several small apps share one
reusable platform foundation — the screen, the sprite creature, the buttons, the
buzzer, the clock, and the change-suppressing render loop are written once and reused,
so a new experiment is a new directory under apps/, not a new firmware:
-
pomodoro — a standalone, offline focus timer: the TFT shows
MM:SSand a Claude creature that codes through a focus and dances through a break; the two buttons drive it and the buzzer sounds each transition. Screen + buttons + buzzer, no network. -
plant-monitor — an M5 Earth Unit soil probe → moisture dashboard, surfaced to Home Assistant through a home-grown ESPHome native-API crate so HA does the storing, graphing, and alerting.
-
host-monitor — a desk display of every homelab host's CPU and memory as live scrolling sparklines, one row per host: the board fetches a single bearer-gated hostpulse endpoint over WiFi, which returns a ready-to-plot per-host series for all hosts at once (the PromQL
rate()is done server-side). Screen + WiFi, a pure metrics client. -
orientation — a live readout of which way the board is pointing: the MPU6886 IMU's gravity vector as three signed X/Y/Z bars, the pitch and roll in degrees, and the face it is resting on — laid out for whichever way up the board is being held. Screen + IMU, no network, no buttons — turn it and watch.
-
generative-art — a gallery of twelve generative sketches, one on the panel at a time, the front button cycling to the next (wrapping). Eight are faithful ports of a Dwitter — the plume (an ambient feathered frond, 7 500 points, north of 80 fps), squares, fan, orbits, the phoenix, and four flowers — two are original pieces authored for this panel (the willow curtain and a weeping-willow tree, with fireflies and moonlight), and one is a port of a twigl shader: the urchin, a raymarched Apollonian sphere of bubbles turning inside a cloud of more of them. Each is a pure producer of a frame from a phase, sharing one startup-built sine table and one full-screen buffer blitted in a single DMA burst. Its point is the platform's math and rendering under load. Screen + one button — press to move through the gallery.
The phoenix and the flowers are the odd ones out and deliberately so: they are drawn by accumulation, their translucent dots piling a pixel up a sixteen-rung grey ladder rather than stamping it, which is the only way they read as anything but a white blob. They are also the gallery's slow pieces, measured on the glass and reported rather than dressed up — at the 160 MHz the app ran until the fourth optimisation round, the parasol held 50 fps, the thistle, dahlia and spider mum 33, the phoenix 25. The gallery now runs the chip at 240 MHz with a 1 ms FreeRTOS tick, both in its own layered
sdkconfig.gallery.defaults— the CPU clock is a frame-rate setting exactly the way the flash clock below is, and the tick sizes the render loop's mandatory yield, which at 100 Hz taxed every over-budget frame with up to 10 ms of waiting for a tick boundary. Every paint is now two thirds its old cost; the urchin's rate below is re-measured, the others are due a re-read on a lap of the glass. It costs the flowers nothing visible: a flower's breath takes twenty-two seconds, so even at 25 fps a frame advances it by a five-hundredth of a cycle.The urchin is the odd one out in a different way, and it is the gallery's expensive piece by an order of magnitude: it is a raymarcher, so where every other sketch draws marks onto a canvas, this one walks a ray through eight nested sphere inversions per step and asks what light comes back. None of that can be precomputed, tabulated or shared with the ray next door — the chaos is the picture — so the whole design is a budget. Measured on the board, a ray costs 5 µs a step at the 240 MHz clock; at the source's own fidelity — one ray a pixel, 98 steps — a frame would take 16 seconds. What it actually holds is 12.6 fps (78 ms a frame), and the reductions that bought that are each written down where they are decided: one ray per 6×6 block; 24 of the source's 98 march steps — the fourth round was granted a 5 % fidelity allowance and spent it here, measured ray-for-ray against the previous 32-step picture at 3.5 % of full scale (a shallower fold was measured too and declined: half the savings for more error and a 57 % worst case); every division through a Newton–Raphson reciprocal (worth a measured 2.1× at the current budget) — unguarded, because the fold's own clamp proves its divisor in range and the guard's dead compares were another 20 % of the march — the logarithm through
fast_ln(exponent bits plus a polynomial;libm::logfis a call per step), the source'ssqrtproved away as a no-op, and the far half of every frame marched on the ESP32's second core. Placement and latency-hiding were measured too, and bought nothing — the march is FPU-occupancy bound, so only removing float instructions moves it. The rays are then bilinearly blended across the panel rather than stamped as flat blocks — 920 point samples of a fractal is far below its own detail, and painted flat that reads as squares of static rather than as an object. The blend itself got the same treatment once the march stopped dwarfing it: walked span-major with the per-pixel division count at zero, quantised through tables, delivered through a batchedCanvas::run(golden byte-identical), and streamed into the march's shadow — the worker hands back each far row the moment it is marched, and the render thread spreads every band while the other core is still marching, so of the spread's 23 ms nothing but the last row's band is ever paid in series. It turns once a minute, so a frame still advances it by about half a degree.The one operation those two kinds of sketch are made of — read the rung a pixel is on, write where one more dot sends it — is a single call on the drawing-surface port (
Canvas::composite), not agetand aset. Through the pair, a dot bounds-checks, addresses and (on the board's packed 12-bit surface) splits the pixel's nibbles twice; the wire adapter overrides the port's default to pay for all of it once, and a property test holds the override to what the default paints at every coordinate, colour and parity. Measured on the board: 0.654 → 0.551 µs a dot, which is 1–2 ms off every flower's frame.Getting there was two thirds arithmetic and one third the flash interface. Every sketch runs from flash through the instruction cache and their inner loops miss it, so the same source laid out two ways ran 2x apart; the board's flash was clocked at 40 MHz and is stable at 80. That one line did more for the flowers than any loop transform, which is worth knowing before optimising arithmetic on this chip. The transforms that did pay are in
flower-core: the projection folded into the sweep's per-rib constants, a rotation recurrence in place of a table lookup per point, and1.0 / xin multiplies because this FPU has no divide instruction either.The four flowers — dahlia, spider mum, thistle, parasol — arrived as four separate Dwitters and turned out to be one flower at four sets of numbers: the same golden-angle whorl, breath, cup and tilted projection, differing in about eighteen coefficients. So they are one domain crate (
flower-core) parameterised by aSpecies, one rasteriser, and four constants. They are also the only pieces shown in landscape: every flower is wider than it is tall, so aSketchdeclares which way up it wants and the canvas turns for it (the wire adapter transposes on the way into the same DMA buffer — the panel itself never turns, so there is no black frame at the switch). Hold the stick with its USB-C port to the right for these. Seedocs/plans/generative-art-sketches-handoff.md. -
fluidbox — a fluid in a box you tilt: a few hundred particles of position-based fluid (Clavet's double-density relaxation) in a shallow 3D tank behind the glass, pulled toward true down by the MPU6886 and splashed by a shake. A faithful port of the C original esp32-fluidbox, its solver re-derived on the host against an f64 reference — down to the source's two-core architecture: the solver free-runs on Core1 at the pace the physics costs while Core0 paints the newest frame, joined by
platform-parallel's latest-wins triple buffer so neither ever waits. Depth and speed pick each particle's colour off a blue→white ramp — the back of the tank sits dimmer and smaller than the front — and the front button resets the pour while the side button cycles the particle count. Screen + IMU + buttons, no network. -
led-driver — a NightDriverStrip-style WS2812 animation driver (the repo's original purpose; the
led-coreeffects domain lives on). Future. -
rover — a controllable robot. Future; diverges in hardware.
The domain is framework-free and dependencies point inward. The heart of each app (the pomodoro FSM, the moisture curve, the LED effects) never mentions hardware, so it is verified on the host — Gherkin, property, and unit tests — with no device attached. The firmware is the thin imperative shell: adapters that implement the domain's ports against real ESP-IDF peripherals, plus a composition root per app.
What makes it a platform is that the board-generic machinery is carved out of any one
app and shared. The pomodoro timer and the plant monitor drive the same generic render
loop, over the same Screen/Animated/Clock ports, painting the same ClaudePix creature
through the same ST7789 panel adapter — each app supplies only its own picture and its own
state.
stick-c-plus/
├─ platform/ # the reusable, app-agnostic foundation (context = "shared")
│ ├─ platform-core/ # domain — Tick, the Clock/Screen/Backlight/Tone/AudioIn
│ │ # ports, the Animated contract, the rotation policy
│ ├─ platform-numerics/ # domain — the startup-built sine table (a LUT proven within
│ │ # 1e-3 of libm), shared by every generative sketch
│ ├─ platform-input/ # domain — the board's three buttons as one event source:
│ │ # the levelled + latched ports, and the pure
│ │ # click / double-click / long-hold recognizer
│ ├─ platform-audio/ # domain — the acoustic level (DC-removed RMS) + sound-present
│ │ # verdict (the chime self-test's ears, host-tested)
│ ├─ platform-display/ # port-and-adapter — the ClaudePix sprite library, the fixed-width
│ │ # text primitives, the sparkline, the signed axis
│ │ # bar, the colour self-test
│ ├─ platform-canvas/ # port-and-adapter — the full-screen drawing surface: the Canvas port,
│ │ # the Ramp + grey ladder, the host Rgb565 Frame, and
│ │ # the panel's 12-bit wire-order Canvas adapter
│ ├─ platform-parallel/ # port-and-adapter — the second core as a library: the job/result
│ │ # Worker (fork-join) and the latest-wins triple
│ │ # buffer (two loops at their own paces), no unsafe
│ ├─ platform-runtime/ # driving-adapter — the Monotonic clock, the generic change-
│ │ # suppressing render loop (over any Animated state),
│ │ # and the backlight switch it skips a dark paint on
│ ├─ firmware-core/ # domain — pure shared kernel (ADC oversampling, gating)
│ ├─ esphome-api/ # domain — ESPHome native-API framework (prost + std::net)
│ └─ esphome-server/ # driving-adapter — the native-API server host (accept loop → FSM)
├─ apps/ # one bounded context per app, built on the platform
│ ├─ pomodoro/ # pomodoro-core (FSM) · pomodoro-display (screen) · pomodoro-shell
│ ├─ plant-monitor/ # plant-core (moisture) · plant-display · plant-shell
│ ├─ host-monitor/ # host-core (Pulse frame + clamp/gap transform) · host-wire (JSON codec) · host-display · host-shell
│ ├─ orientation/ # orientation-core (tilt + resting face) · orientation-display · orientation-shell
│ ├─ generative-art/ # art-core (Sketch running order + Selector) · one *-core domain crate
│ │ # per piece (plume, squares, fan, orbits, willow, weeping-willow,
│ │ # phoenix: each a field/shape + its phase clock) · art-display (the
│ │ # gallery renderer + per-sketch raster, plotting through
│ │ # platform-canvas) · art-shell (the front-button input thread +
│ │ # shared selector)
│ ├─ fluidbox/ # fluidbox-core (the particle solver: grid, pair cache, double-
│ │ # density relaxation, rounded-box walls) · fluidbox-display
│ │ # (pinhole projection + depth/speed palette + disc raster) ·
│ │ # fluidbox-shell (the IMU sampler thread + the sim-then-paint runner)
│ └─ led-driver/ # led-core (WS2812 effects)
├─ firmware/ # the Xtensa boundary — a detached std/ESP-IDF workspace
│ ├─ platform/ # board-support (BSP: AXP192, MPU6886, I2C) · adapters (ST7789 panel +
│ │ # generic PanelScreen, G37/G39 buttons + the AXP192 PEK power button,
│ │ # the LDO2 backlight, G2 LEDC buzzer, G0/G34 PDM mic, MPU6886 IMU) ·
│ │ # net (shared WiFi STA + DNS resolve)
│ └─ apps/ # plant-monitor/{adapters, firmware-infra, bin} · host-monitor/{adapters, bin}
│ # · pomodoro/bin (+ the chime-selftest bench tool) · orientation/bin
│ # · generative-art/bin (the gallery) · fluidbox/bin
└─ kb/ # Knowledge base — board facts, sources, findings (kbe-style)
The generic render loop is the keystone of the reuse. Its Animated contract carries a
coarse anchor (a plant Observation; a pomodoro (phase, status)) that resets the
creature's animation clock only on a real transition — so a pomodoro's mm:ss can tick
every second while its creature keeps animating on the phase's clock, and a healthy plant
reading stays a motionless creature the loop never repaints. Motion is spent only where it
buys the operator information.
Every crate in both workspaces carries one [package.metadata.hex-arch] role tag (and, in
the host workspace, a bounded context — shared for the platform, one per app otherwise).
hex-lint enforces the role matrix and context isolation on each commit via
just precommit; effect-audit holds the functional cores pure. Neither is advisory.
kb/ is a ~/kbe-style knowledge base for everything we learn
about this board — cited sources, on-device experiments, and the findings distilled from
them. It never compiles into the firmware. Its headline source is M5Stack's shipped
FactoryTest app (m5stack/M5StickC-Plus, a
pinned submodule) — the AXP192 / ST7789 bring-up we port into firmware/. Start at
kb/INDEX.md. Fresh checkouts: git submodule update --init.
The two worlds build under different toolchains, on purpose: the host workspace on
stable rustc (cargo test), the firmware on the esp fork for
xtensa-esp32-espidf. firmware/ is its own workspace ([workspace] + root exclude) so
its Xtensa target never touches cargo test; firmware crates reach the host crates by
path across the boundary.
just screens renders every state each app's TFT can show — the pomodoro's ready / focus /
break / paused / finished screens, the plant monitor's four Observation states, and the
orientation readout's poses in both the landscape and the portrait layout — to
target/screens/*.png. The pixels come from each app's render, the same function the
ST7789 adapter calls on the board, drawn into a host framebuffer instead of down an SPI bus.
It is the layout, not a picture of it — proven against real pixels by a shared test
framebuffer.
The right-hand region holds a creature that is the status. On the pomodoro it heads
down to code through a focus, bounces through a break, and winks when a phase completes; on
the plant monitor it breathes while healthy, is startled when the probe lies, and sleeps
when the sampler stops. The artwork is vendored from ClaudePix,
whose licence is unresolved; just sprites regenerates it with babashka,
just sprite-screens draws all 13 presets.
A host render proves the wording, the alignment, the colour each state is drawn in, and that
a short value erases the longer one it replaces. It proves nothing below the
DrawTarget: the panel's colour order, CGRAM offset, inversion and backlight are the
adapter's business, and a framebuffer paints red as red however the glass is wired. For the
red/blue order, just run-bin display-colour-check and look at the board.
- The
esprustc fork + Xtensa toolchain, viaespup:cargo install espup && espup install --targets esp32. No~/export-esp.shto source —esp-idf-sysself-provisions clang, xtensa-gcc, and a Python venv underfirmware/.embuild. A fresh ESP-IDF bootstrap needs Python ≤ 3.12 plusninjaandldproxyonPATH(the justfile handles the Python shim). espflashfor flashing.
just test # host — every app's domain + shell, stable rustc, no device
just build # firmware — Xtensa std/ESP-IDF (both bins, release)
just screens # render every app's screens to target/screens/*.png
just ci # fmt + hex-lint + sprites + clippy (both worlds) + test + buildConnect the board (appears as /dev/ttyUSB0), then flash the app you want:
just run-pomodoro # the standalone pomodoro timer (screen + buttons + buzzer, offline)
just run-chime-selftest # play every jingle through the buzzer, hear it back on the PDM mic
just run # the plant monitor (a.k.a. `just flash`)
just run-host-monitor # the homelab CPU/memory monitor (WiFi → hostpulse → per-host sparklines)
just run-orientation # the IMU orientation readout (X/Y/Z bars + pitch/roll + resting face)
just run-generative-art # the button-cycled gallery of generative sketches (front button = next)
just monitor # serial monitor only — pty-free (espflash --non-interactive)The host-monitor needs the hostpulse endpoint reachable on the LAN (the control node,
:9099 by default): a single read-only, bearer-gated GET /pulse that returns a ready-to-plot
per-host CPU/memory series for every host, having already done the PromQL rate() server-side.
Its endpoint (host:port) and token (a 64-hex bearer) go in firmware/secrets.toml's
[host_monitor] table (git-ignored, alongside the WiFi credentials — see
firmware/secrets.toml.example; the token is never committed or logged). The board fetches the
frame every ~20 s and simply replaces its buffers — no on-device parsing or rate math — and
draws one row of CPU/memory sparklines per host, keeping the last good frame if the endpoint
faults or goes stale.
The pomodoro controls: front button (G37) click = start / pause / resume, front
double-click = restart the whole session, front long hold = reset the current phase, side
button (G39) click = skip to the next phase, power button click = light the glass or darken
it (a dark screen is not painted at all, so it costs no SPI traffic). Only the front button
reports double-clicks, and it is the only one that pays for them: telling a double-click from a
single one means waiting out a 300 ms window, so the side button's skip and the power button's
toggle stay immediate. Durations are the classic 25 / 5 / 15 min (long
break every 4th focus) — one constant in pomodoro-core to change, or shrink for a bench
test. The transition jingles are melodies in intent, but the tiny passive buzzer is not a
speaker: measured on-device it is loud across ~2–9 kHz yet radiates almost none of its energy at
the pitch it is driven, so it renders them as loud beeps told apart by rhythm and note count, not
tune — the notes are simply kept in that loud band. just run-chime-selftest proves the
audibility on-device — it plays every note through the buzzer while listening on the PDM mic and
logs each note's acoustic level against the silent floor, so audibility is falsifiable instead of
taken on faith. Serial traps (dialout group, the FT232 baud ceiling)
are in kb/guides/flashing-and-serial-access.md.
Pin-exact in the KB (kb/guides/m5stickc-plus-board-reference.md):
the ST7789 TFT on SPI (SCLK 13 / MOSI 15 / CS 5 / DC 23 / RST 18), the front / side buttons
on G37 / G39 (input-only, active-low), the passive buzzer on G2 (LEDC PWM). The plant
probe is the M5 Earth Unit on G33 (ADC1_CH5) — ADC1 so it coexists with WiFi — and the
LED strip (project #3) is WS2812 on G32.
Tracked in beads — just ready for unblocked work, just triage for graph-ranked
recommendations. Done: the platform carve-out and the standalone pomodoro timer (host-tested
FSM + on-device screen / buttons / buzzer); the plant monitor's WiFi, mDNS, ADC sampler, and
the Sensor entity served over the native-API host (verified host-first against the real HA
client; the on-device adoption pass awaits the board); the host monitor — WiFi promoted to
a shared net crate, the hostpulse Pulse frame + clamp/gap transform + JSON codec + a
board-generic sparkline, one row per host, all host-tested, cross-compiled to a linked Xtensa
image (the on-device run awaits a reachable hostpulse endpoint). Next up: the plant monitor's
Noise encryption + OTA, then the WS2812 driver and the rover on the same platform.
The code here is dual-licensed under either of
- Apache License, Version 2.0 (
LICENSE-APACHEor http://www.apache.org/licenses/LICENSE-2.0) - MIT license (
LICENSE-MITor http://opensource.org/licenses/MIT)
at your option. Each crate's license field is the source of truth — every first-party crate
carries this pair, with one deliberate exception: platform/esphome-api is MIT-only, because
its message types are vendored from aioesphomeapi
(MIT); see its PROVENANCE.md.
Two things the dual licence does not cover:
- The ClaudePix creature art in
platform/platform-display, vendored from ClaudePix: its licence is unresolved, so it is not offered under the terms above.just spritesregenerates it — resolve or replace it before any redistribution that needs clean provenance. - The
kb/sources/submodules (M5Stack's FactoryTest, aioesphomeapi, UbiHome's esphome-native-api): each keeps its own upstream licence.
Unless you state otherwise, any contribution you intentionally submit for inclusion in the work, as defined in the Apache-2.0 licence, shall be dual licensed as above, without additional terms.