Skip to content

Release 4.1.0 — LED strip + assignable status LEDs, BLE download rework, RPM fixes, menu escapes - #150

Merged
TheAngryRaven merged 58 commits into
masterfrom
BETA
Aug 24, 2026
Merged

TheAngryRaven merged 58 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: 12 merged PRs (#141–#147, #149, #151–#156) plus the changelog restructure (#157, merged) and the plan renumbering (#158), design records 0006–0014, ~11,000 insertions across ~85 files.

Merge order: #158 → this PR. #158 is docs/comments only, but it renumbers the plan records the CHANGELOG cites, so merging it first ships the release with a unique plan sequence.

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
0011 #152 #153 Loop CPU profiling (beta channel only) — DWT-timed per-subsystem shares, LOOP PROFILE race page, pin-30 scope output
0012 #156 Beta download-regression fix — the SensorEgg scanner is race-gated off the transfer path (it was throttling beta downloads to ~33 KB/s); conn-interval/PHY diagnostic on the transfer page
0013 #155 User-assignable status LEDs — eight modes incl. lap/sector verdicts; a speed bar + target_speed_mph for no-tach sessions; rev_limit → target_rpm with first-boot migration; two-stage purple celebration; DOVEX temp columns now SensorEgg-only
0014 #154 Multi-cylinder RPM fix — the cylinder count no longer divides RPM (a V8 configured honestly read an eighth of its crank speed)
— #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 the release diff, incl. one that shipped broken
— #157 CHANGELOG restructure — post-release-cut merges folded into 4.1.0, duplicate headings deduped (merged)
— #158 Plan renumbering — the three records that all claimed 0012 become 0012/0013/0014, every citation updated by blame attribution

Numbering note: open PR #148 (drag mode, next beta cycle) still styles itself plan 0011 — profiling's number — and should take 0015, the next free number, when it lands.

New host-tested pure units this release: ble_stream, led_frame, led_modes, led_status, led_animations, sector_purple, local_time, setting_parse, loop_profile — plus a heavily reworked tach_filter.

⚠️ 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.

Compatibility

  • Track files, log filenames and the BLE command protocol are unchanged from 4.0.0.
  • Stock DOVEX logs drop the three always-nan temperature columns (plan 0012: assignable LED status modes, speed bar, lap/sector verdicts, two-stage purple #155): 13 data columns instead of 16. Readers key the data section off its own CSV header line — the companion app's doveParser.ts already does — so 4.0.0 and 4.1.0 logs both load, but a fixed-16-column assumption breaks.
  • RPM changes for any device with cylinder_count set above 1 (tach: cylinder count no longer divides RPM (plan 0012) #154): the reading was wrongly divided by the count. Devices at the default of 1 are byte-identical.
  • rev_limit → target_rpm migrates automatically on first boot; the old key only ever existed on the beta channel.
  • The UICR conversion is irreversible per device but touches none of the MAJOR-bump categories in the semver policy — hence upgrade notes rather than a 5.0.0.

Beta-only riders

BIRDSEYE_ENABLE_SENSOREGG and the new BIRDSEYE_ENABLE_PROFILING are both off in the shipped images. Two beta-channel notes: the scanner race-gating (#156) is what fixes the beta download throughput regression, and a profiling image can never switch the 5 V LED boost rail — pin 30 is the profiling scope output instead of the boost EN, so a beta unit asleep on a battery with a strip wired will drain it.

DovesLapTimer pin: no bump needed

Checked at the release cut: git diff v4.3.0 origin/BETA in TheAngryRaven/DovesLapTimer touches nothing under src/, and every API the firmware calls exists in v4.3.0. Re-checked for the 0011–0014 merges — they add no new library calls (all sector/lap reads go through the existing activeTimer*() wrappers), so the release pin holds. The definitive check remains the post-merge master-push CI run, which builds against v4.3.0.

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

CI on this PR still doesn't build the exact release configuration. The compile workflow selects its channel from head_ref == 'BETA', so this PR compiles against the library's BETA branch with all three feature flags on. The flags-off job closes most of the old gap (it builds the flags-off image on every PR, with the library deliberately held at BETA so the flags are the only variable); the one combination still untested is flags-off × v4.3.0, which the compile-sketch run on the post-merge master push builds. Wait for that run to go green before tagging v4.1.0, and read its OTA image-size line — the 408 KiB OTA self-flash cap is the binding constraint now, not the 90 % flash gate.

Checklist

Related issues

Deliberately excludes #148 (drag mode), which targets BETA and is slated for the next beta cycle (as plan 0015 — see the numbering note above).

Release steps after merge

  1. Merge Renumber colliding plan records — the three 0012s become 0012 / 0013 / 0014 #158 into BETA (plan renumbering; CHANGELOG cleanup: fold post-release-cut merges into 4.1.0, dedupe section headings #157 is already in), then this PR into master.
  2. Wait for compile-sketch on the master push — the only run that builds flags-off against the v4.3.0 library pin — and check its OTA image-size line.
  3. Tag v4.1.0; release.yml builds both variants, publishes the Release and pushes the OTA manifest to gh-pages.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MqSstYTWPhm3TZ3PxXs4hk

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 🟢 1854/1881 (98.6%)
Functions 🟢 197/197 (100.0%)
Branches 🟢 1372/1516 (90.5%)

📄 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 🟢 84/84 (100.0%) 🟢 7/7 (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 🟢 67/68 (98.5%) 🟢 6/6 (100.0%) 🟢 50/52 (96.2%)
BirdsEye/led_status.cpp 🟢 106/108 (98.1%) 🟢 11/11 (100.0%) 🟢 71/75 (94.7%)
BirdsEye/local_time.cpp 🟢 48/48 (100.0%) 🟢 6/6 (100.0%) 🟢 46/50 (92.0%)
BirdsEye/loop_profile.cpp 🟢 65/65 (100.0%) 🟢 7/7 (100.0%) 🟢 35/36 (97.2%)
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 🟢 84/85 (98.8%) 🟢 3/3 (100.0%) 🟡 57/64 (89.1%)
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 🟢 91/91 (100.0%) 🟢 13/13 (100.0%) 🟡 72/82 (87.8%)
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%)

TheAngryRaven and others added 22 commits August 23, 2026 19:48
Adds a beta-channel-only build flag that measures where a main-loop
iteration's time actually goes, as the input to the nRF52840-vs-nRF5340
board decision and to judging what leaving the Arduino core would buy.
Plan 0011.

Two instruments:

  - Pin 30 is driven HIGH for the span being profiled and LOW outside
    it, so a scope reads the loop period off the rising edges with no
    software in the measurement path. PROFILING_PIN_SECTION selects the
    span (whole loop body by default).
  - The same sections are timed in software and rolled up once a second
    onto a new LOOP PROFILE race page: loop rate, mean and worst
    iteration, and every section's share of the last second.

Pin 30 is the NeoPixel boost converter's EN line and cannot be both, so
a profiling build never drives EN — setup, sleep and the charging-loop
resume all no-op — and the regulator runs at its hardware default (on).
That is the accepted trade: the rail needs to be switchable only in
*use*, not in *testing*. Consequences are documented loudly in
project.h, profiling.h, neopixel.h, CONTRIBUTING.md and the CHANGELOG —
the rail stays up through System OFF (levels are retained there), so a
beta unit left asleep on a battery with a strip wired will drain it, and
an EN jumper left connected on the rig gets chopped at loop rate.

Timing uses the Cortex-M4 DWT cycle counter (64 ticks/us) because most
sections are well under a microsecond; it is verified to be counting
rather than assumed, and the micros() fallback announces itself with a
'*' on the page. All accounting lives in a new host-tested pure unit,
loop_profile, which works in ticks and saturates rather than wraps.

Prod is untouched: the flag defaults to 0, the section brackets are
macros that expand to the bare call, and pin 30 goes on being EN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqENxo91S1VL3sM9PTWMf1
It runs the same three-pin multi-sample debounce as readButtons(), so
leaving it unbracketed parked a hold-dependent cost (up to ~1 ms per
HELD button, since readButtonMultiSample early-returns on the first
HIGH and only pays its delayMicroseconds while a button is actually
down) in OTH — where it reads as unexplained instrumentation gap rather
than as button sampling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqENxo91S1VL3sM9PTWMf1
…ranch-8jmgzr

feat(beta): main-loop CPU profiling behind BIRDSEYE_ENABLE_PROFILING
…e run

The first bench run came back `999Hz av0.0`: a loop rate pinned at the
999 display clamp and a mean quantised to nothing. Both fields had been
sized from the "~250 Hz / 4 ms iteration" figure this project's docs had
carried for years. The real loop is at least an order of magnitude
faster than that — which is exactly the kind of thing this page exists
to find, so the clamps must not be what hides it.

- Rate clamps at 99999 instead of 999.
- Mean renders in whole microseconds below 1 ms, milliseconds above.
- The row still cannot outgrow the 21-column line, because rate and mean
  are reciprocal: a five-digit rate forces a sub-millisecond (<=4 char)
  mean, and a mean wide enough to need "99.9ms" forces a three-digit
  rate. Verified by rendering both extremes through the real
  GFX/SH110X framebuffer.

Also corrects the overhead claim in profiling.h, CLAUDE.md and plan
0011. A bracket is ~0.5 us and there are 13 of them, which was written
off as "under 0.1% of a 4 ms loop"; against a sub-100 us iteration it is
order 10% of what the page reports, sections under ~1% sit at their own
bracket's noise floor, and the un-instrumented loop is faster than the
rate shown. Plan 0011 records the run itself.

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

Code review of the profiler, prompted by a first hardware reading whose
loop rate pinned the display clamp: the window was being closed on the
DWT cycle counter, and DWT counts CPU CYCLES, not time. It stops dead
whenever the core halts (WFE/WFI in the FreeRTOS idle task,
sd_app_evt_wait). So a "one second" window was one second of CPU-AWAKE
time, and everything downstream inherited it:

  - loops were counted over however much wall time it took to accumulate
    a second of cycles, so the reported rate is the true rate multiplied
    by the awake fraction;
  - every share was a fraction of awake time wearing a wall-time label.

The micros() fallback never had the bug — it is a real clock. Only the
DWT path, which is the one that runs on hardware.

The unit now keeps both clocks deliberately: durations stay in ticks
(a sub-microsecond section must not quantise to zero), while the window
closes on millis() and every share is computed against that wall time.
Report gains awakeUs alongside windowUs so the discrepancy stays
visible, and busyPermille — wall time actually spent executing loop().

That last number is the one worth having. The subsystem previously
ASSERTED there was no idle time, on the reasoning that loop() runs back
to back; that assumption was baked into the measurement and is what made
the first reading wrong. The balance is now measured and reported as a
new SLP slot: scheduler dispatch, other FreeRTOS tasks, and CPU sleep.
Read on the fixed build, SLP near zero means the loop really is that
fast and the old numbers were merely clamped; a large SLP means the CPU
sleeps and the old rate was inflated by exactly that factor.

SLP's grid slot came from folding the boot-page state machines (PGE)
into DSP — same family of work, and the grid was exactly full. All
fourteen slots are now shares of the same wall-clock second and sum to
~1000 permille, which is an invariant a reader can check at a glance.

Five new tests, including the regression: a CPU awake 25% of a second
must report 1000 Hz and SLP 750, not 4000 Hz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqENxo91S1VL3sM9PTWMf1
…ranch-8jmgzr

fix(profiling): close the rollup window on wall time, and measure idle instead of assuming it
Plan 0003 shipped `pulses_per_rev = cylinder_count x spark_factor`, which
is only correct for a pickup clamped on a shared coil or distributor king
lead. This device has one sense wire and one clamp, and it goes around one
spark plug wire -- so it sees ONE cylinder's ignition however many the
engine has, and the cylinder term divided RPM by that count.

The case that surfaced it: a V8 on a traditional magneto, electrically
compatible with the pickup, configured honestly as 8 cylinders + single
fire, read an EIGHTH of its real crank speed. Plan 0003 papered over this
by redefining the setting as "cylinders the pickup sees" -- a field
labelled Cylinders that must not hold the engine's cylinder count, which
is a trap rather than a configuration.

Geometry is now the spark mode alone:

    revs_per_pulse = (spark_mode == wasted ? 1.0 : 2.0)

The debounce loses the same term (one wire cannot deliver pulses faster
than the cylinder it wraps fires), so it returns to a flat 3 ms / 6 ms and
the ~20,000 RPM ceiling is identical in both spark modes instead of
falling with each configured cylinder.

`cylinder_count` stays, as the engine's ACTUAL count. It is descriptive:
`rpmIsInferred()` drives the user-facing warning that above one cylinder
crank speed is inferred from one cylinder's ignition pulses -- an
assumption between firings, and a dropped cylinder reads as a stopped
engine. That is the known and accepted behaviour of every clamp-on
inductive tach, so it is stated in the settings UI, the README table and
the boot debug line instead of being discovered from a trace.

Breaking for devices with cylinder_count > 1: RPM stops being divided.
Defaults (1 cylinder) are byte-identical. No settings migration -- same
keys, same defaults, same file size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQvwnqBUZaJTPFkJZAFgKm
Field report: 33 KB/s against a remembered 120+, with the transfer page
showing 8M / 251 / 244 — so the SD fast clock, DLE, and MTU levers all
verifiably landed and the regression is elsewhere. The record exonerates
the SD card (clock switch verified in code and on-screen; the read-ahead
ceilings at ~950 KB/s), the transfer code path (byte-identical since plan
0008), and the web viewer (unchanged, and ATT notifies carry no app-level
ack), then names the two live suspects: the SensorEgg passive scanner's
44% radio duty running through the whole transfer on beta builds, and the
still-invisible connection interval / PHY. Ranked fix plan included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgibVbW9FcKj4Y57Jv2NoK
Phase 2 of the NeoPixel subsystem — the assignability plan 0006 named
and deferred — plus the four usability gaps a season of driving found.

Status LEDs are no longer hardcoded. `led_status_left` and
`led_status_right` each select one of eight modes (off / target RPM /
target speed / GPS lock / camera sync / last lap / last sector / EGT)
via the new host-tested `led_status` pure unit. The threshold modes
build a StatusAction on the stack and delegate to the existing
led_modes::evalStatus, so hysteresis, latch-release-on-invalid and
flash phase keep exactly one implementation; flashOn() was extracted so
the camera mode's flash shares that definition too. GPS and camera stay
lit on the main menu — "am I ready to drive" is a paddock question.

Fixes the shipped bug that motivated the work: the right pixel was
hardwired to the SensorEgg Temp1 tri-state, whose no-probe-signal state
is a solid blue, and on a stock build that reading is permanently
unavailable — so every logger with LEDs fitted showed solid blue from
lights-out to chequered flag. The mode now renders dark on a build with
no probe, gated by an Inputs field rather than an #if so the host test
can prove it with a hot valid reading.

The 9-px bar scales against a new target_speed_mph on a session with no
tachometer, where the RPM scale was nine dark pixels until the first lap
landed. RACE_ENTRY_TACH is the gate, not a live rpm>0 test, because
manual/speed sessions already promote to it at >500 rpm — so it means
"never seen an engine" and cannot flicker between scales at every stall.
The speed bar is green all the way up: the RPM bar's red band means
"approaching the limiter", and there is no equivalent hazard in reaching
a speed you were aiming for.

sector_purple gains lap tracking rather than growing a second close-edge
tracker beside it — the anti-race machinery there was hard-won, and
bestLapAtOpen is the same open-time snapshot one level up, because the
library folds a finished lap into getBestLapTime() at the crossing too.
Verdicts compare the LAST recorded time, not the best: against the best
you only ever get purple or red, which says nothing about whether you
are improving. Lap tracking no longer needs sector lines, so it works in
a Lap Anything session.

Purple is two-stage: a session-best sector keeps the 1.6 s animation, a
session-best lap gets a 2.6 s two-wave version that outranks it. Both
call one renderer, so renderPurple's golden frames are untouched.

rev_limit becomes target_rpm — it always was the shift point, with
overrev_limit as the real limit, and the old name taught the wrong thing
to everyone who opened SETTINGS.json. Existing devices migrate on first
boot ahead of the defaults walk (which would otherwise stamp the default
over a tuned value), and the old key is dropped only once the new one is
confirmed written.

Egg gating closed: temp1_alert_c and the DOVEX Temp1/Junction1/Temp2
columns join the flag. The log format now forks by channel (13 stock
columns vs 16) — a deliberate reversal of the uniform-shape rule, since
three dead `nan` columns on every row of every stock log paid for
nothing. Verified safe: readers key off the CSV header line where all
three are optional, and the companion app already builds a name->index
map and does not read them.

Settings file measures 570 B stock / 592 B SensorEgg against the
1023-byte read cap — gating temp1_alert_c and dropping rev_limit paid
for two of the three new keys.

CI: led_status.cpp added to clang-tidy's hand-maintained source list,
and a flags-off compile job added for BETA-targeted PRs, which until now
built only the flag-on arm — so the new #else branches would have
reached master having never been through a compiler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYeq2pZonbkPtEGsFUVbJE
sketch-paths was missing from the job added in the previous commit, so
arduino/compile-sketches fell back to its default of `examples` — a path
this repo does not have — and failed before touching the sketch. The one
arm added specifically to compile the new #else branches was the one arm
not compiling.

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

The SensorEgg passive scanner (44% radio duty) ran forever from boot on
every egg-enabled build — including through BLE transfers, where it denied
the link's connection-event extension and throttled downloads to ~33 KB/s
against a 120+ KB/s baseline. The scanner is now race-gated: configured at
setup, started by SENSOREGG_LOOP()'s reconcile when raceActive goes true
(1 s throttle retries a refused start — the reconcile replaces the
self-heal as the start-retry path, and the self-heal now only cures a
wedge while running), and stopped when the session ends. The scan-callback
resume() shares the same wanted gate so a deferred report can't resurrect
a stopped scan, SENSOREGG_WAKE() only clears the sleep gate (a charging
resume lands on the menu), and BLE_SETUP() calls SENSOREGG_SLEEP() so a
transfer session is guaranteed scan-free rather than inferred (exits
reboot, so no wake path is needed). The EGT feed loses nothing: the Temp
pages and DOVEX columns are race-only consumers.

The transfer page grows a second diagnostic line (e.g. '15.0ms 2M') with
the live connection interval and PHY — the two throughput levers the
central decides and the device can only request, and the ones plan 0008's
line couldn't show. Backed by new bleLinkIntervalUnits()/bleLinkPhy()
accessors reading the connection object's cached values; the sim stubs
them, and the page's non-transfer layout is unchanged (golden hashes
verified locally, 539/539 host tests pass, native sim + oracle green).

(Committed via the GitHub API — the session's git push credential path
was down; docs follow in the companion commit.)
…e line

Companion to the headers/stubs commit: sensoregg.ino's race-gate reconcile
and gated resume/self-heal, bluetooth.ino's SENSOREGG_SLEEP() in
BLE_SETUP() plus the bleLinkIntervalUnits()/bleLinkPhy() accessors, and
display_pages.ino's second transfer diagnostic line.
bluetooth.ino: SENSOREGG_SLEEP() at the top of BLE_SETUP() and the
bleLinkIntervalUnits()/bleLinkPhy() accessors. display_pages.ino: the
second transfer diagnostic line (interval + PHY), with the non-transfer
layout byte-identical (sim golden hashes verified). Plan 0012's status
updated to reflect the implemented, race-gated scope.
bluetooth.ino: SENSOREGG_SLEEP() at the top of BLE_SETUP() (explicit
scan-free guarantee for the transfer session) and the new
bleLinkIntervalUnits()/bleLinkPhy() accessors reading the connection
object's cached values. display_pages.ino: the second transfer
diagnostic line (live interval + PHY); the non-transfer layout stays
byte-identical so the sim golden hashes hold.
SENSOREGG_SLEEP() at the top of BLE_SETUP() (explicit scan-free
guarantee for the transfer session; exits reboot so no wake path), and
the bleLinkIntervalUnits()/bleLinkPhy() accessors the transfer page's
second diagnostic line reads — live from the connection object's cached
values so a mid-transfer renegotiation shows its real numbers.
…adation-hdjwrr

plan 0012: race-gate the SensorEgg scanner + interval/PHY on the transfer page
…ability-daknhd

plan 0012: assignable LED status modes, speed bar, lap/sector verdicts, two-stage purple
…onfig-c1e4ht

tach: cylinder count no longer divides RPM (plan 0012)
…e headings

The parallel merges after the 4.1.0 release cut (#152-#156: loop CPU
profiling, the multi-cylinder RPM fix, assignable LED status modes, and
the download-throughput regression fix) each appended their entries under
a fresh [Unreleased] heading, leaving that block with three separate
'Fixed' sections while FIRMWARE_VERSION on this branch is already 4.1.0 -
everything here ships in the release PR #150 promotes. Fold those entries
into the [4.1.0] section, one heading per category, and refresh the
release intro: the date, the DOVEX byte-for-byte claim (stock logs now
drop the three temperature columns), the cylinder_count RPM caveat, and
the beta-only note (SensorEgg gained the scanner race-gating fix, and the
profiler is a second beta-only flag).

Entry text is moved verbatim except where the release-cut entries named
the setting by its mid-cycle key: 'rev_limit' references now read
'target_rpm' (the key 4.1.0 actually ships), the temp1_alert_c entry is
marked SensorEgg-only, and the rename entry notes the old key only ever
existed on the beta channel.

Also merges the same mass-merge duplication in released sections - 3.0.1
carried three 'Added', three 'Changed' and two 'Fixed' headings, 2.2.0
two 'Changed' - moving entries verbatim, and adds the missing blank line
before 4.0.0's 'Fixed'. An empty [Unreleased] heading stays at the top.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqSstYTWPhm3TZ3PxXs4hk
@TheAngryRaven TheAngryRaven changed the title Release 4.1.0 — LED strip subsystem, BLE download rework, RPM filter, menu escapes Release 4.1.0 — LED strip + assignable status LEDs, BLE download rework, RPM fixes, menu escapes Aug 24, 2026
TheAngryRaven and others added 2 commits August 24, 2026 17:37
CHANGELOG cleanup: fold post-release-cut merges into 4.1.0, dedupe section headings
Three design records landed in the same mass-merge window all claiming
plan number 0012. Renumber by merge order into BETA, so the earliest
merge keeps the number:

  #156  0012-download-throughput-regression-deepdive  (keeps 0012)
  #155  0012-led-status-modes            -> 0013-led-status-modes
  #154  0012-tach-rpm-single-pickup     -> 0014-tach-rpm-single-pickup

Every in-tree citation follows: each line referencing "0012" was
attributed to the merge that introduced it via first-parent blame (so
prose like "plan 0012" and "pre-0012" lands with the right plan), then
rewritten - code comments, CLAUDE.md, CONTRIBUTING.md, the CHANGELOG's
two slug citations, the compile-sketch.yml flags-off comment, test
comments and test-case names, and plan 0003's superseded-by link. The
deepdive's own references stay 0012 untouched. No behavior change;
host suite still 567/567.

Commit messages citing "plan 0012" remain ambiguous - history cannot be
rewritten - but the files they resolve to are now unique. PR #148 (drag
mode) also styles itself plan 0011, which profiling holds; it should
take 0015, the next free number, when it lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqSstYTWPhm3TZ3PxXs4hk
Renumber colliding plan records — the three 0012s become 0012 / 0013 / 0014

Copy link
Copy Markdown
Owner Author

Release-notes blog post for the LapWingData webapp — copy the raw markdown from the block below (pure markdown, no HTML):

# BirdsEye 4.1.0 — Lights On

The LED strip graduates from beta experiment to standard equipment, RPM readings get a
serious accuracy pass, and Bluetooth downloads get their speed back. Everything below is
live for every logger — no special builds.

## ⚠️ Read this before updating

**This update permanently converts your logger's two NFC pads to GPIO, and the logger will
reboot itself once while doing it.**

The LED strip now ships in the normal firmware, so the pins that drive it have to be made
usable — and that means a one-time write to the chip's permanent config, which **no later
firmware update can undo**. Your logger does this the first time it boots after updating,
then resets itself once. That single extra restart is expected — nothing is wrong.

This happens whether or not you have LEDs attached. The logger's hardware never uses NFC
for anything, so for almost everyone this changes nothing — but if you have some future
plan for those pads on your device, skip this release.

## The LED strip is standard now

Wire up the 11-pixel strip and it just works — no beta build required:

- **Pace pip** — once you have a lap down, a pixel shows you against your best: right of
  center in green means faster, left in red means slower. Before that, the bar is an RPM
  scale — or, on karts with no tach hookup, a **speed bar** scaled to the new
  *Target speed* setting, so the strip is never just dark.
- **Two assignable status LEDs.** The end pixels are yours to configure — each shows one
  of: target-RPM flash, target-speed flash, GPS status, camera status, last-lap verdict,
  last-sector verdict, EGT alert (beta EGT pod only), or off. The lap and sector modes
  answer "was that better than the last one?" — green yes, red no, **purple for a session
  best**, with a strip-wide purple celebration to match (laps get a bigger one than
  sectors).
- **Overrev alert.** Set an overrev limit and the entire strip flashes red past it — the
  "engine is in trouble" signal, distinct from the target-RPM shift flash.
- **Night mode.** Give the logger your timezone and it dims the strip to a separate night
  brightness after dark (7 pm to 7 am by default, both configurable). Your logged data is
  not affected — logs stay in UTC, and the app handles display timezones as always.

## RPM you can trust

- **The spikes are gone.** Logged RPM traces could show spikes of thousands of RPM the
  engine never did — single bad ignition edges passing straight through the filter. The
  new filter rejects them while staying honest about real step changes (a clutch dump
  still reads as a clutch dump). If you want to compare, a new *Tach filter* setting can
  switch back to the old filter, or to completely raw readings, per session.
- **Multi-cylinder engines read correctly.** The tach pickup clamps around **one** plug
  wire, so it sees one cylinder — but the firmware was dividing RPM by your configured
  cylinder count, which is only right for a clamp on a shared coil lead. An honestly
  configured V8 read an eighth of its real crank speed. That's fixed: spark mode alone
  sets the math now, and *Cylinders* means what it says — your engine's actual count.
  - **Heads up:** if you had *Cylinders* set above 1, your RPM readings will change
    (they're correct now), and logs recorded before this update read low by exactly that
    factor. Loggers left at the default of 1 are unchanged.
- **"Rev limit" is now "Target RPM".** It was always the shift/warning point rather than
  a limiter, and the old name confused everyone. Your configured value carries over
  automatically — nothing to redo.

## Bluetooth downloads: faster, and honest about it

A big session was downloading at a crawl on some phones and tablets. Three separate causes,
all fixed: the logger now verifies (and re-requests) the large-packet negotiation instead
of assuming it worked, makes an Apple-compliant second request when iOS rejects the fast
connection timing, and reads ahead from the SD card so the radio never waits on a disk
read. The transfer screen now shows **live KB/s** plus the actual link details, so a slow
download is visible immediately instead of a full session later.

## Fixes and quality of life

- **Every menu has a way out.** The transfer menu, replay browser, and course-creator
  screens all gained Back/Cancel rows — no more being stuck in a screen you opened by
  mistake.
- **Settings changed over Bluetooth now always apply.** Exiting transfer mode with the
  button reboots the logger just like a phone disconnect always did.
- **The app's settings screen works again** — the logger's settings list had stopped
  answering after the file grew past an internal limit.
- **Exiting USB transfer mode no longer risks a hang** while the computer is mid-write.
- **A stalled engine shows `STOPPED`** on the pace page instead of a still-counting pace
  timer, and the LED bar goes dark while the alerts stay live.
- **A garbled LED brightness setting** no longer switches the strip off silently — bad
  values fall back to the default like they were always supposed to.

## Your data and the app

- Track files, log filenames, and the Bluetooth protocol are unchanged — existing tracks,
  cards, and this app keep working exactly as before.
- Standard loggers no longer write the three always-empty temperature columns (those now
  exist only on beta EGT-pod builds). LapWingData reads both old and new logs — this only
  matters if you feed logs to your own custom tooling, which should read the CSV header
  line rather than assuming a fixed column count.

## How to update

Update over the air from this app as usual. Remember: expect **one extra self-restart**
on the first boot after the update — that's the one-time pad conversion doing its job.

Generated by Claude Code

@TheAngryRaven
TheAngryRaven merged commit 0077141 into master Aug 24, 2026
14 checks passed
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