Skip to content

Firmware Architecture

This page describes the shape of the OnSpeed firmware for someone opening the source tree for the first time. It names the layers, the streams, and the seams between them, with enough file pointers that the next click lands in the right place.

The model in one paragraph

OnSpeed is a stream-processing system. Data sources — real sensors, recorded log replay, the X-Plane plugin, synthetic-test fixtures — feed a pure-core processing layer (AHRS fusion, AOA calculation, percent-lift mapping, tone decisions). The processed state is consumed by sinks — the I2S audio output, the M5/huVVer wire frame, the WebSocket JSON for browser pages, the SD log CSV, the X-Plane in-sim display. Producers publish lock-free per-stream snapshots; consumers read those snapshots at their own cadence. No consumer takes a mutex on the producer's hot path.

Three layers

software/Libraries/onspeed_core/ — pure C++17

The processing core. No Arduino.h, no FreeRTOS, no ESP-IDF. Compiles with plain g++ on the development host and is covered by the native test suite under test/. The CI invariant lives in scripts/check_core_purity.sh, which fails the build if any file under onspeed_core/ includes a platform header or calls a platform API.

Subdirectory What lives there
aero/ Wind-triangle solver
ahrs/ AHRS algorithms (Madgwick, EKFQ) and the four-stage pipeline orchestrator
aoa/ AOA calculator, polynomial curve fitting, percent-lift, display anchors
api/ JSON helpers for the /api/calwizSave, /api/calwizState, /api/sensorBiases endpoints, plus LiveDataJsonKeys.h (the live-data JSON key contract shared with the browser pages)
audio/ Tone selection, tone synthesis, envelope, G-limit and VNO-chime decisions, volume curve, WAV decoder, panning
boom/ Boom-probe binary-protocol parser
config/ OnSpeedConfig struct + tinyxml2-backed parse/emit
efis/ Per-brand parsers: Dynon D10/SkyView, Garmin G3X/G5, MGL Binary, VN-300, plus OAT source selection
filters/ EMA, running mean/median, Savitzky-Golay derivative, G-onset filter, rate-adjusted accel EMA
gauges/ Arc geometry, bar range scaling, tick layout, flap widget math, gauge state
log/ Aligned writes, log metadata builder/file
proto/ Wire codecs (DisplaySerial M5 binary, LogCsv with header-keyed reads)
replay/ LogReplayEngine, LogReplayTask, and the LogRowAhrsInputs adapter that drives the AHRS step from a parsed log row
sensors/ Raw-counts → physical-units conversions (boom, pressure, OAT, flaps, DS18B20 decode)
test_frames/ Synthetic-frame fixtures used by native tests and the X-Plane plugin
types/ POD frames passed between layers (AhrsInputs, AhrsOutputs, EfisFrame, ImuSample, SensorSample, FlapState, LogRow, AudioFrame)
util/ SnapshotPublisher<T> seqcount primitive, CRC, OnSpeedTypes unit conversions, PERF instrumentation

Every module takes inputs, returns outputs, and holds its state on this. There are no hidden globals at this layer.

software/sketch_common/ — platform-bound C++

FreeRTOS tasks, hardware drivers, web server, serial I/O ports. This is the code that binds the pure core to the ESP32 and is shared across sketches.

Subdirectory What lives there
ahrs/ Snapshot publisher payloads (AhrsSnapshot.h, FlapSnapshot.h, SensorSnapshot.h, ImuSnapshot.h)
audio_io/ I2S writer task, volume hardware read
config/ Config load/save against LittleFS
drivers/ IMU330, HSC pressure sensors, MCP3202 ADC, SPI bus, SD card, OneWire DS18B20, switch
io/ Serial ports for the M5 display, EFIS RX, boom probe, console
tasks/ The AHRS step wrapper, EFIS read, boom read, flap detector, housekeeping, log writer, log replay, debug log, PERF dump
test/ SyntheticStream and other on-target test scaffolding shared across sketches
util/ Boot diagnostics, error logger, small helpers
web_server/ HTTP request routing, API handlers, WebSocket data server

The sketches

  • software/OnSpeed-Gen3-ESP32/ — the main firmware. Sketch entry point (.ino), per-board HardwareMap.h, audio assets, the Web/ PROGMEM HTML pages, and the Preact bundle bound into PROGMEM via scripts/build_web_bundle.py.
  • software/OnSpeed-M5-Display/ — the M5Stack secondary display. Reads the 78-byte DisplaySerial wire frame and renders the indexer.
  • software/OnSpeed-XPlane-Plugin/ — the X-Plane plugin. Reads sim datarefs, produces the same DisplayBuildInputs wire shape the firmware emits, runs the same ToneCalc / ToneSynth chain for audio, and embeds the same indexer modes in an X-Plane window.

The plugin is the existence proof that the core is platform-free: it links onspeed_core against macOS/Linux/Windows toolchains with no firmware code.

Boot flow

Execution starts in OnSpeed-Gen3-ESP32.ino at setup(). The sequence: create the four global mutexes, run BootDiag::Init() (which reads reset reason and boot count from NVS), mount the SD card, mount LittleFS, call g_Config.LoadConfig() to populate g_Config from /onspeed.cfg, latch the IMU sample rate from iLogRate, and then spawn the flight-data tasks (ImuReadTask, SensorReadTask, AudioPlayTask) on Core 1 and the I/O and web tasks (WriteDisplayDataTask, EfisReadTask, BoomReadTask, HousekeepingTask, SwitchCheckTask, DataServerTask, WebServerTask, ConsoleReadTask, LogSensorCommitTask) on Core 0 via xTaskCreatePinnedToCore. After setup() returns the Arduino loop() runs on Core 1 at priority 1 and is intentionally minimal — the real work is in the spawned tasks.

The four-stage AHRS pipeline

Ahrs::Step() in onspeed_core/src/ahrs/Ahrs.cpp orchestrates four named stages, with stage markers in source comments:

Stage 1 — Sensor.        TAS update (density correction + EMA-smoothed derivative)
                         at pressure-sensor cadence; installation-bias rotation
                         of gyro and accel from body axes to corrected frame.

Stage 2 — AHRS algorithm. Madgwick or EKFQ, picked by config. Each algorithm is a
                         wrapper class (`onspeed::ahrs::Madgwick`, `EkfqPipeline`)
                         that owns its internal pre-filtering, IAS gating, and
                         comp-fade ramp. Input is sensor-stage state; output is
                         `AhrsOutputs`.

Stage 3 — Smoothing.     Wire-spec EMA on the corrected accel components,
                         display-rate running mean on the corrected gyro rates,
                         and altitude/VSI (EKFQ owns these as filter states;
                         Madgwick runs a standalone 3-state Kalman).

Stage 4 — Outputs.       VSI zeroing when IAS is below the alive threshold,
                         FlightPath = asin(VSI / TAS), DerivedAOA (Madgwick path
                         derives it from SmoothedPitch − FlightPath; EKFQ already
                         computed it kinematically), then assemble `AhrsOutputs`.

The stages are reachable at Ahrs.cpp:239, :270, :351, and :375. The two algorithms are not interchangeable at the field level — each one's tuning constants are its own — but they expose a uniform InputsOutputs seam so the orchestrator and the four-stage shape stay the same regardless of which one is active.

The snapshot architecture

Producers publish state through SnapshotPublisher<T>, a lock-free single-writer / multi-reader seqcount primitive in onspeed_core/util/SnapshotPublisher.h.

The pattern: the writer holds a version counter. Before each publish it increments the counter to an odd value ("write in progress"), memcpy's the payload, then increments again to the next even value ("done"). A reader samples the version, copies the payload, then samples the version again; if either sample is odd or the two samples disagree, it retries. Writers never block on a reader; readers never block each other. The payload must be trivially copyable (a static_assert enforces this). See the header comment for the full memory-ordering specification.

The per-stream publishers in production:

Publisher Producer Payload
g_ImuSnapshot ImuReadTask (416 Hz, Core 1) ImuSample
g_SensorSnapshot SensorReadTask (50 Hz, Core 1) SensorSnapshotPayload
g_AhrsSnapshot Ahrs::PublishSnapshot() after each Step() AhrsSnapshotPayload
g_FlapSnapshot Flaps::Update() writer-side, and config-save handlers FlapSnapshotPayload
EfisSerialPort::suEfis_pub_ EfisReadTask (Core 0) SuEfisData (Dynon / Garmin / MGL frames)
EfisSerialPort::suVN300_pub_ EfisReadTask (Core 0) SuVN300Data (VN-300 INS frames)
BoomSerialIO::published_ BoomReadTask (Core 0) SuBoomData

EfisSerialPort owns two publishers because the VN-300 INS protocol carries a different field set than the EFIS brands and is held separately so the boom-AOA pipeline can read it without going through the brand-EFIS selection.

Sinks read these. DisplaySerial's wire builder, DataServer's WebSocket JSON, LogSensor's SD writer, Housekeeping's G-limit and VNO-chime decisions, and the /api/sensorBiases HTTP handler all pull the AHRS state from g_AhrsSnapshot and the flap vector from g_FlapSnapshot. None of them take xAhrsMutex.

Readers use read() for the tolerant case (spins until the payload is coherent — typically zero retries) and tryRead(out) for deadlined consumers (audio task, display task) that would rather skip a frame than spin.

Smoothing lives at the producer. The AHRS step's wire-side accel EMA runs inside Stage 3 of the pipeline; the EKFQ algorithm's internal IAS EMA runs inside the algorithm wrapper. Sinks read smoothed values straight from the snapshot; consumers do not run a second smoothing layer.

Task topology and rates

The flight-data tasks split across the two ESP32 cores. Core 1 holds deterministic flight-cadence work; Core 0 holds I/O and the WiFi stack. Rate constants live in HardwareMap.h.

Task Rate Core Priority
ImuReadTask 416 Hz (fixed) 1 5
SensorReadTask 50 Hz (pressure sensors, OAT) 1 5
AudioPlayTask sleeps until notified; pumps 15 ms I2S chunks (~67 Hz) while a tone or voice clip is active 1 6
WriteDisplayDataTask 20 Hz (50 ms period) 0 4
EfisReadTask event-driven on serial RX 0 3
BoomReadTask polled serial 0 3
HousekeepingTask 20 Hz (G-limit, VNO chime, LED, volume) 0 3
SwitchCheckTask event-driven (datamark long-press, mute single-click) 0 3
DataServerTask 20 Hz WebSocket broadcast 0 2
WebServerTask event-driven HTTP 0 1
ConsoleReadTask event-driven serial 0 1
LogSensorCommitTask matches iLogRate (1–416 Hz; 50 default) 0 1

SensorReadTask, ImuReadTask, and AudioPlayTask ride Core 1 because their cadence is load-bearing for flight; everything else lives on Core 0 so that web traffic or a stalled WiFi peer cannot stretch the IMU loop.

The mutex contracts

Four global FreeRTOS mutexes carry the flight path; the web layer holds two of its own for endpoints that need their own serialization. None are held by a flight-data reader.

Mutex Holds Hold duration
xAhrsMutex Ahrs::Init() reseed, the flap-vector swap inside Flaps::Update(), the config-save handlers in ApiHandlers / ConfigWebServer typically sub-millisecond on the IMU-loop reseed path; up to tens of milliseconds on the config-save path while a full flap-vector swap and snapshot publish complete
xSensorMutex SPI bus access (IMU, pressure sensors, MCP3202, SD) one SPI transaction
xWriteMutex SD card file ops one write
xSerialLogMutex console output one printf
g_FormatJobMutex the background SD-format job in web_server/ApiHandlers.cpp duration of the format job
uploadMutex config-upload serialization in web_server/ConfigWebServer.cpp duration of one upload

xAhrsMutex is writer-side only: it serializes the places that mutate AHRS state (the live IMU loop's reseed path, the config-save handlers, log-replay's per-row write-back). Readers do not contend for it — they read the snapshot publishers. xSensorMutex is a peripheral bus lock, not a data lock.

Where to make changes

Task Where it goes
New AHRS algorithm Add to onspeed_core/src/ahrs/. Wrap it as a class with Inputs / Outputs POD types and an algorithm enum entry in Ahrs.h. Stage 2 of the pipeline dispatches on the enum.
New EFIS vendor Add a parser to onspeed_core/src/efis/ returning EfisFrame. Add a case to the protocol dispatch in EfisParser.cpp. Register the protocol name in the config schema.
New audio cue Pure decision logic in onspeed_core/src/audio/ (e.g. alongside ToneCalc and GLimitDecision). The I/O side — choosing when to call it from the audio task — lives in sketch_common/src/audio_io/Audio.cpp.
New web endpoint Add to software/sketch_common/src/web_server/ApiHandlers.cpp. New endpoints land there.
New sink (MAVLink, BLE, an external display) Read the snapshot publishers from a writer task in sketch_common/src/tasks/. The DataServer and DisplaySerial wire builders are the templates: one snapshot read per tick, format into the new wire shape, write to the transport.
New filter Header-only into onspeed_core/src/filters/. State on this. Native test under test/.

What's in onspeed_core/types/

POD frames passed between layers. None of them carry pointers or allocate; all are trivially copyable so a SnapshotPublisher can memcpy them.

  • ImuSample — accel and gyro in body axes, with the IMU's microsecond timestamp.
  • SensorSample — IAS, pitot-vs-AOA pressure ratio (CP), pressure altitude, OAT, IAS-alive flag.
  • AhrsInputs — what Stage 1 hands Stage 2: corrected IMU plus sensor-stage TAS.
  • AhrsOutputs — what Stage 4 hands the sinks: pitch, roll, flight path, DerivedAOA, TAS, altitude, VSI, smoothed gyro rates, earth-vertical G.
  • EfisFrame — the union of all EFIS-vendor fields, with NaN sentinels for "this protocol does not carry this field."
  • FlapState — current detected flap index plus the per-flap setpoints (alpha_0, alpha_stall, LDMAX, OnSpeed fast/slow, stall warn/stall, maneuvering).
  • LogRow — the SD CSV row shape. Producer-side for the log writer; consumer-side for log replay.
  • AudioFrame — the small struct the tone decision passes to the synth.

When a new value needs to cross a layer boundary, the first question is "which of these does it extend, or does it deserve a new POD here."

What's in onspeed_core/proto/

Wire-format codecs.

  • DisplaySerial.h / .cpp — the 78-byte M5/huVVer binary wire frame. Encoder and decoder live next to each other; their round trip is exercised in test/test_display_serial/. The X-Plane plugin builds the same frame from sim datarefs and feeds it to the same indexer renderer.
  • LogCsv.h / .cpp plus LogCsvHeaderIndex.h / .cpp — the SD log CSV format, parsed by column name. BuildHeaderIndex resolves column names to ordinals at file open, reports missing required columns explicitly, and reports missing optional groups (boom / standard EFIS / VN-300) as feature flags the replay engine reads. Old logs and logs with reordered columns both parse cleanly without code edits.
  • CsvHeaderMatch.h — the header-string match helpers LogCsv consumes.

The codecs are pure: they read and write LogRow / DisplayBuildInputs without doing I/O, and round-trip tests exercise them on fixtures. Adding a new wire format means adding a new module here, a new test, and a new producer/consumer pair; no other layer changes.