plan 0002 §5: on-device course creator — walk the cones, no laptop - #128
Merged
Conversation
…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
Coverage — host-testable units📂 Overall coverage
📄 File coverage
|
This was referenced Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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_creatorowns the model — row table, required-vs-optional lines, save validation, the point-averaging hold, name generation.track_jsonis 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 waygps_status_page/sd_format_pagealready are — pages indisplay_pages, routing indisplay_ui, state inBirdsEye.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
Not breaking: additive menu entry, additive page constants, and the emitted JSON is the object format
parseTrackFile()already reads.How it was verified
course_creator_test.cpp,track_json_test.cpp).inosources Arduino concatenates, so it's a real compile check for every file this PR touches.clang-tidyclean — not run locally (CI covers it)arduino-cliisn't available in this environment, so CI'scompile-sketch+ flash-size gate is the first real toolchain check. Worth watching, since this adds two.cppunits and ~200 lines of page rendering.need finish.Checklist
CHANGELOG.mdupdated under[Unreleased]ARCHITECTURE.md/CLAUDE.mdupdated — new subsystem 15, file map, page constants, key constantstests/Notes for reviewers
Two things departed from the spec, both recorded in the new plan §5.1.
Names are
N{YYMMDD}_{HHMM}, notNEWTRACK_{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'sshortNameisMMDDHHMM— exactly the webapp'sTrack.shortNamebudget 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.A new global,
gpsPvtSequence. The averaging hold needed "is this a new PVT sample?" andgpsDataFreshcannot answer it —GPS_LOOP()consumes that flag earlier in the same loop iteration, andgpsDataholds 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. (gpsFrameCounteris 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_jsonbuilds text by hand rather than through ArduinoJson, which looks like the wrong call until you hit the coordinate formatting: this core has no working%finsnprintf(the sketch reaches fordtostrfeverywhere for exactly that reason) anddtostrfdoesn't exist on the host.formatFixeddoes it with integer math instead — identical on both targets and testable to the last digit. Same reasoning asgps_time's hand-rolledu64ToDecimalString.One shim change: the sim's
SdFatgainsrename(), 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:DovesDataViewerplan 0015 (PRs #375–#379).Generated by Claude Code