Skip to content

Beta 4.2.0 - drag timer - #160

Open
TheAngryRaven wants to merge 44 commits into
masterfrom
BETA
Open

TheAngryRaven wants to merge 44 commits into
masterfrom
BETA

Conversation

@TheAngryRaven

@TheAngryRaven TheAngryRaven commented Aug 26, 2026 •

Copy link
Copy Markdown
Owner

Requested by Dove · project thread

Summary

Release PR: BETA → master for firmware 4.2.0. Headline feature is drag mode (automatic + manual christmas tree). It also carries the SD/watchdog boot hardening, and two beta-only SensorEgg features that compile out of the release image.

Before: the logger timed circuit laps and sprint runs, but had no way to time a straight-line pass. A firmware-initiated reboot could hand the next boot a running watchdog, which on a slow card wedged the SD and offered a healthy card for formatting on every reboot.

After: main menu → Drag → pick a distance and Automatic or Manual, and the device times ET, trap speed, 0-60 (and reaction time in Manual) with no track file. Soft reboots survive a slow SD init, and the format page only offers an erase on consistent evidence.

What ships in the release image (master build: NeoPixel on, SensorEgg off, profiling off)

Drag mode — automatic (plan 0015, #148)

  • Main menu Drag → distance picker: 1/8 mile, 1000 ft, 1/4 mile, 1/2 mile, 1 mile (+ Back). No track file, no course detection.
  • Stages at a standstill (re-latching anchor), starts the clock rollout-style 11.25 in past the staged position, ends at the target distance with interpolated crossings. Reports ET, trap speed, 0-60 mph; re-arms automatically for the next pass.
  • Runs duck-type as laps (lapHistory, laps line in the DOVEX header). Header gets race_mode=DRAG and a course name like DRAG 1/8 MILE; same backwards-compatible trailing-column scheme as SPRINT.
  • Idle grace re-arms on every fresh stage and every completed run, so a moving queue doesn't end the session.
  • The run state machine is the new host-tested drag_timer pure unit (tests/drag_timer_test.cpp); review fixes (time base, launch guards, unit hygiene) in 7e7dd93.

Drag mode — manual christmas tree (plan 0016, #161)

  • After the distance the menu asks Automatic or Manual.
  • Manual stages like a strip: STOP TO STAGE → white staging pip → three yellows at the sportsman 500 ms cadence → green, mirrored on the LED bar and as a big OLED 3…2…1 / flashing GO.
  • Moving during the yellows is a RED LIGHT foul; sitting still 5 s after green is FAILED TO LAUNCH. Every run ends on a results screen (ET, trap, 0-60, reaction time) that re-arms on any button.
  • The screen is pinned to staging info for the whole session; hold Select 2 s to end it. The physics launch gate opens only on green.
  • The tree is the new host-tested drag_tree pure unit (tests/drag_tree_test.cpp). New sim golden fixtures cover the drag pages.

Boot / SD hardening (#163)

  • The nRF52 WDT survives NVIC_SystemReset(), so every soft reboot (BLE/USB exit, OTA, format page, reboot combo) handed the next boot a ~4 s deadline. setup() now detects a carried-over WDT first thing (wdtBootCheck()), feeds it between slow boot steps, and wdtSetup() no longer writes to a running (locked) WDT.
  • SD_SETUP() re-probes once after a 250 ms settle and only offers a format when the card answered both times and the volume mounted neither time (new sd_probe pure unit). A card that stops answering between probes goes to FAULT, never an erase offer.
  • After SD.format() the volume must mount before "Format OK"; otherwise the page says Formatted: no mount / Power-cycle the unit instead of reboot-looping.
  • Format page and SD FAULT page show the boot cause and SdFat's last error (e.g. boot:WDT err:20); wake_cause gains short names and a watchdog cause.
  • Recovered-on-probe path now records the active SPI clock.

Docs (#159 and the feature PRs)

  • Plans 0015–0018 added; plan 0011 records the wall-clocked hardware profiling run (9.1 kHz loop, ~96 µs mean iteration, 37 ms worst — the nRF52840 has ample headroom).
  • CLAUDE.md, ARCHITECTURE.md, CHANGELOG, page-id and settings tables updated.

Beta channel only (BIRDSEYE_ENABLE_SENSOREGG=1; compiled out of the release image)

SensorEgg pairing menu + EGG TEST page (plan 0017, #162)

  • Egg main-menu row: 2-minute window-gated pairing (long-press the egg's button), stored to the new sensoregg_mac setting and applied live; paired page offers Back / Test / Unpair (persist-first, like the camera). Unpaired = accept any egg, as before.
  • EGG TEST bench page: rf link state, protocol version, EGT/CJ/AUX/battery, sequence + packet rate, PAIR/FAULT flags, active MAC filter. The camera test page's egg soak line works at the desk again.
  • MAC parse/format/byte-order helpers are host-tested in sensoregg_protocol.

SensorEgg GATT link (plan 0018, #164)

  • A paired egg is now connected to as BLE central during races and on the test pages: reads the pod's self-describing channel table, anchors a clock fit with boot_id reboot detection, and streams per-channel sample frames into the same data surface as the beacon (DOVEX columns, race pages, LEDs unchanged). Beacon stays the fallback and pairing transport; no 44 % scan duty while streaming.
  • SensorEgg builds run Bluefruit.begin(1, 1) with skinny central parameters; the camera link still wins. SENSOREGG_SLEEP() drops the pod link for transfers and shutdown.
  • Decoders are the new host-tested sensoregg_gatt pure unit.

Type of change

  • Bug fix (SD/watchdog boot hardening)
  • New feature / behavior (drag mode; beta-only SensorEgg pairing + GATT)
  • Refactor (no behavior change)
  • Tests only (new pure-unit suites alongside each feature)
  • CI / tooling / docs
  • Breaking change — track files, BLE protocol and existing log formats are unchanged; race_mode=DRAG is a new value in an existing trailing header column.

How it was verified

  • Host unit tests pass (ctest --test-dir tests/build) — new suites: drag_timer, drag_tree, sd_probe, sensoregg_gatt, plus additions to sensoregg_protocol and wake_cause
  • clang-tidy clean (static analysis check green)
  • Compiles for the XIAO nRF52840 Sense and plain nRF52840 (beta flags), plus a flags-off Sense build
  • Native + WASM sim builds, goldens (incl. new drag pages) and lap oracles pass
  • Tested on real hardware — describe what you exercised:

Checklist

  • CHANGELOG.md updated under [Unreleased] (if user-visible)
  • ARCHITECTURE.md / CLAUDE.md updated (if a module or interface changed)
  • New testable logic has a matching test in tests/
  • Branch is focused — this is the long-lived BETA → master integration PR

Before tagging v4.2.0

  • Set FIRMWARE_VERSION "4.2.0" in BirdsEye/project.h (still 4.1.0 on BETA — the OTA manifest takes its version from the tag, so a mismatch makes the companion offer the update forever)
  • Rename ## [Unreleased] to ## [4.2.0] - <date> in CHANGELOG.md
  • Keep the release workflow's DovesLapTimer pin at v4.3.0 (its src/ is identical to the library's BETA)

Related issues

Merges #148, #159, #161, #162, #163, #164.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw

TheAngryRaven and others added 5 commits August 25, 2026 00:54
… is answered

9134 Hz, 96 us mean, 37 ms worst iteration, SLP 12.0, with a GPS lock,
logging and the camera recording.

The instrument validates itself: 9134 Hz x 96 us = 87.7% of the second
inside loop(), and SLP — computed by a different route — reads 12.0%.
All fourteen slots sum to ~99.8.

SLP 12% also settles what the previous build could not distinguish: the
CPU is not sleeping through the second, so the loop genuinely runs at
~9 kHz and the old pegged rate was the display clamp rather than the
timebase bug. There was no way to know that until the window was
wall-clocked.

Headroom, against the binding constraint (one iteration per 40 ms PVT
frame): ~420x on the mean, 43x on the worst iteration against the 4 KB
GPS ring, 108x against the watchdog. Nothing here justifies an nRF5340
on throughput, and SLP 12% caps what leaving the Arduino core could
reclaim.

Also records where the 88% goes — it is polling, not work. GPS is called
9134 times a second to service a 25 Hz stream; DSP renders 3 times and
early-returns ~9000; BTN is six digitalReads per iteration with nobody
touching a button. Rate-limiting the polls would reclaim most of it
without changing silicon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqENxo91S1VL3sM9PTWMf1
Main menu -> Drag -> pick 1/8 Mile / 1000 ft / 1/4 Mile / 1/2 Mile /
1 Mile and the session starts: no track file, no detection. Stage at a
standstill, rollout-style launch (11.25 in past a re-latching standstill
anchor, ET start interpolated between 25 Hz fixes), run ends at the
target distance with interpolated ET + trap speed and a 0-60 split, then
re-arms automatically for the next pass. All passes land in one DOVEX
session with race_mode=DRAG (laps line = run ETs, same trailing-column
scheme as SPRINT).

- New host-tested pure unit drag_timer.{h,cpp}: the whole run state
  machine (ARMED/STAGED/LAUNCHED), the distance table, silent aborts
  (mid-run standstill, >=2 s fix gap — also how queue-creep phantom
  launches self-cancel), 14 doctest cases.
- Sketch glue mirrors sprint: dragTimer != nullptr IS drag mode, drag
  branch first in every activeTimer*() helper, run capture on the
  run-count edge, trackDetected latched, idle-grace re-arm on each run
  AND each fresh STAGED latch.
- UI: Drag main-menu row, PAGE_DRAG_DISTANCE picker (scrolling window),
  drag branches on the lap-time/pace/best pages (live ET, *staged*,
  trap/0-60 subtext, live 0-60 readout), LED pace pip suppressed between
  runs like sprint.
- Sim: golden walk updated for the new menu row + a picker fixture;
  goldens regenerated; soak/determinism/goldens/oracles all pass.

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

docs(0011): record the wall-clocked hardware run — the board question…
Six findings from the branch review, all fixed:

- Time base: the glue fed getGpsTimeInMilliseconds() (time-of-day, wraps
  to zero at UTC midnight — evening sessions) into a unit whose contract
  says epoch ms. Both call sites now feed getGpsUnixTimestampMillis(),
  and the unit's non-monotonic guard resyncs on a backwards step instead
  of rejecting every subsequent fix — one step used to wedge the drag
  timer for the rest of the session.
- Launch edge: a >=2 s fix gap while STAGED now re-stages instead of
  evaluating the launch, so a launch that happened inside a GPS dropout
  can't interpolate its ET start back to a parked-car fix.
- Prove-out gate: a launch must reach 15 mph within 5 s of the ET start
  or the run is silently abandoned — a wave-off driven to the pits at
  4 mph never holds the sub-2 mph standstill abort and used to record
  660 ft of pit road as a ~90 s "run".
- activeTimerTotalDistance() converts the drag branch to meters, matching
  every other branch of the accessor.
- distanceFeet() delegates to the haversine unit (x 5280) instead of
  carrying a second copy of the formula and Earth radius.
- The seconds.hundredths split rendering is now one function
  (displayPrintSplitSeconds) shared by the results subtext and the pace
  page's live 0-60 readout.

Five new regression tests cover the wedge recovery, the staged-gap
launch, the wave-off, a slow-but-real pass, and duplicate timestamps.
Plan 0015 doc + CLAUDE.md constants updated to match.

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

plan 0015: Drag mode — distance runs without a track
@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown

Coverage — host-testable units

📂 Overall coverage

Metric Coverage
Lines 🟢 2387/2422 (98.6%)
Functions 🟢 253/254 (99.6%)
Branches 🟢 1735/1918 (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/drag_timer.cpp 🟢 177/182 (97.3%) 🟢 11/11 (100.0%) 🟡 92/112 (82.1%)
BirdsEye/drag_tree.cpp 🟢 116/118 (98.3%) 🟡 8/9 (88.9%) 🟢 94/100 (94.0%)
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 🟢 48/48 (100.0%) 🟢 7/7 (100.0%) 🟢 32/34 (94.1%)
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 🟢 34/34 (100.0%) 🟢 4/4 (100.0%) 🟢 22/22 (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 🟢 72/73 (98.6%) 🟢 7/7 (100.0%) 🟢 60/62 (96.8%)
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/sd_probe.cpp 🟢 6/6 (100.0%) 🟢 1/1 (100.0%) 🟢 8/8 (100.0%)
BirdsEye/sector_purple.cpp 🟢 84/85 (98.8%) 🟢 3/3 (100.0%) 🟡 57/64 (89.1%)
BirdsEye/sensoregg_gatt.cpp 🟢 154/154 (100.0%) 🟢 24/24 (100.0%) 🟢 99/110 (90.0%)
BirdsEye/sensoregg_protocol.cpp 🟢 90/91 (98.9%) 🟢 14/14 (100.0%) 🟢 76/78 (97.4%)
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 🟢 23/24 (95.8%) 🟢 3/3 (100.0%) 🟢 27/28 (96.4%)

claude and others added 22 commits August 26, 2026 04:07
Drag now asks Automatic or Manual after the distance. Manual stages
like a strip: stop, and the 9-px bar lights a white staging pip, three
yellows at the sportsman 500 ms cadence, then green — mirrored on the
OLED as STOP TO STAGE, a big 3..2..1, and a flashing GO. Movement
during the yellows is a RED LIGHT foul; 5 s still after green is
FAILED TO LAUNCH; both flash red and wait for a button. Runs end on a
results screen (ET, trap, 0-60, and a new reaction time = green ->
interpolated rollout crossing, both epoch ms). The screen is pinned to
staging info for the whole manual session; hold Select 2 s to end it.
Automatic mode is behavior-identical (one extra menu hop).

- New host-tested pure unit drag_tree.{h,cpp}: the ONE state machine
  driving both the LED strip (renderStrip) and the OLED (countdownDigit
  / flashPhase) so they can never disagree; 17 doctest cases (exact
  stage timings, fouls from every yellow, exit-hold semantics, golden
  strip frames). Flash half-period 500 ms — the 3 Hz OLED aliases
  anything faster.
- drag_timer grows setLaunchEnabled (default true; while disabled a
  rollout-at-speed re-arms — a pre-green move is the tree's foul, never
  a run) and runStartEpochMs() for the RT computation; 3 new tests.
- Glue: dragStagingLoop() in the gpsStatusPageLoop slot (Inputs/step/
  Effects, resetButtons on consumption); the launch gate re-asserted
  every loop (open exactly while green); pin cleared only by
  endRaceSession so every session ender releases it.
- UI: PAGE_DRAG_MODE (-18) Auto/Manual/Back; PAGE_DRAG_STAGING (-19)
  pinned via the gpsLockHold construction. Fixed a plan-0015 layout
  bug while here: subtext after size-3 text needs a size-3 newline or
  it renders on the ET's bottom glyph rows.
- NeoPixel: tree renders strip-only as a new cascade arm (status LEDs
  live, search pip wins without a fix, normal scale during the run);
  paceValid gains !dragManualActive() (constant false for auto).
- Sim: 6 new golden fixtures including a FULL manual pass through the
  real pipeline (park -> tree -> green -> 70 mph -> results with RT);
  goldens regenerated (23 fixtures), soak/determinism/oracles pass.

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

plan 0016: Manual drag mode — the christmas tree
parseMac/formatMac (settings-string codec), macReverse (human <->
ble_gap_addr_t LSB-first, aliasing-safe), macIsWildcard, macAccepts —
the byte-order reversal the scan callback depends on finally moves
under host test, with a case pinning that a same-order compare rejects.
kPairingTimeoutMs (120 s, camera parity) and kMacStrLen live with the
other tunables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcdBRTn52QpCQWkyVm5Lkg
Runtime MAC filter boots from the SENSOREGG_MAC fallback and loads
sensoregg_mac at SENSOREGG_SETUP(); the scan callback stays protocol-
blind (it additionally memcpys the advertiser address into a per-slot
field of the existing double buffer, ready-flag-last preserved) and
bypasses the MAC filter while the capture window is open. Capture runs
in the main-loop drain on the parsed pairingActive bit: persist first,
then the RAM filter, then close the window — a failed SD write retries
on the next frame. 120 s window timeout; bench latch (eggTestActive)
and the pairing window now force the race-gated scanner on; packet-rate
meter for the test page. Twelve new public accessors with no-op twins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcdBRTn52QpCQWkyVm5Lkg
Main-menu row 'Egg' (index 6, appended after Camera so nothing shifts),
PAGE_PAIR_EGG (-20) cloning the camera page's shape — paired =
Back/Test/Unpair with Back at index 0, unpaired = window-gated capture
status with B2 cancel and B1 reserved for future manual entry — and
PAGE_EGG_TEST (-21): rf tri-state + proto version, EGT/CJ/AUX/BAT,
sequence + measured packet rate, live PAIR/FAULT flags, active MAC
filter, Back. All five display_ui coupling points wired under the
build flag; the camera test page now latches the egg bench scan on
enter/exit, restoring the desk-soak behavior its comment promised
(stale since plan 0012's race gate — comment fixed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcdBRTn52QpCQWkyVm5Lkg
Flag-0 sim never renders the Egg pages; stubs mirror the module's full
surface so a future flag-1 sim build links. Golden hashes verified
UNCHANGED — the gate does not leak into stock renders.

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

Also fixes the two stale subsystem-14 blocks that still described a
PW-ADV-1-only 14-byte parser and hardcoded-MAC-only pairing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcdBRTn52QpCQWkyVm5Lkg
plan 0017: SensorEgg pairing menu + live-data test page
…, show the boot cause

Field report (2026-09): a new unit claimed its SD card was unformatted,
the on-device format reported OK and rebooted, and the same page came
straight back — until a full power cycle, after which the card mounted
fine with no further format. The format had worked; the boot after it
had not.

Root cause: the nRF52 hardware watchdog survives NVIC_SystemReset()
(only a pin, brown-out, power-on or System OFF reset clears it). Every
firmware-initiated reboot therefore hands the next boot a running ~4 s
WDT that setup() never fed until wdtSetup() at its very end. A clean
boot fits; a slow SD init (SdFat's 2 s ACMD41 timeout, three attempts)
does not, and the WDT reset the device mid-SD-transaction — leaving a
card that firmware cannot reset (CS grounded, no power switch) reading
as "card answers, no FAT volume" on every following soft boot.

- setup() now calls wdtBootCheck() first: a WDT already running is fed
  immediately (wdtCarriedOver), setup() pets between every slow step
  (display delays, each SD.begin() attempt, the probe settle, GPS
  probe, camera/egg/strip init — all no-ops on a clean boot), and
  wdtSetup() leaves a running, register-locked WDT alone.
- sdPerformFormat() requires the fresh volume to mount before "Format
  OK": a mount failure stays on the confirm page as "Formatted: no
  mount / Power-cycle the unit" instead of rebooting into an identical
  loop. sdFormatLastFailed becomes sdFormatFailure (NONE/ERASE/MOUNT).
- SD_SETUP() re-probes the volume once after a 250 ms settle and only
  declares the card unformatted on consecutive no-volume probes with
  the card layer answering both times; a card-layer flap is "dead"
  (FAULT, never an erase offer). The rule is the new host-tested
  sd_probe unit. The recovered-on-probe path also records the SPI
  clock it had been leaving at 0.
- wake_cause::shortName() puts the boot cause on the debug line, the
  format page's last line (boot:WDT err:20, with SdFat's error code)
  and the SD FAULT page, so a watchdog-induced loop is visible.

Sim: sd_probe added to the core sources, sdErrorCode()/sdErrorData()
stubbed on the SdFat shim, wdtBootCheck prototype added. Goldens
unchanged (the GPS status page is untouched).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhDTiCUTGJhV7XvogdU9CQ
…e-mlctto

SD format page: survive the WDT across a soft reset, verify the mount, show the boot cause
…ers)

parseDescriptor (strides by declared record_len — forward compat),
parseSampleFrame, parseClock, sampleToReal (sentinel -> NaN before
scaling), descriptor-driven role mapping (normative names + quantity,
never hardcoded channel ids), fastestChannel, and the v1 clock fit
(request/response midpoint anchor, boot_id epoch check, wrap-safe
mapping both directions). Fixtures are byte-identical to the egg
repo's pw_gatt_encode goldens — encode == decode pinned across repos,
the sensoregg_protocol <-> pw_adv_encode discipline applied to the
GATT contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcdBRTn52QpCQWkyVm5Lkg
bleCoreEnsureInit grows a flag-gated central slot (begin(1,1) +
configCentralConn(247, 6, 1, 1) — 7.5 ms event cap so the pod link can
never crowd the camera; stock builds stay begin(1,0)). The scan
callback doubles as the connect trigger (paired connectable report
while WAIT_ADV -> Central.connect, no resume on that path); the
central connect callback runs the blocking bring-up sequence in the
callback task (documented deviation — client ops each wait a conn
interval and would stall the 25 Hz row engine on the main loop):
discover, MTU 247, read Descriptor, timed Clock read (the fit anchor),
enableNotify, ready-flag-last. The notify data plane keeps the
copy-only discipline: an 8-slot frame ring drained by SENSOREGG_LOOP,
which parses, epoch-checks boot_id (mismatch -> log + reconnect
re-anchors), routes the latest sample per frame by descriptor-driven
role mapping into the SAME eggReading surface the beacon feeds, and
feeds the zombie monitor from the fastest channel's frame seq only.

Gate split keeps plan-0012 discipline uniform: eggLinkWanted = radio
gate + paired; eggScanWanted = radio gate && (pairing window || no
link engaged) — streaming pays no scan duty; capturing a NEW egg
drops the old link. SENSOREGG_SLEEP cancels/drops the central link
(covers transfers + shutdown; the quiesce settle gives it airtime).
EGG TEST: rf:GATT state + live MTU. Stale be80-era central prose in
bluetooth.{h,ino} fixed; subsystem-14 radio-role rewritten (the
camera-priority rule survives as mechanism, not prohibition).

Verified: host suite green (fixtures byte-identical to the egg's
pw_gatt_encode goldens), sim goldens unchanged, both flag arms
compile (ON 47%/36%, OFF 45%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcdBRTn52QpCQWkyVm5Lkg
plan 0018: SensorEgg GATT link — the logger connects to the paired egg
Review finding D1. Every drag_timer abort (fix gap, mid-run standstill,
prove-out) edges on a fix passed to onFix(), and the glue only feeds it
while gpsData.fix. A fix that dropped mid-pass and never came back left
the run LAUNCHED forever; in manual mode the tree sat in kRunning (where
the Select-hold exit is deliberately suppressed) on a pinned screen that
ate every button — a wedge with no way out but the reboot combo.

The root cause is that the physics has no notion of wall time, so the
fix lives there: DragTimer::checkFixLoss(nowMs, lastFixMs). The glue
records millis() whenever it feeds a fix and calls the watchdog every
loop; a staged or launched timer with no fix for kFixLossAbortMs (= the
in-stream kFixGapAbortMs) drops to ARMED and forgets its previous fix so
a returning fix starts a fresh stream rather than resuming a stale run.
The tree's existing "runActive fell without a run" rule then shows RUN
ABORTED, which accepts the exit hold. Keying on the last fix FED also
covers a receiver that stops streaming with its fix flag latched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
Review finding D2. dragStagingLoop() fed the tree in.fix = gpsData.fix,
and GPS_LOOP fed dragTimer->onFix() on a bare fix too, while the LED
search pip and the staging screen's WAITING FOR GPS line both required
fix && timeValid. So the tree could run its countdown while the screen
said it was waiting — and, worse, before the lock the receiver reports a
placeholder date, while drag timing is Unix epoch ms: the staged anchor,
the green stamp and the ET start all sat on a clock that jumps when the
lock lands (a backwards step silently aborts the run, a forwards one is
a garbage reaction time).

gpsFixAndTimeLocked() is now the single predicate for the physics feed
(automatic mode included), the tree input, the staging screen and the
LED search pip, so they cannot disagree again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
Review finding D3. The drag stage latch, the drag run and the sprint run
re-armed the auto-idle grace by rewriting raceSessionStartedAt, but
checkAutoIdle() returned early during the grace without touching an
idle timer that had already started. Creep below 5 mph long enough to
start the timer, then re-stage: the stale timer kept its old start, and
the session ended the moment the new grace expired rather than a full
hold later — exactly the staging-queue case the re-arm exists for.

The grace start and the idle timer now live together in
idle_policy::Clock. rearmGrace() is the only way to restart the grace
and always clears the timer; advance() holds the grace/reset/hold
sequence the sketch used to inline, so the whole thing is host-tested
(including the creep -> re-stage regression, camera yield and millis
wrap). The sketch keeps only millis() and the end-of-session effects.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
scanTrackDir() opens and ArduinoJson-parses every file under /TRACKS and
/TRACKS/SPRINT (up to MAX_LOCATIONS, 8 KB each at the 2 MHz SPI clock)
inside one call. setup() only pets before and after buildTrackList(), so
on a soft-reset boot — where the WDT carried over from the previous
session is already counting (#163) — a large track set or a slow card
could outlast the ~4 s deadline and reset the device mid-SD-read: the
exact card wedge #163 exists to prevent. The same walk re-runs after a
BLE track upload/delete with the WDT armed.

Feed it per directory entry. Audit of the other SD walks reachable at
boot or under the armed WDT:
- ensureDefaultSettings(): one full file round trip per key (~25) inside
  SETTINGS_SETUP() — now fed per key.
- buildReplayFileList(): capped by .dovex files FOUND, not entries scanned,
  so unbounded over a busy root — now fed per entry.
- fwCrcOfStageFile(): reads back up to a 408 KiB image in 512 B blocks
  in one loop() iteration — now fed per block.
- BLE LIST/TLIST already feed per entry; parseTrackFile, the course
  creator write and sdPlanSprintPrune are a single bounded file each.

wdtPet() is a plain register write, a no-op before the WDT runs, and is
already prototyped for the sim TU (sim_prototypes.h). No host test: the
pets are Arduino glue with nothing pure to extract.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
HAS_DEBUG builds block in setup() on `while (!Serial);` right after the
single wdtBootCheck() pet. After a soft reset the WDT carried over from
the previous session is already counting, so with no terminal attached
the wait outlived the ~4 s deadline and the device reset — into the same
wait, forever. Feed it inside the wait. No other unbounded busy-wait
sits on the setup() path (the NVMC READY spins in the one-time UICR
write are microseconds, and setup() pets around that step already).

Developer builds only; nothing shipped defines HAS_DEBUG.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
compile-sketch.yml said a master/release build needed "a DovesLapTimer
release tag carrying CrossingEngine/SprintTimer" before BETA could be
promoted, and justified the flags-off arm's BETA library ref with
"pinning the release tag would fail on BETA-era library APIs". Both
stopped being true with v4.3.0: its src/ is byte-identical to the
library's BETA branch (git diff v4.3.0 origin/BETA -- src is empty).
CLAUDE.md's Simulator table also said the sim's CMakeLists pins
DovesLapTimer BETA "(matches CI channel)", but it defaults
DOVESLAPTIMER_REF to v4.3.0 and sim-build.yml overrides it to BETA only
for BETA-targeted builds.

Stale pin notes are how the wrong library gets shipped, so reword them
to what is true and name every place the pin lives. Comments/docs only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
The release PR's CI never built what release.yml ships. Its `compile`
job correctly follows the SOURCE channel (BETA library + beta flags),
and the flags-off `compile-stock-arm` job linked the library's BETA
branch and built the Sense board only. So BETA source met the pinned
release library (v4.3.0) — and the non-Sense board met the flags-off
configuration — for the first time on the tag push, after the merge.

compile-stock-arm is now a matrix over both boards, and on the release
PR (head_ref == BETA) it links the release pin instead of BETA, with
exactly release.yml's extra_flags. On feature PRs into BETA and BETA
pushes it keeps the BETA library so the flags stay the only variable.
The Sense job keeps its old check name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
release.yml derives the OTA manifest's version from the git tag, but
the image reports FIRMWARE_VERSION from BirdsEye/project.h over BLE, and
nothing tied the two together. Tagging v4.2.0 on a tree whose project.h
still says "4.1.0" published a manifest no device could ever satisfy:
every unit would install the update, still report 4.1.0, and be offered
it again forever.

Add a first step to the build job, tag builds only, that extracts the
quoted literal from the non-override FIRMWARE_VERSION branch (asserting
exactly one exists), fails unless it equals ${GITHUB_REF_NAME#v}, and
also requires a `## [x.y.z]` CHANGELOG heading. It fails before the
compile, and release/pages need `build`, so nothing is published.
Manual (workflow_dispatch) runs have no tag and skip it. The version
number itself is unchanged.

Verified locally by running the step's script against v4.1.0 (pass),
v4.2.0 and v4.0.0 (version mismatch), and a patched tree at 9.9.9 with
no CHANGELOG heading (heading failure).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
Review finding D4. dragStagingLoop() stamped the green edge with
getGpsUnixTimestampMillis(), which is the time of the LAST PVT — the
green lands anywhere up to a nav period (40 ms at 25 Hz) after it. The
run start it is subtracted from is interpolated to fix-time accuracy,
so every reaction time read 0-40 ms high with sampling-phase jitter.

onPVTReceived() now records millis() at arrival (gpsPvtArrivalMillis)
and the green is stamped gps_time::epochNowMs(lastEpoch, arrival,
millis()); the subtraction moved to drag_tree::reactionTimeMs(). Both
are pure and host-tested, including millis wrap and the old bias. The
receiver's own output latency remains (not observable without PPS) and
is documented as a small constant residual.

The sim golden drag_staging_results changes: the same scripted pass now
reads RT 0.66 instead of 0.68. Regenerated from --print output (only
that one line differs).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
Review finding D5. The manual tree's exit effect called endRaceSession()
directly. endRaceSession() deliberately never touches the camera (a tach
session's recording must outlive a stationary grid idle), so every
ender that owns the camera notifies it first — and the LOGGING STOP
confirm did, while the drag exit did not: a paired Insta360 kept
recording after the user explicitly ended the session.

Both user-initiated enders now go through endRaceSessionByUser()
(CAMERA_NOTIFY_SESSION_END, then endRaceSession), so the pair cannot
drift apart again. Pure glue with no decision to extract; covered by the
sim build/walk (camera surface stubbed there).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
Review finding D6. NEOPIXEL_LOOP()'s paceValid became true once one
run existed and the next was live, and drag's pace accessor is
hard-wired 0.0 (there is no reference to pace against). From run 2 on,
an automatic pass therefore got the pace pip parked in its deadband —
one dim centre pixel — instead of the RPM/speed scale run 1 had. Plan
0016 had patched this for manual drag only (!dragManualActive()).

The gate is now led_modes::paceValid(PaceGate), pure and host-tested,
with hasPaceReference = !dragModeIsActive() — covering both drag modes
by construction rather than by a per-mode exception.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
The central disconnect callback runs in a higher-priority task than the
main loop. If the egg dropped right after enableNotify(), the callback
could set BACKOFF and invalidate the handle before the loop consumed
eggBringupReady — and the loop then overwrote BACKOFF with STREAMING.
STREAMING only acts when the handle is valid and counts as "engaged", so
the scanner stayed stopped and EGT read NaN until shutdown or a
transfer.

The commit is now conditional (state still BRINGUP and a handle still
held) and is checked and applied inside one critical section; a
BRINGUP/STREAMING state with no handle is reconciled to BACKOFF every
loop as a backstop. Both rules live in sensoregg_gatt (LinkState +
linkMayCommitStreaming / linkReconcileOrphan) with host tests. The
connect callback and SENSOREGG_SLEEP() clear stale ready/failed flags so
a bring-up staged before a sleep can never be consumed against a later
link's buffers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
Review finding D7. In automatic mode a car stopped in the shutdown area
stages, and driving the return road back at 20-30 mph then clears the
rollout, the 15 mph prove-out and the target distance — a slow bogus
run in lapHistory and the DOVEX laps line.

A completed run is now discarded (rejectedRuns() counts it) only when
BOTH hold: its launch->finish chord points more than 90 deg from the
last recorded run's (a strip runs one way, the return road the other),
AND its trap speed is below kCruiseTrapRatio (1.2) x its average speed.
The ratio is the physics of a pass — from a standstill at full effort
trap/average is 2.0 at constant acceleration, 1.5 at constant power,
and above ~1.3 even for a car that tops out early — while a drive that
settles into a cruise sits near 1.0-1.15. Requiring both keeps it
conservative: the first run of a session, every same-direction run and
every full-effort run in either direction are kept. A trap floor or ET
ceiling was rejected because karts and brisk return-road drives overlap
on both. Rationale recorded in plan 0015.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
SENSOREGG_SLEEP() cancels a pending connect and disconnects a known
handle, but there is a window where the SoftDevice has already raised
CONNECTED while the deferred central connect callback has not run: the
handle is still INVALID and connect_cancel() is a no-op. The callback
then ran the full bring-up after sleep, and in BLE transfer mode or the
charging park nothing ever reconciled it — the egg link held the radio
for the whole session.

The connect callback now keeps a connection only when it is the one we
asked for (state CONNECTING), the sleep gate is down and the link is
still wanted (linkAcceptCentralConnect, host-tested); otherwise it
disconnects that handle without adopting it (a CONNECTING state is
retired to BACKOFF instead of waiting out the timeout). That also covers a
connect landing after the 10 s connect timeout already moved to
BACKOFF. The disconnect callback now ignores any handle that is not
eggConnHandle, so a refused connection can't drive the state machine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
Review finding D8. Staged, a car creeping at 1-2 mph (above the 1 mph
staging threshold, below the 2 mph launch speed) could cover the whole
11.25 in rollout without launching; when it then passed 2 mph the
launch edge found the rollout already behind it (dPrev >= kRolloutFt),
the interpolation clamped f to 0, and the ET started at the previous
fix with the crept ground credited to the run's distance — a short ET.

A STAGED car moving above the staging threshold that passes the rollout
without launch speed now drops to ARMED. If it stops, it re-stages at
the new spot a second later and the next launch times exactly (the new
test also shows the old code mistimed that case through the anchor
mean's lag); if it rolls straight into a launch there was no standing
start and nothing is recorded. The rule is gated on Doppler speed, so
standstill position jitter past the rollout radius still cannot
un-stage a parked car.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
The GATT stream writes into the same Reading the beacon does, but only
into the roles the descriptor maps, while every frame refreshes the
shared eggRxMs. So tcFault, the aux temperature and the battery kept
whatever the last beacon said for as long as frames kept coming: a
stuck *TC FAULT*, and a pod with no IAT channel logging a flat line into
Temp2 — exactly what the never-hold rule forbids. Zombie detection only
watches the fastest channel, so an EGT channel that stopped while CJ
continued also held EGT.

On STREAMING commit the surface is now reset (readingResetForStream:
temps NaN, battery 0xFF, flags/status/tcFault/pairing false, seq 0;
protoVersion kept) and the sequence monitor restarts. Each mapped role
gets its own receive stamp (RoleFreshness) and every value accessor
also requires its role to be fresh while the stream was the last
writer; a beacon parse hands the gate back to the plain 1 s rule.

The per-role window is the channel's own frame cadence plus one frame
of slack (2 x n x interval), floored at kStalenessMs and capped at 60 s:
a flat 1 s rule would flap the EGT pod's 1 s IAT channel and blank its
30 s battery forever, while EGT/CJ keep exactly the 1 s rule. All of it
lives in sensoregg_gatt with host tests. eggRoleLive() takes a uint8_t
because the auto-prototype lands above the sensoregg_gatt include.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
The scan callback ignored Bluefruit.Central.connect()'s return. A
refused request (radio busy, bad params) never produces a connect
callback, so the link sat in CONNECTING for the full 10 s timeout, then
5 s of backoff — with the scanner still paused on the report that
triggered the connect, so the beacon path could not fill in. That is
15 s of NaN EGT per refusal.

On false the state now goes straight to BACKOFF (linkAfterConnectRequest,
host-tested), the backoff clock is stamped, and the scanner is resumed
from the callback when the scan is wanted — the same callback-safe
resume the beacon path already does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
The notify ring had 32-byte slots, enough for the current egg's <=26-byte
frames but not for the spec's 117-sample maximum: any frame over 11
samples was counted in eggFrameDrops and discarded while the link stayed
STREAMING and looked healthy, and nothing ever read the counter.

Slots are now kMaxFrameLen (244 = ATT_MTU 247 - 3, the largest notify
the link can carry at the MTU cap we configure), still 8 deep: ~1.9 KB
instead of 256 B, justified in the comment — the depth rides out a
main-loop stall at the stream's frame rate and the extra RAM is small
next to the track-JSON buffers. The drop counter is exposed
(sensoreggFrameDrops(), no-op twins in the flag-off build and the sim)
and rendered on EGG TEST as D<n>. A new host-tested DropMonitor drops
the link after drops in 3 consecutive 1 s windows so the beacon path
takes over; one SD-stall burst is a single window and never trips it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
The battery role routed the raw int16 straight into the percent,
assuming scale 1.0 / offset 0 — true of the EGT pod, but the whole
point of the self-describing descriptor is that a future pod need not
match. A pod declaring deci-percent would have read 870 -> "unknown".

The frame value now goes through sampleToReal() like every other role,
then the new host-tested batteryPercent(): sentinel/NaN -> 0xFF,
otherwise clamped to 0-100 and rounded. NaN is detected with isNanF()
because the device build is -Ofast, where isnan() folds to false and
NaN comparisons are undefined.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
Capture is persist-first: a failed setSetting() leaves the pairing
window open so a later frame retries. But "a later frame" was every
matching beacon, ~10 Hz — a full SD read-modify-write of SETTINGS.json
per frame, for up to the 2-minute window, against a card that had just
failed a write. That is the worst possible load for a struggling card
and stalls the main loop on every beacon.

Attempts now go through sensoregg_protocol::pairPersistDue(): the
window's first attempt is immediate, later ones at least
kPairPersistRetryMs (1 s) apart, wrap-safe and host-tested. Requesting
a new window re-arms the immediate first attempt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw
Review flagged clockFitPodToLogger() and ClockFit::halfRttMs as having
no firmware consumer. That is true and intended — the surface is
latest-value-only and DOVEX rows are stamped by the GPS clock — but
nothing said so, so the API read as dead code. The tested API stays
(it is the groundwork for per-sample row timestamping); sensoregg_gatt.h,
sensoregg.h, the sketch's eggClockFit and plan 0018 now say plainly that
only the boot_id epoch check is consumed today and the mapping is
reserved for the resampling follow-up. No sketch state was dead: the
anchored fit carries the epoch the reboot check uses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw

This branch has not been deployed

No deployments
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