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/.github/workflows/a4ec-refresh.yml b/.github/workflows/a4ec-refresh.yml index 9e532cae..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,17 +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 }}" + --timestamp "${{ steps.release.outputs.published_at }}" \ + --change-report "$RUNNER_TEMP/a4ec-changes.md" \ + "${extra[@]}" - name: Test A4EC generator run: | @@ -197,6 +208,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/.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..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 = @@ -26,7 +23,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/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/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/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 1c52e190..e133cec3 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 */ @@ -105,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 @@ -161,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. @@ -191,6 +210,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 @@ -379,6 +403,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/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..6392f074 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 */ @@ -33,9 +32,14 @@ // ── _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 -#define NODE_STATUS_REQ_ADDR 0x86FE // host→device roster-request export address +// Full 34-hex field decode: FirmwarePlan/04-dcs-bios-integration.md. +#define NODE_STATUS_PROTO_VERSION 4 +// 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. 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" @@ -47,8 +51,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). @@ -56,8 +60,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 @@ -71,6 +76,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..93b12fe1 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 @@ -38,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 @@ -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 }; @@ -54,15 +60,17 @@ 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; // 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 @@ -158,7 +171,8 @@ 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 (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. if (address == NODE_STATUS_REQ_ADDR) return; @@ -318,7 +332,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; @@ -332,7 +346,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(); @@ -342,7 +356,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; @@ -366,7 +380,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 @@ -438,7 +452,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); } +#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 + // 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; } @@ -462,7 +492,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 } @@ -487,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); } @@ -501,7 +537,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. @@ -535,7 +571,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 } @@ -612,7 +648,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 ef1a37f5..71488fe4 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 */ @@ -32,6 +31,14 @@ #include +// Node-status reporting to the host (#86) — `_NODE_STATUS` / `_NODE_STATUS_END` / `_NODE_TELEM` and +// 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 +#define PANELBRIDGE_NODE_STATUS 1 +#endif + namespace PanelBridge { /** @brief Callback type for node liveness events. Parameter is PanelGroup node ID (1–63). */ @@ -136,12 +143,13 @@ 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); -#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..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. @@ -84,7 +85,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` = @@ -92,7 +93,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/PanelBridge/library.json b/Firmware/Libraries/PanelBridge/library.json index b69bac43..32e90c5e 100644 --- a/Firmware/Libraries/PanelBridge/library.json +++ b/Firmware/Libraries/PanelBridge/library.json @@ -16,8 +16,9 @@ "dependencies": { "STM32Board": "*", "CANProtocol": "*", + "FirmwareVersion": "*", "NodeStatus": "*", "A4EC": "*", - "DCS-BIOS": "*" + "DCS-BIOS": "^0.3.10" } } 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.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 6e0f89e4..01300d13 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 */ @@ -65,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/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.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 930bf3df..213a2ff9 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 */ @@ -13,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 6a99a7a4..99f35804 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 */ @@ -13,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. * @@ -61,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/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.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 a9abdeea..24f209dd 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 */ @@ -247,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/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/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/Libraries/STM32Board/STM32Board.h b/Firmware/Libraries/STM32Board/STM32Board.h index 5ea6bfcc..dc80ba61 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 */ @@ -70,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(); @@ -95,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); @@ -108,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); @@ -127,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(); @@ -165,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(); @@ -174,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(); @@ -191,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/Firmware/Libraries/SimGateway/AxisCal.h b/Firmware/Libraries/SimGateway/AxisCal.h index aae77290..967a8714 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 */ @@ -188,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, @@ -209,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, @@ -235,7 +237,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 }; @@ -308,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 @@ -336,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 745d860a..3ce9b04b 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)); } @@ -480,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); @@ -505,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/Libraries/SimGateway/SimGateway.h b/Firmware/Libraries/SimGateway/SimGateway.h index 7c09f8bf..9a22594d 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. * @@ -233,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/Libraries/SimGateway/library.json b/Firmware/Libraries/SimGateway/library.json index 412d285c..9747a984 100644 --- a/Firmware/Libraries/SimGateway/library.json +++ b/Firmware/Libraries/SimGateway/library.json @@ -15,6 +15,7 @@ "raspberrypi" ], "dependencies": { - "adafruit/Adafruit TinyUSB Library": ">=3.0.0" + "FirmwareVersion": "*", + "adafruit/Adafruit TinyUSB Library": "^3.7.7" } } diff --git a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md index d06ac634..13d19fd2 100644 --- a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md +++ b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md @@ -530,3 +530,108 @@ 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. + +--- + +## 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). + +--- + +## 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`). + +--- + +## 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 9758ce08..83f4298b 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 @@ -82,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) | @@ -91,13 +103,11 @@ 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) | - -> **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. +| *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. | --- @@ -155,7 +165,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 +213,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,10 +288,38 @@ 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`. -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. @@ -315,7 +354,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 | |---------|-------|----------| @@ -327,10 +372,32 @@ 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 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`, `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 +node mirroring the 8 bytes unchanged. The payload contents are bridge-internal (above). --- diff --git a/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md b/Firmware/ScratchPad/FirmwarePlan/03-uart-usb-hid-protocol.md index ba6893d6..e04eef5d 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. @@ -274,7 +278,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 | @@ -288,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`, @@ -350,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 @@ -360,6 +365,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 +423,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 | @@ -478,7 +497,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/04-dcs-bios-integration.md b/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md index 0b06119b..db56aa16 100644 --- a/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md +++ b/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md @@ -30,18 +30,20 @@ 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 **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 @@ -86,12 +95,12 @@ 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) 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: @@ -109,11 +118,33 @@ 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. +### Compatibility — how the host lines grow (#295, D22) + +`_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 + 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 diff --git a/Firmware/ScratchPad/FirmwarePlan/05-panelgroup-api.md b/Firmware/ScratchPad/FirmwarePlan/05-panelgroup-api.md index 044b857a..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 @@ -348,7 +360,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 +368,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 +402,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 +419,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/FirmwarePlan/06-panelbridge-api.md b/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md index 9d318dd6..3da23f1f 100644 --- a/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md +++ b/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md @@ -51,19 +51,22 @@ 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 + -`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 + (`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 @@ -89,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/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/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/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/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..b95eb48c 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,18 +64,19 @@ 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_REQ_ADDR 0x86FE +#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 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 #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..e1085a1d 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 `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`). 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 @@ -410,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/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/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogInput.md b/Firmware/ScratchPad/TechSpec/PanelGroup/Inputs/AnalogInput.md index 546e79a2..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: @@ -201,7 +207,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/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/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/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/PanelGroup/PinRef.md b/Firmware/ScratchPad/TechSpec/PanelGroup/PinRef.md index 890fa727..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 @@ -405,17 +410,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/ScratchPad/TechSpec/PowerMonitor.md b/Firmware/ScratchPad/TechSpec/PowerMonitor.md index c88dc38e..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. --- @@ -160,7 +163,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/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/Firmware/ScratchPad/TechSpec/SimGateway.md b/Firmware/ScratchPad/TechSpec/SimGateway.md index c375cbc0..97234db2 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 @@ -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/Firmware/Templates/PanelBridge/platformio.ini b/Firmware/Templates/PanelBridge/platformio.ini index bc9e019b..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 @@ -27,7 +24,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/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/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/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/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/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/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/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/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/PanelBridge/platformio.ini b/Firmware/Tests/PanelBridge/platformio.ini index f76e82ec..7e84fb92 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 @@ -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/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/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/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/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 diff --git a/Firmware/Tests/StepperMotor/platformio.ini b/Firmware/Tests/StepperMotor/platformio.ini index 08923457..3615828f 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 @@ -55,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/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..e0b5663b 100644 --- a/docs/architecture/can-bus.md +++ b/docs/architecture/can-bus.md @@ -35,12 +35,16 @@ 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 | +| *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. - `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 @@ -98,6 +102,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 @@ -120,7 +127,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..02d2bcb1 100644 --- a/docs/architecture/firmware-tiers.md +++ b/docs/architecture/firmware-tiers.md @@ -71,9 +71,9 @@ 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 -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 +alive (from their heartbeats) and reports that roster to the host over +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 diff --git a/docs/firmware/build-flags.md b/docs/firmware/build-flags.md index 116f482e..75c3746e 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 @@ -60,10 +61,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` | **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` | | `PINREF_DEBUG` | off | never set | `PinRef.cpp:8` + ~9 more | | `SHIFTBUS_SCK` | `PB3` | never set | `ShiftBus.cpp:13` | @@ -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_*`) @@ -214,18 +209,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:104` | +| `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` | +| `NEEDLEGAUGE_TEST` | NeedleGauge | `Outputs/NeedleGauge/NeedleGauge.h:77` | +| `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) | +| `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 `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/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 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 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. 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" } ] diff --git a/tools/gen_a4ec/README.md b/tools/gen_a4ec/README.md index 2591e495..8c80cfbf 100644 --- a/tools/gen_a4ec/README.md +++ b/tools/gen_a4ec/README.md @@ -40,7 +40,22 @@ 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. + +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) diff --git a/tools/gen_a4ec/gen_a4ec.py b/tools/gen_a4ec/gen_a4ec.py index 545a7ca5..6eefd4b5 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 @@ -32,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 @@ -193,6 +195,99 @@ 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" + 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: + lines.append(f"- moved `{name}`: `{old:#06x}` → `{new:#06x}`") + 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: @@ -336,6 +431,10 @@ 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", help="(not yet implemented) Fetch JSON from DCS-Skunkworks/dcs-bios release") args = parser.parse_args() @@ -371,6 +470,30 @@ 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) + 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") + 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..ba089f14 100644 --- a/tools/gen_a4ec/tests/test_gen.py +++ b/tools/gen_a4ec/tests/test_gen.py @@ -352,3 +352,79 @@ 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)] + + +# ── 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) 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"):