Telemetry Model
Everything the node knows about the car lives in one place: a single global struct called telemetry_data, defined in telemetry.h and owned by telemetry.c.
extern telemetry_t telemetry_data;It is a flat struct of raw values: hundreds of fields grouped by source board, gateway front/rear, ECU, BMS, LVSOC, left/right inverters, dashboard, IMU, and GPS.
Raw in, raw out
Fields are stored as decoded-but-unscaled values. Filtering, unit conversion, and interpretation happen downstream (in the pit software), not on the node.
The problem: many writers, many readers
Several threads write different parts of the struct, and several threads read the whole thing, all concurrently:
- Writers: CAN RX, GPS, Lap.
- Readers: SD Buffer, UDP, CAN Periodic TX.
A reader must never observe a half-written struct: for example a GPS latitude from one fix paired with a longitude from the next. A mutex would serialize every reader against every CAN interrupt, which is too expensive at these rates. So the node uses a seqlock.
The seqlock
The struct carries two sequence counters:
volatile uint32_t seq; /* main write domain (CAN + timestamps) */
volatile uint32_t gps_seq; /* GPS write domain */The rule is simple: even = stable, odd = a writer is mid-update.
A writer brackets its update with begin/end calls that increment the counter and issue a data memory barrier (__DMB()):
telemetry_write_begin(&telemetry_data); // seq: even -> odd
/* ... update CAN fields ... */
telemetry_write_end(&telemetry_data); // seq: odd -> evenGPS uses its own pair (telemetry_gps_write_begin / _end) so a GPS update and a CAN update don't invalidate each other's readers.
Taking a snapshot
Readers never touch telemetry_data directly. They copy it out with telemetry_snapshot(), which implements the reader side of the seqlock:
telemetry_t snap;
if (telemetry_snapshot(&telemetry_data, &snap)) {
/* snap is a consistent, private copy, read freely */
}Internally it:
- Reads both counters (
seq,gps_seq). - Copies the whole struct.
- Reads both counters again.
- Accepts the copy only if both counters are unchanged and both were even.
If a writer was active during the copy, it retries (up to 3 times), then gives up and returns 0. Callers treat a 0 as "skip this tick and try again," which every consumer thread does.
Always check the return value
telemetry_snapshot() can fail (return 0) if it loses the race three times in a row. Never read the destination struct without checking. Every consumer in the codebase does if (telemetry_snapshot(...)) { ... } else { retry/skip }; follow that pattern.
Lap write domain (feature branch)
The header also declares a lap write domain (lap_seq, telemetry_lap_write_begin/_end, telemetry_lap_count_set) intended to let the lap thread publish lap_count under its own sequence counter.
Work in progress
On the feature/lap-tracking branch these lap-domain functions are declared but not yet implemented in telemetry.c, and telemetry_snapshot() currently validates only seq and gps_seq. See Lap Tracking for the current state before relying on lap_count.
API reference
| Function | Side | Purpose |
|---|---|---|
telemetry_init() | n/a | Zero the struct, reset counters |
telemetry_write_begin/_end() | writer | Bracket a CAN-domain update |
telemetry_gps_write_begin/_end() | writer | Bracket a GPS-domain update |
telemetry_ingest_can() | writer | Decode one CAN frame into the struct |
telemetry_ingest_gps_lwgps() | writer | Fold an lwGPS fix into the struct |
telemetry_snapshot() | reader | Take a consistent private copy |
telemetry_csv_header() | reader | The CSV column header string |
telemetry_csv_line() | reader | Serialize a snapshot to one CSV line |
