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.XML turns a MAVLink XML file and its includes into a struct per message, encode/1, decode/2 and 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 in common.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.

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.xml holds 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.

Use cases

Use caseWhat MAVLink gives it
A companion computer (a Nerves board, a Raspberry Pi) talking to a PX4 or ArduPilot flight controller over UARTThe autopilot already speaks it: telemetry, commands, parameters and missions with no firmware changes
A ground station or fleet service talking to vehiclesThe 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 USBA 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 commandsMessage 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:

NeedMAVLink
Finding frame boundaries in a stream, and recovering after noiseA start byte, a length, and a checksum to resynchronise on
Catching corruptionCRC-16 over every frame
Catching two ends built from different message definitionscrc_extra, a per-message byte from the definition folded into the checksum, so a mismatched peer's frames fail the check
Detecting lost framesA sequence number per sender
Adding fields without breaking old peersExtension fields, and zero-extension of short payloads
Authenticating framesSigning, with replay protection
Tools to inspect and test itpymavlink, MAVProxy, a generated Wireshark dissector, and autopilot simulators

Compared with other protocols

AlternativeDifference
Protobuf, CBOR or JSON over serialThese 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 DDSThese need an IP network, and MQTT also needs a broker. MAVLink runs point to point on a 57600-baud radio
CAN, DroneCAN or CyphalA 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 ArgumentError naming what is wrong and what would be right, with the closest names for a misspelt option or enum entry (:baud suggests :speed; :mav_cmd_arm suggests :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"}
  ]
end

Requires 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"
end

The 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.

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
:dialectthe dialect module (required)
:transporta MAVLink2.Transport; default MAVLink2.Transport.UART. Remaining options go to its open/1
:device, :speed, :flow_controlMAVLink2.Transport.UART options; :speed defaults to 115200. A USB CDC ACM port (/dev/ttyACM0) is a serial device too
:sysid, :compidthis 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
:sendablemessage ids the peer accepts; others return {:error, {:not_sendable, id}}
:signinga MAVLink2.Signing; see below
:nameregistered 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: :keep let it pass on messages outside its dialect.
  • Frames a link's signing refuses are forwarded too, but teach it no routes.
  • SETUP_SIGNING is never forwarded.
  • A SYSTEM_TIME showing 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})
ModuleOptions
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)

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:

PartGuide
Frames, checksum, crc_extra, payload truncationSerialization
SigningMessage signing
MAVLink2.RouterRouting
MAVLink2.Command, Param, MissionCommand, parameter and mission protocols

Where it goes beyond the guide:

  • SYSID32 and TARGET32 (incompat flags 0x02, 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.Framev2 encode and decode for a dialect; drain/2 over a byte stream
MAVLink2.Dialectthe behaviour a dialect implements: crc_extra/1, payload_length/1, optionally target_offsets/1
MAVLink2.Dialect.XMLgenerates a dialect from MAVLink XML at compile time
MAVLink2.XMLreads MAVLink XML with its includes: wire order, lengths, crc_extra
MAVLink2.Codecpayloads from a wire layout; NaN and the infinities as atoms
MAVLink2.Signingsign, verify, per-stream replay protection
MAVLink2.CRCCRC-16/MCRF4XX with crc_extra
MAVLink2.Linka link process over one transport
MAVLink2.Routerforwards frames between links
MAVLink2.CommandCOMMAND_LONG and COMMAND_INT, with acks
MAVLink2.Paramthe parameter protocol
MAVLink2.Missionthe mission protocol
MAVLink2.Transportthe transport behaviour: open/1, write/2, close/1
MAVLink2.Transport.UARTa serial port through Circuits.UART
MAVLink2.Transport.Loopbackin 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.