Skip to content

plan 0006: NeoPixel strip subsystem — pace pip, RPM scale, status flashers, purple sectors - #142

Open
TheAngryRaven wants to merge 8 commits into
BETAfrom
claude/neopixel-strip-control-3z39f4
Open

plan 0006: NeoPixel strip subsystem — pace pip, RPM scale, status flashers, purple sectors#142
TheAngryRaven wants to merge 8 commits into
BETAfrom
claude/neopixel-strip-control-3z39f4

Conversation

@TheAngryRaven

@TheAngryRaven TheAngryRaven commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Summary

New NeoPixel strip subsystem (plan 0006, design record in docs/plans/0006-neopixel-strip.md): 11 WS2812 pixels on the NFC pads converted to GPIO — pixels 0/10 are status alert LEDs, pixels 1–9 the strip with a centerline. Beta-channel only via BIRDSEYE_ENABLE_NEOPIXEL (SensorEgg precedent); master/release compile it out entirely.

  • Pins: 30 (P0.09/NFC1) = 5 V boost converter EN (rail truly off in sleep — EN driven LOW is retained through System OFF), 31 (P0.10/NFC2) = data. Both single-point #defines in neopixel.h — swap if wired the other way.
  • Orientation: the chain is wired data-in on the physical RIGHT (chain px 0 = rightmost LED), so the whole chain — status LEDs included — is mirrored. Renderers author in logical left-to-right space; led_frame::physicalIndex() (kChainReversed = true) maps logical→wire once, at push time.
  • NFC→GPIO: one-time runtime UICR->NFCPINS write in NEOPIXEL_SETUP() + a single self-reset, instead of -DCONFIG_NFCT_PINS_AS_GPIOS (that define lives in the core's system_nrf52840.c, which compiler.cpp.extra_flags can't reach, and would miss IDE builds). Runs before the SoftDevice comes up and before the WDT arms. One-way, as discussed — first beta boot on existing units will reset once.
  • Global brightness cap (the core requirement): led_frame::applyCap() is the single choke point, applied once per frame at push time; host tests sweep caps × saturated frames asserting no channel ever exceeds it. New led_brightness setting (default 64/255; 0 disables the LEDs and never enables the boost).
  • Strip modes: off outside a race session; RPM scale (green filling left→right, red past halfway, ceiling = new rev_limit setting, default 15000) until pace is valid, then the pace pip — getPaceDifference() ms/m, full deflection ±1.0 ms/m, slower = left of center in red, faster = right in green, deadband = dim-white centerline. ScaleSpec is generic so temp scales reuse it in phase 2.
  • Status LED actions: data-driven StatusAction PODs (source/threshold/hysteresis/color/flash) — the phase-2 assignability hook. Defaults: logical-left = rev-limit flasher (red, ≥ rev_limit, 100 ms), logical-right = EGT flasher (orange, ≥ 650 °C, 250 ms; NaN/stale → off AND latch released, isNanF-guarded).
  • Animations: 2.6 s boot (hue comet circling the ring, fade-out, seeded sparkles) and a 1.6 s purple celebration — both pure functions of (t, seed), golden-testable.
  • Purple sector detection (sector_purple unit): the library updates best sectors at the start/finish crossing, not at sector lines, so the monitor snapshots bests at sector open and derives S3 from lastLapTime − s1 − s2 at the lap edge — immune to the lap-line race (explicitly tested). No purple on lap 1. Three new sprint-first activeTimer* sector wrappers feed it.
  • Wiring: NEOPIXEL_LOOP() (30 Hz self-throttled) after CAMERA_LOOP() and inside both parked branches (blanks instead of freezing); NEOPIXEL_SLEEP() in enterShutdown() (blank → data LOW → EN LOW, strip dark while charging too); NEOPIXEL_WAKE() in the charging soft-resume.
  • Radio/timing: Adafruit_NeoPixel's nRF52 show() uses a free PWM instance via EasyDMA with interrupts ON (~0.4 ms/frame); this sketch uses no tone/analogWrite, so PWM0–2 are free and TIMER3/tach ISRs are unaffected.

Type of change

  • Bug fix (no user-visible behavior change beyond the fix)
  • New feature / behavior
  • Refactor (no behavior change)
  • Tests only
  • CI / tooling / docs
  • Breaking change (track files, log format, BLE protocol, or a removed mode)

