AI-generated text still pending human review/editing.
How CAN works ↗ J1939 ↗

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

28 … 24
priority (5)
23 … 8
message type ID (16)
7
svc = 0
6 … 0
source node ID (1-127)

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

28 … 24
priority (5)
23 … 16
service type ID (8)
15
req = 1
14 … 8
destination node (7)
7
svc = 1
6 … 0
source node ID (7)

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

28 … 24
priority (5)
23 … 10
discriminator (14)
9 … 8
low 2 bits of type ID
7
svc = 0
6 … 0
source = 0

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.

bit 7start
bit 6end
bit 5toggle
bits 4-0transfer ID

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:

18015514  ·  priority 24, type 341, node 20
89
00
00
00uptime_sec
00health/mode
00
00vendor code
C0tail

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:

14040A0B  ·  priority 20, type 1034, node 11
73
71transfer CRC
00
00
00
00error_count
66
83start, tog 0
4Bvoltage
80
4Bcurrent
BD
5Ctemp K
64
19
23tog 1
09
00rpm/pct/index
43end, tog 0

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:

8A
00
00
00uptime 138
40health = 01
00
00
C1tail, tid 1

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:

healthMeaningmodeMeaning
0OK functioning properly0OPERATIONAL
1WARNING a parameter out of range, or a minor failure1INITIALIZATION
2ERROR - a major failure2MAINTENANCE
3CRITICAL - a fatal malfunction3SOFTWARE_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:

NodeNamePublishes
11-14ESC1-ESC4esc.Status
20GNSSgnss.Fix2
21POWERpower.BatteryInfo
22AHRSahrs.Solution
23RANGErange_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:

IDTypeCarries
1034equipment.esc.StatusRPM, current, voltage, temperature (in Kelvin), a power-rating percentage, an error count and the ESC's own index
1092equipment.power.BatteryInfopack voltage, current, temperature, 10-second mean power, state of charge
1063equipment.gnss.Fix2latitude and longitude at 1e-8 degrees, both heights, fix status, satellite count, PDOP
1000equipment.ahrs.Solutionattitude as a quaternion, angular velocity, linear acceleration
1050equipment.range_sensor.Measurementrange, beam orientation, sensor type, and whether the reading is valid or out of range
1028 / 1027equipment.air_data.*static pressure as a real float32, and the raw air-data set
1071equipment.hardpoint.Statuswhether the cargo hook is holding, and the payload weight in newtons
1044equipment.camera_gimbal.Statuswhere the camera mount is actually pointing, as a quaternion
1100equipment.safety.ArmingStatusDISARMED 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:

IDTypeSays
1081indication.LightsCommanda light ID and a colour, packed RGB565
1080indication.BeepCommanda frequency and a duration
1070hardpoint.Command0 to release the cargo hook, 1 or more to hold
1040camera_gimbal.AngularCommandan orientation for the mount, in a named reference frame
1010actuator.ArrayCommandpositions 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:

14043901  ·  priority 20, type 1081, node 1
00light_id
F8
00RGB565
C0tail

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:

180194FF  ·  service 1, request, tool 127 → node 20
C3tail only

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:

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:

140594FF  ·  service 5, request, tool 127 → node 20
1E
1B
55
CE
ACmagic_number
C4tail, tid 4
14057F94  ·  the answer, node 20 → tool 127, same transfer ID
80ok = true
C4tid 4

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:

Stage 1 - anonymous, source node 0, six bytes of unique ID
01node_id 0, first = 1
5A
21
9C
04
E7
33
C0tail
The allocator answers - not anonymous, from node 1 - echoing what it heard
00node_id 0: nothing granted yet
5A
21
9C
04
E7
33
C0
… two more instalments later, the grant: all sixteen bytes, and an ID
D6
85CRC
FAnode_id 125
5A
21
9C
04
82start

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:

1 no preference → start at 125 → 2 search up to 125 → 3 then down from the preference → 4 126 and 127 never handed out

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.

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.