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 LogRow → AhrsInputs 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-boardHardwareMap.h, audio assets, theWeb/PROGMEM HTML pages, and the Preact bundle bound into PROGMEM viascripts/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 sameDisplayBuildInputswire shape the firmware emits, runs the sameToneCalc/ToneSynthchain 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 Inputs →
Outputs 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 intest/test_display_serial/. The X-Plane plugin builds the same frame from sim datarefs and feeds it to the same indexer renderer.LogCsv.h / .cppplusLogCsvHeaderIndex.h / .cpp— the SD log CSV format, parsed by column name.BuildHeaderIndexresolves 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 helpersLogCsvconsumes.
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.