How it was verified

  • Host unit tests pass (ctest --test-dir tests/build) — 433 cases / 5249 assertions, including 4 new suites (cap invariant + chain mirror, pip/scale/status tables, animation determinism + lifecycle, purple detection incl. the S3 lap-line race)
  • clang-tidy clean — new units added to the workflow list; verified locally and in CI
  • Compiles for both XIAO nRF52840 variants — CI builds flag-ON against DovesLapTimer BETA (also the compile-check of the sector accessor names)
  • Tested on real hardware — not possible from this environment; open hardware items: the one-time UICR write + self-reset, boost EN polarity (assumed HIGH=on) and 5 ms settle, GRB color order, chain orientation (kChainReversed, set for data-in-right wiring), real current at cap 64, sleep current with EN low
  • Sim build green: 60 s boot soak, all golden page hashes, both lap oracles pass with the module stubbed out (BLE-module precedent)

Checklist

  • CHANGELOG.md updated under [Unreleased] (bumped slated version to 4.1.0)
  • ARCHITECTURE.md / CLAUDE.md updated (File Map, pin map, subsystem 16, key constants, settings, libraries, flags) + CONTRIBUTING.md flag table
  • New testable logic has a matching test in tests/
  • Branch is focused — one subsystem, commits split design/units/flag/module/CI/docs

Related issues

None — user-requested feature (phase 2, settings-driven mode/action assignment, deliberately deferred; the POD tables are the interface it fills in).

🤖 Generated with Claude Code

https://claude.ai/code/session_011PaL2sQSbU3KCPF4FaZsPP

claude added 6 commits August 13, 2026 06:23
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PaL2sQSbU3KCPF4FaZsPP
…ction

led_frame owns the pixel layout and the single global-brightness choke
point (applyCap; post-condition tested channel<=cap). led_modes owns the
pace pip (ms/m, slower=left/red), the generic ScaleSpec fill (RPM red
past halfway), and the StatusAction hysteresis/flash table — the phase-2
assignability hook. led_animations renders boot + purple as pure
functions of (t, seed). sector_purple detects session-best sectors with
open-time best snapshots and a derived S3 so the library's lap-line
updateBestSectors() can't race the comparison.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PaL2sQSbU3KCPF4FaZsPP
BIRDSEYE_ENABLE_NEOPIXEL (default 0, beta-on) guards the whole
subsystem — a flag-off build never writes UICR or touches the NFC pads.
led_brightness (0-255, 0 = disabled) and rev_limit (true RPM,
1000-20000) join the settings table and the boot read block. Three
sprint-first activeTimer* sector accessors feed the purple monitor;
WaypointLapTimer sessions report no sectors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PaL2sQSbU3KCPF4FaZsPP
…p hooks

neopixel.{h,ino}: one-time UICR NFC->GPIO write (before SoftDevice/WDT,
single self-reset), boost EN + strip bring-up, 30 Hz frame loop
(priority: boot anim > purple anim > parked/menu off > race pace-or-RPM
strip + status flashers), applyCap as the single brightness choke
point. Wired into setup() (pre-CAMERA_SETUP), the main frame after
CAMERA_LOOP, both parked branches (blank, not freeze), enterShutdown
(blank -> data LOW -> boost EN LOW, retained through System OFF) and
the charging soft-resume. Sim: module excluded from the TU, surface
stubbed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PaL2sQSbU3KCPF4FaZsPP
All three build workflows install Adafruit NeoPixel; beta.yml and
BETA-targeted compile-sketch runs pass -DBIRDSEYE_ENABLE_NEOPIXEL=1.
Master/release keep the flag off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PaL2sQSbU3KCPF4FaZsPP
…flag table, CHANGELOG

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PaL2sQSbU3KCPF4FaZsPP
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

Coverage — host-testable units

📂 Overall coverage

Metric Coverage
Lines 🟢 1445/1468 (98.4%)
Functions 🟢 151/151 (100.0%)
Branches 🟢 1084/1204 (90.0%)

📄 File coverage

