Skip to content

Nodes announce their board kind in HEALTH_n and _NODE_STATUS v3 #344

Closed

Description

@mottihoresh

Why

The client can't tell what kind of board a NODE_ID is. Today it has two weak signals:

  • NODE_IDs. These don't work: PDUs can take additional IDs or entirely different ones, so the ID says nothing about the board.
  • PDU telemetry (_NODE_TELEM). This proves a node is a PDU. But a panel group never sends any, so "no telemetry yet" is not proof of anything.

So the client's Adopt dialog (SkyHawkClient#77, PR SkyHawkClient#87) blocks adopting a new node until its kind is known. Until this ticket lands, only PDUs can be adopted. A new panel group shows up but stays locked, which blocks the rest of the client's v0.2.0 Cockpit work.

What

Every board announces its own board kind in its HEALTH_n frame, and the host sees every board's kind in _NODE_STATUS. That includes the bridge board itself (decided 2026-10-16: all boards report, for consistency).

  • Wire (CAN): NodeHealthPayload byte 5 (today rsvd[0], sent as 0) becomes nodeKind, a new append-only NodeKind enum in NodeStatus.h. Suggested values:

    • 0x00 unknown: old firmware, which already sends 0, so it is backward-safe;
    • 0x01 panel group;
    • 0x02 PDU;
    • 0x03 bridge: the SimGateway + PanelBridge board. It is one board with two MCUs (RP2040 SimGateway on USB, STM32 PanelBridge on CAN). The PanelBridge MCU reports it. The SimGateway is not on the bus and has no kind of its own;
    • append more as new node types appear.

    The remaining bytes 6–7 stay reserved.

  • Who sets it: the board's own firmware, not the ID. For example, CANProtocol::serviceNodeStatus() takes the kind from the node's role: a PanelGroup sketch → panel group, the PDU / PowerMonitor sketch → PDU. One default per library/role, so a sketch can't forget it, with an override only where a role is ambiguous.

  • The bridge reports itself: _NODE_STATUS today covers nodes 1–63 only. Add a row for node 0, the bridge's own heartbeat state and its own HEALTH_0, with kind 03, in every roster reply. The spec's "nodeId 1–63" rule changes to 0–63.

  • The bridge stays on node 0. Node 0 is the bus master's address: every node watches HB_0 for master loss. That doesn't change. A second bridge has no use case and is out of scope.

  • Duplicate NODE_ID detection (added 2026-10-16): two boards flashed with the same NODE_ID today show as one blended node. Uptime and rxCount jump between the two boards, health and faults come from whichever spoke last, and collisions on identical CAN IDs drive error frames, error-passive and possibly bus-off. Inputs mostly still work, so it hides as a "flaky board". Nothing detects it today.

    • A node never receives its own transmissions (no loopback). So each node adds its OWN canIdHb(NODE_ID) to its RX filter list. If a frame arrives on it, another board has its ID.
    • On that, it raises a new append-only NodeFaultCode DUPLICATE_NODE_ID through the existing FaultSource / aggregateFaults() path: DEGRADED + faultId in HEALTH_n, logged on DiagSerial. Choose its priority against other faults, so a duplicate isn't masked by a lesser fault.
    • Latching vs clearing (e.g. clear after N s without a foreign HB): decide in the plan.
    • The PanelBridge (node 0) gets the same check for free, but don't build anything bridge-specific.
  • Host (_NODE_STATUS): add the field to the cached HEALTH fields, giving 28 hex chars. Bump NODE_STATUS_PROTO_VERSION 2 → 3, so a client on v2 fails loudly instead of misreading.

  • Not before HEALTH is seen: until the node's first HEALTH frame arrives, the kind reads 00, the same rule as dieTempC = 80.

Done when

  • NodeKind enum + NodeHealthPayload.nodeKind (byte 5); makeNodeHealthPayload fills it; every production sketch reports the right kind
  • PanelBridge caches it and forwards it in _NODE_STATUS v3 (28 hex), including its own node-0 row (kind 03). The _NODE_STATUS_END count rule is updated to match.
  • Unit tests: payload packing, _NODE_STATUS v3 encoding (kind present / 00 before the first HEALTH frame)
  • Duplicate NODE_ID: the own HB ID is in the RX filter; a received own-ID HB raises DUPLICATE_NODE_ID (faultId + DEGRADED in HEALTH). Unit-tested; bench = two boards flashed with the same NODE_ID, then the fault shows in _NODE_STATUS. Benched on node 0 (two boards); a duplicate between two nodes in 1–63 needs a third board, so it moved to the Firmware v0.2.0 QA list.
  • Spec synced in the same PR: Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md (HEALTH layout), 04-dcs-bios-integration.md (v3 field list), TechSpec, docs/firmware/ + docs/architecture/; tools/docs-drift/check_doc_refs.py clean
  • Bench: PanelBridge + one panel group (+ the PDU once it arrives, week of 2026-10-19) report the right kind on the host. Bridge + panel group PASSED 2026-10-16; the PDU check moved to the Firmware v0.2.0 QA list.
  • Client resync, after this merges. It is done in the SkyHawkClient Cockpit work (SkyHawkClient#63 / docs: weekly docs-drift review playbook + PR-time reminder #77), not a separate ticket:
    • bump PIN.commit in scripts/sync-a4ec.ts to the merge commit;
    • run npm run sync, which regenerates the NodeStatus.h contract (NodeKind enum, NODE_STATUS_PROTO_VERSION 3) and must pass the CI freshness gate;
    • update the _NODE_STATUS parser to v3 (28 hex), so a v2 bridge fails loudly;
    • use nodeKind as the top source in the kind resolver, so Adopt unlocks for panel groups.
  • Bench after the resync: PanelBridge on v3 + the client on the new pin. The panel group reads "panel group" and can be adopted. A v2 bridge with the new client gives the proto-mismatch error, not a misread.

Outcome

Merged in PR #345 (d29433ab), together with the review follow-ups 814656e0 (node_telem expectation) and ca1691a3 (TechSpec wording). Decisions are recorded as D19 in FirmwarePlan/00-decisions.md:

  • serviceNodeStatus(NodeKind kind, …): kind is required and passed by each node library.
  • A _NODE_STATUS delta is pushed when an alive node's kind changes.
  • A cached kind is kept when a node revives.
  • DUPLICATE_NODE_ID (0x09) is checked before aggregateFaults() and auto-clears 3 s after the last foreign frame.

Bench, 2026-10-16. Rig: a clone PanelBridge (needs CPUTAPID 0) and E2E_DCS_Test/PanelGroup, read through the SimGateway CDC. Results:

  • boot seed: node-0 row 80/00, END 1;
  • bridge row kind 03;
  • the panel group's first sighting reads kind 00, then a kind 01 delta;
  • roster END 2;
  • kind kept across a removal and revival;
  • duplicate on node 0: 02/09 while a second board sent HB_0, cleared about 4.5 s after it stopped.

Full table in the PR description.

Observations, outside this ticket's scope:

Remaining items (PDU kind, a duplicate on nodes 1–63, the DiagSerial-only tests) are on the Firmware v0.2.0 QA list. The client resync and its bench belong to SkyHawkClient#63.

Notes

  • Client rule: a new node is adoptable only if its kind is 01 panel group or 02 PDU. Anything else (00 unknown, 03 bridge, future values) can't be adopted; Ignore stays available. The node-0 row is the client's own link, not a "new" node.
  • The client gets the DUPLICATE_NODE_ID code through the resync (npm run sync) and adds a fault-table label, e.g. "Duplicate NODE_ID: another board uses this ID".
  • Kind order in the client, as decided 2026-10-16: announced nodeKind > PDU telemetry > stored/registry kind; otherwise unknown. Never inferred from the NODE_ID or from silence.
  • Old nodes send 00 and stay "unknown" in the client, so they can't be adopted until reflashed. That is intended.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestfirmwareFirmware implementation workpriority: highCritical path; do next

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions