Repository navigation
Release 4.1.0 — LED strip + assignable status LEDs, BLE download rework, RPM fixes, menu escapes - #150
Merged
Conversation
…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
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
Coverage — host-testable units📂 Overall coverage
📄 File coverage
|
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
5 of 11 tasks
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
8 of 13 tasks
Renumber colliding plan records — the three 0012s become 0012 / 0013 / 0014
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Promotes
BETAtomasterfor 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
tach_filterA/B settingtarget_speed_mphfor no-tach sessions;rev_limit→target_rpmwith first-boot migration; two-stage purple celebration; DOVEX temp columns now SensorEgg-onlyNumbering 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 reworkedtach_filter.BIRDSEYE_ENABLE_NEOPIXELnow 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 programsUICR->NFCPINSto 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
nantemperature 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'sdoveParser.tsalready does — so 4.0.0 and 4.1.0 logs both load, but a fixed-16-column assumption breaks.cylinder_countset 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_rpmmigrates automatically on first boot; the old key only ever existed on the beta channel.Beta-only riders
BIRDSEYE_ENABLE_SENSOREGGand the newBIRDSEYE_ENABLE_PROFILINGare 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/BETAinTheAngryRaven/DovesLapTimertouches nothing undersrc/, and every API the firmware calls exists inv4.3.0. Re-checked for the 0011–0014 merges — they add no new library calls (all sector/lap reads go through the existingactiveTimer*()wrappers), so the release pin holds. The definitive check remains the post-mergemaster-push CI run, which builds againstv4.3.0.Type of change
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'sBETAbranch 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 atBETAso the flags are the only variable); the one combination still untested is flags-off ×v4.3.0, which thecompile-sketchrun on the post-mergemasterpush builds. Wait for that run to go green before taggingv4.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
CHANGELOG.mdupdated — release cut in chore: cut v4.1.0 — version stamp, CHANGELOG cut, and ship the LED strip in every build #149, restructured to match the merged reality in CHANGELOG cleanup: fold post-release-cut merges into 4.1.0, dedupe section headings #157, plan citations renumbered in Renumber colliding plan records — the three 0012s become 0012 / 0013 / 0014 #158ARCHITECTURE.md/CLAUDE.mdupdated — subsystems 16–18 (NeoPixel, local time, profiling), plan 0013/0014 tach + status-LED semantics, settings/constants tables, CONTRIBUTING flag tabletests/Related issues
Deliberately excludes #148 (drag mode), which targets
BETAand is slated for the next beta cycle (as plan 0015 — see the numbering note above).Release steps after merge
BETA(plan renumbering; CHANGELOG cleanup: fold post-release-cut merges into 4.1.0, dedupe section headings #157 is already in), then this PR intomaster.compile-sketchon themasterpush — the only run that builds flags-off against thev4.3.0library pin — and check its OTA image-size line.v4.1.0;release.ymlbuilds both variants, publishes the Release and pushes the OTA manifest togh-pages.🤖 Generated with Claude Code
https://claude.ai/code/session_01MqSstYTWPhm3TZ3PxXs4hk