Skip to content

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

FieldTypePurpose
isTimingActiveboolWhether a lap is currently being timed
currentLapTimeMsuint32_tElapsed time in the lap in progress
currentDistancefloatDistance 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
fastestLapTimeMsuint32_tThe 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
manualFastestLapTimeMsuint32_tA driver-entered target lap time, set from lap_data_screen. Same sentinel convention
manualLapModeboolWhether 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 (updates liveDeltaMs against 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 from lap_data_screen.

The CAN integration gap

Model::tick() calls these two functions from a pair of flags, and nothing else:

cpp
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

  1. 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).
  2. Regenerate or hand-add the corresponding CAN_MSG_..._ID and struct in can_signal_defs.h, matching the pattern of the existing messages.
  3. Add an FDCAN filter entry for the new ID in fdcan.c (see CAN; remember StdFiltersNbr needs to grow if you run out of dual-filter slots).
  4. Add a case to the switch in process_can_frames() (can_comm.c) that sets ui_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 existing CAN_MSG_GIL_INV_DATA_2_ID/CAN_MSG_GIR_INV_DATA_2_ID cases 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.

Released under the MIT License.