File Lines Functions Branches
BirdsEye/camera_fsm.cpp 🟢 238/246 (96.7%) 🟢 20/20 (100.0%) 🟡 142/160 (88.8%)
BirdsEye/course_creator.cpp 🟢 209/217 (96.3%) 🟢 21/21 (100.0%) 🟡 114/131 (87.0%)
BirdsEye/course_prune.cpp 🟢 37/37 (100.0%) 🟢 5/5 (100.0%) 🟢 47/50 (94.0%)
BirdsEye/crc32.cpp 🟢 30/30 (100.0%) 🟢 4/4 (100.0%) 🟢 24/24 (100.0%)
BirdsEye/crossing_pattern.cpp 🟢 15/15 (100.0%) 🟢 1/1 (100.0%) 🟢 12/12 (100.0%)
BirdsEye/dovex_header.cpp 🟢 106/107 (99.1%) 🟢 7/7 (100.0%) 🔴 62/88 (70.5%)
BirdsEye/filename_validator.cpp 🟢 14/14 (100.0%) 🟢 1/1 (100.0%) 🟢 30/30 (100.0%)
BirdsEye/gps_stats.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 8/8 (100.0%)
BirdsEye/gps_status_page.cpp 🟢 29/29 (100.0%) 🟢 4/4 (100.0%) 🟢 28/28 (100.0%)
BirdsEye/gps_time.cpp 🟢 45/45 (100.0%) 🟢 6/6 (100.0%) 🟢 30/32 (93.8%)
BirdsEye/gps_validation.cpp 🟢 24/24 (100.0%) 🟢 2/2 (100.0%) 🟢 66/66 (100.0%)
BirdsEye/haversine.cpp 🟢 8/8 (100.0%) 🟢 1/1 (100.0%) ⚫ 0/0 (0.0%)
BirdsEye/idle_policy.cpp 🟢 17/17 (100.0%) 🟢 2/2 (100.0%) 🟢 14/14 (100.0%)
BirdsEye/insta360_protocol.cpp 🟢 140/140 (100.0%) 🟢 16/16 (100.0%) 🟡 86/98 (87.8%)
BirdsEye/lap_format.cpp 🟢 18/18 (100.0%) 🟢 1/1 (100.0%) 🟢 9/9 (100.0%)
BirdsEye/led_animations.cpp 🟢 76/76 (100.0%) 🟢 5/5 (100.0%) 🟢 43/46 (93.5%)
BirdsEye/led_frame.cpp 🟢 21/21 (100.0%) 🟢 7/7 (100.0%) 🟢 6/6 (100.0%)
BirdsEye/led_modes.cpp 🟢 49/50 (98.0%) 🟢 4/4 (100.0%) 🟢 44/46 (95.7%)
BirdsEye/sat_bars.cpp 🟢 33/33 (100.0%) 🟢 2/2 (100.0%) 🟢 51/54 (94.4%)
BirdsEye/sd_access_policy.cpp 🟢 9/9 (100.0%) 🟢 3/3 (100.0%) 🟢 18/18 (100.0%)
BirdsEye/sd_format_page.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 25/26 (96.2%)
BirdsEye/sector_purple.cpp 🟢 51/51 (100.0%) 🟢 2/2 (100.0%) 🟡 42/50 (84.0%)
BirdsEye/sensoregg_protocol.cpp 🟢 44/45 (97.8%) 🟢 7/7 (100.0%) 🟢 33/34 (97.1%)
BirdsEye/sprint_select.cpp 🟢 25/25 (100.0%) 🟢 4/4 (100.0%) 🟢 46/48 (95.8%)
BirdsEye/tach_filter.cpp 🟢 27/27 (100.0%) 🟢 6/6 (100.0%) 🟢 17/18 (94.4%)
BirdsEye/track_json.cpp 🟢 116/120 (96.7%) 🟢 12/12 (100.0%) 🟡 67/88 (76.1%)
BirdsEye/wake_cause.cpp 🟢 14/14 (100.0%) 🟢 2/2 (100.0%) 🟢 20/20 (100.0%)

claude added 2 commits August 13, 2026 06:44
…pe include order

const-qualify the locals clang-tidy flagged (misc-const-correctness),
replace the (int)(x + 0.5f) casts with lroundf
(bugprone-incorrect-roundings), and pull led_frame.h into neopixel.h:
Arduino's generated prototype for npxPushFrame(led_frame::Frame&) lands
before neopixel.ino's own includes, so the type must be visible from
BirdsEye.ino's include block — the exact include-order trap documented
in CLAUDE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PaL2sQSbU3KCPF4FaZsPP
Chain pixel 0 is the RIGHTMOST LED on this build, which mirrors the
entire chain (status LEDs included), not just the strip — so the
strip-only kStripReversed flip is replaced by kChainReversed +
led_frame::physicalIndex(): renderers stay in logical left-to-right
space and the push path maps logical->wire once. Involution-tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PaL2sQSbU3KCPF4FaZsPP
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants