Skip to content

SignalStore

SignalStore is the firmware’s shared snapshot of every decoded CAN signal + synthesized derived values. It is written by taskCAN (and the OBD-II poller, and the button widget’s optimistic-write path) and read by taskUI, taskUSB (telemetry), taskBLE (telemetry), and the alert engine. Sources: src/runtime/signal_store.cpp, src/runtime/signal_store.h.

The tuner’s CAN bus route taps the same raw stream that feeds SignalStore on the device — useful to confirm an ID is on the wire before chasing a decoder bug.

Critical-section invariant

Same rule as ErrorStore (see error-store.md): all public mutations and reads hold a portMUX_TYPE for the RMW window. No LOG / no ALLOC / no LOCK inside portENTER_CRITICAL. The file header in signal_store.cpp documents the invariant.

The portMUX rather than a FreeRTOS semaphore was chosen so the writes from taskCAN (high prio, time-sensitive) don’t block on priority inversion when taskUI is mid-frame. The lock is held for tens of microseconds at most.

Storage layout

struct SignalValue {
float raw; // last sample as decoded by CanParser
float smoothed; // EMA-filtered value (gauges read this)
uint32_t lastUpdateMs; // millis() at last update()
bool valid; // false until the first sample arrives
};
SignalValue s_signals[SIGNAL_STORE_MAX_SIGNALS];

The table is indexed by SignalId (a uint8_t enum). Adding a signal means: bump the enum in include/signal_map.h, add the name → id mapping row in src/can/signal_map.cpp::kNameToId, and ensure SIGNAL_STORE_MAX_SIGNALS is large enough (currently 32 — matches CONFIG_MAX_SIGNALS).

EMA smoothing

update(id, value) writes the raw sample AND updates smoothed via SIGNAL_EMA_ALPHA = 0.2f:

new_smoothed = valid
? (SIGNAL_EMA_ALPHA * value + (1.0f - SIGNAL_EMA_ALPHA) * smoothed)
: value;

The first sample seeds smoothed = value to avoid a slow climb from 0. The gauge widget reads smoothed; the label widget and warning widget read raw so threshold crossings fire on the actual sample, not the filtered tail.

set(id, value) — bypass the EMA

SignalStore::set(id, value) writes both raw and smoothed to the same value without EMA blending. Used by:

  • The button widget’s optimistic-write path. A toggle button binds to a flag signal; on click the button updates the local visual AND writes the new value to SignalStore so any other widget watching that signal (DiagDrawer ECU flags, top bar mode badge) reflects the press immediately instead of waiting for the ECU echo (#1285).
  • Input bindings that target a signal directly (#833).

The EMA path was wrong for boolean signals — read() returned fractional values like 0.3 after a few EMA steps, and != 0.0 was always true → the toggle got stuck pushing 0.

checkTimeouts() — stale invalidation

SignalStore::checkTimeouts() walks the table and clears valid for any entry whose lastUpdateMs is older than the configured timeout (SIGNAL_DEFAULT_TIMEOUT_MS = 1000 by default; per-signal override in signals.json). Called from PageManager::updateWidgets() every ~100 ms.

Stale invalidation is what drives the gauge widget’s placeholder render (top bar shows the dot as orange/red, gauges show “0”). Without timeouts, a disconnected sensor would keep displaying the last value indefinitely.

anyValid(ids, count) — batched lock

The top bar uses status dots that turn green when “any of {RPM, COOLANT, BATTERY}” is valid. Calling isValid() three times pays three IRQ-disable round-trips per UI frame. anyValid(ids, count) takes the lock once and checks all three under a single portMUX (#1342). Used by top_bar.cpp::anySignalValid.

getAllValuesSnapshot() — telemetry-side batched read

Both USB telemetry and BLE telemetry need a consistent snapshot of every signal value per emit. Calling read(id) per signal under the lock is ~30 µs per call; the batched snapshot copies the whole table under one lock acquire and returns a const SignalValue[] for the emitter to walk. This is what taskUSB and taskBLE both use; it’s also what the F1 path in PageManager::updateWidgets uses to drive widget redraws.

Why this lives in BSS

s_signals is a static array, not a Vec. The runtime cannot tolerate heap allocation in the hot RX path (taskCAN at TASK_PRIO_CAN=15, decoding 1000+ frames/sec on a busy bus). The fixed-size table is sized for the worst case and indexed by integer; lookup is O(1).