Moon-themed Tildagon hexpansion app for the EMF 2024/2026 badge (ESP32-S3). Drives 7× SK6812 addressable LEDs and stores itself on an ST M24C64 8 KB I²C EEPROM (LittleFS). Auto-runs when the badge detects the board on insertion.
Built and verified against emfcamp/badge-2024-software @ 2775c86 (branch main),
running on a badge with MicroPython .mpy bytecode v6.3.
Firmware/ is the app, Hardware/Shroomgon/ is the KiCad board. The commands below
assume you are in Firmware/.
| File | Purpose |
|---|---|
app.py |
The app source (source of truth). |
app.mpy |
Compiled bytecode — this is what gets flashed to the EEPROM. |
prepare_shroomgon.py |
Runs on the badge to write the header + format LittleFS. |
test_shroomgon.py |
Standalone CPython test of the gamma / power-clamp / animation maths. |
KiCad 9 project in Hardware/Shroomgon/, built from the EMF Tildagon hexpansion
template. 7× SK6812MINI-E LEDs, an ST M24C64 EEPROM at 0x50 carrying the hexpansion
header and the app, and an AP3602A regulator. The mushroom outline and silkscreen are
generated by gen_mushroom.py; production/ holds gerbers, drills and the LCSC BOM.
Opening the project needs the Tildagon KiCad libraries, which are not vendored here.
sym-lib-tableexpectstildagon.kicad_symin atildagon-base/directory besideShroomgon/, andfp-lib-tableexpects atildagon.pretty/footprint library inside it. Without them the symbols and footprints will not resolve.
- Auto-launch: on insertion the badge OS mounts the EEPROM, imports the app,
reads
__app_export__, constructs it with theHexpansionConfig, and starts it. The app emitsRequestForegroundPushEvent(from its first background tick, after it's registered with the scheduler) so it pops to the screen automatically. - 5 effects × 5 palettes × 5 brightness levels, gamma-corrected. A matching moon phase is drawn on the screen in the current colour, with the app version under it.
| Effect | LEDs | Moon |
|---|---|---|
Shroomgon |
sweep follows the badge's spin | follows spin |
Auto Moon |
same sweep, on a 20 s clock | cycles on the clock |
Moonlight |
all LEDs breathe together | cycles on the clock |
Ripple |
each LED breathes on its own period, drifting out of phase | clock |
Twinkle |
random sparkle with decay | cycles on the clock |
- Spin-driven moon:
imu.gyro_read()[GYRO_AXIS]gives rotation rate in deg/s. Integrating it over each frame'sdtturns the moon by the same fraction you turn the badge — 360° of badge = one full moon cycle. Default axis is X (tilting the badge over); setGYRO_AXISto 1 or 2 for Y or Z (Z = spinning it flat, about the screen normal). Reading the gyro rather than gravity means it works at any orientation, where the accelerometer went blind whenever gravity lined up with the axis of rotation.GYRO_DEADBAND(3 °/s) stops a resting badge creeping on sensor noise. Because the angle is integrated, it has no absolute reference: the moon stays wherever you left it, and any residual drift accumulates rather than self-correcting.SPIN_MAX_DTignores frame gaps over 0.5 s, so a stalled loop can't teleport the moon.SPIN_STARTis the boot angle (0.25 = half-lit, not dark).- No IMU, or a read that throws, falls back to a slow timed sweep — never a crash.
- Version on screen (
VERSION, small and dim under the moon) and in the Install message, so you can tell at a glance which build a board or an installed copy is running. - LEDs run in
background_task— the light show keeps going even after you exit the screen; only a real unplug/stop (deinit) blanks them. - Power clamp (safety): ~20 mA per fully-lit channel, scaled so worst case stays under 450 mA. RGB full white = 420 mA; RGBW = 560 mA → clamped ~448 mA.
- Never hangs: update/draw/LED writes guarded; degrades to screen-only if no board is found.
Hexpansion buttons run A–F clockwise from 12 o'clock: A=UP, B=RIGHT, C=CONFIRM,
D=DOWN, E=LEFT, F=CANCEL. Each hint is drawn at its own button.
| Button | Action | Hint position |
|---|---|---|
| UP (A) | Install app onto the badge (/apps/Shroomgon) |
12 o'clock (Install) |
| CONFIRM (C) | Next effect | 4 o'clock (next mode name) |
| DOWN (D) | Brightness, 5 levels, cycles round | 6 o'clock (Bri n/5) |
| LEFT (E) | Next colour palette | 8 o'clock (next palette name) |
| CANCEL (F) | Exit to menu (LEDs keep running) | 10 o'clock (EXIT) |
Colours: Moon (cool white-blue), Amber, Spore (green), Rose, and Badge. The
palette tints the on-screen moon too. On RGBW strips (BYTES_PER_PIXEL = 4) only Moon
drives the W channel; the other palettes use RGB with W off.
Badge palette matches the badge's own front-panel LED — the one above your port — so the Shroomgon rides along with the default rainbow cycle (or whatever pattern is set).
- Front LEDs are
tildagonos.leds[1..12], readable viaComposedNeoPixel.__getitem__. Port N sits at LED 2N: the notification component places port N at(2π/12)·(2N−1), which lands on that LED.PORT_LED_OFFSETnudges the mapping if a board disagrees. - The stored value is the pattern colour ×
pattern_brightness(0.1 by default), so a raw read is ~10% of the real colour. We normalise the largest channel back to 255 — taking the LED's hue and applying our own brightness. Downside: at 10% the channels are quantised to ~25 steps, so the hue is approximate. - An unlit LED (all channels 0) has no hue to match, so the last colour is held.
Brightness: BRIGHT_LEVELS are linear multipliers of MAX_BRIGHTNESS (0.33), giving
0.01 / 0.09 / 0.17 / 0.25 / 0.33 → measured 0.5 / 11 / 21 / 31 / 41 mA per strip. Boots
at level 3 (BRIGHT_START). Level 1 is a deliberate near-off whisper — at 0.01 most
channels round to zero, so it reads as a dim tint rather than the palette colour.
- Install (UP button) copies the app off the hexpansion into
/apps/Shroomgon, so it appears in the badge launcher menu and survives without the EEPROM. - Port auto-detect: launched from the menu (no
HexpansionConfigpassed), the app scans I²C ports 1–6 for its own EEPROM (VID0xCA75/ PID0x534D) and builds the config itself — so the LEDs still work from a menu launch while the board is plugged in. No board → graceful screen-only. - Reconnect (no duplicates): a menu launch (no
HexpansionConfig) detects an already-running Shroomgon viascheduler.apps, re-foregrounds that instance, and stops the new copy. This is the on-badge way to relaunch without unplugging — just pick it from the menu. OS launches (config passed) never reconnect, so multiple boards each keep their own instance — see Multiple boards.
Works in any hexpansion port (1–6). No ADC needed — one high-speed pin to LED DIN
and the I²C EEPROM at 0x50. DIN pin comes from config.pin[LED_PIN_INDEX].
Up to 6 Shroomgons at once, any mix of ports. The badge OS launches one app
instance per hexpansion (App(config=HexpansionConfig(port))) and stops that instance
when that port is unplugged — so each board owns exactly one strip and its own
lifecycle. Unplugging one board blanks only its strip; the others keep animating.
- In sync: the timeline comes from
time.ticks_ms(), not a per-instance counter, so all boards run the same phase with no shared state. - One screen: only the first instance to tick pushes itself to the foreground; the rest run LEDs-only. (Best effort — near-simultaneous mounts can stack two moons; press EXIT again.)
- Buttons broadcast: effect, colour and brightness are set on every peer instance
(found via
scheduler.apps), so all boards switch together. The screen showsx<N>when more than one board is running. - Reconnect is menu-only: a launch with a config is a real board and always keeps
its instance; only a menu launch (
config=None) re-foregrounds a running instance and stops itself. This is what keeps board #2 from being mistaken for a duplicate.
Power is clamped per strip (450 mA), which matches the per-port 3.3 V rail. Measured peak is ~129 mA per strip at full brightness (Badge palette on a white front LED), so 6 boards are nowhere near any total.
Flash each board the same way, with PORT in prepare_shroomgon.py set to its slot.
They can share the same VID/PID/unique_id — the OS keys mounts by port.
Unplug the board whose moon is on screen and the OS stops that instance: the other boards keep animating, but the screen returns to the menu. Relaunch Shroomgon from the menu to reconnect to a still-running board.
The M24C64 (8 KB, 512-byte LittleFS blocks) leaves ~6.5 KB for one file; the full app is larger, so it ships as compiled bytecode (~6.2 KB, fits with only ~370 B spare — the empirical ceiling for a single file on this fs is 6568 B, so watch the margin).
.mpy bytecode is version-locked to the badge's MicroPython. This badge is mpy
v6.3; the compiled file tags no v6.1+ features (header 4D 06 00) so it loads on any
v6 runtime. Confirm your badge's version and build a match:
# on the badge REPL: import sys; print(sys.version); print(sys.implementation._mpy)
# _mpy low byte = mpy version (6); low 2 bits of the next byte = sub-version.
pip install mpy-cross # or ==<matching MicroPython version>
python3 -m mpy_cross app.py # -> app.mpy (pure bytecode, no -march)Plug the Shroomgon into a slot, connect over USB. Set PORT at the top of
prepare_shroomgon.py to the physical slot number (1–6). Adjust /dev/ttyACM* to
your device. Everything touching the runtime mount must be one mpremote session
(chained with +).
mpremote connect /dev/ttyACM0 run prepare_shroomgon.py \
+ fs cp app.mpy :/eeprom/app.mpy \
+ fs ls /eeprom \
+ exec "import vfs; vfs.umount('/eeprom')"prepare_shroomgon.py prints Header OK: (only if the checksum validates) plus the
partition block counts. fs ls shows app.mpy. Reformatting each flash keeps a single
file on the fs (no .py/.mpy import ambiguity).
If
could not enter raw repl: the running app holds the REPL.mpremote connect /dev/ttyACM0 repl, press Ctrl-C to>>>, Ctrl-] to quit, then rerun.
mpremote connect /dev/ttyACM0 exec "
from machine import I2C
from system.hexpansion.util import detect_eeprom_addr, read_hexpansion_header
i2c = I2C(2) # <-- your PORT
addr, alen = detect_eeprom_addr(i2c)
print(read_hexpansion_header(i2c, addr, addr_len=alen)) # raises on bad checksum
"Unplug and re-plug the hexpansion. The badge shows a "Shroomgon" notification,
mounts the EEPROM at /hexpansion_<PORT>, and the app pops to the screen with LEDs
animating. mpremote connect /dev/ttyACM0 fs ls /hexpansion_2 confirms the file.
Top-of-file constants in app.py (rebuild app.mpy after changing):
| Constant | Meaning |
|---|---|
LED_PIN_INDEX |
Which HS pin drives DIN. 0 = HS1 (config.pin[0]) … 3 = HS4. |
NUM_LEDS |
Number of SK6812 (default 7). |
BYTES_PER_PIXEL |
3 = RGB SK6812, 4 = RGBW (drives the W channel). |
MAX_BRIGHTNESS |
0..1 soft cap, before the power clamp. Applied after gamma. |
BRIGHT_LEVELS |
Multipliers of MAX_BRIGHTNESS the DOWN button cycles. |
BRIGHT_START |
Index into BRIGHT_LEVELS the app boots at. |
GYRO_AXIS |
Which gyro axis turns the moon. 0=X (tilt), 1=Y, 2=Z (flat spin). |
GYRO_DEADBAND |
°/s below which gyro output counts as noise and is ignored. |
GYRO_SCALE |
Moon turns per badge turn. Negate to flip the spin direction. |
SPIN_START |
Boot angle in turns. 0.25 = half-lit. |
SPIN_MAX_DT |
Seconds; frame gaps longer than this are ignored, not integrated. |
AUTO_RATE |
Turns/sec for the non-spin effects' moon. 0.05 = 20 s per cycle. |
VERSION |
Shown under the moon and in the Install message. Bump it when you reflash. |
PALETTES |
(name, (r, g, b)) colours the LEFT button cycles. Index 0 is the moon; a None colour means "live", read from the front-panel LED. |
PORT_LED_OFFSET |
Nudges the port→front-LED mapping (port N → LED 2N by default). |
POWER_BUDGET_MA / MA_PER_CHANNEL |
Power-clamp budget / per-channel estimate. |
VID / PID |
Must match prepare_shroomgon.py (used by port auto-detect). |
This board routes DIN to HS1 = pin 12 →
LED_PIN_INDEX = 0. If a revision moves DIN to another high-speed pin, update the index.
Tuning brightness: measured peak at level 5/5 is ~71 mA on the fixed palettes, and
~129 mA on Badge if the front LED goes full white (vs the 450 mA budget). Ripple is
the hungriest effect, since its LEDs can drift into alignment near full. Huge headroom
either way: raise MAX_BRIGHTNESS freely if the top level still looks dim; the clamp
keeps enforcing the ceiling.
Simplest: re-run the flashing procedure (it reformats — fine for a single-file app).
To overwrite without reformatting, mount the existing partition and fs cp in one
session (see git history / earlier notes).
python3 test_shroomgon.pyStubs the badge-only modules, imports the real app.py, and checks gamma is
monotonic/bounded, every effect frame stays under the 450 mA budget, full white (RGB
and RGBW) is clamped, and the brightness cap holds.
Split, because the hardware and the firmware have different ancestry:
- Firmware and docs — MIT, see LICENSE.
- Hardware (
Hardware/Shroomgon/) — CERN-OHL-P v2, seeHardware/Shroomgon/LICENSE.txt. It derives from the EMF Tildagon hexpansion template, which is CERN-OHL-P; the modifications are recorded inHardware/Shroomgon/NOTICE.mdper subsection 3.3(b).