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 eleven 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 at 50 fps), squares, fan, orbits, the phoenix, and four flowers — and two are original pieces authored for this panel (the willow curtain and a weeping-willow tree, with fireflies and moonlight). 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 — the parasol now holds 50 fps, the thistle, dahlia and spider mum 33, the phoenix 25. It costs them 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 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.