Skip to content

Release 4.1.0 — LED strip subsystem, BLE download rework, RPM filter, menu escapes - #150

Open
TheAngryRaven wants to merge 33 commits into
masterfrom
BETA
Open

Release 4.1.0 — LED strip subsystem, BLE download rework, RPM filter, menu escapes#150
TheAngryRaven wants to merge 33 commits into
masterfrom
BETA

Conversation

@TheAngryRaven

@TheAngryRaven TheAngryRaven commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Summary

Promotes BETA to master for the 4.1.0 release: 28 commits, 7 merged PRs (#141#147), 5 design records (plans 0006–0010), ~5,800 insertions across 63 files — plus the release cut (#149) and the review fixes (#151), which stack into BETA ahead of this.

Merge order: #149#151 → this PR. Merging this ahead of them would promote a tree still stamped 4.0.0 with the LED strip still beta-gated.

What's in it

Plan PR What
0006 #142 NeoPixel strip subsystem — 11 px on the NFC pads, pace pip / RPM scale, status flashers, purple sectors, boot animation
0007 #143 LED rev/overrev + temp alerts, GPS-search pip, engine-stopped gate
0008 #144 BLE download throughput — DLE readback + retry, iOS-legal conn-interval re-ask, 4 KB SD read-ahead, live KB/s on the transfer page
0009 #146 RPM spikes out of the logged trace — Kalman outlier gate, RPM-aware measurement noise, per-second process noise, tach_filter A/B setting
0010 #147 Local timezone + LED day/night brightness, settings buffers 512 → 1024 B
#145 Back/Cancel rows on the four menus that had no way out
#141 Transfer-mode exit fixes — USB block-I/O drain before reset, reboot on manual BLE exit; wasm sim harness refresh
#149 Release cut, and the LED strip ships in every build (see below)
#151 Six defects found reviewing this diff, incl. one that shipped broken

New host-tested pure units: ble_stream, led_frame, led_modes, led_animations, sector_purple, local_time, setting_parse — plus a heavily reworked tach_filter. Eight new test files.

⚠️ This release permanently converts every device's NFC pads

BIRDSEYE_ENABLE_NEOPIXEL now defaults to 1, so the LED strip is a core feature rather than a beta build — a stock logger drives a strip the moment someone wires one. The subsystem's first boot programs UICR->NFCPINS to convert the two NFC pads to GPIO and self-resets once. That write is one-way: undoing it needs a full chip erase and a bootloader reflash over USB, and it happens on every device that installs 4.1.0, LEDs attached or not, because the firmware cannot tell.

Deliberate call — this hardware never uses NFC, the pads are otherwise idle, and gating the headline feature behind a separate build had kept it out of users' hands. The CHANGELOG leads its 4.1.0 section with an upgrade note stating it, including the extra reboot to expect. Anyone with a future use for those pads should not install 4.1.0.

The SensorEgg EGT POC stays beta-only and gained nothing this release, so everything else here is live for every user.

DovesLapTimer pin: no bump needed

compile-sketch.yml's channel comment warns that promoting BETA means bumping the library pin deliberately. Checked rather than assumed: git diff v4.3.0 origin/BETA in TheAngryRaven/DovesLapTimer touches nothing under src/ — five CI workflow files and that is all. getCurrentSector(), getCurrentLapSector1Time() and getBestSector1Time() all exist in v4.3.0 across DovesLapTimer.h, SprintTimer.h and WaypointLapTimer.h, so the new activeTimer*Sector*() wrappers compile against the release pin. All three workflows are correct as they stand.

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)

Backwards compatible where the CHANGELOG's definition applies: the DOVEX layout, log filenames, track JSON and the BLE command protocol are byte-for-byte what 4.0.0 wrote, so existing logs, cards and companion apps keep working. The UICR conversion is irreversible per device but is none of those things — hence an upgrade note rather than a MAJOR bump.

How it was verified

CI on this PR does not build the release configuration. compile-sketch.yml selects its channel from head_ref == 'BETA' as well as base_ref — deliberately, so a BETA-sourced PR compiles against BETA's library and flags. The consequence is that this PR's compile job builds with SENSOREGG=1 and the library's BETA branch, not the shipped configuration.

