Lap Tracking
Lap timing state lives entirely inside Model (TouchGFX/gui/src/model/Model.cpp) and is advanced once per Model::tick(), using the elapsed wall-clock time since the previous tick and the current GPS speed from can_input_data.gps_angles.GPS_Speed.
State
| Field | Type | Purpose |
|---|---|---|
isTimingActive | bool | Whether a lap is currently being timed |
currentLapTimeMs | uint32_t | Elapsed time in the lap in progress |
currentDistance | float | Distance covered in the lap in progress, integrated from GPS speed |
currentLapProfile[MAX_LAP_DISTANCE_M] | uint32_t[] | Elapsed time recorded at each whole-meter distance bucket, for the in-progress lap |
fastestLapTimeMs | uint32_t | The best organic (GPS-timed) lap so far. Sentinel 0xFFFFFFFF means "no fastest lap yet" |
fastestLapProfile[] | uint32_t[] | Distance profile of the fastest organic lap, used for the live per-meter delta in BEST mode |
manualFastestLapTimeMs | uint32_t | A driver-entered target lap time, set from lap_data_screen. Same sentinel convention |
manualLapMode | bool | Whether the delta shown to the driver compares against fastestLapTimeMs (BEST) or manualFastestLapTimeMs (MANUAL) |
ui_data.currentLapTimeMs and ui_data.liveDeltaMs are the two fields the UI actually reads (via updateUIData(), every frame, since lap time changes even when no CAN data has changed).
The distance-indexed profile and live delta
As the lap progresses, currentLapProfile[floor(currentDistance)] is stamped with the current lap time the first time each meter bucket is entered. If GPS updates skip a meter (speed high enough that more than one bucket is crossed between ticks), the skipped buckets are backfilled by linear interpolation between the last recorded bucket and the new one, so the profile stays dense even at low update rates.
Each time a new bucket is first entered, and only in BEST mode with a valid organic fastest lap on record, ui_data.liveDeltaMs is updated to currentLapTimeMs - fastestLapProfile[distIndex]: a live, continuously updating gap to the fastest lap's time at the same point on track. In MANUAL mode, this per-meter update is skipped entirely; the delta shown to the driver is a single static number set once, at the end of the previous lap, comparing that lap's total time to manualFastestLapTimeMs (see triggerLapComplete() below). MANUAL mode is for comparing against a fixed target time, not a point-by-point ghost.
Lap completion and reset
Two entry points drive the state machine, and both are meant to be triggered externally, not from inside Model:
triggerLapComplete(): called once per finish-line crossing. If a lap was already in progress, it scores that lap (updatesliveDeltaMsagainst whichever mode is active, and always checks whether it's a new organic fastest lap regardless of mode) and then immediately starts timing the next lap. The first call after a reset has no previous lap to score and simply starts lap one; this is also how "start line cross" is represented, there's no separate start event.resetLapData(): stops timing, clears both profiles and the fastest-lap sentinel, and zeros the UI-facing fields. This is the driver's "reset lap data" action fromlap_data_screen.
The CAN integration gap
Model::tick() calls these two functions from a pair of flags, and nothing else:
if (ui_data.lap_completed_flag)
{
ui_data.lap_completed_flag = 0;
triggerLapComplete();
}
if (ui_data.lap_reset_flag)
{
ui_data.lap_reset_flag = 0;
resetLapData();
}ui_data.lap_completed_flag and ui_data.lap_reset_flag are declared in UI_Data_t (data_structs.h) specifically as the external trigger surface for this state machine. Nothing in this firmware currently sets either flag. This is intentional, not an oversight: the dashboard has no way to know where the finish line is on its own, and is expected to receive a lap-complete (and, less critically, lap-reset) signal over CAN from whichever board owns that detection. On this car, that's most likely Telemetry, which has GPS and does its own finish-line crossing logic for its local lap counter; but as of this writing, no CAN message for this exists yet in MAIN_DBC.dbc, on either the Dashboard or the Telemetry side.
How to wire it up once a message exists
- Coordinate with whoever owns lap/finish-line detection to define a message and add it to
MAIN_DBC.dbc(a single-bit or single-byte signal is enough: a rising edge or a nonzero value on each finish-line crossing). - Regenerate or hand-add the corresponding
CAN_MSG_..._IDand struct incan_signal_defs.h, matching the pattern of the existing messages. - Add an FDCAN filter entry for the new ID in
fdcan.c(see CAN; rememberStdFiltersNbrneeds to grow if you run out of dual-filter slots). - Add a
caseto theswitchinprocess_can_frames()(can_comm.c) that setsui_data.lap_completed_flag = 1;(and, if a separate reset signal is added,ui_data.lap_reset_flag = 1;in its own case), following the same shape as the existingCAN_MSG_GIL_INV_DATA_2_ID/CAN_MSG_GIR_INV_DATA_2_IDcases added for TS temperature ingestion.
That's the entire integration surface. Model::tick() already does the rest: it picks the flags up on the very next tick, at most one frame of latency, and clears them itself.
Debounce on the sender side, not here
Nothing in Model::tick() debounces lap_completed_flag. If the sending board can produce the trigger more than once per actual crossing (bouncing GPS geofence edge, retransmitted frame, and so on), debounce it there. Every set of the flag here is treated as a real crossing.
Simulator mode
Under the SIMULATOR build define, Model::tick() bypasses the flag mechanism entirely and drives triggerLapComplete() directly on a synthetic 1000 m lap with simulated GPS speed, purely so the UI can be exercised without hardware or a CAN bus. That path is not representative of the real trigger contract described above.
