mavlink2

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.

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.

The MAVLink developer guide documents the frame format, the message definitions and the microservices.

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

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:

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

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)

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:

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.