Skip to content

docs(firmware): v1.0 API + wire-format freeze review and compatibility policy #295

Closed

Description

@mottihoresh

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 — PinRef everywhere, 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 HB 0x100, HEALTH 0x140, TELEM 0x180, EVT 0x200, ECHO 0x300, READY 0x400, REL 0x500, DIR 0x600, ACTION 0x700 — and with them the arbitration order (A18).
  • Payload layouts (ControlPacket / ControlPacketPair, HeartbeatPayload, NodeHealthPayload incl. nodeKind, TelemPayload pages), 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 + the 0x86FE roster 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)

Draft #339 (SoakRig) needs its two positional AnalogInput lines converted to AnalogConfig (its session has been told).

Delivery

  1. Chunk 1 — review (this body). Done.
  2. 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.py before push, BEGIN_COMMIT_OVERRIDE listing each fix.
  3. 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

  1. added this to the Firmware v1.0.0 milestone on Sep 21, 2026
  2. added theissue type on Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    docsDocumentation site contentfirmwareFirmware implementation workpriority: highCritical path; do next

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions