Skip to content

plan 0002 §5: on-device course creator — walk the cones, no laptop - #128

Merged
TheAngryRaven merged 5 commits into
BETAfrom
claude/sprint-run-log-parsing-a6x07o
Aug 4, 2026
Merged

plan 0002 §5: on-device course creator — walk the cones, no laptop#128
TheAngryRaven merged 5 commits into
BETAfrom
claude/sprint-run-log-parsing-a6x07o

Conversation

@TheAngryRaven

Copy link
Copy Markdown
Owner

Summary

Main menu → Create → pick the track you're at (or start a new one) → Circuit or Sprint → capture each timing line by standing at the cone and holding for three seconds. Writes /TRACKS[/SPRINT]/N260803_1432.json, or appends a course to the track you're standing at.

This is plan 0002 §5 — "the big ask". Autocross venues re-lay their course every event, so a sprint course had to be authorable at the event; the alternative was a laptop in a paddock. Circuit courses come along for free (same lines, one fewer to walk). It was deliberately sequenced last because the generated names are meant to be renamed in the web app afterwards, and that side needed to exist first.

Two pure units carry everything decidable without hardware. course_creator owns the model — row table, required-vs-optional lines, save validation, the point-averaging hold, name generation. track_json is the firmware's first track-JSON writer (everywhere else the format is read-only). The sketch is left with rendering, GPS and SD, glued in the way gps_status_page / sd_format_page already are — pages in display_pages, routing in display_ui, state in BirdsEye.ino — rather than adding a module for one feature.

Points are averaged, not snapshotted. Three seconds at 25 Hz is ~75 fixes, and the user is standing at the cone anyway, so the accuracy is free. A hold gathering fewer than 8 usable fixes fails rather than averaging noise into a timing line; fixes worse than 10 m are dropped; fixes arriving after the window are ignored so a mean already shown can't shift underneath the user.

Save is refused with the reason on the row, never a button that silently does nothing. Beyond the obvious required lines, two rules exist purely so a course this device writes stays editable in the web app: 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 we write that the app then refuses to save is worse than one never written.

Also: Back is a real undo (line edits are scratch-then-commit), and appends rename a temp file over the original so a power loss mid-save can't leave a truncated track file behind — this runs in a field, on a battery, at an event.

Type of change

  • New feature / behavior
  • Bug fix / Refactor / Tests only / CI / Breaking change

Not breaking: additive menu entry, additive page constants, and the emitted JSON is the object format parseTrackFile() already reads.

How it was verified

  • Host unit tests pass — 342 cases / 4469 assertions, 72 of them new (course_creator_test.cpp, track_json_test.cpp)
  • Simulator: all 6 ctest targets pass, including both lap oracles and the two-session carryover. The sim compiles the same .ino sources Arduino concatenates, so it's a real compile check for every file this PR touches.
  • Five new golden fixtures walk the actual menus, inject actual PVT, 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. I eyeballed the dumped PNGs — that's how I caught a header overflowing the 21-char panel.
  • clang-tidy clean — not run locally (CI covers it)
  • Compiles for the XIAO nRF52840 Sense — not verified locally; arduino-cli isn't available in this environment, so CI's compile-sketch + flash-size gate is the first real toolchain check. Worth watching, since this adds two .cpp units and ~200 lines of page rendering.
  • Tested on real hardware — not done. This is the PR you asked for to go test it. Suggested first pass: walk a circuit course (start line only) and confirm the file lands and shows up in the browser; walk a sprint course with start + finish + one split; append a second course to an existing track and confirm the original survives; try Save with a missing finish line and confirm the row says need finish.

Checklist

  • CHANGELOG.md updated under [Unreleased]
  • ARCHITECTURE.md / CLAUDE.md updated — new subsystem 15, file map, page constants, key constants
  • New testable logic has a matching test in tests/
  • Branch is focused — five commits, one concern each, all citing the plan

Notes for reviewers

Two things departed from the spec, both recorded in the new plan §5.1.

  1. Names are N{YYMMDD}_{HHMM}, not NEWTRACK_{date}. §5 specified the prefix and, in its own design notes, flagged that the 13-char track browser (MAX_LOCATION_LENGTH) would truncate it — so every same-day creation would render identically on-device, which defeats picking the right one. This resolves that note rather than shipping the papercut: 12 chars, unique to the minute, chronologically sortable, still obviously machine-generated. A new track's shortName is MMDDHHMM — exactly the webapp's Track.shortName budget and half of the (kind, shortName) key its device-sync merge uses, so two tracks walked the same day can't collide there either. These two formats are the contract the webapp's import flow should match, which is why §5.1 calls them out under a heading aimed at it.

  2. A new global, gpsPvtSequence. The averaging hold needed "is this a new PVT sample?" and gpsDataFresh cannot answer it — GPS_LOOP() consumes that flag earlier in the same loop iteration, and gpsData holds its last value between updates. Un-gated, the hold folded one fix in ~250 times a second and reported a confidence the fix never had. (gpsFrameCounter is no help; it zeroes every second for the frame-rate maths.) The sim caught this — the capture simply never committed — which is a decent argument for the golden fixtures existing.

track_json builds text by hand rather than through ArduinoJson, which looks like the wrong call until you hit the coordinate formatting: this core has no working %f in snprintf (the sketch reaches for dtostrf everywhere for exactly that reason) and dtostrf doesn't exist on the host. formatFixed does it with integer math instead — identical on both targets and testable to the last digit. Same reasoning as gps_time's hand-rolled u64ToDecimalString.

One shim change: the sim's SdFat 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.

Plan status moved CONCEPT → SHIPPED. All three repos have now landed their phase. The two deliberately-deferred items stay open and are tracked in the webapp's plan 0015: Android IPC TS* parity (gated on that app's release) and the §2 sync-prune. Worth noting this PR makes the second one matter more — every event walked appends a course to a file the device re-parses through a 4 KB budget.

Related issues

Completes docs/plans/0002-sprint-mode.md §5. Cross-repo counterpart: DovesDataViewer plan 0015 (PRs #375–#379).


Generated by Claude Code

claude added 5 commits August 3, 2026 18:01
…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
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Coverage — host-testable units

📂 Overall coverage

Metric Coverage
Lines 🟢 1162/1184 (98.1%)
Functions 🟢 122/122 (100.0%)
Branches 🟡 858/962 (89.2%)

📄 File coverage

File Lines Functions Branches
BirdsEye/camera_fsm.cpp 🟢 222/230 (96.5%) 🟢 20/20 (100.0%) 🟡 126/144 (87.5%)
BirdsEye/course_creator.cpp 🟢 209/217 (96.3%) 🟢 21/21 (100.0%) 🟡 114/131 (87.0%)
BirdsEye/crc32.cpp 🟢 30/30 (100.0%) 🟢 4/4 (100.0%) 🟢 24/24 (100.0%)
BirdsEye/crossing_pattern.cpp 🟢 15/15 (100.0%) 🟢 1/1 (100.0%) 🟢 12/12 (100.0%)
BirdsEye/dovex_header.cpp 🟢 106/107 (99.1%) 🟢 7/7 (100.0%) 🔴 62/88 (70.5%)
BirdsEye/filename_validator.cpp 🟢 14/14 (100.0%) 🟢 1/1 (100.0%) 🟢 30/30 (100.0%)
BirdsEye/gps_stats.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 8/8 (100.0%)
BirdsEye/gps_status_page.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 24/24 (100.0%)
BirdsEye/gps_time.cpp 🟢 45/45 (100.0%) 🟢 6/6 (100.0%) 🟢 30/32 (93.8%)
BirdsEye/gps_validation.cpp 🟢 24/24 (100.0%) 🟢 2/2 (100.0%) 🟢 66/66 (100.0%)
BirdsEye/haversine.cpp 🟢 8/8 (100.0%) 🟢 1/1 (100.0%) ⚫ 0/0 (0.0%)
BirdsEye/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/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/sensoregg_protocol.cpp 🟢 44/45 (97.8%) 🟢 7/7 (100.0%) 🟢 33/34 (97.1%)
BirdsEye/sprint_select.cpp 🟢 25/25 (100.0%) 🟢 4/4 (100.0%) 🟢 46/48 (95.8%)
BirdsEye/tach_filter.cpp 🟢 15/15 (100.0%) 🟢 3/3 (100.0%) 🟡 7/8 (87.5%)
BirdsEye/track_json.cpp 🟢 116/120 (96.7%) 🟢 12/12 (100.0%) 🟡 67/88 (76.1%)
BirdsEye/wake_cause.cpp 🟢 14/14 (100.0%) 🟢 2/2 (100.0%) 🟢 20/20 (100.0%)

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