The safety net is the push to master after merge: compile-sketch.yml runs on push: branches: [master], where ref_name == 'master' resolves to LAPTIMER_REF=v4.3.0 and an empty FEATURE_FLAGS — the true release config. Wait for that run to go green before tagging v4.1.0, and read its OTA image-size line: the both-flags-on beta build sits at 86.5% of the 417,792 B cap (361,524 B), and NeoPixel-on/SensorEgg-off will land just under that — inside the 90% warn line, but this is the binding constraint now, not the 90% flash budget (which is only at 44.6%).

Checklist

Related issues

Deliberately excludes #148 (plan 0011, drag mode), which targets BETA and is slated for the next beta rather than this release.

Release steps after merge

  1. Merge chore: cut v4.1.0 — version stamp, CHANGELOG cut, and ship the LED strip in every build #149, then fix: release-review findings for 4.1.0 — BLE settings list, LED rail, strict setting parse #151, into BETA; then this PR into master.
  2. Wait for compile-sketch on the master push — the only run that compiles the real release configuration — and check its OTA size line.
  3. Tag v4.1.0; release.yml builds both variants, publishes the Release and pushes the OTA manifest to gh-pages.

claude and others added 29 commits August 11, 2026 23:10
…n manual BLE exit

Two 4.0.0 transfer-mode exit bugs, slated for 4.0.1:

USB: USB_MSC_DISABLE() raced the USBD task. setUnitReady(false) only
refuses NEW SCSI commands, and the exit quiesce tracked write-callback
ENTRY times only — so an in-flight READ10/WRITE10 (reads were never
tracked at all, and a writeSectors() can stall 100 ms–2 s on SD garbage
collection) was still driving SdFat on the USBD task while the main loop
ran syncDevice() on the same SPI bus. The wedge came back via the ~4 s
watchdog instead of the clean reset. Now the exit detaches USB first (so
host traffic actually stops), tracks in-flight state + completion time
around all three block callbacks, and drains WDT-fed for up to 4 s
before syncing and resetting.

BLE: only a phone disconnect triggered the transfer auto-reboot; the
on-device Exit button just BLE_STOP()'d back to the menu, so settings
written over BLE silently didn't apply until the next power cycle. New
bleExitTransferMode() (BLE_STOP + the same 100 ms-delay reset) makes
both ways out of transfer mode reboot, matching USB. The SIM stub
returns after stopping so the golden menu walk still exits the
Bluetooth page.

Both fixes are TinyUSB/Bluefruit-bound (no host-testable pure logic);
goldens, boot soak, and lap oracles verified unchanged.

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

The browser test harness (wasm/test.html) had fallen behind the firmware:

- dovex playback filtered rows to exactly 13 columns, so 4.0.0 logs
  (16 columns after Temp1/Junction1/Temp2) injected nothing. Accept >=13
  and read the stable first 13.
- Nothing could satisfy the course creator's fix + time-lock entry gate
  interactively. New "GPS fix" toggle + mph field: a deterministic
  synthetic 25 Hz fix parked on the bundled OKC track's start line,
  injected one PVT per <=40 ms step slice (a burst before one big step
  collapses into a single fix and starves the creator's >=8-fix
  averaging hold). Loading a dovex unchecks the toggle — playback owns
  the GPS feed.

