Skip to content
PurpleSentinel edited this page Oct 11, 2026 · 6 revisions

Track Session Timer GPS

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.

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.

Where the project actually is

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 built and host-tested, never met a receiver
Lap timing engine, lap state machine built and host-tested, never seen a fix

GNSS is the critical path. The whole chain is in the firmware and linked in — an I2C transport, a UBX parser, a fix-quality gate, and a pipeline that counts every way a fix can go missing — and the boot log confirms gnss: transport attached over i2c. But no part of it has met a real receiver. The receiver and antenna are in hand; the Qwiic cables are not. Until they arrive the device is a session timer that cannot time a lap.

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.

Start here

make venv          # builds .venv from the pinned requirements-dev.txt
make check         # 45 host test targets, schema validation, repo hygiene

make 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 frozen

The 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.

Things that will bite you

Each of these was discovered the hard way and cost real time.

The I2C socket's pin order is unknown

This is the one step that can destroy a board. The Waveshare has two physically identical SH1.0 4-pin sockets. The UART block's rear label reads UART, GND, 3v3, TX, RX. The I2C block's pin order has never been reported. If its power ordering differs, a board-to-board cable puts 3.3 V onto a data line. Read the label before plugging anything in. Tracked in #9, with the wiring it affects on the Hardware page.

Capturing a boot log needs DTR held low, or you get silence

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.

Updating the card means overwriting the pack directory it already has

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.

A missing Python dependency widens what the validators accept

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.

Closing keywords cannot be negated

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.

The provenance gate is not bureaucracy

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.

Document map

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

What is blocked, and on what

Blocked on Issues
A Qwiic cable — satellite count climbing to a fix, the gap counter tripping on-device, a promoted circuit arming the engine #178, #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 — whether GPIO43/44 are broken out, and the I2C socket's pin order #181, #9

Open decisions

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::confirm refuses 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
  • Is a USB-UART adapter on UART0 still wanted? It is the only way to capture a boot following a genuine power cycle, which is now the sole case the scriptable capture cannot reach. #181

Conventions worth knowing

  • C++17, snake_case functions, PascalCase types, 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_ns is receiver time-of-week, arrival_monotonic_us is 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.

Clone this wiki locally