Skip to content

feat(firmware): nodes announce their board kind in HEALTH_n and _NODE_STATUS v3 (#344) - #345

Merged
mottihoresh merged 3 commits into
mainfrom
feat/344-node-kind
Oct 16, 2026
Merged

mottihoresh merged 3 commits into
mainfrom
feat/344-node-kind

Conversation

@mottihoresh

@mottihoresh mottihoresh commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #344.

Every board now says what it is, and the host sees every board's kind, the bridge's included. Today a NODE_ID is no signal (PDUs take any ID) and silence proves nothing, so the client could never let a panel group be adopted. This also adds runtime detection of two boards flashed with the same NODE_ID.

What changed

Wire (CAN, HEALTH_n)

  • NodeHealthPayload byte 5 (was rsvd[0], always 0) is now nodeKind, a new append-only NodeKind in NodeStatus.h: 00 unknown, 01 panel group, 02 PDU, 03 bridge. Bytes 6–7 stay reserved. A static_assert pins the struct at 8 bytes.
  • Pre-Nodes announce their board kind in HEALTH_n and _NODE_STATUS v3 #344 firmware already sends 0 there, so it reads as unknown.

Who sets the kind

  • CANProtocol::serviceNodeStatus(NodeKind kind, uint16_t rxCount = 0): kind is required, so a node library that forgets it won't compile. Sketches never call the service, so the library is the role:
    • PanelGroup passes PANEL_GROUP;
    • PduNode (PowerMonitor) passes PDU;
    • PanelBridge passes BRIDGE.
  • The kind is never derived from NODE_ID.
  • makeNodeHealthPayload(nodeId, kind, dieTempC, fault) packs it.

Host (_NODE_STATUS v3)

  • NODE_STATUS_PROTO_VERSION 2 → 3. Each row is 28 hex, with nodeKind appended last, so a v2 client fails loudly.
  • Every roster reply now starts with a node-0 row for the bridge itself: its live heartbeat state plus the HEALTH_0 it last sent (new CANProtocol::lastNodeHealth()), kind 03. nodeId range is now 0–63.
  • _NODE_STATUS_END <count> now counts the node-0 row, so 1 means the bridge alone.
  • New live delta: when an alive node's cached kind changes. The alive delta leaves on the node's first HB with kind 00; the kind delta follows ~1 s later with its first HEALTH. Without it, the kind would wait for the client's next roster poll.
  • A cached kind is kept when a node times out and comes back.
  • Kind reads 00 until a node's first HEALTH (same rule as dieTempC 80).

Duplicate NODE_ID detection

  • start() adds the node's own HB_n to its filter, on every node. bxCAN never receives its own frames, so a frame there means another board has this ID.
  • The node then reports the new NodeFaultCode::DUPLICATE_NODE_ID (0x09) in HEALTH_n: DEGRADED plus faultId, and the DiagSerial edge log.
  • Priority: it is checked before aggregateFaults(), so no FaultSource can mask it. Registry order is construction order, which can't guarantee a priority across translation units.
  • Auto-clears 3 s (DUP_NODE_TIMEOUT_MS) after the last foreign frame. A live duplicate re-raises within one 500 ms heartbeat.
  • In startLoopback() the node's own heartbeats come back. The service counts the heartbeats it sent and drain() discounts that many, so the existing loopback tests don't false-trip.
  • No bridge-specific code: PanelBridge gets the same check.

Decisions (signed off 2026-10-16)

  • 0x03 = bridge, reported by PanelBridge in HEALTH_0. SimGateway is not on the bus and has no kind.
  • Push a _NODE_STATUS delta on a kind change.
  • Keep the cached kind when a node revives.
  • DUPLICATE_NODE_ID takes top priority and auto-clears after 3 s.

Recorded as D19 in FirmwarePlan/00-decisions.md.

Spec + docs (same PR)

  • FirmwarePlan:
    • 02-can-protocol.md: HEALTH layout, nodeKind, the duplicate check, the bridge RX note;
    • 04-dcs-bios-integration.md: v3 field list, node-0 row, END count, kind delta;
    • 06-panelbridge-api.md;
    • 10-implementation-plan.md;
    • D19 in 00-decisions.md.
  • TechSpec: NodeStatus, CANProtocol, PanelBridge (new node-status section), PanelGroup/PanelGroup, PowerMonitor.
  • Published docs: docs/architecture/can-bus.md, docs/architecture/firmware-tiers.md, docs/firmware/node-id.md (duplicate NODE_ID warning; an ID is not a type), and docs/firmware/build-flags.md (text, plus line cites re-pointed).
  • tools/docs-drift/check_doc_refs.py: clean. The PlatformIO impact map is up to date.

Tests

  • Tests/CANProtocol:
    • health_payload: kind in byte 5, bytes 6–7 zero, UNKNOWN packs 0;
    • health_flags: updated to the new signature;
    • new test_duplicate_node_id (loopback): own heartbeats never trip the check; extra own-ID HBs raise DUPLICATE_NODE_ID plus DEGRADED, hold while they flow, and clear about 3 s after they stop;
    • node_status_bus: the summary now prints fault / kind, and it doubles as the two-board duplicate bench.
  • Tests/PanelBridge/node_status, v3 expectations:
    • node 1 HEALTH before alive (kind 01 in the alive delta);
    • node 2 alive with kind 00, then its first HEALTH pushes a 02 delta;
    • the roster opens with the node-0 row (kind 03), and _NODE_STATUS_END 3.

Compiled locally (all pass, 73 envs): Tests/CANProtocol (19), Tests/PanelBridge (14), Tests/PanelGroup (8), Tests/PowerMonitor (5), Tests/DrumDisplay (18), Tests/NodeStatus, Templates/PanelBridge, Templates/PanelGroup, Panels/PDU_Left|Center|Right, Examples/E2E_DCS_Test/{PanelBridge,PanelGroup,PanelGroupInputs}. Not yet run on hardware.

Bench (hardware, by the maintainer)

Rig: three dev boards.

  • SimGateway (RP2040) on USB to the PC, and over UART to the bridge. It is unchanged by this PR, so no reflash.
  • PanelBridge board (STM32F103CB) and panel-group board (STM32F103C8), on one CAN bus.
  • One ST-Link, which moves between the two STM32 boards once.
  • No DiagSerial is wired, so every check below reads _NODE_STATUS on the SimGateway USB port. A duplicate fault drives no LED, by design.

Clone chips (CPUTAPID). Templates/PanelBridge and Examples/E2E_DCS_Test/PanelGroup keep the set CPUTAPID 0 override commented out. If an upload fails with an unexpected tap ID / "tap not found", uncomment their upload_flags block locally and don't commit it. Tests/CANProtocol already sets it.

Watcher.

  • What it does: it sends one roster request, then prints every _NODE_STATUS / _NODE_STATUS_END line for N seconds, with a timestamp.
  • Before running it: close the client so the port is free, and fill in the SimGateway port.
  • Rows decode as nodeId present flags uptime rxCount esr dieTempC hFlags faultMask faultId nodeKind. The last pair is the kind; the pair before it is faultId. The expected rows below add spaces between fields for readability; real lines have none.
python3 - /dev/cu.usbmodemXXXX 10 <<'EOF'
import serial, sys, time
s = serial.Serial(sys.argv[1], 250000, timeout=0.2)
s.write(bytes([0x55, 0x55, 0x55, 0x55, 0xFE, 0x86, 0x02, 0x00, 0x00, 0x00]))  # roster request: export write to 0x86FE
t0 = time.time()
while time.time() - t0 < float(sys.argv[2]):
    line = s.readline().decode(errors="replace").strip()
    if line.startswith("_NODE_STATUS"): print(f"{time.time() - t0:5.1f}s  {line}")
EOF

A. Programmer on the PanelBridge board

  1. Flash the bridge:
    ~/.platformio/penv/bin/pio run -d Firmware/Templates/PanelBridge -t upload
  2. Bridge row, kind 00 before its first HEALTH.
    • Hold the panel-group board unpowered.
    • Start the watcher with 30, and press reset on the bridge while it runs.
    • Expect the boot seed after the reset: _NODE_STATUS 00 01 … ending …80 00 00 00 00, then _NODE_STATUS_END 1. That is the node-0 row with dieTempC unseen and kind 00.
  3. Bridge row, kind 03. Run the watcher with 5. Expect the node-0 row ending …00 00 00 03 with a real dieTempC, then _NODE_STATUS_END 1.

B. Move the programmer to the panel-group board (the one swap)

  1. Duplicate NODE_ID, seen on the host. With two boards the duplicate has to land on node 0, the bridge's own ID.
    • Flash this board with the bus test that keeps transmitting on HB_0 (node 0) every 50 ms:
      ~/.platformio/penv/bin/pio run -d Firmware/Tests/CANProtocol -e test_dual_bidir_node0 -t upload
    • With it running on the bus, run the watcher with 5. Expect the node-0 row with hFlags 02 (DEGRADED) and faultId 09 (DUPLICATE_NODE_ID), ending …02 00 09 03. The kind stays 03.
    • Power this board off and wait 5 s, then run the watcher with 5. The node-0 row is back to …00 00 00 03.
  2. Flash the panel group (NODE_ID 1), still with this board on the bus:
    ~/.platformio/penv/bin/pio run -d Firmware/Examples/E2E_DCS_Test/PanelGroup -t upload
  3. Panel group kind. Start the watcher with 20, then reset the panel-group board. Expect two live deltas about 1 s apart:
    • _NODE_STATUS 01 01 … ending …00 (alive, kind not seen yet);
    • then the same row ending …01 (panel group).
  4. Roster. Run the watcher with 5. Expect the node-0 row …03, node 1 …01, and _NODE_STATUS_END 2.
  5. Removal keeps the kind. Start the watcher with 20, power the panel group off, and power it back on after about 8 s. Expect:
    • about 3 s after power-off: _NODE_STATUS 01 00 … ending …01 (removed, kind kept);
    • after power-on: the alive delta 01 01 … already ending …01, and no second kind delta.

C. Later

  • PDU (week of 2026-10-19): flash Panels/PDU_Left (NODE_ID 3) and put it on the bus. Watcher with 20, then reset the PDU: expect the alive delta 03 01 …00, then …02 about 1 s later.
  • Duplicate on a CAN node (1–63): this needs a third STM32 on the bus. Step 4 covers the shared code path (the own-ID check, then HEALTH, then _NODE_STATUS) on node 0.
  • Loopback unit tests (test_duplicate_node_id, test_health_payload) report only on DiagSerial. They are compile-checked here and can run whenever a USB-UART is on a board.

Bench results (2026-10-16, firmware dfe06ee)

Rig: the bridge is a clone (DPIDR 0x2ba01477, needs CPUTAPID 0; flashed through OpenOCD with the override, platformio.ini untouched). The panel group runs E2E_DCS_Test/PanelGroup. Read through the SimGateway CDC.

Step Result Captured
2. Boot seed ✅ 0001000000000000008000000000 + _NODE_STATUS_END 1
3. Bridge row kind ✅ 0001000006000000692D00000003
4. Duplicate NODE_ID on node 0 ✅ 0001000003000000002E02000903 while test_dual_bidir_node0 sent HB_0; back to …00000003 ~4.5 s after it stopped (3 s timeout + 1 s HEALTH cadence)
6. First sighting 00 → 01 ✅ 01010000280000001E8000000000, then 01010000280000001E2402000101
7. Roster ✅ node 0 …03, node 1 …01, _NODE_STATUS_END 2
8. Removal/revival ✅ …0100…01 on timeout, …0101…01 on return, no second kind delta

Not part of this PR's checks:

Still open: the PDU (when the board arrives), and a duplicate on a node 1–63, which needs a third board.

After merge

The client resync (PIN bump, npm run sync, v3 parser, adopt whitelist 01/02, DUPLICATE_NODE_ID label) happens in the SkyHawkClient Cockpit work (SkyHawkClient#63 / #77), per #344.

…_STATUS v3 (#344)

HEALTH_n byte 5 carries a NodeKind (unknown / panel group / PDU / bridge)
set by each node library's role via a required serviceNodeStatus() argument,
never from NODE_ID. PanelBridge forwards it in _NODE_STATUS v3 (28 hex),
adds its own node-0 row to every roster reply, and pushes a delta when an
alive node's kind changes.

Every node also filters its own HB_n; a frame on it means another board has
the same NODE_ID and raises DUPLICATE_NODE_ID ahead of every FaultSource,
clearing 3 s after the last foreign frame.

Spec synced: FirmwarePlan 02/04/06/10 + D19, TechSpec, docs.
@mottihoresh mottihoresh added this to the Firmware v0.2.0 milestone Oct 16, 2026
@mottihoresh mottihoresh added enhancement New feature or request firmware Firmware implementation work labels Oct 16, 2026
@mottihoresh
mottihoresh merged commit d29433a into main Oct 16, 2026
12 checks passed
@mottihoresh
mottihoresh deleted the feat/344-node-kind branch October 16, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request firmware Firmware implementation work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nodes announce their board kind in HEALTH_n and _NODE_STATUS v3

1 participant