From 123183db6a21b42a160318f8ef89173815124217 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 11:48:22 -0400 Subject: [PATCH 01/21] build(firmware): bound DCS-BIOS and TinyUSB ranges, pin the RP2040 platform (#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. --- .github/workflows/release-please.yml | 4 +++- Firmware/Examples/E2E_DCS_Test/PanelBridge/platformio.ini | 2 +- Firmware/Examples/E2E_DCS_Test/SimGateway/platformio.ini | 4 ++-- Firmware/Libraries/PanelBridge/library.json | 2 +- Firmware/Libraries/SimGateway/library.json | 2 +- Firmware/ScratchPad/TechSpec/SimGateway.md | 4 ++-- Firmware/Templates/PanelBridge/platformio.ini | 2 +- Firmware/Templates/SimGateway/platformio.ini | 7 +++++-- Firmware/Tests/PanelBridge/platformio.ini | 2 +- Firmware/Tests/SimGateway/platformio.ini | 8 ++++---- 10 files changed, 21 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index a14b93b9..3876be44 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -46,8 +46,10 @@ jobs: manifest-file: .release-please-manifest.json firmware-dependencies: - # Third-party versions are declared as ranges (^2.0.0, >=3.0.0), so + # Third-party libraries are declared as caret ranges (^2.0.0, ^3.7.7), so # record what a release actually resolved to rather than pinning them. + # (The RP2040 platform is the exception: a git URL has no range, so it is + # pinned to a commit.) name: Record third-party versions needs: release-please if: needs.release-please.outputs.firmware_released == 'true' diff --git a/Firmware/Examples/E2E_DCS_Test/PanelBridge/platformio.ini b/Firmware/Examples/E2E_DCS_Test/PanelBridge/platformio.ini index d736893f..b744bbeb 100644 --- a/Firmware/Examples/E2E_DCS_Test/PanelBridge/platformio.ini +++ b/Firmware/Examples/E2E_DCS_Test/PanelBridge/platformio.ini @@ -26,7 +26,7 @@ lib_deps = PanelBridge CANProtocol A4EC - dcs-skunkworks/DCS-BIOS + dcs-skunkworks/DCS-BIOS@^0.3.10 upload_protocol = stlink ; Some STM32F103CB clones require the CPUTAPID override below. diff --git a/Firmware/Examples/E2E_DCS_Test/SimGateway/platformio.ini b/Firmware/Examples/E2E_DCS_Test/SimGateway/platformio.ini index 54012d8d..00c3e391 100644 --- a/Firmware/Examples/E2E_DCS_Test/SimGateway/platformio.ini +++ b/Firmware/Examples/E2E_DCS_Test/SimGateway/platformio.ini @@ -1,5 +1,5 @@ [env:E2E_SimGateway] -platform = https://github.com/maxgerhardt/platform-raspberrypi.git +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#79ce473e1a8010ed8096388690222fd2c94858a6 ; Change board to match your module: rpipico, rpipico2, waveshare_rp2040_zero, etc. board = rpipico board_build.core = earlephilhower @@ -13,6 +13,6 @@ lib_extra_dirs = ${PROJECT_DIR}/../../../Libraries lib_deps = HIDControls SimGateway - adafruit/Adafruit TinyUSB Library + adafruit/Adafruit TinyUSB Library@^3.7.7 monitor_speed = 250000 diff --git a/Firmware/Libraries/PanelBridge/library.json b/Firmware/Libraries/PanelBridge/library.json index b69bac43..70eab2d6 100644 --- a/Firmware/Libraries/PanelBridge/library.json +++ b/Firmware/Libraries/PanelBridge/library.json @@ -18,6 +18,6 @@ "CANProtocol": "*", "NodeStatus": "*", "A4EC": "*", - "DCS-BIOS": "*" + "DCS-BIOS": "^0.3.10" } } diff --git a/Firmware/Libraries/SimGateway/library.json b/Firmware/Libraries/SimGateway/library.json index 412d285c..50986eba 100644 --- a/Firmware/Libraries/SimGateway/library.json +++ b/Firmware/Libraries/SimGateway/library.json @@ -15,6 +15,6 @@ "raspberrypi" ], "dependencies": { - "adafruit/Adafruit TinyUSB Library": ">=3.0.0" + "adafruit/Adafruit TinyUSB Library": "^3.7.7" } } diff --git a/Firmware/ScratchPad/TechSpec/SimGateway.md b/Firmware/ScratchPad/TechSpec/SimGateway.md index c375cbc0..37a750ba 100644 --- a/Firmware/ScratchPad/TechSpec/SimGateway.md +++ b/Firmware/ScratchPad/TechSpec/SimGateway.md @@ -52,7 +52,7 @@ Firmware/SimGateway/ "frameworks": "arduino", "platforms": "raspberrypi", "dependencies": { - "adafruit/Adafruit TinyUSB Library": ">=3.0.0" + "adafruit/Adafruit TinyUSB Library": "^3.7.7" } } ``` @@ -159,7 +159,7 @@ No USB host is required. USB CDC is used only as the on-device test report conso ```ini [env_base] -platform = https://github.com/maxgerhardt/platform-raspberrypi.git +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#79ce473e1a8010ed8096388690222fd2c94858a6 board = rpipico board_build.core = earlephilhower framework = arduino diff --git a/Firmware/Templates/PanelBridge/platformio.ini b/Firmware/Templates/PanelBridge/platformio.ini index bc9e019b..bd3fb7a1 100644 --- a/Firmware/Templates/PanelBridge/platformio.ini +++ b/Firmware/Templates/PanelBridge/platformio.ini @@ -27,7 +27,7 @@ lib_deps = PanelBridge CANProtocol A4EC - dcs-skunkworks/DCS-BIOS + dcs-skunkworks/DCS-BIOS@^0.3.10 upload_protocol = stlink ; Some STM32F103CB clones require the CPUTAPID override below. diff --git a/Firmware/Templates/SimGateway/platformio.ini b/Firmware/Templates/SimGateway/platformio.ini index fdf32e83..3f7dad11 100644 --- a/Firmware/Templates/SimGateway/platformio.ini +++ b/Firmware/Templates/SimGateway/platformio.ini @@ -1,5 +1,8 @@ [env:SimGateway] -platform = https://github.com/maxgerhardt/platform-raspberrypi.git +; Pinned to a commit: a git platform has no version range, so an unpinned URL builds whatever the +; fork's latest code is. Raising it changes the RP2040 core (USB stack, flash layout) — rebuild, +; bench the Gateway, then move every SimGateway env together. +platform =https://github.com/maxgerhardt/platform-raspberrypi.git#79ce473e1a8010ed8096388690222fd2c94858a6 ; Change board to match your module: rpipico, rpipico2, waveshare_rp2040_zero, etc. board = rpipico board_build.core = earlephilhower @@ -14,6 +17,6 @@ lib_extra_dirs = ${PROJECT_DIR}/../../Libraries lib_deps = HIDControls SimGateway - adafruit/Adafruit TinyUSB Library + adafruit/Adafruit TinyUSB Library@^3.7.7 monitor_speed = 250000 diff --git a/Firmware/Tests/PanelBridge/platformio.ini b/Firmware/Tests/PanelBridge/platformio.ini index f76e82ec..37ad1cbe 100644 --- a/Firmware/Tests/PanelBridge/platformio.ini +++ b/Firmware/Tests/PanelBridge/platformio.ini @@ -21,7 +21,7 @@ lib_deps = STM32Board A4EC HIDControls - dcs-skunkworks/DCS-BIOS + dcs-skunkworks/DCS-BIOS@^0.3.10 upload_protocol = stlink monitor_speed = 115200 diff --git a/Firmware/Tests/SimGateway/platformio.ini b/Firmware/Tests/SimGateway/platformio.ini index 22fbdb74..2d166858 100644 --- a/Firmware/Tests/SimGateway/platformio.ini +++ b/Firmware/Tests/SimGateway/platformio.ini @@ -2,7 +2,7 @@ src_dir = tests [env_base] -platform = https://github.com/maxgerhardt/platform-raspberrypi.git +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#79ce473e1a8010ed8096388690222fd2c94858a6 board = rpipico board_build.core = earlephilhower framework = arduino @@ -68,7 +68,7 @@ build_src_filter = -<*> + ; framework libraries. Force the include path directly so SimGateway.cpp can find it. ; -DUSE_TINYUSB selects the TinyUSB USB stack (equivalent to Arduino IDE Tools→USB Stack→Adafruit TinyUSB). [env:test_hid_enumeration] -platform = https://github.com/maxgerhardt/platform-raspberrypi.git +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#79ce473e1a8010ed8096388690222fd2c94858a6 board = rpipico board_build.core = earlephilhower framework = arduino @@ -85,7 +85,7 @@ build_src_filter = -<*> + ; EEPROM path out, so persistence can only be exercised here. Two phases separated by a ; power cycle; the sketch decides which to run from what setup() loaded. [env:test_axis_cal_persist] -platform = https://github.com/maxgerhardt/platform-raspberrypi.git +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#79ce473e1a8010ed8096388690222fd2c94858a6 board = rpipico board_build.core = earlephilhower framework = arduino @@ -103,7 +103,7 @@ build_src_filter = -<*> + ; No lib_deps — sketch uses only Arduino.h + Adafruit_TinyUSB.h directly. ; PID 0x4135 (vs 0x4134 for production) avoids OS device-cache conflicts. [env:test_hid_stress] -platform = https://github.com/maxgerhardt/platform-raspberrypi.git +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#79ce473e1a8010ed8096388690222fd2c94858a6 board = rpipico board_build.core = earlephilhower framework = arduino From 0fe91dc1d5f13c7fd2c12462a4df056eb5def8b0 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 11:52:23 -0400 Subject: [PATCH 02/21] build(firmware): one release-managed firmware version (#295 A11) 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. --- Firmware/Libraries/CANProtocol/CANProtocol.h | 1 - Firmware/Libraries/DrumDisplay/DrumDisplay.h | 1 - .../FirmwareVersion/FirmwareVersion.h | 20 ++++++++++++++++ .../Libraries/FirmwareVersion/library.json | 10 ++++++++ Firmware/Libraries/HIDControls/HIDControls.h | 1 - Firmware/Libraries/NodeStatus/NodeStatus.h | 1 - Firmware/Libraries/PanelBridge/PanelBridge.h | 1 - Firmware/Libraries/PanelGroup/ADS1115.h | 1 - .../Drivers/MotorDriver/MotorDriver.h | 1 - .../Drivers/StepperMotor/StepperMotor.h | 1 - .../PanelGroup/Helpers/I2cHealth/I2cHealth.h | 1 - .../PanelGroup/Helpers/I2cMux/I2cMux.h | 1 - .../PanelGroup/Helpers/ShiftBus/ShiftBus.h | 1 - .../Inputs/ActionButton/ActionButton.h | 1 - .../Inputs/AnalogInput/AnalogInput.h | 1 - .../Inputs/AnalogMultiPos/AnalogMultiPos.h | 1 - .../AngleSensorInput/AngleSensorInput.h | 1 - .../Inputs/MultiPosInput/MultiPosInput.h | 1 - .../RotaryAcceleratedEncoder.h | 1 - .../Inputs/RotaryEncoder/RotaryEncoder.h | 1 - .../PanelGroup/Inputs/Switch2Pos/Switch2Pos.h | 1 - .../PanelGroup/Inputs/Switch3Pos/Switch3Pos.h | 1 - .../Inputs/SwitchMultiPos/SwitchMultiPos.h | 1 - .../SwitchWithCover2Pos/SwitchWithCover2Pos.h | 1 - .../Outputs/AnalogOutput/AnalogOutput.h | 1 - .../PanelGroup/Outputs/Dimmer/Dimmer.h | 1 - .../Outputs/IntegerOutput/IntegerOutput.h | 1 - .../Libraries/PanelGroup/Outputs/LED/LED.h | 1 - .../Outputs/NeedleGauge/NeedleGauge.h | 1 - Firmware/Libraries/PanelGroup/PanelGroup.h | 1 - Firmware/Libraries/PanelGroup/PinRef.h | 1 - Firmware/Libraries/STM32Board/STM32Board.h | 1 - Firmware/Libraries/SimGateway/AxisCal.h | 3 +-- Firmware/Libraries/SimGateway/SimGateway.cpp | 3 ++- Firmware/Libraries/SimGateway/SimGateway.h | 7 ------ Firmware/Libraries/SimGateway/library.json | 1 + .../FirmwarePlan/03-uart-usb-hid-protocol.md | 2 +- .../TechSpec/PanelGroup/Inputs/AnalogInput.md | 2 +- Firmware/ScratchPad/TechSpec/README.md | 5 +++- docs/firmware/build-flags.md | 24 +++++++++---------- docs/firmware/index.md | 1 + release-please-config.json | 3 +++ 42 files changed, 56 insertions(+), 55 deletions(-) create mode 100644 Firmware/Libraries/FirmwareVersion/FirmwareVersion.h create mode 100644 Firmware/Libraries/FirmwareVersion/library.json diff --git a/Firmware/Libraries/CANProtocol/CANProtocol.h b/Firmware/Libraries/CANProtocol/CANProtocol.h index 1c52e190..c31c9480 100644 --- a/Firmware/Libraries/CANProtocol/CANProtocol.h +++ b/Firmware/Libraries/CANProtocol/CANProtocol.h @@ -13,7 +13,6 @@ * Dependency: STM32Board::begin() must be called before CANProtocol::start(). * CANProtocol owns CAN bus operation; STM32Board owns peripheral hardware init. * - * @version 0.3.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/DrumDisplay/DrumDisplay.h b/Firmware/Libraries/DrumDisplay/DrumDisplay.h index 07e56ad9..dbe0d7df 100644 --- a/Firmware/Libraries/DrumDisplay/DrumDisplay.h +++ b/Firmware/Libraries/DrumDisplay/DrumDisplay.h @@ -19,7 +19,6 @@ * construct N DrumDisplay instances sharing one I2cMux, each with its own channel; the class * re-selects its channel before every buffer send. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/FirmwareVersion/FirmwareVersion.h b/Firmware/Libraries/FirmwareVersion/FirmwareVersion.h new file mode 100644 index 00000000..438b39a0 --- /dev/null +++ b/Firmware/Libraries/FirmwareVersion/FirmwareVersion.h @@ -0,0 +1,20 @@ +/** + * @file FirmwareVersion.h + * @brief The firmware release version — one source for every board that reports it. + * + * Every board is built from the same release, so there is one version, not one per library. + * It goes on the wire in two places: SimGateway's calibration HELLO_ACK, and each STM32 node's + * READY_n payload (#295). release-please rewrites the three numbers below when a firmware release + * PR merges (generic updater, `x-release-please-*` markers; see release-please-config.json) — + * never edit them by hand. + * + * Shared between STM32 and RP2040. Contains only `#define` constants. + * + * @copyright GPL-2.0-only — see Firmware/LICENSE + */ + +#pragma once + +#define OPENSKYHAWK_FW_MAJOR 0 // x-release-please-major +#define OPENSKYHAWK_FW_MINOR 1 // x-release-please-minor +#define OPENSKYHAWK_FW_PATCH 0 // x-release-please-patch diff --git a/Firmware/Libraries/FirmwareVersion/library.json b/Firmware/Libraries/FirmwareVersion/library.json new file mode 100644 index 00000000..7d805da3 --- /dev/null +++ b/Firmware/Libraries/FirmwareVersion/library.json @@ -0,0 +1,10 @@ +{ + "name": "FirmwareVersion", + "version": "0.1.0", + "description": "The OpenSkyhawk firmware release version, one source for every board", + "frameworks": "arduino", + "platforms": "*", + "build": { + "srcFilter": "-<*>" + } +} diff --git a/Firmware/Libraries/HIDControls/HIDControls.h b/Firmware/Libraries/HIDControls/HIDControls.h index ea2c0ff1..452fe565 100644 --- a/Firmware/Libraries/HIDControls/HIDControls.h +++ b/Firmware/Libraries/HIDControls/HIDControls.h @@ -11,7 +11,6 @@ * 0x0030–0x00AF HID buttons — routed to button setters on SimGateway * 0x00B0–0x00FF Reserved HID expansion slots * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/NodeStatus/NodeStatus.h b/Firmware/Libraries/NodeStatus/NodeStatus.h index 550ef938..3a01d6d6 100644 --- a/Firmware/Libraries/NodeStatus/NodeStatus.h +++ b/Firmware/Libraries/NodeStatus/NodeStatus.h @@ -23,7 +23,6 @@ * frame struct) stays in `CANProtocol.h`; the fault *detail* strings stay local (DiagSerial), * never on the wire. Header-only contract + a tiny `FaultSource` registry (`NodeStatus.cpp`). * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelBridge/PanelBridge.h b/Firmware/Libraries/PanelBridge/PanelBridge.h index ef1a37f5..6f729dbd 100644 --- a/Firmware/Libraries/PanelBridge/PanelBridge.h +++ b/Firmware/Libraries/PanelBridge/PanelBridge.h @@ -23,7 +23,6 @@ * } * @endcode * - * @version 0.2.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/ADS1115.h b/Firmware/Libraries/PanelGroup/ADS1115.h index 6cb620d7..c0514bcc 100644 --- a/Firmware/Libraries/PanelGroup/ADS1115.h +++ b/Firmware/Libraries/PanelGroup/ADS1115.h @@ -10,7 +10,6 @@ * ADS1115 adc; * PanelGroup::registerADC(adc, 0x48, Wire); * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Drivers/MotorDriver/MotorDriver.h b/Firmware/Libraries/PanelGroup/Drivers/MotorDriver/MotorDriver.h index 3ac07dae..06d14616 100644 --- a/Firmware/Libraries/PanelGroup/Drivers/MotorDriver/MotorDriver.h +++ b/Firmware/Libraries/PanelGroup/Drivers/MotorDriver/MotorDriver.h @@ -14,7 +14,6 @@ * * Concrete drivers: StepperMotor (now); ServoMotor / StepDirMotor (future siblings). * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Drivers/StepperMotor/StepperMotor.h b/Firmware/Libraries/PanelGroup/Drivers/StepperMotor/StepperMotor.h index 6e0f89e4..22473feb 100644 --- a/Firmware/Libraries/PanelGroup/Drivers/StepperMotor/StepperMotor.h +++ b/Firmware/Libraries/PanelGroup/Drivers/StepperMotor/StepperMotor.h @@ -19,7 +19,6 @@ * seeks a debounced digital home sensor — a micro switch, reed, hall, or opto-interrupter * all read identically through one PinRef + an active-level flag. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Helpers/I2cHealth/I2cHealth.h b/Firmware/Libraries/PanelGroup/Helpers/I2cHealth/I2cHealth.h index 82839869..e6d3fa25 100644 --- a/Firmware/Libraries/PanelGroup/Helpers/I2cHealth/I2cHealth.h +++ b/Firmware/Libraries/PanelGroup/Helpers/I2cHealth/I2cHealth.h @@ -13,7 +13,6 @@ * This is the C++ analog of a trait: shared behaviour lives here, the contract (`i2cProbe()`) is * pure-virtual so a mixing class cannot compile without honouring it. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Helpers/I2cMux/I2cMux.h b/Firmware/Libraries/PanelGroup/Helpers/I2cMux/I2cMux.h index 2bc24306..77b9184a 100644 --- a/Firmware/Libraries/PanelGroup/Helpers/I2cMux/I2cMux.h +++ b/Firmware/Libraries/PanelGroup/Helpers/I2cMux/I2cMux.h @@ -8,7 +8,6 @@ * live. The mux is a passive switch — the U8G2 driver still owns the device address; * the mux only routes SDA/SCL. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Helpers/ShiftBus/ShiftBus.h b/Firmware/Libraries/PanelGroup/Helpers/ShiftBus/ShiftBus.h index 909520eb..d07821e5 100644 --- a/Firmware/Libraries/PanelGroup/Helpers/ShiftBus/ShiftBus.h +++ b/Firmware/Libraries/PanelGroup/Helpers/ShiftBus/ShiftBus.h @@ -18,7 +18,6 @@ * * TechSpec: Firmware/ScratchPad/TechSpec/PanelGroup/Helpers/ShiftBus.md (issues #197 / #133). * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Inputs/ActionButton/ActionButton.h b/Firmware/Libraries/PanelGroup/Inputs/ActionButton/ActionButton.h index 40187cff..6e252191 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/ActionButton/ActionButton.h +++ b/Firmware/Libraries/PanelGroup/Inputs/ActionButton/ActionButton.h @@ -2,7 +2,6 @@ * @file ActionButton.h * @brief Momentary push button that fires one DCS-BIOS action per press. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Inputs/AnalogInput/AnalogInput.h b/Firmware/Libraries/PanelGroup/Inputs/AnalogInput/AnalogInput.h index 930bf3df..04961a16 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/AnalogInput/AnalogInput.h +++ b/Firmware/Libraries/PanelGroup/Inputs/AnalogInput/AnalogInput.h @@ -2,7 +2,6 @@ * @file AnalogInput.h * @brief Continuous analog input (potentiometer / axis) for OpenSkyhawk PanelGroup nodes. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Inputs/AnalogMultiPos/AnalogMultiPos.h b/Firmware/Libraries/PanelGroup/Inputs/AnalogMultiPos/AnalogMultiPos.h index 9da6a402..1b0732d9 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/AnalogMultiPos/AnalogMultiPos.h +++ b/Firmware/Libraries/PanelGroup/Inputs/AnalogMultiPos/AnalogMultiPos.h @@ -2,7 +2,6 @@ * @file AnalogMultiPos.h * @brief Resistor-ladder multi-position selector for OpenSkyhawk PanelGroup nodes. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Inputs/AngleSensorInput/AngleSensorInput.h b/Firmware/Libraries/PanelGroup/Inputs/AngleSensorInput/AngleSensorInput.h index 6a99a7a4..1f17eb0d 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/AngleSensorInput/AngleSensorInput.h +++ b/Firmware/Libraries/PanelGroup/Inputs/AngleSensorInput/AngleSensorInput.h @@ -2,7 +2,6 @@ * @file AngleSensorInput.h * @brief Magnetic angle sensor as an absolute knob / axis, for OpenSkyhawk PanelGroup nodes. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Inputs/MultiPosInput/MultiPosInput.h b/Firmware/Libraries/PanelGroup/Inputs/MultiPosInput/MultiPosInput.h index f7184c0d..3124bc3f 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/MultiPosInput/MultiPosInput.h +++ b/Firmware/Libraries/PanelGroup/Inputs/MultiPosInput/MultiPosInput.h @@ -2,7 +2,6 @@ * @file MultiPosInput.h * @brief Shared base for multi-position selector inputs (SwitchMultiPos, AnalogMultiPos, ...). * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Inputs/RotaryAcceleratedEncoder/RotaryAcceleratedEncoder.h b/Firmware/Libraries/PanelGroup/Inputs/RotaryAcceleratedEncoder/RotaryAcceleratedEncoder.h index 18ad20c6..2985b63c 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/RotaryAcceleratedEncoder/RotaryAcceleratedEncoder.h +++ b/Firmware/Libraries/PanelGroup/Inputs/RotaryAcceleratedEncoder/RotaryAcceleratedEncoder.h @@ -3,7 +3,6 @@ * @brief Quadrature encoder with a momentum filter and a fast-detent step — DcsBios * RotaryAcceleratedEncoder parity (D16, #287). * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Inputs/RotaryEncoder/RotaryEncoder.h b/Firmware/Libraries/PanelGroup/Inputs/RotaryEncoder/RotaryEncoder.h index fca170fc..88ed219c 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/RotaryEncoder/RotaryEncoder.h +++ b/Firmware/Libraries/PanelGroup/Inputs/RotaryEncoder/RotaryEncoder.h @@ -2,7 +2,6 @@ * @file RotaryEncoder.h * @brief Quadrature rotary encoder input for OpenSkyhawk PanelGroup nodes. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Inputs/Switch2Pos/Switch2Pos.h b/Firmware/Libraries/PanelGroup/Inputs/Switch2Pos/Switch2Pos.h index e1de82ad..eafc3251 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/Switch2Pos/Switch2Pos.h +++ b/Firmware/Libraries/PanelGroup/Inputs/Switch2Pos/Switch2Pos.h @@ -2,7 +2,6 @@ * @file Switch2Pos.h * @brief Debounced 2-position switch for OpenSkyhawk PanelGroup nodes. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Inputs/Switch3Pos/Switch3Pos.h b/Firmware/Libraries/PanelGroup/Inputs/Switch3Pos/Switch3Pos.h index 64f5d95e..e455a83a 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/Switch3Pos/Switch3Pos.h +++ b/Firmware/Libraries/PanelGroup/Inputs/Switch3Pos/Switch3Pos.h @@ -2,7 +2,6 @@ * @file Switch3Pos.h * @brief Three-position (ON-OFF-ON) switch for OpenSkyhawk PanelGroup nodes. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Inputs/SwitchMultiPos/SwitchMultiPos.h b/Firmware/Libraries/PanelGroup/Inputs/SwitchMultiPos/SwitchMultiPos.h index d69b2ef6..e93e0cad 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/SwitchMultiPos/SwitchMultiPos.h +++ b/Firmware/Libraries/PanelGroup/Inputs/SwitchMultiPos/SwitchMultiPos.h @@ -2,7 +2,6 @@ * @file SwitchMultiPos.h * @brief N-pin rotary selector switch for OpenSkyhawk PanelGroup nodes. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Inputs/SwitchWithCover2Pos/SwitchWithCover2Pos.h b/Firmware/Libraries/PanelGroup/Inputs/SwitchWithCover2Pos/SwitchWithCover2Pos.h index e21abe3f..4d38eab3 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/SwitchWithCover2Pos/SwitchWithCover2Pos.h +++ b/Firmware/Libraries/PanelGroup/Inputs/SwitchWithCover2Pos/SwitchWithCover2Pos.h @@ -2,7 +2,6 @@ * @file SwitchWithCover2Pos.h * @brief Guarded 2-position switch — one pin drives the sim's cover and the switch under it. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Outputs/AnalogOutput/AnalogOutput.h b/Firmware/Libraries/PanelGroup/Outputs/AnalogOutput/AnalogOutput.h index 2c7d363c..4c27fed7 100644 --- a/Firmware/Libraries/PanelGroup/Outputs/AnalogOutput/AnalogOutput.h +++ b/Firmware/Libraries/PanelGroup/Outputs/AnalogOutput/AnalogOutput.h @@ -2,7 +2,6 @@ * @file AnalogOutput.h * @brief Family base for outputs driven by one 16-bit DCS-BIOS value. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Outputs/Dimmer/Dimmer.h b/Firmware/Libraries/PanelGroup/Outputs/Dimmer/Dimmer.h index b5d8f8ef..4b94cc75 100644 --- a/Firmware/Libraries/PanelGroup/Outputs/Dimmer/Dimmer.h +++ b/Firmware/Libraries/PanelGroup/Outputs/Dimmer/Dimmer.h @@ -2,7 +2,6 @@ * @file Dimmer.h * @brief PWM backlight / dimmer output for OpenSkyhawk PanelGroup nodes. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Outputs/IntegerOutput/IntegerOutput.h b/Firmware/Libraries/PanelGroup/Outputs/IntegerOutput/IntegerOutput.h index ef87dfee..b4d34dd8 100644 --- a/Firmware/Libraries/PanelGroup/Outputs/IntegerOutput/IntegerOutput.h +++ b/Firmware/Libraries/PanelGroup/Outputs/IntegerOutput/IntegerOutput.h @@ -2,7 +2,6 @@ * @file IntegerOutput.h * @brief Callback output for OpenSkyhawk PanelGroup nodes — the escape hatch. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Outputs/LED/LED.h b/Firmware/Libraries/PanelGroup/Outputs/LED/LED.h index adc214b8..7fd54434 100644 --- a/Firmware/Libraries/PanelGroup/Outputs/LED/LED.h +++ b/Firmware/Libraries/PanelGroup/Outputs/LED/LED.h @@ -2,7 +2,6 @@ * @file LED.h * @brief Digital LED output for OpenSkyhawk PanelGroup nodes. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/Outputs/NeedleGauge/NeedleGauge.h b/Firmware/Libraries/PanelGroup/Outputs/NeedleGauge/NeedleGauge.h index c1fef6a5..083dc127 100644 --- a/Firmware/Libraries/PanelGroup/Outputs/NeedleGauge/NeedleGauge.h +++ b/Firmware/Libraries/PanelGroup/Outputs/NeedleGauge/NeedleGauge.h @@ -15,7 +15,6 @@ * driftMotor, DRIFT_CAL); * @endcode * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/PanelGroup.h b/Firmware/Libraries/PanelGroup/PanelGroup.h index a9abdeea..534662a2 100644 --- a/Firmware/Libraries/PanelGroup/PanelGroup.h +++ b/Firmware/Libraries/PanelGroup/PanelGroup.h @@ -33,7 +33,6 @@ * void loop() { PanelGroup::loop(); } * @endcode * - * @version 0.3.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/PanelGroup/PinRef.h b/Firmware/Libraries/PanelGroup/PinRef.h index dd4fb1ae..3c441e77 100644 --- a/Firmware/Libraries/PanelGroup/PinRef.h +++ b/Firmware/Libraries/PanelGroup/PinRef.h @@ -11,7 +11,6 @@ * Contains no logic beyond read/write dispatch — no debounce, no filtering, no state. * PinRef objects are value types: live on the stack or as class members, never heap-allocated. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/STM32Board/STM32Board.h b/Firmware/Libraries/STM32Board/STM32Board.h index 5ea6bfcc..74ced159 100644 --- a/Firmware/Libraries/STM32Board/STM32Board.h +++ b/Firmware/Libraries/STM32Board/STM32Board.h @@ -11,7 +11,6 @@ * - UART : USART1 PA9 TX / PA10 RX @ 115200 (diagnostic tap) * - CAN : SN65HVD230 on PA11 (RX) / PA12 (TX) * - * @version 0.4.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/Libraries/SimGateway/AxisCal.h b/Firmware/Libraries/SimGateway/AxisCal.h index aae77290..687d1288 100644 --- a/Firmware/Libraries/SimGateway/AxisCal.h +++ b/Firmware/Libraries/SimGateway/AxisCal.h @@ -18,7 +18,6 @@ * Deliberately not wrapped in `#ifdef ARDUINO_ARCH_RP2040` — this is portable C++ and the * guard would only obstruct compiling it anywhere else. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ @@ -235,7 +234,7 @@ enum CalHelloAckField : uint8_t { CAL_HELLO_ACK_PROTO = 0, // u8: CAL_PROTO_VERSION CAL_HELLO_ACK_BLOB_VERSION = 1, // u8: CAL_VERSION CAL_HELLO_ACK_AXIS_SLOTS = 2, // u8: AXIS_CAL_SLOTS - CAL_HELLO_ACK_FW_MAJOR = 3, // u8: SimGateway firmware version + CAL_HELLO_ACK_FW_MAJOR = 3, // u8: firmware release version (FirmwareVersion.h) CAL_HELLO_ACK_FW_MINOR = 4, // u8 CAL_HELLO_ACK_FW_PATCH = 5, // u8 }; diff --git a/Firmware/Libraries/SimGateway/SimGateway.cpp b/Firmware/Libraries/SimGateway/SimGateway.cpp index 745d860a..6c090a2d 100644 --- a/Firmware/Libraries/SimGateway/SimGateway.cpp +++ b/Firmware/Libraries/SimGateway/SimGateway.cpp @@ -1,6 +1,7 @@ #ifdef ARDUINO_ARCH_RP2040 #include "SimGateway.h" +#include // OPENSKYHAWK_FW_* — the release version HELLO_ACK reports // ── TinyUSB HID backend (production builds only) ────────────────────────────── // @@ -439,7 +440,7 @@ void _calSendHelloAck(uint8_t seq) { OpenSkyhawk::CAL_PROTO_VERSION, (uint8_t)OpenSkyhawk::CAL_VERSION, OpenSkyhawk::AXIS_CAL_SLOTS, - SIMGATEWAY_FW_MAJOR, SIMGATEWAY_FW_MINOR, SIMGATEWAY_FW_PATCH, + OPENSKYHAWK_FW_MAJOR, OPENSKYHAWK_FW_MINOR, OPENSKYHAWK_FW_PATCH, }; _calSend(OpenSkyhawk::CAL_T_HELLO_ACK, seq, p, sizeof(p)); } diff --git a/Firmware/Libraries/SimGateway/SimGateway.h b/Firmware/Libraries/SimGateway/SimGateway.h index 7c09f8bf..e6178f7c 100644 --- a/Firmware/Libraries/SimGateway/SimGateway.h +++ b/Firmware/Libraries/SimGateway/SimGateway.h @@ -11,7 +11,6 @@ * Does NOT run DCS-BIOS, parse DCS-BIOS addresses, or interact with CAN. * Platform: RP2040 only. * - * @version 0.1.0 * @copyright GPL-2.0-only — see Firmware/LICENSE */ @@ -149,12 +148,6 @@ namespace SimGateway { static constexpr uint8_t DEFAULT_UART_TX_PIN = 0; ///< RP2040 UART0 TX to PanelBridge RX. static constexpr uint8_t DEFAULT_UART_RX_PIN = 1; ///< RP2040 UART0 RX from PanelBridge TX. -// Firmware version reported in the calibration protocol's HELLO_ACK, so the client can show -// which build a device is running. Keep in step with library.json. -#define SIMGATEWAY_FW_MAJOR 0 -#define SIMGATEWAY_FW_MINOR 1 -#define SIMGATEWAY_FW_PATCH 0 - /** * @brief Initialise USB identity, the HID device, and the UART link to PanelBridge. * diff --git a/Firmware/Libraries/SimGateway/library.json b/Firmware/Libraries/SimGateway/library.json index 50986eba..9747a984 100644 --- a/Firmware/Libraries/SimGateway/library.json +++ b/Firmware/Libraries/SimGateway/library.json @@ -15,6 +15,7 @@ "raspberrypi" ], "dependencies": { + "FirmwareVersion": "*", "adafruit/Adafruit TinyUSB Library": "^3.7.7" } } diff --git a/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md b/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md index ba6893d6..9053a520 100644 --- a/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md +++ b/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md @@ -274,7 +274,7 @@ the mapping — but that is data in the message, not the message's identity. | Type | `LEN` | Payload | |---|---|---| | `HELLO` | 0 | — | -| `HELLO_ACK` | 6 | `proto`:u8, `blobVersion`:u8, `axisSlots`:u8 (8), `fw`:u8×3 (major, minor, patch) | +| `HELLO_ACK` | 6 | `proto`:u8, `blobVersion`:u8, `axisSlots`:u8 (8), `fw`:u8×3 (major, minor, patch — the release version, `OPENSKYHAWK_FW_*` in `FirmwareVersion.h`) | | `GET_CAL` | 0 | — | | `CAL_DATA` | 82 | `presentMask`:u8, `calibratedMask`:u8, then **8 ×** { `controlId`:u16, `min`:u16, `centre`:u16, `max`:u16, `deadzone`:u16 } | | `SESSION_OPEN` | 1 | `axisIdx`:u8 — axis to stream, or `0xFF` for none | diff --git a/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogInput.md b/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogInput.md index 546e79a2..8b9f894e 100644 --- a/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogInput.md +++ b/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogInput.md @@ -201,7 +201,7 @@ Three preconditions, each of which binds: - **HID routing.** See the note below on DCS-BIOS-routed values. 2 ms is not arbitrary: SimGateway declares its HID endpoint with `interval_ms = 2` -(`SimGateway.cpp:78-79`), so 500 Hz is exactly the USB report rate and a faster axis is discarded at +(`SimGateway.cpp:80-81`), so 500 Hz is exactly the USB report rate and a faster axis is discarded at the USB boundary. The gain is **latency, not throughput** — real flying sits at 40–200 Hz per axis, below even the shipped 125 Hz, so a faster poll buys up to ~6 ms less sampling delay on every movement rather than more updates. diff --git a/Firmware/ScratchPad/TechSpec/README.md b/Firmware/ScratchPad/TechSpec/README.md index a001eef1..a7e600e6 100644 --- a/Firmware/ScratchPad/TechSpec/README.md +++ b/Firmware/ScratchPad/TechSpec/README.md @@ -157,12 +157,15 @@ Dependencies flow downward — implement in this order: ``` HIDControls (no dependencies — platform-agnostic header; shared by STM32 and RP2040) +FirmwareVersion (no dependencies — platform-agnostic header; the release version every board + reports, rewritten by release-please — never edited by hand) A4EC (no dependencies — generated by running tools/gen_a4ec/gen_a4ec.py) CANProtocol (depends on HIDControls — includes HIDControls.h) STM32Board (depends on CANProtocol for the CanStatus event type) ↑ implement HIDControls first; then A4EC and CANProtocol (either order); then STM32Board PanelBridge (depends on CANProtocol, STM32Board, A4EC) -SimGateway (depends on HIDControls — CTRL_* constants used in sketch declarations) +SimGateway (depends on HIDControls — CTRL_* constants used in sketch declarations; + FirmwareVersion — the version HELLO_ACK reports) PowerMonitor (depends on CANProtocol, STM32Board, NodeStatus — the PDU node, #216; no PanelGroup dependency) ↑ build the bridge/gateway backbone before the large PanelGroup surface diff --git a/docs/firmware/build-flags.md b/docs/firmware/build-flags.md index 116f482e..2e4a6f7c 100644 --- a/docs/firmware/build-flags.md +++ b/docs/firmware/build-flags.md @@ -214,18 +214,18 @@ is set only by its own `Firmware/Tests//platformio.ini`. | Flag | Library it opens | First read at | |---|---|---| -| `ANALOGINPUT_TEST` | AnalogInput | `Inputs/AnalogInput/AnalogInput.h:85` | -| `ANALOGMULTIPOS_TEST` | AnalogMultiPos | `Inputs/AnalogMultiPos/AnalogMultiPos.h:90` | -| `DRUMDISPLAY_TEST` | DrumDisplay | `DrumDisplay/DrumDisplay.h:279` | -| `LED_TEST` | LED | `Outputs/LED/LED.h:68` | -| `MULTIPOS_TEST` | MultiPosInput | `Inputs/MultiPosInput/MultiPosInput.h:60` | -| `NEEDLEGAUGE_TEST` | NeedleGauge | `Outputs/NeedleGauge/NeedleGauge.h:78` | -| `PANELBRIDGE_TEST` | PanelBridge | `PanelBridge/PanelBridge.h:85` | -| `ROTARYENCODER_TEST` | RotaryEncoder | `Inputs/RotaryEncoder/RotaryEncoder.h:109` | -| `SHIFTBUS_TEST` | ShiftBus | `Helpers/ShiftBus/ShiftBus.h:142` | -| `SIMGATEWAY_TEST` | SimGateway | `SimGateway/SimGateway.cpp:11` (no-op HID stubs) | -| `STEPPERMOTOR_TEST` | StepperMotor | `Drivers/StepperMotor/StepperMotor.h:162` | -| `STM32BOARD_TEST` | STM32Board | `STM32Board/STM32Board.h:32` | +| `ANALOGINPUT_TEST` | AnalogInput | `Inputs/AnalogInput/AnalogInput.h:84` | +| `ANALOGMULTIPOS_TEST` | AnalogMultiPos | `Inputs/AnalogMultiPos/AnalogMultiPos.h:89` | +| `DRUMDISPLAY_TEST` | DrumDisplay | `DrumDisplay/DrumDisplay.h:278` | +| `LED_TEST` | LED | `Outputs/LED/LED.h:67` | +| `MULTIPOS_TEST` | MultiPosInput | `Inputs/MultiPosInput/MultiPosInput.h:59` | +| `NEEDLEGAUGE_TEST` | NeedleGauge | `Outputs/NeedleGauge/NeedleGauge.h:77` | +| `PANELBRIDGE_TEST` | PanelBridge | `PanelBridge/PanelBridge.h:84` | +| `ROTARYENCODER_TEST` | RotaryEncoder | `Inputs/RotaryEncoder/RotaryEncoder.h:108` | +| `SHIFTBUS_TEST` | ShiftBus | `Helpers/ShiftBus/ShiftBus.h:141` | +| `SIMGATEWAY_TEST` | SimGateway | `SimGateway/SimGateway.cpp:12` (no-op HID stubs) | +| `STEPPERMOTOR_TEST` | StepperMotor | `Drivers/StepperMotor/StepperMotor.h:161` | +| `STM32BOARD_TEST` | STM32Board | `STM32Board/STM32Board.h:31` | Paths are relative to `Firmware/Libraries/`, and the PanelGroup ones to `Firmware/Libraries/PanelGroup/`. Most seams are read in several places; the column gives the diff --git a/docs/firmware/index.md b/docs/firmware/index.md index 1b7a326f..235e0fce 100644 --- a/docs/firmware/index.md +++ b/docs/firmware/index.md @@ -20,6 +20,7 @@ source** for any constant or API. When a doc and a header disagree, the header w | `CANProtocol` | STM32 | Packet structs (`ControlPacket`, `ControlPacketPair`), CAN IDs, the `controlId` namespace, TX/RX queues | | `STM32Board` | STM32 | Hardware init — CAN, UART, the bi-color status LED, SWD-only debug (JTAG pins freed as GPIO). Used by both STM32 tiers | | `HIDControls` | shared | HID `controlId` allocations (`CTRL_*`), shared between SimGateway and CANProtocol | +| `FirmwareVersion` | shared | The firmware release version every board reports — one number for the whole release, set by the release process | | `PanelGroup` | STM32 | CAN sub-node: `PinRef`, MCP23017 management, input and output control classes | | `PanelBridge` | STM32 | CAN master + DCS-BIOS processor | | `PowerMonitor` | STM32 | The power-distribution (PDU) node: rail voltage/current + board temperature, `TELEM_n` telemetry, per-board calibration | diff --git a/release-please-config.json b/release-please-config.json index 447e20f1..9958d476 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -15,10 +15,13 @@ { "type": "json", "path": "Libraries/A4EC/library.json", "jsonpath": "$.version" }, { "type": "json", "path": "Libraries/CANProtocol/library.json", "jsonpath": "$.version" }, { "type": "json", "path": "Libraries/DrumDisplay/library.json", "jsonpath": "$.version" }, + { "type": "json", "path": "Libraries/FirmwareVersion/library.json", "jsonpath": "$.version" }, + { "type": "generic", "path": "Libraries/FirmwareVersion/FirmwareVersion.h" }, { "type": "json", "path": "Libraries/HIDControls/library.json", "jsonpath": "$.version" }, { "type": "json", "path": "Libraries/NodeStatus/library.json", "jsonpath": "$.version" }, { "type": "json", "path": "Libraries/PanelBridge/library.json", "jsonpath": "$.version" }, { "type": "json", "path": "Libraries/PanelGroup/library.json", "jsonpath": "$.version" }, + { "type": "json", "path": "Libraries/PowerMonitor/library.json", "jsonpath": "$.version" }, { "type": "json", "path": "Libraries/STM32Board/library.json", "jsonpath": "$.version" }, { "type": "json", "path": "Libraries/SimGateway/library.json", "jsonpath": "$.version" } ] From 67183aece45f047ffbf60de37f50660cfb5ac5b4 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 12:20:53 -0400 Subject: [PATCH 03/21] feat(firmware): nodes announce their firmware version in READY_n, _NODE_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. --- .github/firmware-platformio-impact.json | 37 +++++++++++ .../Libraries/CANProtocol/CANProtocol.cpp | 14 ++++ Firmware/Libraries/CANProtocol/CANProtocol.h | 31 +++++++++ Firmware/Libraries/CANProtocol/library.json | 3 + Firmware/Libraries/NodeStatus/NodeStatus.h | 16 +++-- .../Libraries/PanelBridge/PanelBridge.cpp | 49 +++++++++++--- Firmware/Libraries/PanelBridge/library.json | 1 + Firmware/Libraries/PanelGroup/PanelGroup.cpp | 7 +- Firmware/Libraries/PanelGroup/PanelGroup.h | 2 +- Firmware/Libraries/PowerMonitor/PduNode.cpp | 2 +- .../ScratchPad/FirmwarePlan/00-decisions.md | 34 ++++++++++ .../FirmwarePlan/02-can-protocol.md | 35 +++++++++- .../FirmwarePlan/04-dcs-bios-integration.md | 27 +++++--- .../FirmwarePlan/06-panelbridge-api.md | 9 ++- .../09-startup-resync-diagnostics.md | 11 ++-- Firmware/ScratchPad/TechSpec/CANProtocol.md | 26 +++++++- Firmware/ScratchPad/TechSpec/NodeStatus.md | 16 +++-- Firmware/ScratchPad/TechSpec/PanelBridge.md | 18 +++-- .../TechSpec/PanelGroup/PanelGroup.md | 11 ++-- Firmware/ScratchPad/TechSpec/PowerMonitor.md | 2 +- Firmware/Tests/ActionButton/platformio.ini | 1 + Firmware/Tests/AnalogInput/platformio.ini | 1 + Firmware/Tests/AnalogMultiPos/platformio.ini | 1 + .../Tests/AngleSensorInput/platformio.ini | 1 + .../tests/protocol_layout/protocol_layout.cpp | 4 ++ Firmware/Tests/Dimmer/platformio.ini | 1 + Firmware/Tests/DrumDisplay/platformio.ini | 1 + Firmware/Tests/IntegerOutput/platformio.ini | 1 + Firmware/Tests/LED/platformio.ini | 1 + Firmware/Tests/NeedleGauge/platformio.ini | 1 + .../dual_integration/dual_integration.cpp | 2 +- .../tests/node_status/node_status.cpp | 65 ++++++++++++------- .../tests/node_tracking/node_tracking.cpp | 2 +- .../tests/test_seq_diag/test_seq_diag.cpp | 2 +- Firmware/Tests/PanelGroup/platformio.ini | 1 + .../tests/boot_sequence/boot_sequence.cpp | 2 +- .../PanelGroup/tests/heartbeat/heartbeat.cpp | 2 +- Firmware/Tests/PinRef/platformio.ini | 2 + .../RotaryAcceleratedEncoder/platformio.ini | 1 + Firmware/Tests/RotaryEncoder/platformio.ini | 1 + Firmware/Tests/ShiftBus/platformio.ini | 1 + Firmware/Tests/StepperMotor/platformio.ini | 1 + Firmware/Tests/Switch2Pos/platformio.ini | 1 + Firmware/Tests/Switch3Pos/platformio.ini | 1 + Firmware/Tests/SwitchMultiPos/platformio.ini | 1 + .../Tests/SwitchWithCover2Pos/platformio.ini | 1 + docs/architecture/can-bus.md | 5 +- docs/architecture/firmware-tiers.md | 4 +- docs/firmware/build-flags.md | 8 +-- 49 files changed, 369 insertions(+), 98 deletions(-) diff --git a/.github/firmware-platformio-impact.json b/.github/firmware-platformio-impact.json index 9b5b5433..266cb7fc 100644 --- a/.github/firmware-platformio-impact.json +++ b/.github/firmware-platformio-impact.json @@ -56,6 +56,43 @@ "Firmware/Examples/E2E_DCS_Test/PanelGroup", "Firmware/Tests/DrumDisplay" ], + "FirmwareVersion": [ + "Firmware/Examples/E2E_DCS_Test/PanelBridge", + "Firmware/Examples/E2E_DCS_Test/PanelGroup", + "Firmware/Examples/E2E_DCS_Test/PanelGroupInputs", + "Firmware/Examples/E2E_DCS_Test/SimGateway", + "Firmware/Panels/Center_Armament", + "Firmware/Panels/PDU_Center", + "Firmware/Panels/PDU_Left", + "Firmware/Panels/PDU_Right", + "Firmware/Templates/PanelBridge", + "Firmware/Templates/PanelGroup", + "Firmware/Templates/SimGateway", + "Firmware/Tests/ActionButton", + "Firmware/Tests/AnalogInput", + "Firmware/Tests/AnalogMultiPos", + "Firmware/Tests/AngleSensorInput", + "Firmware/Tests/CANProtocol", + "Firmware/Tests/Dimmer", + "Firmware/Tests/DrumDisplay", + "Firmware/Tests/IntegerOutput", + "Firmware/Tests/LED", + "Firmware/Tests/NeedleGauge", + "Firmware/Tests/PanelBridge", + "Firmware/Tests/PanelGroup", + "Firmware/Tests/PinRef", + "Firmware/Tests/PowerMonitor", + "Firmware/Tests/RotaryAcceleratedEncoder", + "Firmware/Tests/RotaryEncoder", + "Firmware/Tests/STM32Board", + "Firmware/Tests/ShiftBus", + "Firmware/Tests/SimGateway", + "Firmware/Tests/StepperMotor", + "Firmware/Tests/Switch2Pos", + "Firmware/Tests/Switch3Pos", + "Firmware/Tests/SwitchMultiPos", + "Firmware/Tests/SwitchWithCover2Pos" + ], "HIDControls": [ "Firmware/Examples/E2E_DCS_Test/PanelBridge", "Firmware/Examples/E2E_DCS_Test/PanelGroup", diff --git a/Firmware/Libraries/CANProtocol/CANProtocol.cpp b/Firmware/Libraries/CANProtocol/CANProtocol.cpp index d8d68c7b..c1069262 100644 --- a/Firmware/Libraries/CANProtocol/CANProtocol.cpp +++ b/Firmware/Libraries/CANProtocol/CANProtocol.cpp @@ -2,6 +2,7 @@ #include "CANProtocol.h" #include +#include // OPENSKYHAWK_FW_* — the release version READY_n carries (#295) #include #include @@ -464,6 +465,19 @@ NodeHealthPayload makeNodeHealthPayload(uint8_t nodeId, NodeKind kind, int8_t di return p; } +ReadyPayload makeReadyPayload() { + ReadyPayload p = {}; + p.fwMajor = OPENSKYHAWK_FW_MAJOR; + p.fwMinor = OPENSKYHAWK_FW_MINOR; + p.fwPatch = OPENSKYHAWK_FW_PATCH; + return p; +} + +void sendReady() { + const ReadyPayload p = makeReadyPayload(); + send(canIdReady(NODE_ID), reinterpret_cast(&p), sizeof(p)); +} + uint32_t txDropCount() { return _txDrops; } const NodeHealthPayload& lastNodeHealth() { return _lastHealth; } diff --git a/Firmware/Libraries/CANProtocol/CANProtocol.h b/Firmware/Libraries/CANProtocol/CANProtocol.h index c31c9480..af0020a0 100644 --- a/Firmware/Libraries/CANProtocol/CANProtocol.h +++ b/Firmware/Libraries/CANProtocol/CANProtocol.h @@ -104,6 +104,22 @@ struct __attribute__((packed)) TelemPayload { }; static_assert(sizeof(TelemPayload) == 8, "TelemPayload must fill one classic CAN frame"); +/** + * @brief 8-byte payload carried by READY_n — the node's boot announcement (#295). + * + * Sent once per boot, after the boot EVT burst, so every reflash, reset or power cycle announces + * the firmware the node now runs. PanelBridge caches the version per node and reports it in + * `_NODE_STATUS` (v4). Firmware older than this payload sends READY_n empty (DLC 0): the bridge + * then reports the version as not reported (0.0.0). + */ +struct __attribute__((packed)) ReadyPayload { + uint8_t fwMajor; ///< 0: firmware release version (FirmwareVersion.h) + uint8_t fwMinor; ///< 1 + uint8_t fwPatch; ///< 2 + uint8_t rsvd[5]; ///< 3-7: reserved, transmit 0 +}; +static_assert(sizeof(ReadyPayload) == 8, "ReadyPayload must fill one classic CAN frame"); + /** @brief CAN bus health states. Reported to STM32Board via onStatusChange(). */ enum class CanStatus { STARTING, ///< CAN peripheral configured, not yet started @@ -378,6 +394,21 @@ namespace CANProtocol { NodeHealthPayload makeNodeHealthPayload(uint8_t nodeId, NodeKind kind, int8_t dieTempC, NodeFaultCode fault = NodeFaultCode::NONE); + /** + * @brief Build the 8-byte READY_n payload: this build's firmware release version (#295). + * @return ReadyPayload with fwMajor/fwMinor/fwPatch from FirmwareVersion.h, reserved bytes 0. + */ + ReadyPayload makeReadyPayload(); + + /** + * @brief Send this node's READY_n (`canIdReady(NODE_ID)`) carrying makeReadyPayload(). + * + * Call once at the end of the node's setup(), after its boot EVT burst. PanelBridge marks the + * node alive, caches its version and broadcasts SYNC_REQ. Bounded retry like every other + * special frame (02-can-protocol.md). + */ + void sendReady(); + /** @brief Return the cumulative TX queue drop count since startup. */ uint32_t txDropCount(); diff --git a/Firmware/Libraries/CANProtocol/library.json b/Firmware/Libraries/CANProtocol/library.json index 85cf6759..e374418e 100644 --- a/Firmware/Libraries/CANProtocol/library.json +++ b/Firmware/Libraries/CANProtocol/library.json @@ -14,6 +14,9 @@ "ststm32" ], "dependencies": [ + { + "name": "FirmwareVersion" + }, { "name": "HIDControls" }, diff --git a/Firmware/Libraries/NodeStatus/NodeStatus.h b/Firmware/Libraries/NodeStatus/NodeStatus.h index 3a01d6d6..b5fd66c0 100644 --- a/Firmware/Libraries/NodeStatus/NodeStatus.h +++ b/Firmware/Libraries/NodeStatus/NodeStatus.h @@ -32,8 +32,8 @@ // ── _NODE_STATUS DCS-BIOS host-reporting contract (#86) ─────────────────────── // PanelBridge reports connected nodes + health to the host over DCS-BIOS. Bump // NODE_STATUS_PROTO_VERSION on any _NODE_STATUS wire change so the client's sync fails loudly. -// Full 28-hex field decode: FirmwarePlan/04-dcs-bios-integration.md. -#define NODE_STATUS_PROTO_VERSION 3 +// Full 34-hex field decode: FirmwarePlan/04-dcs-bios-integration.md. +#define NODE_STATUS_PROTO_VERSION 4 #define NODE_STATUS_REQ_ADDR 0x86FE // host→device roster-request export address #define NODE_STATUS_MSG_NAME "_NODE_STATUS" #define NODE_STATUS_END_MSG_NAME "_NODE_STATUS_END" @@ -46,8 +46,8 @@ */ constexpr uint8_t GATEWAY_NODE_ID = 0; -/// `_NODE_STATUS` row length in bytes, sent as twice as many hex chars (proto v3: 14 → 28 hex). -constexpr uint8_t NODE_STATUS_ROW_BYTES = 14; +/// `_NODE_STATUS` row length in bytes, sent as twice as many hex chars (proto v4: 17 → 34 hex). +constexpr uint8_t NODE_STATUS_ROW_BYTES = 17; /** * @brief `_NODE_STATUS` row field offsets, in bytes of the decoded row (hex index = 2 × offset). @@ -55,8 +55,9 @@ constexpr uint8_t NODE_STATUS_ROW_BYTES = 14; * Each field is its numeric value as fixed-width uppercase hex, most-significant byte first, so * the u16 fields read big-endian here although they are little-endian on CAN. A field runs to the * next offset; the last runs to NODE_STATUS_ROW_BYTES. The first six come from the node's HB_n - * (plus `present`), the last five from its HEALTH_n. (Not `NODE_ID`: every node build defines - * that as a macro.) + * (plus `present`), the next five from its HEALTH_n, the last three from its READY_n (v4, #295). + * Fields are only ever appended: a reader parses the fields it knows from a longer row and ignores + * the rest. (Not `NODE_ID`: every node build defines that as a macro.) */ enum class NodeStatusField : uint8_t { ID = 0, // u8: node ID; GATEWAY_NODE_ID = the bridge's own row, 1-63 = CAN nodes @@ -70,6 +71,9 @@ enum class NodeStatusField : uint8_t { FAULT_MASK = 11, // u8: reserved, 00 FAULT_ID = 12, // u8: NodeFaultCode NODE_KIND = 13, // u8: NodeKind + FW_MAJOR = 14, // u8: firmware release version from the node's READY_n; 00 00 00 = not reported + FW_MINOR = 15, // u8 + FW_PATCH = 16, // u8 }; // ── _NODE_TELEM DCS-BIOS host-reporting contract (#216) ─────────────────────── diff --git a/Firmware/Libraries/PanelBridge/PanelBridge.cpp b/Firmware/Libraries/PanelBridge/PanelBridge.cpp index 186ffea7..77dfe776 100644 --- a/Firmware/Libraries/PanelBridge/PanelBridge.cpp +++ b/Firmware/Libraries/PanelBridge/PanelBridge.cpp @@ -24,6 +24,7 @@ namespace DcsBios { #include // CAN types + NodeHealthPayload #include // NODE_STATUS_REQ_ADDR / NODE_STATUS_MSG_NAME (node-status reporting, #86) #include +#include // OPENSKYHAWK_FW_* — the bridge's own node-0 row version (#295) #include #include @@ -46,6 +47,11 @@ struct NodeState { uint8_t faultMask = 0; // fault source/domain bitmap — reserved #163 uint8_t faultId = 0; // NodeFaultCode (NodeStatus.h) — #163 uint8_t nodeKind = 0; // NodeKind (NodeStatus.h), 0 = unseen/unknown — #344 + // Firmware version from the node's last READY_n (#295); 0.0.0 = not reported (no READY seen + // since the bridge booted, or an empty READY from older firmware). Kept across timeouts. + uint8_t fwMajor = 0; + uint8_t fwMinor = 0; + uint8_t fwPatch = 0; #endif }; @@ -60,9 +66,11 @@ static uint8_t _deadCount = 0; // tracked nodes currently tim // command messages on a reserved control name. Owned entirely by PanelBridge; // SimGateway relays the ASCII verbatim. See NodeStatus.h for the wire format. -// Emit one status row (proto v3, NODE_STATUS_ROW_BYTES = 14 bytes as 28 hex chars): -// _NODE_STATUS -// The last five come from the node's HEALTH_n frame (#221 contract). DIE_TEMP_UNAVAILABLE + nodeKind +// Emit one status row (proto v4, NODE_STATUS_ROW_BYTES = 17 bytes as 34 hex chars): +// _NODE_STATUS +// The next five come from the node's HEALTH_n frame (#221 contract), the last three from its READY_n +// (#295; 000000 = not reported). DIE_TEMP_UNAVAILABLE + nodeKind // 00 = not-yet-seen; hFlags (DEGRADED) + faultId are populated per node by aggregateFaults (#163); // faultMask stays 0 (reserved for future fault-domain bits); nodeKind is the board kind (#344). // The format string is the row layout: each field's width must match NodeStatusField (NodeStatus.h). @@ -71,17 +79,20 @@ static_assert((uint8_t)NSF::ID == 0 && (uint8_t)NSF::PRESENT == 1 && (uint8_t)NS && (uint8_t)NSF::UPTIME == 3 && (uint8_t)NSF::RX_COUNT == 5 && (uint8_t)NSF::ESR == 7 && (uint8_t)NSF::DIE_TEMP_C == 9 && (uint8_t)NSF::HEALTH_FLAGS == 10 && (uint8_t)NSF::FAULT_MASK == 11 && (uint8_t)NSF::FAULT_ID == 12 - && (uint8_t)NSF::NODE_KIND == 13 && NODE_STATUS_ROW_BYTES == 14, + && (uint8_t)NSF::NODE_KIND == 13 && (uint8_t)NSF::FW_MAJOR == 14 + && (uint8_t)NSF::FW_MINOR == 15 && (uint8_t)NSF::FW_PATCH == 16 + && NODE_STATUS_ROW_BYTES == 17, "NodeStatusField moved: update emitRow's snprintf format to match"); static void emitRow(uint8_t nodeId, bool present, const HeartbeatPayload& hb, int8_t dieTempC, uint8_t hFlags, uint8_t faultMask, uint8_t faultId, - uint8_t nodeKind) { + uint8_t nodeKind, uint8_t fwMajor, uint8_t fwMinor, uint8_t fwPatch) { char hex[2 * NODE_STATUS_ROW_BYTES + 1]; - snprintf(hex, sizeof(hex), "%02X%02X%02X%04X%04X%04X%02X%02X%02X%02X%02X", + snprintf(hex, sizeof(hex), "%02X%02X%02X%04X%04X%04X%02X%02X%02X%02X%02X%02X%02X%02X", (unsigned)nodeId, present ? 1u : 0u, (unsigned)hb.flags, (unsigned)hb.uptime, (unsigned)hb.rxCount, (unsigned)hb.esr, (unsigned)(uint8_t)dieTempC, (unsigned)hFlags, (unsigned)faultMask, - (unsigned)faultId, (unsigned)nodeKind); + (unsigned)faultId, (unsigned)nodeKind, + (unsigned)fwMajor, (unsigned)fwMinor, (unsigned)fwPatch); DcsBios::sendDcsBiosMessage(NODE_STATUS_MSG_NAME, hex); } @@ -89,15 +100,17 @@ static void emitRow(uint8_t nodeId, bool present, const HeartbeatPayload& hb, static void emitNode(uint8_t nodeId, bool present) { const NodeState& ns = _nodes[nodeId - 1]; emitRow(nodeId, present, ns.last, ns.dieTempC, ns.healthFlags, ns.faultMask, ns.faultId, - ns.nodeKind); + ns.nodeKind, ns.fwMajor, ns.fwMinor, ns.fwPatch); } // The bridge's own row, GATEWAY_NODE_ID (#344): its heartbeat state now, and the HEALTH_0 it last -// sent (kind BRIDGE once the first HEALTH_0 has gone out). Always present — it is the one reporting. +// sent (kind BRIDGE once the first HEALTH_0 has gone out), and the version this build carries (#295). +// Always present — it is the one reporting. static void emitSelf() { const HeartbeatPayload hb = CANProtocol::makeHeartbeatPayload(GATEWAY_NODE_ID, 0); const NodeHealthPayload& h = CANProtocol::lastNodeHealth(); - emitRow(GATEWAY_NODE_ID, true, hb, h.dieTempC, h.flags, h.faultMask, h.faultId, h.nodeKind); + emitRow(GATEWAY_NODE_ID, true, hb, h.dieTempC, h.flags, h.faultMask, h.faultId, h.nodeKind, + OPENSKYHAWK_FW_MAJOR, OPENSKYHAWK_FW_MINOR, OPENSKYHAWK_FW_PATCH); } // Emit the full roster (request response / boot seed): the bridge's own node-0 row, one @@ -438,7 +451,23 @@ static void onCanRx(uint32_t canId, const uint8_t* data, uint8_t len) { auto& d = STM32Board::diagSerial(); d.print(F("[BRIDGE] READY node=")); d.println(nodeId); } +#ifdef PANELBRIDGE_NODE_STATUS + // Firmware version (#295): every boot re-announces it, so a reflash is seen at once. Cache + // it before markNodeAlive() so an alive delta already carries it; a node that rebooted + // inside the heartbeat timeout stays alive, so push the change as its own delta. An empty + // READY is older firmware: not reported. + NodeState& ns = _nodes[nodeId - 1]; + ReadyPayload r = {}; + if (len >= 3) memcpy(&r, data, len < sizeof(r) ? len : sizeof(r)); + const bool fwChanged = r.fwMajor != ns.fwMajor || r.fwMinor != ns.fwMinor + || r.fwPatch != ns.fwPatch; + ns.fwMajor = r.fwMajor; + ns.fwMinor = r.fwMinor; + ns.fwPatch = r.fwPatch; + if (!markNodeAlive(nodeId, now) && fwChanged) emitNode(nodeId, true); +#else markNodeAlive(nodeId, now); +#endif broadcastSyncReq(); return; } diff --git a/Firmware/Libraries/PanelBridge/library.json b/Firmware/Libraries/PanelBridge/library.json index 70eab2d6..32e90c5e 100644 --- a/Firmware/Libraries/PanelBridge/library.json +++ b/Firmware/Libraries/PanelBridge/library.json @@ -16,6 +16,7 @@ "dependencies": { "STM32Board": "*", "CANProtocol": "*", + "FirmwareVersion": "*", "NodeStatus": "*", "A4EC": "*", "DCS-BIOS": "^0.3.10" diff --git a/Firmware/Libraries/PanelGroup/PanelGroup.cpp b/Firmware/Libraries/PanelGroup/PanelGroup.cpp index ef7ce0d9..0e91949c 100644 --- a/Firmware/Libraries/PanelGroup/PanelGroup.cpp +++ b/Firmware/Libraries/PanelGroup/PanelGroup.cpp @@ -299,9 +299,10 @@ void setup() { // Step 7 — flush trailing batch CANProtocol::flushBatched(canIdEvt(NODE_ID)); - // Step 8 — READY frame. The heartbeat/HEALTH timers arm on the first serviceNodeStatus() call - // in loop(); the master-loss watchdog stays disarmed until the first HB_0 is seen. - CANProtocol::send(canIdReady(NODE_ID), nullptr, 0); + // Step 8 — READY frame, carrying this build's firmware version (#295). The heartbeat/HEALTH + // timers arm on the first serviceNodeStatus() call in loop(); the master-loss watchdog stays + // disarmed until the first HB_0 is seen. + CANProtocol::sendReady(); } // ── PanelGroup::loop ────────────────────────────────────────────────────────── diff --git a/Firmware/Libraries/PanelGroup/PanelGroup.h b/Firmware/Libraries/PanelGroup/PanelGroup.h index 534662a2..24f209dd 100644 --- a/Firmware/Libraries/PanelGroup/PanelGroup.h +++ b/Firmware/Libraries/PanelGroup/PanelGroup.h @@ -246,7 +246,7 @@ namespace PanelGroup { * 5. CANProtocol::start(). * 6. forceReport() burst — one EVT per registered input. * 7. flushBatched(canIdEvt(NODE_ID)). - * 8. READY_n frame (canIdReady(NODE_ID), DLC=0). (Heartbeat/HEALTH timers arm on + * 8. READY_n frame (CANProtocol::sendReady(): the firmware version, #295). (Heartbeat/HEALTH timers arm on * the first CANProtocol::serviceNodeStatus() call in loop().) * * @note Wire.begin() must be called by the sketch before this function. diff --git a/Firmware/Libraries/PowerMonitor/PduNode.cpp b/Firmware/Libraries/PowerMonitor/PduNode.cpp index a613c7a8..2e6edda8 100644 --- a/Firmware/Libraries/PowerMonitor/PduNode.cpp +++ b/Firmware/Libraries/PowerMonitor/PduNode.cpp @@ -224,7 +224,7 @@ void setup() { auto& d = out(); d.print(F("[PDU] NODE_ID=")); d.println((int)NODE_ID); printCal(); - CANProtocol::send(canIdReady(NODE_ID), nullptr, 0); + CANProtocol::sendReady(); } void loop() { diff --git a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md index d06ac634..e152cd0a 100644 --- a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md +++ b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md @@ -530,3 +530,37 @@ the calibration protocol. **Affects:** `03-uart-usb-hid-protocol.md`, `07-simgateway-api.md`, `08-hardware-firmware-contracts.md`, `09-startup-resync-diagnostics.md`, TechSpec `SimGateway.md`, `docs/architecture/sim-gateway.md`, `docs/firmware/debugging.md`. Client half: SkyHawkClient sync of `CalDiagField`. + +--- + +## D21 — Every node announces its firmware version in READY_n (#295) + +**Decision:** `READY_n`, empty until now, carries a `ReadyPayload`: bytes 0–2 = the firmware +release version (`OPENSKYHAWK_FW_*` from the header-only `FirmwareVersion` library, rewritten by +release-please), bytes 3–7 reserved. Nodes send it through `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, and reports it in `_NODE_STATUS` **v4** (34 hex), the node-0 row carrying +its own. An empty READY (older firmware) and a READY the bridge never saw both read 0.0.0, "not +reported". + +**Rationale:** +- *v1.0 has a mixed-version rule* — nodes on one bus share a major version — and until now nothing + on the bus said which firmware a board runs. A missed board in a reflash stayed invisible. +- *READY, not HEALTH.* HEALTH's two reserved bytes are kept for future generic health, and TELEM + never carries health, so spending them on identity would leave health nowhere to grow. READY was + empty: eight free bytes, no new frame, no extra traffic. +- *Every update is a reboot.* An ST-Link upload resets the MCU, and READY is the last step of every + boot, so a reflash is announced at once. A node that only lost its CAN link doesn't reboot, and + its cached version is still right. +- *Empty means older, unambiguously.* Pre-#295 firmware sends READY with DLC 0, so a missing + payload can't be mistaken for a real version. +- *Accepted gap:* READY is once per boot, so a bridge that boots after a node reads it as not + reported until that node reboots. A re-announce on the bridge's boot `SYNC_REQ` can follow as a + minor change — `SYNC_REQ` is empty today. +- *One version source.* The same header feeds SimGateway's `HELLO_ACK`, so the Gateway's two MCUs + can't report different versions for one build. + +**Affects:** `02-can-protocol.md`, `03-uart-usb-hid-protocol.md`, `04-dcs-bios-integration.md`, +`06-panelbridge-api.md`, `09-startup-resync-diagnostics.md`, TechSpec (`CANProtocol`, `NodeStatus`, +`PanelBridge`, `PanelGroup`, `PowerMonitor`, `README`), `docs/architecture/can-bus.md`, +`docs/architecture/firmware-tiers.md`. Client half: SkyHawkClient sync of `NodeStatusField` v4. diff --git a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md index 9758ce08..ff80128d 100644 --- a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md +++ b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md @@ -91,7 +91,7 @@ constexpr uint32_t canIdReady(uint8_t n) { return 0x400 + n; } // 0x401–0x43F | `TELEM_n` | `0x180+n` | Node_n → PanelBridge | 8 | Paged node telemetry (`TelemPayload`) — one frame per second plus flag-change edges; first user the PDU (#216) — see TELEM payload below | | `EVT_n` | `0x200+n` | Node_n → PanelBridge | 8 | Absolute input events (switches, selectors, pots) — `ControlPacketPair`, value `%u` → `set_state` | | `ECHO_n` | `0x300+n` | Node_n → PanelBridge | 8 | TEST_SEQ echo — 8-byte payload mirrored unchanged | -| `READY_n` | `0x400+n` | Node_n → PanelBridge | 0 | One-time boot signal after initial input poll and EVT burst are complete | +| `READY_n` | `0x400+n` | Node_n → PanelBridge | 8 | One-time boot signal after initial input poll and EVT burst are complete; carries the node's firmware version (`ReadyPayload`, #295) — see READY payload below | | `EVT_REL_n` | `0x500+n` | Node_n → PanelBridge | 8 | Relative encoder events (`RotaryEncoder` REL) — `ControlPacketPair`, signed `%+d` → `variable_step` (#147) | | `EVT_DIR_n` | `0x600+n` | Node_n → PanelBridge | 8 | Directional encoder events (`RotaryEncoder` DIR) — `ControlPacketPair`, `±1` → `INC`/`DEC` `fixed_step` (#147) | | `EVT_ACTION_n` | `0x700+n` | Node_n → PanelBridge | 8 | Action events (`ActionButton`) — `ControlPacketPair`, selector `0` → `TOGGLE` `action` (#116) | @@ -155,7 +155,8 @@ health that PanelBridge reports to the host. No new CAN frame is added — Panel last `HB_n` per node and re-serializes it into a DCS-BIOS `_NODE_STATUS` message (see `04-dcs-bios-integration.md` and `06-panelbridge-api.md`). As of `_NODE_STATUS` proto **v2** the message also carries the node's cached `dieTempC` + health/fault fields from `HEALTH_n` (below); -proto **v3** (#344) adds the node's board kind and the bridge's own node-0 row. +proto **v3** (#344) adds the node's board kind and the bridge's own node-0 row; proto **v4** (#295) +adds the node's firmware version from its `READY_n` (below). --- @@ -202,7 +203,7 @@ kind of its own. Firmware older than #344 sent `0` in this byte (it was reserved as unknown — the client never adopts a node on it. **Default-on**, compile out with `-DNODE_HEALTH_TELEM=0`. PanelBridge caches each node's fields -(under `PANELBRIDGE_NODE_STATUS`) and forwards all of them in `_NODE_STATUS` (proto v3, #344), plus +(under `PANELBRIDGE_NODE_STATUS`) and forwards all of them in `_NODE_STATUS` (proto v4, #295), plus its own `HEALTH_0` as the node-0 row. A `HEALTH_n` frame never changes node liveness (`HB_n` owns that); the only delta it triggers is a `_NODE_STATUS` push when the node's cached `nodeKind` changes (see `04-dcs-bios-integration.md`). @@ -277,6 +278,34 @@ are `FaultSource`s, so their worst condition also reaches `HEALTH_n.faultId` (`F --- +## Ready Payload — READY_n (#295) + +`READY_n` (`0x400+n`) is sent once per boot, at the end of the node's `setup()` after its boot EVT +burst (`CANProtocol::sendReady()`), so every reflash, reset or power cycle announces the firmware +the node now runs: + +```cpp +struct ReadyPayload { // 8 bytes, DLC 8 + uint8_t fwMajor; // 0: firmware release version (FirmwareVersion.h, OPENSKYHAWK_FW_*) + uint8_t fwMinor; // 1 + uint8_t fwPatch; // 2 + uint8_t rsvd[5]; // 3-7: reserved, transmit 0 +}; +``` + +PanelBridge caches the version per node (under `PANELBRIDGE_NODE_STATUS`), keeps it when the node +times out, and reports it in `_NODE_STATUS` v4 (`04-dcs-bios-integration.md`). A READY from a node +that is already alive — it rebooted inside the 3 s heartbeat timeout, e.g. a quick reflash — pushes +a `_NODE_STATUS` delta when the version changed. Firmware older than #295 sends READY with DLC 0: +the bridge reads that as **not reported** (0.0.0). The bridge's only other use of READY — mark the +node alive, broadcast `SYNC_REQ` — is unchanged and ignores the payload. + +**Known gap (accepted):** READY is one frame per boot. If the bridge boots after a node, it never +sees that node's READY, and the node reads 0.0.0 until it next reboots. A re-announce on the +bridge's boot `SYNC_REQ` can be added later without a wire break (`SYNC_REQ` is empty today). + +--- + ## ControlPacketPair Frame Payload `CTRL_BCAST` and `EVT_n` frames carry exactly **8 bytes** — a `ControlPacketPair`. diff --git a/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md b/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md index 0b06119b..5b5a4044 100644 --- a/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md +++ b/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md @@ -37,11 +37,13 @@ forwards host→device; the response is ASCII it forwards device→host). PanelB **Request** — client writes a DCS-BIOS export frame to `0x86FE`. PanelBridge's `NodeStatusReqListener` (an `ExportStreamListener`) fires → emits the full roster. -**Response** — `_NODE_STATUS ` where `` is **28 chars** (proto **v3**, #344; v2 was 26 chars -without `nodeKind`) — a `NODE_STATUS_ROW_BYTES` (14) byte row, **each field its numeric value as -fixed-width uppercase hex (most-significant nibble first)**: -`nodeId(2) present(2) flags(2) uptime(4) rxCount(4) esr(4) dieTempC(2) hFlags(2) faultMask(2) faultId(2) nodeKind(2)` -(the 8-byte `HeartbeatPayload` plus `present`, then the node's cached `HEALTH_n` fields). Each field's +**Response** — `_NODE_STATUS ` where `` is **34 chars** (proto **v4**, #295; v3 was 28 chars +without the version, v2 26 without `nodeKind`) — a `NODE_STATUS_ROW_BYTES` (17) byte row, **each field +its numeric value as fixed-width uppercase hex (most-significant nibble first)**: +`nodeId(2) present(2) flags(2) uptime(4) rxCount(4) esr(4) dieTempC(2) hFlags(2) faultMask(2) faultId(2) nodeKind(2) fwMajor(2) fwMinor(2) fwPatch(2)` +(the 8-byte `HeartbeatPayload` plus `present`, then the node's cached `HEALTH_n` fields, then the +firmware version from its last `READY_n`). Fields are only ever appended: a reader parses the fields +it knows from a longer row and ignores the rest. Each field's byte offset in the row is named by `NodeStatusField` in `NodeStatus.h` (hex index = 2 × offset). `present`: `01` alive, `00` removed. `flags`: `HeartbeatFlag` — bit0 BOFF, bit1 EPVF. `esr`: low byte TEC, high byte REC (`HB_ESR_TEC_SHIFT` / `HB_ESR_REC_SHIFT`). `nodeId` 0–63: `GATEWAY_NODE_ID` (0) is @@ -50,7 +52,8 @@ wrap at 65535 (~18 h / 65 k frames); treat as health indicators, not monotonic c **Node-0 row (v3).** Every roster reply starts with the bridge's own row: `present` `01`, its heartbeat state (`flags`/`uptime`/`esr` read live; `rxCount` `0000` — the bridge keeps no RX -count), and the health fields of the `HEALTH_0` it last sent, so `nodeKind` `03` (bridge). Before +count), the health fields of the `HEALTH_0` it last sent, so `nodeKind` `03` (bridge), and (v4) the +version this bridge build carries. Before its first `HEALTH_0` (~1 s after boot — e.g. the boot seed) the row reads not-yet-seen like any node: `dieTempC` `80`, `nodeKind` `00`. The row only appears in roster replies, never as a live delta: the bridge is the one reporting, so it is present whenever anything arrives. @@ -67,14 +70,20 @@ The last five fields come from the node's `HEALTH_n` frame (see `02-can-protocol `HEALTH_n` (same rule as `dieTempC` `80`) and from pre-#344 firmware. The client adopts a new node only on `01` or `02`. A `faultId` of `09` (`DUPLICATE_NODE_ID`) means two boards share this `nodeId`; every other field in the row is then a blend of both boards. +- `fwMajor` / `fwMinor` / `fwPatch` (v4, #295): the firmware release version from the node's last + `READY_n` (`ReadyPayload`, `02-can-protocol.md`). `000000` = not reported: no READY seen since + the bridge booted, or an empty READY from older firmware. Kept when the node times out. Nodes on + one bus must share a major version; the client warns when a node's major differs from the + node-0 row's. **Emission semantics:** - A single bare `_NODE_STATUS` is a **live delta** — emitted on each node alive/dead transition (`present` 01/00), and (v3) when an alive node's cached `nodeKind` changes. The alive delta goes out on a node's first `HB_n`, before its first `HEALTH_n`, so it reads kind `00`; the kind delta follows ~1 s later, when that `HEALTH_n` arrives. A node re-flashed to another role pushes one - too. A cached kind is kept when a node times out and comes back. The host applies a delta - immediately. + too. A cached kind is kept when a node times out and comes back. (v4) A `READY_n` that changes an + alive node's cached version pushes one as well — a node reflashed inside the 3 s heartbeat timeout + never goes dead, so no alive delta would carry it. The host applies a delta immediately. - A **request/boot reply** is the node-0 row, then one `_NODE_STATUS` per alive node, then `_NODE_STATUS_END `. That set is the **authoritative present-roster** — the host reconciles (prunes nodes absent from it). `count` includes the node-0 row: `count=1` = the @@ -91,7 +100,7 @@ change). The whole feature is gated behind `-DPANELBRIDGE_NODE_STATUS` (default ### Node telemetry — `_NODE_TELEM` (#216) Same transport, separate contract (`NODE_TELEM_*` in `NodeStatus.h`, `NODE_TELEM_PROTO_VERSION` 1 — -independent of `_NODE_STATUS`, now v3). PanelBridge forwards **every** `TELEM_n` CAN frame +independent of `_NODE_STATUS`, now v4). PanelBridge forwards **every** `TELEM_n` CAN frame (`0x181`–`0x1BF`, see `02-can-protocol.md`) to the host **unread**, one DCS-BIOS command message per frame: diff --git a/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md b/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md index 9d318dd6..cd6a1525 100644 --- a/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md +++ b/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md @@ -55,13 +55,16 @@ emitted in response to `SYNC_REQ` are the state snapshot. PanelBridge surfaces the roster + per-node health to OpenSkyhawk Client over DCS-BIOS (see `04-dcs-bios-integration.md`). It caches each node's last `HeartbeatPayload` (`NodeState::last`, populated in the `HB_n` handler) plus its last `HEALTH_n` fields (`dieTempC` + health/fault + -`nodeKind`, cached in the `HEALTH_n` handler, #221/#344) and emits a `_NODE_STATUS ` DCS-BIOS -command message (proto v3): +`nodeKind`, cached in the `HEALTH_n` handler, #221/#344) and the firmware version from its last +`READY_n` (`fwMajor`/`fwMinor`/`fwPatch`, cached in the `READY_n` handler, #295) and emits a +`_NODE_STATUS ` DCS-BIOS command message (proto v4): - on every alive/dead transition — a single bare `_NODE_STATUS` (a live delta; the 3 s heartbeat timeout in `checkNodeTimeouts` emits `present=00` for a silently-dead node), - when an alive node's cached `nodeKind` changes (#344) — normally its first `HEALTH_n`, ~1 s after - the alive delta, which went out with kind `00`, and + the alive delta, which went out with kind `00`, +- when a `READY_n` changes an alive node's cached firmware version (#295) — a reflash inside the + heartbeat timeout, and - at boot, and in response to a host request — a DCS-BIOS export write to `NODE_STATUS_REQ_ADDR` (`0x86FE`), handled by a dedicated `ExportStreamListener`, excluded from the CAN broadcast. A request/boot reply is the bridge's own node-0 row (its live heartbeat state + diff --git a/Firmware/ScratchPad/FirmwarePlan/09-startup-resync-diagnostics.md b/Firmware/ScratchPad/FirmwarePlan/09-startup-resync-diagnostics.md index 28156350..9780070a 100644 --- a/Firmware/ScratchPad/FirmwarePlan/09-startup-resync-diagnostics.md +++ b/Firmware/ScratchPad/FirmwarePlan/09-startup-resync-diagnostics.md @@ -26,9 +26,10 @@ assignments (→ 08), HID frame format (→ 03). `0x8000 <= controlId <= 0x86FF` → `sendDcsBiosMessage()` → ASCII on UART → SimGateway → USB CDC; `controlId < 0x8000` → HID frame → SimGateway → USB HID report. If no process is reading the USB ports, the host silently discards the data — no special handling needed. -7. Send `READY` frame (`canIdReady(NODE_ID)` = `0x400 + NODE_ID`) — signals PanelBridge - that the initial input poll and EVT burst are complete, and that this node is ready to - answer a full-state `SYNC_REQ`. +7. Send `READY` frame (`CANProtocol::sendReady()` → `canIdReady(NODE_ID)` = `0x400 + NODE_ID`) — + signals PanelBridge that the initial input poll and EVT burst are complete, and that this node + is ready to answer a full-state `SYNC_REQ`. Its payload is the node's firmware version + (`ReadyPayload`, #295). 8. Heartbeat begins — `CANProtocol::serviceNodeStatus()` arms its timers on its first call, in the first `loop()` after boot, so the first HB goes out ~500 ms after boot completes (#340). @@ -69,7 +70,9 @@ and ready to answer a full-state request. PanelBridge logs the READY event, upda `lastSeenMs` like a heartbeat, marks the node alive if needed, and broadcasts `SYNC_REQ`. READY does not carry input state itself. The state snapshot is the EVT burst emitted when the -PanelGroup receives the following `SYNC_REQ`. +PanelGroup receives the following `SYNC_REQ`. It carries the node's firmware version instead +(#295): PanelBridge caches it per node for `_NODE_STATUS` and pushes a delta when an alive node's +version changes. An empty READY (older firmware) reads as not reported. --- diff --git a/Firmware/ScratchPad/TechSpec/CANProtocol.md b/Firmware/ScratchPad/TechSpec/CANProtocol.md index 75343277..80032a40 100644 --- a/Firmware/ScratchPad/TechSpec/CANProtocol.md +++ b/Firmware/ScratchPad/TechSpec/CANProtocol.md @@ -55,7 +55,8 @@ Firmware/Tests/CANProtocol/ ├── platformio.ini └── tests/ ├── protocol_layout.cpp — static_assert sizeof(ControlPacket)==4, - │ sizeof(ControlPacketPair)==8, sizeof(HeartbeatPayload)==8; + │ sizeof(ControlPacketPair)==8, sizeof(HeartbeatPayload)==8, + │ sizeof(ReadyPayload)==8 with the version in bytes 0-2 (#295); │ canIdHb(0)==0x100 reserved, canIdHb(1)==0x101; slot B null │ sentinel is controlId == 0x0000 ├── loopback_filter.cpp — filterAcceptId / filterAcceptAll; mandatory IDs always present; @@ -279,6 +280,20 @@ struct __attribute__((packed)) TelemPayload { }; static_assert(sizeof(TelemPayload) == 8, "TelemPayload must fill one classic CAN frame"); +/** + * @brief 8-byte payload carried by READY_n — the node's boot announcement (#295). + * + * The firmware release version (FirmwareVersion.h). Sent once per boot by sendReady(); older + * firmware sends READY_n empty (DLC 0), which PanelBridge reports as not reported (0.0.0). + */ +struct __attribute__((packed)) ReadyPayload { + uint8_t fwMajor; ///< 0: OPENSKYHAWK_FW_MAJOR + uint8_t fwMinor; ///< 1 + uint8_t fwPatch; ///< 2 + uint8_t rsvd[5]; ///< 3-7: reserved, transmit 0 +}; +static_assert(sizeof(ReadyPayload) == 8, "ReadyPayload must fill one classic CAN frame"); + /** @brief CAN bus health states. Reported to STM32Board via onStatusChange(). */ enum class CanStatus { STARTING, ///< Peripheral configured, not yet started @@ -586,6 +601,15 @@ namespace CANProtocol { */ uint32_t txDropCount(); + /** @brief Build the READY_n payload: this build's firmware version (FirmwareVersion.h), #295. */ + ReadyPayload makeReadyPayload(); + + /** + * @brief Send this node's READY_n with makeReadyPayload(). Called once, at the end of the node + * library's setup() (PanelGroup step 8, PduNode) — never by sketches. + */ + void sendReady(); + // ── Node status service (every node) ────────────────────────────────────── /** diff --git a/Firmware/ScratchPad/TechSpec/NodeStatus.md b/Firmware/ScratchPad/TechSpec/NodeStatus.md index a0ba4833..035a52c8 100644 --- a/Firmware/ScratchPad/TechSpec/NodeStatus.md +++ b/Firmware/ScratchPad/TechSpec/NodeStatus.md @@ -18,8 +18,9 @@ Owns: version, request address, message names). **The canonical contract source the client's `sync-a4ec.ts` parses**; `reference.test.ts` asserts the proto version so a firmware bump fails the client loudly. -- **`GATEWAY_NODE_ID`** (0) and the `_NODE_STATUS` row layout — `NODE_STATUS_ROW_BYTES` (14) and - `NodeStatusField` (byte offset of each row field; hex index = 2 × offset). +- **`GATEWAY_NODE_ID`** (0) and the `_NODE_STATUS` row layout — `NODE_STATUS_ROW_BYTES` (17) and + `NodeStatusField` (byte offset of each row field; hex index = 2 × offset). Fields are only ever + appended (v4 added the firmware version from READY_n, #295). - **`HeartbeatFlag`** (BOFF, EPVF) + **`HB_ESR_TEC_SHIFT` / `HB_ESR_REC_SHIFT`** — the HB_n `flags` bits and `esr` byte split. - **`NodeHealthFlag`** — HEALTH_n flag bits (OVERHEAT, DEGRADED) mirroring `NodeHealthPayload.flags`; @@ -28,7 +29,7 @@ Owns: UNDER_VOLTAGE, SHORT_CIRCUIT, HOST_LINK_LOST, DUPLICATE_NODE_ID, …). Client maps id → label (SkyHawkClient#40). - **`NodeKind`** (#344) — the `HEALTH_n` byte-5 board kind (UNKNOWN / PANEL_GROUP / PDU / BRIDGE), - append-only, forwarded as `_NODE_STATUS` v3's last field. Set by each node library's role via + append-only, forwarded in `_NODE_STATUS` (field `NODE_KIND`). Set by each node library's role via `CANProtocol::serviceNodeStatus(kind, …)`, never from the NODE_ID. - **`FaultSource`** — the interface a fault-producing object implements + a self-registering list. - **`NODE_TELEM_*` + the TELEM_n page vocabulary (#216)** — `NODE_TELEM_MSG_NAME` (`_NODE_TELEM`) and @@ -63,7 +64,7 @@ Firmware/Libraries/NodeStatus/ ## Public API ```cpp -#define NODE_STATUS_PROTO_VERSION 3 // bump on ANY _NODE_STATUS wire change (v3: nodeKind + node-0 row, #344) +#define NODE_STATUS_PROTO_VERSION 4 // bump on ANY _NODE_STATUS wire change (v3: nodeKind + node-0 row, #344; v4: firmware version, #295) #define NODE_STATUS_REQ_ADDR 0x86FE #define NODE_STATUS_MSG_NAME "_NODE_STATUS" #define NODE_STATUS_END_MSG_NAME "_NODE_STATUS_END" @@ -71,10 +72,11 @@ Firmware/Libraries/NodeStatus/ #define NODE_TELEM_MSG_NAME "_NODE_TELEM" constexpr uint8_t GATEWAY_NODE_ID = 0; // the bridge: HB_0 / HEALTH_0, first roster row -constexpr uint8_t NODE_STATUS_ROW_BYTES = 14; // 28 hex +constexpr uint8_t NODE_STATUS_ROW_BYTES = 17; // 34 hex enum class NodeStatusField : uint8_t { // row byte offsets; multi-byte fields MSB-first ID = 0, PRESENT = 1, HB_FLAGS = 2, UPTIME = 3, RX_COUNT = 5, ESR = 7, DIE_TEMP_C = 9, HEALTH_FLAGS = 10, FAULT_MASK = 11, FAULT_ID = 12, NODE_KIND = 13, + FW_MAJOR = 14, FW_MINOR = 15, FW_PATCH = 16, // from READY_n; 00 00 00 = not reported (#295) }; constexpr uint8_t NODE_TELEM_ROW_BYTES = 9; // 18 hex enum class NodeTelemField : uint8_t { ID = 0, FRAME = 1 }; @@ -158,8 +160,8 @@ its detail, null `faultDetail()` normalized to `""`, `nullptr` detailOut accepte indirectly: `DrumDisplay` breaker test asserts `faultCode()`/`faultDetail()` via the `FaultSource` override; `CANProtocol` `health_payload`/`health_flags` tests assert the faultId/DEGRADED packing (incl. overheat+degraded coexistence) and the `nodeKind` byte; `CANProtocol` duplicate_node_id test -covers `DUPLICATE_NODE_ID` raise/hold/clear; `PanelBridge` node_status test exercises the v3 -`_NODE_STATUS` emission (kind `00` before HEALTH, kind delta, node-0 row); `PanelBridge` node_telem test exercises `_NODE_TELEM` forwarding; `PowerMonitor` tests +covers `DUPLICATE_NODE_ID` raise/hold/clear; `PanelBridge` node_status test exercises the v4 +`_NODE_STATUS` emission (kind `00` before HEALTH, kind delta, version delta from READY_n, node-0 row); `PanelBridge` node_telem test exercises `_NODE_TELEM` forwarding; `PowerMonitor` tests cover the PDU fault sources and page flags; the client `reference.test.ts` asserts the proto-version contract. diff --git a/Firmware/ScratchPad/TechSpec/PanelBridge.md b/Firmware/ScratchPad/TechSpec/PanelBridge.md index 6ca39263..47af3730 100644 --- a/Firmware/ScratchPad/TechSpec/PanelBridge.md +++ b/Firmware/ScratchPad/TechSpec/PanelBridge.md @@ -371,16 +371,21 @@ All other IDs are discarded silently. `HB_0` (`0x100`) is PanelBridge's own tran a child-node heartbeat — one that does arrive is another board on node 0, which `CANProtocol::drain()` flags as a duplicate NODE_ID before `onCanRx` ignores it (#344). -### Node-status reporting (#86, proto v3 #344) +### Node-status reporting (#86, proto v3 #344, v4 #295) -Behind `-DPANELBRIDGE_NODE_STATUS`. `NodeState` caches each node's last `HeartbeatPayload` and its +Behind `-DPANELBRIDGE_NODE_STATUS`. `NodeState` caches each node's last `HeartbeatPayload`, its `HEALTH_n` fields (`dieTempC`, `healthFlags`, `faultMask`, `faultId`, `nodeKind` — not-yet-seen: -`DIE_TEMP_UNAVAILABLE` / `0`). One `emitRow()` formats a 28-hex `_NODE_STATUS` row — its `snprintf` +`DIE_TEMP_UNAVAILABLE` / `0`) and the firmware version from its last `READY_n` (`fwMajor`, +`fwMinor`, `fwPatch` — `0.0.0` = not reported; an empty READY from older firmware also reads 0.0.0; +kept across a timeout). One `emitRow()` formats a 34-hex `_NODE_STATUS` row — its `snprintf` format is the `NodeStatusField` layout, held to it by a `static_assert`; `emitNode(id, present)` feeds it from the cache and `emitSelf()` from the bridge's own state — `makeHeartbeatPayload(GATEWAY_NODE_ID, 0)` -plus `CANProtocol::lastNodeHealth()` (its last `HEALTH_0`, kind `BRIDGE`). `emitAllNodes()` (boot +plus `CANProtocol::lastNodeHealth()` (its last `HEALTH_0`, kind `BRIDGE`) and its own +`OPENSKYHAWK_FW_*` version. `emitAllNodes()` (boot seed + host request) sends the node-0 row, every alive node, then `_NODE_STATUS_END ` with -node 0 counted. Live deltas: alive/dead transitions, and a `nodeKind` change on an alive node. +node 0 counted. Live deltas: alive/dead transitions, a `nodeKind` change on an alive node, and a +`READY_n` that changes an alive node's version (a reflash inside the heartbeat timeout — the READY +branch caches the version before `markNodeAlive()`, so an alive delta already carries it). Contract: FirmwarePlan `04-dcs-bios-integration.md`. Test hooks (`PANELBRIDGE_TEST`): `testFeedHeartbeat`, `testFeedHealth(nodeId, dieTempC, hFlags, @@ -390,7 +395,8 @@ faultMask, faultId, nodeKind)` (mirrors the `HEALTH_n` branch, incl. the kind de ### READY and Recovery SYNC_REQ READY is a signal that a PanelGroup completed its boot-time input poll and can answer a -full-state request. READY carries no input data. On READY, PanelBridge updates `lastSeenMs`, +full-state request. READY carries no input data — its payload is the node's firmware version +(`ReadyPayload`, #295), cached for node-status reporting. On READY, PanelBridge updates `lastSeenMs`, marks the node alive if needed, and broadcasts `SYNC_REQ`. Heartbeat recovery uses the same mechanism. If a node was unseen or marked dead and later diff --git a/Firmware/ScratchPad/TechSpec/PanelGroup/PanelGroup.md b/Firmware/ScratchPad/TechSpec/PanelGroup/PanelGroup.md index 5b6d5ee1..ac555005 100644 --- a/Firmware/ScratchPad/TechSpec/PanelGroup/PanelGroup.md +++ b/Firmware/ScratchPad/TechSpec/PanelGroup/PanelGroup.md @@ -333,7 +333,7 @@ namespace PanelGroup { * CANProtocol::sendBatched(canIdEvt(NODE_ID), ...). * 6. CANProtocol::flushBatched(canIdEvt(NODE_ID)) — flush any trailing odd packet * before READY. - * 7. Send READY_n frame (canIdReady(NODE_ID), DLC=0). + * 7. Send READY_n frame (CANProtocol::sendReady() — the firmware version, #295). * 8. (Heartbeat/HEALTH timers arm on the first CANProtocol::serviceNodeStatus() call * in loop() — first HB_n ~500 ms after boot completes.) * @@ -630,8 +630,9 @@ is left pending. ### READY Frame -Sent once during `setup()`, after the initial EVT burst and `flushBatched()`. DLC = 0. No -payload. CAN ID = `canIdReady(NODE_ID)`. PanelBridge marks the node online and broadcasts +Sent once during `setup()`, after the initial EVT burst and `flushBatched()`, by +`CANProtocol::sendReady()`. DLC = 8: `ReadyPayload`, the firmware release version (#295). +CAN ID = `canIdReady(NODE_ID)`. PanelBridge marks the node online and broadcasts `SYNC_REQ` on receipt. ### Heartbeat, HEALTH and master watchdog @@ -710,8 +711,8 @@ for (InputBase* p = InputBase::head(); p; p = p->next()) { p->forceReport(); } // 6. Flush trailing batch CANProtocol::flushBatched(canIdEvt(NODE_ID)); -// 7. READY frame -CANProtocol::send(canIdReady(NODE_ID), nullptr, 0); +// 7. READY frame — carries the firmware version (#295) +CANProtocol::sendReady(); // (HB/HEALTH timers arm on the first CANProtocol::serviceNodeStatus() call in loop().) ``` diff --git a/Firmware/ScratchPad/TechSpec/PowerMonitor.md b/Firmware/ScratchPad/TechSpec/PowerMonitor.md index c88dc38e..b098818a 100644 --- a/Firmware/ScratchPad/TechSpec/PowerMonitor.md +++ b/Firmware/ScratchPad/TechSpec/PowerMonitor.md @@ -160,7 +160,7 @@ Plus `Tests/PanelBridge` `test_node_telem` (`_NODE_TELEM` forwarding, needs the | Dependency | Use | |---|---| -| CANProtocol | `send`, `drain`, `start`, `serviceNodeStatus`, `canIdTelem`, `canIdReady`, `TelemPayload` | +| CANProtocol | `send`, `drain`, `start`, `serviceNodeStatus`, `sendReady`, `canIdTelem`, `TelemPayload` | | STM32Board | `begin`, `tick`, `diagSerial`, `onCanStatus`, ADC resolution | | NodeStatus | `FaultSource`, `NodeFaultCode`, `TelemPage`, `PduRailFlag`, `PduStatusFlag` | | STM32duino core | `analogRead` (incl. `AVREF`), `micros`, `millis`, HAL flash (`HAL_FLASHEx_Erase`, `HAL_FLASH_Program`) | diff --git a/Firmware/Tests/ActionButton/platformio.ini b/Firmware/Tests/ActionButton/platformio.ini index 1c6a3102..8d279bd6 100644 --- a/Firmware/Tests/ActionButton/platformio.ini +++ b/Firmware/Tests/ActionButton/platformio.ini @@ -19,6 +19,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/AnalogInput/platformio.ini b/Firmware/Tests/AnalogInput/platformio.ini index 776d3546..7f3431f9 100644 --- a/Firmware/Tests/AnalogInput/platformio.ini +++ b/Firmware/Tests/AnalogInput/platformio.ini @@ -20,6 +20,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/AnalogMultiPos/platformio.ini b/Firmware/Tests/AnalogMultiPos/platformio.ini index 13e432ae..ff6903f1 100644 --- a/Firmware/Tests/AnalogMultiPos/platformio.ini +++ b/Firmware/Tests/AnalogMultiPos/platformio.ini @@ -21,6 +21,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/AngleSensorInput/platformio.ini b/Firmware/Tests/AngleSensorInput/platformio.ini index 6e38dba1..6395c7de 100644 --- a/Firmware/Tests/AngleSensorInput/platformio.ini +++ b/Firmware/Tests/AngleSensorInput/platformio.ini @@ -20,6 +20,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/CANProtocol/tests/protocol_layout/protocol_layout.cpp b/Firmware/Tests/CANProtocol/tests/protocol_layout/protocol_layout.cpp index 5aab1dae..b6dd86ee 100644 --- a/Firmware/Tests/CANProtocol/tests/protocol_layout/protocol_layout.cpp +++ b/Firmware/Tests/CANProtocol/tests/protocol_layout/protocol_layout.cpp @@ -8,11 +8,15 @@ #include #include +#include // offsetof static_assert(sizeof(ControlPacket) == 4, "ControlPacket must be 4 bytes"); static_assert(sizeof(ControlPacketPair) == 8, "ControlPacketPair must be 8 bytes"); static_assert(sizeof(HeartbeatPayload) == 8, "HeartbeatPayload must be 8 bytes"); static_assert(sizeof(TelemPayload) == 8, "TelemPayload must be 8 bytes"); +static_assert(sizeof(ReadyPayload) == 8, "ReadyPayload must be 8 bytes"); +static_assert(offsetof(ReadyPayload, fwMajor) == 0 && offsetof(ReadyPayload, fwMinor) == 1 + && offsetof(ReadyPayload, fwPatch) == 2, "ReadyPayload version bytes are 0-2 (#295)"); static_assert(canIdHb(0) == 0x100U, "canIdHb(0) reserved for PanelBridge"); static_assert(canIdHb(1) == 0x101U, "canIdHb(1) must be 0x101"); diff --git a/Firmware/Tests/Dimmer/platformio.ini b/Firmware/Tests/Dimmer/platformio.ini index b66717c3..d0072707 100644 --- a/Firmware/Tests/Dimmer/platformio.ini +++ b/Firmware/Tests/Dimmer/platformio.ini @@ -20,6 +20,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/DrumDisplay/platformio.ini b/Firmware/Tests/DrumDisplay/platformio.ini index b5c7f72d..9cc82e42 100644 --- a/Firmware/Tests/DrumDisplay/platformio.ini +++ b/Firmware/Tests/DrumDisplay/platformio.ini @@ -36,6 +36,7 @@ lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/DrumDisplay file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/IntegerOutput/platformio.ini b/Firmware/Tests/IntegerOutput/platformio.ini index 5e2c2b95..720a4090 100644 --- a/Firmware/Tests/IntegerOutput/platformio.ini +++ b/Firmware/Tests/IntegerOutput/platformio.ini @@ -19,6 +19,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/LED/platformio.ini b/Firmware/Tests/LED/platformio.ini index c45291c4..0f5374a6 100644 --- a/Firmware/Tests/LED/platformio.ini +++ b/Firmware/Tests/LED/platformio.ini @@ -20,6 +20,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/NeedleGauge/platformio.ini b/Firmware/Tests/NeedleGauge/platformio.ini index b28e0bbd..589f50ca 100644 --- a/Firmware/Tests/NeedleGauge/platformio.ini +++ b/Firmware/Tests/NeedleGauge/platformio.ini @@ -21,6 +21,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/PanelBridge/tests/dual_integration/dual_integration.cpp b/Firmware/Tests/PanelBridge/tests/dual_integration/dual_integration.cpp index e44e748e..c5680529 100644 --- a/Firmware/Tests/PanelBridge/tests/dual_integration/dual_integration.cpp +++ b/Firmware/Tests/PanelBridge/tests/dual_integration/dual_integration.cpp @@ -121,7 +121,7 @@ void loop() { // Boot sequence: EVT burst then READY after 200 ms if (!_readySent && now > 200) { sendEvtBurst(); - CANProtocol::send(canIdReady(NODE_ID), nullptr, 0); + CANProtocol::sendReady(); _readySent = true; STM32Board::diagSerial().println(F("[NODE] READY sent")); } diff --git a/Firmware/Tests/PanelBridge/tests/node_status/node_status.cpp b/Firmware/Tests/PanelBridge/tests/node_status/node_status.cpp index ef4f1127..58352444 100644 --- a/Firmware/Tests/PanelBridge/tests/node_status/node_status.cpp +++ b/Firmware/Tests/PanelBridge/tests/node_status/node_status.cpp @@ -1,13 +1,14 @@ -// PanelBridge — node_status test (#86, v3 #344) +// PanelBridge — node_status test (#86, v3 #344, v4 #295) // // Purpose: verify node-status reporting. PanelBridge surfaces connected nodes + health to the -// host as `_NODE_STATUS` DCS-BIOS command messages (proto v3, 28 hex), emitted on: +// host as `_NODE_STATUS` DCS-BIOS command messages (proto v4, 34 hex), emitted on: // - node alive (dead/unseen → alive transition), // - a node's board kind changing (its first HEALTH_n after the alive delta) (#344), +// - a node's firmware version changing (a READY_n while it is alive — a reflash) (#295), // - host request (full roster: the bridge's own node-0 row first), and -// - node removal (heartbeat timeout, present=00, cached health retained). +// - node removal (heartbeat timeout, present=00, cached health + version retained). // Exercises the PANELBRIDGE_NODE_STATUS path via PANELBRIDGE_TEST hooks — no CAN -// bus or second board required (heartbeats are injected directly). +// bus or second board required (heartbeats, HEALTH and READY are injected directly). // // Hardware: single STM32F103CB board. // - Serial (UART2 PA2/PA3) → USB-UART adapter → PC terminal @ 250000 baud @@ -19,42 +20,49 @@ // 2. Open USB-UART @ 250000 (the _NODE_STATUS messages) and DiagSerial @ 115200 (phase log). // // Also at boot (before the bridge's first HEALTH_0, so its row reads not-yet-seen 80 / kind 00): -// _NODE_STATUS 0001ffuuuu0000eeee8000000000 +// _NODE_STATUS 0001ffuuuu0000eeee8000000000vvvvvv // _NODE_STATUS_END 1 // -// Expected on USB-UART (250000), repeating each cycle. Node 1 is fed a DEGRADED HEALTH frame -// before it goes alive (42 °C → dieTempC 2A; hFlags 02 DEGRADED; faultMask 00 reserved; -// faultId 01 I2C_PERIPHERAL; nodeKind 01 PANEL_GROUP) — this proves the #163 degraded fields and -// the #344 kind forward through the cache into _NODE_STATUS. Node 2 goes alive with no HEALTH -// (temp 80, kind 00 — not yet seen), then gets its first HEALTH (35 °C, kind 02 PDU), which pushes -// one delta: +// Expected on USB-UART (250000), repeating each cycle n = 0, 1, 2, … Node 1 is fed a DEGRADED +// HEALTH frame before it goes alive (42 °C → dieTempC 2A; hFlags 02 DEGRADED; faultMask 00 +// reserved; faultId 01 I2C_PERIPHERAL; nodeKind 01 PANEL_GROUP) — this proves the #163 degraded +// fields and the #344 kind forward through the cache into _NODE_STATUS. Node 2 goes alive with no +// HEALTH (temp 80, kind 00 — not yet seen), then gets its first HEALTH (35 °C, kind 02 PDU), which +// pushes one delta. Node 1 announces firmware 1.2.n in a READY while alive (a reflash) — one delta; +// node 2 never sends READY, so its version stays 000000 (not reported). pppppp = node 1's version +// from the previous cycle (000000 on the first, kept across the timeout), nn = n in hex: // Phase A — two nodes go alive (bare delta emits, no terminator): -// _NODE_STATUS 010100000A001200002A02000101 -// _NODE_STATUS 0201020014003400028000000000 -// Phase B — node 2's first HEALTH (kind 00 → 02 delta), then host request (full roster, -// node-0 row first, terminated; count includes node 0): -// _NODE_STATUS 0201020014003400022300000002 -// _NODE_STATUS 0001ffuuuu0000eeeett00000003 -// _NODE_STATUS 010100000A001200002A02000101 -// _NODE_STATUS 0201020014003400022300000002 +// _NODE_STATUS 010100000A001200002A02000101pppppp +// _NODE_STATUS 0201020014003400028000000000000000 +// Phase B — node 1's READY (version → 1.2.n delta), node 2's first HEALTH (kind 00 → 02 delta), +// then host request (full roster, node-0 row first, terminated; count includes node 0): +// _NODE_STATUS 010100000A001200002A020001010102nn +// _NODE_STATUS 0201020014003400022300000002000000 +// _NODE_STATUS 0001ffuuuu0000eeeett00000003vvvvvv +// _NODE_STATUS 010100000A001200002A020001010102nn +// _NODE_STATUS 0201020014003400022300000002000000 // _NODE_STATUS_END 3 // Phase C — heartbeat timeout (~3 s after Phase A) removes both (present=00 deltas, -// cached health retained, incl. node 1's degraded fields and both kinds): -// _NODE_STATUS 010000000A001200002A02000101 -// _NODE_STATUS 0200020014003400022300000002 +// cached health and version retained, incl. node 1's degraded fields and both kinds): +// _NODE_STATUS 010000000A001200002A020001010102nn +// _NODE_STATUS 0200020014003400022300000002000000 // // hex = nodeId(2) present(2) flags(2) uptime(4) rxCount(4) esr(4) dieTempC(2) hFlags(2) -// faultMask(2) faultId(2) nodeKind(2); each field a fixed-width hex number (MSN first). +// faultMask(2) faultId(2) nodeKind(2) fwMajor(2) fwMinor(2) fwPatch(2); each field a +// fixed-width hex number (MSN first). // dieTempC int8 two's-complement (80 = unseen). Node-0 row: ff = the bridge's own CAN flags, -// uuuu = its uptime in s, eeee = its ESR, tt = its die temp, kind 03 BRIDGE — live values. +// uuuu = its uptime in s, eeee = its ESR, tt = its die temp, kind 03 BRIDGE — live values; +// vvvvvv = this build's version (FirmwareVersion.h; 000100 for 0.1.0). #define DCSBIOS_DEFAULT_SERIAL #include #include #include #include // NodeHealthFlag / NodeFaultCode / NodeKind for the HEALTH injections (#163/#344) +#include // canIdReady + ReadyPayload for the READY injection (#295) static uint8_t _phase = 0; +static uint8_t _cycle = 0; // node 1 announces firmware 1.2., so every cycle's READY is a change static uint32_t _lastMs = 0; void setup() { @@ -89,7 +97,12 @@ void loop() { PanelBridge::testFeedHeartbeat(2, 0x02, 20, 52, 0x0002); // node 2: no HEALTH → temp 80 _phase = 1; break; - case 1: // Phase B — host roster request + case 1: // Phase B — node 1 reflashed, node 2's first HEALTH, host roster request + { + d.print(F("[TEST] B: node 1 READY fw 1.2.")); d.print(_cycle); d.println(F(" -> expect 1 delta")); + ReadyPayload r = {}; + r.fwMajor = 1; r.fwMinor = 2; r.fwPatch = _cycle; + PanelBridge::testFeedCanFrame(canIdReady(1), reinterpret_cast(&r), sizeof(r)); d.println(F("[TEST] B: node 2 first HEALTH (kind PDU) -> expect 1 delta")); PanelBridge::testFeedHealth(2, 35, 0, 0, 0, (uint8_t)NodeKind::PDU); d.println(F("[TEST] B: request -> expect node-0 row + 2x _NODE_STATUS + _NODE_STATUS_END 3")); @@ -97,8 +110,10 @@ void loop() { d.println(F("[TEST] C: stop feeding -> ~3 s timeout removes both (present=00)")); _phase = 2; break; + } default: // idle a cycle so the real 3 s timeout fires, then repeat d.println(F("[TEST] --- cycle ---")); + _cycle++; _phase = 0; break; } diff --git a/Firmware/Tests/PanelBridge/tests/node_tracking/node_tracking.cpp b/Firmware/Tests/PanelBridge/tests/node_tracking/node_tracking.cpp index 391fc0d6..c378a382 100644 --- a/Firmware/Tests/PanelBridge/tests/node_tracking/node_tracking.cpp +++ b/Firmware/Tests/PanelBridge/tests/node_tracking/node_tracking.cpp @@ -71,7 +71,7 @@ static void sendHb() { } static void sendReady() { - CANProtocol::send(canIdReady(NODE_ID), nullptr, 0); + CANProtocol::sendReady(); } void setup() { diff --git a/Firmware/Tests/PanelBridge/tests/test_seq_diag/test_seq_diag.cpp b/Firmware/Tests/PanelBridge/tests/test_seq_diag/test_seq_diag.cpp index ecf5e404..4bbbe0a5 100644 --- a/Firmware/Tests/PanelBridge/tests/test_seq_diag/test_seq_diag.cpp +++ b/Firmware/Tests/PanelBridge/tests/test_seq_diag/test_seq_diag.cpp @@ -72,7 +72,7 @@ void loop() { uint32_t now = millis(); if (!_readySent && now > 200) { - CANProtocol::send(canIdReady(NODE_ID), nullptr, 0); + CANProtocol::sendReady(); _readySent = true; } if (now - _lastHbMs >= 500) { diff --git a/Firmware/Tests/PanelGroup/platformio.ini b/Firmware/Tests/PanelGroup/platformio.ini index fa21312e..3a6d268e 100644 --- a/Firmware/Tests/PanelGroup/platformio.ini +++ b/Firmware/Tests/PanelGroup/platformio.ini @@ -23,6 +23,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/PanelGroup/tests/boot_sequence/boot_sequence.cpp b/Firmware/Tests/PanelGroup/tests/boot_sequence/boot_sequence.cpp index a651b11c..77fa0a64 100644 --- a/Firmware/Tests/PanelGroup/tests/boot_sequence/boot_sequence.cpp +++ b/Firmware/Tests/PanelGroup/tests/boot_sequence/boot_sequence.cpp @@ -43,7 +43,7 @@ void setup() { // Emit boot sequence tail (no expanders/ADCs, no inputs for EVT burst) CANProtocol::flushBatched(canIdEvt(NODE_ID)); - CANProtocol::send(canIdReady(NODE_ID), nullptr, 0); + CANProtocol::sendReady(); // At 500 kbps, a CAN frame takes ~100 µs to complete and appear in FIFO0. // delay(2) ensures the hardware loopback completes before drain() polls FIFO0. delay(2); diff --git a/Firmware/Tests/PanelGroup/tests/heartbeat/heartbeat.cpp b/Firmware/Tests/PanelGroup/tests/heartbeat/heartbeat.cpp index 4e558d9c..ba2664d5 100644 --- a/Firmware/Tests/PanelGroup/tests/heartbeat/heartbeat.cpp +++ b/Firmware/Tests/PanelGroup/tests/heartbeat/heartbeat.cpp @@ -35,7 +35,7 @@ void setup() { // Tail of PanelGroup::setup() without full board init (the HB timer arms in loop()) CANProtocol::flushBatched(canIdEvt(NODE_ID)); - CANProtocol::send(canIdReady(NODE_ID), nullptr, 0); + CANProtocol::sendReady(); setupDoneMs = millis(); } diff --git a/Firmware/Tests/PinRef/platformio.ini b/Firmware/Tests/PinRef/platformio.ini index aad65716..3d15d35a 100644 --- a/Firmware/Tests/PinRef/platformio.ini +++ b/Firmware/Tests/PinRef/platformio.ini @@ -23,6 +23,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls @@ -55,5 +56,6 @@ build_src_filter = -<*> + lib_deps = file://../../Libraries/STM32Board file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/HIDControls diff --git a/Firmware/Tests/RotaryAcceleratedEncoder/platformio.ini b/Firmware/Tests/RotaryAcceleratedEncoder/platformio.ini index bf940bfb..46305cc8 100644 --- a/Firmware/Tests/RotaryAcceleratedEncoder/platformio.ini +++ b/Firmware/Tests/RotaryAcceleratedEncoder/platformio.ini @@ -20,6 +20,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/RotaryEncoder/platformio.ini b/Firmware/Tests/RotaryEncoder/platformio.ini index decb9dca..3cc11655 100644 --- a/Firmware/Tests/RotaryEncoder/platformio.ini +++ b/Firmware/Tests/RotaryEncoder/platformio.ini @@ -20,6 +20,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/ShiftBus/platformio.ini b/Firmware/Tests/ShiftBus/platformio.ini index c26a3d13..bf4f00c9 100644 --- a/Firmware/Tests/ShiftBus/platformio.ini +++ b/Firmware/Tests/ShiftBus/platformio.ini @@ -22,6 +22,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/StepperMotor/platformio.ini b/Firmware/Tests/StepperMotor/platformio.ini index 08923457..b6bfa0f9 100644 --- a/Firmware/Tests/StepperMotor/platformio.ini +++ b/Firmware/Tests/StepperMotor/platformio.ini @@ -24,6 +24,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/Switch2Pos/platformio.ini b/Firmware/Tests/Switch2Pos/platformio.ini index 87e9c390..7ca4f232 100644 --- a/Firmware/Tests/Switch2Pos/platformio.ini +++ b/Firmware/Tests/Switch2Pos/platformio.ini @@ -19,6 +19,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/Switch3Pos/platformio.ini b/Firmware/Tests/Switch3Pos/platformio.ini index 890c6e87..9ca82614 100644 --- a/Firmware/Tests/Switch3Pos/platformio.ini +++ b/Firmware/Tests/Switch3Pos/platformio.ini @@ -20,6 +20,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/SwitchMultiPos/platformio.ini b/Firmware/Tests/SwitchMultiPos/platformio.ini index ba7f8957..16d7566d 100644 --- a/Firmware/Tests/SwitchMultiPos/platformio.ini +++ b/Firmware/Tests/SwitchMultiPos/platformio.ini @@ -20,6 +20,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/Firmware/Tests/SwitchWithCover2Pos/platformio.ini b/Firmware/Tests/SwitchWithCover2Pos/platformio.ini index d952ea13..5d31f5b2 100644 --- a/Firmware/Tests/SwitchWithCover2Pos/platformio.ini +++ b/Firmware/Tests/SwitchWithCover2Pos/platformio.ini @@ -20,6 +20,7 @@ monitor_speed = 115200 lib_deps = file://../../Libraries/PanelGroup file://../../Libraries/CANProtocol + file://../../Libraries/FirmwareVersion file://../../Libraries/NodeStatus file://../../Libraries/STM32Board file://../../Libraries/HIDControls diff --git a/docs/architecture/can-bus.md b/docs/architecture/can-bus.md index e622e65e..9a90ec57 100644 --- a/docs/architecture/can-bus.md +++ b/docs/architecture/can-bus.md @@ -35,7 +35,7 @@ helper functions — **never hard-code these values**, always use the helper. | `TELEM_n` | `0x180 + n` | `canIdTelem(n)` | Node *n* → PanelBridge | 8 | | `EVT_n` | `0x200 + n` | `canIdEvt(n)` | Node *n* → PanelBridge | 8 | | `ECHO_n` | `0x300 + n` | `canIdEcho(n)` | Node *n* → PanelBridge | 8 | -| `READY_n` | `0x400 + n` | `canIdReady(n)` | Node *n* → PanelBridge | 0 | +| `READY_n` | `0x400 + n` | `canIdReady(n)` | Node *n* → PanelBridge | 8 | | `EVT_REL_n` | `0x500 + n` | `canIdEvtRel(n)` | Node *n* → PanelBridge | 8 | | `EVT_DIR_n` | `0x600 + n` | `canIdEvtDir(n)` | Node *n* → PanelBridge | 8 | | `EVT_ACTION_n` | `0x700 + n` | `canIdEvtAction(n)` | Node *n* → PanelBridge | 8 | @@ -120,7 +120,8 @@ on the [NODE_ID & CAN Addressing](../firmware/node-id.md) page. PanelGroup nodes may power up before or after PanelBridge, so boot state can't be assumed. The `SYNC_REQ` / `READY` handshake makes input state deterministic: -1. On cold boot a PanelGroup completes its initial input poll, then sends `READY_n`. +1. On cold boot a PanelGroup completes its initial input poll, then sends `READY_n`, carrying its + firmware version so the client can show which build every board runs. 2. PanelBridge broadcasts `SYNC_REQ` — on its own cold boot, on a DCS session change, when it sees a `READY_n`, or when a node transitions from unseen to alive. 3. On receiving `SYNC_REQ`, every PanelGroup re-polls all its inputs and re-sends their diff --git a/docs/architecture/firmware-tiers.md b/docs/architecture/firmware-tiers.md index c67c6394..5afb7489 100644 --- a/docs/architecture/firmware-tiers.md +++ b/docs/architecture/firmware-tiers.md @@ -72,8 +72,8 @@ DCS output onto the CAN bus as `CTRL_BCAST` frames. In the other direction it co events (`EVT_n`) from the nodes and routes each one: DCS-BIOS inputs go out as ASCII commands over UART; HID inputs are wrapped in HID frames for SimGateway. It also tracks which nodes are alive (from their heartbeats) and — behind a build flag — reports that roster to the host over -DCS-BIOS itself (`_NODE_STATUS` messages; request address `0x86FE`): each node's health and board -kind, plus a row for the bridge itself. The client can show connected boards without any +DCS-BIOS itself (`_NODE_STATUS` messages; request address `0x86FE`): each node's health, board +kind and firmware version, plus a row for the bridge itself. The client can show connected boards without any SimGateway change ([#86](https://github.com/OpenSkyHawk/OpenSkyhawk/issues/86)). diff --git a/docs/firmware/build-flags.md b/docs/firmware/build-flags.md index 2e4a6f7c..522f436a 100644 --- a/docs/firmware/build-flags.md +++ b/docs/firmware/build-flags.md @@ -60,10 +60,10 @@ actually does. | Flag | Default | Set where | Read at | |---|---|---|---| -| `NODE_ID` | none — omitting it fails to compile | every STM32 env that links our libraries; `WiringCheck` omits it deliberately | `CANProtocol.cpp:225`, `PanelGroup.cpp:300`, `STM32Board.h:29` | -| `PANELBRIDGE_NODE_STATUS` | off | on in `Templates/PanelBridge` | `PanelBridge.cpp:41` + ~10 more | -| `NODE_HEALTH_TELEM` | **on** | never set | `CANProtocol.cpp:497` (`serviceNodeStatus`, every node) | -| `NODE_OVERHEAT_C` | **unset — by design** | bench envs only | `CANProtocol.cpp:451`, `CANProtocol.cpp:514` | +| `NODE_ID` | none — omitting it fails to compile | every STM32 env that links our libraries; `WiringCheck` omits it deliberately | `CANProtocol.cpp:226`, `PanelGroup.cpp:300`, `STM32Board.h:29` | +| `PANELBRIDGE_NODE_STATUS` | off | on in `Templates/PanelBridge` | `PanelBridge.cpp:42` + ~10 more | +| `NODE_HEALTH_TELEM` | **on** | never set | `CANProtocol.cpp:511` (`serviceNodeStatus`, every node) | +| `NODE_OVERHEAT_C` | **unset — by design** | bench envs only | `CANProtocol.cpp:452`, `CANProtocol.cpp:528` | | `FORCE_CLOCK_FALLBACK` | off | `Tests/STM32Board` env `test_adc_clock_fallback` | `STM32Board.cpp:179` | | `PINREF_DEBUG` | off | never set | `PinRef.cpp:8` + ~9 more | | `SHIFTBUS_SCK` | `PB3` | never set | `ShiftBus.cpp:13` | From 678fe2d53b66a1ff581802cf00591a396af56e0d Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 12:23:19 -0400 Subject: [PATCH 04/21] feat(firmware): PanelBridge node-status reporting on by default (#295 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. --- .../E2E_DCS_Test/PanelBridge/platformio.ini | 3 --- .../Libraries/PanelBridge/PanelBridge.cpp | 24 +++++++++---------- Firmware/Libraries/PanelBridge/PanelBridge.h | 10 +++++++- Firmware/Libraries/PanelBridge/README.md | 2 +- .../FirmwarePlan/04-dcs-bios-integration.md | 4 ++-- .../FirmwarePlan/06-panelbridge-api.md | 2 +- Firmware/ScratchPad/TechSpec/PanelBridge.md | 2 +- Firmware/Templates/PanelBridge/platformio.ini | 3 --- Firmware/Tests/PanelBridge/platformio.ini | 2 ++ docs/architecture/firmware-tiers.md | 2 +- docs/firmware/build-flags.md | 19 ++++++--------- 11 files changed, 36 insertions(+), 37 deletions(-) diff --git a/Firmware/Examples/E2E_DCS_Test/PanelBridge/platformio.ini b/Firmware/Examples/E2E_DCS_Test/PanelBridge/platformio.ini index b744bbeb..4db8f6fd 100644 --- a/Firmware/Examples/E2E_DCS_Test/PanelBridge/platformio.ini +++ b/Firmware/Examples/E2E_DCS_Test/PanelBridge/platformio.ini @@ -15,9 +15,6 @@ build_flags = -DHAL_CAN_MODULE_ENABLED -DUSB_NONE -DHSE_VALUE=8000000 - ; Report connected PanelGroup nodes to the host over DCS-BIOS (#86). - ; Client half shipped (SkyHawkClient#16), so it is on by default now. - -DPANELBRIDGE_NODE_STATUS lib_extra_dirs = ${PROJECT_DIR}/../../../Libraries lib_deps = diff --git a/Firmware/Libraries/PanelBridge/PanelBridge.cpp b/Firmware/Libraries/PanelBridge/PanelBridge.cpp index 77dfe776..fd785e54 100644 --- a/Firmware/Libraries/PanelBridge/PanelBridge.cpp +++ b/Firmware/Libraries/PanelBridge/PanelBridge.cpp @@ -39,7 +39,7 @@ struct NodeState { bool alive; bool everSeen; uint32_t lastSeenMs; -#ifdef PANELBRIDGE_NODE_STATUS +#if PANELBRIDGE_NODE_STATUS HeartbeatPayload last; // last HB payload, surfaced to the host (#86) // Cached NodeHealthPayload fields from the node's last HEALTH_n frame (#221 contract): int8_t dieTempC = DIE_TEMP_UNAVAILABLE; // internal die temp °C, unseen until HEALTH_n — #213 @@ -60,7 +60,7 @@ static void (*_cbAlive)(uint8_t) = nullptr; static void (*_cbDead)(uint8_t) = nullptr; static uint8_t _deadCount = 0; // tracked nodes currently timed-out (drives WARNING) -#ifdef PANELBRIDGE_NODE_STATUS +#if PANELBRIDGE_NODE_STATUS // ── Node-status reporting (#86) ────────────────────────────────────────────── // Surface node presence + health to the host (OpenSkyhawk Client) as DCS-BIOS // command messages on a reserved control name. Owned entirely by PanelBridge; @@ -171,7 +171,7 @@ static void sendHidFrame(uint16_t controlId, uint16_t value) { // Internal helper called by BridgeExportListener — separated for testability. void handleDcsBiosExport(uint16_t address, uint16_t value) { -#ifdef PANELBRIDGE_NODE_STATUS +#if PANELBRIDGE_NODE_STATUS // Reserved: the host's node-status request — handled by NodeStatusReqListener, // never broadcast onto CAN. if (address == NODE_STATUS_REQ_ADDR) return; @@ -331,7 +331,7 @@ static bool markNodeAlive(uint8_t nodeId, uint32_t now) { if (wasDead && _deadCount > 0 && --_deadCount == 0) STM32Board::setWarning(false); if (_cbAlive) _cbAlive(nodeId); -#ifdef PANELBRIDGE_NODE_STATUS +#if PANELBRIDGE_NODE_STATUS emitNode(nodeId, true); // push presence on dead/unseen → alive (#86) #endif return true; @@ -345,7 +345,7 @@ static void onCanRx(uint32_t canId, const uint8_t* data, uint8_t len) { // HB_1 – HB_63: update liveness; broadcast SYNC_REQ only on dead→alive transition if (canId >= canIdHb(1) && canId <= canIdHb(MAX_NODE_ID)) { uint8_t nodeId = (uint8_t)(canId - canIdHb(0)); -#ifdef PANELBRIDGE_NODE_STATUS +#if PANELBRIDGE_NODE_STATUS if (len >= 8) memcpy(&_nodes[nodeId - 1].last, data, 8); // cache health for #86 reporting #endif if (markNodeAlive(nodeId, now)) broadcastSyncReq(); @@ -355,7 +355,7 @@ static void onCanRx(uint32_t canId, const uint8_t* data, uint8_t len) { // HEALTH_1 – HEALTH_63: cache internal die temp + health/fault fields for host reporting (#213). // Cache only — HB owns liveness/SYNC, so a health frame never triggers markNodeAlive. if (canId >= canIdHealth(1) && canId <= canIdHealth(MAX_NODE_ID)) { -#ifdef PANELBRIDGE_NODE_STATUS +#if PANELBRIDGE_NODE_STATUS if (len >= sizeof(NodeHealthPayload)) { uint8_t nodeId = (uint8_t)(canId - canIdHealth(0)); NodeHealthPayload h; @@ -379,7 +379,7 @@ static void onCanRx(uint32_t canId, const uint8_t* data, uint8_t len) { // TELEM_1 – TELEM_63: paged node telemetry — forwarded to the host unread (#216). Like HEALTH, // never a liveness signal (HB owns that). if (canId >= canIdTelem(1) && canId <= canIdTelem(MAX_NODE_ID)) { -#ifdef PANELBRIDGE_NODE_STATUS +#if PANELBRIDGE_NODE_STATUS if (len == sizeof(TelemPayload) && data[0] != 0) emitTelem((uint8_t)(canId - canIdTelem(0)), data); #endif @@ -451,7 +451,7 @@ static void onCanRx(uint32_t canId, const uint8_t* data, uint8_t len) { auto& d = STM32Board::diagSerial(); d.print(F("[BRIDGE] READY node=")); d.println(nodeId); } -#ifdef PANELBRIDGE_NODE_STATUS +#if PANELBRIDGE_NODE_STATUS // Firmware version (#295): every boot re-announces it, so a reflash is seen at once. Cache // it before markNodeAlive() so an alive delta already carries it; a node that rebooted // inside the heartbeat timeout stays alive, so push the change as its own delta. An empty @@ -491,7 +491,7 @@ static void checkNodeTimeouts(uint32_t now) { _deadCount++; // a tracked node died → WARNING (amber) STM32Board::setWarning(true); if (_cbDead) _cbDead(nodeId); -#ifdef PANELBRIDGE_NODE_STATUS +#if PANELBRIDGE_NODE_STATUS emitNode(nodeId, false); // push removal on alive → dead (#86) #endif } @@ -530,7 +530,7 @@ class BridgeExportListener : public DcsBios::ExportStreamListener { static BridgeExportListener _exportListener; static DcsBios::StringBuffer<5> _modelTimeBuf(CommonData_MOD_TIME_A, onModelTimeChange); -#ifdef PANELBRIDGE_NODE_STATUS +#if PANELBRIDGE_NODE_STATUS // Host roster request (#86): the client writes NODE_STATUS_REQ_ADDR on the DCS-BIOS // export stream; PanelBridge replies with the full roster. handleDcsBiosExport() // drops this address so it is never broadcast onto CAN. @@ -564,7 +564,7 @@ void setup() { CANProtocol::onReceive(onCanRx); CANProtocol::start(); broadcastSyncReq(); -#ifdef PANELBRIDGE_NODE_STATUS +#if PANELBRIDGE_NODE_STATUS emitAllNodes(); // seed the host with the current roster (node-0 row only at cold boot) (#86) #endif } @@ -641,7 +641,7 @@ void testHandleExport(uint16_t address, uint16_t value) { handleDcsBiosExport(address, value); } -#ifdef PANELBRIDGE_NODE_STATUS +#if PANELBRIDGE_NODE_STATUS void testFeedHeartbeat(uint8_t nodeId, uint8_t flags, uint16_t uptime, uint16_t rxCount, uint16_t esr) { HeartbeatPayload hb{ nodeId, flags, uptime, rxCount, esr }; diff --git a/Firmware/Libraries/PanelBridge/PanelBridge.h b/Firmware/Libraries/PanelBridge/PanelBridge.h index 6f729dbd..5902e373 100644 --- a/Firmware/Libraries/PanelBridge/PanelBridge.h +++ b/Firmware/Libraries/PanelBridge/PanelBridge.h @@ -31,6 +31,14 @@ #include +// Node-status reporting to the host (#86) — `_NODE_STATUS` / `_NODE_STATUS_END` / `_NODE_TELEM` and +// the `0x86FE` roster request. On by default (#295): the client depends on it. Compile it out with +// `-DPANELBRIDGE_NODE_STATUS=0`; node tracking itself (liveness, SYNC_REQ, WARNING, callbacks) +// never depends on it. Test with `#if PANELBRIDGE_NODE_STATUS`, never `#ifdef`. +#ifndef PANELBRIDGE_NODE_STATUS +#define PANELBRIDGE_NODE_STATUS 1 +#endif + namespace PanelBridge { /** @brief Callback type for node liveness events. Parameter is PanelGroup node ID (1–63). */ @@ -140,7 +148,7 @@ namespace PanelBridge { */ void testHandleExport(uint16_t address, uint16_t value); -#ifdef PANELBRIDGE_NODE_STATUS +#if PANELBRIDGE_NODE_STATUS /** * @brief Cache a heartbeat and mark a node alive (bypassing CAN) (#86). * diff --git a/Firmware/Libraries/PanelBridge/README.md b/Firmware/Libraries/PanelBridge/README.md index b7adefef..2ad595df 100644 --- a/Firmware/Libraries/PanelBridge/README.md +++ b/Firmware/Libraries/PanelBridge/README.md @@ -84,7 +84,7 @@ broadcasts `SYNC_REQ` so the node re-polls and sends its current input state. ### Reporting nodes to the host (#86) -Behind `-DPANELBRIDGE_NODE_STATUS` (default off), PanelBridge surfaces the roster + per-node +On by default (`-DPANELBRIDGE_NODE_STATUS=0` compiles it out), PanelBridge surfaces the roster + per-node health to OpenSkyhawk Client over DCS-BIOS — no bespoke sideband, SimGateway untouched: - **Response:** a DCS-BIOS command message `_NODE_STATUS ` per node (`hex` = diff --git a/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md b/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md index 5b5a4044..d7b65c47 100644 --- a/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md +++ b/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md @@ -95,7 +95,7 @@ The last five fields come from the node's `HEALTH_n` frame (see `02-can-protocol Both directions ride the serial/CDC, so this works in the client's **Bridge mode only** (no serial in Monitor/Replay). Reserved constants + the wire format live in `NodeStatus.h` (the canonical contract source the client syncs against; `NODE_STATUS_PROTO_VERSION` bumps on any wire -change). The whole feature is gated behind `-DPANELBRIDGE_NODE_STATUS` (default off). +change). The whole feature is on by default; `-DPANELBRIDGE_NODE_STATUS=0` compiles it out (#295). ### Node telemetry — `_NODE_TELEM` (#216) @@ -118,7 +118,7 @@ The row is `NODE_TELEM_ROW_BYTES` (9) bytes; `NodeTelemField` names the two offs the 8 bytes are raw frame bytes, so multi-byte page fields are **little-endian**. - PanelBridge drops a frame whose page byte is `0x00` or whose length is not 8; a `TELEM_n` frame never changes liveness (`HB_n` owns that). -- Bridge mode only, gated by the same `-DPANELBRIDGE_NODE_STATUS` flag. Like `_NODE_STATUS`, a copy +- Bridge mode only, gated by the same `PANELBRIDGE_NODE_STATUS` flag (on by default). Like `_NODE_STATUS`, a copy that reaches DCS through the client's byte-for-byte relay is ignored as an unknown command. Client side: OpenSkyhawk/SkyHawkClient#61. diff --git a/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md b/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md index cd6a1525..953f3d35 100644 --- a/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md +++ b/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md @@ -51,7 +51,7 @@ READY event, updates `lastSeenMs` like a heartbeat, marks the node alive if need broadcasts `SYNC_REQ`. The READY frame carries no input state itself; the following EVTs emitted in response to `SYNC_REQ` are the state snapshot. -**Node-status reporting to the host (#86, behind `-DPANELBRIDGE_NODE_STATUS`, default off):** +**Node-status reporting to the host (#86; `PANELBRIDGE_NODE_STATUS`, on by default since #295, `=0` compiles it out):** PanelBridge surfaces the roster + per-node health to OpenSkyhawk Client over DCS-BIOS (see `04-dcs-bios-integration.md`). It caches each node's last `HeartbeatPayload` (`NodeState::last`, populated in the `HB_n` handler) plus its last `HEALTH_n` fields (`dieTempC` + health/fault + diff --git a/Firmware/ScratchPad/TechSpec/PanelBridge.md b/Firmware/ScratchPad/TechSpec/PanelBridge.md index 47af3730..1715ddec 100644 --- a/Firmware/ScratchPad/TechSpec/PanelBridge.md +++ b/Firmware/ScratchPad/TechSpec/PanelBridge.md @@ -373,7 +373,7 @@ a child-node heartbeat — one that does arrive is another board on node 0, whic ### Node-status reporting (#86, proto v3 #344, v4 #295) -Behind `-DPANELBRIDGE_NODE_STATUS`. `NodeState` caches each node's last `HeartbeatPayload`, its +Behind `PANELBRIDGE_NODE_STATUS`, which `PanelBridge.h` defaults to `1` (#295; `-DPANELBRIDGE_NODE_STATUS=0` compiles it out — code tests it with `#if`, never `#ifdef`). `NodeState` caches each node's last `HeartbeatPayload`, its `HEALTH_n` fields (`dieTempC`, `healthFlags`, `faultMask`, `faultId`, `nodeKind` — not-yet-seen: `DIE_TEMP_UNAVAILABLE` / `0`) and the firmware version from its last `READY_n` (`fwMajor`, `fwMinor`, `fwPatch` — `0.0.0` = not reported; an empty READY from older firmware also reads 0.0.0; diff --git a/Firmware/Templates/PanelBridge/platformio.ini b/Firmware/Templates/PanelBridge/platformio.ini index bd3fb7a1..2c37ed6f 100644 --- a/Firmware/Templates/PanelBridge/platformio.ini +++ b/Firmware/Templates/PanelBridge/platformio.ini @@ -15,9 +15,6 @@ build_flags = -DHAL_CAN_MODULE_ENABLED -DUSB_NONE -DHSE_VALUE=8000000 - ; Report connected PanelGroup nodes to the host over DCS-BIOS (#86). - ; Client half shipped (SkyHawkClient#16), so it is on by default now. - -DPANELBRIDGE_NODE_STATUS ; When copied to Firmware//, change ../../Libraries → ../Libraries lib_extra_dirs = ${PROJECT_DIR}/../../Libraries diff --git a/Firmware/Tests/PanelBridge/platformio.ini b/Firmware/Tests/PanelBridge/platformio.ini index 37ad1cbe..7e84fb92 100644 --- a/Firmware/Tests/PanelBridge/platformio.ini +++ b/Firmware/Tests/PanelBridge/platformio.ini @@ -68,6 +68,7 @@ build_flags = -DHSE_VALUE=8000000 -DSERIAL_RX_BUFFER_SIZE=256 -DPANELBRIDGE_TEST + -DPANELBRIDGE_NODE_STATUS=0 ; the test reads the USB-UART stream; keep node-status lines off it build_src_filter = -<*> + ; EVT routing: controlId < 0x8000 → 6-byte HID frame (0xAA 0x55 ...) on Serial. @@ -82,6 +83,7 @@ build_flags = -DHSE_VALUE=8000000 -DSERIAL_RX_BUFFER_SIZE=256 -DPANELBRIDGE_TEST + -DPANELBRIDGE_NODE_STATUS=0 ; the test reads the USB-UART stream; keep node-status lines off it build_src_filter = -<*> + ; DCS-BIOS export listener: address 0x8000-0x86FF → CANProtocol::sendBatched() → CTRL_BCAST. diff --git a/docs/architecture/firmware-tiers.md b/docs/architecture/firmware-tiers.md index 5afb7489..53332a9d 100644 --- a/docs/architecture/firmware-tiers.md +++ b/docs/architecture/firmware-tiers.md @@ -71,7 +71,7 @@ stream from SimGateway over UART, fires the DCS-BIOS export listener, and broadc DCS output onto the CAN bus as `CTRL_BCAST` frames. In the other direction it collects input events (`EVT_n`) from the nodes and routes each one: DCS-BIOS inputs go out as ASCII commands over UART; HID inputs are wrapped in HID frames for SimGateway. It also tracks which nodes are -alive (from their heartbeats) and — behind a build flag — reports that roster to the host over +alive (from their heartbeats) and reports that roster to the host over DCS-BIOS itself (`_NODE_STATUS` messages; request address `0x86FE`): each node's health, board kind and firmware version, plus a row for the bridge itself. The client can show connected boards without any SimGateway change diff --git a/docs/firmware/build-flags.md b/docs/firmware/build-flags.md index 522f436a..1c519cd2 100644 --- a/docs/firmware/build-flags.md +++ b/docs/firmware/build-flags.md @@ -48,6 +48,7 @@ this is just the flags. | `SHIFTBUS_ISR_HZ` | You want timer-ISR sampling for encoder feel instead of polled sampling. | | `SERIAL_RX_BUFFER_SIZE=256` | **PanelBridge only.** A PanelGroup has no use for it. | | `NODE_HEALTH_TELEM=0` | You specifically want health telemetry **off**. It is on by default — omitting the line does not disable it. | +| `PANELBRIDGE_NODE_STATUS=0` | **PanelBridge only.** You specifically want node reporting to the host **off** — the client then shows no boards. On by default. | ### Never in a panel @@ -61,7 +62,7 @@ actually does. | Flag | Default | Set where | Read at | |---|---|---|---| | `NODE_ID` | none — omitting it fails to compile | every STM32 env that links our libraries; `WiringCheck` omits it deliberately | `CANProtocol.cpp:226`, `PanelGroup.cpp:300`, `STM32Board.h:29` | -| `PANELBRIDGE_NODE_STATUS` | off | on in `Templates/PanelBridge` | `PanelBridge.cpp:42` + ~10 more | +| `PANELBRIDGE_NODE_STATUS` | **on** | `=0` in `Tests/PanelBridge` `test_input_dispatch_dcs` / `_hid` | `PanelBridge.h:38` (default), `PanelBridge.cpp:42` + ~10 more | | `NODE_HEALTH_TELEM` | **on** | never set | `CANProtocol.cpp:511` (`serviceNodeStatus`, every node) | | `NODE_OVERHEAT_C` | **unset — by design** | bench envs only | `CANProtocol.cpp:452`, `CANProtocol.cpp:528` | | `FORCE_CLOCK_FALLBACK` | off | `Tests/STM32Board` env `test_adc_clock_fallback` | `STM32Board.cpp:179` | @@ -119,16 +120,10 @@ them: client's rendering). Enabling it for real is a one-line `-DNODE_OVERHEAT_C=N` once someone has calibration data. See `FirmwarePlan/00-decisions.md`. -!!! note "`NODE_HEALTH_TELEM` is ON by default — disable with `=0`, not by omission" - It's the only flag in this group that's on when absent (`#if !defined(X) || (X)`). Leaving it - out gets you health telemetry; you need an explicit `-DNODE_HEALTH_TELEM=0` to turn it off. - -!!! note "`PANELBRIDGE_NODE_STATUS` is off in the library but on in every shipped PanelBridge" - The library defaults it off, so the table above says "off" — but the PanelBridge template - enables it, and so does the E2E example. Every build you would actually flash to a cockpit - has it on. (Most of the 13 `Tests/PanelBridge` envs leave it off; they are testing other - things.) The client half is already released, which is why the default flipped in the - template rather than in the library. +!!! note "`NODE_HEALTH_TELEM` and `PANELBRIDGE_NODE_STATUS` are ON by default — disable with `=0`, not by omission" + They're the two flags in this group that are on when absent. Leaving them out gets you health + telemetry and the bridge's node reporting; you need an explicit `=0` to turn either off. The + client depends on both, so a shipped build keeps them on. ## PDU node thresholds (`PDU_*`) @@ -220,7 +215,7 @@ is set only by its own `Firmware/Tests//platformio.ini`. | `LED_TEST` | LED | `Outputs/LED/LED.h:67` | | `MULTIPOS_TEST` | MultiPosInput | `Inputs/MultiPosInput/MultiPosInput.h:59` | | `NEEDLEGAUGE_TEST` | NeedleGauge | `Outputs/NeedleGauge/NeedleGauge.h:77` | -| `PANELBRIDGE_TEST` | PanelBridge | `PanelBridge/PanelBridge.h:84` | +| `PANELBRIDGE_TEST` | PanelBridge | `PanelBridge/PanelBridge.h:92` | | `ROTARYENCODER_TEST` | RotaryEncoder | `Inputs/RotaryEncoder/RotaryEncoder.h:108` | | `SHIFTBUS_TEST` | ShiftBus | `Helpers/ShiftBus/ShiftBus.h:141` | | `SIMGATEWAY_TEST` | SimGateway | `SimGateway/SimGateway.cpp:12` (no-op HID stubs) | From 4ab91dc2697cc2780156a5387c1f1234d0804904 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 12:24:44 -0400 Subject: [PATCH 05/21] feat(firmware): AnalogMultiPos::NO_DEADBAND (#295 A2) 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. --- .../PanelGroup/Inputs/AnalogMultiPos/AnalogMultiPos.h | 8 ++++++-- .../TechSpec/PanelGroup/Inputs/AnalogMultiPos.md | 1 + .../AnalogMultiPos/tests/equal_spacing/equal_spacing.cpp | 8 +++++++- docs/firmware/build-flags.md | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Firmware/Libraries/PanelGroup/Inputs/AnalogMultiPos/AnalogMultiPos.h b/Firmware/Libraries/PanelGroup/Inputs/AnalogMultiPos/AnalogMultiPos.h index 1b0732d9..7e9609e7 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/AnalogMultiPos/AnalogMultiPos.h +++ b/Firmware/Libraries/PanelGroup/Inputs/AnalogMultiPos/AnalogMultiPos.h @@ -47,6 +47,9 @@ static constexpr uint16_t ANALOG_NC = MultiPosInput::NO_POSITION; class AnalogMultiPos : public MultiPosInput { public: static constexpr uint16_t DEFAULT_DEADBAND = 1000; ///< counts trimmed from each band edge + /// No deadband — bands meet edge to edge. Pass this, not a literal `0`: a literal `0` is also a + /// null `posVals` pointer, so `AnalogMultiPos(id, pin, n, 0)` is ambiguous and doesn't compile. + static constexpr uint16_t NO_DEADBAND = 0; static constexpr uint16_t POLL_MS = 8; ///< min interval between ADC reads (ms) /** @@ -57,7 +60,8 @@ class AnalogMultiPos : public MultiPosInput { * @param numPos N — number of positions (valid indices 0..N-1). * @param posVals caller-owned array of N expected 16-bit ADC values, one per position. * Must outlive this object. Use ANALOG_NC for a position with no detent. - * @param deadband counts trimmed from each band edge for hysteresis (default 1000). + * @param deadband counts trimmed from each band edge for hysteresis (default 1000; + * NO_DEADBAND for none). */ AnalogMultiPos(uint16_t controlId, PinRef pin, uint8_t numPos, const uint16_t* posVals, uint16_t deadband = DEFAULT_DEADBAND); @@ -68,7 +72,7 @@ class AnalogMultiPos : public MultiPosInput { * @param controlId DCSIN_* or CTRL_* constant. * @param pin analog PinRef. * @param numPos N — number of positions. - * @param deadband counts trimmed from each band edge (default 1000). + * @param deadband counts trimmed from each band edge (default 1000; NO_DEADBAND for none). */ AnalogMultiPos(uint16_t controlId, PinRef pin, uint8_t numPos, uint16_t deadband = DEFAULT_DEADBAND); diff --git a/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogMultiPos.md b/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogMultiPos.md index 64141d02..6a0445c3 100644 --- a/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogMultiPos.md +++ b/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogMultiPos.md @@ -59,6 +59,7 @@ static constexpr uint16_t ANALOG_NC = 0xFFFF; // posVals[] sentinel — no det class AnalogMultiPos : public MultiPosInput { public: static constexpr uint16_t DEFAULT_DEADBAND = 1000; + static constexpr uint16_t NO_DEADBAND = 0; // named so it never reads as a null posVals (#295) static constexpr uint16_t POLL_MS = 8; // Explicit ladder. posVals = N expected 16-bit ADC values (ANALOG_NC for a no-detent position). diff --git a/Firmware/Tests/AnalogMultiPos/tests/equal_spacing/equal_spacing.cpp b/Firmware/Tests/AnalogMultiPos/tests/equal_spacing/equal_spacing.cpp index a14bae08..ab1a8764 100644 --- a/Firmware/Tests/AnalogMultiPos/tests/equal_spacing/equal_spacing.cpp +++ b/Firmware/Tests/AnalogMultiPos/tests/equal_spacing/equal_spacing.cpp @@ -1,7 +1,8 @@ // AnalogMultiPos — equal_spacing test // // Shorthand ctor (no posVals): N=5 → positions evenly spaced 0..65535 → {0,16383,32767,49151,65535}. -// Pure debugResolve. +// Pure debugResolve. gNoDb also proves NO_DEADBAND selects this overload — a literal 0 would be +// ambiguous with the posVals (null pointer) form and not compile (#295). #include #include @@ -11,6 +12,7 @@ using namespace OpenSkyhawk; static constexpr uint16_t CTRL_ID = 0x803b; AnalogMultiPos gSel(CTRL_ID, PinRef(PA1), 5); // shorthand — equal spacing +AnalogMultiPos gNoDb(CTRL_ID + 1, PinRef(PA2), 5, AnalogMultiPos::NO_DEADBAND); static constexpr uint16_t NOPOS = MultiPosInput::NO_POSITION; @@ -35,6 +37,10 @@ void setup() { // Gap at the midpoint of positions 0 and 1 (8191) ± deadband. check("8191 -> NOPOS (gap)", gSel.debugResolve(8191) == NOPOS); + // NO_DEADBAND: bands meet, so the midpoint resolves to a position instead of a gap. + check("NO_DEADBAND 8191 -> a position", gNoDb.debugResolve(8191) != NOPOS); + check("NO_DEADBAND 16383 -> 1", gNoDb.debugResolve(16383) == 1); + STM32Board::diagSerial().println(pass ? "=== ALL PASS ===" : "=== FAIL ==="); } diff --git a/docs/firmware/build-flags.md b/docs/firmware/build-flags.md index 1c519cd2..54a41c57 100644 --- a/docs/firmware/build-flags.md +++ b/docs/firmware/build-flags.md @@ -210,7 +210,7 @@ is set only by its own `Firmware/Tests//platformio.ini`. | Flag | Library it opens | First read at | |---|---|---| | `ANALOGINPUT_TEST` | AnalogInput | `Inputs/AnalogInput/AnalogInput.h:84` | -| `ANALOGMULTIPOS_TEST` | AnalogMultiPos | `Inputs/AnalogMultiPos/AnalogMultiPos.h:89` | +| `ANALOGMULTIPOS_TEST` | AnalogMultiPos | `Inputs/AnalogMultiPos/AnalogMultiPos.h:93` | | `DRUMDISPLAY_TEST` | DrumDisplay | `DrumDisplay/DrumDisplay.h:278` | | `LED_TEST` | LED | `Outputs/LED/LED.h:67` | | `MULTIPOS_TEST` | MultiPosInput | `Inputs/MultiPosInput/MultiPosInput.h:59` | From 9680e974093d3ae65173294a6aa81de018d4d65b Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 12:39:49 -0400 Subject: [PATCH 06/21] feat(firmware)!: AnalogInput and AngleSensorInput take a config struct; 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. --- .../E2E_DCS_Test/PanelGroup/src/main.cpp | 2 +- .../PanelGroupInputs/src/main.cpp | 2 +- .../Inputs/AnalogInput/AnalogInput.cpp | 29 +++++--- .../Inputs/AnalogInput/AnalogInput.h | 68 ++++++++++++------- .../AngleSensorInput/AngleSensorInput.cpp | 19 ++++-- .../AngleSensorInput/AngleSensorInput.h | 23 ++++++- .../FirmwarePlan/03-uart-usb-hid-protocol.md | 2 +- .../FirmwarePlan/05-panelgroup-api.md | 28 +++++--- .../TechSpec/PanelGroup/Inputs/AnalogInput.md | 50 ++++++++------ .../PanelGroup/Inputs/AngleSensorInput.md | 10 ++- .../ScratchPad/TechSpec/PanelGroup/PinRef.md | 6 +- .../tests/hysteresis/hysteresis.cpp | 22 ++++++ .../AnalogInput/tests/poll_rate/poll_rate.cpp | 6 +- .../AnalogInput/tests/reverse/reverse.cpp | 2 +- .../tests/scale_clamp/scale_clamp.cpp | 2 +- .../tests/shift_bounds/shift_bounds.cpp | 2 +- .../tests/inherits/inherits.cpp | 17 +++++ docs/firmware/build-flags.md | 2 +- .../controls/inputs/analoginput.md | 35 +++++----- .../controls/inputs/anglesensorinput.md | 11 +-- 20 files changed, 230 insertions(+), 108 deletions(-) diff --git a/Firmware/Examples/E2E_DCS_Test/PanelGroup/src/main.cpp b/Firmware/Examples/E2E_DCS_Test/PanelGroup/src/main.cpp index 34ca178f..25a02f24 100644 --- a/Firmware/Examples/E2E_DCS_Test/PanelGroup/src/main.cpp +++ b/Firmware/Examples/E2E_DCS_Test/PanelGroup/src/main.cpp @@ -121,7 +121,7 @@ RotaryEncoder destLat (DCSIN_DEST_LAT_KNB, PinRef(PA8), PinRef(PB5), Encode RotaryEncoder freq10 (DCSIN_ARC51_FREQ_10MHZ, PinRef(PB3), PinRef(PB4), EncoderStepsPerDetent::Four, EncoderMode::Dir); // hysteresis 1024 (≈64 steps over the throw) — default 128 floods DCS-BIOS import on a sweep (~125 // cmd/s) and lags the cockpit seconds behind; 1024 cuts that ~5× while staying smooth for a vol knob. -AnalogInput arcVol (DCSIN_ARC51_VOL, PinRef(PA2), /*reverse=*/false, 0, 65535, /*hysteresis=*/1024); +AnalogInput arcVol (DCSIN_ARC51_VOL, PinRef(PA2)); // DCSIN → hysteresis 1024 by default AnalogMultiPos arcMode (DCSIN_ARC51_MODE, PinRef(PA3), 4); void setup() { diff --git a/Firmware/Examples/E2E_DCS_Test/PanelGroupInputs/src/main.cpp b/Firmware/Examples/E2E_DCS_Test/PanelGroupInputs/src/main.cpp index aacad16e..0abd93bb 100644 --- a/Firmware/Examples/E2E_DCS_Test/PanelGroupInputs/src/main.cpp +++ b/Firmware/Examples/E2E_DCS_Test/PanelGroupInputs/src/main.cpp @@ -20,7 +20,7 @@ using namespace OpenSkyhawk; RotaryEncoder destLat (DCSIN_DEST_LAT_KNB, PinRef(PA8), PinRef(PB5), EncoderStepsPerDetent::Four, EncoderMode::Rel, /*step=*/1600); RotaryEncoder freq10 (DCSIN_ARC51_FREQ_10MHZ, PinRef(PB3), PinRef(PB4), EncoderStepsPerDetent::Four, EncoderMode::Dir); -AnalogInput arcVol (DCSIN_ARC51_VOL, PinRef(PA2), /*reverse=*/false, 0, 65535, /*hysteresis=*/1024); +AnalogInput arcVol (DCSIN_ARC51_VOL, PinRef(PA2)); // DCSIN → hysteresis 1024 by default AnalogMultiPos arcMode (DCSIN_ARC51_MODE, PinRef(PA3), 4); void setup() { diff --git a/Firmware/Libraries/PanelGroup/Inputs/AnalogInput/AnalogInput.cpp b/Firmware/Libraries/PanelGroup/Inputs/AnalogInput/AnalogInput.cpp index 636b311a..4ce89757 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/AnalogInput/AnalogInput.cpp +++ b/Firmware/Libraries/PanelGroup/Inputs/AnalogInput/AnalogInput.cpp @@ -11,17 +11,28 @@ namespace OpenSkyhawk { -AnalogInput::AnalogInput(uint16_t controlId, PinRef pin, bool reverse, - uint16_t minRaw, uint16_t maxRaw, - uint16_t hysteresis, uint8_t ewmaShift, uint16_t pollMs) +namespace { + +// HYSTERESIS_AUTO → the route's default; an explicit value is used as given. The route is the one +// PanelBridge applies: the DCS-BIOS range goes to DCS, everything else to HID. +uint16_t resolveHysteresis(uint16_t controlId, uint16_t hysteresis) { + if (hysteresis != AnalogConfig::HYSTERESIS_AUTO) return hysteresis; + const bool dcs = controlId >= CTRL_ID_DCS_MIN && controlId <= CTRL_ID_DCS_MAX; + return dcs ? AnalogConfig::DCS_HYSTERESIS : AnalogConfig::HID_HYSTERESIS; +} + +} // namespace + +AnalogInput::AnalogInput(uint16_t controlId, PinRef pin, const AnalogConfig& cfg) : _controlId(controlId), _pin(pin), - _reverse(reverse), - _minRaw(minRaw), - _maxRaw(maxRaw), - _hysteresis(hysteresis), - _ewmaShift(ewmaShift > MAX_EWMA_SHIFT ? MAX_EWMA_SHIFT : ewmaShift), - _pollMs(pollMs), // not clamped: 0 = read every loop iteration (see header @param) + _reverse(cfg.reverse), + _minRaw(cfg.minRaw), + _maxRaw(cfg.maxRaw), + _hysteresis(resolveHysteresis(controlId, cfg.hysteresis)), + _ewmaShift(cfg.ewmaShift > AnalogConfig::MAX_EWMA_SHIFT ? AnalogConfig::MAX_EWMA_SHIFT + : cfg.ewmaShift), + _pollMs(cfg.pollMs), // not clamped: 0 = read every loop iteration (see AnalogConfig) _acc(0), _smoothed(0), _lastSent(0), diff --git a/Firmware/Libraries/PanelGroup/Inputs/AnalogInput/AnalogInput.h b/Firmware/Libraries/PanelGroup/Inputs/AnalogInput/AnalogInput.h index 04961a16..213a2ff9 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/AnalogInput/AnalogInput.h +++ b/Firmware/Libraries/PanelGroup/Inputs/AnalogInput/AnalogInput.h @@ -12,6 +12,44 @@ namespace OpenSkyhawk { +/** + * @brief Tuning for an AnalogInput. Every field has a default, so a sketch sets only what differs, + * by name: `{ .hysteresis = 32, .ewmaShift = 4, .pollMs = 2 }` (#295). + * + * Fields are only ever appended, each with a default, so a new setting never breaks a sketch. + * The constants are the defaults and limits the fields refer to. + */ +struct AnalogConfig { + /// `hysteresis` default: chosen from the controlId's route when the input is constructed — + /// DCS_HYSTERESIS for a DCSIN_* (DCS-BIOS) id, HID_HYSTERESIS for a CTRL_* (HID) id. Any other + /// value is used as given. + static constexpr uint16_t HYSTERESIS_AUTO = 0xFFFF; + /// DCS-BIOS route: 1024 keeps a sweep from flooding DCS-BIOS import (SoakRig and E2E use it). + static constexpr uint16_t DCS_HYSTERESIS = 1024; + /// HID route: 128 — the value the axis calibration was validated with (#46/#251). + static constexpr uint16_t HID_HYSTERESIS = 128; + static constexpr uint8_t DEFAULT_EWMA_SHIFT = 3; ///< EWMA α = 1/2^3 = 1/8. + static constexpr uint8_t MAX_EWMA_SHIFT = 15; ///< cap: scaled< 32767u ? 32767u : (uint16_t)half; } +// The base's config: the angle's own tuning, plus the raw window the travel implies (centred on +// mid-scale — readRaw() moves the centre angle there). +AnalogConfig baseConfig(float travelDeg, const AngleConfig& cfg) { + AnalogConfig a; + a.minRaw = (uint16_t)(32768u - halfSpanCounts(travelDeg)); + a.maxRaw = (uint16_t)(32768u + halfSpanCounts(travelDeg)); + a.hysteresis = cfg.hysteresis; + a.ewmaShift = cfg.ewmaShift; + a.pollMs = cfg.pollMs; + return a; +} + } // namespace // The base's [minRaw, maxRaw] window is centred on mid-scale because readRaw() moves the centre // angle there — so the window is the same whatever part of the circle the knob sits on. AngleSensorInput::AngleSensorInput(uint16_t controlId, PinRef pin, float centerDeg, - float travelDeg, uint16_t pollMs) - : AnalogInput(controlId, pin, /*reverse=*/false, - /*minRaw=*/(uint16_t)(32768u - halfSpanCounts(travelDeg)), - /*maxRaw=*/(uint16_t)(32768u + halfSpanCounts(travelDeg)), - DEFAULT_HYSTERESIS, DEFAULT_EWMA_SHIFT, pollMs), + float travelDeg, const AngleConfig& cfg) + : AnalogInput(controlId, pin, baseConfig(travelDeg, cfg)), _centerRaw(degToCounts(centerDeg)) {} uint16_t AngleSensorInput::readRaw() { diff --git a/Firmware/Libraries/PanelGroup/Inputs/AngleSensorInput/AngleSensorInput.h b/Firmware/Libraries/PanelGroup/Inputs/AngleSensorInput/AngleSensorInput.h index 1f17eb0d..99f35804 100644 --- a/Firmware/Libraries/PanelGroup/Inputs/AngleSensorInput/AngleSensorInput.h +++ b/Firmware/Libraries/PanelGroup/Inputs/AngleSensorInput/AngleSensorInput.h @@ -12,6 +12,24 @@ namespace OpenSkyhawk { +/** + * @brief Tuning for an AngleSensorInput — the AnalogConfig fields that still mean something once + * the raw window comes from the centre and travel (#295). Set by name: + * `{ .hysteresis = 512 }`. Fields are only ever appended, each with a default. + * + * There is no reverse here: flip direction with the sensor's DIR pin. + */ +struct AngleConfig { + /// Output counts of movement before a new value is emitted. HYSTERESIS_AUTO picks by route, + /// like AnalogInput: DCS_HYSTERESIS for DCSIN_*, HID_HYSTERESIS for CTRL_*. + uint16_t hysteresis = AnalogConfig::HYSTERESIS_AUTO; + /// EWMA smoothing strength: α = 1/2^ewmaShift (capped to AnalogConfig::MAX_EWMA_SHIFT). + uint8_t ewmaShift = AnalogConfig::DEFAULT_EWMA_SHIFT; + /// Min interval between ADC reads, ms. Per instance; an ADS1115 PinRef blocks ~8 ms per + /// conversion regardless. + uint16_t pollMs = AnalogConfig::DEFAULT_POLL_MS; +}; + /** * @brief Magnetic angle sensor (AS5600 / MT6701) as an absolute knob or axis. * @@ -60,11 +78,10 @@ class AngleSensorInput : public AnalogInput { * turn, because the ends of a full turn are one sensor angle. The wrap * point sits opposite centerDeg, so the closer travel comes to 360° the * nearer that break sits to the ends of travel. - * @param pollMs min interval between ADC reads, ms (default DEFAULT_POLL_MS). Per - * instance; an ADS1115 PinRef blocks ~8 ms per conversion regardless. + * @param cfg tuning — hysteresis, smoothing, poll rate; set only what differs. */ AngleSensorInput(uint16_t controlId, PinRef pin, float centerDeg, float travelDeg, - uint16_t pollMs = DEFAULT_POLL_MS); + const AngleConfig& cfg = {}); protected: /** @brief The base's reading, re-centred so centerDeg sits at mid-scale (0°/360° wraps). */ diff --git a/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md b/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md index 9053a520..af559fe4 100644 --- a/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md +++ b/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md @@ -478,7 +478,7 @@ must therefore mean "the last received value has not moved for N ms", never "N c agreed", which would never complete on a stick held still. **Do not assume an update rate.** `AnalogInput`'s read throttle is **per instance** — the `pollMs` -constructor parameter, default `DEFAULT_POLL_MS` = 8 ms — so it is a per-axis build choice, not a +field of its `AnalogConfig`, default `AnalogConfig::DEFAULT_POLL_MS` = 8 ms — so it is a per-axis build choice, not a constant you can read off the class, and two axes on one node may differ. Whatever it is set to bounds only that axis's ADC sampling; hysteresis, `PanelGroup::loop()` timing, CAN, and PanelBridge all reduce it further. Measure it. diff --git a/Firmware/ScratchPad/FirmwarePlan/05-panelgroup-api.md b/Firmware/ScratchPad/FirmwarePlan/05-panelgroup-api.md index 044b857a..8a94b78c 100644 --- a/Firmware/ScratchPad/FirmwarePlan/05-panelgroup-api.md +++ b/Firmware/ScratchPad/FirmwarePlan/05-panelgroup-api.md @@ -348,7 +348,7 @@ Continuous or stepped analog input. All sources normalised to **16-bit (0–6553 | ADS1115 | 15-bit single-ended (0–32767) | 0–3.3V → 0–65534, factor from the gain (#325) | GAIN_ONE (±4.096V FSR); 3.3V ≈ 26400 raw → 65534 — same span as a board pin | Configurable `[minRaw, maxRaw]` input range; values outside clamped to 0 or 65535. Read throttle: -**per instance** — `pollMs`, default `DEFAULT_POLL_MS` = 8 ms, so a HID flight axis and a cockpit +**per instance** — `pollMs`, default `AnalogConfig::DEFAULT_POLL_MS` = 8 ms, so a HID flight axis and a cockpit pot on the same node can sample at different rates (`forceReport()` bypasses the throttle for the boot / SYNC baseline). @@ -356,18 +356,22 @@ boot / SYNC baseline). shift not a divide — no soft-float on the F103) smooths the ×16-amplified ADC noise; a new value is emitted only when the smoothed result moves more than `hysteresis` counts from the last sent value, or reaches a rail (0 / 65535) moving toward it — so endpoints are always reached and a settled pot -is silent. The constructor exposes `reverse`, `[minRaw, maxRaw]`, `hysteresis` (default 128), -`ewmaShift` (default 3; **valid 0–15**, capped so the `int32` accumulator `scaled << ewmaShift` -cannot overflow at full scale), and `pollMs` (default 8; **not** capped — 0 means read every loop -iteration). `pollMs` and `ewmaShift` are **coupled**: the filter time constant is +is silent. Tuning is one `AnalogConfig` argument (#295) — a struct with every field defaulted, set by +name (`{ .hysteresis = 32, .pollMs = 2 }`): `reverse`, `[minRaw, maxRaw]`, `hysteresis` (default +`HYSTERESIS_AUTO` — route-aware: `DCS_HYSTERESIS` 1024 for a `DCSIN_*` id, so a sweep doesn't flood +DCS-BIOS import, and `HID_HYSTERESIS` 128 for a `CTRL_*` id, the value the axis calibration was +validated with; an explicit value always wins), `ewmaShift` (default 3; **valid 0–15**, capped so +the `int32` accumulator `scaled << ewmaShift` cannot overflow at full scale), and `pollMs` (default +8; **not** capped — 0 means read every loop iteration). Fields are only ever appended. `pollMs` and `ewmaShift` are **coupled**: the filter time constant is τ ≈ 2^`ewmaShift` × `pollMs`, so halving the read interval halves τ unless the shift rises with it. A *linear* class — it sends an absolute value on `EVT_n` (ABS) like the selector classes, but the value is continuous, not a position index. ```cpp -OpenSkyhawk::AnalogInput throttle(CTRL_THROTTLE, PinRef(PA0)); +OpenSkyhawk::AnalogInput throttle(CTRL_THROTTLE, PinRef(PA0)); // HID: hysteresis 128 +OpenSkyhawk::AnalogInput volume(DCSIN_ARC51_VOL, PinRef(PA2), { .reverse = true }); // DCS: 1024 -ADS1115 adc(0x48, Wire); +ADS1115 adc; // PanelGroup::registerADC(adc, 0x48) in setup() OpenSkyhawk::AnalogInput rudder(CTRL_RUDDER, PinRef(adc, 0)); ``` @@ -386,7 +390,10 @@ Design (D16) — **`AngleSensorInput : AnalogInput`**, not a sibling class: - **Takes a `PinRef`** like every control class: the sensor's analog output pin, read through an STM32 ADC pin or an ADS1115 channel. It calls `AnalogInput`'s existing public constructor, with - `centerDeg` / `travelDeg` converted to `[minRaw, maxRaw]`. + `centerDeg` / `travelDeg` converted to `[minRaw, maxRaw]`. Its own tuning is an `AngleConfig` + (#295) — `hysteresis` (route-aware by default, like `AnalogInput`), `ewmaShift`, `pollMs` — the + `AnalogConfig` fields that still mean something once the window comes from the angle. No + `reverse`: the sensor's DIR pin sets direction. - `AnalogInput` gains one protected virtual `readRaw()` (default: `_pin.readAnalog()`); `AngleSensorInput` overrides it to **re-centre** the reading so `centerDeg` lands at mid-scale — the 0°/360° seam then only matters for travel wider than ±180°. Everything else is inherited — @@ -400,10 +407,11 @@ Design (D16) — **`AngleSensorInput : AnalogInput`**, not a sibling class: addition, not part of the first cut. ```cpp -ADS1115 adc(0x48, Wire); +ADS1115 adc; // PanelGroup::registerADC(adc, 0x48) in setup() // AS5600 OUT pin → ADS1115 channel 2 (or an STM32 ADC pin: PinRef(PA3)) OpenSkyhawk::AngleSensorInput gunsight(DCSIN_GUNSIGHT_KNB, PinRef(adc, 2), - /*centerDeg*/ 180.0f, /*travelDeg*/ 150.0f); + /*centerDeg*/ 180.0f, /*travelDeg*/ 150.0f, + { .hysteresis = 512 }); // optional: default 1024 (DCS) ``` --- diff --git a/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogInput.md b/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogInput.md index 8b9f894e..365d32d5 100644 --- a/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogInput.md +++ b/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogInput.md @@ -26,8 +26,8 @@ Handles: - **hysteresis + near-rail** — emit only when the smoothed value moves more than `hysteresis` counts from the last sent value, or reaches a rail (0 / 65535) moving toward it (so endpoints are always reached and a settled pot is silent); -- a **per-instance ADC read throttle** — `pollMs`, default `DEFAULT_POLL_MS` (8 ms), a constructor - parameter rather than a class constant, so two AnalogInputs on one node may read at different +- a **per-instance ADC read throttle** — `pollMs`, default `AnalogConfig::DEFAULT_POLL_MS` (8 ms), + an `AnalogConfig` field rather than a class constant, so two AnalogInputs on one node may read at different rates (`forceReport()` bypasses it for the boot/SYNC baseline). Does **not** interpret `controlId`, drive a position index, or enable internal pull-ups. @@ -67,28 +67,35 @@ CAN runs in **normal mode** so the node ACKs the PanelBridge. No jumpers or pot ## Public API ```cpp +// Tuning — every field defaulted; a sketch sets only what differs, by name (#295). +// Namespace scope, not nested: a nested struct with default member initialisers can't be a +// `= {}` default argument inside its enclosing class (GCC, CWG 1397). +struct AnalogConfig { + static constexpr uint16_t HYSTERESIS_AUTO = 0xFFFF; // pick by route at construction + static constexpr uint16_t DCS_HYSTERESIS = 1024; // DCSIN_*: no DCS-BIOS import flood + static constexpr uint16_t HID_HYSTERESIS = 128; // CTRL_*: the calibration-validated value + static constexpr uint8_t DEFAULT_EWMA_SHIFT = 3; // α = 1/8 + static constexpr uint8_t MAX_EWMA_SHIFT = 15; // scaled << shift must fit int32 + static constexpr uint16_t DEFAULT_POLL_MS = 8; // min ms between ADC reads + + bool reverse = false; // minRaw → 0, maxRaw → 65535; true: inverted + uint16_t minRaw = 0; // raw value mapping to 0; below is clamped + uint16_t maxRaw = 65535; // raw value mapping to 65535; above is clamped + uint16_t hysteresis = HYSTERESIS_AUTO; // output counts before a new value is emitted + uint8_t ewmaShift = DEFAULT_EWMA_SHIFT; // α = 1/2^ewmaShift, capped to MAX_EWMA_SHIFT + uint16_t pollMs = DEFAULT_POLL_MS; // per instance; not clamped — 0 = every loop +}; + class AnalogInput : public InputBase { public: - static constexpr uint16_t DEFAULT_HYSTERESIS = 128; // counts on the 16-bit output - static constexpr uint8_t DEFAULT_EWMA_SHIFT = 3; // α = 1/8 - static constexpr uint8_t MAX_EWMA_SHIFT = 15; // scaled << shift must fit int32 - static constexpr uint16_t DEFAULT_POLL_MS = 8; // min ms between ADC reads - /** - * @param controlId DCSIN_* or CTRL_* constant. Determines PanelBridge routing. - * @param pin analog PinRef (STM32 ADC GPIO or ADS1115 channel). - * @param reverse false (default): minRaw → 0, maxRaw → 65535. true: inverted. - * @param minRaw raw value mapping to 0 (default 0); below is clamped. - * @param maxRaw raw value mapping to 65535 (default 65535); above is clamped. - * @param hysteresis output counts of movement before a new value is emitted (default 128). - * @param ewmaShift EWMA strength α = 1/2^ewmaShift (default 3 → 1/8). - * @param pollMs min interval between ADC reads, ms (default 8). Per instance, not per - * node. Not clamped — 0 = read every loop iteration. + * @param controlId DCSIN_* or CTRL_* constant. Determines PanelBridge routing — and, with + * HYSTERESIS_AUTO, the hysteresis (DCS-BIOS range → 1024, else 128; the same + * split PanelBridge routes by: CTRL_ID_DCS_MIN..MAX). + * @param pin analog PinRef (STM32 ADC GPIO or ADS1115 channel). + * @param cfg tuning, e.g. `{ .reverse = true }`. */ - AnalogInput(uint16_t controlId, PinRef pin, bool reverse = false, - uint16_t minRaw = 0, uint16_t maxRaw = 65535, - uint16_t hysteresis = DEFAULT_HYSTERESIS, uint8_t ewmaShift = DEFAULT_EWMA_SHIFT, - uint16_t pollMs = DEFAULT_POLL_MS); + AnalogInput(uint16_t controlId, PinRef pin, const AnalogConfig& cfg = {}); void poll() override; // throttled read + EWMA; emit on hysteresis / rail void forceReport() override; // sample fresh, seed EWMA, emit baseline @@ -186,8 +193,7 @@ every input unconditionally, every iteration, and owns no analog timer of its ow **Validated HID stick axis** (assembled PanelGroup Rev 1, 2026-08-14) — τ = 2^4 × 2 = 32 ms: ```cpp -OpenSkyhawk::AnalogInput axisX(CTRL_ROLL, PIN_X, /*reverse=*/false, 0, 65535, - /*hysteresis=*/32, /*ewmaShift=*/4, /*pollMs=*/2); +OpenSkyhawk::AnalogInput axisX(CTRL_ROLL, PIN_X, { .hysteresis = 32, .ewmaShift = 4, .pollMs = 2 }); ``` Three preconditions, each of which binds: diff --git a/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AngleSensorInput.md b/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AngleSensorInput.md index 2ef9f6fa..fa7c3749 100644 --- a/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AngleSensorInput.md +++ b/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AngleSensorInput.md @@ -98,11 +98,19 @@ match. Not part of the first cut. ## Public API (sketch) ```cpp +// The AnalogConfig fields that still mean something once the window comes from the angle (#295). +// No reverse: the sensor's DIR pin sets direction. +struct AngleConfig { + uint16_t hysteresis = AnalogConfig::HYSTERESIS_AUTO; // route-aware, like AnalogInput + uint8_t ewmaShift = AnalogConfig::DEFAULT_EWMA_SHIFT; + uint16_t pollMs = AnalogConfig::DEFAULT_POLL_MS; +}; + class AngleSensorInput : public AnalogInput { public: AngleSensorInput(uint16_t controlId, PinRef pin, float centerDeg, float travelDeg, - uint16_t pollMs = DEFAULT_POLL_MS); + const AngleConfig& cfg = {}); protected: uint16_t readRaw() override; // AnalogInput::readRaw(), re-centred on centerDeg private: diff --git a/Firmware/ScratchPad/TechSpec/PanelGroup/PinRef.md b/Firmware/ScratchPad/TechSpec/PanelGroup/PinRef.md index 890fa727..cd242be2 100644 --- a/Firmware/ScratchPad/TechSpec/PanelGroup/PinRef.md +++ b/Firmware/ScratchPad/TechSpec/PanelGroup/PinRef.md @@ -405,17 +405,17 @@ library's default and one we never override (`PinRef.cpp` sets the gain delibera conversion time is a **configuration choice we have not yet made**, not a fixed property of the silicon — at 860 SPS it is ~1.2 ms. -`AnalogMultiPos::POLL_MS` (fixed, 8 ms) and `AnalogInput::DEFAULT_POLL_MS` (8 ms) happen to sit at +`AnalogMultiPos::POLL_MS` (fixed, 8 ms) and `AnalogConfig::DEFAULT_POLL_MS` (8 ms) happen to sit at the same figure, but they were **not sized for this part**: both are inherited from DCS-BIOS `PotentiometerEWMA`, chosen for a cockpit pot on a bandwidth-constrained serial link (see `AnalogInput`'s class comment). The match is coincidence, not design. It does still set the floor at the data rate we currently run — so at 128 SPS, do not go below it on an ADS-backed input. -`AnalogInput::pollMs` is per instance and may be set below 8 — but only for a **GPIO** PinRef, +`AnalogConfig::pollMs` is per instance and may be set below 8 — but only for a **GPIO** PinRef, where `analogRead()` costs microseconds. On an **ADS1115** PinRef a smaller `pollMs` cannot make the conversion faster; it only makes `poll()` re-enter the blocking read as soon as the previous one returns, so the node's loop runs at the ADC's conversion rate and every other input and -output on that node queues behind it. Keep an ADS1115-backed `AnalogInput` at `DEFAULT_POLL_MS` +output on that node queues behind it. Keep an ADS1115-backed `AnalogInput` at `AnalogConfig::DEFAULT_POLL_MS` or higher. (That a slow ADS knob already delays a fast axis sharing the node is a known defect — #269.) Do not call `readAnalog()` on an ADS1115 PinRef from an ISR. diff --git a/Firmware/Tests/AnalogInput/tests/hysteresis/hysteresis.cpp b/Firmware/Tests/AnalogInput/tests/hysteresis/hysteresis.cpp index 7ae3ba11..72372593 100644 --- a/Firmware/Tests/AnalogInput/tests/hysteresis/hysteresis.cpp +++ b/Firmware/Tests/AnalogInput/tests/hysteresis/hysteresis.cpp @@ -4,6 +4,9 @@ // that settles beyond it emits and the value tracks. debugStep() runs one EWMA step bypassing the // 8 ms throttle. Verified via emitCount()/value(). // +// Route-aware default (#295): with no hysteresis set, a DCSIN_* id gets DCS_HYSTERESIS (1024) and a +// CTRL_* id HID_HYSTERESIS (128); an explicit value always wins. A Δ = 500 settle tells them apart. +// // Rig: this STM32 alone, CAN in silent loopback (no bus, no PanelBridge, no pot). Do NOT run it // on a board wired to a live bus: a loopback node never ACKs, which drives the bridge // error-passive. PASS/FAIL comes from the input's own test seams, not from CAN. @@ -16,6 +19,21 @@ static constexpr uint16_t CTRL_ID = 0x567A; OpenSkyhawk::AnalogInput gAna(CTRL_ID, PinRef(PA1)); // defaults: hysteresis 128, EWMA shift 3 +static constexpr uint16_t DCS_ID = 0x8001; // in the DCS-BIOS range (DCSIN_*) +static constexpr uint16_t HID_ID = 0x0010; // in the HID range (CTRL_ROLL) +OpenSkyhawk::AnalogInput gDcs(DCS_ID, PinRef(PA2)); // auto → 1024 +OpenSkyhawk::AnalogInput gHid(HID_ID, PinRef(PA3)); // auto → 128 +OpenSkyhawk::AnalogInput gOver(DCS_ID + 1, PinRef(PA4), { .hysteresis = 128 }); // explicit wins + +// Baseline 20000, settle at 20500 (Δ = 500): true if it emitted past the baseline. +static bool emitsOn500(OpenSkyhawk::AnalogInput& a) { + a.configure(); + a.debugSetRaw(20000); a.forceReport(); + a.debugSetRaw(20500); + for (int i = 0; i < 60; i++) a.debugStep(); + return a.emitCount() > 1; +} + void setup() { STM32Board::setDebug(true); STM32Board::begin(); @@ -44,6 +62,10 @@ void setup() { for (int i = 0; i < 80; i++) gAna.debugStep(); check("supra-hysteresis change: emitted + tracks", gAna.emitCount() > 1 && gAna.value() > 49000); + check("auto, DCSIN id: 1024 -> Δ500 silent", !emitsOn500(gDcs)); + check("auto, CTRL id: 128 -> Δ500 emits", emitsOn500(gHid)); + check("explicit 128 on a DCSIN id: emits", emitsOn500(gOver)); + CANProtocol::flushBatched(canIdEvt(NODE_ID)); STM32Board::diagSerial().println(pass ? "=== ALL PASS ===" : "=== FAIL ==="); } diff --git a/Firmware/Tests/AnalogInput/tests/poll_rate/poll_rate.cpp b/Firmware/Tests/AnalogInput/tests/poll_rate/poll_rate.cpp index 9d6a896b..d5f27da0 100644 --- a/Firmware/Tests/AnalogInput/tests/poll_rate/poll_rate.cpp +++ b/Firmware/Tests/AnalogInput/tests/poll_rate/poll_rate.cpp @@ -47,10 +47,8 @@ static constexpr uint16_t RAW_HELD = 30000; // mid-scale: settled EWMA, clear static constexpr uint32_t WINDOW_MS = 1000; // nominal 500 fast reads / 125 slow reads // Same node, same measurement window, different throttles. -OpenSkyhawk::AnalogInput gFast(CTRL_FAST, PinRef(PA0), /*reverse=*/false, 0, 65535, - OpenSkyhawk::AnalogInput::DEFAULT_HYSTERESIS, - OpenSkyhawk::AnalogInput::DEFAULT_EWMA_SHIFT, /*pollMs=*/2); -OpenSkyhawk::AnalogInput gSlow(CTRL_SLOW, PinRef(PA1)); // default pollMs = DEFAULT_POLL_MS (8) +OpenSkyhawk::AnalogInput gFast(CTRL_FAST, PinRef(PA0), { .pollMs = 2 }); +OpenSkyhawk::AnalogInput gSlow(CTRL_SLOW, PinRef(PA1)); // default pollMs = AnalogConfig::DEFAULT_POLL_MS (8) void setup() { STM32Board::setDebug(false); // MANDATORY — see the header note on prints inside the window diff --git a/Firmware/Tests/AnalogInput/tests/reverse/reverse.cpp b/Firmware/Tests/AnalogInput/tests/reverse/reverse.cpp index 22707c12..51c32736 100644 --- a/Firmware/Tests/AnalogInput/tests/reverse/reverse.cpp +++ b/Firmware/Tests/AnalogInput/tests/reverse/reverse.cpp @@ -13,7 +13,7 @@ static constexpr uint16_t CTRL_ID = 0x567A; -OpenSkyhawk::AnalogInput gAna(CTRL_ID, PinRef(PA1), /*reverse=*/true); // full range 0..65535 +OpenSkyhawk::AnalogInput gAna(CTRL_ID, PinRef(PA1), { .reverse = true }); // full range 0..65535 void setup() { STM32Board::setDebug(true); diff --git a/Firmware/Tests/AnalogInput/tests/scale_clamp/scale_clamp.cpp b/Firmware/Tests/AnalogInput/tests/scale_clamp/scale_clamp.cpp index 70337128..0ea19443 100644 --- a/Firmware/Tests/AnalogInput/tests/scale_clamp/scale_clamp.cpp +++ b/Firmware/Tests/AnalogInput/tests/scale_clamp/scale_clamp.cpp @@ -15,7 +15,7 @@ static constexpr uint16_t CTRL_ID = 0x567A; // minRaw = 1000, maxRaw = 60000 → a sub-range calibration. -OpenSkyhawk::AnalogInput gAna(CTRL_ID, PinRef(PA1), /*reverse=*/false, 1000, 60000); +OpenSkyhawk::AnalogInput gAna(CTRL_ID, PinRef(PA1), { .minRaw = 1000, .maxRaw = 60000 }); void setup() { STM32Board::setDebug(true); diff --git a/Firmware/Tests/AnalogInput/tests/shift_bounds/shift_bounds.cpp b/Firmware/Tests/AnalogInput/tests/shift_bounds/shift_bounds.cpp index 29496ec5..d886bde0 100644 --- a/Firmware/Tests/AnalogInput/tests/shift_bounds/shift_bounds.cpp +++ b/Firmware/Tests/AnalogInput/tests/shift_bounds/shift_bounds.cpp @@ -15,7 +15,7 @@ static constexpr uint16_t CTRL_ID = 0x567A; // ewmaShift = 20 → clamped to MAX_EWMA_SHIFT (15). -OpenSkyhawk::AnalogInput gAna(CTRL_ID, PinRef(PA1), false, 0, 65535, 128, /*ewmaShift=*/20); +OpenSkyhawk::AnalogInput gAna(CTRL_ID, PinRef(PA1), { .hysteresis = 128, .ewmaShift = 20 }); void setup() { STM32Board::setDebug(true); diff --git a/Firmware/Tests/AngleSensorInput/tests/inherits/inherits.cpp b/Firmware/Tests/AngleSensorInput/tests/inherits/inherits.cpp index 0a30de67..4fd5b671 100644 --- a/Firmware/Tests/AngleSensorInput/tests/inherits/inherits.cpp +++ b/Firmware/Tests/AngleSensorInput/tests/inherits/inherits.cpp @@ -12,6 +12,9 @@ // centerDeg 180 / travelDeg 150 → ~27300 raw counts span the full 0..65535 output, so one raw // count is about 2.4 output counts: +20 raw stays under the hysteresis, +400 raw clears it. // +// AngleConfig (#295): the hysteresis is route-aware by default, like AnalogInput, and overridable. +// +400 raw (~960 output counts) stays under a DCSIN id's 1024 and clears an explicit 128. +// // Rig: this STM32 alone, CAN in silent loopback (no bus, no PanelBridge, no sensor). Do NOT run it // on a board wired to a live bus: a loopback node never ACKs, which drives the bridge // error-passive. PASS/FAIL comes from the input's own test seams, not from CAN. @@ -25,6 +28,17 @@ static constexpr uint16_t CTRL_ID = 0x567E; static uint16_t deg(float d) { return (uint16_t)(d * 65536.0f / 360.0f); } OpenSkyhawk::AngleSensorInput gAngle(CTRL_ID, PinRef(PA1), /*centerDeg=*/180.0f, /*travelDeg=*/150.0f); +OpenSkyhawk::AngleSensorInput gDcsAngle(0x8001, PinRef(PA2), 180.0f, 150.0f); // auto → 1024 +OpenSkyhawk::AngleSensorInput gOverAngle(0x8002, PinRef(PA3), 180.0f, 150.0f, { .hysteresis = 128 }); + +// Baseline at the centre, then +400 raw: true if it emitted past the baseline. +static bool emitsOn400(OpenSkyhawk::AngleSensorInput& a, uint16_t centre) { + a.configure(); + a.debugSetRaw(centre); a.forceReport(); + a.debugSetRaw((uint16_t)(centre + 400)); + for (uint8_t i = 0; i < 60; i++) a.debugStep(); + return a.emitCount() > 1; +} void setup() { STM32Board::setDebug(true); @@ -67,6 +81,9 @@ void setup() { check("return to centre: near baseline", gAngle.value() > baseline - 600 && gAngle.value() < baseline + 600); + check("AngleConfig auto, DCSIN id: silent", !emitsOn400(gDcsAngle, centre)); + check("AngleConfig explicit 128: emits ", emitsOn400(gOverAngle, centre)); + CANProtocol::flushBatched(canIdEvt(NODE_ID)); STM32Board::diagSerial().println(pass ? "=== ALL PASS ===" : "=== FAIL ==="); } diff --git a/docs/firmware/build-flags.md b/docs/firmware/build-flags.md index 54a41c57..ff797470 100644 --- a/docs/firmware/build-flags.md +++ b/docs/firmware/build-flags.md @@ -209,7 +209,7 @@ is set only by its own `Firmware/Tests//platformio.ini`. | Flag | Library it opens | First read at | |---|---|---| -| `ANALOGINPUT_TEST` | AnalogInput | `Inputs/AnalogInput/AnalogInput.h:84` | +| `ANALOGINPUT_TEST` | AnalogInput | `Inputs/AnalogInput/AnalogInput.h:104` | | `ANALOGMULTIPOS_TEST` | AnalogMultiPos | `Inputs/AnalogMultiPos/AnalogMultiPos.h:93` | | `DRUMDISPLAY_TEST` | DrumDisplay | `DrumDisplay/DrumDisplay.h:278` | | `LED_TEST` | LED | `Outputs/LED/LED.h:67` | diff --git a/docs/getting-started/controls/inputs/analoginput.md b/docs/getting-started/controls/inputs/analoginput.md index d795bf21..5702df6b 100644 --- a/docs/getting-started/controls/inputs/analoginput.md +++ b/docs/getting-started/controls/inputs/analoginput.md @@ -107,12 +107,13 @@ at the bottom and the reading at the top — and it stretches *that* across the instead: ```cpp -OpenSkyhawk::AnalogInput volume(DCSIN_ARC51_VOL, VOLUME_KNOB_PIN, false, 5000, 60000); +OpenSkyhawk::AnalogInput volume(DCSIN_ARC51_VOL, VOLUME_KNOB_PIN, { .minRaw = 5000, .maxRaw = 60000 }); ``` -The three new numbers are, in order: whether to reverse the knob (`false` here), the bottom of -your range, and the top. Anything below the bottom counts as the bottom, and anything above the -top counts as the top, so the cockpit control always reaches both ends. +Settings go in braces after the pin, each by its name, and you only write the ones you change — +here the bottom of your range (`minRaw`) and the top (`maxRaw`). Anything below the bottom counts +as the bottom, and anything above the top counts as the top, so the cockpit control always reaches +both ends. To find your two numbers, turn on the [debug stream](../../../firmware/debugging.md#diagserial-the-debug-stream) with the plain @@ -124,11 +125,11 @@ one, and put them in as the bottom and top. Then ## Troubleshooting **The cockpit knob turns the opposite way to mine.** -The outer legs are the other way round. Rather than rewiring them, add `true` after the pin -address and the board will flip it for you: +The outer legs are the other way round. Rather than rewiring them, add `{ .reverse = true }` after +the pin address and the board will flip it for you: ```cpp -OpenSkyhawk::AnalogInput volume(DCSIN_ARC51_VOL, VOLUME_KNOB_PIN, true); +OpenSkyhawk::AnalogInput volume(DCSIN_ARC51_VOL, VOLUME_KNOB_PIN, { .reverse = true }); ``` **The cockpit knob doesn't quite reach the end of its travel.** @@ -137,12 +138,12 @@ Your knob doesn't use the whole range. Measure its two ends and set them, as des **The cockpit knob twitches when nobody's touching it, or DCS falls behind when I turn it quickly.** -The board is sending more changes than it needs to. Make the dead zone bigger by adding a fourth -number after the bottom and top. At 1024, the knob sends about 64 steps across its whole turn, -which is plenty for a volume or brightness knob: +The board is sending more changes than it needs to. A cockpit knob already has a dead zone of +1024, about 64 steps across its whole turn. Make it bigger with the `hysteresis` setting — at 2048 +the knob sends about 32 steps, still plenty for a volume or brightness knob: ```cpp -OpenSkyhawk::AnalogInput volume(DCSIN_ARC51_VOL, VOLUME_KNOB_PIN, false, 0, 65535, 1024); +OpenSkyhawk::AnalogInput volume(DCSIN_ARC51_VOL, VOLUME_KNOB_PIN, { .hysteresis = 2048 }); ``` **The cockpit knob doesn't move at all.** @@ -152,8 +153,10 @@ the analog pins listed above. ??? info "Going further" No reading is perfectly steady: electrical noise makes the number wobble by a few steps even when nobody touches the knob. If the board passed on every wobble, it would bury DCS in tiny, - pointless changes, so by default the dead zone is 128 steps (about 1/500 of a full turn). - This dead zone is called *hysteresis*. + pointless changes, so there's a dead zone, called *hysteresis*. Its default depends on where the + knob goes: 1024 steps (about 1/64 of a turn) for a cockpit control, so a quick turn doesn't + flood DCS, and 128 steps (about 1/500) for a joystick axis, which wants the finer detail. + Setting `hysteresis` yourself always overrides the default. On top of the dead zone, the board smooths the readings by averaging each one with the ones before it. It catches up with a fast turn in a fraction of a second, which is quick enough @@ -164,9 +167,9 @@ the analog pins listed above. connects. A flight-control axis, such as a stick, wants a finer dead zone and a faster reading than a - cockpit knob. The class takes two more settings after the dead zone, the smoothing strength - and how often it reads, and a stick axis that's been tested on real hardware uses - `OpenSkyhawk::AnalogInput roll(CTRL_ROLL, ROLL_PIN, false, 0, 65535, 32, 4, 2);`. Those + cockpit knob. Two more settings cover that, the smoothing strength (`ewmaShift`) and how often + it reads (`pollMs`, in milliseconds), and a stick axis that's been tested on real hardware uses + `OpenSkyhawk::AnalogInput roll(CTRL_ROLL, ROLL_PIN, { .hysteresis = 32, .ewmaShift = 4, .pollMs = 2 });`. Those settings rely on a 1 kΩ resistor and 100 nF capacitor filter at the board end of the wire, and on the debug stream being off. Only use them for a joystick axis, because a cockpit knob read that fast would crowd out every other control on the way to DCS. diff --git a/docs/getting-started/controls/inputs/anglesensorinput.md b/docs/getting-started/controls/inputs/anglesensorinput.md index 5c91ebe1..a6a61b96 100644 --- a/docs/getting-started/controls/inputs/anglesensorinput.md +++ b/docs/getting-started/controls/inputs/anglesensorinput.md @@ -148,10 +148,13 @@ above the chip, directly over the middle. ??? info "Going further" Underneath, an `AngleSensorInput` is an [AnalogInput](analoginput.md) with angle maths added. It smooths the readings and ignores tiny wobbles in exactly the same way, and it - reports its position when the board starts up and whenever DCS asks. Unlike `AnalogInput`, - it doesn't take a reverse or dead-zone setting: the sensor's `DIR` pin sets the direction, - and the dead zone stays at the standard 128 steps. A joystick axis ID such as `CTRL_ROLL` - works in place of the cockpit control name, just as it does for `AnalogInput`. + reports its position when the board starts up and whenever DCS asks. Its dead zone has the + same defaults — 1024 steps for a cockpit control, 128 for a joystick axis — and you can change + it the same way, in braces after the travel: + `OpenSkyhawk::AngleSensorInput gunsightKnob(DCSIN_GUNSIGHT_KNB, GUNSIGHT_KNOB_PIN, 135, 150, { .hysteresis = 512 });`. + Unlike `AnalogInput`, it has no reverse setting: the sensor's `DIR` pin sets the direction. A + joystick axis ID such as `CTRL_ROLL` works in place of the cockpit control name, just as it does + for `AnalogInput`. A sensor that reads a whole circle has to have a jump somewhere, because 0° and 360° are the same place. The class moves that jump to the point directly opposite your centre, as far from From 05a896b8d07c766c85e073461a237eb817e7b31c Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 12:45:43 -0400 Subject: [PATCH 07/21] feat(firmware)!: StepperConfig carries the X27 defaults; makeX27Config 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. --- .../Drivers/StepperMotor/StepperMotor.cpp | 19 ++-- .../Drivers/StepperMotor/StepperMotor.h | 90 +++++++++---------- .../PanelGroup/Drivers/StepperMotor.md | 53 +++++------ .../tests/calibration/calibration.cpp | 1 + .../NeedleGauge/tests/drift_sim/drift_sim.cpp | 1 + .../NeedleGauge/tests/value_map/value_map.cpp | 1 + Firmware/Tests/StepperMotor/platformio.ini | 4 + .../cal_steps_per_rev/cal_steps_per_rev.cpp | 1 + .../tests/coil_port_check/coil_port_check.cpp | 1 + .../tests/config_defaults/config_defaults.cpp | 56 ++++++++++++ .../StepperMotor/tests/deadband/deadband.cpp | 1 + .../tests/home_sensor/home_sensor.cpp | 1 + .../tests/home_stall/home_stall.cpp | 1 + .../tests/motion_profile/motion_profile.cpp | 1 + .../tests/recal_guard/recal_guard.cpp | 1 + .../tests/step_pattern/step_pattern.cpp | 1 + .../Tests/StepperMotor/tests/wrap/wrap.cpp | 1 + docs/firmware/build-flags.md | 2 +- .../controls/outputs/needlegauge.md | 9 +- 19 files changed, 156 insertions(+), 89 deletions(-) create mode 100644 Firmware/Tests/StepperMotor/tests/config_defaults/config_defaults.cpp diff --git a/Firmware/Libraries/PanelGroup/Drivers/StepperMotor/StepperMotor.cpp b/Firmware/Libraries/PanelGroup/Drivers/StepperMotor/StepperMotor.cpp index 2c546b30..5e64efa2 100644 --- a/Firmware/Libraries/PanelGroup/Drivers/StepperMotor/StepperMotor.cpp +++ b/Firmware/Libraries/PanelGroup/Drivers/StepperMotor/StepperMotor.cpp @@ -255,17 +255,14 @@ int32_t StepperMotor::position() const { } StepperConfig makeX27Config(int16_t homePosition, int16_t parkPosition, - int16_t minPos, int16_t maxPos, - HomeMode home, bool homeSeekClockwise, HomeSensor sensor, - bool wrap, uint8_t deadband, bool autoRecal, uint32_t recalDebounceMs, - uint16_t stepsPerRev, uint16_t rangeSteps, uint16_t homeStepUs) { - return StepperConfig{ - stepsPerRev, StepPattern::SWITEC_6STATE, kSwitecDefaultAccel, kSwitecDefaultAccelN, - home, homeSeekClockwise, sensor, - homePosition, parkPosition, minPos, maxPos, - wrap, deadband, autoRecal, recalDebounceMs, - rangeSteps, homeStepUs, - }; + int16_t minPos, int16_t maxPos, HomeMode home) { + StepperConfig c; // the X27-family defaults + c.home = home; + c.homePosition = homePosition; + c.parkPosition = parkPosition; + c.minPos = minPos; + c.maxPos = maxPos; + return c; } } // namespace OpenSkyhawk diff --git a/Firmware/Libraries/PanelGroup/Drivers/StepperMotor/StepperMotor.h b/Firmware/Libraries/PanelGroup/Drivers/StepperMotor/StepperMotor.h index 22473feb..01300d13 100644 --- a/Firmware/Libraries/PanelGroup/Drivers/StepperMotor/StepperMotor.h +++ b/Firmware/Libraries/PanelGroup/Drivers/StepperMotor/StepperMotor.h @@ -64,72 +64,62 @@ struct HomeSensor { uint16_t maxSeekSteps; ///< abort the seek after this many steps (mis-wired safety) }; +/** @brief Default SwitecX25 acceleration table; fits the X27/VID-29/BKA-30 air-core family. */ +extern const AccelPoint kSwitecDefaultAccel[5]; +constexpr uint8_t kSwitecDefaultAccelN = 5; + /** * @brief Full per-instance stepper configuration. Authored per sketch (panel wiring). + * + * Every field has a default — the X27 / VID-29 / BKA-30 air-core family's — so a sketch sets only + * what differs, by name (#295): + * @code + * const OpenSkyhawk::StepperConfig OIL_MOTOR = { .maxPos = 960, .rangeSteps = 960 }; // 320° BKA-30 + * @endcode + * Fields are only ever appended, each with a default, so a new setting never breaks a sketch; a + * positional `{…}` initialiser that stops early gets the defaults for the rest. + * * @note @c accel must point at storage that outlives the StepperMotor (e.g. a static * const table). @c homePosition / @c parkPosition / @c minPos / @c maxPos are in steps; * a sketch may compute them from degrees as @c round(deg*stepsPerRev/360). */ struct StepperConfig { - uint16_t stepsPerRev; ///< steps per full revolution (calibrate empirically) - StepPattern pattern; ///< coil drive sequence - const AccelPoint* accel; ///< acceleration curve (not owned) - uint8_t accelN; ///< entries in accel[] - HomeMode home; ///< homing strategy - bool homeSeekClockwise; ///< direction to seek the home reference - HomeSensor sensor; ///< SENSOR-mode params (ignored for STALL) - int16_t homePosition; ///< step index assigned at the home reference - int16_t parkPosition; ///< step to rest at after homing - int16_t minPos; ///< lower travel clamp for moveTo (ignored if wrap) - int16_t maxPos; ///< upper travel clamp for moveTo (ignored if wrap) - bool wrap; ///< continuous-rotation gauge (shortest-path, no clamp) - uint8_t deadband; ///< ignore target changes within this many steps - bool autoRecal; ///< re-zero to homePosition when the sensor next asserts - uint32_t recalDebounceMs; ///< minimum interval between auto-recals - // Appended for back-compat: existing positional initialisers omit these → value-initialised to - // 0 → legacy behaviour (STALL home drives stepsPerRev at the library default rate). - uint16_t rangeSteps; ///< mechanical stop-to-stop travel in steps; STALL home drives this (+margin). 0 → stepsPerRev - uint16_t homeStepUs; ///< homing seek rate µs/step; MUST stay under the motor start-stop rate or the seek slips. 0 → library default (2000) + uint16_t stepsPerRev = 1080; ///< steps per full revolution (X27/BKA datasheet, 1/3°/step; calibrate empirically) + StepPattern pattern = StepPattern::SWITEC_6STATE; ///< coil drive sequence + const AccelPoint* accel = kSwitecDefaultAccel; ///< acceleration curve (not owned) + uint8_t accelN = kSwitecDefaultAccelN; ///< entries in accel[] + HomeMode home = HomeMode::STALL; ///< homing strategy + bool homeSeekClockwise = false; ///< direction to seek the home reference + HomeSensor sensor = { true, 5, 2000 }; ///< SENSOR-mode params (ignored for STALL) + int16_t homePosition = 0; ///< step index assigned at the home reference + int16_t parkPosition = 0; ///< step to rest at after homing + int16_t minPos = 0; ///< lower travel clamp for moveTo (ignored if wrap) + int16_t maxPos = 945; ///< upper travel clamp for moveTo (ignored if wrap); default = rangeSteps + bool wrap = false; ///< continuous-rotation gauge (shortest-path, no clamp) + uint8_t deadband = 1; ///< ignore target changes within this many steps + bool autoRecal = false; ///< re-zero to homePosition when the sensor next asserts + uint32_t recalDebounceMs = 0; ///< minimum interval between auto-recals + uint16_t rangeSteps = 945; ///< mechanical stop-to-stop travel in steps; STALL home drives this (+margin). 945 = X27.589 ~315°; 0 → stepsPerRev + uint16_t homeStepUs = 0; ///< homing seek rate µs/step; MUST stay under the motor start-stop rate or the seek slips. 0 → library default (2000) }; -/** @brief Default SwitecX25 acceleration table; fits the X27/VID-29/BKA-30 air-core family. */ -extern const AccelPoint kSwitecDefaultAccel[5]; -constexpr uint8_t kSwitecDefaultAccelN = 5; - /** - * @brief Build a StepperConfig with the X27 air-core motor defaults filled in. + * @brief Build a StepperConfig for an X27-family gauge from its travel. * - * Bakes the motor-invariant fields — `stepsPerRev`, `pattern` (SWITEC_6STATE), and the default - * SwitecX25 accel table — so a sketch specifies only the per-gauge wiring/travel. Shared by every - * X27 / VID-29 / BKA-30 gauge; override any default for a specific panel. + * Every other field keeps the StepperConfig default (1080 steps/rev, SWITEC_6STATE, the SwitecX25 + * accel table, 945-step stop-to-stop range, deadband 1, …). To change one of those, write the + * config by name instead: `{ .homePosition = 0, .maxPos = 960, .rangeSteps = 960 }`. * - * @param homePosition step index at the home reference. - * @param parkPosition rest position after homing. - * @param minPos lower moveTo travel clamp (ignored if wrap). - * @param maxPos upper moveTo travel clamp (ignored if wrap). - * @param home homing strategy. Default STALL. - * @param homeSeekClockwise seek direction. Default false. - * @param sensor home-sensor params (SENSOR mode). Default active-low, 5 ms, 2000 steps. - * @param wrap continuous-rotation gauge. Default false. - * @param deadband anti-jitter band, steps. Default 1. - * @param autoRecal re-zero on sensor crossing. Default false. - * @param recalDebounceMs minimum interval between auto-recals. Default 0. - * @param stepsPerRev full revolution in steps. Default 1080 (X27/BKA datasheet, 1/3°/step). - * @param rangeSteps mechanical stop-to-stop travel in steps = STALL home distance. Default 945 - * (X27.589 ~315°); set per gauge (e.g. 960 for a 320° BKA-30). - * @param homeStepUs homing seek rate µs/step. Default 0 → library default (2000 ≈ 500 steps/s). - * Keep under the motor start-stop rate (~774 steps/s) or the seek slips. + * @param homePosition step index at the home reference. + * @param parkPosition rest position after homing. + * @param minPos lower moveTo travel clamp (ignored if wrap). + * @param maxPos upper moveTo travel clamp (ignored if wrap). + * @param home homing strategy. Default STALL. * @return Populated StepperConfig. */ StepperConfig makeX27Config(int16_t homePosition, int16_t parkPosition, int16_t minPos, int16_t maxPos, - HomeMode home = HomeMode::STALL, - bool homeSeekClockwise = false, - HomeSensor sensor = { true, 5, 2000 }, - bool wrap = false, uint8_t deadband = 1, - bool autoRecal = false, uint32_t recalDebounceMs = 0, - uint16_t stepsPerRev = 1080, uint16_t rangeSteps = 945, - uint16_t homeStepUs = 0); + HomeMode home = HomeMode::STALL); /** * @brief Non-blocking instrument-gauge stepper driven through PinRef coils. diff --git a/Firmware/ScratchPad/TechSpec/PanelGroup/Drivers/StepperMotor.md b/Firmware/ScratchPad/TechSpec/PanelGroup/Drivers/StepperMotor.md index 5ea6d96c..965d913a 100644 --- a/Firmware/ScratchPad/TechSpec/PanelGroup/Drivers/StepperMotor.md +++ b/Firmware/ScratchPad/TechSpec/PanelGroup/Drivers/StepperMotor.md @@ -46,6 +46,7 @@ No new 3rd-party dependency (own PinRef stepping) — so it stays inside PanelGr | `deadband` | CI | sub-deadband target changes ignored | | `wrap` | CI | continuous-rotation shortest signed path; `position()` wraps | | `step_pattern` | CI | 6-state and 4-state both reach target | +| `config_defaults`| CI | `StepperConfig` defaults; `makeX27Config` = defaults + its arguments; a by-name initialiser changes only what it names (#295) | | `recal_guard` | CI | auto-recal needs `homed()` **and** a sensor asserted continuously for `sensor.debounceMs`; a mid-window glitch restarts the wait | | `coil_port_check`| CI | coils split across MCP ports/chips are refused and stay inert; one port — or all direct GPIO — is accepted | | `bringup` | bench | sweep a real X27 on 4 GPIO at 3.3 V | @@ -72,36 +73,36 @@ enum class StepPattern : uint8_t { SWITEC_6STATE, FULL_4STATE }; struct AccelPoint { uint16_t stepThreshold; uint16_t delayUs; }; // SwitecX25 (cumstep → delay) struct HomeSensor { bool activeLow; uint8_t debounceMs; uint16_t maxSeekSteps; }; -struct StepperConfig { - uint16_t stepsPerRev; // calibrate empirically (cal_steps_per_rev) - StepPattern pattern; - const AccelPoint* accel; uint8_t accelN; // last delayUs = top speed - HomeMode home; - bool homeSeekClockwise; - HomeSensor sensor; // SENSOR only - int16_t homePosition; // step index at the home reference - int16_t parkPosition; // rest position after homing - int16_t minPos, maxPos; // moveTo clamp (ignored if wrap) - bool wrap; // continuous-rotation, shortest path - uint8_t deadband; - bool autoRecal; uint32_t recalDebounceMs; - // Appended for back-compat — positional initialisers that omit them value-initialise to 0. - uint16_t rangeSteps; // stop-to-stop travel = STALL home distance; 0 → stepsPerRev - uint16_t homeStepUs; // homing seek µs/step; 0 → library default (2000 ≈ 500 steps/s) -}; - extern const AccelPoint kSwitecDefaultAccel[5]; // {20,3000},{50,1500},{100,1000},{150,800},{300,600} constexpr uint8_t kSwitecDefaultAccelN = 5; -// Factory: fills the X27 air-core motor-invariant fields (stepsPerRev/pattern/accel); a sketch sets -// only the per-gauge wiring/travel. Shared by every X27 / VID-29 / BKA-30 gauge — override any default. +// Every field defaulted to the X27 / VID-29 / BKA-30 family (#295): a sketch names only what differs, +// `{ .maxPos = 960, .rangeSteps = 960 }`. Fields are only ever appended, each with a default — a +// positional initialiser that stops early gets the defaults for the rest. +struct StepperConfig { + uint16_t stepsPerRev = 1080; // X27/BKA datasheet, 1/3°/step; calibrate (cal_steps_per_rev) + StepPattern pattern = StepPattern::SWITEC_6STATE; + const AccelPoint* accel = kSwitecDefaultAccel; // last delayUs = top speed + uint8_t accelN = kSwitecDefaultAccelN; + HomeMode home = HomeMode::STALL; + bool homeSeekClockwise = false; + HomeSensor sensor = { true, 5, 2000 }; // SENSOR only + int16_t homePosition = 0; // step index at the home reference + int16_t parkPosition = 0; // rest position after homing + int16_t minPos = 0; // moveTo clamp (ignored if wrap) + int16_t maxPos = 945; // … default = rangeSteps + bool wrap = false; // continuous-rotation, shortest path + uint8_t deadband = 1; + bool autoRecal = false; + uint32_t recalDebounceMs = 0; + uint16_t rangeSteps = 945; // stop-to-stop = STALL home distance; X27.589 ~315°; 0 → stepsPerRev + uint16_t homeStepUs = 0; // homing seek µs/step; 0 → 2000 (≈ 500 steps/s); keep under ~774 steps/s +}; + +// Factory for the common case: the defaults plus the gauge's travel. Anything else — a BKA-30's +// range, a different step count — is written by name instead. StepperConfig makeX27Config(int16_t homePosition, int16_t parkPosition, int16_t minPos, int16_t maxPos, - HomeMode home = HomeMode::STALL, bool homeSeekClockwise = false, - HomeSensor sensor = {true,5,2000}, bool wrap = false, uint8_t deadband = 1, - bool autoRecal = false, uint32_t recalDebounceMs = 0, - uint16_t stepsPerRev = 1080, // X27/BKA datasheet, 1/3°/step - uint16_t rangeSteps = 945, // X27.589 ~315°; e.g. 960 for a 320° BKA-30 - uint16_t homeStepUs = 0); // 0 → 2000 µs; keep under ~774 steps/s start-stop + HomeMode home = HomeMode::STALL); class StepperMotor : public MotorDriver { public: diff --git a/Firmware/Tests/NeedleGauge/tests/calibration/calibration.cpp b/Firmware/Tests/NeedleGauge/tests/calibration/calibration.cpp index d03cb067..4c58de6d 100644 --- a/Firmware/Tests/NeedleGauge/tests/calibration/calibration.cpp +++ b/Firmware/Tests/NeedleGauge/tests/calibration/calibration.cpp @@ -13,6 +13,7 @@ using namespace OpenSkyhawk; static const StepperConfig MOTOR_CFG = { 720, StepPattern::SWITEC_6STATE, kSwitecDefaultAccel, kSwitecDefaultAccelN, HomeMode::STALL, false, { false, 0, 0 }, 0, 0, 0, 720, false, 0, false, 0, + 0, // rangeSteps: 0 = stepsPerRev, as before the #295 default (945) }; StepperMotor gMotor(PinRef(), PinRef(), PinRef(), PinRef(), MOTOR_CFG); diff --git a/Firmware/Tests/NeedleGauge/tests/drift_sim/drift_sim.cpp b/Firmware/Tests/NeedleGauge/tests/drift_sim/drift_sim.cpp index 89ac6bcf..701fccda 100644 --- a/Firmware/Tests/NeedleGauge/tests/drift_sim/drift_sim.cpp +++ b/Firmware/Tests/NeedleGauge/tests/drift_sim/drift_sim.cpp @@ -35,6 +35,7 @@ static const StepperConfig DRIFT_MOTOR = { /* deadband */ 1, /* autoRecal */ false, /* recalMs */ 0, + 0, // rangeSteps: 0 = stepsPerRev, as before the #295 default (945) }; StepperMotor driftMotor(PinRef(PA0), PinRef(PA1), PinRef(PA4), PinRef(PA5), DRIFT_MOTOR); diff --git a/Firmware/Tests/NeedleGauge/tests/value_map/value_map.cpp b/Firmware/Tests/NeedleGauge/tests/value_map/value_map.cpp index 7bd5fe23..93bdc1cb 100644 --- a/Firmware/Tests/NeedleGauge/tests/value_map/value_map.cpp +++ b/Firmware/Tests/NeedleGauge/tests/value_map/value_map.cpp @@ -13,6 +13,7 @@ using namespace OpenSkyhawk; static const StepperConfig MOTOR_CFG = { 720, StepPattern::SWITEC_6STATE, kSwitecDefaultAccel, kSwitecDefaultAccelN, HomeMode::STALL, false, { false, 0, 0 }, 0, 0, 0, 720, false, 0, false, 0, + 0, // rangeSteps: 0 = stepsPerRev, as before the #295 default (945) }; StepperMotor gMotor(PinRef(), PinRef(), PinRef(), PinRef(), MOTOR_CFG); diff --git a/Firmware/Tests/StepperMotor/platformio.ini b/Firmware/Tests/StepperMotor/platformio.ini index b6bfa0f9..3615828f 100644 --- a/Firmware/Tests/StepperMotor/platformio.ini +++ b/Firmware/Tests/StepperMotor/platformio.ini @@ -56,6 +56,10 @@ build_src_filter = -<*> + extends = env_base build_src_filter = -<*> + +[env:config_defaults] +extends = env_base +build_src_filter = -<*> + + ; ── on-hardware bench scenarios (flash + eyeball / drive a real motor) ────────── [env:bringup] extends = env_base diff --git a/Firmware/Tests/StepperMotor/tests/cal_steps_per_rev/cal_steps_per_rev.cpp b/Firmware/Tests/StepperMotor/tests/cal_steps_per_rev/cal_steps_per_rev.cpp index ff7494c3..ea9e416c 100644 --- a/Firmware/Tests/StepperMotor/tests/cal_steps_per_rev/cal_steps_per_rev.cpp +++ b/Firmware/Tests/StepperMotor/tests/cal_steps_per_rev/cal_steps_per_rev.cpp @@ -31,6 +31,7 @@ static const StepperConfig CFG = { /* deadband */ 0, /* autoRecal */ false, /* recalDebounceMs */ 0, + /* rangeSteps */ 0, // 0 = stepsPerRev, as before the #295 default (945) }; StepperMotor gMotor(PinRef(PA0), PinRef(PA1), PinRef(PA4), PinRef(PA5), CFG); diff --git a/Firmware/Tests/StepperMotor/tests/coil_port_check/coil_port_check.cpp b/Firmware/Tests/StepperMotor/tests/coil_port_check/coil_port_check.cpp index 51a5ffbd..70a3cd47 100644 --- a/Firmware/Tests/StepperMotor/tests/coil_port_check/coil_port_check.cpp +++ b/Firmware/Tests/StepperMotor/tests/coil_port_check/coil_port_check.cpp @@ -36,6 +36,7 @@ static const StepperConfig CFG = { /* deadband */ 0, /* autoRecal */ false, /* recalDebounceMs */ 0, + /* rangeSteps */ 0, // 0 = stepsPerRev, as before the #295 default (945) }; MCP23017 gChipA(0x20, Wire); diff --git a/Firmware/Tests/StepperMotor/tests/config_defaults/config_defaults.cpp b/Firmware/Tests/StepperMotor/tests/config_defaults/config_defaults.cpp new file mode 100644 index 00000000..9ee28027 --- /dev/null +++ b/Firmware/Tests/StepperMotor/tests/config_defaults/config_defaults.cpp @@ -0,0 +1,56 @@ +// StepperMotor — config_defaults (tier 1: deterministic) +// +// StepperConfig carries the X27-family defaults on its fields (#295), so a sketch names only what +// differs. Checks that makeX27Config() is exactly those defaults plus its five arguments, and that +// a by-name initialiser changes only the fields it names. Compiling the by-name form is half the +// test: it must stay valid as fields are appended. + +#include +#include +#include + +using namespace OpenSkyhawk; + +static const StepperConfig DEFAULTS = {}; +static const StepperConfig X27 = makeX27Config(/*home*/ 0, /*park*/ 10, /*min*/ 0, /*max*/ 810); +static const StepperConfig BKA30 = { .maxPos = 960, .rangeSteps = 960 }; // 320° BKA-30 + +void setup() { + STM32Board::setDebug(true); + STM32Board::begin(); + STM32Board::diagSerial().println("=== StepperMotor config_defaults ==="); + + bool pass = true; + auto check = [&](const char* label, bool ok) { + if (!ok) pass = false; + STM32Board::diagSerial().print(label); + STM32Board::diagSerial().println(ok ? ": PASS" : ": FAIL"); + }; + + check("defaults: 1080 steps/rev, 6-state, Switec accel", + DEFAULTS.stepsPerRev == 1080 && DEFAULTS.pattern == StepPattern::SWITEC_6STATE + && DEFAULTS.accel == kSwitecDefaultAccel && DEFAULTS.accelN == kSwitecDefaultAccelN); + check("defaults: STALL home, 945 range, deadband 1", + DEFAULTS.home == HomeMode::STALL && DEFAULTS.rangeSteps == 945 && DEFAULTS.deadband == 1 + && DEFAULTS.minPos == 0 && DEFAULTS.maxPos == 945); + check("defaults: sensor active-low, 5 ms, 2000 steps", + DEFAULTS.sensor.activeLow && DEFAULTS.sensor.debounceMs == 5 + && DEFAULTS.sensor.maxSeekSteps == 2000); + + check("makeX27Config: its arguments", + X27.homePosition == 0 && X27.parkPosition == 10 && X27.minPos == 0 && X27.maxPos == 810 + && X27.home == HomeMode::STALL); + check("makeX27Config: everything else is the default", + X27.stepsPerRev == DEFAULTS.stepsPerRev && X27.rangeSteps == DEFAULTS.rangeSteps + && X27.deadband == DEFAULTS.deadband && X27.homeStepUs == DEFAULTS.homeStepUs + && X27.accel == DEFAULTS.accel && !X27.wrap && !X27.autoRecal); + + check("by name: only the named fields change", + BKA30.maxPos == 960 && BKA30.rangeSteps == 960 + && BKA30.stepsPerRev == DEFAULTS.stepsPerRev && BKA30.minPos == DEFAULTS.minPos + && BKA30.deadband == DEFAULTS.deadband); + + STM32Board::diagSerial().println(pass ? "=== ALL PASS ===" : "=== FAIL ==="); +} + +void loop() {} diff --git a/Firmware/Tests/StepperMotor/tests/deadband/deadband.cpp b/Firmware/Tests/StepperMotor/tests/deadband/deadband.cpp index 1287428b..a984694e 100644 --- a/Firmware/Tests/StepperMotor/tests/deadband/deadband.cpp +++ b/Firmware/Tests/StepperMotor/tests/deadband/deadband.cpp @@ -25,6 +25,7 @@ static const StepperConfig CFG = { /* deadband */ 5, /* autoRecal */ false, /* recalDebounceMs */ 0, + /* rangeSteps */ 0, // 0 = stepsPerRev, as before the #295 default (945) }; StepperMotor gMotor(PinRef(), PinRef(), PinRef(), PinRef(), CFG); diff --git a/Firmware/Tests/StepperMotor/tests/home_sensor/home_sensor.cpp b/Firmware/Tests/StepperMotor/tests/home_sensor/home_sensor.cpp index 6bd7bb92..08c44bd4 100644 --- a/Firmware/Tests/StepperMotor/tests/home_sensor/home_sensor.cpp +++ b/Firmware/Tests/StepperMotor/tests/home_sensor/home_sensor.cpp @@ -29,6 +29,7 @@ static StepperConfig cfg(bool activeLow, uint16_t maxSeek, int16_t park) { /* deadband */ 0, /* autoRecal */ false, /* recalDebounceMs */ 0, + /* rangeSteps */ 0, // 0 = stepsPerRev, as before the #295 default (945) }; return c; } diff --git a/Firmware/Tests/StepperMotor/tests/home_stall/home_stall.cpp b/Firmware/Tests/StepperMotor/tests/home_stall/home_stall.cpp index d35d3b81..e82aa0f9 100644 --- a/Firmware/Tests/StepperMotor/tests/home_stall/home_stall.cpp +++ b/Firmware/Tests/StepperMotor/tests/home_stall/home_stall.cpp @@ -25,6 +25,7 @@ static const StepperConfig CFG = { /* deadband */ 0, /* autoRecal */ false, /* recalDebounceMs */ 0, + /* rangeSteps */ 0, // 0 = stepsPerRev, as before the #295 default (945) }; StepperMotor gMotor(PinRef(), PinRef(), PinRef(), PinRef(), CFG); diff --git a/Firmware/Tests/StepperMotor/tests/motion_profile/motion_profile.cpp b/Firmware/Tests/StepperMotor/tests/motion_profile/motion_profile.cpp index 32364d51..52317690 100644 --- a/Firmware/Tests/StepperMotor/tests/motion_profile/motion_profile.cpp +++ b/Firmware/Tests/StepperMotor/tests/motion_profile/motion_profile.cpp @@ -32,6 +32,7 @@ static const StepperConfig CFG = { /* deadband */ 0, /* autoRecal */ false, /* recalDebounceMs */ 0, + /* rangeSteps */ 0, // 0 = stepsPerRev, as before the #295 default (945) }; StepperMotor gMotor(PinRef(), PinRef(), PinRef(), PinRef(), CFG); diff --git a/Firmware/Tests/StepperMotor/tests/recal_guard/recal_guard.cpp b/Firmware/Tests/StepperMotor/tests/recal_guard/recal_guard.cpp index f761a044..e62ab7e0 100644 --- a/Firmware/Tests/StepperMotor/tests/recal_guard/recal_guard.cpp +++ b/Firmware/Tests/StepperMotor/tests/recal_guard/recal_guard.cpp @@ -38,6 +38,7 @@ static StepperConfig cfg(uint16_t maxSeek) { /* deadband */ 0, /* autoRecal */ true, /* recalDebounceMs */ 0, + /* rangeSteps */ 0, // 0 = stepsPerRev, as before the #295 default (945) }; return c; } diff --git a/Firmware/Tests/StepperMotor/tests/step_pattern/step_pattern.cpp b/Firmware/Tests/StepperMotor/tests/step_pattern/step_pattern.cpp index 92e18b85..7ade206f 100644 --- a/Firmware/Tests/StepperMotor/tests/step_pattern/step_pattern.cpp +++ b/Firmware/Tests/StepperMotor/tests/step_pattern/step_pattern.cpp @@ -27,6 +27,7 @@ static StepperConfig cfg(StepPattern pattern) { /* deadband */ 0, /* autoRecal */ false, /* recalDebounceMs */ 0, + /* rangeSteps */ 0, // 0 = stepsPerRev, as before the #295 default (945) }; return c; } diff --git a/Firmware/Tests/StepperMotor/tests/wrap/wrap.cpp b/Firmware/Tests/StepperMotor/tests/wrap/wrap.cpp index 71bc3570..7e78ebde 100644 --- a/Firmware/Tests/StepperMotor/tests/wrap/wrap.cpp +++ b/Firmware/Tests/StepperMotor/tests/wrap/wrap.cpp @@ -25,6 +25,7 @@ static const StepperConfig CFG = { /* deadband */ 0, /* autoRecal */ false, /* recalDebounceMs */ 0, + /* rangeSteps */ 0, // 0 = stepsPerRev, as before the #295 default (945) }; StepperMotor gMotor(PinRef(), PinRef(), PinRef(), PinRef(), CFG); diff --git a/docs/firmware/build-flags.md b/docs/firmware/build-flags.md index ff797470..75c3746e 100644 --- a/docs/firmware/build-flags.md +++ b/docs/firmware/build-flags.md @@ -219,7 +219,7 @@ is set only by its own `Firmware/Tests//platformio.ini`. | `ROTARYENCODER_TEST` | RotaryEncoder | `Inputs/RotaryEncoder/RotaryEncoder.h:108` | | `SHIFTBUS_TEST` | ShiftBus | `Helpers/ShiftBus/ShiftBus.h:141` | | `SIMGATEWAY_TEST` | SimGateway | `SimGateway/SimGateway.cpp:12` (no-op HID stubs) | -| `STEPPERMOTOR_TEST` | StepperMotor | `Drivers/StepperMotor/StepperMotor.h:161` | +| `STEPPERMOTOR_TEST` | StepperMotor | `Drivers/StepperMotor/StepperMotor.h:151` | | `STM32BOARD_TEST` | STM32Board | `STM32Board/STM32Board.h:31` | Paths are relative to `Firmware/Libraries/`, and the PanelGroup ones to diff --git a/docs/getting-started/controls/outputs/needlegauge.md b/docs/getting-started/controls/outputs/needlegauge.md index bdc4c7ce..92bff927 100644 --- a/docs/getting-started/controls/outputs/needlegauge.md +++ b/docs/getting-started/controls/outputs/needlegauge.md @@ -195,7 +195,14 @@ then pull the needle off its shaft and press it back on pointing exactly at zero **What `makeX27Config` fills in for you.** 1,080 steps per full turn, and 945 steps (about 315°) between the motor's two end stops. Homing drives that distance plus an eighth, so it reaches the stop from anywhere. A motor with different stops, such as a 320° BKA-30, - takes its own value; the full list of options is in the API reference. + takes its own value. Write the description by name instead, giving only what differs: + + ```cpp + const OpenSkyhawk::StepperConfig OIL_MOTOR = { .maxPos = 960, .rangeSteps = 960 }; + ``` + + Everything you leave out keeps the X27 default. The full list of settings is in the API + reference. **Smooth moves.** Every move speeds up and slows down gently, using the same motion as Guy Carpenter's SwitecX25 library. Changes of a single step are ignored, so a value that From b20308f08f708f31634c67e42c2259fa56f430e0 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 12:49:03 -0400 Subject: [PATCH 08/21] docs(firmware): mark library-internal APIs and document the control-class interface (#295 A9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Firmware/Libraries/CANProtocol/CANProtocol.h | 5 + Firmware/Libraries/STM32Board/STM32Board.h | 16 ++- docs/guides/new-control-type.md | 136 ++++++++++++++----- 3 files changed, 120 insertions(+), 37 deletions(-) diff --git a/Firmware/Libraries/CANProtocol/CANProtocol.h b/Firmware/Libraries/CANProtocol/CANProtocol.h index af0020a0..1771ca47 100644 --- a/Firmware/Libraries/CANProtocol/CANProtocol.h +++ b/Firmware/Libraries/CANProtocol/CANProtocol.h @@ -206,6 +206,11 @@ using CanRxCallback = void(*)(uint32_t canId, const uint8_t* data, uint8_t using CanSyncReqCallback = void(*)(); // ── Runtime API (STM32 only) ────────────────────────────────────────────────── +// +// Library-internal: the node libraries (PanelGroup, PanelBridge, PduNode) drive the bus through +// these, not sketches. Not covered by the compatibility policy (#295) — except sendBatched(), which +// a custom control class uses to send its value (with ControlPacket and the canIdEvt* functions +// above, which are covered with the wire format). #ifdef ARDUINO_ARCH_STM32 diff --git a/Firmware/Libraries/STM32Board/STM32Board.h b/Firmware/Libraries/STM32Board/STM32Board.h index 74ced159..dc80ba61 100644 --- a/Firmware/Libraries/STM32Board/STM32Board.h +++ b/Firmware/Libraries/STM32Board/STM32Board.h @@ -69,9 +69,11 @@ namespace STM32Board { void setDebug(bool on); /** - * @brief Drive LED animations. Call once per loop() iteration. + * @brief Drive LED animations. Called once per loop() iteration by the node library. * * Advances blink state using millis(). Fully non-blocking. + * @note Library-internal: PanelGroup / PanelBridge / PduNode call it from their loop(). + * Not sketch API — not covered by the compatibility policy (#295). */ void tick(); @@ -94,6 +96,8 @@ namespace STM32Board { * setWarning(false) once the condition recovers. * * @param on True to raise WARNING (default); false to clear it. + * @note Library-internal: raised by CANProtocol and PanelBridge. + * Not sketch API — not covered by the compatibility policy (#295). */ void setWarning(bool on = true); @@ -107,6 +111,8 @@ namespace STM32Board { * if data is still flowing. * * @param active True to (re)assert the data-flowing link; false to drop it immediately. + * @note Library-internal: driven by PanelGroup and PanelBridge. + * Not sketch API — not covered by the compatibility policy (#295). */ void setLinkActive(bool active); @@ -126,6 +132,8 @@ namespace STM32Board { * clock, so callers MUST NOT drive the bus — a faulted node transmitting at the * wrong rate corrupts every other frame. CANProtocol::start() consults this and * comes up in listen-only (silent) mode instead. Latched once at boot; read-only. + * @note Library-internal: read by CANProtocol for node health. + * Not sketch API — not covered by the compatibility policy (#295). */ bool clockFault(); @@ -164,6 +172,8 @@ namespace STM32Board { * flagging, not precise ambient measurement. * @return Die temperature in whole °C, or DIE_TEMP_UNAVAILABLE (NodeStatus.h) if the internal * channels are unavailable on this variant. + * @note Library-internal: read by CANProtocol for HEALTH_n. + * Not sketch API — not covered by the compatibility policy (#295). */ int8_t readDieTempC(); @@ -173,6 +183,8 @@ namespace STM32Board { * @note Uses the STM32F103 typical Vrefint of 1.20 V (the F103 has no VREFINT_CAL * factory value), so absolute accuracy is limited; good for relative trend. * @return Vdd in millivolts, or 0 if the internal reference is unavailable. + * @note Library-internal: a diagnostic for the node libraries. + * Not sketch API — not covered by the compatibility policy (#295). */ uint16_t readVddMv(); @@ -190,6 +202,8 @@ namespace STM32Board { * @param tag Short node tag for the log line, e.g. "NODE" or "BRIDGE". * @param fault The node's current aggregated NodeFaultCode (from OpenSkyhawk::aggregateFaults()). * @param detail That fault's faultDetail() (non-null; ignored when fault == NONE). + * @note Library-internal: used by the node-status service. + * Not sketch API — not covered by the compatibility policy (#295). */ void logNodeFaultEdge(const char* tag, NodeFaultCode fault, const char* detail); diff --git a/docs/guides/new-control-type.md b/docs/guides/new-control-type.md index 3a12d8d2..45205fd4 100644 --- a/docs/guides/new-control-type.md +++ b/docs/guides/new-control-type.md @@ -1,49 +1,113 @@ # Adding a New Control Type -Most input and output [control types](../firmware/control-types.md) are specified but not yet -implemented (Phase 4 inputs, Phase 5 outputs). If your panel needs one that doesn't exist, you -may be the one writing it. This guide is the pattern; the per-class spec lives in the -FirmwarePlan TechSpec. +The [control types](../firmware/control-types.md) cover the switches, selectors, encoders, pots, +lamps, gauges and displays of the A-4E cockpit. If your panel needs something none of them does, +you can write your own class — in your own sketch, or added to the library for everyone. -!!! note "Read the spec first" - Each control type has a detailed specification in the project's TechSpec (value encoding, - debounce, polling rate, edge cases). Implement to the spec — don't improvise behaviour. - Before writing a new class, check whether it only changes an existing class's *source* (inputs) - or *sink* (outputs) — then it's a subclass in that class's family, not a new class. Names - follow `dcs-bios-arduino-library` where an equivalent exists (`RotaryAcceleratedEncoder` extends - `RotaryEncoder`; the planned `Dimmer` and `IntegerOutput` extend `AnalogOutput`). +The interface a control class is written against is part of the firmware's compatibility +promise: a class you write against v1 keeps compiling and working on every 1.x release. -## Where it goes +!!! note "Check the family first" + Before writing a new class, check whether yours only changes an existing class's *source* + (inputs) or *sink* (outputs). Then it belongs in that class's family as a subclass, not as a + new class. Names follow `dcs-bios-arduino-library` where an equivalent exists: + `RotaryAcceleratedEncoder` extends `RotaryEncoder`, `Dimmer` and `IntegerOutput` extend + `AnalogOutput`. Family subclasses use protected hooks that are *not* part of the promise, so + they belong in the library, not a sketch. -Control classes live in the **`PanelGroup`** library (`Firmware/Libraries/PanelGroup/`). Inputs -derive from the input base, outputs from the output base. +## The interface -## The pattern +An input derives from `OpenSkyhawk::InputBase`, an output from `OpenSkyhawk::OutputBase`. Declaring +an object registers it — `PanelGroup::setup()` and `PanelGroup::loop()` find it on their own. -- **Self-registration.** Constructors register the object into a static linked list — the - sketch just declares globals; `PanelGroup::loop()` walks the list. No manual registration. -- **Take a `PinRef`, not a raw pin.** Support GPIO / MCP23017 / ADS1115 backends through - `PinRef`. PWM/servo outputs must check `isGpio()` (only direct GPIO can do PWM). -- **Inputs fire a `ControlPacket`** via `CANProtocol::sendBatched(canIdEvt(NODE_ID), pkt)` — the - `controlId` (passed to your constructor) decides routing, not the class. Honour the batch - flush rules (slot B `0x0000`; flush a half-full batch within two `loop()` iterations). -- **Outputs receive `CTRL_BCAST` packets** dispatched by `PanelGroup::loop()`; pick out your - value by address + mask. -- **Normalise analog to 16-bit (0–65535)** at the source — no rescaling downstream. +| Input (`InputBase`) | Called | +|---|---| +| `configure()` | once, at start-up, after the expanders are ready — set your pins up here, not in the constructor | +| `forceReport()` | at start-up and on every `SYNC_REQ` — send the current state, unconditionally | +| `poll()` | every `loop()` — send only on a confirmed change; never block | +| `sampleTick()` | optional; from a sampling interrupt on boards that run one — read cached pins only | -## Value encoding and PanelBridge dispatch +| Output (`OutputBase`) | Called | +|---|---| +| `configure()` | once, at start-up — set your pins up here | +| `onControlPacket(controlId, value)` | for every value DCS sends — keep the ones whose `controlId` is yours, store them, return | +| `update()` | optional; every `loop()` — do the continuous work (stepping, fading) here, not in `onControlPacket` | -Inputs send a numeric `value`; PanelBridge maps it to a DCS-BIOS argument by **dispatch type** -(`SWITCH`, `ACTION`, `ENCODER`, `ACCEL_ENCODER`, `MULTIPOS`). When you add an input class, decide -which dispatch type it uses and make sure the generator/input-map handles it — see -[DCS-BIOS Integration](../firmware/dcsbios-integration.md). +Read and write hardware through the `PinRef` you were given (`read()`, `write()`, +`readAnalog()`, …), never a raw pin, so the class works on board pins, expanders and shift +registers alike. An output that can fail (a display that stops answering, say) also derives from +`FaultSource`, so the fault shows on the node's health. -## Tests +## Sending a value -Every control type ships with a test project under `Firmware/Tests/`. Add one that exercises the -value semantics and edge cases from the spec, on real hardware where the type needs it. +An input sends `CANProtocol::sendBatched(canIdEvt(NODE_ID), ControlPacket{ controlId, value })`. +PanelGroup batches and flushes the frames for you. The `controlId` you were given decides where the +value goes — a `DCSIN_*` id to DCS-BIOS, a `CTRL_*` id to the joystick — so the class never needs to +know. Analog values are 0–65535, already scaled. -## Then document it +The frame family decides what DCS-BIOS receives: -Update [Control Types](../firmware/control-types.md): move the type from "Planned" to -implemented, and add an example. Honesty in the status table matters. +| Frame | Value | DCS-BIOS gets | +|---|---|---| +| `canIdEvt` | the position or level | that number (`set_state`) | +| `canIdEvtRel` | a signed step, ±n | `+n` / `-n` (`variable_step`) | +| `canIdEvtDir` | ±1 | `INC` / `DEC` (`fixed_step`) | +| `canIdEvtAction` | 0 | `TOGGLE` (`action`) | + +The relative, direction and action frames only reach DCS-BIOS; a joystick input uses `canIdEvt`. + +## An example + +A pushbutton that sends 1 while held and 0 when released, written in a sketch: + +```cpp +#include +#include + +// A pushbutton that sends 1 while held and 0 when released. +// (No debounce — kept short. A real class should debounce, like Switch2Pos.) +class SimpleButton : public OpenSkyhawk::InputBase { +public: + SimpleButton(uint16_t controlId, PinRef pin) : _id(controlId), _pin(pin) {} + + void configure() override { _pin.configureAsInput(); } // pins are ready only now + + void forceReport() override { // boot and every SYNC_REQ + _pressed = !_pin.read(); // active-low + send(); + } + + void poll() override { // every loop(); never block + const bool pressed = !_pin.read(); + if (pressed != _pressed) { _pressed = pressed; send(); } + } + +private: + void send() { + CANProtocol::sendBatched(canIdEvt(NODE_ID), ControlPacket{ _id, (uint16_t)(_pressed ? 1 : 0) }); + } + + uint16_t _id; + PinRef _pin; + bool _pressed = false; +}; + +SimpleButton windDir(DCSIN_ASN41_WINDDIR_BTN, PinRef(PB0)); + +void setup() { + Wire.begin(); + PanelGroup::setup(); +} + +void loop() { + PanelGroup::loop(); +} +``` + +## Adding it to the library + +A class added to the library lives under `Firmware/Libraries/PanelGroup/Inputs/` or `Outputs/`, +follows its TechSpec in `Firmware/ScratchPad/TechSpec/`, and ships with a test project under +`Firmware/Tests/` that exercises its values and edge cases, on real hardware where it needs it. +Then add it to the `OpenSkyhawk.h` umbrella header and to +[Control Types](../firmware/control-types.md), with an example. From 1da0212f75dab4bde1ba69991ee1f096b2293e5a Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 12:49:26 -0400 Subject: [PATCH 09/21] docs(firmware): write down the PinRef native-pin exceptions (#295 A6) 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. --- .../ScratchPad/FirmwarePlan/05-panelgroup-api.md | 12 ++++++++++++ Firmware/ScratchPad/TechSpec/PanelGroup/PinRef.md | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/Firmware/ScratchPad/FirmwarePlan/05-panelgroup-api.md b/Firmware/ScratchPad/FirmwarePlan/05-panelgroup-api.md index 8a94b78c..c3e68625 100644 --- a/Firmware/ScratchPad/FirmwarePlan/05-panelgroup-api.md +++ b/Firmware/ScratchPad/FirmwarePlan/05-panelgroup-api.md @@ -19,6 +19,7 @@ All input and output objects take a `PinRef` instead of a raw `uint8_t` pin. | Direct STM32 GPIO | `PinRef(uint8_t pin)` | `digitalRead`/`digitalWrite`/`analogRead`/`analogWrite` directly | | MCP23017 GPIO | `PinRef(MCP23017& chip, uint8_t port, uint8_t bit)` | port = `PORT_A` or `PORT_B`; bit = 0–7 | | ADS1115 channel | `PinRef(ADS1115& adc, uint8_t channel)` | channel = 0–3; read via `readSingleEnded()` | +| 74HC165 / 74HC595 bit | `PinRef(ShiftBus& bus, uint8_t chip, uint8_t bit)` | `configureAsInput()` binds the '165 chain, `configureAsOutput()` the '595 (#197) | **Interface:** - `bool read()` — digital read @@ -32,6 +33,17 @@ All input and output objects take a `PinRef` instead of a raw `uint8_t` pin. PinRefs are never PWM/servo-capable. For direct GPIO, sketches still choose PWM/servo-capable pins from the board wiring map. +**Native-pin exceptions (#295).** Control classes always take a `PinRef`. Backend wiring — the lines +a `PinRef` backend itself needs — takes native MCU pins, because those lines must be real GPIO: + +- `PanelGroup::registerExpander(chip, intaPin, intbPin)` — the MCP23017 INTA/INTB lines go to + `attachInterrupt()`, which needs a native EXTI pin. +- `ShiftBus(spi, sck, miso, mosi, load, latch)` — a ShiftBus *is* a `PinRef` backend, so its own + lines can't be shift-register `PinRef`s, and load/latch are toggled from the TIM2 sampling ISR. + +A bus object (`TwoWire&` for `registerADC`, `I2cMux` and `DrumDisplay`; `SPIClass&` for `ShiftBus`) +is not a pin and not an exception. These are the only two. + --- ## MCP23017 Management diff --git a/Firmware/ScratchPad/TechSpec/PanelGroup/PinRef.md b/Firmware/ScratchPad/TechSpec/PanelGroup/PinRef.md index cd242be2..78580cd2 100644 --- a/Firmware/ScratchPad/TechSpec/PanelGroup/PinRef.md +++ b/Firmware/ScratchPad/TechSpec/PanelGroup/PinRef.md @@ -16,6 +16,11 @@ control declarations are identical regardless of where the physical pin lives. Contains no logic beyond read/write dispatch — no debounce, no filtering, no state. +Every control class takes a `PinRef`. The two native-pin exceptions are backend wiring, not +controls — `PanelGroup::registerExpander()`'s INTA/INTB lines (native EXTI) and the `ShiftBus` +constructor's SPI + load/latch pins (a backend's own lines) — see FirmwarePlan +`05-panelgroup-api.md` § PinRef (#295). + --- ## File Layout From 1f4e90452d591ea534a58d77b349ac00d8bbe87a Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 12:50:00 -0400 Subject: [PATCH 10/21] docs(firmware): within a major the wire only grows; readers tolerate 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. --- .../ScratchPad/FirmwarePlan/00-decisions.md | 23 ++++++++++++++++ .../FirmwarePlan/02-can-protocol.md | 26 +++++++++++++++++++ .../FirmwarePlan/04-dcs-bios-integration.md | 22 ++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md index e152cd0a..e95e5088 100644 --- a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md +++ b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md @@ -564,3 +564,26 @@ reported". `06-panelbridge-api.md`, `09-startup-resync-diagnostics.md`, TechSpec (`CANProtocol`, `NodeStatus`, `PanelBridge`, `PanelGroup`, `PowerMonitor`, `README`), `docs/architecture/can-bus.md`, `docs/architecture/firmware-tiers.md`. Client half: SkyHawkClient sync of `NodeStatusField` v4. + +--- + +## D22 — Within a major, the wire only grows and every reader tolerates growth (#295) + +**Decision:** From v1.0, CAN frames and payloads, the host `_NODE_*` lines and their enums are +frozen per major. Writers only append: a frame never grows (8 bytes), a field never moves, resizes +or changes meaning; new data goes into reserved bytes, a new `TelemPage`, or a new frame family in +a free ID range, and new enum values are allowed. Readers accept a longer `_NODE_STATUS` row, +parse the fields they know and ignore the rest, keep unknown enum values as "unknown (0xNN)", and +ignore reserved bits. A shorter row means older firmware and is reported, not dropped silently. + +**Rationale:** +- *A strict reader makes every addition a major.* The client dropped any `_NODE_STATUS` row whose + length wasn't exactly 28, so the first 1.x field (as #344's `nodeKind` and #295's version were) + would have blanked every 1.0 client's node list. +- *Growth paths are named so they don't collide.* Health stays in `HEALTH_n` (its two reserved + bytes), node telemetry in `TELEM_n` pages, identity in `READY_n` — each owner kept single. +- *The calibration channel is the exception.* Its `LEN` is fixed per type on purpose (false-frame + rejection inside the DCS-BIOS stream), so it grows by new message types instead (`03`). + +**Affects:** `02-can-protocol.md`, `03-uart-usb-hid-protocol.md`, `04-dcs-bios-integration.md`, +`NodeStatus.h`. Client half: SkyHawkClient's `_NODE_STATUS` parser and alerts (client 1.0). diff --git a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md index ff80128d..650b6fde 100644 --- a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md +++ b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md @@ -363,6 +363,32 @@ unpacked C++ struct, because natural alignment can pad this layout beyond 8 byte --- +## Compatibility — how the wire grows (#295, D22) + +From v1.0 the frame IDs and payloads are frozen: a change that breaks an existing reader is a new +**major** version. Within a major, the wire only grows, and every reader tolerates growth: + +- **Frames never grow and fields never move.** A classic CAN frame carries 8 bytes, and the + STM32F103's bxCAN has no CAN FD. An existing field is never moved, resized or reinterpreted — + that is a major. +- **Reserved bits and bytes:** transmit 0, ignore on receive. A later minor may give one a + meaning; an older reader keeps ignoring it. +- **Unknown values are kept, not rejected.** A `NodeKind`, `NodeFaultCode`, `TelemPage` or flag bit + a reader doesn't know is carried as "unknown (0xNN)". New values are minor. +- **Where new data goes** (each a minor): + - generic node health → `HEALTH_n`'s reserved bytes (6–7); + - node-specific telemetry → a new `TelemPage` on `TELEM_n` (append-only, forwarded unread); + - identity or anything else → `READY_n`'s reserved bytes (3–7), or a new frame family in a + free ID range (`0x013`–`0x0FF`, `0x1C0`–`0x1FF`, `0x240`–`0x2FF`, `0x340`–`0x3FF`, + `0x440`–`0x4FF`, `0x540`–`0x5FF`, `0x640`–`0x6FF`, `0x740`–`0x7FF`). Its position in the map + sets its arbitration priority (lower ID wins), which is frozen with it. +- `TELEM_n` never carries generic health, and `HEALTH_n` never carries node-specific telemetry. + +`TEST_SEQ` / `ECHO_n` are covered only as far as other boards rely on them: the two IDs, and every +node mirroring the 8 bytes unchanged. The payload contents are bridge-internal (above). + +--- + ## PanelBridge Receive Filter The heartbeat address family is `canIdHb(n) = 0x100 + n`, so the full HB range is diff --git a/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md b/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md index d7b65c47..0067593d 100644 --- a/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md +++ b/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md @@ -123,6 +123,28 @@ The row is `NODE_TELEM_ROW_BYTES` (9) bytes; `NodeTelemField` names the two offs Client side: OpenSkyhawk/SkyHawkClient#61. +### Compatibility — how the host lines grow (#295, D22) + +`_NODE_STATUS`, `_NODE_STATUS_END`, `_NODE_TELEM` and the `0x86FE` roster request are frozen from +v1.0 like the CAN wire (`02-can-protocol.md` § Compatibility). Within a major: + +- **Writers only append.** A new `_NODE_STATUS` field goes at the end of the row; existing fields + never move, resize or change meaning (that is a major). `NODE_STATUS_PROTO_VERSION` still bumps, + so the client's sync notices — but the row stays readable by older clients. +- **Readers accept longer.** A well-formed row at least as long as the layout a reader knows is + parsed for the fields it knows; the rest is ignored. A shorter well-formed row means firmware + older than the reader needs: the client says "Gateway firmware too old", once — it never drops + rows silently. +- **Unknown values show as unknown** — "unknown (0xNN)" for a `NodeKind`, `NodeFaultCode`, + `TelemPage` or flag bit — never an error. +- **Versions:** each row's `fwMajor` comes from the node's `READY_n` (v4). The client warns when a + board's major differs from the node-0 row's (nodes on one bus share a major), and says "client + too old" when the Gateway's major is newer than the client supports. `000000` = not reported. +- Malformed lines are dropped and logged for debugging only. + +The calibration channel on the USB CDC port follows its own rule — exact `LEN` per type, so it +can't grow in place (`03-uart-usb-hid-protocol.md` § Calibration Protocol). + --- ## DCSIN_* — Compact Transport Aliases From 99456a4df349802858ad6b0b5450432d8811cf90 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 12:50:23 -0400 Subject: [PATCH 11/21] docs(firmware): state ControlPacket's byte order (#295 A14) 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. --- .../FirmwarePlan/02-can-protocol.md | 23 +++++++++++++------ docs/architecture/can-bus.md | 3 +++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md index 650b6fde..6ab2a83e 100644 --- a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md +++ b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md @@ -44,6 +44,10 @@ struct ControlPacket { }; ``` +**Byte order: both fields little-endian on the wire** — `controlId` low byte first, then its high +byte, then `value` the same way (#295). It is the packed struct's native layout on the STM32 and +the RP2040, and the only byte order a third-party node or a CAN capture should assume. + `ControlPacketPair` packs two packets into one 8-byte CAN frame: ```cpp @@ -53,8 +57,16 @@ struct ControlPacketPair { }; ``` -`ControlPacketPair` is used for `CTRL_BCAST` and `EVT_n` frames only. If only one packet is -ready, slot B uses `controlId = 0x0000` as the null sentinel and receivers ignore it. +| Byte(s) | Field | Encoding | +|---------|-------|----------| +| 0-1 | `a.controlId` | uint16_t little-endian | +| 2-3 | `a.value` | uint16_t little-endian | +| 4-5 | `b.controlId` | uint16_t little-endian; `0x0000` = empty slot | +| 6-7 | `b.value` | uint16_t little-endian | + +`ControlPacketPair` is used for `CTRL_BCAST` and the four event frames (`EVT_n`, `EVT_REL_n`, +`EVT_DIR_n`, `EVT_ACTION_n`) only. If only one packet is ready, slot B uses `controlId = 0x0000` +as the null sentinel and receivers ignore it. Special/control/diagnostic frames (`SYNC_REQ`, `READY_n`, `TEST_SEQ`, `ECHO_n`, `HB_n`) use the frame-specific DLC and payload listed in the frame table. A special frame may carry a @@ -96,9 +108,6 @@ constexpr uint32_t canIdReady(uint8_t n) { return 0x400 + n; } // 0x401–0x43F | `EVT_DIR_n` | `0x600+n` | Node_n → PanelBridge | 8 | Directional encoder events (`RotaryEncoder` DIR) — `ControlPacketPair`, `±1` → `INC`/`DEC` `fixed_step` (#147) | | `EVT_ACTION_n` | `0x700+n` | Node_n → PanelBridge | 8 | Action events (`ActionButton`) — `ControlPacketPair`, selector `0` → `TOGGLE` `action` (#116) | -> **Migration note (Phase 1):** existing `CAN_ID_HB_1 = 0x100` changes to `canIdHb(1) = 0x101`. -> Update any diagnostic tools, captured CAN logs, or test fixtures that reference the old value. - --- ## Heartbeat Payload @@ -308,8 +317,8 @@ bridge's boot `SYNC_REQ` can be added later without a wire break (`SYNC_REQ` is ## ControlPacketPair Frame Payload -`CTRL_BCAST` and `EVT_n` frames carry exactly **8 bytes** — a `ControlPacketPair`. -No other frame type is implicitly decoded as a pair: +`CTRL_BCAST` and the four event frames carry exactly **8 bytes** — a `ControlPacketPair` +(byte layout in § ControlPacket Wire Format). No other frame type is implicitly decoded as a pair: ```cpp // Payload = two ControlPackets. Slot B may be null. diff --git a/docs/architecture/can-bus.md b/docs/architecture/can-bus.md index 9a90ec57..05d26fa7 100644 --- a/docs/architecture/can-bus.md +++ b/docs/architecture/can-bus.md @@ -98,6 +98,9 @@ struct ControlPacketPair { ControlPacket a; ControlPacket b; }; // 8 bytes occupy different CAN frame fields. See [DCS-BIOS vs HID](dcsbios-vs-hid.md) for the `controlId` ranges. - **`value`** is the payload — interpretation depends on the `controlId` range. +- **Byte order:** both fields are little-endian on the wire, low byte first — in a + `ControlPacketPair`, bytes 0–1 `a.controlId`, 2–3 `a.value`, 4–5 `b.controlId`, 6–7 `b.value`. + Every multi-byte CAN payload field in this project is little-endian. `CTRL_BCAST` and all four event frames — `EVT_n`, `EVT_REL_n`, `EVT_DIR_n` and `EVT_ACTION_n` — carry a **`ControlPacketPair`** so two packets share one 8-byte frame. Each From af967bd96b4d479fbf55be0883ca9f735f801258 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 12:50:34 -0400 Subject: [PATCH 12/21] docs(firmware): TEST_SEQ's payload is bridge-internal (#295 A19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md index 6ab2a83e..8378c986 100644 --- a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md +++ b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md @@ -353,7 +353,13 @@ void CANProtocol::flushBatched(uint32_t canId); // forc ### TEST_SEQ Payload Wire Layout -`TEST_SEQ` carries exactly 8 payload bytes and `ECHO_n` mirrors those bytes unchanged: +`TEST_SEQ` carries exactly 8 payload bytes and `ECHO_n` mirrors those bytes unchanged. It is a bench +diagnostic: PanelBridge sends it when `T` is typed on its DiagSerial and prints the round-trip time. + +**What the freeze covers (#295):** the two IDs, and every node mirroring all 8 bytes unchanged on +`ECHO_n` — a 1.x node must keep answering a 1.0 bridge's test. The layout below is +**bridge-internal**: only PanelBridge writes and reads it (nodes never look inside), so it may +change in a minor release. | Byte(s) | Field | Encoding | |---------|-------|----------| @@ -365,11 +371,6 @@ Implementations must serialize this into a `uint8_t[8]`, or use a packed struct `static_assert(sizeof(TestSeqPayload) == 8)`. Do not transmit `sizeof(struct)` from an unpacked C++ struct, because natural alignment can pad this layout beyond 8 bytes. -> **Prototype note:** the existing `PanelGroup.cpp` sends 8 bytes — `ControlPacket` followed -> by a 4-byte `millis()` timestamp. The timestamp was added for prototype RTT measurement only. -> Production firmware uses the full 8 bytes as `ControlPacketPair`; bytes 4–7 are no longer -> a timestamp. - --- ## Compatibility — how the wire grows (#295, D22) From c4536f41eda3f16f72228a3beea1c2e8b8b898cb Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 12:50:56 -0400 Subject: [PATCH 13/21] docs(firmware): the calibration channel's own compatibility rule (#295 A17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../FirmwarePlan/03-uart-usb-hid-protocol.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md b/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md index af559fe4..9631525a 100644 --- a/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md +++ b/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md @@ -360,6 +360,20 @@ and older firmware rejects the unknown type (`calLenValidForType()` fails), so i the relay and no reply comes. A client therefore treats "no `DIAG` within its request timeout" as "not supported", not as a link fault. Bumping the version would make a client that requires an exact match refuse calibration against new firmware until it synced, for no gain. + +### Compatibility — how the channel grows (#295, D22) + +The `_NODE_*` lines grow by appending fields (`04-dcs-bios-integration.md` § Compatibility). This +channel can't: `LEN` is fixed per type, so a longer payload on an existing type is "not a frame" to +an older reader. From v1.0 it follows its own rule: + +1. **`HELLO` / `HELLO_ACK` never change**, in any version — they are how a client learns `proto`, + so every client must always be able to read them. +2. **A new message type is a minor.** Older firmware fails `calLenValidForType()` on it and relays + the bytes as DCS data, so no reply comes; the client treats a timeout as "not supported" and + falls back (as `GET_DIAG` does). `CAL_PROTO_VERSION` stays. +3. **Any change to an existing type's `LEN`, field layout or meaning bumps `CAL_PROTO_VERSION`**, + and is a major: a client refuses calibration against a `proto` it doesn't know. ### Session model `HELLO`, `GET_CAL` and `GET_DIAG` work **outside** a session. This is what lets the client show @@ -404,7 +418,7 @@ axis, and no other axis is affected by it. |---|---|---|---| | `0x01` | `BAD_CRC` | frame checksum mismatch | retransmit | | `0x02` | `BAD_LENGTH` | `LEN` inconsistent with its type | client bug — log it | -| `0x03` | `BAD_TYPE` | unknown type code | protocol version mismatch | +| `0x03` | `BAD_TYPE` | unknown type code | never sent: an unknown type fails `calLenValidForType()` first and is relayed, so the client sees a timeout (see § Compatibility) | | `0x04` | `BAD_INDEX` | `idx` out of range | client bug | | `0x05` | `BAD_ORDER` | endpoints not ordered; `detail` names the axis | "Axis N: endpoints out of order" | | `0x06` | `NO_SESSION` | command requires an open session | client bug | From a4d9cdab35064a5afea962a03289c5ea223cc81d Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 12:51:15 -0400 Subject: [PATCH 14/21] docs(firmware): stored calibration is not covered by the freeze (#295 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. --- Firmware/ScratchPad/FirmwarePlan/07-simgateway-api.md | 5 +++++ Firmware/ScratchPad/TechSpec/PowerMonitor.md | 3 +++ 2 files changed, 8 insertions(+) diff --git a/Firmware/ScratchPad/FirmwarePlan/07-simgateway-api.md b/Firmware/ScratchPad/FirmwarePlan/07-simgateway-api.md index b41b0380..7d099d6a 100644 --- a/Firmware/ScratchPad/FirmwarePlan/07-simgateway-api.md +++ b/Firmware/ScratchPad/FirmwarePlan/07-simgateway-api.md @@ -83,6 +83,11 @@ Out of scope, deliberately: response curves and axis inversion (both belong in t firmware curve would stack with DCS's per-aircraft curves), and boot-time centre auto-zero (the stick may not be at rest at power-on, and not every axis has a rest position). +**Not covered by the v1.0 compatibility policy (#295).** The stored blob survives a normal reflash, +but its layout (`CAL_VERSION`) — and the EEPROM-emulation sector, whose position comes from the +RP2040 core — may change in any release, and every axis then reads uncalibrated. The client keeps a +copy per board serial and offers to restore it (same PC); otherwise, recalibrate. + --- ## HIDButton diff --git a/Firmware/ScratchPad/TechSpec/PowerMonitor.md b/Firmware/ScratchPad/TechSpec/PowerMonitor.md index b098818a..f895323c 100644 --- a/Firmware/ScratchPad/TechSpec/PowerMonitor.md +++ b/Firmware/ScratchPad/TechSpec/PowerMonitor.md @@ -136,6 +136,9 @@ wins, erase only when full): average; refuses above 75 mA on 12 V / 120 mA on 5 V), `CAL CLEAR`, `RAW` (live values + scan rate). - **Flash budget.** `board_upload.maximum_size = 64512` keeps the image out of the calibration page; the PDU build is ~31.5 KB. +- **Not covered by the v1.0 compatibility policy (#295).** The calibration survives a normal + reflash, but its record layout (`CAL_VERSION`) or page may change in any release; the PDU then + reports `UNCAL_VDDA` / `UNCAL_ZERO` (shown by the client) — recalibrate at the bench over serial. --- From 376ee3a38b9ca3080a4d1f8776bf9419c465ce35 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 12:52:35 -0400 Subject: [PATCH 15/21] ci(a4ec): report removed or moved symbols on a DCS-BIOS refresh; guard DCSIN ids (#295 A22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/a4ec-refresh.yml | 6 ++- tools/gen_a4ec/README.md | 10 ++++- tools/gen_a4ec/gen_a4ec.py | 66 +++++++++++++++++++++++++++++- tools/gen_a4ec/tests/test_gen.py | 41 +++++++++++++++++++ 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/.github/workflows/a4ec-refresh.yml b/.github/workflows/a4ec-refresh.yml index 9e532cae..f6bd4739 100644 --- a/.github/workflows/a4ec-refresh.yml +++ b/.github/workflows/a4ec-refresh.yml @@ -99,7 +99,8 @@ jobs: python tools/gen_a4ec/gen_a4ec.py \ --source tools/gen_a4ec/data/A-4E-C.jsonp \ --source-label "DCS-BIOS ${{ steps.release.outputs.tag }} (${{ steps.release.outputs.source_ref }})" \ - --timestamp "${{ steps.release.outputs.published_at }}" + --timestamp "${{ steps.release.outputs.published_at }}" \ + --change-report "$RUNNER_TEMP/a4ec-changes.md" - name: Test A4EC generator run: | @@ -197,6 +198,9 @@ jobs: - A4EC generator pytest suite - PlatformIO build for projects that reference `A4EC` BODY + # Removed or moved symbols break sketches built against the old headers; the generator + # lists them so the maintainer decides the version bump (#295). Additions only is normal. + { echo; cat "$RUNNER_TEMP/a4ec-changes.md"; } >> "$body_file" existing_pr="$(gh pr list \ --repo "$GITHUB_REPOSITORY" \ diff --git a/tools/gen_a4ec/README.md b/tools/gen_a4ec/README.md index 2591e495..ab341449 100644 --- a/tools/gen_a4ec/README.md +++ b/tools/gen_a4ec/README.md @@ -40,7 +40,15 @@ PanelGroup node should run firmware built from the same header revision. id for its lifetime, appends a new control at `max+1`, and retires (never reuses) a removed control's id — so a snapshot update never renumbers existing controls, it only appends. Deleting the ledger re-seeds from scratch (renumbers everything), so -keep it committed. +keep it committed. The generator refuses to run if an existing `DCSIN_*` id would +change against the committed headers (#295). + +Each run also prints a **symbol change report** — added, removed and moved +`DCSIN_*` / `A_4E_C_*` symbols against the committed headers (`--change-report +` writes it to a file; the refresh workflow puts it in the PR body). Additions +are the normal case. A removed symbol breaks the build of a sketch that uses it, and +a moved `A_4E_C_*` address silently breaks a lamp or gauge built against the old one, +so either is the maintainer's call on the version bump. ## Source resolution (priority order) diff --git a/tools/gen_a4ec/gen_a4ec.py b/tools/gen_a4ec/gen_a4ec.py index 545a7ca5..8db4754f 100644 --- a/tools/gen_a4ec/gen_a4ec.py +++ b/tools/gen_a4ec/gen_a4ec.py @@ -9,7 +9,7 @@ Firmware/Libraries/A4EC/GENERATOR_GAPS.md — skipped controls with reasons Usage (from repo root): - python tools/gen_a4ec/gen_a4ec.py [--source ] [--timestamp ] [--fetch-github] + python tools/gen_a4ec/gen_a4ec.py [--source ] [--timestamp ] [--change-report ] Source resolution (priority order): 1. --source explicit override, any platform @@ -22,6 +22,7 @@ import argparse import json import platform +import re import sys from datetime import datetime, timezone from pathlib import Path @@ -193,6 +194,49 @@ def build_output_entries(controls: dict) -> tuple: out.sort(key=lambda e: e["addr_id"]) return out, gaps +# ── Change report (#295) ────────────────────────────────────────────────────── +# DCSIN_* ids are append-only (the ledger) and must never change; A_4E_C_* addresses and masks +# follow DCS-BIOS upstream. A refresh that removes or moves a symbol a sketch may use is listed so +# the maintainer can decide the version bump — additions are the normal case. + +_DEFINE_RE = re.compile(r"^#define\s+(\w+)\s+(0x[0-9A-Fa-f]+)", re.M) + +def parse_defines(text: str) -> dict: + """{name: value} for every `#define NAME 0x…` line of a generated header.""" + return {name: int(value, 16) for name, value in _DEFINE_RE.findall(text)} + +def diff_defines(old: dict, new: dict) -> dict: + """Added / removed / moved (same name, new value) symbols between two header versions.""" + return { + "added": sorted(set(new) - set(old)), + "removed": sorted(set(old) - set(new)), + "moved": sorted((n, old[n], new[n]) for n in set(old) & set(new) if old[n] != new[n]), + } + +def changed_dcsin_ids(cmd_diff: dict) -> list: + """DCSIN_* symbols whose id would change — a ledger violation, never allowed.""" + return [m for m in cmd_diff["moved"] if m[0].startswith("DCSIN_")] + +def emit_change_report(cmd_diff: dict, out_diff: dict) -> str: + """Markdown for the refresh PR body: what a sketch built against the old headers loses.""" + removed = cmd_diff["removed"] + out_diff["removed"] + moved = out_diff["moved"] + added = cmd_diff["added"] + out_diff["added"] + lines = ["## Symbol changes", ""] + if not removed and not moved: + lines.append(f"Additions only ({len(added)} new symbols) — nothing a sketch uses was removed " + "or moved.") + return "\n".join(lines) + "\n" + lines += ["**Removed or moved symbols** — a sketch using one of these stops compiling (removed) " + "or silently reads the wrong address (moved). Decide whether this release needs a " + "major version bump before merging.", ""] + for name in removed: + lines.append(f"- removed `{name}`") + for name, old, new in moved: + lines.append(f"- moved `{name}`: `{old:#06x}` → `{new:#06x}`") + lines += ["", f"Added: {len(added)} new symbols."] + return "\n".join(lines) + "\n" + # ── Emission helpers ────────────────────────────────────────────────────────── def _c_str(val: Optional[str]) -> str: @@ -336,6 +380,8 @@ def main() -> None: help="Generation timestamp/label to embed; defaults to current UTC time") parser.add_argument("--source-label", metavar="TEXT", help="Source label to embed; defaults to the source filename") + parser.add_argument("--change-report", metavar="PATH", + help="Write the removed/moved/added symbol report (Markdown) here") parser.add_argument("--fetch-github", action="store_true", help="(not yet implemented) Fetch JSON from DCS-Skunkworks/dcs-bios release") args = parser.parse_args() @@ -371,6 +417,24 @@ def main() -> None: "GENERATOR_GAPS.md": emit_gaps(all_gaps, source_name, ts), } + def previous(filename: str) -> dict: + path = OUTPUT_DIR / filename + return parse_defines(path.read_text(encoding="utf-8")) if path.exists() else {} + + cmd_diff = diff_defines(previous("A4EC_CmdIds.h"), parse_defines(writes["A4EC_CmdIds.h"])) + out_diff = diff_defines(previous("A4EC_OutputIds.h"), parse_defines(writes["A4EC_OutputIds.h"])) + broken = changed_dcsin_ids(cmd_diff) + if broken: + for name, old, new in broken: + print(f"ERROR: {name} would change {old:#06x} -> {new:#06x}", file=sys.stderr) + sys.exit("ERROR: DCSIN_* ids are append-only (id_ledger.json) — an existing id must never " + "change. Restore the ledger from git; do not edit or re-seed it.") + + report = emit_change_report(cmd_diff, out_diff) + print(report.rstrip()) + if args.change_report: + Path(args.change_report).write_text(report, encoding="utf-8") + for filename, content in writes.items(): out_path = OUTPUT_DIR / filename out_path.write_text(content, encoding="utf-8") diff --git a/tools/gen_a4ec/tests/test_gen.py b/tools/gen_a4ec/tests/test_gen.py index 3c913f49..208f455b 100644 --- a/tools/gen_a4ec/tests/test_gen.py +++ b/tools/gen_a4ec/tests/test_gen.py @@ -352,3 +352,44 @@ def test_inputmap_emitted_in_cmdid_order(tmp_path): ordered_ids = [by_name[n] for n in rows] assert ordered_ids == sorted(ordered_ids) # rows ascend by cmdId assert rows[0] != "AAA_NEW_SWITCH" # not first despite alphabetical name + + +# ── Change report (#295) ────────────────────────────────────────────────────── + +def _header_defines(tmp_path, controls): + """{name: value} of the CmdIds + OutputIds headers the generator would emit for `controls`.""" + entries, _ = G.build_input_entries(controls, tmp_path / "ledger.json") + outs, _ = G.build_output_entries(controls) + return (G.parse_defines(G.emit_cmdids(entries, "fixture", "T")), + G.parse_defines(G.emit_outputids(outs, "fixture", "T"))) + + +def test_change_report_additions_only(tmp_path): + """A new control is reported as an addition, with nothing removed or moved.""" + controls = load_fixture() + old_cmd, old_out = _header_defines(tmp_path, controls) + controls["ZZZ_NEW_SWITCH"] = _switch_control() + new_cmd, new_out = _header_defines(tmp_path, controls) + + cmd_diff = G.diff_defines(old_cmd, new_cmd) + out_diff = G.diff_defines(old_out, new_out) + assert cmd_diff["added"] == ["DCSIN_ZZZ_NEW_SWITCH"] + assert not cmd_diff["removed"] and not cmd_diff["moved"] and not G.changed_dcsin_ids(cmd_diff) + assert "Additions only" in G.emit_change_report(cmd_diff, out_diff) + + +def test_change_report_lists_removed_and_moved(): + """Removed and moved output symbols are listed for the maintainer, with old/new addresses.""" + old = {"A_4E_C_GONE": 0x8400, "A_4E_C_MOVED": 0x8402, "A_4E_C_SAME": 0x8404} + new = {"A_4E_C_MOVED": 0x8410, "A_4E_C_SAME": 0x8404} + report = G.emit_change_report(G.diff_defines({}, {}), G.diff_defines(old, new)) + assert "removed `A_4E_C_GONE`" in report + assert "moved `A_4E_C_MOVED`: `0x8402` → `0x8410`" in report + assert "A_4E_C_SAME" not in report + + +def test_changed_dcsin_id_is_flagged(): + """A DCSIN_* id that changes (hand-edited or re-seeded ledger) is a violation; an address isn't.""" + old = {"DCSIN_ARM_MASTER": 0x8001, "A_4E_C_X": 0x8400} + new = {"DCSIN_ARM_MASTER": 0x8005, "A_4E_C_X": 0x8402} + assert G.changed_dcsin_ids(G.diff_defines(old, new)) == [("DCSIN_ARM_MASTER", 0x8001, 0x8005)] From 7714dffc4252e7fae0e760c74a5e952b0e68ddfb Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 13:12:43 -0400 Subject: [PATCH 16/21] ci(a4ec): make the change-report warning one explicit string (#295) --- tools/gen_a4ec/gen_a4ec.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/gen_a4ec/gen_a4ec.py b/tools/gen_a4ec/gen_a4ec.py index 8db4754f..8e934452 100644 --- a/tools/gen_a4ec/gen_a4ec.py +++ b/tools/gen_a4ec/gen_a4ec.py @@ -227,9 +227,10 @@ def emit_change_report(cmd_diff: dict, out_diff: dict) -> str: lines.append(f"Additions only ({len(added)} new symbols) — nothing a sketch uses was removed " "or moved.") return "\n".join(lines) + "\n" - lines += ["**Removed or moved symbols** — a sketch using one of these stops compiling (removed) " - "or silently reads the wrong address (moved). Decide whether this release needs a " - "major version bump before merging.", ""] + warning = ("**Removed or moved symbols** — a sketch using one of these stops compiling (removed) " + "or silently reads the wrong address (moved). Decide whether this release needs a " + "major version bump before merging.") + lines += [warning, ""] for name in removed: lines.append(f"- removed `{name}`") for name, old, new in moved: From d41506bb122bb372b1303e00b51bd788c1991aeb Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 13:17:21 -0400 Subject: [PATCH 17/21] feat(firmware)!: move the roster request to export address 0xFF00 (#295 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). --- Firmware/Libraries/NodeStatus/NodeStatus.h | 6 ++++- Firmware/Libraries/PanelBridge/PanelBridge.h | 2 +- Firmware/Libraries/PanelBridge/README.md | 2 +- Firmware/Libraries/SimGateway/SimGateway.h | 2 +- .../ScratchPad/FirmwarePlan/00-decisions.md | 22 +++++++++++++++++++ .../FirmwarePlan/04-dcs-bios-integration.md | 6 ++--- .../FirmwarePlan/06-panelbridge-api.md | 2 +- Firmware/ScratchPad/TechSpec/NodeStatus.md | 2 +- docs/architecture/firmware-tiers.md | 2 +- docs/architecture/sim-gateway.md | 2 +- 10 files changed, 37 insertions(+), 11 deletions(-) diff --git a/Firmware/Libraries/NodeStatus/NodeStatus.h b/Firmware/Libraries/NodeStatus/NodeStatus.h index b5fd66c0..e477b25b 100644 --- a/Firmware/Libraries/NodeStatus/NodeStatus.h +++ b/Firmware/Libraries/NodeStatus/NodeStatus.h @@ -34,7 +34,11 @@ // NODE_STATUS_PROTO_VERSION on any _NODE_STATUS wire change so the client's sync fails loudly. // Full 34-hex field decode: FirmwarePlan/04-dcs-bios-integration.md. #define NODE_STATUS_PROTO_VERSION 4 -#define NODE_STATUS_REQ_ADDR 0x86FE // host→device roster-request export address +// Host→device roster-request export address (#295, v4). No DCS-BIOS module exports here: every +// aircraft ends by 0xB2FB and MetadataEnd is 0xFFFE (checked against DCS-BIOS v0.11.8). The old +// 0x86FE is a live address for the AH-64D and the Mirage F1, so flying either answered every +// export of that control with a roster burst. +#define NODE_STATUS_REQ_ADDR 0xFF00 #define NODE_STATUS_MSG_NAME "_NODE_STATUS" #define NODE_STATUS_END_MSG_NAME "_NODE_STATUS_END" diff --git a/Firmware/Libraries/PanelBridge/PanelBridge.h b/Firmware/Libraries/PanelBridge/PanelBridge.h index 5902e373..6c676ffe 100644 --- a/Firmware/Libraries/PanelBridge/PanelBridge.h +++ b/Firmware/Libraries/PanelBridge/PanelBridge.h @@ -32,7 +32,7 @@ #include // Node-status reporting to the host (#86) — `_NODE_STATUS` / `_NODE_STATUS_END` / `_NODE_TELEM` and -// the `0x86FE` roster request. On by default (#295): the client depends on it. Compile it out with +// the `0xFF00` roster request. On by default (#295): the client depends on it. Compile it out with // `-DPANELBRIDGE_NODE_STATUS=0`; node tracking itself (liveness, SYNC_REQ, WARNING, callbacks) // never depends on it. Test with `#if PANELBRIDGE_NODE_STATUS`, never `#ifdef`. #ifndef PANELBRIDGE_NODE_STATUS diff --git a/Firmware/Libraries/PanelBridge/README.md b/Firmware/Libraries/PanelBridge/README.md index 2ad595df..c0818dee 100644 --- a/Firmware/Libraries/PanelBridge/README.md +++ b/Firmware/Libraries/PanelBridge/README.md @@ -92,7 +92,7 @@ health to OpenSkyhawk Client over DCS-BIOS — no bespoke sideband, SimGateway u alive/dead transition; a 3 s heartbeat timeout reports `present=00` for silent death). A request/boot reply is the full roster terminated by `_NODE_STATUS_END ` so the host can reconcile/prune. PanelBridge caches the last `HeartbeatPayload` for the health bytes. -- **Request:** the client writes a DCS-BIOS export to the reserved address `0x86FE` +- **Request:** the client writes a DCS-BIOS export to the reserved address `0xFF00` (`NODE_STATUS_REQ_ADDR`); a dedicated `ExportStreamListener` replies with the full roster. Reserved IDs live in `NodeStatus.h`. See `FirmwarePlan/04-dcs-bios-integration.md`. diff --git a/Firmware/Libraries/SimGateway/SimGateway.h b/Firmware/Libraries/SimGateway/SimGateway.h index e6178f7c..9a22594d 100644 --- a/Firmware/Libraries/SimGateway/SimGateway.h +++ b/Firmware/Libraries/SimGateway/SimGateway.h @@ -226,7 +226,7 @@ static constexpr uint32_t WATCHDOG_MS = 3000; * * @note Node-status reporting (#86) needs no handling here: PanelBridge's `_NODE_STATUS` DCS-BIOS * messages are ASCII (≤ 0x7F) forwarded by step 2, and the host's roster request is a - * DCS-BIOS export write (addr 0x86FE) forwarded by step 1. Both transit transparently. + * DCS-BIOS export write (addr NODE_STATUS_REQ_ADDR, 0xFF00) forwarded by step 1. Both transit transparently. * * @note Parser state persists across calls — frames split across iterations assemble correctly. */ diff --git a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md index e95e5088..002715db 100644 --- a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md +++ b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md @@ -587,3 +587,25 @@ ignore reserved bits. A shorter row means older firmware and is reported, not dr **Affects:** `02-can-protocol.md`, `03-uart-usb-hid-protocol.md`, `04-dcs-bios-integration.md`, `NodeStatus.h`. Client half: SkyHawkClient's `_NODE_STATUS` parser and alerts (client 1.0). + +--- + +## D23 — The roster request moves to export address 0xFF00 (#295) + +**Decision:** The client asks PanelBridge for the node roster by writing DCS-BIOS export address +`0xFF00` (`NODE_STATUS_REQ_ADDR`), not `0x86FE`. It ships with `_NODE_STATUS` v4, which the client +must adopt anyway. + +**Rationale:** +- *`0x86FE` was not reserved, only unused by the A-4E.* In DCS-BIOS v0.11.8 it is the AH-64D's + `PLT_VIDEO_FLIR_GAIN` and the Mirage F1's `P_VOR_01_G`. Flying either with the cockpit connected, + every export of that control looked like a roster request and drew a full roster burst. +- *`0xFF00` belongs to no module.* Every aircraft module ends by `0xB2FB`; DCS-BIOS's own + `MetadataEnd` sits at `0xFFFE`. Module blocks are allocated upward from low addresses, so the top + of the map is the safest place for a host-only address. +- *Now, not later.* The request address is part of the v1.0 host contract; moving it after the + freeze would be a major. + +**Affects:** `NodeStatus.h`, `04-dcs-bios-integration.md`, `06-panelbridge-api.md`, TechSpec +`NodeStatus`, PanelBridge README, `docs/architecture/sim-gateway.md`, `docs/architecture/firmware-tiers.md`. +Client half: SkyHawkClient's roster request (synced from `NodeStatus.h`). diff --git a/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md b/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md index 0067593d..db56aa16 100644 --- a/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md +++ b/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md @@ -30,11 +30,11 @@ forwards host→device; the response is ASCII it forwards device→host). PanelB | Reserved ID | Direction | Purpose | |-------------|-----------|---------| -| Export address `0x86FE` (`NODE_STATUS_REQ_ADDR`) | host→device | Client writes it (value ignored) to request the roster. Above every real A-4E-C output (~`0x8554`), so DCS never exports it. Excluded from the CAN broadcast in `handleDcsBiosExport()`. | +| Export address `0xFF00` (`NODE_STATUS_REQ_ADDR`, v4) | host→device | Client writes it (value ignored) to request the roster. No DCS-BIOS module exports it — every aircraft ends by `0xB2FB` and `MetadataEnd` is `0xFFFE` (DCS-BIOS v0.11.8) — so DCS never triggers it, whatever aircraft is flown. (v3 used `0x86FE`, a live AH-64D and Mirage F1 address, #295.) Outside the bridge's CAN-broadcast window, and excluded in `handleDcsBiosExport()` regardless. | | Command name `_NODE_STATUS` (`NODE_STATUS_MSG_NAME`) | device→host | One DCS-BIOS command message per node (the bridge's own node-0 row included). Leading underscore — no A-4E-C control collides; DCS-BIOS ignores it as an unknown control if a copy leaks. | | Command name `_NODE_STATUS_END` (`NODE_STATUS_END_MSG_NAME`) | device→host | Burst terminator for request/boot replies; argument = row count, node 0 included. Lets the client know a roster reply is complete and reconcile/prune. | -**Request** — client writes a DCS-BIOS export frame to `0x86FE`. PanelBridge's +**Request** — client writes a DCS-BIOS export frame to `0xFF00`. PanelBridge's `NodeStatusReqListener` (an `ExportStreamListener`) fires → emits the full roster. **Response** — `_NODE_STATUS ` where `` is **34 chars** (proto **v4**, #295; v3 was 28 chars @@ -125,7 +125,7 @@ Client side: OpenSkyhawk/SkyHawkClient#61. ### Compatibility — how the host lines grow (#295, D22) -`_NODE_STATUS`, `_NODE_STATUS_END`, `_NODE_TELEM` and the `0x86FE` roster request are frozen from +`_NODE_STATUS`, `_NODE_STATUS_END`, `_NODE_TELEM` and the `0xFF00` roster request are frozen from v1.0 like the CAN wire (`02-can-protocol.md` § Compatibility). Within a major: - **Writers only append.** A new `_NODE_STATUS` field goes at the end of the row; existing fields diff --git a/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md b/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md index 953f3d35..80fec4ec 100644 --- a/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md +++ b/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md @@ -66,7 +66,7 @@ populated in the `HB_n` handler) plus its last `HEALTH_n` fields (`dieTempC` + h - when a `READY_n` changes an alive node's cached firmware version (#295) — a reflash inside the heartbeat timeout, and - at boot, and in response to a host request — a DCS-BIOS export write to `NODE_STATUS_REQ_ADDR` - (`0x86FE`), handled by a dedicated `ExportStreamListener`, excluded from the CAN broadcast. A + (`0xFF00`; `0x86FE` before v4), handled by a dedicated `ExportStreamListener`, excluded from the CAN broadcast. A request/boot reply is the bridge's own node-0 row (its live heartbeat state + `CANProtocol::lastNodeHealth()`, the `HEALTH_0` it last sent), then the full roster, then `_NODE_STATUS_END ` (count includes node 0), so the host can tell the burst is complete diff --git a/Firmware/ScratchPad/TechSpec/NodeStatus.md b/Firmware/ScratchPad/TechSpec/NodeStatus.md index 035a52c8..b95eb48c 100644 --- a/Firmware/ScratchPad/TechSpec/NodeStatus.md +++ b/Firmware/ScratchPad/TechSpec/NodeStatus.md @@ -65,7 +65,7 @@ Firmware/Libraries/NodeStatus/ ```cpp #define NODE_STATUS_PROTO_VERSION 4 // bump on ANY _NODE_STATUS wire change (v3: nodeKind + node-0 row, #344; v4: firmware version, #295) -#define NODE_STATUS_REQ_ADDR 0x86FE +#define NODE_STATUS_REQ_ADDR 0xFF00 // v4 (#295): no DCS-BIOS module exports here; 0x86FE was AH-64D/MirageF1 #define NODE_STATUS_MSG_NAME "_NODE_STATUS" #define NODE_STATUS_END_MSG_NAME "_NODE_STATUS_END" #define NODE_TELEM_PROTO_VERSION 1 // bump on a _NODE_TELEM framing / existing-page layout change diff --git a/docs/architecture/firmware-tiers.md b/docs/architecture/firmware-tiers.md index 53332a9d..02d2bcb1 100644 --- a/docs/architecture/firmware-tiers.md +++ b/docs/architecture/firmware-tiers.md @@ -72,7 +72,7 @@ DCS output onto the CAN bus as `CTRL_BCAST` frames. In the other direction it co events (`EVT_n`) from the nodes and routes each one: DCS-BIOS inputs go out as ASCII commands over UART; HID inputs are wrapped in HID frames for SimGateway. It also tracks which nodes are alive (from their heartbeats) and reports that roster to the host over -DCS-BIOS itself (`_NODE_STATUS` messages; request address `0x86FE`): each node's health, board +DCS-BIOS itself (`_NODE_STATUS` messages; request address `0xFF00`): each node's health, board kind and firmware version, plus a row for the bridge itself. The client can show connected boards without any SimGateway change ([#86](https://github.com/OpenSkyHawk/OpenSkyhawk/issues/86)). diff --git a/docs/architecture/sim-gateway.md b/docs/architecture/sim-gateway.md index 025d3028..fedd4bbc 100644 --- a/docs/architecture/sim-gateway.md +++ b/docs/architecture/sim-gateway.md @@ -91,7 +91,7 @@ DCS-BIOS data, and health reporting keeps flowing while it is open. This "dumb relay" is also why **node-status reporting** ([#86](https://github.com/OpenSkyHawk/OpenSkyhawk/issues/86)) needs no SimGateway change: PanelBridge reports connected PanelGroup nodes to the host over DCS-BIOS itself — `_NODE_STATUS` ASCII command messages downstream, and a roster request as a -DCS-BIOS export write to reserved address `0x86FE` upstream. Both ride the existing streams and +DCS-BIOS export write to reserved address `0xFF00` upstream (an address no aircraft module uses). Both ride the existing streams and transit SimGateway transparently; PanelBridge owns the feature. ## controlId routing logic From c5b194006e031b3ca7b59d1c2d87c3f06d49a0d9 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 13:23:03 -0400 Subject: [PATCH 18/21] docs(firmware): reserve ID ranges for the future firmware updater (#295 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. --- Firmware/Libraries/CANProtocol/CANProtocol.h | 4 ++++ Firmware/Libraries/SimGateway/AxisCal.h | 3 +++ Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md | 6 ++++-- .../ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md | 4 ++++ docs/architecture/can-bus.md | 3 +++ 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Firmware/Libraries/CANProtocol/CANProtocol.h b/Firmware/Libraries/CANProtocol/CANProtocol.h index 1771ca47..e133cec3 100644 --- a/Firmware/Libraries/CANProtocol/CANProtocol.h +++ b/Firmware/Libraries/CANProtocol/CANProtocol.h @@ -176,6 +176,10 @@ constexpr uint32_t canIdEcho(uint8_t n) { return 0x300 + n; } /** @brief Boot-complete READY frame ID for node n. Range 0x401-0x43F. */ constexpr uint32_t canIdReady(uint8_t n) { return 0x400 + n; } +// Reserved, not yet assigned (#295): 0x740-0x7BF for a future boot/update frame family — +// BOOT_CMD_n = 0x740+n (bridge → node n), BOOT_RSP_n = 0x780+n (node n → bridge). The lowest +// priority on the bus, so update traffic never delays a control. Don't allocate it to anything else. + // ── controlId namespace ─────────────────────────────────────────────────────── // Payload controlIds live inside ControlPacket data bytes. They are intentionally // separate from the 11-bit CAN arbitration IDs above. diff --git a/Firmware/Libraries/SimGateway/AxisCal.h b/Firmware/Libraries/SimGateway/AxisCal.h index 687d1288..00a0407e 100644 --- a/Firmware/Libraries/SimGateway/AxisCal.h +++ b/Firmware/Libraries/SimGateway/AxisCal.h @@ -187,6 +187,8 @@ constexpr uint16_t CAL_MAX_PAYLOAD = 82; ///< CAL_DATA, the largest legal p constexpr uint16_t CAL_MAX_FRAME = CAL_ENVELOPE_BYTES + CAL_MAX_PAYLOAD; // 92 /// Message types. High bit set = device→client, so direction is readable in a capture. +/// Reserved for firmware update (#295), not yet assigned: 0x10-0x1F (client→device) and +/// 0x90-0x9F (device→client). Don't allocate them to anything else. enum CalType : uint8_t { CAL_T_HELLO = 0x01, CAL_T_GET_CAL = 0x02, @@ -208,6 +210,7 @@ enum CalType : uint8_t { }; /// NACK reasons. `detail` names the offending axis where one applies, else 0xFF. +/// Reserved for firmware update (#295), not yet assigned: 0x09-0x0E. enum CalNackReason : uint8_t { CAL_NACK_BAD_CRC = 0x01, CAL_NACK_BAD_LENGTH = 0x02, diff --git a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md index 8378c986..49d8ab79 100644 --- a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md +++ b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md @@ -107,6 +107,7 @@ constexpr uint32_t canIdReady(uint8_t n) { return 0x400 + n; } // 0x401–0x43F | `EVT_REL_n` | `0x500+n` | Node_n → PanelBridge | 8 | Relative encoder events (`RotaryEncoder` REL) — `ControlPacketPair`, signed `%+d` → `variable_step` (#147) | | `EVT_DIR_n` | `0x600+n` | Node_n → PanelBridge | 8 | Directional encoder events (`RotaryEncoder` DIR) — `ControlPacketPair`, `±1` → `INC`/`DEC` `fixed_step` (#147) | | `EVT_ACTION_n` | `0x700+n` | Node_n → PanelBridge | 8 | Action events (`ActionButton`) — `ControlPacketPair`, selector `0` → `TOGGLE` `action` (#116) | +| *reserved* | `0x740`–`0x7BF` | — | — | Future boot/update family (#295): `BOOT_CMD_n` = `0x740+n` (bridge → node n), `BOOT_RSP_n` = `0x780+n` (node n → bridge). Lowest priority on the bus, so update traffic never delays a control. Not assigned yet; nothing else may claim it. | --- @@ -390,8 +391,9 @@ From v1.0 the frame IDs and payloads are frozen: a change that breaks an existin - node-specific telemetry → a new `TelemPage` on `TELEM_n` (append-only, forwarded unread); - identity or anything else → `READY_n`'s reserved bytes (3–7), or a new frame family in a free ID range (`0x013`–`0x0FF`, `0x1C0`–`0x1FF`, `0x240`–`0x2FF`, `0x340`–`0x3FF`, - `0x440`–`0x4FF`, `0x540`–`0x5FF`, `0x640`–`0x6FF`, `0x740`–`0x7FF`). Its position in the map - sets its arbitration priority (lower ID wins), which is frozen with it. + `0x440`–`0x4FF`, `0x540`–`0x5FF`, `0x640`–`0x6FF`, `0x7C0`–`0x7FF`; `0x740`–`0x7BF` is + reserved for boot/update, see the frame table). Its position in the map sets its arbitration + priority (lower ID wins), which is frozen with it. - `TELEM_n` never carries generic health, and `HEALTH_n` never carries node-specific telemetry. `TEST_SEQ` / `ECHO_n` are covered only as far as other boards rely on them: the two IDs, and every diff --git a/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md b/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md index 9631525a..05b814d2 100644 --- a/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md +++ b/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md @@ -260,6 +260,10 @@ High bit of `TYPE` set = device→client, so direction is readable at a glance i | `0x08` | `STREAM_SELECT` | `0x87` | `DIAG` | | `0x09` | `GET_DIAG` | | | +**Reserved for firmware update (#295), not yet assigned:** types `0x10`–`0x1F` (client → device) and +`0x90`–`0x9F` (device → client), and NACK reasons `0x09`–`0x0E`. Nothing else may claim them; when +assigned they follow § Compatibility (new types are a minor change). + These are numeric type codes, not strings. Contrast `_NODE_STATUS`, which *is* text because it must imitate a DCS-BIOS message riding the ASCII stream; this channel is quiet during exchanges, so binary is free. diff --git a/docs/architecture/can-bus.md b/docs/architecture/can-bus.md index 05d26fa7..e89cc26c 100644 --- a/docs/architecture/can-bus.md +++ b/docs/architecture/can-bus.md @@ -39,8 +39,11 @@ helper functions — **never hard-code these values**, always use the helper. | `EVT_REL_n` | `0x500 + n` | `canIdEvtRel(n)` | Node *n* → PanelBridge | 8 | | `EVT_DIR_n` | `0x600 + n` | `canIdEvtDir(n)` | Node *n* → PanelBridge | 8 | | `EVT_ACTION_n` | `0x700 + n` | `canIdEvtAction(n)` | Node *n* → PanelBridge | 8 | +| *reserved* | `0x740`–`0x7BF` | — | — | — | - `CTRL_BCAST` carries DCS-BIOS output state from PanelBridge to every node. +- `0x740`–`0x7BF` is reserved for a future firmware-update frame family, at the lowest priority + on the bus so update traffic never delays a control. - `EVT_n` carries input events from a node back up to PanelBridge. - `HB_n` is a 500 ms heartbeat with CAN health (TEC/REC, flags, uptime). Every node — PanelBridge included, as `HB_0` — sends `HB_n` and `HEALTH_n` from one shared From dd53acf24c8dcc0d46180f1df7677218f8a40287 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 13:30:11 -0400 Subject: [PATCH 19/21] feat(firmware): PanelBridge forwards the whole DCS-BIOS export stream (#295 A25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Libraries/PanelBridge/PanelBridge.cpp | 11 ++++++-- Firmware/Libraries/PanelBridge/PanelBridge.h | 3 ++- Firmware/Libraries/PanelBridge/README.md | 5 ++-- .../ScratchPad/FirmwarePlan/00-decisions.md | 26 +++++++++++++++++++ .../FirmwarePlan/02-can-protocol.md | 2 +- .../FirmwarePlan/06-panelbridge-api.md | 4 +-- .../FirmwarePlan/10-implementation-plan.md | 2 +- Firmware/ScratchPad/TechSpec/PanelBridge.md | 7 +++-- .../output_submission/output_submission.cpp | 16 +++++++----- docs/architecture/can-bus.md | 3 ++- 10 files changed, 60 insertions(+), 19 deletions(-) diff --git a/Firmware/Libraries/PanelBridge/PanelBridge.cpp b/Firmware/Libraries/PanelBridge/PanelBridge.cpp index fd785e54..93b12fe1 100644 --- a/Firmware/Libraries/PanelBridge/PanelBridge.cpp +++ b/Firmware/Libraries/PanelBridge/PanelBridge.cpp @@ -171,6 +171,7 @@ static void sendHidFrame(uint16_t controlId, uint16_t value) { // Internal helper called by BridgeExportListener — separated for testability. void handleDcsBiosExport(uint16_t address, uint16_t value) { + if (address == 0x0000) return; // the ControlPacket empty-slot sentinel; never carried #if PANELBRIDGE_NODE_STATUS // Reserved: the host's node-status request — handled by NodeStatusReqListener, // never broadcast onto CAN. @@ -516,12 +517,18 @@ static void onModelTimeChange(char* value) { _lastModelTimeSec = sec; } -// DCS-BIOS export listener — intercepts 0x8000–0x86FF and batches to CTRL_BCAST. +// DCS-BIOS export listener — forwards the whole export stream to CTRL_BCAST (#295): every address +// of whatever aircraft is flying, plus CommonData and the metadata, so the nodes decide what they +// use. It starts at 0x0002 because a ControlPacket's controlId 0x0000 is the empty-slot sentinel — +// the one address CAN can't carry (the first two characters of MetadataStart's _ACFT_NAME). // ExportStreamListener::firstExportStreamListener (Protocol.cpp) is zero-initialized // before any constructors run, so file-scope registration is safe. +static constexpr uint16_t EXPORT_FIRST_ADDR = 0x0002; +static constexpr uint16_t EXPORT_LAST_ADDR = 0xFFFE; + class BridgeExportListener : public DcsBios::ExportStreamListener { public: - BridgeExportListener() : DcsBios::ExportStreamListener(0x8000, 0x86FF) {} + BridgeExportListener() : DcsBios::ExportStreamListener(EXPORT_FIRST_ADDR, EXPORT_LAST_ADDR) {} void onDcsBiosWrite(unsigned int address, unsigned int data) override { handleDcsBiosExport((uint16_t)address, (uint16_t)data); } diff --git a/Firmware/Libraries/PanelBridge/PanelBridge.h b/Firmware/Libraries/PanelBridge/PanelBridge.h index 6c676ffe..71488fe4 100644 --- a/Firmware/Libraries/PanelBridge/PanelBridge.h +++ b/Firmware/Libraries/PanelBridge/PanelBridge.h @@ -143,7 +143,8 @@ namespace PanelBridge { * Equivalent to the BridgeExportListener receiving address/value from DCS-BIOS. * Calls CANProtocol::sendBatched(CAN_ID_CTRL_BCAST, ...) with the given pair. * - * @param address DCS-BIOS export address (0x8000–0x86FF for cockpit outputs). + * @param address DCS-BIOS export address — any address the export stream carries (#295); + * 0x0000 is dropped (the CAN empty-slot sentinel). * @param value 16-bit export value. */ void testHandleExport(uint16_t address, uint16_t value); diff --git a/Firmware/Libraries/PanelBridge/README.md b/Firmware/Libraries/PanelBridge/README.md index c0818dee..061bb2b2 100644 --- a/Firmware/Libraries/PanelBridge/README.md +++ b/Firmware/Libraries/PanelBridge/README.md @@ -18,8 +18,9 @@ PanelBridge (STM32F103CBT6) ← this library └── PanelGroup #N ``` -**DCS-BIOS exports (sim → cockpit):** `BridgeExportListener` listens to the DCS-BIOS -stream on addresses `0x8000–0x86FF` and batches each value as a `ControlPacket` to +**DCS-BIOS exports (sim → cockpit):** `BridgeExportListener` forwards the whole DCS-BIOS export +stream — every address of whatever aircraft is flying, plus CommonData and the metadata, except +`0x0000` (the CAN empty-slot sentinel) — and batches each value as a `ControlPacket` to `CANProtocol::sendBatched(CAN_ID_CTRL_BCAST)`. PanelGroup nodes subscribe to these frames to drive LEDs, gauges, and other outputs. diff --git a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md index 002715db..13d19fd2 100644 --- a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md +++ b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md @@ -609,3 +609,29 @@ must adopt anyway. **Affects:** `NodeStatus.h`, `04-dcs-bios-integration.md`, `06-panelbridge-api.md`, TechSpec `NodeStatus`, PanelBridge README, `docs/architecture/sim-gateway.md`, `docs/architecture/firmware-tiers.md`. Client half: SkyHawkClient's roster request (synced from `NodeStatus.h`). + +--- + +## D24 — PanelBridge forwards the whole DCS-BIOS export stream (#295) + +**Decision:** `BridgeExportListener` covers `0x0002`–`0xFFFE`: every export address of whatever +aircraft is flying, plus CommonData and the DCS-BIOS metadata, goes onto CAN as `CTRL_BCAST`. The +nodes decide what they use — each output matches its own address. Only `0x0000` is skipped: it is +the `ControlPacket` empty-slot sentinel, so CAN can't carry it (it holds the first two characters of +`_ACFT_NAME`). + +**Rationale:** +- *The old window was the input-ID range by accident.* It was `0x8000`–`0x86FF` — the `DCSIN_*` + range — not the A-4E's export block (`0x8400`–`0x8555`), so it already passed parts of the AH-64D, + MB-339 and Mirage F1. Filtering by aircraft would tie the bridge to one aircraft; the multi-aircraft + direction wants the cockpit, not the bridge, to choose. +- *Nodes already ignore what they don't use.* `onControlPacket` hands every packet to every output, + and each keeps only its own address. + +**Cost — confirm on the #290 soak:** `CTRL_BCAST` (the highest-priority frame) now also carries +CommonData (position, heading, model time) and the per-frame update counters, so bus load rises. +The soak's "no false offline / zero TX drops" checks are the guard; `CTRL_BCAST` keeps the newest +state when its queue is full. + +**Affects:** `PanelBridge.cpp`, `02-can-protocol.md`, `06-panelbridge-api.md`, `10-implementation-plan.md`, +TechSpec `PanelBridge`, PanelBridge README, `docs/architecture/can-bus.md`. diff --git a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md index 49d8ab79..83f4298b 100644 --- a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md +++ b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md @@ -94,7 +94,7 @@ constexpr uint32_t canIdReady(uint8_t n) { return 0x400 + n; } // 0x401–0x43F | Frame | CAN ID | Direction | DLC | Purpose | |-------|--------|-----------|-----|---------| -| `CTRL_BCAST` | `0x010` | PanelBridge → All | 8 | Broadcast `ControlPacketPair` to all panels | +| `CTRL_BCAST` | `0x010` | PanelBridge → All | 8 | Broadcast `ControlPacketPair` to all panels — the whole DCS-BIOS export stream (`controlId` = export address, `0x0002`–`0xFFFE`; #295, D24) | | `TEST_SEQ` | `0x011` | PanelBridge → All | 8 | CAN throughput / RTT test — 8-byte payload echoed back unchanged by sub-nodes | | `SYNC_REQ` | `0x012` | PanelBridge → All | 0 | Request all nodes to re-poll inputs and emit EVTs. Sent on cold boot, on `model_time` backward, in response to `READY`, and when a node transitions from dead/unseen to alive. | | `HB_0` | `0x100` | PanelBridge → All | 8 | Master liveness beacon every 500 ms; every other node runs a master-loss watchdog on it (#93) | diff --git a/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md b/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md index 80fec4ec..3da23f1f 100644 --- a/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md +++ b/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md @@ -92,8 +92,8 @@ The listener overrides the DCS-BIOS Arduino library callback: void onDcsBiosWrite(unsigned int address, unsigned int data); ``` -For each DCS-BIOS export address in the `0x8000`-`0x86FF` range, the listener builds a -`ControlPacket` and calls: +For every DCS-BIOS export address the stream carries (`0x0002`–`0xFFFE`: whatever aircraft is +flying, CommonData and the metadata; #295, D24), the listener builds a `ControlPacket` and calls: ```cpp CANProtocol::sendBatched(CAN_ID_CTRL_BCAST, packet); diff --git a/Firmware/ScratchPad/FirmwarePlan/10-implementation-plan.md b/Firmware/ScratchPad/FirmwarePlan/10-implementation-plan.md index 2a73c21b..56b97293 100644 --- a/Firmware/ScratchPad/FirmwarePlan/10-implementation-plan.md +++ b/Firmware/ScratchPad/FirmwarePlan/10-implementation-plan.md @@ -77,7 +77,7 @@ - [x] Integrate DCS-BIOS library: configure `DCSBIOS_DEFAULT_SERIAL` to use `Serial` (UART2 PA2/PA3). - [x] Add `ExportStreamListener` subclass that auto-registers on `PanelBridge::setup()` and - submits all 0x8000–0x86FF writes to CANProtocol's `CTRL_BCAST` batching path. + submits every export write (0x0002–0xFFFE since #295) to CANProtocol's `CTRL_BCAST` batching path. - [x] Remove the UART argument from `PanelBridge::setup()` — DCS-BIOS library owns `Serial` directly via `DCSBIOS_DEFAULT_SERIAL`. Update `PanelBridge.h` to match. - [x] Replace `MAX_NODES=2` with dynamic node tracking; hardware CAN filter remains pass-all; diff --git a/Firmware/ScratchPad/TechSpec/PanelBridge.md b/Firmware/ScratchPad/TechSpec/PanelBridge.md index 1715ddec..e1085a1d 100644 --- a/Firmware/ScratchPad/TechSpec/PanelBridge.md +++ b/Firmware/ScratchPad/TechSpec/PanelBridge.md @@ -416,13 +416,16 @@ PanelBridge owns an internal DCS-BIOS listener/adapter. The adapter is private t class BridgeExportListener : public DcsBios::ExportStreamListener { public: BridgeExportListener() - : DcsBios::ExportStreamListener(0x8000, 0x86FF) {} + : DcsBios::ExportStreamListener(EXPORT_FIRST_ADDR, EXPORT_LAST_ADDR) {} // 0x0002, 0xFFFE void onDcsBiosWrite(unsigned int address, unsigned int data) override; }; ``` -For each DCS-BIOS export update in the cockpit output address range (`0x8000`-`0x86FF`): +For each DCS-BIOS export update, at any address the stream carries (#295, D24) — the whole stream +is forwarded, whatever aircraft is flying; the nodes decide what they use. Address `0x0000` is +skipped (the listener starts at `0x0002`, and `handleDcsBiosExport()` drops it): it is the +`ControlPacket` empty-slot sentinel, so CAN can't carry it. 1. Build `ControlPacket{ address, value }`. 2. Call `CANProtocol::sendBatched(CAN_ID_CTRL_BCAST, packet)`. diff --git a/Firmware/Tests/PanelBridge/tests/output_submission/output_submission.cpp b/Firmware/Tests/PanelBridge/tests/output_submission/output_submission.cpp index e3cb302b..fffcfcf7 100644 --- a/Firmware/Tests/PanelBridge/tests/output_submission/output_submission.cpp +++ b/Firmware/Tests/PanelBridge/tests/output_submission/output_submission.cpp @@ -1,12 +1,12 @@ // PanelBridge — output_submission test // // Purpose: verify that handleDcsBiosExport() submits ControlPackets to -// CANProtocol::sendBatched(CAN_ID_CTRL_BCAST) for in-range DCS-BIOS addresses. +// CANProtocol::sendBatched(CAN_ID_CTRL_BCAST) for DCS-BIOS export addresses. The bridge forwards +// the whole export stream (#295) — any aircraft, CommonData, metadata — except 0x0000, the +// ControlPacket empty-slot sentinel. // // NOTE: testHandleExport() calls handleDcsBiosExport() directly, bypassing -// BridgeExportListener. The listener's registration and address filter (0x8000-0x86FF) -// are NOT exercised by this test. Do not add out-of-range address cases here — -// they would be submitted to sendBatched regardless of address value. +// BridgeExportListener, so the listener's registration (0x0002-0xFFFE) is NOT exercised here. // // Hardware: single STM32F103CB board. // - DiagSerial (USART1 PA9/PA10) at 115200 baud @@ -37,9 +37,11 @@ struct ExportCase { }; static const ExportCase CASES[] = { - { 0x8001, 0x0001 }, // first valid cockpit output address - { 0x8040, 0x4000 }, // arbitrary mid-range address - { 0x86FF, 0xFFFF }, // last valid DCS output address — boundary check + { 0x0402, 0x0001 }, // CommonData — forwarded like any aircraft output + { 0x8400, 0x4000 }, // A-4E-C (RPM) + { 0x8000, 0x1234 }, // AH-64D's block — forwarded whatever aircraft is flying + { 0xFFFE, 0xFFFF }, // MetadataEnd — the last address the stream carries + { 0x0000, 0x0042 }, // the empty-slot sentinel — dropped, nothing submitted }; static const uint8_t CASE_COUNT = sizeof(CASES) / sizeof(CASES[0]); diff --git a/docs/architecture/can-bus.md b/docs/architecture/can-bus.md index e89cc26c..e0b5663b 100644 --- a/docs/architecture/can-bus.md +++ b/docs/architecture/can-bus.md @@ -41,7 +41,8 @@ helper functions — **never hard-code these values**, always use the helper. | `EVT_ACTION_n` | `0x700 + n` | `canIdEvtAction(n)` | Node *n* → PanelBridge | 8 | | *reserved* | `0x740`–`0x7BF` | — | — | — | -- `CTRL_BCAST` carries DCS-BIOS output state from PanelBridge to every node. +- `CTRL_BCAST` carries DCS-BIOS output state from PanelBridge to every node — the whole export + stream, whatever aircraft is flying; each output picks out its own address. - `0x740`–`0x7BF` is reserved for a future firmware-update frame family, at the lowest priority on the bus so update traffic never delays a control. - `EVT_n` carries input events from a node back up to PanelBridge. From 2221fbef9c40267c29a9a0783eddf56204a603f5 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 13:46:19 -0400 Subject: [PATCH 20/21] ci(a4ec): warn when a DCS-BIOS aircraft uses our roster address or message 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. --- .github/workflows/a4ec-refresh.yml | 12 ++++- Firmware/Libraries/NodeStatus/NodeStatus.h | 3 +- tools/gen_a4ec/README.md | 7 +++ tools/gen_a4ec/gen_a4ec.py | 58 ++++++++++++++++++++++ tools/gen_a4ec/tests/test_gen.py | 35 +++++++++++++ 5 files changed, 113 insertions(+), 2 deletions(-) diff --git a/.github/workflows/a4ec-refresh.yml b/.github/workflows/a4ec-refresh.yml index f6bd4739..a84ead7d 100644 --- a/.github/workflows/a4ec-refresh.yml +++ b/.github/workflows/a4ec-refresh.yml @@ -82,6 +82,8 @@ jobs: fi cp "$json_path" tools/gen_a4ec/data/A-4E-C.jsonp + # Every aircraft's JSON, for the reserved-address warning (#295). + modules_dir="$(find "$extract_dir" -type d -path '*/doc/json' -print -quit)" { echo "tag=$tag" @@ -89,18 +91,26 @@ jobs: echo "published_at=$published_at" echo "source_ref=$source_ref" echo "json_path=$json_path" + echo "modules_dir=$modules_dir" } >> "$GITHUB_OUTPUT" echo "Using DCS-BIOS $tag from $source_ref" echo "A-4E-C metadata: $json_path" - name: Regenerate A4EC metadata + env: + MODULES_DIR: ${{ steps.release.outputs.modules_dir }} run: | + # The reserved-address warning (#295) needs every aircraft's JSON; skip it if the + # archive has none rather than fail the refresh. + extra=() + if [ -n "$MODULES_DIR" ]; then extra=(--modules-dir "$MODULES_DIR"); fi python tools/gen_a4ec/gen_a4ec.py \ --source tools/gen_a4ec/data/A-4E-C.jsonp \ --source-label "DCS-BIOS ${{ steps.release.outputs.tag }} (${{ steps.release.outputs.source_ref }})" \ --timestamp "${{ steps.release.outputs.published_at }}" \ - --change-report "$RUNNER_TEMP/a4ec-changes.md" + --change-report "$RUNNER_TEMP/a4ec-changes.md" \ + "${extra[@]}" - name: Test A4EC generator run: | diff --git a/Firmware/Libraries/NodeStatus/NodeStatus.h b/Firmware/Libraries/NodeStatus/NodeStatus.h index e477b25b..6392f074 100644 --- a/Firmware/Libraries/NodeStatus/NodeStatus.h +++ b/Firmware/Libraries/NodeStatus/NodeStatus.h @@ -37,7 +37,8 @@ // Host→device roster-request export address (#295, v4). No DCS-BIOS module exports here: every // aircraft ends by 0xB2FB and MetadataEnd is 0xFFFE (checked against DCS-BIOS v0.11.8). The old // 0x86FE is a live address for the AH-64D and the Mirage F1, so flying either answered every -// export of that control with a roster burst. +// export of that control with a roster burst. The A4EC refresh warns if a new DCS-BIOS release ever +// puts an aircraft here (tools/gen_a4ec, --modules-dir). #define NODE_STATUS_REQ_ADDR 0xFF00 #define NODE_STATUS_MSG_NAME "_NODE_STATUS" #define NODE_STATUS_END_MSG_NAME "_NODE_STATUS_END" diff --git a/tools/gen_a4ec/README.md b/tools/gen_a4ec/README.md index ab341449..8c80cfbf 100644 --- a/tools/gen_a4ec/README.md +++ b/tools/gen_a4ec/README.md @@ -50,6 +50,13 @@ are the normal case. A removed symbol breaks the build of a sketch that uses it, a moved `A_4E_C_*` address silently breaks a lamp or gauge built against the old one, so either is the maintainer's call on the version bump. +With `--modules-dir /doc/json` (the refresh workflow passes it), the report also +checks **every** aircraft in the release against our reserved DCS-BIOS items — the +roster-request address `NODE_STATUS_REQ_ADDR` and the `_NODE_*` message names, read from +`NodeStatus.h` — and warns if any aircraft now uses one (#295). The old `0x86FE` was moved +because the AH-64D and Mirage F1 use it. A warning doesn't stop the refresh; it means the +address should move before that DCS-BIOS version reaches the cockpit. + ## Source resolution (priority order) 1. `--source ` — explicit override, any platform, `.json` or `.jsonp` diff --git a/tools/gen_a4ec/gen_a4ec.py b/tools/gen_a4ec/gen_a4ec.py index 8e934452..6eefd4b5 100644 --- a/tools/gen_a4ec/gen_a4ec.py +++ b/tools/gen_a4ec/gen_a4ec.py @@ -33,6 +33,7 @@ OUTPUT_DIR = REPO_ROOT / "Firmware" / "Libraries" / "A4EC" COMMITTED_SNAPSHOT = SCRIPT_DIR / "data" / "A-4E-C.jsonp" LEDGER_PATH = SCRIPT_DIR / "id_ledger.json" +NODE_STATUS_H = REPO_ROOT / "Firmware" / "Libraries" / "NodeStatus" / "NodeStatus.h" DCSIN_BASE = 0x8001 DCSIN_MAX = 0x86FF @@ -238,6 +239,55 @@ def emit_change_report(cmd_diff: dict, out_diff: dict) -> str: lines += ["", f"Added: {len(added)} new symbols."] return "\n".join(lines) + "\n" +# ── Reserved DCS-BIOS address / names check (#295) ──────────────────────────── +# The client asks PanelBridge for the node roster by writing NODE_STATUS_REQ_ADDR into the DCS-BIOS +# export stream, and PanelBridge answers with _NODE_* command messages. Both only work while no +# DCS-BIOS aircraft uses that address or those names — 0x86FE was moved off for exactly that (the +# AH-64D and Mirage F1 use it). A refresh checks every module in the release and warns if one now +# does. It warns rather than fails: the refresh itself is still fine, the address may need moving. + +def read_reserved(node_status_h: Path = NODE_STATUS_H) -> tuple: + """(roster-request address, {message names}) as NodeStatus.h defines them — the single source.""" + text = node_status_h.read_text(encoding="utf-8") + addr = int(re.search(r"#define\s+NODE_STATUS_REQ_ADDR\s+(0x[0-9A-Fa-f]+)", text).group(1), 16) + names = set(re.findall(r'#define\s+NODE_\w+_MSG_NAME\s+"(\w+)"', text)) + return addr, names + +def find_reserved_conflicts(modules_dir: Path, addr: int, names: set) -> list: + """Every DCS-BIOS module output that covers `addr`, and every control named like ours.""" + conflicts = [] + for path in sorted(Path(modules_dir).glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (ValueError, UnicodeDecodeError): + continue + for category in data.values(): + if not isinstance(category, dict): # AircraftAliases.json is a different shape + continue + for ident, ctrl in category.items(): + if ident in names: + conflicts.append(f"`{path.stem}` has a control named `{ident}`") + for out in ctrl.get("outputs", []): + start = out["address"] + length = out.get("max_length") if out.get("type") == "string" else None + end = start + ((length + 1) // 2 * 2 if length else 2) - 1 + if start <= addr <= end: + conflicts.append(f"`{path.stem}` uses `{addr:#06x}` (`{ident}`)") + return conflicts + +def emit_reserved_report(conflicts: list, addr: int) -> str: + lines = ["## Reserved address check", ""] + if not conflicts: + lines.append(f"No aircraft in this release uses the roster-request address `{addr:#06x}` or " + "our `_NODE_*` message names.") + else: + lines += [f"**Warning:** the roster request (`NODE_STATUS_REQ_ADDR` = `{addr:#06x}`) or our " + "`_NODE_*` names now clash with DCS-BIOS. Flying that aircraft would make the " + "bridge send the roster on every update of that control. Move the address " + "(`NodeStatus.h`, then client sync) before this reaches the cockpit.", ""] + lines += [f"- {c}" for c in conflicts] + return "\n".join(lines) + "\n" + # ── Emission helpers ────────────────────────────────────────────────────────── def _c_str(val: Optional[str]) -> str: @@ -381,6 +431,8 @@ def main() -> None: help="Generation timestamp/label to embed; defaults to current UTC time") parser.add_argument("--source-label", metavar="TEXT", help="Source label to embed; defaults to the source filename") + parser.add_argument("--modules-dir", metavar="DIR", + help="DCS-BIOS release doc/json folder; warn if any module uses our reserved address/names") parser.add_argument("--change-report", metavar="PATH", help="Write the removed/moved/added symbol report (Markdown) here") parser.add_argument("--fetch-github", action="store_true", @@ -432,6 +484,12 @@ def previous(filename: str) -> dict: "change. Restore the ledger from git; do not edit or re-seed it.") report = emit_change_report(cmd_diff, out_diff) + if args.modules_dir: + req_addr, req_names = read_reserved() + conflicts = find_reserved_conflicts(Path(args.modules_dir), req_addr, req_names) + for c in conflicts: + print(f"WARNING: reserved address/name clash: {c}", file=sys.stderr) + report += "\n" + emit_reserved_report(conflicts, req_addr) print(report.rstrip()) if args.change_report: Path(args.change_report).write_text(report, encoding="utf-8") diff --git a/tools/gen_a4ec/tests/test_gen.py b/tools/gen_a4ec/tests/test_gen.py index 208f455b..ba089f14 100644 --- a/tools/gen_a4ec/tests/test_gen.py +++ b/tools/gen_a4ec/tests/test_gen.py @@ -393,3 +393,38 @@ def test_changed_dcsin_id_is_flagged(): old = {"DCSIN_ARM_MASTER": 0x8001, "A_4E_C_X": 0x8400} new = {"DCSIN_ARM_MASTER": 0x8005, "A_4E_C_X": 0x8402} assert G.changed_dcsin_ids(G.diff_defines(old, new)) == [("DCSIN_ARM_MASTER", 0x8001, 0x8005)] + + +# ── Reserved address / names check (#295) ───────────────────────────────────── + +def test_read_reserved_from_node_status_h(): + """The roster address and _NODE_* names come from NodeStatus.h, the single source.""" + addr, names = G.read_reserved() + assert addr == 0xFF00 + assert names == {"_NODE_STATUS", "_NODE_STATUS_END", "_NODE_TELEM"} + + +def _module(tmp_path, name, controls): + (tmp_path / f"{name}.json").write_text(json.dumps({"Cat": controls}), encoding="utf-8") + + +def test_reserved_check_clear(tmp_path): + _module(tmp_path, "Plane", {"KNOB": {"outputs": [{"address": 0x8400, "type": "integer"}]}}) + (tmp_path / "AircraftAliases.json").write_text(json.dumps({"": ["x"]}), encoding="utf-8") + assert G.find_reserved_conflicts(tmp_path, 0xFF00, {"_NODE_STATUS"}) == [] + assert "No aircraft" in G.emit_reserved_report([], 0xFF00) + + +def test_reserved_check_warns_on_address_and_name(tmp_path): + _module(tmp_path, "NewJet", { + "GAIN": {"outputs": [{"address": 0xFF00, "type": "integer"}]}, + "_NODE_STATUS": {"outputs": []}, + }) + _module(tmp_path, "Display", { # a string starting below 0xFF00 that runs over it + "TEXT": {"outputs": [{"address": 0xFEFC, "type": "string", "max_length": 8}]}, + }) + conflicts = G.find_reserved_conflicts(tmp_path, 0xFF00, {"_NODE_STATUS"}) + assert any("NewJet" in c and "0xff00" in c for c in conflicts) + assert any("_NODE_STATUS" in c for c in conflicts) + assert any("Display" in c for c in conflicts) + assert "**Warning:**" in G.emit_reserved_report(conflicts, 0xFF00) From 2d7d941fbc93d699677017ce9e6b04d80c56c12f Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sun, 25 Oct 2026 14:33:01 -0400 Subject: [PATCH 21/21] feat(firmware): reserve room in DIAG for future link-health counters (#295 A27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Firmware/Libraries/SimGateway/AxisCal.h | 11 ++++++++--- Firmware/Libraries/SimGateway/SimGateway.cpp | 7 ++++--- .../FirmwarePlan/03-uart-usb-hid-protocol.md | 3 ++- Firmware/ScratchPad/TechSpec/SimGateway.md | 2 +- tools/simgw_bench/simgw_bench.py | 2 ++ 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Firmware/Libraries/SimGateway/AxisCal.h b/Firmware/Libraries/SimGateway/AxisCal.h index 00a0407e..967a8714 100644 --- a/Firmware/Libraries/SimGateway/AxisCal.h +++ b/Firmware/Libraries/SimGateway/AxisCal.h @@ -310,13 +310,17 @@ enum CalRawField : uint8_t { CAL_RAW_CAL = 3, // u16: the same reading through axisCalApply() }; -/// `DIAG` payload (47 bytes, device→client): link-health counters and event times (#355). +/// `DIAG` payload (64 bytes, device→client): link-health counters and event times (#355). /// /// Times are millis() since this boot; 0 means "never". Counters are u16 and saturate at /// 0xFFFF rather than wrap, so a large number never reads as a small one. The four UART error /// counters count status ticks on which that PL011 RSR bit was set — the core's UART IRQ owns /// the data register, so per-character counts are not available. The three u8 fields at the /// end survive a warm reboot (watchdog scratch registers) and reset on power-on. +/// +/// Bytes 47–63 are reserved (#295): transmit 0, ignore on receive. A calibration frame's `LEN` is +/// fixed per type, so `DIAG` can't grow in place — a new counter fills reserved bytes instead, a +/// minor change that older clients simply don't read. enum CalDiagField : uint8_t { CAL_DIAG_UPTIME_MS = 0, // u32: millis() when the reply was built CAL_DIAG_FIRST_UART_ERR_MS = 4, // u32: first PL011 error this boot @@ -338,10 +342,11 @@ enum CalDiagField : uint8_t { CAL_DIAG_RECOVERY_REBOOTS = 44, // u8: recovery ladder reboots (survives reboot) CAL_DIAG_WATCHDOG_RESETS = 45, // u8: hardware watchdog resets (survives reboot) CAL_DIAG_RESET_REASON = 46, // u8: CalDiagResetReason for this boot + CAL_DIAG_RESERVED = 47, // 17 bytes: reserved, transmit 0 (#295) }; -/// `DIAG` payload length. -constexpr uint8_t CAL_DIAG_BYTES = 47; +/// `DIAG` payload length — 47 bytes in use, the rest reserved. +constexpr uint8_t CAL_DIAG_BYTES = 64; /// Why this boot happened, as reported in `DIAG`. enum CalDiagResetReason : uint8_t { diff --git a/Firmware/Libraries/SimGateway/SimGateway.cpp b/Firmware/Libraries/SimGateway/SimGateway.cpp index 6c090a2d..3ce9b04b 100644 --- a/Firmware/Libraries/SimGateway/SimGateway.cpp +++ b/Firmware/Libraries/SimGateway/SimGateway.cpp @@ -481,9 +481,10 @@ void _calSendCalData(uint8_t seq) { _calSend(OpenSkyhawk::CAL_T_CAL_DATA, seq, p, sizeof(p)); } -// Fixed 47 bytes in CalDiagField order — the client reads it with one typed view (#355). +// Fixed CAL_DIAG_BYTES in CalDiagField order — the client reads it with one typed view (#355). +// Zero-initialised, so the reserved tail goes out as 0 (#295). void _calSendDiag(uint8_t seq) { - uint8_t p[OpenSkyhawk::CAL_DIAG_BYTES]; + uint8_t p[OpenSkyhawk::CAL_DIAG_BYTES] = {}; _put32(p + OpenSkyhawk::CAL_DIAG_UPTIME_MS, millis()); _put32(p + OpenSkyhawk::CAL_DIAG_FIRST_UART_ERR_MS, _diag.firstUartErrMs); _put32(p + OpenSkyhawk::CAL_DIAG_LAST_UART_ERR_MS, _diag.lastUartErrMs); @@ -506,7 +507,7 @@ void _calSendDiag(uint8_t seq) { p[OpenSkyhawk::CAL_DIAG_RESET_REASON] = _diag.resetReason; _calSend(OpenSkyhawk::CAL_T_DIAG, seq, p, sizeof(p)); } -static_assert(OpenSkyhawk::CAL_DIAG_RESET_REASON + 1 == OpenSkyhawk::CAL_DIAG_BYTES, +static_assert(OpenSkyhawk::CAL_DIAG_RESET_REASON + 1 == OpenSkyhawk::CAL_DIAG_RESERVED, "CalDiagField grew: extend _calSendDiag to fill it"); // Write a candidate blob to flash and, only if that succeeds, make it the live calibration. diff --git a/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md b/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md index 05b814d2..e04eef5d 100644 --- a/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md +++ b/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md @@ -292,7 +292,7 @@ the mapping — but that is data in the message, not the message's identity. | `NACK` | 3 | `type`:u8, `reason`:u8, `detail`:u8 (axis index, or `0xFF`) | | `RAW` | 5 | `idx`:u8, `raw`:u16, `cal`:u16 | | `GET_DIAG` | 0 | — | -| `DIAG` | 47 | link-health counters and event times — see below | +| `DIAG` | 64 | link-health counters and event times — see below; bytes 47–63 reserved | Field offsets are named in `AxisCal.h` — one enum per type with a payload (`CalHelloAckField`, `CalDataField` + `CalDataRecField` and `CAL_DATA_REC_BYTES`, `CalSessionOpenField`, @@ -354,6 +354,7 @@ Times are `millis()` since this boot, `0` = never. Counters are u16 and saturate | 44 | `RECOVERY_REBOOTS` | u8 | recovery-ladder reboots — survives a warm reboot | | 45 | `WATCHDOG_RESETS` | u8 | hardware-watchdog resets — survives a warm reboot | | 46 | `RESET_REASON` | u8 | `CalDiagResetReason`: 0 power-on, 1 watchdog, 2 recovery reboot | +| 47–63 | `RESERVED` | — | transmit 0, ignore on receive (#295). `LEN` is fixed per type, so a new counter fills these bytes — a minor change — instead of growing `DIAG` | The UART error counters count status ticks on which the bit was set, not characters: the core's UART IRQ owns the data register. The two "survives" counters live in watchdog scratch registers and diff --git a/Firmware/ScratchPad/TechSpec/SimGateway.md b/Firmware/ScratchPad/TechSpec/SimGateway.md index 37a750ba..97234db2 100644 --- a/Firmware/ScratchPad/TechSpec/SimGateway.md +++ b/Firmware/ScratchPad/TechSpec/SimGateway.md @@ -380,7 +380,7 @@ The calibration wire protocol (FirmwarePlan 03) is declared below the blob API: payload-offset enum per type that carries a payload: `CalHelloAckField`, `CalDataField` + `CalDataRecField` (`CAL_DATA_REC_BYTES` = 10), `CalSessionOpenField`, `CalSessionAckField`, `CalStreamSelectField`, `CalCommitField`, `CalResetField`, `CalAckField`, `CalNackField`, -`CalRawField`, `CalDiagField` (`CAL_DIAG_BYTES` = 47, plus `CalDiagResetReason`). Offsets are into +`CalRawField`, `CalDiagField` (`CAL_DIAG_BYTES` = 64 — 47 in use, `CAL_DIAG_RESERVED` 47–63 reserved for new counters, #295 — plus `CalDiagResetReason`). Offsets are into the payload, little-endian; `SimGateway.cpp` reads and writes every payload through them, and a `static_assert` ties `CAL_DATA`'s layout to `CAL_MAX_PAYLOAD`. diff --git a/tools/simgw_bench/simgw_bench.py b/tools/simgw_bench/simgw_bench.py index c5df765b..9158983b 100755 --- a/tools/simgw_bench/simgw_bench.py +++ b/tools/simgw_bench/simgw_bench.py @@ -113,6 +113,8 @@ def print_diag(pay: bytes) -> None: nxt = fields[idx + 1][1][0] if idx + 1 < len(fields) else DIAG_BYTES val = int.from_bytes(pay[off:nxt], "little") label = name.removeprefix("CAL_DIAG_") + if label == "RESERVED": + continue if label == "RESET_REASON": shown = RESET_REASONS.get(val, f"unknown ({val})").removeprefix("CAL_DIAG_RESET_") elif label.endswith("_MS"):