Repository navigation
docs(firmware): v1.0 API + wire-format freeze review and compatibility policy #295
Description
Goal
v1.0.0 freezes the sketch API and the CAN wire format — after it, a breaking change means 2.0. This ticket does the review that makes the freeze deliberate, and writes down what the freeze means, so it isn't only implied by a tag. Split out of #290, which keeps the soak and the gate checklist.
The review runs before the #290 soak, so the soak exercises the final format. Anything the soak finds is still pre-1.0: it becomes a new row below and is fixed normally.
Scope
1. API + wire-format review — anything that should change, changes now:
- Sketch API: public constructor signatures of every control class (argument order, defaults, types —
PinRefeverywhere, native-pin exceptions per A6), the role entry points, config structs/factories, build flags, and the custom-control extension interface (A9). - CAN ID map: fixed
CTRL_BCAST 0x010/TEST_SEQ 0x011/SYNC_REQ 0x012; per-node HB0x100, HEALTH0x140, TELEM0x180, EVT0x200, ECHO0x300, READY0x400, REL0x500, DIR0x600, ACTION0x700— and with them the arbitration order (A18). - Payload layouts (
ControlPacket/ControlPacketPair,HeartbeatPayload,NodeHealthPayloadincl.nodeKind,TelemPayloadpages), the append-only wire enums (NodeKind,TelemPage,NodeFaultCode, flag bits), and the UART HID frame. - Host-facing (client-parsed):
_NODE_STATUS/_NODE_STATUS_END/_NODE_TELEM+ the0x86FEroster request, the USB CDC calibration protocol, and the Gateway's USB identity. - Stored data and ID ledgers: the calibration stores (A21),
DCSIN_*/CTRL_*/A_4E_C_*stability (A22). - Findings go into this ticket's body; each one is either fixed before the tag or explicitly accepted.
2. Compatibility policy page (published, docs/firmware/compatibility.md, Firmware Reference nav after Build Flags) — short:
- What is covered by the freeze (the list A9 defines + the wire/host/USB surfaces above) and what isn't (library-internal functions, test seams, family-base protected hooks, TechSpec wording, stored calibration).
- What counts as breaking (→ major), feature (→ minor), fix (→ patch), mapped to Conventional Commits.
- Forward-compatibility + growth rules (A13), byte order (A14), the calibration channel's own rule (A17).
- Mixed-version rule: nodes on one bus must share a major version — now visible per board (A12).
Findings
Each is marked fix (change before the tag) or accepted (stays, written down here). A1–A5 were found while writing the beginner control pages (#320 / #324); A6–A22 come from the full review (chunk 1, 2026-10-25).
| # | Finding | Decision |
|---|---|---|
| A1 | ActionButton, RotaryEncoder and RotaryAcceleratedEncoder headers said a CTRL_* (HID) id is allowed, but PanelBridge drops HID-range action / REL / DIR frames — they only reach DCS. |
Accepted: DCS-only. The beginner pages only offer a joystick tab where HID works (Switch2Pos, AnalogInput). Header comments corrected in #327. |
| A2 | AnalogMultiPos(id, pin, n, 0) doesn't compile: a literal 0 matches both the deadband overload and the posVals (null pointer) overload. |
Fix: add static constexpr uint16_t NO_DEADBAND = 0; beside DEFAULT_DEADBAND. A named constant isn't a null-pointer constant, so it always picks the equal-spacing form (verified with a host compile). Additive, non-breaking; docs only ever show the named constants. |
| A3 | Switch3Pos takes debounceMs; SwitchMultiPos has its 20 ms fixed. |
Accepted. |
| A4 | AngleSensorInput takes no reverse or hysteresis (hardcoded false / DEFAULT_HYSTERESIS). |
Fix (reopened in chunk 2): AngleConfig { hysteresis, ewmaShift, pollMs }, hysteresis route-aware by default like AnalogInput and overridable. Still no reverse — the sensor's DIR pin. |
| A5 | AnalogInput defaults to hysteresis = 128, but both tested DCS-BIOS sketches (SoakRig, E2E) use 1024 — 128 floods DCS-BIOS import on a sweep. |
Fix: route-aware default. DCSIN_* (DCS-BIOS) → 1024; CTRL_* (HID axis) → 128, the value the axis calibration was validated with (#46/#251). Lands as HYSTERESIS_AUTO, the default of AnalogConfig::hysteresis (A10). The tuned fast-stick profile (hysteresis 32, ewmaShift 4, pollMs 2, with the 1 kΩ + 100 nF RC) stays an explicit per-instance choice. Confirm on the #290 soak. |
| A6 | Two sketch-facing calls take raw MCU pins, not PinRef: PanelGroup::registerExpander(chip, intaPin, intbPin) (EXTI lines) and ShiftBus(spi, sck, miso, mosi, load, latch) (a PinRef backend's own bus — circular otherwise; load/latch toggled from the TIM2 ISR). Nothing documents these as exceptions. |
Accepted + documented. No signature change. One paragraph in 05-panelgroup-api.md (PinRef) + TechSpec PinRef.md: control classes take a PinRef; backend wiring takes native MCU pins. |
| A7 | Unpinned third-party deps on the Gateway side: dcs-skunkworks/DCS-BIOS (none / *), Adafruit TinyUSB Library (none / >=3.0.0), and the RP2040 platform as a bare git URL tracking the repo's latest code. A v1.0.0 rebuild later can pull a different binary. |
Fix: pin to what's built today — DCS-BIOS@^0.3.10, TinyUSB ^<version CI resolves>, the platform URL #<tag or commit for 1.19.0> — in platformio.ini and library.json. Policy: raising a pin is ≥ minor, major if it changes the sketch API. |
| A8 | makeX27Config takes 15 positional arguments, with the per-gauge motor settings (stepsPerRev, rangeSteps, homeStepUs) last — a BKA-30 override means writing out 13 arguments first, and post-freeze the list can only grow at the end. |
Fix: StepperConfig gets default member values = today's X27 defaults, so a sketch writes { .maxPos = 960, .rangeSteps = 960 }; makeX27Config shrinks to (home, park, minPos, maxPos, homeMode = STALL) (every caller uses ≤ 5 args). Positional test initialisers that omit rangeSteps get an explicit value so their behaviour doesn't change. Policy precedent: config structs grow at the end, new fields always defaulted. |
| A9 | Nothing defines where the sketch API ends: libraries expose far more than sketches use, and mark internals inconsistently (PanelGroup banner, per-function notes in STM32Board, nothing on the CANProtocol runtime). | Fix: the policy page lists what's covered — role entry points (PanelGroup::setup/loop/registerExpander/registerADC, PanelBridge::setup/loop/onNodeAlive/onNodeDead, SimGateway::setup/loop, PduNode::setup/loop), STM32Board::begin/setDebug/isDebug/log/diagSerial, every class in OpenSkyhawk.h + DrumDisplay / ShiftBus / I2cMux (ctors, config structs/enums, factories, DEFAULT_*), PinRef ctors, the user-set build flags. Custom control classes are covered: InputBase / OutputBase virtuals + self-registration, PinRef methods, CANProtocol::sendBatched with ControlPacket / canIdEvt*, FaultSource. Not covered: family-base protected hooks (MultiPosInput, RotaryEncoder family ctor), clockFault / setWarning, the CANProtocol runtime, test seams. Code: label the 5 unmarked STM32Board internals + one CANProtocol banner (comment-only). Refresh the stale docs/guides/new-control-type.md — it now documents a covered interface. |
| A10 | AnalogInput(id, pin, reverse, minRaw, maxRaw, hysteresis, ewmaShift, pollMs) — six positional optionals; the beginner fast-stick line reads …, false, 0, 65535, 32, 4, 2. |
Fix: AnalogInput(id, pin, const AnalogConfig& cfg = {}) with defaulted fields (hysteresis = HYSTERESIS_AUTO, A5). Replaces the positional form; the 9 in-repo call sites are updated in the same PR. |
| A11 | The release version is stamped in places release-please doesn't update: SIMGATEWAY_FW_MAJOR/MINOR/PATCH (sent in HELLO_ACK, maintained by hand), PowerMonitor/library.json (missing from extra-files), 32 hand-kept @version file tags (0.1.0–0.4.0). At 1.0.0 the Gateway would still report 0.1.0. |
Fix: one release-managed version header with release-please x-release-please-major/-minor/-patch markers (SimGateway uses it; A12 uses it too); add PowerMonitor/library.json to extra-files; delete the @version tags. Verify no unmanaged version stamp remains. |
| A12 | No board on CAN reports its firmware version; only the Gateway's USB half does (HELLO_ACK), so the mixed-version rule can't be seen. HEALTH bytes 6–7 are reserved for future generic health, so they are not used for this. |
Fix: version in READY_n. READY (empty today) carries fwMajor / fwMinor / fwPatch in bytes 0–2, bytes 3–7 reserved; an empty READY = older firmware. PanelBridge caches it per node, keeps it across timeouts, pushes a _NODE_STATUS delta when it changes; _NODE_STATUS → v4 (+3 bytes, 34 hex chars), node-0 row = the bridge's own version. Every reflash / power cycle / reset re-sends READY. Accepted gap: if the Gateway boots after a node, that node reads "unknown" until it reboots (a SYNC_REQ re-announce can follow later as a minor). |
| A13 | Old readers reject anything new: the client drops any _NODE_STATUS row whose length isn't exactly 28 (only the v2 length gets an alert), so the first 1.x addition would blank every 1.0 client. No per-board out-of-date indication exists. |
Fix (rule + client): writers only append (new fields at the end / reserved bytes; moving, resizing or reinterpreting = major); readers parse the known prefix of a longer row; a shorter well-formed row → "Gateway firmware too old" alert; unknown enum values show as "unknown (0xNN)"; reserved = send 0, ignore on receive. With A12: per-board major-mismatch warning and "client too old". Growth: health → HEALTH reserved bytes; node telemetry → new TELEM pages; anything else → a new frame family; existing frames never grow. Client work lands in client 1.0 (see follow-ups). |
| A14 | ControlPacket's byte order isn't stated (every other multi-byte field in 02-can-protocol.md is). _NODE_STATUS prints numbers MSB-first while _NODE_TELEM forwards raw little-endian bytes. |
Fix (docs): state little-endian + a ControlPacketPair byte-offset table in 02; drop the stale Phase 1 migration note; the policy page states the byte-order rule once. No wire change. |
| A15 | HB uptime / rxCount are 16-bit and wrap (~18.2 h / ~131 s at full bus load); HB is a full frame. |
Accepted. Both stay as they are; the wrap is documented and the client only displays them. A node whose uptime drops near zero without going offline has rebooted — a possible client feature, not a freeze item. |
| A16 | _NODE_STATUS / _NODE_STATUS_END / _NODE_TELEM / the roster request exist only with -DPANELBRIDGE_NODE_STATUS, default off (on in the template). Node tracking itself (alive/dead, SYNC_REQ, WARNING LED, callbacks) is always on. |
Fix: default on, =0 to opt out (the NODE_HEALTH_TELEM pattern). Tests/PanelBridge envs whose assertions would see the extra lines get =0. Docs: build-flags.md, FirmwarePlan 04/06, TechSpec PanelBridge.md, the PanelBridge README. |
| A17 | The CDC calibration channel can't follow A13's "accept longer": LEN is fixed per type on purpose (false-frame rejection inside the DCS-BIOS text stream). |
Accepted + documented in 03 and the policy page: HELLO / HELLO_ACK never change; a new message type = minor (old firmware NACKs BAD_TYPE); any change to an existing type = proto bump = major. A13's rule covers the _NODE_* text lines only. |
| A18 | Freezing the ID map freezes arbitration order: HB / HEALTH / TELEM outrank EVT; REL / DIR / ACTION are lowest. | Accepted. Documented trade-off (≤ 1–2 frames/s per node, ≤ ~0.27 ms per input event). New families pick their priority by placement in the free ranges. The #290 soak's "no false offline / zero TX drops" checks are the guard. |
| A19 | TEST_SEQ / ECHO_n: a bench RTT diagnostic, always compiled in (PanelBridge sends it on T over DiagSerial; every node mirrors it). |
Accepted. Covered: the two IDs + "mirror all 8 bytes unchanged on ECHO_n". The payload contents are bridge-internal (only the bridge writes and reads them). |
| A20 | The Gateway's USB identity — VID 0x2E8A / PID 0x4134 / product "A-4E Skyhawk" — is self-assigned: 0x4134 isn't in Raspberry Pi's published PID list. DCS keys a player's bindings to the device name + VID/PID-derived GUID, so changing it after 1.0 breaks every user's bindings. |
Fix — moved out of this ticket. Application submitted 2026-10-25. When Raspberry Pi assigns a PID, a new issue switches setID + docs (the client syncs it); a daily reminder watches for the reply. No transition handling — the only boards are the maintainer's. Policy: VID, PID, product and CDC port strings are covered. |
| A21 | Two calibration stores survive a reflash (PDU flash page 0x0800FC00; the Gateway's EEPROM-emulation sector); a layout-version bump drops the stored calibration. |
Accepted: stored calibration is not covered and may reset on any upgrade. Gateway: the client offers to restore from its per-board cache (same PC). PDU: shows UNCAL VDDA / UNCAL ZERO; recalibrate at the bench over serial. One line each in 07 and TechSpec PowerMonitor.md. |
| A22 | DCSIN_* ids are append-only by design (id_ledger.json, 4 tests), but nothing checks the committed ledger, and the DCS-BIOS refresh workflow doesn't report what changed. |
Fix: gen_a4ec.py compares old vs new headers and the refresh PR body lists removed / moved A_4E_C_* / DCSIN_* symbols (additions stay feat(a4ec); the maintainer decides any version bump); the run fails if an existing DCSIN_* id would change. Policy: DCSIN_* ids are stable forever; A_4E_C_* addresses follow DCS-BIOS upstream. |
| A23 | A CAN bootloader and uploader are planned (client upload-utilities design); nothing reserved their number spaces. | Fix (docs): reserve CAN 0x740–0x7BF (BOOT_CMD_n = 0x740+n, BOOT_RSP_n = 0x780+n, lowest priority), calibration types 0x10–0x1F / 0x90–0x9F, NACK reasons 0x09–0x0E. |
| A24 | The roster-request export address 0x86FE is a live DCS-BIOS address for the AH-64D (PLT_VIDEO_FLIR_GAIN) and the Mirage F1 (P_VOR_01_G) — flying either drew a roster burst on every export of that control. |
Fix: NODE_STATUS_REQ_ADDR = 0xFF00 (no module uses it; all end by 0xB2FB, MetadataEnd at 0xFFFE). Decision D23. The client picks it up by re-sync. |
| A25 | The bridge's export window was 0x8000–0x86FF — the DCSIN_* input-ID range reused by accident, not the A-4E's block (0x8400–0x8555) — so it already passed parts of the AH-64D, MB-339 and Mirage F1. |
Fix (maintainer's choice): forward everything. 0x0002–0xFFFE, whatever aircraft; nodes keep their own addresses (multi-aircraft direction). 0x0000 skipped (CAN empty-slot sentinel). CTRL_BCAST load rises — confirm on the #290 soak. Decision D24. |
| A26 | A future DCS-BIOS release could put an aircraft on our reserved address or names (as happened to 0x86FE). |
Fix: the A4EC refresh checks every module in the release against NODE_STATUS_REQ_ADDR and the _NODE_* names (read from NodeStatus.h) and warns in the refresh PR. Run on v0.11.8: 0xFF00 clear; the old 0x86FE flagged for both aircraft. |
| A27 | DIAG (#358) has a fixed LEN (47), so a new link-health counter would need a new type or a protocol bump. |
Fix: DIAG is 64 bytes — 47 used, CAL_DIAG_RESERVED 47–63 (send 0, ignore) — so counters can be added as a minor. Unreleased, so nothing in the field changes. |
Review coverage
- Sketch API — constructors, entry points, config structs, build flags, extension interface (A6–A11)
- Payloads + append-only enums (A12–A15)
- Host-facing serial + CDC calibration (A16, A17, A27)
- CAN ID map (A18, A19, A23)
- UART HID frame + USB identity (A20)
- Persistent flash (A21)
- ID ledgers (A22)
- DCS-BIOS address space vs every aircraft module, v0.11.8 (A24–A26)
Chunk 2 — merged
#359 merged 2026-10-25 as 5771d0d2 (every fix above except A20). All 37 firmware projects built; new or extended on-target tests built but not yet run on hardware (node_status, hysteresis, inherits, equal_spacing, config_defaults). Releases as firmware 0.2.0 (pre-1.0, bump-minor-pre-major).
Still open: A20 (waiting on Raspberry Pi's PID).
Chunk 3 — merged
The policy page, Compatibility & Versioning (docs/firmware/compatibility.md), merged 2026-10-25 in #374. #290's "API + wire review" item is ticked.
Release: #373 dropped bump-minor-pre-major for firmware (strict Conventional Commits: a break bumps the major), so release draft #372 reads firmware 1.0.0. It stays a draft until 2026-11-22, after the #290 soak: anything merged before then — soak fixes, A20 — rides in 1.0.0 (the next version is computed from the last release, 0.2.0, so further breaking fixes don't make it 2.0).
Closed. A20 (the registered Gateway USB PID) continues outside this ticket: when Raspberry Pi assigns the PID, a new issue switches setID and the docs, the client re-syncs, and the maintainer adds the row to raspberrypi/usb-pid.
Client follow-ups (SkyHawkClient, milestone v0.2.0)
- feat: node status v4 — tolerant row parsing and the 0xFF00 roster request (firmware #359 re-sync) SkyHawkClient#170 — re-sync to
5771d0d2, tolerant_NODE_STATUSv4 parsing, roster request at0xFF00. Until it lands, don't run a feat(firmware)!: v1.0 freeze fixes — firmware version on the bus, config structs, pinned dependencies (#295) #359 Gateway against the client. - feat: show each board's firmware version and warn on a major mismatch SkyHawkClient#171 — show each board's firmware version; major-mismatch and client-too-old warnings.
- fix: identify the aircraft by _ACFT_NAME, not by export address range SkyHawkClient#172 — identify the aircraft by
_ACFT_NAME, not address range. - feat: read and show the Gateway's link-health counters (GET_DIAG) SkyHawkClient#169 —
DIAGis 64 bytes (A27).
Draft #339 (SoakRig) needs its two positional AnalogInput lines converted to AnalogConfig (its session has been told).
Delivery
- Chunk 1 — review (this body). Done.
- Chunk 2 — one fix PR (feat(firmware)!: v1.0 freeze fixes — firmware version on the bus, config structs, pinned dependencies (#295) #359, merged) for every fix row, spec + docs in the same PR (
FirmwarePlan,TechSpec,docs/firmware,docs/architecture),check_doc_refs.pybefore push,BEGIN_COMMIT_OVERRIDElisting each fix. - Chunk 3 — the policy page (
docs/firmware/compatibility.md) — merged in docs(firmware): v1.0 compatibility and versioning policy (#295) #374.
Done when
The review findings are all resolved or accepted in writing, the policy page is merged, and #290's "API + wire review" item is ticked.
Activity
feat(firmware): DrumDisplay takes a bus or a mux (#318)
ci(a4ec): make the change-report warning one explicit string (#295)
feat(firmware)!: move the roster request to export address 0xFF00 (#295…
docs(firmware): reserve ID ranges for the future firmware updater (#295…
feat(firmware): PanelBridge forwards the whole DCS-BIOS export stream (…
ci(a4ec): warn when a DCS-BIOS aircraft uses our roster address or me…
feat(firmware): reserve room in DIAG for future link-health counters (#…
feat(firmware)!: v1.0 freeze fixes — firmware version on the bus, con…
docs(firmware): v1.0 compatibility and versioning policy (#295) (#374)
test(firmware): adapt SoakRig to the #295 freeze API changes
docs(firmware): refresh SoakRig flash figures after the #295 freeze m…