Skip to content

Display Serial Protocol

The OnSpeed firmware emits a serial data stream from its display UART intended for an external panel display (the M5Stack secondary display, the LiveView web page's underlying data source uses the WebSocket — not this stream — see Display serial vs LiveView below). Two formats are selectable via the SERIALOUTFORMAT configuration field:

  • ONSPEED — the native #1 framing covered in detail below. Used by the M5Stack secondary display, the m5-replay bench tool, and any third-party panel display reading OnSpeed's full data set.
  • G3X — a Garmin-G3X-compatible subset (=11 framing) for feeding an EFIS that wants to display OnSpeed AOA without parsing the native format.

This page is the canonical wire-format reference. The source of truth in code is software/Libraries/onspeed_core/src/proto/DisplaySerial.h; when the two disagree the header wins and this page is stale — file an issue.

Design intent

The OnSpeed #1 wire is a percent-lift contract, not a body-angle contract. Every AOA-related quantity on the wire is expressed as a percentage of the wing's lift envelope (the honest single-linear normalization (AOA − α₀) / (α_stall − α₀) × 100). The producer side computes this as a whole-percent float clamped to [0.0, 99.9]; the wire encoder multiplies by 10 and truncates to the %03u tenths-of-a-percent field (0..999), so the consumer's index bar can render at sub-pixel temporal smoothness off the 20 Hz frame cadence. The four band-edge percents (tonesOnPctLift, onSpeedFastPctLift, onSpeedSlowPctLift, stallWarnPctLift) stay at integer-percent resolution (0..99) — they only move at detent-snap or config-save events. They vary per flap because the underlying body-angle calibration varies per flap.

Within that contract, the visual L/DMAX pip slides smoothly and the operational anchors snap. The pip's wire field, pipPctLift (added in v4.22), interpolates linearly in lever-pot space across the entire pot range from the cleanest detent's L/DMAX percent to the most-deployed detent's OnSpeed-band center. The operational anchors — tonesOnPctLift (L/DMAX for the active detent, drives the chevron + audio gate), onSpeedFastPctLift, onSpeedSlowPctLift, stallWarnPctLift — all snap to the active detent, in lockstep with the audio cues. Vac's design rule (ld_max.pdf §8): "L/Dmax pips are aerodynamic references. Fast tone is an operational limit cue. They must remain independent."

The consumer renders entirely in percent space — one mapping function from percent to screen y, with the four anchors as inputs. Body-angle setpoints stay inside the firmware. See onspeed_core/aoa/PercentLift.h for the formula and how-aoa-works.md for the aerodynamic background.

Physical layer

Parameter Value
Baud rate 115200
Frame format 8N1
Levels TTL or RS-232 (auto-detected by the M5; pin invert depends on which power-board variant feeds the line)
Pinout (Gen3) TX on GPIO 10 (kDisplayTx in HardwareMap.h); shares R1_OUT with EFIS RX
Frame cadence 20 Hz nominal (50 ms period); driven by kDisplaySerialPeriodMs in HardwareMap.h
Direction One-way, OnSpeed → display. The display does not transmit.

The OnSpeed firmware's WriteDisplayDataTask runs at the cadence above and re-aligns to the current tick if it ever runs late — it does not catch up with back-to-back frames. Consumers should measure their own per-frame dt rather than assuming exactly 50 ms; bench-replay tools and a slightly-late tick can drift the actual interval into the 40–60 ms band.

OnSpeed format (#1 framing)

Frame structure

Each frame is exactly 77 bytes of ASCII (v4.23), terminated by CRLF. Field offsets, widths, and scale factors are fixed — there are no length prefixes, no variable-width fields, and no escapes inside the payload.

Offset Width Field printf format Wire scale Engineering range Wire range
0 2 magic literal "#1" "#1"
2 4 pitchDeg %+04d ×10 ±99.9° ±999
6 5 rollDeg %+05d ×10 ±999.9° ±9999
11 4 iasKt %04u ×10 0 – 999.9 kt 0 – 9999
15 6 paltFt %+06d ×1 ±99 999 ft ±99999
21 5 turnRateDps %+05d ×10 ±999.9°/s ±9999
26 3 lateralG %+03d ×100 ±0.99 g (body-frame, +rightward; see below) ±99
29 3 verticalG %+03d ×10 ±9.9 g (rounded to nearest 0.1 g) ±99
32 3 percentLift %03u ×10 0.0 – 99.9 (current AOA, envelope fraction in tenths-of-a-percent) 0 – 999
35 4 vsiFpm10 %+04d ×1 ±9 990 fpm ±999 (already divided by 10)
39 3 oatC %+03d ×1 ±99 °C ±99
42 4 flightPathDeg %+04d ×10 ±99.9° ±999
46 3 flapsDeg %+03d ×1 ±99° ±99
49 2 tonesOnPctLift %02u ×1 0 – 99 (active-detent L/DMAX; operational, audio gate) 0 – 99
51 2 onSpeedFastPctLift %02u ×1 0 – 99 (OnSpeedFast percent for active flap) 0 – 99
53 2 onSpeedSlowPctLift %02u ×1 0 – 99 (OnSpeedSlow percent for active flap) 0 – 99
55 2 stallWarnPctLift %02u ×1 0 – 99 (StallWarn percent for active flap) 0 – 99
57 3 flapsMinDeg %+03d ×1 ±99° (full retract) ±99
60 3 flapsMaxDeg %+03d ×1 ±99° (full extend) ±99
63 4 gOnsetRate %+04d ×100 ±9.99 g/s ±999
67 2 spinRecoveryCue %+02d ×1 −9 to +9 −9 to +9
69 2 dataMark %02u ×1 0 – 99 0 – 99
71 2 pipPctLift %02u ×1 0 – 99 (visual L/DMAX pip; aerodynamic, lerp clean→fullflap) 0 – 99
73 2 checksum %02X hex sum of bytes 0–72, low byte 00FF
75 1 terminator literal CR (0x0D)
76 1 terminator literal LF (0x0A)

Sign and width invariants:

  • Signed fields use a leading + or - sign character counted in the field width (e.g. pitchDeg width 4 = sign + 3 digits).
  • Out-of-range values are clamped, not wrapped. The producer uses C-style truncation toward zero before clamping, so 99.94° pitch becomes +999, −0.05° becomes -000.
  • NaN/Inf inputs emit zero (the producer's SafeScaledInt helper).

Field semantics

Most fields are self-describing. The ones with non-obvious conventions:

  • lateralG is in body-frame at v4.23 (positive = airframe accelerating rightward), matching the IMU, SD log, and WebSocket JSON conventions. Slip-skid ball renderers negate locally at the rendering site — the ball lags opposite the airframe's centripetal acceleration, so right-yaw → ball drawn left of center. The M5 firmware (SerialRead.cpp::SerialProcess) and the LiveView (tools/web/lib/core/slipBall.js) both follow this pattern. See WebSocket protocol — lateralGLoad for the full physics discussion.
  • verticalG is lroundf(g × 10) — round-to-nearest-tenth, matching the LiveView's verticalGLoad rendering and the way pilots intuitively read a single-decimal display. Over-G alerting reads the unrounded float in GLimitDecision (Housekeeping path), so this encoding choice does not affect chime / limit behaviour.
  • vsiFpm10 is already divided by 10. The wire field carries floor(VSI_fpm / 10). Multiply by 10 on receive to get fpm. The cap is ±9 990 fpm.
  • percentLift is computed via the canonical ComputePercentLift — the honest single-linear (AOA − α₀) / (α_stall − α₀) × 100, returned as a float in whole-percent units (e.g. 47.3) and clamped to [0.0, 99.9]. The wire encoder multiplies by 10 and truncates to the %03u tenths-of-a-percent field; consumers divide by 10 on receive. Below α₀ reads 0.0; above α_stall clamps at 99.9 (so the wire saturates at 999, never 1000 — that's load-bearing for the saturation convention). The same formula populates the WebSocket JSON's percentLift field, but the JSON formats with %.1f (rounded), so for a producer-side float of 47.27 the wire delivers 47.2 (truncated tenths) while the JSON delivers 47.3 (rounded tenths). The two transports therefore agree to within 0.1% — the wire's tenths-of-a-percent quantization is the resolution limit; sub-tenth differences arise from the truncate-vs-round choice at each surface's encoding boundary.
  • percentLift goes to 0 below the audio mute threshold (iMuteAudioUnderIAS). The wire stays silent for the AOA region while the aircraft is parked.
  • The four band-edge percents stay at integer-percent resolution. They only move at detent-snap or config-save events, not on every frame; sub-percent resolution buys nothing on those.
  • tonesOnPctLift is the active detent's L/DMAX body angle put through ComputePercentLift — the percent at which the audio low tone turns on. The M5 indexer's bottom green chevron gates on this value; the audio path compares g_Sensors.AOA directly to g_Config.aFlaps[g_Flaps.iIndex].fLDMAXAOA. Both fire from the same source; they snap together at every detent transition. Operational cue (Vac §8).
  • pipPctLift is the visual L/DMAX pip — interpolated linearly across the entire pot range from flapEntries[0].fLDMAXAOA percent (cleanest) to the geometric center of flapEntries[entryCount-1]'s OnSpeed band (most-deployed). Slides smoothly with the lever; intermediate detents are intentionally ignored. Aerodynamic cue (Vac §8). At the cleanest detent's pot position with iIndex == 0, pipPctLift == tonesOnPctLift exactly. Elsewhere they differ.
  • onSpeedFastPctLift / onSpeedSlowPctLift / stallWarnPctLift are SNAPPED to the active detent (the same one the audio path compares against). They define the donut and chevron screen-percent positions on the indexer; snapping keeps those overlays in lockstep with the audio cues that fire at the same calibrated thresholds. Operational cues.
  • flapsDeg interpolates per-bracket between adjacent detents' iDegrees based on lever position so the numeric flap-angle readout slides smoothly during deployment rather than stepping at the detection midpoint. Mechanical cue, distinct from pipPctLiftflapsDeg visits every detent's iDegrees exactly when the lever pot equals that detent's iPotPosition; the pip skips intermediate detents.
  • flapsMinDeg / flapsMaxDeg are the configured travel range, scanned across all entries in aFlaps. Useful for a flap-position widget that draws its arc against actual aircraft endpoints rather than hardcoded values.
  • dataMark wraps mod 100. The pilot's data-mark counter increments without bound in the firmware; the wire field carries counter % 100.

G-onset rate

gOnsetRate (offset 62) carries a low-pass-filtered d(verticalG)/dt in g/s, with a 250 ms time constant applied at the 20 Hz wire rate. Sign convention follows verticalG (production reaction-force convention: +1 g level), so positive output means "G load increasing". The M5's Primary-mode renderer draws a vertical orange tape on the right edge whose height saturates at 2 g/s. Implementation: onspeed_core/filters/GOnsetFilter.h.

Aspirational / not-yet-wired fields

One field is part of the wire layout today but populated with a placeholder value by the producer. It occupies its byte offset so future producers/consumers don't have to bump the protocol again. Treat it as reserved — but don't be surprised if you see real values flowing through it later.

Field Status What's the gap
spinRecoveryCue (offset 66) Always 0 from the producer today. Intended as a −1 / 0 / +1 direction cue (left / none / right) for an upcoming spin-recovery indicator. No consumer renders it yet. Both ends: producer needs the cue logic; M5 needs a render glyph.

Checksum

Two uppercase ASCII hex digits at offset 72, computed over bytes 0–71 inclusive:

checksum = sum(payload[0..71]) & 0xFF

The reference implementation lives in onspeed_core/util/Crc.h (util::Checksum8). Lowercase hex is rejected by the parser.

Parsing recommendations

The onspeed_core library ships a reference parser at proto/DisplaySerial.h that runs natively (no Arduino dependency). Two entry points:

  • ParseDisplayFrame(const uint8_t* buf, size_t len) — one-shot. Hand it a 77-byte buffer; receive an optional<DisplayFrame>. Fails closed on bad magic, bad CRC, or any field that fails to parse.
  • DisplayFrameAccumulator::Inject(uint8_t byte) — byte-stream. Feed it whatever the UART hands you; it returns a parsed frame on the byte that completes a valid frame, or nullopt otherwise. Internally it resets to start-of-frame on any #, drops frames that don't end with LF, and clears its buffer between frames. The same struct is used by the M5 firmware and is exercised by the native test suite.

If you implement your own parser, the failure modes worth handling are the ones the reference parser handles:

  • Garbage before the next #1. Real wire output is clean, but bench-replay tools, hot reconnect, and partial-frame recovery all produce situations where your parser starts mid-stream. Treat any byte before the first # as ignorable.
  • Mid-frame #. A stray # at any offset must restart the frame from byte 0. Without this, a transient line glitch can desynchronise the parser indefinitely.
  • Bad CRC. Drop and resync; do not let bad data through "because it's only one frame."
  • Frame doesn't end with LF. Treat as out-of-sync and reset the accumulator.

At 20 Hz, a robust parser should re-sync within one frame (50 ms) of any disturbance.

Producer/consumer alignment

The #1 format is a hard versioned protocol: there is no length prefix, no field-presence bitmap, and no fallback path. A producer and a consumer must agree on the frame size byte-for-byte or no frames will parse.

When updating the protocol, flash both ends in lockstep. The OnSpeed firmware and the M5 (or third-party display) firmware released for a given version are paired — do not mix major versions across a wire-format bump.

Change log

OnSpeed version Frame size Change
≤ 4.20 80 bytes Original layout — pitch through dataMark plus per-flap body-angle setpoints (tonesOnAoaDeg, onSpeedFastAoaDeg, onSpeedSlowAoaDeg, stallWarnAoaDeg) and aoaDeg. Consumers reproduced the percent-lift segments locally from the body-angle anchors.
4.21 74 bytes Wire becomes a percent-lift contract. The body-angle setpoints and aoaDeg come off the wire; in their place the producer emits tonesOnPctLift, onSpeedFastPctLift, onSpeedSlowPctLift, stallWarnPctLift — each per-flap setpoint put through the canonical ComputePercentLift. Consumers render entirely in percent space. ComputePercentLift itself moves to the honest single-linear formula at the same time. gOnsetRate (offset 62) populates from GOnsetFilter instead of always-zero. See PR #320 and PR #328.
4.22 76 bytes Pip and audio threshold separated. New field pipPctLift at offset 70 carries the visual L/DMAX pip; it interpolates linearly across the entire pot range from cleanest to most-deployed detent (intermediate detents intentionally ignored). tonesOnPctLift reverts to PR #320's snap-per-active-detent behavior so the M5 bottom chevron and the audio low-tone gate fire from the same threshold, in lockstep. All existing field offsets unchanged; new field appended before checksum. Per Vac's design rule (ld_max.pdf §8): aerodynamic references and operational cues must remain independent. See Indexer Spec. Coordinated reflash: Gen3 main firmware and all M5 display board variants (Basic, Core2, huVVer-AVI) must be flashed together — a v4.21 receiver will fail the 76-byte parse and render NO DATA, and a v4.22 receiver will fail to assemble a 74-byte sender's frames. Same operational drill as the v4.20 → v4.21 transition.
4.23 77 bytes percentLift widens to tenths-of-a-percent + lateralG switches to body-frame. Two coordinated wire changes ship in one bump. (1) percentLift (offset 32) widens from %02u (0..99, integer percent) to %03u (0..999, tenths) — the M5/huVVer-display index bar now advances at sub-pixel temporal smoothness off the 20 Hz frame cadence. Every field after it shifts +1: vsiFpm10 34→35, pipPctLift 70→71, checksum 72→73. The four band-edge percents stay at integer-percent because they only move on detent or config-save events. The Garmin G3X subset format (SERIALOUTFORMAT=G3X) keeps integer-percent on its own =11 frame; producer divides by 10. (2) lateralG (offset 26) flips from ball-frame (positive = leftward) to body-frame (positive = airframe accelerating rightward), matching the IMU, SD log, and WebSocket JSON conventions. Frame size unchanged by this change — only the value's sign convention. Slip-skid ball renderers negate locally at the rendering site; the M5's SerialRead::SerialProcess does, the LiveView's slipBall.js already does. See PR #386, PR #383, and LATERAL_G_CONVENTION.md. Coordinated reflash: Gen3 main firmware and all M5 display board variants (Basic, Core2, huVVer-AVI) must be flashed together — a v4.22 receiver will fail the 77-byte parse and render NO DATA, and a v4.23 receiver will fail to assemble a 76-byte sender's frames. A pre-v4.23 M5 paired with v4.23 main firmware would render the slip ball mirrored — a coordination cue with the wrong sign. Same operational drill as the v4.21 → v4.22 transition; we're paying the wire-break cost once for both improvements.

G3X format (=11 framing)

Selected by setting SERIALOUTFORMAT=G3X. This format exists for Garmin-EFIS users who want OnSpeed AOA on their PFD without writing a parser. It carries a strict subset of the data — pitch, roll, IAS, Palt, lateralG, verticalG, and percentLift — formatted to match the Garmin G3X attitude-and-AHRS sentence the EFIS already understands.

Offset Width Field printf format Wire scale
0 2 magic literal "=1"
2 8 reserved (zeros) literal "00000000"
10 4 pitchDeg %+04d ×10
14 5 rollDeg %+05d ×10
19 3 reserved literal "___"
22 4 iasKt %04u ×10
26 6 paltFt %+06d ×1
32 4 reserved literal "____"
36 3 lateralG %+03d ×100
39 3 verticalG %+03d ×10
42 2 percentLift %02u ×1
44 10 reserved literal "__________"
54 2 checksum %02X sum of bytes 0–53
56 2 terminator CR LF

Total: 58 bytes per frame. Same 20 Hz cadence, same 115200 8N1 wire. AOA setpoints, derived data, and aerodynamic anchors are not transmitted — Garmin EFISes don't have a place to render them.

Display serial vs LiveView — the two data paths

The OnSpeed web LiveView page does not consume the display serial stream documented above. It receives a JSON payload over a WebSocket on port 81 — see the LiveView WebSocket protocol for the full schema. The two paths share the same percent-lift contract — percentLift, tonesOnPctLift, onSpeedFastPctLift, onSpeedSlowPctLift, stallWarnPctLift, and pipPctLift are computed by the same firmware code (onspeed_core::ComputePercentLift + ComputeDisplayPctAnchors). The JSON formats percentLift as %.1f (rounded); the wire encodes int(pct × 10) in %03u (truncated). A consumer reading either transport reconstructs a whole-percent float; the two reconstructions agree to within 0.1% (the wire's tenths-quantization plus a sub-tenth truncate-vs-round delta at the boundary). A shared indexer renderer can run identically off either transport at the granularity that matters for visual rendering.

The encodings differ:

Aspect Display serial (#1) LiveView WebSocket
Transport UART 115200 8N1, one-way WebSocket port 81, bidirectional
Encoding Fixed-offset ASCII, byte-summed CRC, CRLF-terminated JSON over WebSocket text frames
Cadence 20 Hz (every 50 ms) 20 Hz — gated on ≥ 1 connected client; both paths driven by kDisplaySerialPeriodMs
Audience Panel display, third-party EFIS Browser running LiveView
Adding a field Hard protocol change — both ends must flash together Soft change — old browsers ignore unknown JSON keys
Body-angle AOA (degrees) not on wire yes (AOA) — used for the numeric corner readout
Body-angle DerivedAOA (degrees) not on wire yes (DerivedAOA) — for advanced overlays / debug
kalmanVSI, coeffP, PitchRate, DecelRate not on wire yes — LiveView-specific instrumentation
flapIndex (which detent is active) not on wire yes

The asymmetry is by design: the panel displays render the indexer, so the wire ships percent anchors. The LiveView additionally shows numeric body-angle AOA so a pilot can compare it to DerivedAOA — those degrees-units fields stay on the WebSocket but never on the panel-serial wire.