MAVLink v2 for Elixir: a frame codec, message signing, dialects generated from MAVLink XML, a link process over a serial port or any other byte transport, a router, and the command, parameter and mission protocols.
- Frames: payload truncation and zero-extension, resynchronisation after a
bad frame, partial frames buffered until complete, 32-bit system ids and
header targets (
SYSID32,TARGET32). - Dialects:
use MAVLink2.Dialect.XMLturns a MAVLink XML file and its includes into a struct per message,encode/1,decode/2and enum lookups. - Signing: sign and verify, per-stream replay protection, timestamps that never repeat when the clock steps back.
- Link: a GenServer that owns a transport, numbers outgoing frames, delivers decoded frames to subscribers and counts errors, sequence gaps per sender and rejected signatures.
- Router: forwards frames between links by MAVLink's routing rules, unchanged, signatures intact.
- Services: commands with acks and retries, parameters read, set and listed, missions uploaded, downloaded and cleared.
- Checked against pymavlink:
crc_extra, payload length, wire order and field types for every message incommon.xml, packed payloads, and signed frames byte for byte; and against the C library: frames with extended headers, signed and unsigned.
MAVLink v1 frames are refused.
What MAVLink is
MAVLink (Micro Air Vehicle Link) is a lightweight message protocol for drones, robots and the systems around them: autopilots, companion computers, ground stations, gimbals, cameras and microcontrollers. It is the protocol the PX4 and ArduPilot autopilots, and the QGroundControl and Mission Planner ground stations, speak.
- Messages are defined in XML: each has an id and a fixed list of typed
fields, sent in small binary frames with a checksum. A set of definitions
is a dialect;
common.xmlholds the messages most systems share, and a project can add its own. - Systems and components each have an id, carried in every frame, so many of them can share links and messages can be routed between them.
- Microservices are protocols built from messages: commands with acks, parameters, missions, and more.
- MAVLink 2, the version this library implements, added extension fields, 24-bit message ids, payload truncation and message signing.
The MAVLink developer guide documents the frame format, the message definitions and the microservices.
When to use MAVLink
Use cases
| Use case | What MAVLink gives it |
|---|---|
| A companion computer (a Nerves board, a Raspberry Pi) talking to a PX4 or ArduPilot flight controller over UART | The autopilot already speaks it: telemetry, commands, parameters and missions with no firmware changes |
| A ground station or fleet service talking to vehicles | The same messages QGroundControl and Mission Planner use, so both can be on the link alongside it |
| A host processor talking to a microcontroller on the same board (motor control, power, sensors, access control) over UART or USB | A custom dialect in XML, with C headers for the firmware generated by MAVLink's tools and an Elixir module for the host generated by this library, both from one file; no extra hardware beyond the port |
| Several components sharing links (a telemetry radio, a companion, a gimbal, a camera) | System and component ids on every frame, and routing rules, so MAVLink2.Router can forward between them |
| A link that must refuse forged or replayed commands | Message signing with a shared key and per-stream timestamps |
Compared with building your own
A protocol over a byte stream needs all of these, and MAVLink has them:
| Need | MAVLink |
|---|---|
| Finding frame boundaries in a stream, and recovering after noise | A start byte, a length, and a checksum to resynchronise on |
| Catching corruption | CRC-16 over every frame |
| Catching two ends built from different message definitions | crc_extra, a per-message byte from the definition folded into the checksum, so a mismatched peer's frames fail the check |
| Detecting lost frames | A sequence number per sender |
| Adding fields without breaking old peers | Extension fields, and zero-extension of short payloads |
| Authenticating frames | Signing, with replay protection |
| Tools to inspect and test it | pymavlink, MAVProxy, a generated Wireshark dissector, and autopilot simulators |
Compared with other protocols
| Alternative | Difference |
|---|---|
| Protobuf, CBOR or JSON over serial | These encode a message but do not frame it on a byte stream, check it, number it or sign it. MAVLink's overhead is 12 bytes a frame (25 signed), and trailing zeros are dropped from the payload |
| MQTT, ROS 2 or DDS | These need an IP network, and MQTT also needs a broker. MAVLink runs point to point on a 57600-baud radio |
| CAN, DroneCAN or Cyphal | A multi-drop bus with priority arbitration, and framing, CRC, acknowledgement and retransmission in the controller. Each classic CAN frame costs about 67 bits for at most 8 data bytes, and DroneCAN and Cyphal use one of those for a tail byte: a 28-byte message is 5 frames, about 655 bits, against 400 bits for MAVLink on a UART at 8N1. MAVLink needs only a UART or USB port, and leaves checking to software and retries to the services (MAVLink2.Command, Param, Mission) |
When something else fits better
- Payloads over 255 bytes: a large payload has to be split, or sent through MAVLink's FTP service.
- Video and bulk data: these usually go on their own stream (RTP, for example).
- Rich request/response APIs with nested or variable-length types: gRPC or HTTP suit these better on an IP network.
Using it, or having an agent use it
usage-rules.md is a short set of rules for writing code
against this library, for people and coding agents alike; tools such as
usage_rules gather it from your
dependencies. The library checks how it is called:
- A mistake in a call raises
ArgumentErrornaming what is wrong and what would be right, with the closest names for a misspelt option or enum entry (:baudsuggests:speed;:mav_cmd_armsuggests:mav_cmd_component_arm_disarm). - Errors from the other end of a link, timeouts and refusals, are returned
as
{:error, reason}.
Installation
def deps do
[
{:mavlink2, "~> 0.1"},
# only for MAVLink2.Transport.UART
{:circuits_uart, "~> 1.5"}
]
endRequires Elixir 1.18 or later.
Usage
A dialect
Put a MAVLink XML file and the files it includes in your project, for example
common.xml, standard.xml and minimal.xml from
mavlink/mavlink:
defmodule MyApp.MAVLink do
use MAVLink2.Dialect.XML, file: "priv/mavlink/common.xml"
endThe path is relative to the project root. The module recompiles when any of
the XML files changes. Each message becomes a struct (HEARTBEAT is
MyApp.MAVLink.Heartbeat) with the XML's field names; enums and entries are
lowercase atoms.
A link
alias MyApp.MAVLink, as: D
{:ok, link} =
MAVLink2.Link.start_link(
dialect: D,
device: "/dev/ttyUSB0",
speed: 57_600,
sysid: 255,
compid: 190
)
:ok = MAVLink2.Link.subscribe(link, self(), decode: true, only: [D.Heartbeat])
:ok =
MAVLink2.Link.send(link, %D.Heartbeat{
type: D.enum_value(:mav_type, :mav_type_gcs),
autopilot: D.enum_value(:mav_autopilot, :mav_autopilot_invalid)
})
receive do
{MAVLink2.Link, ^link, frame, %D.Heartbeat{} = heartbeat} ->
{frame.sysid, frame.compid, heartbeat}
end
MAVLink2.Link.stats(link)
#=> %{rx: 1, tx: 1, errors: 0, dropped: 0, unknown: 0, unsigned: 0, bad_signature: 0, replay: 0}Link options:
| Option | |
|---|---|
:dialect | the dialect module (required) |
:transport | a MAVLink2.Transport; default MAVLink2.Transport.UART. Remaining options go to its open/1 |
:device, :speed, :flow_control | MAVLink2.Transport.UART options; :speed defaults to 115200. A USB CDC ACM port (/dev/ttyACM0) is a serial device too |
:sysid, :compid | this end's ids; default 1, 1. :sysid may be up to 32 bits; 0 is refused |
:unknown | :keep delivers frames whose id the dialect lacks, unchecked, with known: false; default :skip |
:sendable | message ids the peer accepts; others return {:error, {:not_sendable, id}} |
:signing | a MAVLink2.Signing; see below |
:name | registered name |
If the transport does not open, the link logs a warning and start_link/1
returns :ignore.
Signing
key = :crypto.strong_rand_bytes(32) # shared with the peer
signing = MAVLink2.Signing.new(key, link_id: 1)
MAVLink2.Link.start_link(dialect: D, device: "/dev/ttyUSB0", signing: signing)With :signing, every frame sent is signed and a frame received is delivered
only if it verifies. Rejected frames are counted as :unsigned,
:bad_signature or :replay. accept_unsigned: [msgid] lets the listed
message ids through unsigned, and accept_unsigned: :all every one, for a
link that is secure by itself (USB, a board-level UART). A subscriber that
calls Link.subscribe(link, self(), rejected: true) also gets each refused
frame, as {MAVLink2.Link, link, {:rejected, reason, frame}}, to show as
untrusted.
A frame from a stream not seen before is refused when its timestamp is more
than a minute behind the clock, and this end's own frames reflected back are
refused as replays. A board that boots without real time can save
signing.timestamp and start from it with Signing.new(key, timestamp: saved).
A target over 255
A system id over 255 goes in the header: Frame.encode/2 sets SYSID32 for
a sysid over 255 and TARGET32 for a target_sysid. Link.send/2 does
this for a message whose target_system is over 255, putting 255 in the
payload as MAVLink requires:
MAVLink2.Link.send(link, %D.CommandLong{target_system: 100_000, target_component: 1, command: 400})The services below do the same for any target.
Routing
MAVLink2.Router.start_link(links: [:telemetry, :companion, :gimbal], sysid: 1)The router learns which link each {sysid, compid} is on from what it
receives, sends broadcasts to every other link, sends a frame for another
system to the links where that system has been seen, and never back where it
came from. Within its own system (:sysid), a frame for a component goes
only where that component has been seen. It forwards with
MAVLink2.Link.forward/2, which writes a frame's original bytes, so
signatures survive.
- Links with
unknown: :keeplet it pass on messages outside its dialect. - Frames a link's signing refuses are forwarded too, but teach it no routes.
SETUP_SIGNINGis never forwarded.- A
SYSTEM_TIMEshowing a reboot clears that system's routes.
Commands, parameters and missions
Each call runs in its own task subscribed to the link, sends again when the
target is quiet, and needs a dialect with common.xml's messages. target
is {sysid, compid}.
{:ok, :mav_result_accepted, _ack} =
MAVLink2.Command.long(link, {1, 1}, :mav_cmd_component_arm_disarm, [1.0])
{:ok, %{value: 1}} = MAVLink2.Param.read(link, {1, 1}, "SYSID_THISMAV")
{:ok, _} = MAVLink2.Param.set(link, {1, 1}, "RTL_ALT", 3000, :mav_param_type_int32)
{:ok, params} = MAVLink2.Param.list(link, {1, 1})
{:ok, opaque_id} =
MAVLink2.Mission.upload(link, {1, 1}, [
[command: :mav_cmd_nav_takeoff, frame: :mav_frame_global_relative_alt, z: 20.0]
])
{:ok, items, opaque_id} = MAVLink2.Mission.download(link, {1, 1})| Module | Options |
|---|---|
MAVLink2.Command | :timeout, :retries, :progress_timeout, :on_progress for MAV_RESULT_IN_PROGRESS acks; cancel/3 sends COMMAND_CANCEL |
MAVLink2.Param | :timeout, :retries, :encoding (:cast for ArduPilot, :bytewise for PX4); list/3 to compid 0 lists every component |
MAVLink2.Mission | :timeout, :item_timeout, :retries, :mission_type (mission, fence or rally) |
Without a link
MAVLink2.Frame encodes and decodes frames directly:
{msgid, payload} = D.encode(heartbeat)
{:ok, bytes} = MAVLink2.Frame.encode(%MAVLink2.Frame{msgid: msgid, payload: payload}, D)
{frames, errors, rest} = MAVLink2.Frame.drain(bytes, D)drain/2 returns every complete frame in a buffer, the errors it skipped
over, and the bytes left for the next read.
Specification
The library follows the MAVLink developer guide:
| Part | Guide |
|---|---|
Frames, checksum, crc_extra, payload truncation | Serialization |
| Signing | Message signing |
MAVLink2.Router | Routing |
MAVLink2.Command, Param, Mission | Command, parameter and mission protocols |
Where it goes beyond the guide:
SYSID32andTARGET32(incompat flags0x02,0x04) follow the C library (c_library_v2), which has them before the guide does.- Signing is stricter: a timestamp equal to the stream's last is a replay, as in the C library; the window for new streams counts from the clock even before this end has sent; and this end's own frames reflected back are refused.
- Recovery after noise resumes after the start byte of a frame that fails its checksum or has an unknown id; the guide leaves recovery to the implementation.
- A system broadcast within the router's own system (target component 0) goes to the links where that system has been seen.
- MAVLink v1 is not implemented.
Modules
| Module | |
|---|---|
MAVLink2.Frame | v2 encode and decode for a dialect; drain/2 over a byte stream |
MAVLink2.Dialect | the behaviour a dialect implements: crc_extra/1, payload_length/1, optionally target_offsets/1 |
MAVLink2.Dialect.XML | generates a dialect from MAVLink XML at compile time |
MAVLink2.XML | reads MAVLink XML with its includes: wire order, lengths, crc_extra |
MAVLink2.Codec | payloads from a wire layout; NaN and the infinities as atoms |
MAVLink2.Signing | sign, verify, per-stream replay protection |
MAVLink2.CRC | CRC-16/MCRF4XX with crc_extra |
MAVLink2.Link | a link process over one transport |
MAVLink2.Router | forwards frames between links |
MAVLink2.Command | COMMAND_LONG and COMMAND_INT, with acks |
MAVLink2.Param | the parameter protocol |
MAVLink2.Mission | the mission protocol |
MAVLink2.Transport | the transport behaviour: open/1, write/2, close/1 |
MAVLink2.Transport.UART | a serial port through Circuits.UART |
MAVLink2.Transport.Loopback | in memory, for tests; echo: true sends writes back, peer: joins two links |
A custom transport implements MAVLink2.Transport and sends received bytes to
the link as {:mavlink_data, binary}.
Tests
mix test
The fixtures are pymavlink 2.4.50's XML definitions and its output on them;
see test/fixtures/README.md. The extended-header frames in
test/mavlink2/extended_header_test.exs are the C library's.
License
MIT; see LICENSE. The files in test/fixtures keep their own licenses, listed
in test/fixtures/README.md.