Skip to content

CAN Bus Protocol

The CAN bus is the backbone of OpenSkyhawk — every panel hangs off it, and every control crosses it in the same ControlPacket format. This page is the full protocol reference: configuration, frame IDs, wire format, the startup handshake, and the settings you must get right. Constants here are taken from Firmware/Libraries/CANProtocol/CANProtocol.h — when in doubt, that header is authoritative.

Bus configuration

Parameter Value
Baud rate 500 kbps
Bit timing Prescaler = 4, BS1 = 13TQ, BS2 = 4TQ, SJW = 4TQ
Transceiver SN65HVD230 (SOIC-8, 3.3 V logic), one per STM32 board, on PA11 (RX) / PA12 (TX)
Topology Two-wire differential (CANH/CANL), daisy-chained across all boards
Termination 120 Ω across CANH/CANL at the two physical end nodes only — omit on intermediate nodes

The bus requires an external 8 MHz crystal on every STM32 board — the internal RC oscillator is not accurate enough for 500 kbps. Both the crystal requirement and the SJW = 4TQ value are empirical findings; see Design Decisions.

Frame ID scheme

All frames use standard 11-bit CAN IDs. Per-node IDs are computed from NODE_ID (1–63) with helper functions — never hard-code these values, always use the helper.

Frame CAN ID Helper Direction DLC
CTRL_BCAST 0x010 CAN_ID_CTRL_BCAST PanelBridge → All 8
TEST_SEQ 0x011 CAN_ID_TEST_SEQ PanelBridge → All 8
SYNC_REQ 0x012 CAN_ID_SYNC_REQ PanelBridge → All 0
HB_n 0x100 + n canIdHb(n) Node n → PanelBridge 8
HEALTH_n 0x140 + n canIdHealth(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
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
  • CTRL_BCAST carries DCS-BIOS output state from PanelBridge to every node.
  • 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).
  • HEALTH_n carries NodeHealthPayload — internal die temperature, and node degraded/fault state as that lands.
  • TEST_SEQ / ECHO_n are a round-trip latency test: PanelBridge sends TEST_SEQ, a node echoes on ECHO_n.
  • SYNC_REQ / READY_n are the boot/resync handshake (below).

canIdHb(1) = 0x101, not 0x100

canIdHb(0) (0x100) is reserved for PanelBridge and is never transmitted — the master has no consumer for its own heartbeat. Real nodes start at canIdHb(1) = 0x101. The old CAN_ID_HB_1 = 0x100 constant is deprecated; use the canIdHb(n) helper.

Why there are four event frames, not one

EVT_n carries an absolute value: the control's new position, as a number. Three inputs cannot say what they mean that way, so each gets its own frame rather than overloading the value space:

Frame Payload PanelBridge emits
EVT_REL_n signed ±step (int16) %+d for a DCS-BIOS variable_step control
EVT_DIR_n signed ±1 (int16) INC / DEC for a fixed_step control
EVT_ACTION_n selector, 0 = TOGGLE TOGGLE — the only argument the action interface defines

EVT_ACTION_n exists because an action is a keyword, not a number: the absolute value space is already spoken for by literal set_state values, so TOGGLE has no integer to ride on — the same reason EVT_DIR_n exists for INC/DEC. A payload value other than 0 on EVT_ACTION_n is malformed and the bridge drops it.

ControlPacket wire format

Everything that routes a control is a ControlPacket — a 4-byte { controlId, value } pair:

struct ControlPacket     { uint16_t controlId; uint16_t value; };  // 4 bytes
struct ControlPacketPair { ControlPacket a; ControlPacket b; };    // 8 bytes
  • controlId is the routing key — what the control is. It is not a CAN arbitration ID; the two namespaces are separate and equal numeric values don't collide because they occupy different CAN frame fields. See DCS-BIOS vs HID for the controlId ranges.
  • value is the payload — interpretation depends on the controlId range.

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 has its own pending slot. When only one packet is ready, slot B's controlId is set to the null sentinel 0x0000. Batching is owned by CANProtocol::sendBatched() / flushBatched() — callers submit individual ControlPackets and the library packs them.

A frame ID with no batch slot is silently discarded, so adding a batched frame means registering it in _batches[], not just defining its canId helper.

NODE_ID scheme (brief)

NODE_ID is a compile-time constant (build_flags = -DNODE_ID=N in platformio.ini), range 0–63. 0 is PanelBridge (reserved); 1–63 are PanelGroup nodes, assigned incrementally and permanent once assigned. SimGateway has no NODE_ID. Full policy and the live registry are on the NODE_ID & CAN Addressing page.

Startup and sync sequence

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.
  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 current state as EVT_n frames.

This guarantees PanelBridge can request a fresh input snapshot at any time. Missed outputs (LEDs, gauges) are tolerable — a fresh DCS-BIOS update arrives within ~50 ms. Missed inputs (switch positions) are not — they'd leave DCS in the wrong state until the next physical change, which is why the handshake exists. See Design Decisions for the reasoning.

Gotchas — get these right

Two CAN HAL settings are correctness requirements

Setting Required value Consequence if wrong
AutoRetransmission DISABLE One unACKed frame jams all 3 TX mailboxes → immediate bus-off; the TX queue never drains.
AutoBusOff ENABLE Hardware recovers from bus-off in ~3 ms; with it disabled, firmware must restart the controller by hand.

These are not preferences. The full failure analysis is in Design Decisions D4.

  • SJW = 4TQ. Lower values cause intermittent CRC errors from crystal tolerance between boards. Keep it at 4TQ permanently — it costs nothing. See D5.
  • Batch flush deadline. A half-full ControlPacketPair must flush within two firmware loop() iterations — user input must not sit waiting for a slot-B packet that may never arrive. Input-snapshot bursts (boot, SYNC_REQ re-polls) flush any trailing single packet immediately at the end of the poll pass.
  • TX overflow is frame-type dependent. CTRL_BCAST drops stale state and keeps the newest (coalescing by controlId); EVT_n and the special frames use bounded retry (3 attempts) then drop and bump a diagnostic counter. A healthy bus drops nothing — any EVT_n drop is a signal, not normal.