Verified against a fresh emsdk 3.1.61 wasm build (DovesLapTimer BETA,
matching this PR's CI): node smoke passes, and a scripted walk using the
harness's exact injection pattern reaches the creator through the OKC
track prompt and commits a real 3 s point-averaging hold.

Also ignore build-wasm/ (the emcmake build dir CI uses).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rqEnPQUqhMj98sgkefJkz
…t-bugs-q008ew

fix: transfer-mode exit bugs + refresh wasm sim harness (4.0.1)
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
…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
…rol-3z39f4

plan 0006: NeoPixel strip subsystem — pace pip, RPM scale, status flashers, purple sectors
…opped gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PaL2sQSbU3KCPF4FaZsPP
…d EGT clear delta

StatusAction grows invalidColor (shown solid while the source is
NaN/stale; latch always released — never latch stale data). kNone stays
unconditionally off. renderSearchPip bounces one green pixel 0<->8 as a
triangle wave of elapsed time (1.6 s round trip) for the race strip's
no-GPS-lock state. kEgtClearC becomes kEgtClearDeltaC (20 C below
whatever temp1_alert_c configures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PaL2sQSbU3KCPF4FaZsPP
overrev_limit (0 = disabled) + temp1_alert_c settings; raceEngineStopped()
(tach-proven session at 0 RPM — no-tach devices can never trip it).
Strip compose: boot anim > whole-chain overrev red flash > purple >
parked/off > race, where the race strip is engine-stopped bar-off ->
GPS-search pip (fix+timeValid gate) -> pace -> RPM scale; status LEDs
stay live with the bar off. Temp LED goes red (was orange) with solid
blue as its no-probe-signal state. Tach page OVER REV header now trips
at rev_limit instead of a hardcoded 9999; pace page shows STOPPED (and
mutes the faster-flash animation) while the engine is dead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PaL2sQSbU3KCPF4FaZsPP
The tach page header trips at overrev_limit — and only when that limit
is enabled — never at the rev_limit warning flag, which stays the left
LED's job. Docs aligned.

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

plan 0007: LED rev/overrev + temp alerts, GPS-search pip, engine-stopped gate
A 3.3 MB session downloading at 28.8 KB/s on an iPad, against ~130 KB/s
remembered from a bench run. The SD transfer-clock bump (2 -> 8 MHz while
parked) turned out to still be wired up correctly, so the regression was
elsewhere. Three ceilings, all fixed:

- Data Length Extension was requested on connect but never verified. The
  SoftDevice runs one link-layer control procedure at a time, so the ask
  fired immediately behind requestPHY(2M) can return NRF_ERROR_BUSY into a
  return value nobody reads -- and nothing ever called getDataLength() to
  find out. An un-extended link splits every 244-byte notify into ten
  27-byte packets. bleTuneLink() now reads back what negotiated at +500 ms
  and re-asks with nothing else in flight.

- The 7.5 ms connection-interval preference is one Apple centrals are
  required to reject (15 ms floor), leaving iOS on its own choice, commonly
  30 ms. The preference stays as-is so desktop/Android are not slowed; a
  second, Apple-compliant request is made only when the measured interval
  is slower than 15 ms.

- Every chunk was read straight off SdFat before being notified, putting a
  disk read in the radio's critical path and driving the card in 244-byte
  pieces rather than whole sectors. Chunks now stream from a compacting
  4 KB read-ahead filled by one aligned multi-sector read.

The read-ahead index math lives in a new host-tested ble_stream unit,
including a model transfer that reassembles a file and compares it
byte-for-byte -- an off-by-one here corrupts a downloaded session.

Also in the download path: a mid-file SdFat read error used to land in the
same branch as end-of-file and report DONE, handing the app a truncated
session with a clean status. It now reports ERROR.

And so the next regression is visible without a rebuild, the transfer page
shows live KB/s plus the SD clock actually in force, the negotiated
link-layer PDU and the ATT payload. sdActiveSpiHz() backs the SD figure
with the clock SD.begin() accepted -- the 8 MHz bump falls back to 2 MHz
silently, which nothing surfaced before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6mSm54pTqmg1eSgD3az2
…speed-iwclyc

plan 0008: get BLE download throughput off the floor
Four menu pages offered only forward choices: the transfer menu
(Bluetooth / USB), the replay session browser, and the course creator's
track prompt and type picker. None of them is the main menu, and the
idle-shutdown timer only runs there (and on the fault page), so none of
them timed out either — opening one by mistake left the unlabelled
Select + side-button 5 s reboot combo as the only escape.

- Transfer menu gains a Back row. It also joins the reverseDirection
  group in displayLoop(): it renders top-to-bottom like the other static
  menus and always belonged there, but with two items the scroll
  direction was unobservable (either button wrapped to the other row).
  The third row makes it visible.
- The replay browser gains a Back row after the last session, scrolling
  into view like any other row. replayItemCount() in replay.h is the
  single source of that layout for the renderer, the menu limit and the
  select handler, so the row cannot be drawn in one place and unreachable
  in another. The three near-identical render blocks collapse into one
  replayDrawEntry() helper.
- The course creator's two entry screens each gain a Cancel, mapped to
  the Row::kCancel the model already had (select() returns kExit for it,
  so no new plumbing). The type picker's matters most: entering with no
  known track nearby skips the prompt and lands there, making it the
  first screen those users see.

Also: cancelling manual camera-serial entry now returns to the camera
page, where OK already landed, instead of dropping to the main menu.

The course-type page drops the blank line under its title so three
size-2 rows plus the hint line fit the panel, and the hint is blank on
the Cancel row rather than describing a type the cursor is not on.

Tests: two new course_creator cases cover the entry-screen Cancel rows
and their out-of-range clamp. Sim goldens regenerated — transfer_menu
and course_type_select are the only two fixtures whose hash moved, and
both frames were eyeballed. Host suite 435/435, sim ctest 6/6,
clang-tidy clean on course_creator.cpp (which also clears a pre-existing
bugprone-branch-clone finding by merging the two identical rowCount
branches).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BY4rxBEPYL9Zf3n7PCHovM
…s-pcj1h8

Add Back/Cancel rows to the four menus that had no way out
A single-cylinder kart with a digital-chip magneto was logging RPM spikes
of thousands of RPM the engine did not do. The suspicion was that the
filter over-smoothed; it was the opposite.

The estimator sees ONE number per pulse and its steady-state gain was
~0.43, so a single bad edge is a spike rather than a wobble: an ignition
ring clearing the 3 ms debounce at 3000 RPM reads as 15,000 RPM and moves
the published value ~5000. A missed spark does the same downward. Two
further modelling defects made it worse the harder the engine worked:
measurement noise was a flat 2500 RPM^2 even though RPM = K/period makes
a fixed timing error cost RPM^2/K of RPM error, and process noise was
charged per update rather than per second, so the filter was four times
looser at 12,000 RPM than at 3000 and looser again whenever an SD stall
batched pulses together.

tach_filter gains an outlier gate (5 sigma, coast on reject; three
CONSECUTIVE rejects is a real step change and the third is adopted
outright), an RPM- and revsPerPulse-aware measurementNoise(), and a
processNoise() rate multiplied by the engine time the batch spans -
TACH_LOOP passes the sum of the periods it just consumed, never wall
clock. The first measurement after a reset is adopted whole, since
filtering up from 0 RPM would arm the gate partway and reject the
engine's own speed.

Modelled over the whole pipeline on a dirty pickup, error against true
RPM falls from 220-500 RPM sd (worst 2800-6400) to a flat ~20 RPM (worst
under 300) from 1500 to 14,000 RPM. Cost is ~90 ms more lag on a
5500 RPM/s pull; an instant 11,000 -> 4000 drop settles faster than
before (80 ms vs 120 ms).

New `tach_filter` setting so this is settled at the track rather than
argued from a graph: smooth (default) / legacy (the pre-0009 filter, bit
for bit) / raw (no estimator - the rpm column is exactly what the pickup
delivers). With debug_pages shown, the tach page's subtext becomes
"max:NNNNN S rj:NN": the estimator in force plus the gate's reject count,
which is the direct measurement of pickup health.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC9Q3NVSYoXUSwtYGJcepv
…erpolation-p38pcd

plan 0009: keep one bad ignition edge out of the RPM trace
Give the device a local wall clock so the NeoPixel strip can dim after
dark on the driver's schedule: "7am" has to mean their 7am, not
Greenwich's. A US Central driver at 07:30 local is at 12:30 UTC, which a
naive UTC gate calls the middle of the night.

New pure unit local_time.{h,cpp}: a fixed signed minute offset applied to
a 4-digit-year DateTime with correct rollover both ways across month,
year and leap-day boundaries, plus isNight(), which tests the window
[nightStart, dayStart) modulo the day so the ordinary wrapped case
(19:00 -> 07:00) needs no special casing at the call site. Equal bounds
mean an empty window, which is how the swap is disabled without a
separate flag. Minutes rather than hours because India is +5:30 and
Newfoundland -3:30. An out-of-band offset is ignored, not clamped: a
corrupt setting must not be able to walk the calendar. 30 host tests.

The consumer is npxEffectiveBrightness() feeding npxPushFrame(), which
was already the single global-brightness choke point, so the invariant
that no channel exceeds the cap is untouched. Two rules there: no time
lock means DAY (timeValid can be ~12.5 min out from a cold start, and a
strip that comes up dark reads as dead hardware), and a night cap of 0
blanks the frame without cutting the 5 V rail -- only led_brightness 0
does that.

No DST, deliberately. A fixed offset walks the boundary an hour twice a
year, beneath the resolution of a dim-after-dark gate; rule tables are a
standing correctness liability and tzdata is ~100 KB on a sealed device.

Logged data is unchanged and still UTC. DOVEX row timestamps are Unix
epoch ms, and the header datetime, log filenames and generated course
names all stay UTC. A log is routinely viewed somewhere other than where
it was recorded, so timezone presentation belongs to the viewing app.
Nothing in the logging pipeline may call into local_time.

Also fixes a latent cliff the four new keys would have gone over: the
settings file and JSON document were both 512 bytes, every read path
caps at sizeof(settingsFileBuffer) - 1, and the 18-key file was already
436 B. At 543 B the file parses as IncompleteInput, EVERY key read
fails, SETTINGS_SETUP reads that as corruption and regenerates the file
(losing the BLE name, PIN and pairing), ensureDefaultSettings grows it
back over the cap, and it loops every boot. The document was a second
wall -- a <512> doc returns NoMemory at 22 string pairs, measured
against the pinned ArduinoJson 6.21.5. Both raised to 1024, and
setSettingInner() now refuses any write whose document overflowed() or
whose measureJson() exceeds the buffer, turning a silent permanent brick
into one loud refused write with the old file left intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5A5R2ec4hBDiYZX6YjPqa
…-support-jn5noh

plan 0010: local timezone offset + LED day/night brightness
…lease notes

FIRMWARE_VERSION 4.0.0 -> 4.1.0 (matches the v4.1.0 tag to come). MINOR is
right: new settings and device behaviour, nothing removed and no format
break — track files, the DOVEX layout, log filenames and the BLE command
protocol are byte-for-byte what 4.0.0 wrote.

CHANGELOG: [Unreleased] had accreted the same shape as last cut — two
separate "### Changed" headings and a trailing "### Added" after "### Fixed"
from successive merges. Consolidated into one Added/Changed/Fixed set under
[4.1.0] - 2026-08-22. All 20 entries and every body line moved VERBATIM
(verified by diffing the sorted line multiset before and after); only the
five entries below gained a channel marker.

The marker is the substantive change here. Most of this release's headline
work — the whole NeoPixel subsystem — is behind BIRDSEYE_ENABLE_NEOPIXEL,
which is off in the published images, so a reader of these notes would
otherwise go looking for LEDs that a release build never lights. Each
affected entry is now marked *(beta channel)*, and the section intro says
plainly which half of the release is live for every user. Two plan-0007
behaviours are NOT gated and were being described as if they were: the tach
page's corrected *OVER REV* header (display_pages.ino reads
settingOverrevLimit outside any #if) and the pace page's STOPPED state
(raceEngineStopped(), same) both ship active — those entries now say
*(every build)* on that half and *(beta channel)* on the LED half.

Link refs: [Unreleased] was still comparing from v3.1.0, stale since the
4.0.0 cut, and [4.0.0] never got a ref at all. Both fixed, [4.1.0] added.

DovesLapTimer pins are deliberately untouched. compile-sketch.yml warns that
promoting the channel means bumping them, but the library's BETA branch is
source-identical to the v4.3.0 tag master already pins (the only difference
between the two refs is five CI workflow files), so there is nothing to bump.

Verified: 507 host test cases / 325,581 assertions pass.

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

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Coverage — host-testable units

📂 Overall coverage

Metric Coverage
Lines 🟢 1643/1667 (98.6%)
Functions 🟢 174/174 (100.0%)
Branches 🟡 1253/1393 (89.9%)

📄 File coverage

File Lines Functions Branches
BirdsEye/ble_stream.cpp 🟢 34/34 (100.0%) 🟢 8/8 (100.0%) 🟡 17/20 (85.0%)
BirdsEye/camera_fsm.cpp 🟢 238/246 (96.7%) 🟢 20/20 (100.0%) 🟡 142/160 (88.8%)
BirdsEye/course_creator.cpp 🟢 213/221 (96.4%) 🟢 21/21 (100.0%) 🟡 119/136 (87.5%)
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 🟢 66/67 (98.5%) 🟢 5/5 (100.0%) 🟢 50/52 (96.2%)
BirdsEye/local_time.cpp 🟢 48/48 (100.0%) 🟢 6/6 (100.0%) 🟢 46/50 (92.0%)
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/setting_parse.cpp 🟢 29/30 (96.7%) 🟢 2/2 (100.0%) 🟢 38/42 (90.5%)
BirdsEye/sprint_select.cpp 🟢 25/25 (100.0%) 🟢 4/4 (100.0%) 🟢 46/48 (95.8%)
BirdsEye/tach_filter.cpp 🟢 93/93 (100.0%) 🟢 12/12 (100.0%) 🟡 74/84 (88.1%)
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 22, 2026 21:58
…EL defaults to 1

The LED strip stops being a beta-only build and becomes a core feature in
4.1.0: a stock logger drives a strip the moment someone wires one, with no
special firmware. The flag's #ifndef default in project.h goes 0 -> 1, which
is the whole mechanism — every workflow already installs the Adafruit
NeoPixel library, release.yml passes no feature flags at all, and
compile-sketch.yml's non-BETA arm passes an empty FEATURE_FLAGS, so all three
channels pick the new default up on their own. beta.yml's explicit
-DBIRDSEYE_ENABLE_NEOPIXEL=1 is now redundant but harmless, and left alone.

WHAT THIS COSTS, stated plainly because no later firmware can undo it: the
subsystem's first boot programs UICR->NFCPINS to convert the two NFC pads to
GPIO and self-resets once so the latch takes effect. That is a ONE-WAY change
— reversing it needs a full chip erase and a bootloader reflash over USB —
and with the flag on by default it is charged to EVERY device that installs
4.1.0, wired for LEDs or not, since the firmware cannot tell the difference.
The trade was made deliberately: this hardware never uses NFC, the pads are
otherwise idle, and gating a headline feature behind a separate build had
kept it out of everyone's hands. Users who want the pads for something else
must not install 4.1.0, so the CHANGELOG leads its 4.1.0 section with an
upgrade note saying exactly that, including the extra reboot to expect.

No other translation unit changes: grep confirms `#if BIRDSEYE_ENABLE_NEOPIXEL`
appears nowhere outside neopixel.ino, so nothing else compiles differently and
the simulator (which excludes that file) is untouched — 6/6 sim ctest still
green with the flag flipped, goldens and both lap oracles included.

Docs follow the decision rather than trailing it: the CHANGELOG's channel note
is replaced by the upgrade warning and the per-entry "(beta channel)" markers
come off the LED entries (they are not beta any more); CLAUDE.md subsystem 16,
its flags list, constants table, File Map intro and workflows row; the
ARCHITECTURE LED bullet; CONTRIBUTING's flag table (now noting this is the one
flag defaulting to 1); and compile-sketch.yml's channel comment, where the
difference between the two arms is now just SensorEgg and the library ref.
Two in-code comments still calling the strip beta-only are corrected too.

Verified: 507 host test cases / 325,581 assertions and 6/6 sim ctest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6kd8xwnUbXyd2Ce6m8ydX
… strict setting parse

Six defects found reviewing the BETA -> master promotion diff. Each restores
behaviour the code already claimed; none adds a feature.

BLE `SLIST` was broken on every 4.1.0 device (release build, not flag-gated).
/SETTINGS.json is parsed in TWO places, and only one was raised by plan 0010:
settings.ino went 512 -> 1024 because the nine new keys take the default file
from 329 to 538 bytes, while bluetooth.ino's SLIST handler kept a 512-byte
buffer and a <512> document. It read 511 bytes of 538, deserializeJson()
returned IncompleteInput, and the handler answered SERR:PARSE without emitting
a single SVAL: line — the companion app's settings screen came up empty.
SGET/SSET were unaffected (they route through settings.ino). Both parsers are
now sized by SETTINGS_JSON_CAPACITY in settings.h, so they cannot drift again;
the SLIST pair is static rather than stack, matching sd_functions.ino's
"keeps JSON_BUFFER_SIZE off the stack" idiom now that it is 2 KB.

A beta-converted board updating to a release build never powered its LED rail
down. The flag-off stubs did nothing at all, by design — correct for virgin
hardware, wrong for a board carrying the one-way UICR NFC->GPIO conversion an
earlier beta build performed. There P0.09 (boost EN) was left in reset state
(input, disconnected) for the whole session and through System OFF, where the
driven level is the only thing holding the rail down (same retention as the
"blue conn LED stays on after sleep" report). A floating EN reads as enabled,
so "off" kept the 5 V rail and 11 idle WS2812s alive on a device with no power
switch. NEOPIXEL_SETUP/NEOPIXEL_SLEEP now drive EN low, but ONLY when
UICR->NFCPINS shows the pads are already GPIO — an unconverted board is still
never touched, so the flag's promise holds exactly.

Numeric settings were parsed with atoi(), which answers 0 for "" and for
"garbage". For rev_limit and temp1_alert_c that is harmless (0 is below their
floors), but for every key plans 0006/0010 added, 0 is IN range — and for
led_brightness it means "LEDs off, never raise the 5 V rail". So a blank or
corrupt value silently killed the strip instead of "clamping back to the
compiled-in default per the house idiom" as its own comment claimed, and
looked exactly like dead hardware. New host-tested setting_parse unit rejects
anything that is not a complete integer, so the existing range check keeps the
default; a deliberate 0 still works.

evalStatus() strobed when handed clearBelow above threshold. rev_limit and
overrev_limit clamp independently, so overrev_limit <= rev_limit * 0.97 put
the overrev action's release point above its own trip point: the latch set on
one frame and cleared on the next, flickering all 11 pixels at the 30 Hz frame
rate instead of flashing at 100 ms. Fixed in the pure unit rather than at the
call site so it covers the settings-driven assignability planned for phase 2,
with a regression test.

Pace page "STOPPED" was clipped: 8 chars at text size 3 is a 144 px advance on
a 128 px panel. Seven chars at x=1 fits in 126 and centres.

local_time.cpp was the only new pure unit missing from clang-tidy.yml, and it
links into the release image (BirdsEye.ino calls isValidOffsetMinutes()
unconditionally). Added, along with setting_parse.cpp.

CLAUDE.md corrections found in the same pass: the NeoPixel library is not
"linked in but inert" on a flag-off build, it is not compiled at all; the LED
settings keys are written AND parsed on every channel, only their use is
compiled out; the BLE manual-exit reboot was dated to a 4.0.1 release that
does not exist; and subsystem 10's teardown order predated NEOPIXEL_SLEEP/WAKE.
Subsystem 8 now documents that two parsers of the settings file exist.

Verified: 515 host test cases / 325,646 assertions (up from 507/325,581 — 7 new
setting_parse cases, 1 new led_modes case), and 6/6 sim ctest including the
golden pixel fixtures and both lap oracles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6kd8xwnUbXyd2Ce6m8ydX
…sy1wd

chore: cut v4.1.0 — version stamp, CHANGELOG cut, and ship the LED strip in every build
…w-fixes

fix: release-review findings for 4.1.0 — BLE settings list, LED rail, strict setting parse
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