Skip to content

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.

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:

c
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()):

c
telemetry_write_begin(&telemetry_data);   // seq: even -> odd
/* ... update CAN fields ... */
telemetry_write_end(&telemetry_data);     // seq: odd  -> even

GPS 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:

c
telemetry_t snap;
if (telemetry_snapshot(&telemetry_data, &snap)) {
    /* snap is a consistent, private copy, read freely */
}

Internally it:

  1. Reads both counters (seq, gps_seq).
  2. Copies the whole struct.
  3. Reads both counters again.
  4. 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

FunctionSidePurpose
telemetry_init()n/aZero the struct, reset counters
telemetry_write_begin/_end()writerBracket a CAN-domain update
telemetry_gps_write_begin/_end()writerBracket a GPS-domain update
telemetry_ingest_can()writerDecode one CAN frame into the struct
telemetry_ingest_gps_lwgps()writerFold an lwGPS fix into the struct
telemetry_snapshot()readerTake a consistent private copy
telemetry_csv_header()readerThe CSV column header string
telemetry_csv_line()readerSerialize a snapshot to one CSV line

Released under the MIT License.