f_lib/neopixel_v2.py through the LightStrip
subclass in leds.py, wired up in peripherals.py, with the animation library split
between peripherals.py and animations.py.
The ESP32 emulator models this: the
60-pixel ring, the 7-pixel crystal, the palette below, and the animation each event plays.
Everything on this page was read out of the v5.0.3 frozen bytecode (
re/v5.0.3/mpy/*.dis).
Claims are graded confirmed (read in the disassembly), inferred, or called out as
unverifiable. leds.py, animations.py and f_lib/neopixel_v2.py are byte-identical
between v5.0.2 and v5.0.3; peripherals.py differs only in the battery lookup table.
No LED behavior changed in this release.Driver
APA106 is dead code
The page previously claimed the LEDs are APA106 parts driven byapa106.py with
ORDER = (0, 1, 2, 3). That is wrong for v5.0.3:
apa106.pyimportsNeoPixelfrom the genericneopixel.pyand definesclass APA106(NeoPixel): ORDER = (0, 1, 2, 3)— that is its entire body (apa106.dis, 49 lines).- Nothing imports
apa106, and nothing imports the genericneopixelexceptapa106itself (confirmed — the onlyIMPORT_NAME neopixelin all 94 modules isapa106.dis:23, and no module namesapa106).
f_lib/neopixel_v2.py, whose order is GRB, so ORDER = (0, 1, 2, 3) describes
nothing the hardware ever sees. The physical part number is not recoverable from the
bytecode — only that it is a single-wire chain clocked at WS2812 800 kHz timings.
Geometry and pins
All confirmed:
_ring_leds = const(18) / _crystal_leds = const(21)
(peripherals.dis:173-181), and DeviceLeds.__init__ builds
LightStrip(Pin(21), 7, 'rgb') and LightStrip(Pin(18), 60, 'rgb', pwr_gate_pin=19)
(peripherals.dis:268-295). A single module-level instance leds = DeviceLeds() is the
device-wide handle (peripherals.dis:187-189), so every module refers to leds.ring and
leds.crystal.
Two details worth knowing:
- The pixel counts are hardcoded at the call site.
project_data.pydefinesRING_PX_COUNT = 60andCRYSTAL_PX_COUNT = 7(project_data.dis:521-524), butDeviceLeds.__init__passes the literals60and7instead of importing them. The constants are used elsewhere; the strips do not depend on them. - The
mode='rgb'argument is accepted and discarded.LightStrip.__init__never stores it and callssuper().__init__(pin, n)with two arguments (leds.dis:239-274). Changing it would not change the wire order. - Only the ring is power-gated.
off(pwr_off=True)drives GPIO 19 low and clearsis_powered;power_on(is_force=False)drives it high (leds.dis:677-730,:980-1003). The crystal has no gate, so it can only be blanked, not de-powered.
LightStrip primitives
Every animation in the firmware is built from these seven coroutines plus fill/off
(confirmed, leds.dis:152-238 for the signatures, bodies at the cited lines):
off() swallows every exception and logs LED err with is_write=False
(leds.dis:706-720), so a failing strip never takes down the caller.
fade_step is a parameter of twinkle, not a function — the earlier claim that it was
an effect primitive was wrong.
Colors
TheColors class (project_data.py) defines 13 named colors (confirmed,
project_data.dis:1881-1944). The same 13-tuple, in this order, is the palette that
integer color IDs index into (project_data.dis:1890):
All 13 name→RGB bindings are confirmed: the class binds each with an explicit
STORE_ATTR. There is no named OFF color — the clear state is the literal
(0, 0, 0).
Two helpers do all the color math (confirmed):
dim_leds(rgb, brt)→(int(r*brt), int(g*brt), int(b*brt))(project_data.dis:2151-2178).colors.rgb(color, brt=None)(project_data.dis:2097-2149) —brtdefaults toconfig.led_brt, not toGLOBAL_BRT.colormay be a tuple (used directly) or an integer palette index. Any exception logsInvalid color providedand returns(0, 0, 0), so a bad color id silently blacks out a strip rather than raising.GLOBAL_BRT = 0.6(project_data.dis:515-516) is the compile-time default thatconfig.led_brtalso starts at (project_data.dis:1353-1355).
Peer and bond colors
Three different color sources exist; they are easy to confuse.Colors.next_peer_clr has a confirmed off-by-one: the guard is
if len(config.peers) > len(tuple) (i.e. > 8), so a peer count of exactly 8 falls
through to tuple[8] and raises IndexError; the magenta fallback is only reachable at 9
or more. config.max_bonds = 8 and config.max_peers = 9
(project_data.dis:964-969), so the boundary is reachable in principle. Whether any
caller hits it in practice is not determined here.
Peer pixels on the crystal
PEER_CRYSTAL_MAP = (1, 2, 4, 6) was previously documented as “a 4-tuple of color-list
indices (→ orange, yellow, green, teal)”. That is wrong. They are the four crystal
pixel slots handed out to bonded peers:
peer_helpers.py(get_next_peer_pixel/update_peer_pixels) allocates one slot per bonded peer from that tuple.compassing.collect_all_peersthen doesleds.crystal[peer.pixel] = colors.rgb(peer.rgb, brt=led_brt)(compassing.dis:1273-1310).
Animation set
Most of the names previously listed as “animations” are not functions. Three categories (confirmed):asyncio.Event() objects on modes (project_data.dis:1018-1037) —
anim_add_peer, anim_bonding_ctdwn, anim_boot_batt_pct, anim_peer_del_ctdwn,
anim_gnss_search. These are signals, not effects; something else waits on or sets them.
Task names passed to tasks.launch(coro, 'name'), which is how the code stops an
effect later:
Real coroutines.
peripherals.py (peripherals.dis:253):
animations.py (animations.dis:89) holds five more:
shuffle_countdown has an off-by-one
shuffle_countdown builds four colors — hot_pink, yellow, blue, green — but its
inner change_clr generator wraps with if i >= len(clrs) - 1: i = 0
(animations.dis:229-238). The index resets one iteration early, so index 3 is never
yielded: green is constructed and never displayed. The effective cycle is
hot_pink → yellow → blue (confirmed).
Boot battery gauge (powerup_animation)
Confirmed, peripherals.dis:830-1001:
-
Clear
modes.evt_led_override_off(the “an animation owns the LEDs” flag). -
voltsdefaults to4.0;pct = get_batt_pct(max_volts=config.batt_max_volts, cur_volts=volts). -
n_lit = int(60 * pct / 100). -
Colors — body / leading tip:
- Sweep pixel by pixel, writing every frame and sleeping 1 ms on even indices.
-
Hold 2000 ms,
ring.off(), then setmodes.anim_boot_batt_pctandmodes.evt_led_override_off.
powerup_animation is the anim_boot_batt_pct implementation — the event is what it
raises on completion, not a separate effect.
SOS blink
crystal_sos_blink (peripherals.dis:704-829) is confirmed to be real Morse code,
not a plain repeating flash:
f_lib.generators.sos_timing
(f_lib_generators.dis:100-117), once nested inside crystal_sos_blink itself
(peripherals.dis:812-829), and a third identical copy is the module-level generator
leds.toggle_sos (leds.dis:1325-1342) — despite the name, it yields 300, 900, 300
and toggles nothing.
Each flash also blocks on two events: evt_brt_adjusted (so a brightness change
pauses the blink) and evt_ble_cntg_done. Mute/unmute is handled by the caller (logs
Muting SOS / Un-muting SOS in compass.py); a running blink does not re-check
is_sos_mute.
Bond celebration (peer_bonded)
Confirmed, peripherals.dis:1067-1373 — a four-act sequence:
- Blank both strips, 50 ms pause.
- Ring and crystal fade up together to
brtover 6 steps × 50 ms (two tasks, both awaited). - Hold 1000 ms, then run two tasks at once:
collapse_px(start_px=0)on the ring and a crystal fade-out over 5 steps × 80 ms — onpixels=(0, 1, 2, 3, 4, 6), deliberately skipping crystal pixel 5. - Pixel 5 and ring pixel 30 (the point opposite the seam) then cross-fade in 5 steps of
0.2 at 100 ms: the ring dims 1.0 → 0.0 while the crystal brightens 0.0 → 1.0, so the
peer color visibly moves from the halo into the crystal. Ends with
ring.off().
Breathing generators
The page previously listedbreathe_effect, breathe_gen and breathe_v2 as three
primitives. They are two duplicate generators plus one strip method (confirmed):
f_lib.generators.breathe_effect(f_lib_generators.dis:55-99) andcompassing.breathe_gen(compassing.dis:1192-1234) are the same generator written twice: start at 1.0, step ±0.1, bounce off 0.8 at the top and 0.2 at the bottom, yielding a value rounded to 2 dp. They produce brightness levels; they touch no pixels.LightStrip.breathe_v2is the method that actually drives a strip (see the primitives table).
twinkle_crystal is a Compass method, not an animation-module function
(compass.dis:4635-4828): an eco-mode-only crystal shimmer that wakes every 400 ms,
waits on evt_orien_vertical, evt_brt_adjusted and evt_battery_charged, skips while
BLE is connected (ble_conn.status_id == 2) or a peer is in SOS, then fills the crystal
at 0.5 and drops randrange(3, 6) pixels to either black or 0.05. The whole effect is
scaled by 1.0 when config.led_brt >= 0.5 and by 0.2 below that.
Status colors
Identifying the firmware version by the power-up Touch Crystal color (e.g. “5.0 →
Sparkly Pink”) comes from the official update guide, not from firmware evidence. No
version→color table exists in the binary, and no
sparkly or pink string exists
(only hot_pink is a defined color), so this mapping cannot be verified here.__version__:
In v5.0.3,
compass.start_peer_management gained a tail that sleep_ms(300)s and then
calls crystal_default() when modes.evt_orien_vertical is set (confirmed —
compass.dis:7795-7813; the v5.0.2 build of the same function ends without it).
OTA colors
OTA/update state→color mappings are described in the WiFi OTA guide.ota_callback.py defines three constants at import time
(confirmed, ota_callback.dis:250-267):
The purple-to-blue gradient
((200, 0, 200), (100, 0, 200), (0, 0, 200)) shows up twice
(confirmed):
ota_callbacktwinkles it on the crystal during a WiFi OTA:crystal.twinkle(colors=[…], per_ms=80, fade_step=1, bright=0.3, single=False, min_brt=10)(ota_callback.dis:545-560).ota_ble.COLOR_LIST(ota_ble.dis:286-287) is the same three colors as a tuple, used to paint each of the 7 crystal pixels withcolors.rgb(COLOR_LIST[randrange(3)], 0.6)during a BLE OTA (ota_ble.dis:1352-1385). This is a separate constant from the main palette.
Power interaction
Brightness is a single persisted value,config.led_brt, which defaults to 0.6
(project_data.dis:1353-1355) and is what colors.rgb() uses when no brt is passed.
update_brightness(brt) writes it and refreshes compassing.default_rgb
(confirmed, compass.dis:10302-10319).
Eco mode blanks the ring. Compass.change_power_mode(power_mode)
(confirmed, compass.dis:8446-8639):
Either way it also clears
evt_brt_adjusted, re-runs init_touch(False), stops the
sound_react task (Vibe Mode does not survive a brightness change), launches
toggle_brightness_timeout, repaints the crystal from config.color_id, and calls
update_brightness(brt=…).
Compass.toggle_brightness (confirmed, compass.dis:8386-8445) is the single-tap
handler on the power button (sw_power.cb_single_tap; see
physical buttons). It:
- returns immediately if
modes.is_power_downor ifmodes.evt_battery_chargedis not set — so the tap does nothing on a device that has not reported a charged battery; - XOR-toggles the current mode (
cur = 2 if is_eco_mode else 0;nxt = cur ^ 2) and delegates tochange_power_mode(power_mode=…).
toggle_brightness_timeout(timeout_ms=2500, original_val=…) polls every 300 ms against
self.last_brt_adjusted and, once the window lapses, blanks both strips, sets
evt_brt_adjusted, and restores either crystal_default() (if vertical) or touch plus
the sound_react task. Logs Toggle Brightness Timeout Started: {} /
Completed: {}.
The log Revert to full brightness does not belong to toggle_brightness. It is
emitted by compassing.crystal_brightness() (confirmed, compassing.dis:1235-1261),
which returns 0.2 until modes.evt_touch_active fires and 1 thereafter — a
touch-gated crystal dimmer, latched by modes.is_touch_ready_blink.
modes.evt_led_override_off is the mutual-exclusion flag: powerup_animation and
blink_all_leds clear it on entry and set it on exit (peripherals.dis:835-839,
:993-998, :1478-1482, :1578-1582), marking the window in which an animation owns
both strips.
A general lightsleep mechanism exists (dev_total_lightsleep_ms, log Awoke from lightsleep …), but no LED-task-specific sleep gating was found — LED suspension
during lightsleep remains an inference. Separately, the binary’s low-battery mode is
evidenced for touch sensitivity, not LED brightness: _low_batt_sensitivity,
_LOW_BATT_FLOOR, _LOW_BATT_MULTIPLIER and the log
[touch] Low battery mode ON | base_sens: {:.3f} all tie to touch, with nothing linking
low-battery state to LED brightness.
Vibe Mode writes the raw buffer
Vibe Mode (new_vibe.py, task 'sound_react') caches leds.ring, leds.ring.buf and
leds.ring.write as locals and writes bytes straight into the pixel buffer,
bypassing __setitem__ (confirmed, new_vibe.dis:449-457). It therefore has to
handle the GRB byte order itself. Its v5.0.3 tuning (newvibe-1.10) lowers the
brightness base from 0.1 to 0.07 — about 30 % dimmer — which is confirmed by
comparing the module constant tables across versions (new_vibe.dis:70 vs the v5.0.2
build). See Vibe Mode.
Not LED symbols
_spin_degis defined only inimu_fusion_auto.py(confirmed — it appears in no other module). It is fusion-engine state, not an LED symbol. The compass heading is rendered as a lit point that rotates toward the target, but the rendering code was not located in this pass and the mapping from_spin_degto a pixel index is not recovered.apa106.pyandneopixel.py— dead code, see above.