Beta - autocross and more - #113
Open
TheAngryRaven wants to merge 54 commits into
Open
Conversation
Master 2 beta
The egg now broadcasts a 16-byte v2 payload (aux intake-air thermistor
appended at bytes 14-15, real battery percent in byte 11, version 0x02).
This brings the logger up to speed while keeping v1 eggs working.
Parser (sensoregg_protocol, host-tested):
- Accepts v1 AND v2; v1 parses auxC as NaN, so a mixed fleet works.
- A frame claiming v2 but shorter than 16 bytes is corrupt, not v1 -
rejected rather than mis-parsed.
- Reading gains auxC + protoVersion; battery is documented real.
- New golden v2 fixture is byte-identical to the egg repo's
pw_adv_encode fixture - the wire contract pinned from both ends.
- The scan-tuning test's pinned egg adv interval was stale at 160
units; the egg de-aliased to 179 (111.875 ms) - pin updated,
invariants still hold.
RX path (sensoregg.ino):
- The double-buffer captured a fixed 14 bytes, so v2's bytes 14-15
never reached the parser. Buffers now size kPayloadLenMax with
per-slot lengths.
- New accessors with the same staleness/zombie gating as the EGT:
sensoreggAuxC() (NaN when stale/hung/v1/sentinel) and
sensoreggBatteryPct() (0xFF when unknown). Flag-off stubs and sim
module_stubs updated in lockstep.
Display:
- New Temp2 race page after Temp1 (page constants shifted inside the
BIRDSEYE_ENABLE_SENSOREGG arm only): same big-number layout and
staleness rules, subtext shows the egg battery percent ('--' when
unknown) since the thermistor has no cold junction.
DOVEX:
- Temp2 trailing column appended (same backwards-compatible mechanism
as device_name and Temp1/Junction1): aux temp in C, literal "nan" on
stale/v1/invalid, never skips a GPS row, written on every build so
the format does not fork by channel. Sim oracle still parses the
hardware-recorded fixture (first 13 columns positional - unaffected).
Fixed - isnan() compiled out by -Ofast:
- The platform builds sketches with -Ofast (-ffinite-math-only), which
constant-folds isnan() to false. The Temp1 page rendered
lroundf(NaN) garbage ("-214748") instead of '---' on a stale link;
the DOVEX temp columns survived only because dtostrf(NaN) emits a
string the numeric guard rejects into the same "nan" fallback. Egg
paths now use isNanF() (nan_bits.h, IEEE-754 bit-pattern check the
optimizer cannot fold) - same fix the egg firmware shipped.
Docs: CHANGELOG under [Unreleased] (MINOR - backwards-compatible
column append), CLAUDE.md subsystem/file-map/format sections,
ARCHITECTURE.md subsystem bullet, README data-format section (which
was also missing the existing Temp1/Junction1 columns - now current).
Verified: host suite green (v2 golden, truncation gates, v1 compat);
sim builds and all 6 sim tests pass (goldens unchanged - boot/menu
pages only; oracle parses the recorded .dovex fixture); compiles for
xiaonRF52840Sense with the BETA flag set and with defaults.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4WQVFAkryxYqTNmniVL7i
SensorEgg PW-ADV v2: Temp2 page, DOVEX Temp2 column, -Ofast isnan fix
Coverage — host-testable units📂 Overall coverage
📄 File coverage
|
Concept/research doc for a new point-to-point 'sprint' race mode (separate start and finish lines, runs instead of laps) alongside the existing circuit behavior: race_mode device setting, /TRACKS/SPRINT folder, SprintTimer in DovesLapTimer BETA, firmware session-lifecycle changes, DataViewer work last. No code changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
…ENSE System OFF entry hardcoded the tach wake pin as SENSE-LOW, assuming the line idles high under the pull-up. The tach pickup's Schmitt-inverter + optocoupler output stage can idle low, in which case DETECT is satisfied the instant System OFF is entered and the device wake-resets within a second whenever it sleeps on battery. USB sleep was unaffected because a present cable parks in the charging loop and never enters System OFF, and runtime RPM counting can't expose the polarity (a spark pulse yields exactly one falling edge either way), so only battery sleep showed it. Shutdown now samples the parked tach line (15 reads over ~30 ms) and arms SENSE for the opposite of the observed idle level. The majority vote lives in the host-tested wake_cause unit (tachIdleIsHigh); a tie or floating input resolves to idle-high, preserving the original SENSE-LOW arm. Buttons stay fixed SENSE-LOW (active-low by wiring). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNr2KMMP1yjhPgdaUXHmGt
…reboot-srcygo Fix battery-sleep reboot loop: sample tach idle level before arming SENSE
User report: heats of ~4 runs with 30-45 s queue stops, long break between heats, X4 left recording per heat (~20 min videos). Pins the session model to session = heat, matching the existing camera 30 s engine-off auto-stop; run boundaries never touch the camera or end the session. Auto-idle change downgraded to a safety margin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
…per-session Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
… trailing column) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
Move firmware-ota-phase0 -> plans/0000, insta360-ble-current- implementation -> plans/0001, sprint-mode-concept -> plans/0002 (chronological by original commit date), add the plans README, and fix the doc-path references in CLAUDE.md and CHANGELOG.md. Plan numbers now act as lightweight ticket numbers, cited from commit messages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
…or; plan 0003: RPM spark/cylinder settings Fold in the latest user decisions: no race_mode device setting (mode follows the detected track's folder), sprint courses selected by newest date_created (same venue, new course every event), start+finish + up to 2 optional splits (single split legal), and the full on-device course-creator UI spec (no text entry; auto-named NEWTRACK/NEWCOURSE files). Adds design notes: ISO timestamp for date_created, point-capture averaging, first track-JSON writer, 4KB-buffer pruning question, both-kinds-in-range tiebreak. New plan 0003 splits out the spark_mode + cylinder_count settings for true-RPM display/logging. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
…ync-prune, single-source RPM - date_created: ISO timestamp decided (same stamp in generated names) - point capture: 3 s hold, ~75 fixes averaged, h_acc gating - pruning: webapp sync keeps last single day of courses on device (sync -> delete -> push new file); v1 may ship first with disclaimer - race_mode setting returns as the both-kinds-in-range tiebreak only: circuit default yields to a sprint course created today; sprint always prefers the newest sprint course (fixed rally layouts) - plan 0003: true RPM computed once before the Kalman (tuning stays in true-RPM units), tachLastReported is the single source of truth Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
… restarts, DNF is normal op Session arming unchanged (RPM ~1 s brings systems up). Runs are purely line-driven: start crossing begins a run and cancels/restarts any run in progress (botched-course re-launch); finish completes; finish ignored while no run active. DNF records nothing special. Notes the direction-agnostic backward-crossing nuance and the self-healing property of the restart rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
…ing* on lap/pace pages), keep 'laps' verbiage Design complete: all open questions resolved. Between runs the device remains in normal race mode with every page live; Current Lap and Pace show *waiting* while no run is active. Lap wording kept everywhere (AX drivers call them laps) - no relabeling, no page-constant churn, DOVEX laps_ms line unchanged; the race_mode header column alone informs the webapp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
…pes-wt9s7o plan 0002: sprint mode (autocross) concept + numbered docs/plans structure
sprint_select: newest-course-by-date_created ordering (sortable ISO strings, ties resolve to the later file entry) + the circuit-vs-sprint tiebreak decision table (race_mode pref; circuit yields to a sprint course created today). dovex_header: race_mode trailing column (CIRCUIT/SPRINT, empty = circuit, same append mechanism as device_name) and the line-2 splitter now preserves empty middle fields - the strtok version let a blank column shift every later column left (regression test included). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
…imer backend - /TRACKS/SPRINT folder (auto-provisioned; circuit tracks untouched); manifest entries carry their folder kind; buildTrackList scans both via the new scanTrackDir(folder, kind); makeFullTrackPath is kind-aware; FILEPATH_MAX 50->64 - parseTrackFile: track-level "type", per-course finish_* lines and date_created (containsKey idiom, same as sectors) - trackDetectionLoop: nearest entry PER KIND; race_mode setting (new, default circuit) breaks both-in-range ties via sprint_select, with the event-day heuristic parsing the sprint file's newest course date - sprint path skips CourseManager/CourseDetector: createSprintSession() picks the newest course and stands up the library's SprintTimer (start + separate finish + optional splits); sprintTimer != nullptr IS sprint mode, all activeTimer*() helpers duck-type runs as laps - lifecycle: run-count-edge history capture (identical consecutive run times are normal at autocross - value-change dedupe would drop them), each run re-arms the auto-idle grace, sprint idle is engine-aware (running engine at the start line never ends the session) - DOVEX header: sprint course name + race_mode=SPRINT - display: Current Lap / Pace show *waiting* between runs; 'laps' verbiage kept everywhere by design - sim: SprintTimer/CrossingEngine/sprint_select compiled into the TU, prototypes mirrored; all 6 sim tests (incl. lap oracles) green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
… fix) The beta image burst the 320 KB OTA self-flash cap (330,628 B, 100.9%) - and was already at 98.2% before sprint mode. Root cause: the DovesLapTimer library's debug pipeline is gated by a RUNTIME if(_serial) check, so ~150 debug call-sites and their F() strings stay resident in flash on hardware builds that never attach a debug Stream. DovesLapTimer#48 (BETA) adds the DOVES_DISABLE_DEBUG compile-time kill switch; this passes it in compile-sketch/beta/release so the dead pipeline is dropped (~6-8 KB), bringing the image back under the cap with real headroom instead of moving the OTA staging layout. IDE debug builds are unaffected (flag is per-build opt-in; documented in CONTRIBUTING). Requires DovesLapTimer#48 merged to BETA before the compile-sketch gate can pass (CI pulls the library's BETA branch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
merge) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
…pes-wt9s7o plan 0002: sprint mode core — /TRACKS/SPRINT, mode-by-folder, SprintTimer backend, DOVEX race_mode
…e escape hatch) Rebooting with GPREGRET=0x57 (DFU_MAGIC_UF2_RESET, stock on the Adafruit/Seeed bootloader) brings the device up as a USB drive; copying a .uf2 flashes the app region directly - no image-size cap, no staging region, no web/BLE streaming. Deferred to the main loop like every FW command; acknowledges FWDFU:OK before the reboot, cleanly aborts any in-flight OTA first, and uses the same sd_power_gpregret SV-call pattern as the apply path's recovery flag. Groundwork for the SD-staged OTA rework: fleet units that take this update always have a size-unlimited update path over a USB cable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
… design) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
FWDFU pre-update + plan 0004 (SD-direct OTA staging spike) — BETA test vehicle
The two 'calculating' frames were hand-stored 1 KB PROGMEM bitmaps, but decoding them shows both are pure block patterns: eight 16x16 px cells confined to the odd 16 px row bands, with the two frames offset by one cell so alternating them scrolls sideways. Storing 2048 bytes of flash to say that is a bad trade. They are now emitted by the new host-tested crossing_pattern unit and drawn with fillRect(). Equivalence is proven, not assumed: the original 2 KB is pinned as goldens in crossing_pattern_test.cpp, which rasterizes the generated rectangles and memcmps all 128x64 pixels of both frames. The bird splash is untouched - it is real artwork, not a pattern. Reclaims 2,048 B of flash on an image that sits at 99.4% of the OTA cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
…attern Generate the crossing animation instead of storing 2 KB of bitmaps
Sprint courses live in /TRACKS/SPRINT since plan 0002, but the BLE track commands all spliced /TRACKS - the new folder was only reachable over USB mass storage, which makes sprint mode unusable from the web app. Adds TS-prefixed twins of the four track verbs. They share the circuit code paths through a kind parameter (trackFolderFor()) rather than duplicating handlers, so the flash cost is a few hundred bytes on an image already at the OTA ceiling. TSLIST answers with its own TSFILE:/TSEND tokens so a sprint enumeration can't be mistaken for a circuit one; TSGET/TSPUT/TSDEL reuse the existing replies. Security posture is unchanged and deliberate: filename_validator still rejects '/', '..' and FAT-unsafe bytes on every track command, and the target folder is chosen by the OPCODE, never parsed from the wire - a client cannot path between the two folders. Circuit paths resolve byte-identically to before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
plan 0002 (PR B): BLE sprint-track sync — TSLIST / TSGET / TSPUT / TSDEL
compile-sketch resolved LAPTIMER_REF / FEATURE_FLAGS from base_ref and ref_name only. On the long-lived BETA -> master integration PR (#113) base_ref is master and, on a pull_request event, ref_name is the merge ref -- so BETA's source was compiled against master's channel config: DovesLapTimer pinned to v4.2.0 (no CrossingEngine/SprintTimer) and the SensorEgg flag off. Both board jobs failed on BirdsEye.ino:45:10: fatal error: SprintTimer.h: No such file and would have kept failing for as long as that PR stays open. Add head_ref == 'BETA' so a PR whose *source* is BETA builds with BETA's library ref and flags. Feature PRs into BETA (base_ref) and pushes to BETA (ref_name) are unchanged, as is every master/release build. The fallbacks stay pinned: promoting BETA to master still requires bumping them deliberately, which is now called out in the comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
3 tasks
…pes-wt9s7o ci: pick the build channel from the source tree, not just the target
…venly
Plan 0004, the half that needs no hardware spike.
The app and the OTA staging region share one 820 KiB stretch
([0x27000, 0xF4000) = 839,680 B) and BOTH must be able to hold the image:
the incoming one is staged up top, then copied down over the app. So the
largest installable image is half the span. The split was lopsided --
320 KiB staging against 500 KiB of app region -- which capped OTA at
320 KiB while leaving ~180 KiB of app region no legal image could ever
reach. The beta build was sitting at 99.0% of that cap with 3,308 B free.
Move the staging base 0xA4000 -> 0x8E000 for an even, page-aligned split:
staging [0x8E000, 0xF4000) = 417,792 B = 408 KiB (FW_MAX_IMAGE_SIZE)
app [0x27000, 0x8E000) = 421,888 B = 412 KiB (>= the cap)
Two constants. No change to the apply sequence, the FW* protocol, the CRC,
or the web app -- the client never enforced a cap of its own, it relies on
the device's FWERR:SIZE. Beta image goes 99.0% -> 77.6%, 93 KiB free.
Safe for the existing fleet: staging is chosen at apply time from the
INSTALLED firmware's constants, nothing about it is baked into the image
being delivered, so a unit on 3.0.x stages at the old 0xA4000 and installs
this build normally. And the new app region ends below the old staging
base, so an image built for this layout can never collide with an old
unit's staging region.
Add static_asserts for page alignment and app-region fit -- both were
silent invariants a future constant edit could have broken, and getting
either wrong means erasing live code or accepting an un-installable image.
CI gains a second, non-fatal check: an image past the legacy 320 KiB can
no longer be installed by units still on the old layout, which would need
the USB FWDFU -> UF2 path instead. Warn so a fleet split is visible rather
than discovered in the field.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
feat: raise the OTA image cap 320 KiB → 408 KiB by splitting flash evenly
…path map Three updates to the OTA plan now that piece 2 (the even flash split) is merged on both channels and flashed clean on hardware: - Park piece 3 (SD-direct apply) to the END of the project. It is the highest-risk change in the OTA path -- a raw SD driver running with the SoftDevice disabled, interrupts off, from RAM, erasing the app region as it goes -- and the headroom crunch that justified rushing it is gone (93 KiB free against a beta image that grew ~7.5 KiB for all of sprint mode). Do it when the bench is free and nothing else is in flight. - Record the follow-up this raised: DovesDataViewer needs an upgrade-path MAP. Piece 2 created the first case where not every firmware can install every other one -- a 3.0.x device enforces the old 320 KiB cap, so a larger build must reach it over USB rather than OTA. Today that lives only in a CI warning; the web app will offer any release to any device and let it fail late at FWBEGIN. Scheme deliberately left undecided; candidates and their tradeoffs are listed, along with the prerequisite that historical builds stay fetchable (believed true for master releases, explicitly NOT for the latest-only beta channel). - Correct a wrong claim: the plan said no .uf2 was published, so FWDFU left you in UF2 mode with nothing to drag. Both release.yml and beta.yml already build and stage a per-board .uf2. The escape hatch was whole all along. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
docs(plan 0004): park SD-direct apply to end of project; note the DDV upgrade-path map
CLAUDE.md documented the metadata line as
datetime,driver_name,course_name,short_name,best_lap_ms,optimal_lap_ms,...
but dovex_header.cpp:55 emits
datetime,driver,course,short_name,best_lap_ms,optimal_ms,...
Three of the eight names were wrong. This matters because the parser on the
webapp side is key-mapped, not positional: DovesDataViewer's dovexParser reads
`driver` / `course` / `optimal_ms` off the header row by name, so it matches
the CODE. Anyone trusting the doc would have "fixed" the parser to look for
names that never appear and silently blanked those fields for every log.
Found while surveying the viewer for sprint-mode work (DovesDataViewer plan
0015, which records the discrepancy on its side too).
README.md's copy had the right names but predates race_mode, so it stopped one
column short. Add it, and extend the example row to match with CIRCUIT. Also
drop the now-misaligned "(column labels)" / "(session metadata)" annotations
from those two lines -- the content says what they are; laps_ms on line 3 does
not, so it keeps its gloss.
dovex_header.h:11 was already correct and is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb
docs: fix the DOVEX header column names to match what is emitted
…riter
Two pure units for the on-device course creator (§5). Everything that can
be decided without hardware is decided here, so the sketch is left with
rendering, GPS and SD.
course_creator owns the model: which rows each screen shows, which lines
a course type requires, whether the course may be saved yet, the
point-averaging hold, and name generation. Navigation INPUT is left to
the sketch's existing menuSelectionIndex/menuLimit machinery — the unit
supplies the row count and interprets the chosen index, which avoids
reimplementing a menu the firmware already has.
Two save rules are about the webapp, not this device, and are worth
naming: circuit sectors are all-or-nothing (its validator wants zero or
exactly three majors), and sprint splits fill in order (it re-exports
them positionally, so a lone sector 3 returns as a sector 2 after one
sync). A course this device writes and that app then refuses to save is
worse than one never written.
Capture averages rather than snapshots — the user is standing at the cone
anyway. A hold that gathers fewer than eight usable fixes FAILS instead
of averaging noise into a timing line, fixes worse than 10 m are dropped,
and fixes arriving after the window are ignored so a mean already shown
to the user cannot shift underneath them.
Names are "N{YYMMDD}_{HHMM}". §5 proposed a literal NEWTRACK_ prefix and
noted in the same breath that the 13-char track browser would truncate it
— every same-day creation would then render identically on-device, which
defeats picking one. Favouring the timestamp resolves that note: unique
to the minute, chronologically sortable, still obviously generated. The
8-char short name is exactly the webapp's Track.shortName budget and half
of the (kind, shortName) key its sync merge uses.
track_json emits the object format parseTrackFile() already reads. It
builds text by hand because the coordinate formatting is the hard part
either way: this core has no working "%f" in snprintf (the sketch reaches
for dtostrf everywhere for that reason) and dtostrf does not exist on the
host. formatFixed does it with integer math instead — identical on both
targets and testable to the last digit, the same reasoning behind
gps_time's hand-rolled u64ToDecimalString.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
sdSaveCreatedCourse lives with the rest of the track file I/O. A new track is one emitted object written straight out; an append is a read-modify-write through the existing 4 KB trackJson document. The append serializes to <file>.tmp and renames over the original only once it is closed. Rewriting in place would mean a power loss or a yanked card mid-serialize leaves a truncated file where a working track used to be — and this runs in a field, on a battery, at an event. Both on-disk shapes are appendable: the object format's "courses" array and the legacy bare array, which IS the course list. Two refusals rather than a silent half-success: past MAX_LAYOUTS the device would write a course it then never loads, and an ArduinoJson overflow means the file would no longer fit the parse budget on the next boot. buildTrackList() re-runs after a successful write, since a course missing from the manifest does not exist as far as proximity detection is concerned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
The sketch side of the course creator: five page constants, the live model instance, GPS feeding, and the renderers. Glue is distributed the way gps_status_page and sd_format_page already are — pages in display_pages, routing in display_ui, state and helpers in BirdsEye.ino — rather than adding a module for one feature. Every row rendered comes from course_creator::rowAt() instead of a local list, so a row cannot display in one order and act in another. The Save row says WHY it is refused rather than being a button that silently does nothing. Entry needs a fix and a time lock, and refuses at the menu: every screen past the prompt needs GPS to capture and the clock to name the file, so failing here beats failing after a whole course has been walked. Feeding the averaging hold needed a new monotonic gpsPvtSequence. gpsDataFresh could not do it — GPS_LOOP() consumes that flag earlier in the same loop iteration, and gpsData holds its last value between updates, so an un-gated feed would have folded one fix in ~250 times a second and reported a confidence the fix never had. gpsFrameCounter is no help either; it zeroes every second for the frame-rate maths. The line-menu header abbreviates the course type so the track name survives whole — 21 characters at size 1, and a track name may use 13 of them. The name is the part that answers "am I adding this to the right track?". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
The sim compiles the same .ino sources the Arduino build concatenates, so it is the only compile check this repo has outside CI — and with PVT injection it can drive the creator for real rather than just build it. Five new fixtures walk the actual menus, inject actual fixes, run an actual 3 s averaging hold, and lock the rendered pixels: the no-GPS refusal, the type picker, an empty line menu with Save refused, the line detail, the idle capture screen, and the line detail again with point A captured. That last one is the one worth having — it proves the hold completes and commits through the real loop, which is exactly the path the gpsPvtSequence fix was about. The existing camera fixtures moved because Create Course took index 3 on the main menu; their hashes are unchanged. main_menu_transfer's hash moved because the menu gained an item. The SdFat shim gains rename(), which the append path needs. It refuses to clobber an existing destination like the real SdFat does, so the firmware's remove-then-rename ordering stays load-bearing in the sim too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
Adds subsystem 15 to CLAUDE.md (file map, page constants, key constants), the ARCHITECTURE subsystem entry, and the CHANGELOG feature entry. Plan 0002 gains a §5.1 recording what was actually built and, more usefully, where it departs from the spec and why: the name format (resolving §5's own note that the browser truncates a NEWTRACK_ prefix), the two webapp-compatibility save rules, scratch-then-commit line edits, the failable capture hold, the temp-file append, and the new gpsPvtSequence global. The generated name and short-name formats are called out for the webapp's import flow, which is being designed now — they are the contract it should match. The plan's status moves from CONCEPT to SHIPPED: all three repos have landed their phase. The two deliberately-deferred items (Android IPC parity, sync-prune) are noted, with the observation that the creator makes pruning matter more — every event walked appends a course to a file the device re-parses through a 4 KB budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
…ing-a6x07o plan 0002 §5: on-device course creator — walk the cones, no laptop
"FIX (time sync)" parses as a KIND of fix — a time-only, position-less one — when it meant the opposite: the position fix is good and the clock isn't ready yet. So a healthy device looked broken. That cost a bench session today, and the instinctive response to it, a power cycle, is actively harmful: it restarts the ~12.5-minute UTC decode being waited on. The line now reads "FIX ok UTC..", and line 3 names the outstanding milestone instead of leaving the user with nothing to wait for: "UTC: no date/time", then "UTC: resolving <=12m" once date and time are valid but fullyResolved is not. That second state is the slow, normal one — the leap-second parameters live in nav-message subframe 4 page 18, which repeats every ~12.5 minutes, so a clean 3D fix minutes ahead of timeValid is expected rather than a fault. The bound is worst case, not an estimate; seeing a number at all is what stops the power-cycling. Nothing is lost: the constellation readout keeps that line once the clock is locked, so the diagnostic only takes the space while there is something to diagnose. gpsData gains the two halves of timeValid so the page can tell them apart, and the three-way classification is a pure timeSyncState() in gps_status_page rather than branching in the renderer. One of its tests asserts kLocked agrees with timeValid across all four input combinations — a page claiming a lock while logging still waits would be the same confusion wearing a different hat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
"FIX (time sync)" parses as a KIND of fix — a time-only, position-less one — when it meant the opposite: the position fix is good and the clock isn't ready yet. So a healthy device looked broken. That cost a bench session today, and the instinctive response to it, a power cycle, is actively harmful: it restarts the ~12.5-minute UTC decode being waited on. The line now reads "FIX ok UTC..", and line 3 names the outstanding milestone instead of leaving the user with nothing to wait for: "UTC: no date/time", then "UTC: resolving <=12m" once date and time are valid but fullyResolved is not. That second state is the slow, normal one — the leap-second parameters live in nav-message subframe 4 page 18, which repeats every ~12.5 minutes, so a clean 3D fix minutes ahead of timeValid is expected rather than a fault. The bound is worst case, not an estimate; seeing a number at all is what stops the power-cycling. Nothing is lost: the constellation readout keeps that line once the clock is locked, so the diagnostic only takes the space while there is something to diagnose. gpsData gains the two halves of timeValid so the page can tell them apart, and the three-way classification is a pure timeSyncState() in gps_status_page rather than branching in the renderer. One of its tests asserts kLocked agrees with timeValid across all four input combinations — a page claiming a lock while logging still waits would be the same confusion wearing a different hat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
fix: say which GPS milestone is outstanding on the status page
…ing-a6x07o fix: say which GPS milestone is outstanding on the status page
Reported from the bench: exiting the course creator dropped straight into race mode. The reporter's own guess was right — they were on a bike, above the 10 mph auto-race trigger, when they hit Cancel. autoRaceModeCheck() guarded only on "are we on the main menu", with no notion of WHEN we got there. displayLoop() switches the page at the end of one loop iteration and autoRaceModeCheck() runs at the top of the next, so a deliberate exit became "start racing" about four milliseconds later. The menu is never drawn; the device appears to act on its own. This was always true for every page, but the creator is what made it reachable: it is the one screen you use out on the course, on a vehicle that may well be rolling, and the save path lands on the menu too — so finishing a walked course could immediately start a session. Auto-race now requires the menu to have been settled for AUTO_RACE_MENU_GRACE_MS, anchored on the newest of the menu-arrival stamp and the three button lastPressed values. Using the button stamps as well as arrival means actively navigating the menu at speed defers it too, which is the same "a human is driving the UI, not the vehicle" signal. They persist across iterations (the menu-idle block relies on this already), so the guard doesn't care where in loop() it runs. The normal auto-race path is untouched: a device parked on the menu has been quiet for minutes before anyone drives off. The cost is up to three seconds of a session that starts by leaving a menu — and only when the user was pressing buttons moments earlier. switchToDisplayPage() is a safe place to stamp arrival: the direct `currentPage =` assignments elsewhere are all race-page rotation clamps, never the menu. The golden walk now exits the creator at 15 mph, which reproduces the bug exactly — with the guard removed the fixture fails "expected page -1, got 5" as the firmware logs "Auto-entering race mode". Verified by removing it. It coasts back to 0 mph afterwards, since gpsData holds its last value between PVTs and a latched 15 mph would trip auto-race once the window expired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
fix: don't let auto-race hijack the menu the instant you land on it
Reported from the bench: setting a course-creator point while parked near an existing timing line showed the crossing flags over the creator screen. The overlay was gated by a BLOCKLIST — six pages it must not draw over — which meant it drew over everything else by default. That list never grew as pages were added, so the camera pages, the replay browser, the transfer menus, the main menu and (newest) the course creator all inherited it. And the trigger is not rare: the crossing zone reads true while STATIONARY inside it, which is precisely the state of someone standing at a timing line using the device. Inverted to a positive test. The running rotation is a contiguous id block, so "is this a racing page" is a range check; the two diagnostic pages at the bottom and the stop-logging page at the top stay excluded exactly as before, and everything outside the block — negative menu ids, the 90+ confirm/warning/fault pages, the 900+ boot pages — is now excluded by construction rather than by remembering to list it. Strictly more restrictive than the old condition on every input. The new golden fixture parks on the OKC start/finish line with the track detected and locks that the menu still renders as a menu. It is NOT a regression test for this bug and says so: a true crossing flag needs an ARMED timer, and OKC ships eight courses so CourseDetector never locks one without driving a real lap. Confirmed by restoring the old blocklist — the fixture does not budge. The gate itself is argued from the page-id ranges. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
…ce-pages fix: show the crossing animation only on racing pages
The pickup counts ignition sparks and the tach treated one spark as one revolution. That is only true for a single cylinder firing every rev — a 2-stroke, or a 4-stroke with wasted spark — which is the common kart case and why it has been fine. Anything else is out by a fixed factor: a twin firing every rev reads DOUBLE the real speed. Two settings fix it. pulses_per_rev = cylinder_count x (wasted ? 1.0 : 0.5), and the reciprocal is applied at the period->RPM conversion in TACH_LOOP — BEFORE the Kalman filter, because the filter's tuning is in true-RPM units (Q = 800 RPM^2 models crank inertia), so correcting afterwards would filter each engine type differently. The correction point already existed and was already in the right place; it was just hardcoded to 1.0. Defaults (1 cylinder, wasted) give exactly 1.0, so a device that has never been configured reads identically to before. Anything other than an explicit "single" degrades to wasted, so a blank, garbled or future value reads as today rather than doubling every RPM. THE DEBOUNCE HAD TO FOLLOW. A fixed 3 ms gap caps ~20,000 pulses/min, which on a twin firing every rev is only ~10,000 real RPM — the debounce would have become the ceiling. minPulseGapUs() derives it as 3 ms / pulses-per-rev. The floor is 750 us, not the 1.5 ms the plan sketched: 1.5 ms still left a triple at ~13,300 true RPM, under the old ceiling. 750 us holds the full ~20,000 through four cylinders. ISR headroom was never the constraint (<1 us body, ~1300 int/s worst case) — ringing was, and the margin holds from both ends: the input is RC-filtered ~100 us and the documented pickup circuits emit pulses MILLISECONDS wide, TACHOMETER/README.md recording circuit 1's 5 ms pulse as itself the ~9800 RPM limit on that hardware. The audit plan 0003 asked for came back clean: nothing else derives RPM from pulse periods. The only other 60e6 in the tree is the simulator's pulse generator, which is the inverse and matches the default. Knock-on: the RPM thresholds the device acts on — auto-race entry, camera wake/record/stop — now mean what they say on every engine. Verified end to end in the simulator, not just at the unit level: 6000 pulses/min reports 6000 RPM at the defaults and 3000 with revsPerPulse forced to a twin. Golden fixtures and the lap oracle are unchanged, which is the evidence existing devices are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
clang-tidy caught bugprone-incorrect-roundings on (uint32_t)(gap + 0.5f) — a real finding, not a false positive: that idiom rounds incorrectly for negatives and is a known bug class. lroundf would have silenced it, but the float was never needed. Pulses-per-rev is either `cylinders` (wasted spark) or `cylinders / 2` (single-fire), so the base gap divides exactly in both cases once the single-fire case is written as a doubled numerator. Integer throughout keeps float rounding out of a value the ISR compares against on every pulse, and drops a libm call from the firmware. Same numbers as before, so the tests are unchanged and still pass: 3000 / 1500 / 1000 / 750 for one through four cylinders wasted, 6000 for a single-fire single, floored at 750. Verified with the exact CI invocation locally — clang-tidy is clean across all eleven analyzed units, not just this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
plan 0003: true RPM from spark mode and cylinder count
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.
No description provided.