Repository navigation
Home
A standalone GPS lap timer for track days: a Waveshare ESP32-S3 board with a 600×450 panel, a microSD card, a QMI8658 IMU and a u-blox NEO-M9N receiver. It runs a session end to end — countdown, overrun, rest — records lap and G data to the card, and survives a power cycle.
Using the device? Start with the User Manual - every screen and every setting, walked through.
The rest of this wiki holds orientation and the things that are easy to get wrong. The repository holds the authoritative detail; every link below points into it.
| Subsystem | State |
|---|---|
| Display, touch, gesture UI | working on hardware |
| microSD card, settings, session lifecycle | working, survives power cycle |
| IMU, gyro-tracked attitude, G recording | working |
| Session logging and Review | working |
| GNSS | working on hardware. 20 Hz accepted fixes, every frame passing the quality gate |
| Lap timing engine, lap state machine | wired to the fix queue and running on hardware. Fixes reach the timing task, lap events drain back |
| A timed lap | not yet. No circuit on the card has validated geometry, so the engine never arms (#139, #184) |
GNSS works. As of 13 October 2026 the receiver is connected over I2C, configured by the firmware at boot, and delivering accepted fixes on hardware: 20 Hz as configured, 49 frames, 49 accepted, 0 rejected, on 15 satellites.
The critical path has moved. What stands between this device and a lap time is no longer hardware — it is that nothing consumes the fix queue. The timing engine, lap state machine and logger are all built and host-tested and have still never seen a fix. That is software, and it is next.
Getting here cost two bugs worth knowing about, both found only because real hardware was attached. See the traps.
Track geometry is further along than it was. Two definitions can arm the timing engine — Donington Park GP and National — against 22 in the UK pack that cannot, plus a road-loop test fixture. See the provenance gate below for why that number is small on purpose.
56 issues are open and 61 closed. The largest open group is M1 — Display board bring-up with 11, mostly board and UI work. M2 — GNSS bring-up holds only 4, which understates it: those four are the critical path, and most of what follows waits behind them.
make venv # builds .venv from the pinned requirements-dev.txt
make check # 45 host test targets, schema validation, repo hygienemake venv matters. The Makefile uses .venv/bin/python whenever it exists, so it does not
need activating, and installing the requirements into whatever python happens to resolve
to is a documented way to break the suite silently — see
the traps.
make firmware-container-build # pinned ESP-IDF v6.0.2 container, no local toolchain
make firmware-container-flash # flash the attached board
make firmware-capture-boot # capture a cold boot log
make uk-track-pack # build the UK circuit pack
make test-track-pack # build the functional test fixtures
make simulator-test # 72 host tests; the simulator itself is frozenThe simulator is no longer developed. It lags the device and cannot exercise the gesture-driven interaction model the firmware uses, so time spent on it does not buy confidence in the product. Its host tests are still run and still useful.
Each of these was discovered the hard way and cost real time.
gnss: transport attached over i2c means only that the I2C driver accepted an address —
i2c_master_bus_add_device never talks to the chip. For most of this project that line was
the only thing the firmware said about the receiver, which made it easy to read as
confirmation it was fitted and answering. It never was.
The stage lines that follow it are the ones that report a receiver, and they name the cause rather than collapsing everything into "no fix". Details on the Hardware page.
UBX-NAV-PVT has a nano field that reads exactly like a sub-millisecond correction to the
time of week. It is not: it is the fractional part of the UTC second in the same
message's date and time fields, and it ranges a full second either way. The time of week
already carries its own sub-second part, so adding the two double-counts the fraction.
Folded into the measurement timestamp, it made time run backwards — 760 ms into the past on frames with zero checksum errors. Two symptoms followed that looked unrelated: about half of every epoch refused as non-monotonic, and a derived fix rate of exactly half the configured one. The receiver had been delivering its configured rate throughout.
The test asserted the bug. test_ubx_parser.cpp expected the stray term to be present,
because it was written from the same misreading as the code — so the test agreed with the
defect rather than catching it. A zero-initialised payload has nano of zero, which is why
a realistic fixture would have failed on day one and this one could not.
#193.
The M9N ships emitting NMEA, and this firmware parses UBX. The symptom is bytes arriving, nothing parsing, and zero checksum errors — which is confusing, because a stream of garbage sounds like it should produce checksum failures. It does not: a checksum is only reached after a sync pair, and neither NMEA nor a corrupted bus contains one.
So the counters cannot tell a healthy misconfigured receiver from a wiring fault, and the two have nothing in common as fixes. The firmware now logs the first bytes as text for exactly this reason: NMEA reads as a sentence, a corrupted bus does not.
On USB-Serial-JTAG, RTS is wired to EN (reset) and DTR to GPIO0 (boot select). pyserial asserts both when it opens a port, so pulsing RTS alone releases reset with GPIO0 still held low: the chip comes up in the ROM download stub, which prints nothing at all. An empty capture then reads as "the output was discarded" when it means "the application never ran".
make firmware-capture-boot does it correctly. A genuine power cycle cannot be captured
this way at all — the USB device is inside the chip, so cutting power destroys the port the
listener is holding. An RTS reset is a chip reset and the handle survives it. Background in
#181.
The on-card layout is /sdcard/track-packs/<pack_id>/definitions/<track_id>.json, and
<pack_id> is a directory name the firmware does not interpret — it scans whatever
directories contain a definitions subdirectory and takes each definition's identity from
its filename.
So copying a new pack into a new directory does not replace the old one, it doubles it: the same track ids twice, against a 32-entry catalog. The surplus is reported as truncation and those circuits are simply absent from the driver's list. The only symptom is a wrong count on the selection screen.
device-captured is reserved for geometry captured on the device, so wiping a distributed
pack never deletes a capture.
jsonschema enforces a format keyword only when a checker for that format is installed,
and says nothing when one is not. Without rfc3339-validator, format: date-time is not
checked, so provenance.verified_utc — the field the track provenance gate reads — accepts
any string at all. The failure does not stop the validators running; it makes them agree
with you.
make check refuses an interpreter that cannot validate what the suite asserts. This is
why the versions are pinned and why make venv exists.
A pull request body containing the words "does not close #18" still closes #18 on
merge. GitHub's parser sees the keyword and the number and ignores the prose around them.
Use Refs #N unless the PR genuinely completes the issue — and an issue with hardware
acceptance criteria that software cannot satisfy is not complete, however finished the code
is.
Track definitions carry a geometry status on a four-rung ladder:
provisional → device_captured → independently_validated → physically_validated
Only the upper three arm the timing engine. A provisional circuit can be selected and
named on the dashboard, and the device will run a session timer, but it will not produce a
lap time.
That gate exists because the geometry was not merely unverified — it was wrong. The first circuit put through validation, Donington Park, had its start/finish gate on a way named Melbourne Loop (up): 82 m from the actual pit straight, with a heading 162° out. A car crossing the real line would never have triggered it. Checking the rest of the pack the same way found four more gates on the wrong feature, including a rallycross track and a different circuit on the same site.
So the gate was not protecting against imprecision. It was the only thing standing between unverified geometry and confident, wrong lap times.
Promotion by desk research requires two sources of distinct origin agreeing within 10 m, with a status and a date. Two readings of the same database are one source read twice. The pack build fails rather than shipping a promotion that does not meet this. Full process in docs/TRACK_DATABASE.md.
| Read this when | Document |
|---|---|
| Setting up to build, flash, or capture a boot log | DEVELOPMENT_SETUP.md |
| Submitting work, or wondering why a convention exists | CONTRIBUTING.md |
| Working on track geometry, packs, or promotion | TRACK_DATABASE.md |
| Contributing a circuit | TRACK_CONTRIBUTIONS.md |
| Touching GNSS, fix quality, or lap timing | GNSS_AND_LAP_TIMING.md |
| Changing screens or driver-facing text | NAVIGATION.md, UI_UX.md, UI_FOUNDATION.md |
| Wiring, power, antenna, or enclosure | Hardware on this wiki, then HARDWARE_INTEGRATION.md and HARDWARE_BOM.md |
| Understanding a structural decision | docs/decisions/ |
| Checking what is claimed to be tested | TEST_PLAN.md |
| Understanding the limits of a GPS lap timer | SAFETY_AND_LIMITATIONS.md |
| Blocked on | Issues |
|---|---|
| Nothing consuming the fix queue — the timing engine is not wired to it, so no lap can be timed however good the fixes are. Software, and the critical path now | #18, #139 |
| Driving a circuit — one lap time per lap, heading discrimination, lane position, a lap readable in Review | #184, #53 |
| Reading the board's label — the I2C socket's pin order, now used successfully but still never read | #9 |
Two questions are recorded rather than answered, because both are behaviour choices rather than tidy-ups:
-
Should a provisional circuit be selectable at all?
ui::TrackSelectionController::confirmrefuses one and persists nothing, while the path that actually runs on the device persists it and runs timer-only, so the driver keeps the circuit name. The same question, answered two ways. #187 -
Should the Diagnostics GNSS page itself distinguish a bus fault from an absent
receiver?
SubsystemStatehas no vocabulary for the difference and widening it would change a contract the simulator shares, so the distinction is drawn in the log instead — in six named stages. That is arguably the better answer, but it is not the one #178 asked for.
-
C++17,
snake_casefunctions,PascalCasetypes, explicit units in names (speed_mps,h_acc_m) — and say what a distance is measured between, not only its unit. A total width and a centreline-to-centreline separation are not comparable. -
Two clock domains are never compared:
measurement_time_nsis receiver time-of-week,arrival_monotonic_usis MCU monotonic. Lap events use GNSS time; MCU durations use monotonic time. - Bounded queues, bounded work per poll, and loss counted by cause. A drop that is counted is a diagnosis; a drop that is hidden is a mystery.
- The session countdown never depends on GNSS, SD, Wi-Fi or any optional subsystem. A timer that stops working because a receiver is unplugged is not a timer.
-
New tests belong in
make check, as host targets. Anything that does not genuinely need LVGL should run without it.