Skip to content

feat(firmware)!: v1.0 freeze fixes — firmware version on the bus, config structs, pinned dependencies (#295) - #359

Merged
mottihoresh merged 21 commits into
mainfrom
docs/295-v1-freeze
Oct 25, 2026
Merged

mottihoresh merged 21 commits into
mainfrom
docs/295-v1-freeze

Conversation

@mottihoresh

@mottihoresh mottihoresh commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Chunk 2 of #295: every fix from the v1.0 API + wire-format freeze review, plus the doc-only rules the review accepted. The findings, their decisions and the coverage checklist are in the #295 body. A20 (registering the Gateway's USB PID) is waiting on Raspberry Pi and isn't in this PR.

One commit per finding; the squash keeps them apart through the override block below.

Firmware and API

# Change
A12 Every node announces its firmware version in READY_n (ReadyPayload, bytes 0–2; empty READY = older firmware). PanelBridge caches it per node, keeps it across timeouts, pushes a delta when it changes, and reports it in _NODE_STATUS v4 (34 hex; node-0 row = the bridge's own). HEALTH's reserved bytes stay free for health. Decision D21.
A11 One release-managed version: new header-only FirmwareVersion library (OPENSKYHAWK_FW_* with x-release-please-* markers, verified against release-please's generic updater). SimGateway's HELLO_ACK and READY both read it. PowerMonitor added to extra-files; the 32 hand-kept @version tags removed.
A16 Node-status reporting on by default (-DPANELBRIDGE_NODE_STATUS=0 opts out). The two input_dispatch tests opt out because they read the USB-UART stream.
A10 + A5 AnalogInput(id, pin, AnalogConfig) replaces six positional optionals: { .hysteresis = 32, .ewmaShift = 4, .pollMs = 2 }. Hysteresis defaults by route: 1024 for DCSIN_*, 128 for CTRL_*; an explicit value wins.
A4 (reopened) AngleSensorInput takes an AngleConfig: hysteresis is now overridable (route-aware by default).
A8 StepperConfig carries the X27 defaults, so { .maxPos = 960, .rangeSteps = 960 } works; makeX27Config keeps its common 5-argument form.
A2 AnalogMultiPos::NO_DEADBAND.
A7 DCS-BIOS ^0.3.10 and TinyUSB ^3.7.7 bounded; the RP2040 platform pinned to 79ce473e (the bench Gateway's build).
A22 The A4EC refresh lists removed/moved symbols in its PR body and refuses to change an existing DCSIN_* id.
A24 The roster request moves from export address 0x86FE to 0xFF00. In DCS-BIOS v0.11.8, 0x86FE is a live address for the AH-64D (PLT_VIDEO_FLIR_GAIN) and the Mirage F1 (P_VOR_01_G), so flying either drew a roster burst on every export of that control. No module uses 0xFF00 (all end by 0xB2FB). Decision D23.
A25 PanelBridge forwards the whole DCS-BIOS export stream (0x0002–0xFFFE) whatever aircraft is flying; nodes keep only their own addresses. The old window (0x8000–0x86FF) was the input-ID range reused by accident. Only 0x0000 is skipped (CAN's empty-slot marker). CTRL_BCAST load rises — confirm on the soak. Decision D24.
A27 DIAG grows to 64 bytes, with bytes 47–63 reserved (send 0, ignore on receive). A calibration frame's length is fixed per type, so this leaves room for new link-health counters as a minor change. Unreleased (#358), so nothing in the field changes.
A26 The A4EC refresh warns if any aircraft in a new DCS-BIOS release uses our roster-request address or _NODE_* names.

Breaking (pre-1.0, so a minor bump): A10/A5/A4, A8 and A24, each marked ! below. All in-repo call sites, tests and docs are updated.

Docs and specs

A9 (library-internal marks on STM32Board/CANProtocol; new-control-type.md rewritten around the covered InputBase/OutputBase interface, with a compile-checked example), A6 (PinRef native-pin exceptions), A13 (forward compatibility, decision D22), A14 (ControlPacket byte order), A17 (calibration channel's own rule; BAD_TYPE is unreachable), A19 (TEST_SEQ payload is bridge-internal), A21 (stored calibration isn't covered), A23 (CAN 0x740–0x7BF and calibration types 0x10–0x1F / 0x90–0x9F, NACK reasons 0x09–0x0E, reserved for the future firmware updater). FirmwarePlan, TechSpec, docs/firmware and docs/architecture are updated in the same commits as the code they describe. The compatibility policy page itself is chunk 3, a separate PR.

Verification

  • All 37 firmware projects build, every environment, from clean dependency caches.
  • New/extended on-target tests (built, not yet run on hardware): node_status (v4 rows, version delta, retention across timeout), hysteresis and inherits (route-aware defaults and overrides), equal_spacing (NO_DEADBAND), config_defaults (StepperConfig defaults).
  • check_doc_refs.py, impact map, check_node_ids.py and the A4EC pytest suite (34) pass; changed-Markdown link check clean.
  • -DPANELBRIDGE_NODE_STATUS=0 build contains no _NODE_STATUS code; default builds do.

Follow-ups

  • SkyHawkClient#170: re-sync to this PR's merge commit; tolerant _NODE_STATUS v4 parsing; roster request at 0xFF00. Until it lands, don't run a Gateway flashed from this PR against the client.
  • SkyHawkClient#171: show each board's firmware version; warn on a board/Gateway major mismatch and on "client too old".
  • SkyHawkClient#172: identify the aircraft by _ACFT_NAME, not by address range (the AH-64D and MB-339 overlap the A-4E's addresses).
  • SkyHawkClient#169 (GET_DIAG decoder): DIAG is now 64 bytes (CAL_DIAG_BYTES via sync); ignore CAL_DIAG_RESERVED.
  • Draft test(firmware): add SoakRig node sketches for the #291 soak rig #339 (SoakRig): two positional AnalogInput lines need the AnalogConfig form after this lands; its FULL_4STATE helper can become { .pattern = StepPattern::FULL_4STATE, .maxPos = 300 }.
  • A20: switch the Gateway's PID once Raspberry Pi assigns one (no transition handling needed — the only boards are the maintainer's).

BEGIN_COMMIT_OVERRIDE
build(firmware): bound DCS-BIOS and TinyUSB ranges, pin the RP2040 platform (#295)
build(firmware): one release-managed firmware version (#295)
feat(firmware): nodes announce their firmware version in READY_n, _NODE_STATUS v4 (#295)
feat(firmware): PanelBridge node-status reporting on by default (#295)
feat(firmware): AnalogMultiPos::NO_DEADBAND (#295)
feat(firmware)!: AnalogInput and AngleSensorInput take a config struct; hysteresis defaults by route (#295)
feat(firmware)!: StepperConfig carries the X27 defaults; makeX27Config takes 5 arguments (#295)
feat(firmware)!: move the node-roster request to export address 0xFF00 (#295)
docs(firmware): mark library-internal APIs and document the control-class interface (#295)
docs(firmware): v1.0 wire compatibility rules, byte order and PinRef exceptions (#295)
docs(firmware): reserve CAN and calibration ID ranges for the future firmware updater (#295)
ci(a4ec): report removed or moved symbols on a DCS-BIOS refresh; guard DCSIN ids (#295)
feat(firmware): PanelBridge forwards the whole DCS-BIOS export stream (#295)
ci(a4ec): warn when a DCS-BIOS aircraft uses our roster address or message names (#295)
feat(firmware): reserve room in DIAG for future link-health counters (#295)
END_COMMIT_OVERRIDE

…atform (#295 A7)

DCS-BIOS (*) and Adafruit TinyUSB (>=3.0.0) accepted any future major,
and the RP2040 platform was a bare git URL that builds the fork's latest
code. Bound the libraries to ^0.3.10 / ^3.7.7 (what releases resolve
today) and pin the platform to 79ce473e, the commit the bench Gateway is
built on. The release job still records what each release resolved.
The Gateway reported 0.1.0 in HELLO_ACK from hand-kept SIMGATEWAY_FW_*
defines that release-please never touched, PowerMonitor's library.json
was missing from extra-files, and 32 file headers carried their own
@Version stamps (0.1.0-0.4.0) that meant nothing.

Add the header-only FirmwareVersion library: OPENSKYHAWK_FW_MAJOR/MINOR/
PATCH carry release-please's x-release-please-* markers and are rewritten
on every firmware release (generic updater). SimGateway's HELLO_ACK reads
it; READY_n will too. Add it and PowerMonitor to extra-files, delete the
@Version tags, and move the doc citations they shifted.
…DE_STATUS v4 (#295 A12)

v1.0 says nodes on one bus share a major version, but nothing on the bus
reported which firmware a board runs. READY_n, empty until now, carries a
ReadyPayload: bytes 0-2 = the release version from FirmwareVersion.h,
3-7 reserved. Nodes send it through the new CANProtocol::sendReady().

PanelBridge caches the version per node, keeps it across a timeout,
pushes a _NODE_STATUS delta when an alive node's version changes (a
reflash inside the heartbeat timeout never goes dead), and reports it in
_NODE_STATUS v4 (34 hex; node-0 row = the bridge's own build). An empty
READY from older firmware reads 0.0.0, not reported. HEALTH's reserved
bytes stay free for future health. Decision D21.

The node_status test now covers the version delta and its retention
across a timeout; node-imitating test sketches send the real READY.
…A16)

_NODE_STATUS, _NODE_STATUS_END, _NODE_TELEM and the 0x86FE roster request
existed only with -DPANELBRIDGE_NODE_STATUS, off in the library and on in
the template. The client depends on them and v1.0 freezes them, so a
PanelBridge env built without the template silently showed an empty
Cockpit.

PanelBridge.h now defaults the flag to 1 and the code tests it with #if,
so -DPANELBRIDGE_NODE_STATUS=0 compiles it out (the NODE_HEALTH_TELEM
pattern). The template and E2E drop the now-redundant flag; the two
input_dispatch tests, which read the USB-UART stream, opt out. Node
tracking itself never depended on the flag.
AnalogMultiPos(id, pin, n, 0) doesn't compile: a literal 0 matches both
the deadband overload and the posVals (null pointer) overload. A named
constant isn't a null-pointer constant, so NO_DEADBAND always picks the
equal-spacing form. Additive. The equal_spacing test now builds one and
checks the bands meet with no gap.
…t; hysteresis defaults by route (#295 A10, A5, A4)

A10: AnalogInput(id, pin, reverse, minRaw, maxRaw, hysteresis, ewmaShift,
pollMs) had six positional optionals, so tuning the dead zone meant
writing reverse and the raw range first. It now takes one AnalogConfig,
every field defaulted and set by name:
  AnalogInput roll(CTRL_ROLL, ROLL_PIN, { .hysteresis = 32, .ewmaShift = 4, .pollMs = 2 });
The tuning constants move onto AnalogConfig.

A5: the hysteresis default is route-aware (HYSTERESIS_AUTO): 1024 for a
DCSIN_* id, which both tested DCS-BIOS sketches used because 128 floods
DCS-BIOS import on a sweep, and 128 for a CTRL_* id, the value the axis
calibration was validated with. An explicit value always wins.

A4 (reopened): AngleSensorInput's hysteresis was fixed at 128. It now
takes an AngleConfig (hysteresis, ewmaShift, pollMs; route-aware by
default) and can be overridden. Still no reverse: the sensor's DIR pin.

BREAKING CHANGE: AnalogInput's positional tuning arguments are replaced
by an AnalogConfig; AngleSensorInput's pollMs argument by an AngleConfig;
AnalogInput::DEFAULT_HYSTERESIS is gone and the other tuning constants
are AnalogConfig::DEFAULT_*. A DCSIN_* AnalogInput or AngleSensorInput
with no hysteresis set now uses 1024.
…g takes 5 arguments (#295 A8)

makeX27Config took 15 positional arguments with the per-gauge motor
settings (stepsPerRev, rangeSteps, homeStepUs) last, so a 320-degree
BKA-30 meant writing 13 arguments first, and after the freeze the list
could only grow at the end.

Every StepperConfig field now defaults to the X27/VID-29/BKA-30 family,
so a sketch names only what differs:
  const StepperConfig OIL_MOTOR = { .maxPos = 960, .rangeSteps = 960 };
makeX27Config keeps the common case: (homePosition, parkPosition, minPos,
maxPos, home = STALL), and returns exactly the defaults plus those.
Positional test initialisers that stopped before rangeSteps now pass 0
explicitly, so their homing distance is unchanged. New test:
config_defaults.

BREAKING CHANGE: makeX27Config drops its 10 trailing optional arguments;
set those fields by name on a StepperConfig instead. A positional
StepperConfig initialiser that omits rangeSteps now gets 945, not 0.
…lass interface (#295 A9)

v1.0 covers a defined sketch API, including the interface a custom control
class is written against. Libraries marked their internals inconsistently,
so a header alone couldn't tell a builder what is frozen.

STM32Board: the seven functions outside the covered list (tick,
setWarning, setLinkActive, clockFault, readDieTempC, readVddMv,
logNodeFaultEdge) now say they are library-internal and not covered, like
onCanStatus and canHandle already did. CANProtocol: one banner on the
runtime namespace — internal except sendBatched, which a custom class
uses.

docs/guides/new-control-type.md is rewritten: it said most control types
were unimplemented, described dispatch types #147 removed, and told
authors to manage batch flushing. It now documents the covered
InputBase/OutputBase interface, the frame families, and a compile-checked
example.
Every control class takes a PinRef; the rule's two exceptions were only a
convention. PanelGroup::registerExpander's INTA/INTB lines need a native
EXTI pin, and ShiftBus's own SPI and load/latch lines can't be shift-
register PinRefs. FirmwarePlan 05 and the PinRef TechSpec now name them
as the only two, and 05's backend table gains the missing ShiftBus row.
…growth (#295 A13)

The client dropped any _NODE_STATUS row that wasn't exactly 28 hex, so the
first 1.x field would have blanked every 1.0 client. Write down the
forward-compatibility rule as decision D22:

- CAN (02): frames never grow, fields never move; reserved = send 0,
  ignore; unknown enum values kept; new data goes to HEALTH's reserved
  bytes (health), a new TelemPage (node telemetry), READY's reserved
  bytes or a new frame family in a free ID range (anything else).
- Host lines (04): writers append; readers parse a longer row's known
  prefix, report a shorter one as old firmware, show unknown values as
  unknown, and warn on a board/Gateway major mismatch.

The client side lands in client 1.0.
Every other multi-byte field in 02-can-protocol.md stated its byte order;
ControlPacket, the most-used payload on the bus, only implied it through a
packed struct on a little-endian MCU. State it (little-endian, controlId
low byte first) with a ControlPacketPair byte table in 02 and one line in
the published can-bus page, fix 02's "CTRL_BCAST and EVT_n only" to
include the REL/DIR/ACTION frames, and drop the long-done Phase 1
migration note.
TEST_SEQ/ECHO_n is a bench RTT diagnostic. Record what the freeze covers —
the two IDs and every node mirroring the 8 bytes unchanged — and label the
payload layout bridge-internal, since only PanelBridge writes and reads
it. Drop the stale prototype note about PanelGroup's old 8-byte TEST_SEQ.
…A17)

A13's "readers accept longer" can't apply to the USB CDC calibration
channel: LEN is fixed per type on purpose, so a longer payload is not a
frame to an older reader. Write down how it grows instead: HELLO/
HELLO_ACK never change; a new message type is a minor (older firmware
relays it and the client times out, as GET_DIAG does); any change to an
existing type bumps CAL_PROTO_VERSION and is a major.

#358's code also shows the BAD_TYPE NACK is unreachable — an unknown
type fails calLenValidForType() first — so its row now says so.
…A21)

Both calibration stores survive a normal reflash, but nothing said whether
a release may reset them. It may: the Gateway's blob layout or EEPROM
sector, or the PDU's record layout or page, can change in any release.
The Gateway's client offers to restore from its per-board cache; the PDU
reports UNCAL_* and is recalibrated at the bench. One line each in 07
and the PowerMonitor TechSpec.
…d DCSIN ids (#295 A22)

The refresh workflow always titled its PR feat(a4ec) and only checked that
the generator's tests passed and the repo's sketches compiled — not what
changed. Upstream additions are the normal case, but a removed symbol
breaks a user's sketch build and a moved A_4E_C_* address silently breaks
a lamp or gauge.

gen_a4ec.py now compares the committed headers with the new output and
prints added/removed/moved symbols (--change-report writes the Markdown);
the refresh PR body includes it, and the maintainer decides any version
bump. It also refuses to run when an existing DCSIN_* id would change —
the ledger is append-only, and until now only the generator logic was
tested, not the committed file. Three new pytest cases.
Comment thread tools/gen_a4ec/gen_a4ec.py Fixed
… A24)

The client requested the node roster by writing DCS-BIOS export address
0x86FE, chosen because the A-4E never exports there. In DCS-BIOS v0.11.8
it is a live address for the AH-64D (PLT_VIDEO_FLIR_GAIN) and the Mirage
F1 (P_VOR_01_G), so flying either with the cockpit connected answered
every export of that control with a full roster burst.

NODE_STATUS_REQ_ADDR is now 0xFF00: every aircraft module ends by 0xB2FB
and MetadataEnd sits at 0xFFFE. It ships with _NODE_STATUS v4, which the
client adopts anyway. Decision D23.

BREAKING CHANGE: the client must write 0xFF00, not 0x86FE, to request the
roster (synced from NodeStatus.h).
… A23)

A CAN bootloader and uploader are planned. Reserve their number spaces now
so nothing else claims them before they exist:
- CAN 0x740-0x7BF (BOOT_CMD_n = 0x740+n, BOOT_RSP_n = 0x780+n), the lowest
  priority on the bus so update traffic never delays a control;
- calibration-channel message types 0x10-0x1F / 0x90-0x9F and NACK
  reasons 0x09-0x0E.
Comments at the owners (CANProtocol.h, AxisCal.h), FirmwarePlan 02/03 and
the published can-bus frame table. Nothing is assigned yet.
…#295 A25)

The export window was 0x8000-0x86FF — the DCSIN_* input-ID range reused by
accident, not the A-4E's export block (0x8400-0x8555) — so it already
passed parts of the AH-64D, MB-339 and Mirage F1 to the cockpit.

The bridge now forwards every export address (0x0002-0xFFFE): whatever
aircraft is flying, plus CommonData and the metadata. Nodes already hand
every packet to every output, and each keeps only its own address, so
the cockpit decides what it uses (the multi-aircraft direction). Only
0x0000 is skipped: it is the ControlPacket empty-slot sentinel.

CTRL_BCAST load rises (CommonData, per-frame update counters); confirm
on the #290 soak. Decision D24.
…ssage names (#295 A26)

The roster request moved off 0x86FE because DCS-BIOS gave that address to
the AH-64D and the Mirage F1 after it was picked. To catch the same thing
early, the refresh now checks every aircraft in the release against the
roster-request address and the _NODE_* message names (read from
NodeStatus.h) and adds a warning to the refresh PR if any clashes. It
warns rather than fails. Run against DCS-BIOS v0.11.8 it reports 0xFF00
clear and flags both aircraft for the old 0x86FE. Three pytest cases.
…295 A27)

A calibration frame's LEN is fixed per type (A17), so DIAG couldn't grow
in place: a new counter would need a new message type or a protocol bump.
DIAG (#358) is unreleased and the record most likely to grow, so it is
now 64 bytes — 47 in use, CAL_DIAG_RESERVED 47-63 transmitted as 0 and
ignored on receive — and a new counter fills reserved bytes as a minor
change. The client's DIAG decoder (SkyHawkClient#169) syncs the new
length; the bench tool skips the reserved bytes.
mottihoresh added a commit that referenced this pull request Oct 25, 2026
#359 landed the v1.0 API freeze on main. Two changes reach these sketches.

AnalogInput now takes (controlId, PinRef, const AnalogConfig&) and nothing
positional, so instrPot and floodPot lose their six-argument form. Both drop
their tuning entirely rather than restate it: hysteresis defaults to
HYSTERESIS_AUTO, which resolves to AnalogConfig::DCS_HYSTERESIS (1024) for a
DCSIN_* id -- exactly what they were passing. floodPot now declares identically
to instrPot, which is the point of the station pair: only the PinRef differs.

StepperConfig fields default to the X27 family, so the FULL_4STATE alternate
build is a plain designated initializer and the makeCanopy4StateConfig()
helper goes away. The comment keeps why it is spelled out rather than built
from makeX27Config(): that helper fixes the pattern, so the older form had to
overwrite the field after the call.

AngleSensorInput and the makeX27Config calls compile unchanged -- the new
AngleConfig is an optional trailing argument these sketches never passed.

Builds: board A 73.6 % flash of a C8, board B 48.4 % of a CB. Board B's
revision follows in the merge of fd31eeb.

Refs #291
mottihoresh added a commit to OpenSkyHawk/SkyHawkClient that referenced this pull request Oct 27, 2026
Adds `CLAUDE.md`, the repo's agent instructions. The repo had none; it
links to `ARCHITECTURE.md` and `PRD.md` rather than repeating them.

**Rules:**
- **Release facts:** never say a feature is "in firmware vX" without
`git merge-base --is-ancestor <commit> firmware-vX` in OpenSkyhawk.
Firmware before 1.0.0 is test-only; 1.0.0 is the first public release.
Background: the `firmware-v0.2.0` tag was cut 7 minutes before
OpenSkyHawk/OpenSkyhawk#359, so "from firmware v0.2.0" in #169 and #171
was wrong.
- **Minimum firmware:** a client PR that depends on a firmware change
names the firmware commit or release it needs, in the PR body.
- **No magic numbers:** every address, code, enum and layout value comes
from `npm run sync`; if the sync doesn't extract it yet, extend
`scripts/sync-a4ec.ts` (example: `NODE_FORGET_REQ_ADDR`, #188). Points
to the existing `_ACFT_NAME` exception in `ARCHITECTURE.md` so the two
files agree.
- **Ticket closure:** each box ticked with its evidence ("— #PR") or
moved to a named follow-up before a ticket closes.
- **Firmware claims get a review:** ticket text stating firmware
behaviour, wire format or release facts goes to the firmware
(OpenSkyhawk) session before it reaches the user.

## Verification
- Docs only; no code changes.
- `npx prettier --check CLAUDE.md` passes.
- The `ARCHITECTURE.md` anchor link matches its "Firmware values come
from `npm run sync`" heading.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant