Repository navigation
Beta 4.2.0 - drag timer - #160
Open
TheAngryRaven wants to merge 44 commits into
Open
TheAngryRaven wants to merge 44 commits into
TheAngryRaven wants to merge 44 commits into
Conversation
… 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
Coverage — host-testable units📂 Overall coverage
📄 File coverage
|
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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcdBRTn52QpCQWkyVm5Lkg
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
6 of 14 tasks
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
6 of 14 tasks
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
6 of 14 tasks
…wt-egg SensorEgg: GATT link and pairing review fixes (plans 0017/0018)
…wt-sd SD watchdog follow-ups + release-build guards
…wt-drag Drag mode review fixes (plans 0015/0016)
This branch has not been deployed
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.
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)
lapHistory, laps line in the DOVEX header). Header getsrace_mode=DRAGand a course name likeDRAG 1/8 MILE; same backwards-compatible trailing-column scheme as SPRINT.drag_timerpure unit (tests/drag_timer_test.cpp); review fixes (time base, launch guards, unit hygiene) in 7e7dd93.Drag mode — manual christmas tree (plan 0016, #161)
drag_treepure unit (tests/drag_tree_test.cpp). New sim golden fixtures cover the drag pages.Boot / SD hardening (#163)
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, andwdtSetup()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 (newsd_probepure unit). A card that stops answering between probes goes to FAULT, never an erase offer.SD.format()the volume must mount before "Format OK"; otherwise the page saysFormatted: no mount / Power-cycle the unitinstead of reboot-looping.boot:WDT err:20);wake_causegains short names and a watchdog cause.Docs (#159 and the feature PRs)
Beta channel only (
BIRDSEYE_ENABLE_SENSOREGG=1; compiled out of the release image)SensorEgg pairing menu + EGG TEST page (plan 0017, #162)
sensoregg_macsetting and applied live; paired page offers Back / Test / Unpair (persist-first, like the camera). Unpaired = accept any egg, as before.sensoregg_protocol.SensorEgg GATT link (plan 0018, #164)
boot_idreboot 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.Bluefruit.begin(1, 1)with skinny central parameters; the camera link still wins.SENSOREGG_SLEEP()drops the pod link for transfers and shutdown.sensoregg_gattpure unit.Type of change
race_mode=DRAGis a new value in an existing trailing header column.How it was verified
ctest --test-dir tests/build) — new suites:drag_timer,drag_tree,sd_probe,sensoregg_gatt, plus additions tosensoregg_protocolandwake_causeclang-tidyclean (static analysis check green)Checklist
CHANGELOG.mdupdated under[Unreleased](if user-visible)ARCHITECTURE.md/CLAUDE.mdupdated (if a module or interface changed)tests/Before tagging v4.2.0
FIRMWARE_VERSION "4.2.0"inBirdsEye/project.h(still4.1.0on BETA — the OTA manifest takes its version from the tag, so a mismatch makes the companion offer the update forever)## [Unreleased]to## [4.2.0] - <date>inCHANGELOG.mdv4.3.0(itssrc/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