Skip to content

Latest commit

 

History

13 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Shroomgon

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.

Files

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.

Hardware

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-table expects tildagon.kicad_sym in a tildagon-base/ directory beside Shroomgon/, and fp-lib-table expects a tildagon.pretty/ footprint library inside it. Without them the symbols and footprints will not resolve.

What it does

  • Auto-launch: on insertion the badge OS mounts the EEPROM, imports the app, reads __app_export__, constructs it with the HexpansionConfig, and starts it. The app emits RequestForegroundPushEvent (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's dt turns 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); set GYRO_AXIS to 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_DT ignores frame gaps over 0.5 s, so a stalled loop can't teleport the moon.
    • SPIN_START is 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.

Controls (on-screen hints around the moon)

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 via ComposedNeoPixel.__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_OFFSET nudges 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 / relaunch / reconnect

  • 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 HexpansionConfig passed), the app scans I²C ports 1–6 for its own EEPROM (VID 0xCA75 / PID 0x534D) 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 via scheduler.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.

Which ports

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].

Multiple boards

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 shows x<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.

Building app.mpy

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)

Flashing procedure (mpremote)

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.

Verify the header independently (re-read + checksum)

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
"

Confirm auto-launch (hot re-plug)

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.

Configuring for board revisions

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 12LED_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.

Updating on an already-provisioned EEPROM

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).

Testing the maths without hardware

python3 test_shroomgon.py

Stubs 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.

License

Split, because the hardware and the firmware have different ancestry:

About

Moon-themed Tildagon hexpansion app for the EMF badge (ESP32-S3) - 7x SK6812 LEDs, gyro-driven moon phase, auto-runs from an I2C EEPROM.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages