DroneCAN - the bus inside an aircraft
DroneCAN is the protocol a flight controller speaks to its own hardware: the four ESCs, the GPS receiver, the power monitor, the rangefinder, the camera mount, the cargo hook. It is UAVCAN v0 under an older name, still the version ArduPilot and PX4 ship, and it is what sloppyCAN implements most completely - a real transfer encoder, a decoder that reassembles multi-frame transfers off the wire, an eight-node roster, and both halves of the request/response services. This page is the map of what you are looking at in the DroneCAN tab.
The ID is a routing header
DroneCAN rides 29-bit extended CAN identifiers, and like J1939 it treats those 29 bits as a structured record rather than a name. The two cut the bits up completely differently, though, because they are solving different problems. J1939 spends them on a parameter group number and a source address. DroneCAN spends them on a data type, a priority, and - depending on what kind of transfer this is - one or two node IDs.
There are three kinds, and one bit decides which: bit 7, service-not-message. Get that bit wrong and every other field in the ID lands somewhere else, which produces a plausible-looking data type ID built out of half a service ID and half a destination address. Nothing about the frame will look broken.
A message: someone is publishing something
A broadcast. There is no destination, because there is nobody in particular to send it to - an ESC reports its own RPM and current to whoever is listening. Sixteen bits of type ID is generous, and that is the point: the type is the only thing identifying the payload, so it has to be unambiguous.
Priority is five bits and lower wins, exactly as raw CAN arbitration does - the ID with more leading zeros gets the bus. libcanard names four levels: 0 highest, 8 high, 16 medium, 24 low. Telemetry in sloppyCAN goes out at 20, arming and the cargo hook at 16 (they are safety statements, and a hook release must not queue behind a battery report), and the diagnostic services at 24.
A service: someone is asking, or answering
An addressed exchange. The type ID shrinks to eight bits to make room for a second node ID, so both ends of the conversation are on the wire and a listener who saw neither participant can still tell who asked whom.
The extra node ID is not free, and it changes how a decoder has to buffer.
A message is reassembled per (type, source). A service has to be reassembled per
(type, source, destination, direction) - two nodes answering two ground stations
are four independent transfers sharing one service type ID, and a buffer keyed
without the destination will splice two of them into one payload that passes its own CRC.
An anonymous message: someone has no ID yet
A node with no node ID has nothing to put in the source field, so it puts zero - and now the sixteen-bit type ID will not fit beside it. v0 keeps only the low two bits and spends the freed fourteen on a discriminator.
Two bits of type ID is not identification, it is a filter, which is why anonymous transfers are only usable for a protocol both ends already agree on. The discriminator is not identification either: it is random padding, there purely so that two anonymous nodes shouting at the same instant do not transmit byte-identical IDs and destroy each other under arbitration. The spec's own recommendation is to fill it with any 14 bits of the payload's CRC, and that is what sloppyCAN does. There is exactly one thing anonymous frames are for, and it is dynamic node ID allocation, at the bottom of this page.
The tail byte, and why transfers are not frames
A CAN frame carries at most 8 bytes. Almost nothing DroneCAN wants to say fits in 8 bytes, so the last byte of every frame is a header rather than data, and the real unit of the protocol is the transfer: one or more frames that reassemble into one payload.
A payload of 7 bytes or fewer is a single frame: start and end both set, toggle
0, no CRC. Here is a real one - uavcan.protocol.NodeStatus, the heartbeat, from the
GNSS node at ID 20 after 137 seconds of uptime:
0x89 = 137, and uptime_sec is a
byte-aligned uint32, so it comes out plain little-endian. The tail is
0xC0: start and end, toggle 0, transfer ID 0.
Anything longer is split into 7-byte chunks, preceded by a 2-byte transfer CRC, with the toggle bit alternating from 0. That starting value is a v0 rule and it is the first thing to check when a v1 implementation refuses your frames: UAVCAN v1 starts the toggle at 1. The transfer ID is a 5-bit rolling counter kept per data type and source, so a receiver can tell a retransmission from the next reading.
Here is esc.Status from the first ESC at node 11, 14 bytes of payload plus the CRC
across three frames:
The CRC is seeded with the data type signature
This is the thing that will bite you. The transfer CRC is CRC-16-CCITT-FALSE, but it does not start on the payload. It starts on the eight little-endian bytes of the data type's 64-bit signature, and only then eats the payload. The signature is a hash of the type's normalised DSDL definition - its field names, widths and order - so two nodes that disagree about a message's layout produce CRCs that cannot match, and the mismatch is caught at the transport layer instead of being decoded into a plausible number.
esc.Status's signature is 0xA9AF28AEA2FBB254, so its CRC is seeded
with 54 B2 FB A2 AE 28 AF A9. Get one of those bytes wrong and the consequence is
strange: single-frame transfers still work perfectly, because they carry no
CRC at all, while every multi-frame transfer is rejected by every real tool - and accepted by
your own decoder, which is making the same mistake. That asymmetry is why signatures are worth
deriving from the DSDL rather than copying from a document, and why sloppyCAN's self-test
re-derives every one it uses.
DSDL packs bits MSB-first
Fields are laid out little-endian by byte and most-significant-first by
bit. A byte-aligned uint32 therefore looks like ordinary little-endian and
nothing seems unusual - until a field is not a whole number of bytes. NodeStatus is the
canonical case: after the 32-bit uptime come health (2 bits),
mode (3) and sub_mode (3), and they fill byte 4 from the top.
The same GNSS node one second later, with its health degraded to WARNING:
Health 1 in a 2-bit field is 0b01 at the
top of the byte: 0b01000000 = 0x40. Read it from
the bottom and you get 0, which is OK - a fault that decodes as
healthy.
Two more DSDL rules show up constantly in real payloads. A fixed-size array
(float16[4] for a quaternion) is just its elements back to back, no length prefix.
A variable-length array normally carries a length prefix of
ceil(log2(max+1)) bits - but if it is the last field and its elements are
at least 8 bits wide, the tail array optimization drops the prefix entirely.
The array is then literally the bytes that are left. That is why an empty trailing array costs
zero bits, and why a node's name at the end of a GetNodeInfo response is just ASCII running to
the end of the transfer.
NodeStatus: everyone announces themselves
Every DroneCAN node publishes uavcan.protocol.NodeStatus (type 341) about once a
second, from its own node ID, with its own health. That is the whole discovery
mechanism. There is no enumeration step, no master polling a list; you learn the bus by
listening to it for a second.
The four health values and the mode are the node's own judgement of itself:
| health | Meaning | mode | Meaning |
|---|---|---|---|
| 0 | OK functioning properly | 0 | OPERATIONAL |
| 1 | WARNING a parameter out of range, or a minor failure | 1 | INITIALIZATION |
| 2 | ERROR - a major failure | 2 | MAINTENANCE |
| 3 | CRITICAL - a fatal malfunction | 3 | SOFTWARE_UPDATE |
sloppyCAN's simulated airframe carries eight nodes, and the tab's roster is built from their heartbeats rather than from any internal list - it is a bus monitor, so it shows what was said, not what it knows would have been said:
| Node | Name | Publishes |
|---|---|---|
| 11-14 | ESC1-ESC4 | esc.Status |
| 20 | GNSS | gnss.Fix2 |
| 21 | POWER | power.BatteryInfo |
| 22 | AHRS | ahrs.Solution |
| 23 | RANGE | range_sensor.Measurement |
What a node going quiet looks like
Here is the part worth actually internalising, and it is not a feature of the protocol so much as a consequence of its shape.
There is no "node offline" message. There cannot be: the node that would have
to send it is the one that has stopped working. A node's absence is signalled by
nothing at all - the heartbeats stop, and after a while you notice. The DSDL fixes
"a while" at OFFLINE_TIMEOUT_MS = 3000, three missed beats at the recommended 1 Hz,
and that is the whole detection mechanism.
Which means the dangerous failure is not silence. It is a node that keeps publishing its last good reading. Click a roster row in the DroneCAN tab to take that node off the bus and watch what happens: its frames stop, its row goes silent, and - if it is an ESC - the aircraft starts fighting for control on three motors. Now imagine the same failure with the frames still flowing. Four healthy ESCs at 6500 RPM, a clean battery report, a nominal attitude solution, and an aircraft dropping out of the sky. Every number on the bus would be self-consistent and none of it would be true.
So sloppyCAN's simulated nodes genuinely stop transmitting when they are failed, rather than re-broadcasting held values. Silence is the reading. A listener that treats a missing heartbeat as a display problem, and paints the last known value grey instead of raising an alarm, has turned the one honest signal on the bus back into noise.
The sensor messages
Everything above is framing. The actual traffic is a set of standard equipment messages, each published by the peripheral that owns the measurement. The ones sloppyCAN emits are the ones a real quadcopter emits:
| ID | Type | Carries |
|---|---|---|
| 1034 | equipment.esc.Status | RPM, current, voltage, temperature (in Kelvin), a power-rating percentage, an error count and the ESC's own index |
| 1092 | equipment.power.BatteryInfo | pack voltage, current, temperature, 10-second mean power, state of charge |
| 1063 | equipment.gnss.Fix2 | latitude and longitude at 1e-8 degrees, both heights, fix status, satellite count, PDOP |
| 1000 | equipment.ahrs.Solution | attitude as a quaternion, angular velocity, linear acceleration |
| 1050 | equipment.range_sensor.Measurement | range, beam orientation, sensor type, and whether the reading is valid or out of range |
| 1028 / 1027 | equipment.air_data.* | static pressure as a real float32, and the raw air-data set |
| 1071 | equipment.hardpoint.Status | whether the cargo hook is holding, and the payload weight in newtons |
| 1044 | equipment.camera_gimbal.Status | where the camera mount is actually pointing, as a quaternion |
| 1100 | equipment.safety.ArmingStatus | DISARMED or FULLY_ARMED. Two states, and no third one |
A few of those carry a lesson in their own right. Longitude comes before latitude
in Fix2, which is the sort of mistake that still decodes as a plausible position. Temperatures
are Kelvin and forces are newtons, because DSDL is uncompromisingly SI. And DSDL has a
convention for "I do not know this": a float field is set to NaN
rather than to zero. That distinction matters more than it sounds - a battery that reports
remaining_capacity_wh = 0 is claiming to be flat, while one that reports NaN is
saying it has no way to tell you.
ArmingStatus having only two states is a real limitation, not a simplification. A flight controller that was asked to arm and refused has a third thing to say - blocked, and here is which pre-arm check failed - and DroneCAN has no field for it. So a real FC publishes DISARMED and reports the refusal somewhere else entirely. Not every piece of state a system has is on its bus, and pretending otherwise is how a dashboard ends up lying.
The command messages, going the other way
Every message above is a peripheral reporting on itself. A handful flow in the opposite direction - from the flight controller toward the peripherals - and they are published from the FC's node ID rather than from any sensor's:
| ID | Type | Says |
|---|---|---|
| 1081 | indication.LightsCommand | a light ID and a colour, packed RGB565 |
| 1080 | indication.BeepCommand | a frequency and a duration |
| 1070 | hardpoint.Command | 0 to release the cargo hook, 1 or more to hold |
| 1040 | camera_gimbal.AngularCommand | an orientation for the mount, in a named reference frame |
| 1010 | actuator.ArrayCommand | positions for actuators, addressed by actuator ID, in radians |
Note that the last two describe the same intent in two vocabularies. AngularCommand is how you command a camera mount: an orientation, addressed to a gimbal. ArrayCommand is how you command two actuators: raw positions, addressed by actuator ID. Which one a given peripheral answers to is a property of that peripheral, so a ground station with no idea what is fitted sends both, and sloppyCAN does exactly that.
Commands are aperiodic. They go out on a change and not on a clock, because a command spammed at 10 Hz is not what a real bus looks like - and because a command is an event, not a state. Here is a LightsCommand turning the arm tips red, one frame, published from the FC at node 1:
Red is 5 bits, green 6, blue 5, MSB-first:
11111 000000 00000 = 0xF800. The round trip through 16 bits
quantises - a colour comes back a shade off, which is the LEDs' real
resolution and not a rounding bug.
Services: asking, and being answered
Everything so far is one-way. A node decides something is worth publishing, publishes it, and a listener either heard it or did not. Services are the other shape: a request addressed to one node, and that node's answer addressed back. They are the only two-way traffic on the bus.
The pairing rule is short and load-bearing: a response copies the request's transfer ID, and by convention its priority as well. It does not allocate its own. On a bus where four other nodes may be mid-transfer, that copied 5-bit counter is the only thing connecting an answer to its question.
GetNodeInfo (service 1) - who are you?
The request is empty. Zero bits of payload; the frame is one tail byte and nothing else, and the question is entirely carried by the ID:
The answer is the widest transfer on the bus - about 67 bytes across ten frames. It carries the
node's NodeStatus (the same five fields as the heartbeat, so you get the
current health without waiting a second for it), its software version, its
hardware version and 128-bit unique ID - the number burned in at manufacture,
which is the node's real identity, since its node ID may have been handed to it five minutes
ago - and finally its name, an ASCII string in reversed-domain style such as
org.sloppycan.carlito.gnss, riding the tail array optimization at the end of the
transfer.
A node that is not publishing does not answer either. Fail a roster node and then ask it who it is: nothing comes back. That is the same reading its missing heartbeat gives, and a request that goes unanswered is not a bug in the requester.
param.GetSet (service 11) - read and write configuration
Nodes have parameters - a CAN bitrate, an ESC index, a publication period - and GetSet is how
you read and change them. It is the most structurally interesting message in DroneCAN, because
a parameter value is a DSDL union: empty, an int64, a
float32, a bool or a string, prefixed with a 3-bit tag saying which.
Its width depends on its contents, so unlike every other message here it cannot be described as
a flat list of fixed-width fields.
Two conventions do the work, and both are the kind of thing you would never guess:
- An empty value means "read"; a non-empty one means "write, then tell me what it is now". There is no separate get and set. And because the response always reports the actual value after the operation, a write that was refused - out of range, or read-only - is visible as an answer that did not change.
- Index access is only for enumerating. There is no "how many parameters do you have" query. You ask for index 0, then 1, then 2, until a response comes back with an empty name and an empty value, which is the DSDL's way of saying there is no such parameter. Then you address everything you found by name, because the DSDL warns that the ordering is not guaranteed to be stable.
Watch for the void5 and void6 padding fields in the response. They
look like clutter, and they are what makes every union variant land on a byte boundary - which
is in turn what lets the parameter name at the end be a tail array of whole bytes.
RestartNode (service 5) - and a magic number
The request is a single uint40 field, and the DSDL says outright that it must equal
0xACCE551B1E or the request should be rejected:
That constant is not security - it is sitting on the wire in the clear, and anyone can copy it.
It is a guard against an accident: a corrupted frame, a mis-addressed tool, a fuzzer.
Rebooting a node mid-flight is a physical event, and it should take more than a frame landing
on the right ID by chance. (ok = true is a single bit, MSB-first, which is why it
reads 0x80 and not 0x01.)
And what a restart looks like on the bus is worth watching once, because it is not what
a status field would show you. The node acknowledges, and then it goes away: its heartbeats
stop, its roster row goes silent for a moment, and it returns with
uptime_sec back at 0. There is no "restarting" flag anywhere - a reboot is an
absence followed by a counter that went backwards, and the fact that uptime can go
backwards is exactly how the DSDL suggests you detect one.
Dynamic node ID allocation
Every node ID so far was declared: somebody sat down and decided the GNSS receiver is node 20. That is fine for one airframe and untenable for a fleet sharing spare parts, so DroneCAN defines a way for a node with no ID to be given one.
Which is a genuine bootstrapping problem, because the bus's addressing is built out of
node IDs. This is what anonymous frames exist for - and an anonymous frame leaves only
seven payload bytes. A unique ID is sixteen. Hence the DSDL's
MAX_LENGTH_OF_UNIQUE_ID_IN_REQUEST = 6, and hence a handshake that dribbles the
identity across in six-byte instalments while the allocator echoes back how much it has heard:
Two details in that first byte. node_id is a uint7 sitting in the
top seven bits with first_part_of_unique_id underneath it, so
granting ID 125 writes 125 << 1 = 0xFA - libcanard's own
allocatee builds that byte by hand as (PREFERRED_NODE_ID << 1) | 1. And
unique_id is a tail array, which is what lets a 6-byte request and a 16-byte grant
be the same message with the same fields at two different lengths.
The allocator's search order is spelled out in the DSDL, and it is deliberately backwards:
A node with no preference gets the highest free ID rather than the lowest, which keeps dynamically allocated nodes out of the way of declared ones. The top two are reserved for network maintenance tools - which is why the DroneCAN tab's service client claims 127 for itself rather than picking a spare number.
The last property is the one that makes this survivable in flight: the allocator keys its table on the unique ID, so the same device asking twice gets the same node ID back. A peripheral that browns out mid-flight and reboots rejoins as itself, not as a new device with a new address and a new set of subscriptions.
Collisions are expected here rather than prevented. Two anonymous nodes starting together will occasionally pick the same 14-bit discriminator - the DSDL puts it at roughly 0.006% - so the protocol is written around detecting and retrying rather than around never colliding. Both ends randomise their timers for the same reason: a fixed follow-up delay would keep two devices that booted together in lockstep forever.
Try it with no hardware
Press Demo and open the DroneCAN tab. A simulated quadcopter starts publishing through the real encoder, and every frame it produces is fed back through the real decoder - so the transfer log is showing you an actual encode → reassemble → verify round trip, not a rendering of what it intended to send.
- Click a roster row to take that node off the bus. Its frames stop, its row goes silent, and nothing anywhere reports an error - that is the point.
- Get node info on a live node, then on the one you just failed.
- Read parameters and watch the index walk stop on a nameless answer. Then
set
uavcan.node.status_period_msto 4000 and watch that node's row go stale while its neighbours stay green - the parameter really is its publication period. - Restart a node, and try it once with the wrong magic number.
- New node asks for an ID, and follow the three anonymous transfers and the grant through the log.
The transfer log shows one row per reassembled transfer, with the frame count and the CRC verdict as columns, so a multi-frame message appears once rather than as a pile of fragments. Everything on that bus is also visible as raw frames in the Traffic Dump, and the ⊞ link on each row will take you there.