[AZ-641] [AZ-642] [AZ-644] mavlink transport + codec + mission pull

Lands the second batch under epic AZ-626's implementation plan.

mavlink_layer (AZ-641 + AZ-642):
- Hand-rolled MAVLink v2 codec covering the §7.7 surface: HEARTBEAT,
  SYS_STATUS, SET_MODE, ATTITUDE, GLOBAL_POSITION_INT, MISSION_* (7),
  COMMAND_LONG, COMMAND_ACK, EXTENDED_SYS_STATE, STATUSTEXT (17 total).
- Streaming decoder demuxes arbitrary-sized byte arrivals, drops malformed
  frames with typed parse-error counters (crc/truncated/unknown_id/seq_gap),
  and surfaces sequence gaps without hard-failing the link.
- Encoder tracks the per-link tx_seq counter and applies the MAVLink v2
  trailing-zero payload truncation rule.
- UDP and POSIX-serial transports behind a single async Transport trait;
  the run loop owns transport open with bounded exponential backoff
  (2 s serial / 5 s UDP cap) and a tokio::select! per-link read+write
  loop.
- 1 Hz outbound HEARTBEAT scheduler + inbound-heartbeat watchdog that
  fires LinkUp / LinkLost on a broadcast channel and feeds health detail
  (connected, last_heartbeat_age_ms, signing_enabled, parse_errors).

mission_client (AZ-644):
- HTTPS GET /missions/{id} over rustls (no OpenSSL on the airframe).
- Bundled JSON Schema (crates/shared/contracts/mission-schema.json,
  draft-07, additionalProperties:false) validates every response;
  schema-invalid bodies surface as FetchError::SchemaInvalid with a
  1 KiB sample of the raw body for offline analysis.
- Transient failures (timeout, 5xx, 429) retry with bounded exponential
  backoff up to MissionClientOptions.max_attempts (default 5); permanent
  failures (4xx, malformed URL) abort immediately.
- Health surface mirrors AC-1's contract: last_fetch_ts,
  fetch_errors_total, schema_version, connection_state.

Caught and fixed before commit (NOT a code-review finding — caught by
the unit test that hand-computed CRC("123456789")): the hand-rolled
X.25 CRC accumulator was operating in u16 throughout. The MAVLink C
reference declares `tmp` as uint8_t, which silently truncates the
shifted-in bits. Round-trip tests passed (encoder and decoder shared
the bug); a real MAVLink peer would have rejected every frame. Fixed
by mirroring the C reference: `let mut tmp: u8 = …; tmp ^= tmp.wrapping_shl(4);`.
Added a regression test asserting CRC("123456789") == 0x6F91 against
pymavlink's reference value (NOT the textbook 0x29B1 — MAVLink uses a
byte-wise variant, not the bit-reflected CCITT).

AC verification (full detail in
_docs/03_implementation/batch_02_cycle1_report.md):

AZ-641: AC-1 + AC-3 + AC-4 verified via UDP loopback integration tests;
        AC-2 (serial) requires a socat pty pair and runs in the SITL/CI
        tier (test exists as #[ignore]-marked stub).
AZ-642: AC-1 + AC-2 + AC-3 verified via exhaustive codec round-trip and
        decoder negative-path tests; AC-4 (SITL round-trip) requires
        ArduPilot SITL — the CRC fix above means the codec is now
        wire-correct, ready for the sitl-conformance Woodpecker stage.
AZ-644: all four ACs verified via wiremock-driven integration tests.

Workspace gates green:
- cargo check --workspace                                clean
- cargo check --workspace --no-default-features          clean
- cargo fmt --all -- --check                             clean
- cargo clippy --workspace --all-targets -- -D warnings  clean
- cargo test --workspace                                 pass (1 expected ignore)

Layering invariants from module-layout.md hold: mavlink_layer and
mission_client are Layer 2 actors importing only `shared`; no sibling
Layer-2 imports; MavlinkHandle implements shared::contracts::MavlinkSink.

Jira: AZ-641, AZ-642, AZ-644 transitioned To Do → In Progress at batch
start; the matching In Testing transitions follow this commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-19 12:29:49 +03:00
parent a1ce3a6903
commit 740bf37d76
33 changed files with 5293 additions and 69 deletions
@@ -0,0 +1,78 @@
//! ITU-T X.25 CRC-16 — MAVLink's checksum function.
//!
//! Polynomial `0x1021` with initial value `0xFFFF`, reflected per the MAVLink
//! reference implementation. Each frame's CRC is computed over the byte range
//! `[len..payload_end]` followed by the message-specific `CRC_EXTRA` byte.
/// Initial CRC value used by MAVLink. (Polynomial is `0x1021`, implicit in the
/// reflected algorithm below.)
pub const INIT: u16 = 0xFFFF;
/// Update an in-progress CRC accumulator with a single byte.
///
/// Mirrors the MAVLink C reference `crc_accumulate` exactly: `tmp` is a
/// `uint8_t` so the intermediate `tmp ^= (tmp << 4)` truncates to a byte.
/// Implementing this in pure `u16` (without the `as u8` cast) produces a
/// **different** CRC and breaks wire compatibility with real peers.
#[inline]
pub fn accumulate_byte(acc: u16, byte: u8) -> u16 {
let mut tmp: u8 = byte ^ ((acc & 0xFF) as u8);
tmp ^= tmp.wrapping_shl(4);
let tmp = tmp as u16;
(acc >> 8) ^ (tmp << 8) ^ (tmp << 3) ^ (tmp >> 4)
}
/// CRC accumulator over a byte slice, starting from `start`.
#[inline]
pub fn accumulate(start: u16, bytes: &[u8]) -> u16 {
bytes.iter().fold(start, |acc, b| accumulate_byte(acc, *b))
}
/// Compute the full MAVLink CRC for a frame body and its `crc_extra` byte.
///
/// `frame_body` is the range `[len, incompat_flags, compat_flags, seq, sysid,
/// compid, msgid_lo, msgid_mid, msgid_hi, payload...]` — i.e. the frame
/// without `STX` and without the trailing CRC.
#[inline]
pub fn frame_crc(frame_body: &[u8], crc_extra: u8) -> u16 {
let intermediate = accumulate(INIT, frame_body);
accumulate_byte(intermediate, crc_extra)
}
// The dummy value below is the CRC of "123456789" with INIT=0xFFFF and POLY=0x1021,
// computed per the MAVLink reflection. Confirmed against the MAVLink reference
// implementation (crc_accumulate).
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_slice_returns_init() {
// Act
let crc = accumulate(INIT, &[]);
// Assert
assert_eq!(crc, INIT);
}
#[test]
fn single_byte_known_value() {
// Act
let crc = accumulate(INIT, &[0x00]);
// Assert: derived by hand from the C reference. tmp = 0xFF ^ 0xFF = 0,
// wait — tmp = 0x00 ^ 0xFF = 0xFF, then tmp ^= tmp<<4 (u8) = 0xFF ^ 0xF0
// = 0x0F, then accum = 0x00FF ^ 0x0F00 ^ 0x78 ^ 0 = 0x0F87.
assert_eq!(crc, 0x0F87);
}
#[test]
fn mavlink_check_string_matches_pymavlink() {
// MAVLink's CRC variant (byte-wise, not bit-reflected) gives 0x6F91
// for the ASCII string "123456789" — verified by running the same
// bytes through pymavlink's `mavcrc.x25crc`. This is NOT the same as
// the textbook CRC-CCITT (XMODEM) value 0x29B1.
let crc = accumulate(INIT, b"123456789");
assert_eq!(crc, 0x6F91);
}
}
@@ -0,0 +1,290 @@
//! Streaming MAVLink v2 byte decoder.
//!
//! Designed for arbitrary-sized chunk arrivals from UDP / serial. Feed bytes
//! into [`Decoder::feed`] and drain [`DecoderEvent`]s. Malformed frames and
//! unknown message ids are surfaced as events; the decoder never returns an
//! `Err` from `feed` itself.
use super::crc::frame_crc;
use super::messages::{crc_extra_for_id, MavlinkMessage};
use super::parse_errors::{ParseErrorKind, ParseErrors};
use super::{HEADER_LEN, INCOMPAT_FLAG_SIGNED, MAVLINK_V2_STX, MAX_PAYLOAD, SIGNATURE_LEN};
#[derive(Debug, Clone, PartialEq)]
pub enum DecoderEvent {
/// A frame was fully decoded into a typed message.
Message {
sysid: u8,
compid: u8,
seq: u8,
message: MavlinkMessage,
},
/// A frame was discarded because its CRC did not match.
Crc { msg_id: u32, seq: u8 },
/// A frame was discarded because its msgid is outside the §7.7 surface.
UnknownId { msg_id: u32, seq: u8 },
/// A frame's payload bytes did not parse cleanly (e.g. invalid enum).
InvalidPayload {
msg_id: u32,
seq: u8,
reason: &'static str,
},
/// Per-link sequence number jumped (logged but the frame is still emitted
/// in the preceding `Message` event).
SequenceGap {
sysid: u8,
compid: u8,
expected: u8,
actual: u8,
},
}
#[derive(Debug)]
pub struct Decoder {
buf: Vec<u8>,
/// Counter snapshot to expose via the codec health surface.
pub errors: ParseErrors,
/// Last sequence number per (sysid, compid).
last_seq: std::collections::HashMap<(u8, u8), u8>,
}
impl Default for Decoder {
fn default() -> Self {
Self::new()
}
}
impl Decoder {
pub fn new() -> Self {
Self {
buf: Vec::with_capacity(4 * 1024),
errors: ParseErrors::new(),
last_seq: std::collections::HashMap::new(),
}
}
/// Push raw bytes into the decoder and drain any complete events.
pub fn feed(&mut self, bytes: &[u8]) -> Vec<DecoderEvent> {
self.buf.extend_from_slice(bytes);
self.drain()
}
fn drain(&mut self) -> Vec<DecoderEvent> {
let mut events = Vec::new();
loop {
let Some(stx_idx) = self.buf.iter().position(|b| *b == MAVLINK_V2_STX) else {
// No STX in buffer; discard any garbage we have collected.
self.buf.clear();
break;
};
if stx_idx > 0 {
// Skip leading garbage up to the first STX byte.
self.buf.drain(..stx_idx);
}
// After draining, STX is at index 0.
if self.buf.len() < HEADER_LEN {
// Not enough for header yet.
break;
}
let payload_len = self.buf[1] as usize;
let incompat = self.buf[2];
let seq = self.buf[4];
let sysid = self.buf[5];
let compid = self.buf[6];
let msg_id = u32::from_le_bytes([self.buf[7], self.buf[8], self.buf[9], 0]);
if payload_len > MAX_PAYLOAD {
// Malformed length; resync by skipping the STX.
self.errors.record(ParseErrorKind::InvalidPayload);
events.push(DecoderEvent::InvalidPayload {
msg_id,
seq,
reason: "payload_len > 255",
});
self.buf.drain(..1);
continue;
}
let signature_len = if incompat & INCOMPAT_FLAG_SIGNED != 0 {
SIGNATURE_LEN
} else {
0
};
let total_frame = HEADER_LEN + payload_len + 2 + signature_len;
if self.buf.len() < total_frame {
// Wait for the rest of this frame.
break;
}
// Verify CRC.
let body = &self.buf[1..HEADER_LEN + payload_len];
let frame_crc_bytes = u16::from_le_bytes([
self.buf[HEADER_LEN + payload_len],
self.buf[HEADER_LEN + payload_len + 1],
]);
let crc_extra = crc_extra_for_id(msg_id);
match crc_extra {
None => {
self.errors.record(ParseErrorKind::UnknownId);
events.push(DecoderEvent::UnknownId { msg_id, seq });
self.buf.drain(..total_frame);
continue;
}
Some(extra) => {
let computed = frame_crc(body, extra);
if computed != frame_crc_bytes {
self.errors.record(ParseErrorKind::Crc);
events.push(DecoderEvent::Crc { msg_id, seq });
// Consume the bad frame so we don't loop forever.
self.buf.drain(..total_frame);
continue;
}
}
}
let payload = &self.buf[HEADER_LEN..HEADER_LEN + payload_len];
match MavlinkMessage::decode(msg_id, payload) {
Ok(message) => {
if let Some(expected) =
self.last_seq.insert((sysid, compid), seq.wrapping_add(1))
{
if expected != seq {
self.errors.record(ParseErrorKind::SequenceGap);
events.push(DecoderEvent::SequenceGap {
sysid,
compid,
expected,
actual: seq,
});
}
}
events.push(DecoderEvent::Message {
sysid,
compid,
seq,
message,
});
}
Err(crate::internal::codec::MavlinkParseError::UnknownMessageId(id)) => {
self.errors.record(ParseErrorKind::UnknownId);
events.push(DecoderEvent::UnknownId { msg_id: id, seq });
}
Err(crate::internal::codec::MavlinkParseError::TruncatedPayload { .. }) => {
self.errors.record(ParseErrorKind::Truncated);
events.push(DecoderEvent::InvalidPayload {
msg_id,
seq,
reason: "payload shorter than message minimum",
});
}
Err(crate::internal::codec::MavlinkParseError::InvalidPayload {
reason, ..
}) => {
self.errors.record(ParseErrorKind::InvalidPayload);
events.push(DecoderEvent::InvalidPayload {
msg_id,
seq,
reason,
});
}
Err(crate::internal::codec::MavlinkParseError::CrcMismatch { .. }) => {
// Shouldn't reach here — CRC was verified above.
self.errors.record(ParseErrorKind::Crc);
events.push(DecoderEvent::Crc { msg_id, seq });
}
}
self.buf.drain(..total_frame);
}
events
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::internal::codec::encoder::Encoder;
use crate::internal::codec::messages::Heartbeat;
#[test]
fn round_trips_one_heartbeat() {
// Arrange
let enc = Encoder::new(1, 191);
let msg = MavlinkMessage::Heartbeat(Heartbeat {
custom_mode: 0,
mavtype: 2,
autopilot: 3,
base_mode: 0,
system_status: 4,
mavlink_version: 3,
});
let frame = enc.encode(&msg);
// Act
let mut dec = Decoder::new();
let events = dec.feed(&frame);
// Assert
assert_eq!(events.len(), 1, "expected one event, got {events:?}");
let DecoderEvent::Message { message, .. } = &events[0] else {
panic!("expected Message event, got {:?}", events[0]);
};
assert_eq!(*message, msg);
assert_eq!(dec.errors.snapshot().total(), 0);
}
#[test]
fn rejects_bad_crc() {
// Arrange
let enc = Encoder::new(1, 191);
let msg = MavlinkMessage::Heartbeat(Heartbeat {
custom_mode: 0,
mavtype: 2,
autopilot: 3,
base_mode: 0,
system_status: 4,
mavlink_version: 3,
});
let mut frame = enc.encode(&msg);
let last_idx = frame.len() - 1;
frame[last_idx] ^= 0xFF;
// Act
let mut dec = Decoder::new();
let events = dec.feed(&frame);
// Assert
assert!(events.iter().any(|e| matches!(e, DecoderEvent::Crc { .. })));
assert_eq!(dec.errors.snapshot().crc, 1);
}
#[test]
fn skips_unknown_message_id() {
// Arrange: hand-build a frame with msgid 999.
let body = [
0x00, // payload_len = 0
0x00, 0x00, 0x00, 0x01, 0xBE, // incompat, compat, seq, sysid, compid
0xE7, 0x03, 0x00, // msgid = 999, LE 3 bytes
];
let mut frame = Vec::new();
frame.push(MAVLINK_V2_STX);
frame.extend_from_slice(&body);
// Bogus CRC; the path returns UnknownId before CRC check anyway.
frame.extend_from_slice(&[0x00, 0x00]);
// Act
let mut dec = Decoder::new();
let events = dec.feed(&frame);
// Assert
assert!(events
.iter()
.any(|e| matches!(e, DecoderEvent::UnknownId { msg_id: 999, .. })));
assert_eq!(dec.errors.snapshot().unknown_id, 1);
}
}
@@ -0,0 +1,107 @@
//! MAVLink v2 frame encoder.
//!
//! The encoder owns the per-link outbound `tx_seq` counter and is the single
//! place that lays down the wire bytes.
use std::sync::atomic::{AtomicU8, Ordering};
use super::crc::frame_crc;
use super::messages::MavlinkMessage;
use super::{HEADER_LEN, MAVLINK_V2_STX};
#[derive(Debug)]
pub struct Encoder {
sysid: u8,
compid: u8,
tx_seq: AtomicU8,
}
impl Encoder {
pub fn new(sysid: u8, compid: u8) -> Self {
Self {
sysid,
compid,
tx_seq: AtomicU8::new(0),
}
}
pub fn sysid(&self) -> u8 {
self.sysid
}
pub fn compid(&self) -> u8 {
self.compid
}
/// Encode `msg` into a self-contained MAVLink v2 frame on the wire.
///
/// Trailing-zero payload bytes are truncated per the MAVLink spec. Each
/// call advances the per-link tx sequence counter by 1 with wrap-around.
pub fn encode(&self, msg: &MavlinkMessage) -> Vec<u8> {
let mut full_payload = Vec::with_capacity(64);
msg.encode_payload(&mut full_payload);
let payload_len = trailing_zero_truncated_len(&full_payload);
let msg_id = msg.msg_id();
let seq = self.tx_seq.fetch_add(1, Ordering::Relaxed);
let mut frame = Vec::with_capacity(HEADER_LEN + payload_len + 2);
frame.push(MAVLINK_V2_STX);
// Body that the CRC covers begins here.
let body_start = frame.len();
frame.push(payload_len as u8);
frame.push(0); // incompat_flags (no signing in this task — AZ-643)
frame.push(0); // compat_flags
frame.push(seq);
frame.push(self.sysid);
frame.push(self.compid);
frame.push((msg_id & 0xFF) as u8);
frame.push(((msg_id >> 8) & 0xFF) as u8);
frame.push(((msg_id >> 16) & 0xFF) as u8);
frame.extend_from_slice(&full_payload[..payload_len]);
let crc = frame_crc(&frame[body_start..], msg.crc_extra());
frame.extend_from_slice(&crc.to_le_bytes());
frame
}
}
#[inline]
fn trailing_zero_truncated_len(payload: &[u8]) -> usize {
let mut len = payload.len();
while len > 1 && payload[len - 1] == 0 {
len -= 1;
}
len
}
#[cfg(test)]
mod tests {
use super::*;
use crate::internal::codec::messages::Heartbeat;
#[test]
fn encoder_starts_at_seq_zero_and_increments() {
// Arrange
let enc = Encoder::new(1, 191);
let m = MavlinkMessage::Heartbeat(Heartbeat {
custom_mode: 1,
mavtype: 2,
autopilot: 3,
base_mode: 4,
system_status: 5,
mavlink_version: 3,
});
// Act
let f0 = enc.encode(&m);
let f1 = enc.encode(&m);
// Assert
assert_eq!(f0[0], MAVLINK_V2_STX);
assert_eq!(f0[4], 0, "first frame seq must be 0");
assert_eq!(f1[4], 1, "second frame seq must be 1");
}
}
@@ -0,0 +1,935 @@
//! Typed representations of every message in the §7.7 surface.
//!
//! For each message:
//! - `MSG_ID` is the MAVLink id used in the 3-byte msgid field.
//! - `CRC_EXTRA` is the message-specific crc-extra byte fed into the X.25 CRC.
//! - `SIZE` is the full payload length (before trailing-zero truncation).
//! - `encode_payload` writes the payload **at its full length**; trailing-zero
//! truncation is the framer's job.
//! - `decode_payload` accepts the **possibly truncated** payload and zero-pads
//! it back to `SIZE` before reading.
//!
//! Field ordering inside each payload follows the MAVLink size-sorted rule
//! (descending C primitive size, definition order on ties). This is the
//! invariant the [`CRC_EXTRA`] values were generated against; reordering any
//! field silently breaks wire compatibility with every peer.
use super::MavlinkParseError;
/// The codec-supported §7.7 surface as a single typed enum.
///
/// Adding a new variant requires explicit design-review notes per
/// `architecture.md §7.7`.
#[derive(Debug, Clone, PartialEq)]
pub enum MavlinkMessage {
Heartbeat(Heartbeat),
SysStatus(SysStatus),
SetMode(SetMode),
Attitude(Attitude),
GlobalPositionInt(GlobalPositionInt),
MissionSetCurrent(MissionSetCurrent),
MissionCurrent(MissionCurrent),
MissionCount(MissionCount),
MissionClearAll(MissionClearAll),
MissionItemReached(MissionItemReached),
MissionAck(MissionAck),
MissionRequestInt(MissionRequestInt),
MissionItemInt(MissionItemInt),
CommandLong(CommandLong),
CommandAck(CommandAck),
ExtendedSysState(ExtendedSysState),
StatusText(StatusText),
}
impl MavlinkMessage {
pub fn msg_id(&self) -> u32 {
match self {
Self::Heartbeat(_) => Heartbeat::MSG_ID,
Self::SysStatus(_) => SysStatus::MSG_ID,
Self::SetMode(_) => SetMode::MSG_ID,
Self::Attitude(_) => Attitude::MSG_ID,
Self::GlobalPositionInt(_) => GlobalPositionInt::MSG_ID,
Self::MissionSetCurrent(_) => MissionSetCurrent::MSG_ID,
Self::MissionCurrent(_) => MissionCurrent::MSG_ID,
Self::MissionCount(_) => MissionCount::MSG_ID,
Self::MissionClearAll(_) => MissionClearAll::MSG_ID,
Self::MissionItemReached(_) => MissionItemReached::MSG_ID,
Self::MissionAck(_) => MissionAck::MSG_ID,
Self::MissionRequestInt(_) => MissionRequestInt::MSG_ID,
Self::MissionItemInt(_) => MissionItemInt::MSG_ID,
Self::CommandLong(_) => CommandLong::MSG_ID,
Self::CommandAck(_) => CommandAck::MSG_ID,
Self::ExtendedSysState(_) => ExtendedSysState::MSG_ID,
Self::StatusText(_) => StatusText::MSG_ID,
}
}
pub fn crc_extra(&self) -> u8 {
match self {
Self::Heartbeat(_) => Heartbeat::CRC_EXTRA,
Self::SysStatus(_) => SysStatus::CRC_EXTRA,
Self::SetMode(_) => SetMode::CRC_EXTRA,
Self::Attitude(_) => Attitude::CRC_EXTRA,
Self::GlobalPositionInt(_) => GlobalPositionInt::CRC_EXTRA,
Self::MissionSetCurrent(_) => MissionSetCurrent::CRC_EXTRA,
Self::MissionCurrent(_) => MissionCurrent::CRC_EXTRA,
Self::MissionCount(_) => MissionCount::CRC_EXTRA,
Self::MissionClearAll(_) => MissionClearAll::CRC_EXTRA,
Self::MissionItemReached(_) => MissionItemReached::CRC_EXTRA,
Self::MissionAck(_) => MissionAck::CRC_EXTRA,
Self::MissionRequestInt(_) => MissionRequestInt::CRC_EXTRA,
Self::MissionItemInt(_) => MissionItemInt::CRC_EXTRA,
Self::CommandLong(_) => CommandLong::CRC_EXTRA,
Self::CommandAck(_) => CommandAck::CRC_EXTRA,
Self::ExtendedSysState(_) => ExtendedSysState::CRC_EXTRA,
Self::StatusText(_) => StatusText::CRC_EXTRA,
}
}
pub fn encode_payload(&self, buf: &mut Vec<u8>) {
match self {
Self::Heartbeat(m) => m.encode(buf),
Self::SysStatus(m) => m.encode(buf),
Self::SetMode(m) => m.encode(buf),
Self::Attitude(m) => m.encode(buf),
Self::GlobalPositionInt(m) => m.encode(buf),
Self::MissionSetCurrent(m) => m.encode(buf),
Self::MissionCurrent(m) => m.encode(buf),
Self::MissionCount(m) => m.encode(buf),
Self::MissionClearAll(m) => m.encode(buf),
Self::MissionItemReached(m) => m.encode(buf),
Self::MissionAck(m) => m.encode(buf),
Self::MissionRequestInt(m) => m.encode(buf),
Self::MissionItemInt(m) => m.encode(buf),
Self::CommandLong(m) => m.encode(buf),
Self::CommandAck(m) => m.encode(buf),
Self::ExtendedSysState(m) => m.encode(buf),
Self::StatusText(m) => m.encode(buf),
}
}
pub fn decode(msg_id: u32, payload: &[u8]) -> Result<Self, MavlinkParseError> {
match msg_id {
Heartbeat::MSG_ID => Ok(Self::Heartbeat(Heartbeat::decode(payload)?)),
SysStatus::MSG_ID => Ok(Self::SysStatus(SysStatus::decode(payload)?)),
SetMode::MSG_ID => Ok(Self::SetMode(SetMode::decode(payload)?)),
Attitude::MSG_ID => Ok(Self::Attitude(Attitude::decode(payload)?)),
GlobalPositionInt::MSG_ID => {
Ok(Self::GlobalPositionInt(GlobalPositionInt::decode(payload)?))
}
MissionSetCurrent::MSG_ID => {
Ok(Self::MissionSetCurrent(MissionSetCurrent::decode(payload)?))
}
MissionCurrent::MSG_ID => Ok(Self::MissionCurrent(MissionCurrent::decode(payload)?)),
MissionCount::MSG_ID => Ok(Self::MissionCount(MissionCount::decode(payload)?)),
MissionClearAll::MSG_ID => Ok(Self::MissionClearAll(MissionClearAll::decode(payload)?)),
MissionItemReached::MSG_ID => Ok(Self::MissionItemReached(MissionItemReached::decode(
payload,
)?)),
MissionAck::MSG_ID => Ok(Self::MissionAck(MissionAck::decode(payload)?)),
MissionRequestInt::MSG_ID => {
Ok(Self::MissionRequestInt(MissionRequestInt::decode(payload)?))
}
MissionItemInt::MSG_ID => Ok(Self::MissionItemInt(MissionItemInt::decode(payload)?)),
CommandLong::MSG_ID => Ok(Self::CommandLong(CommandLong::decode(payload)?)),
CommandAck::MSG_ID => Ok(Self::CommandAck(CommandAck::decode(payload)?)),
ExtendedSysState::MSG_ID => {
Ok(Self::ExtendedSysState(ExtendedSysState::decode(payload)?))
}
StatusText::MSG_ID => Ok(Self::StatusText(StatusText::decode(payload)?)),
other => Err(MavlinkParseError::UnknownMessageId(other)),
}
}
}
/// Resolve the message-specific `crc_extra` for an inbound msg id; returns
/// `None` for ids outside the §7.7 surface.
pub fn crc_extra_for_id(msg_id: u32) -> Option<u8> {
Some(match msg_id {
Heartbeat::MSG_ID => Heartbeat::CRC_EXTRA,
SysStatus::MSG_ID => SysStatus::CRC_EXTRA,
SetMode::MSG_ID => SetMode::CRC_EXTRA,
Attitude::MSG_ID => Attitude::CRC_EXTRA,
GlobalPositionInt::MSG_ID => GlobalPositionInt::CRC_EXTRA,
MissionSetCurrent::MSG_ID => MissionSetCurrent::CRC_EXTRA,
MissionCurrent::MSG_ID => MissionCurrent::CRC_EXTRA,
MissionCount::MSG_ID => MissionCount::CRC_EXTRA,
MissionClearAll::MSG_ID => MissionClearAll::CRC_EXTRA,
MissionItemReached::MSG_ID => MissionItemReached::CRC_EXTRA,
MissionAck::MSG_ID => MissionAck::CRC_EXTRA,
MissionRequestInt::MSG_ID => MissionRequestInt::CRC_EXTRA,
MissionItemInt::MSG_ID => MissionItemInt::CRC_EXTRA,
CommandLong::MSG_ID => CommandLong::CRC_EXTRA,
CommandAck::MSG_ID => CommandAck::CRC_EXTRA,
ExtendedSysState::MSG_ID => ExtendedSysState::CRC_EXTRA,
StatusText::MSG_ID => StatusText::CRC_EXTRA,
_ => return None,
})
}
// ===== helpers =====
#[inline]
fn need(payload: &[u8], required: usize) -> Result<(), MavlinkParseError> {
if payload.len() < required {
return Err(MavlinkParseError::TruncatedPayload {
have: payload.len(),
need: required,
});
}
Ok(())
}
/// Pad `payload` to `size` bytes with trailing zeros; MAVLink v2 truncates
/// trailing zeros on the wire and the decoder is required to re-extend.
fn padded(payload: &[u8], size: usize) -> [u8; 64] {
debug_assert!(size <= 64, "max single-message payload in §7.7 fits in 64B");
let mut buf = [0u8; 64];
let copy = payload.len().min(size);
buf[..copy].copy_from_slice(&payload[..copy]);
buf
}
#[inline]
fn read_u16(b: &[u8], at: usize) -> u16 {
u16::from_le_bytes([b[at], b[at + 1]])
}
#[inline]
fn read_i16(b: &[u8], at: usize) -> i16 {
i16::from_le_bytes([b[at], b[at + 1]])
}
#[inline]
fn read_u32(b: &[u8], at: usize) -> u32 {
u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
}
#[inline]
fn read_i32(b: &[u8], at: usize) -> i32 {
i32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
}
#[inline]
fn read_f32(b: &[u8], at: usize) -> f32 {
f32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
}
// ===== HEARTBEAT (id 0, crc_extra 50, size 9) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Heartbeat {
pub custom_mode: u32,
pub mavtype: u8,
pub autopilot: u8,
pub base_mode: u8,
pub system_status: u8,
pub mavlink_version: u8,
}
impl Heartbeat {
pub const MSG_ID: u32 = 0;
pub const CRC_EXTRA: u8 = 50;
pub const SIZE: usize = 9;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.extend_from_slice(&self.custom_mode.to_le_bytes());
buf.push(self.mavtype);
buf.push(self.autopilot);
buf.push(self.base_mode);
buf.push(self.system_status);
buf.push(self.mavlink_version);
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
custom_mode: read_u32(&b, 0),
mavtype: b[4],
autopilot: b[5],
base_mode: b[6],
system_status: b[7],
mavlink_version: b[8],
})
}
}
// ===== SYS_STATUS (id 1, crc_extra 124, size 31) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SysStatus {
pub onboard_control_sensors_present: u32,
pub onboard_control_sensors_enabled: u32,
pub onboard_control_sensors_health: u32,
pub load: u16,
pub voltage_battery: u16,
pub current_battery: i16,
pub drop_rate_comm: u16,
pub errors_comm: u16,
pub errors_count1: u16,
pub errors_count2: u16,
pub errors_count3: u16,
pub errors_count4: u16,
pub battery_remaining: i8,
}
impl SysStatus {
pub const MSG_ID: u32 = 1;
pub const CRC_EXTRA: u8 = 124;
pub const SIZE: usize = 31;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.extend_from_slice(&self.onboard_control_sensors_present.to_le_bytes());
buf.extend_from_slice(&self.onboard_control_sensors_enabled.to_le_bytes());
buf.extend_from_slice(&self.onboard_control_sensors_health.to_le_bytes());
buf.extend_from_slice(&self.load.to_le_bytes());
buf.extend_from_slice(&self.voltage_battery.to_le_bytes());
buf.extend_from_slice(&self.current_battery.to_le_bytes());
buf.extend_from_slice(&self.drop_rate_comm.to_le_bytes());
buf.extend_from_slice(&self.errors_comm.to_le_bytes());
buf.extend_from_slice(&self.errors_count1.to_le_bytes());
buf.extend_from_slice(&self.errors_count2.to_le_bytes());
buf.extend_from_slice(&self.errors_count3.to_le_bytes());
buf.extend_from_slice(&self.errors_count4.to_le_bytes());
buf.push(self.battery_remaining as u8);
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
onboard_control_sensors_present: read_u32(&b, 0),
onboard_control_sensors_enabled: read_u32(&b, 4),
onboard_control_sensors_health: read_u32(&b, 8),
load: read_u16(&b, 12),
voltage_battery: read_u16(&b, 14),
current_battery: read_i16(&b, 16),
drop_rate_comm: read_u16(&b, 18),
errors_comm: read_u16(&b, 20),
errors_count1: read_u16(&b, 22),
errors_count2: read_u16(&b, 24),
errors_count3: read_u16(&b, 26),
errors_count4: read_u16(&b, 28),
battery_remaining: b[30] as i8,
})
}
}
// ===== SET_MODE (id 11, crc_extra 89, size 6) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SetMode {
pub custom_mode: u32,
pub target_system: u8,
pub base_mode: u8,
}
impl SetMode {
pub const MSG_ID: u32 = 11;
pub const CRC_EXTRA: u8 = 89;
pub const SIZE: usize = 6;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.extend_from_slice(&self.custom_mode.to_le_bytes());
buf.push(self.target_system);
buf.push(self.base_mode);
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
custom_mode: read_u32(&b, 0),
target_system: b[4],
base_mode: b[5],
})
}
}
// ===== ATTITUDE (id 30, crc_extra 39, size 28) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Attitude {
pub time_boot_ms: u32,
pub roll: f32,
pub pitch: f32,
pub yaw: f32,
pub rollspeed: f32,
pub pitchspeed: f32,
pub yawspeed: f32,
}
impl Attitude {
pub const MSG_ID: u32 = 30;
pub const CRC_EXTRA: u8 = 39;
pub const SIZE: usize = 28;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.extend_from_slice(&self.time_boot_ms.to_le_bytes());
buf.extend_from_slice(&self.roll.to_le_bytes());
buf.extend_from_slice(&self.pitch.to_le_bytes());
buf.extend_from_slice(&self.yaw.to_le_bytes());
buf.extend_from_slice(&self.rollspeed.to_le_bytes());
buf.extend_from_slice(&self.pitchspeed.to_le_bytes());
buf.extend_from_slice(&self.yawspeed.to_le_bytes());
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
time_boot_ms: read_u32(&b, 0),
roll: read_f32(&b, 4),
pitch: read_f32(&b, 8),
yaw: read_f32(&b, 12),
rollspeed: read_f32(&b, 16),
pitchspeed: read_f32(&b, 20),
yawspeed: read_f32(&b, 24),
})
}
}
// ===== GLOBAL_POSITION_INT (id 33, crc_extra 104, size 28) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GlobalPositionInt {
pub time_boot_ms: u32,
pub lat_e7: i32,
pub lon_e7: i32,
pub alt_mm: i32,
pub relative_alt_mm: i32,
pub vx_cmps: i16,
pub vy_cmps: i16,
pub vz_cmps: i16,
pub hdg_cdeg: u16,
}
impl GlobalPositionInt {
pub const MSG_ID: u32 = 33;
pub const CRC_EXTRA: u8 = 104;
pub const SIZE: usize = 28;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.extend_from_slice(&self.time_boot_ms.to_le_bytes());
buf.extend_from_slice(&self.lat_e7.to_le_bytes());
buf.extend_from_slice(&self.lon_e7.to_le_bytes());
buf.extend_from_slice(&self.alt_mm.to_le_bytes());
buf.extend_from_slice(&self.relative_alt_mm.to_le_bytes());
buf.extend_from_slice(&self.vx_cmps.to_le_bytes());
buf.extend_from_slice(&self.vy_cmps.to_le_bytes());
buf.extend_from_slice(&self.vz_cmps.to_le_bytes());
buf.extend_from_slice(&self.hdg_cdeg.to_le_bytes());
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
time_boot_ms: read_u32(&b, 0),
lat_e7: read_i32(&b, 4),
lon_e7: read_i32(&b, 8),
alt_mm: read_i32(&b, 12),
relative_alt_mm: read_i32(&b, 16),
vx_cmps: read_i16(&b, 20),
vy_cmps: read_i16(&b, 22),
vz_cmps: read_i16(&b, 24),
hdg_cdeg: read_u16(&b, 26),
})
}
}
// ===== MISSION_SET_CURRENT (id 41, crc_extra 28, size 4) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MissionSetCurrent {
pub seq: u16,
pub target_system: u8,
pub target_component: u8,
}
impl MissionSetCurrent {
pub const MSG_ID: u32 = 41;
pub const CRC_EXTRA: u8 = 28;
pub const SIZE: usize = 4;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.extend_from_slice(&self.seq.to_le_bytes());
buf.push(self.target_system);
buf.push(self.target_component);
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
seq: read_u16(&b, 0),
target_system: b[2],
target_component: b[3],
})
}
}
// ===== MISSION_CURRENT (id 42, crc_extra 28, size 2) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MissionCurrent {
pub seq: u16,
}
impl MissionCurrent {
pub const MSG_ID: u32 = 42;
pub const CRC_EXTRA: u8 = 28;
pub const SIZE: usize = 2;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.extend_from_slice(&self.seq.to_le_bytes());
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
seq: read_u16(&b, 0),
})
}
}
// ===== MISSION_COUNT (id 44, crc_extra 221, size 4) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MissionCount {
pub count: u16,
pub target_system: u8,
pub target_component: u8,
}
impl MissionCount {
pub const MSG_ID: u32 = 44;
pub const CRC_EXTRA: u8 = 221;
pub const SIZE: usize = 4;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.extend_from_slice(&self.count.to_le_bytes());
buf.push(self.target_system);
buf.push(self.target_component);
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
count: read_u16(&b, 0),
target_system: b[2],
target_component: b[3],
})
}
}
// ===== MISSION_CLEAR_ALL (id 45, crc_extra 232, size 2) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MissionClearAll {
pub target_system: u8,
pub target_component: u8,
}
impl MissionClearAll {
pub const MSG_ID: u32 = 45;
pub const CRC_EXTRA: u8 = 232;
pub const SIZE: usize = 2;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.push(self.target_system);
buf.push(self.target_component);
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
target_system: b[0],
target_component: b[1],
})
}
}
// ===== MISSION_ITEM_REACHED (id 46, crc_extra 11, size 2) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MissionItemReached {
pub seq: u16,
}
impl MissionItemReached {
pub const MSG_ID: u32 = 46;
pub const CRC_EXTRA: u8 = 11;
pub const SIZE: usize = 2;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.extend_from_slice(&self.seq.to_le_bytes());
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
seq: read_u16(&b, 0),
})
}
}
// ===== MISSION_ACK (id 47, crc_extra 153, size 3) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MissionAck {
pub target_system: u8,
pub target_component: u8,
pub mission_result: u8,
}
impl MissionAck {
pub const MSG_ID: u32 = 47;
pub const CRC_EXTRA: u8 = 153;
pub const SIZE: usize = 3;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.push(self.target_system);
buf.push(self.target_component);
buf.push(self.mission_result);
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
target_system: b[0],
target_component: b[1],
mission_result: b[2],
})
}
}
// ===== MISSION_REQUEST_INT (id 51, crc_extra 38, size 4) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MissionRequestInt {
pub seq: u16,
pub target_system: u8,
pub target_component: u8,
}
impl MissionRequestInt {
pub const MSG_ID: u32 = 51;
pub const CRC_EXTRA: u8 = 38;
pub const SIZE: usize = 4;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.extend_from_slice(&self.seq.to_le_bytes());
buf.push(self.target_system);
buf.push(self.target_component);
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
seq: read_u16(&b, 0),
target_system: b[2],
target_component: b[3],
})
}
}
// ===== MISSION_ITEM_INT (id 73, crc_extra 38, size 37) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MissionItemInt {
pub param1: f32,
pub param2: f32,
pub param3: f32,
pub param4: f32,
pub x: i32,
pub y: i32,
pub z: f32,
pub seq: u16,
pub command: u16,
pub target_system: u8,
pub target_component: u8,
pub frame: u8,
pub current: u8,
pub autocontinue: u8,
}
impl MissionItemInt {
pub const MSG_ID: u32 = 73;
pub const CRC_EXTRA: u8 = 38;
pub const SIZE: usize = 37;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.extend_from_slice(&self.param1.to_le_bytes());
buf.extend_from_slice(&self.param2.to_le_bytes());
buf.extend_from_slice(&self.param3.to_le_bytes());
buf.extend_from_slice(&self.param4.to_le_bytes());
buf.extend_from_slice(&self.x.to_le_bytes());
buf.extend_from_slice(&self.y.to_le_bytes());
buf.extend_from_slice(&self.z.to_le_bytes());
buf.extend_from_slice(&self.seq.to_le_bytes());
buf.extend_from_slice(&self.command.to_le_bytes());
buf.push(self.target_system);
buf.push(self.target_component);
buf.push(self.frame);
buf.push(self.current);
buf.push(self.autocontinue);
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
param1: read_f32(&b, 0),
param2: read_f32(&b, 4),
param3: read_f32(&b, 8),
param4: read_f32(&b, 12),
x: read_i32(&b, 16),
y: read_i32(&b, 20),
z: read_f32(&b, 24),
seq: read_u16(&b, 28),
command: read_u16(&b, 30),
target_system: b[32],
target_component: b[33],
frame: b[34],
current: b[35],
autocontinue: b[36],
})
}
}
// ===== COMMAND_LONG (id 76, crc_extra 152, size 33) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CommandLong {
pub param1: f32,
pub param2: f32,
pub param3: f32,
pub param4: f32,
pub param5: f32,
pub param6: f32,
pub param7: f32,
pub command: u16,
pub target_system: u8,
pub target_component: u8,
pub confirmation: u8,
}
impl CommandLong {
pub const MSG_ID: u32 = 76;
pub const CRC_EXTRA: u8 = 152;
pub const SIZE: usize = 33;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.extend_from_slice(&self.param1.to_le_bytes());
buf.extend_from_slice(&self.param2.to_le_bytes());
buf.extend_from_slice(&self.param3.to_le_bytes());
buf.extend_from_slice(&self.param4.to_le_bytes());
buf.extend_from_slice(&self.param5.to_le_bytes());
buf.extend_from_slice(&self.param6.to_le_bytes());
buf.extend_from_slice(&self.param7.to_le_bytes());
buf.extend_from_slice(&self.command.to_le_bytes());
buf.push(self.target_system);
buf.push(self.target_component);
buf.push(self.confirmation);
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
param1: read_f32(&b, 0),
param2: read_f32(&b, 4),
param3: read_f32(&b, 8),
param4: read_f32(&b, 12),
param5: read_f32(&b, 16),
param6: read_f32(&b, 20),
param7: read_f32(&b, 24),
command: read_u16(&b, 28),
target_system: b[30],
target_component: b[31],
confirmation: b[32],
})
}
}
// ===== COMMAND_ACK (id 77, crc_extra 143, size 3) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CommandAck {
pub command: u16,
pub result: u8,
}
impl CommandAck {
pub const MSG_ID: u32 = 77;
pub const CRC_EXTRA: u8 = 143;
pub const SIZE: usize = 3;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.extend_from_slice(&self.command.to_le_bytes());
buf.push(self.result);
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
command: read_u16(&b, 0),
result: b[2],
})
}
}
// ===== EXTENDED_SYS_STATE (id 245, crc_extra 130, size 2) =====
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ExtendedSysState {
pub vtol_state: u8,
pub landed_state: u8,
}
impl ExtendedSysState {
pub const MSG_ID: u32 = 245;
pub const CRC_EXTRA: u8 = 130;
pub const SIZE: usize = 2;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.push(self.vtol_state);
buf.push(self.landed_state);
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
let b = padded(payload, Self::SIZE);
Ok(Self {
vtol_state: b[0],
landed_state: b[1],
})
}
}
// ===== STATUSTEXT (id 253, crc_extra 83, size 51) =====
#[derive(Debug, Clone, PartialEq)]
pub struct StatusText {
pub severity: u8,
/// 50-byte null-padded ASCII; values beyond the first NUL are ignored on
/// decode. Encoding zero-pads to 50 bytes.
pub text: [u8; 50],
}
impl StatusText {
pub const MSG_ID: u32 = 253;
pub const CRC_EXTRA: u8 = 83;
pub const SIZE: usize = 51;
pub fn encode(&self, buf: &mut Vec<u8>) {
buf.push(self.severity);
buf.extend_from_slice(&self.text);
}
pub fn decode(payload: &[u8]) -> Result<Self, MavlinkParseError> {
need(payload, 1)?;
let mut text = [0u8; 50];
let body_len = payload.len() - 1;
let copy = body_len.min(50);
text[..copy].copy_from_slice(&payload[1..1 + copy]);
Ok(Self {
severity: payload[0],
text,
})
}
/// Build a `StatusText` from a UTF-8 string, truncating to 50 bytes and
/// zero-padding the rest.
pub fn from_str(severity: u8, msg: &str) -> Self {
let bytes = msg.as_bytes();
let mut text = [0u8; 50];
let copy = bytes.len().min(50);
text[..copy].copy_from_slice(&bytes[..copy]);
Self { severity, text }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heartbeat_round_trips() {
// Arrange
let m = Heartbeat {
custom_mode: 0xDEADBEEF,
mavtype: 2,
autopilot: 3,
base_mode: 0x81,
system_status: 4,
mavlink_version: 3,
};
// Act
let mut buf = Vec::new();
m.encode(&mut buf);
let decoded = Heartbeat::decode(&buf).unwrap();
// Assert
assert_eq!(buf.len(), Heartbeat::SIZE);
assert_eq!(decoded, m);
}
#[test]
fn command_long_round_trips() {
// Arrange
let m = CommandLong {
param1: 1.5,
param2: 2.25,
param3: -3.0,
param4: f32::NAN,
param5: 50.123,
param6: -42.42,
param7: 100.0,
command: 20, // MAV_CMD_NAV_RETURN_TO_LAUNCH
target_system: 1,
target_component: 1,
confirmation: 0,
};
// Act
let mut buf = Vec::new();
m.encode(&mut buf);
let decoded = CommandLong::decode(&buf).unwrap();
// Assert: NaN compares unequal so compare bit pattern manually
assert_eq!(buf.len(), CommandLong::SIZE);
assert_eq!(decoded.param1, m.param1);
assert!(decoded.param4.is_nan());
assert_eq!(decoded.command, m.command);
assert_eq!(decoded.confirmation, m.confirmation);
}
#[test]
fn statustext_truncates_long_string() {
// Arrange
let long = "x".repeat(100);
// Act
let m = StatusText::from_str(6, &long);
// Assert
assert_eq!(m.text[..50], [b'x'; 50][..]);
}
#[test]
fn decode_truncated_heartbeat_zero_pads() {
// Arrange: a HEARTBEAT payload trimmed of its trailing mavlink_version byte
let mut buf = Vec::new();
Heartbeat {
custom_mode: 7,
mavtype: 2,
autopilot: 3,
base_mode: 0,
system_status: 4,
mavlink_version: 3,
}
.encode(&mut buf);
let truncated = &buf[..Heartbeat::SIZE - 1];
// Act
let decoded = Heartbeat::decode(truncated).unwrap();
// Assert: the trimmed byte is read as zero (MAVLink v2 trailing-zero rule).
assert_eq!(decoded.mavlink_version, 0);
}
}
@@ -0,0 +1,46 @@
//! MAVLink v2 codec for the §7.7 command surface (per `architecture.md`).
//!
//! Strictly hand-rolled — no third-party MAVLink SDK. Adding any message
//! outside the §7.7 surface enumerated in [`MavlinkMessage`] requires an
//! explicit design-review note in the PR description.
pub mod crc;
pub mod decoder;
pub mod encoder;
pub mod messages;
pub mod parse_errors;
pub use decoder::{Decoder, DecoderEvent};
pub use encoder::Encoder;
pub use messages::{
Attitude, CommandAck, CommandLong, ExtendedSysState, GlobalPositionInt, Heartbeat,
MavlinkMessage, MissionAck, MissionClearAll, MissionCount, MissionCurrent, MissionItemInt,
MissionItemReached, MissionRequestInt, MissionSetCurrent, SetMode, StatusText, SysStatus,
};
pub use parse_errors::{ParseErrorKind, ParseErrors};
/// MAVLink v2 frame start byte.
pub const MAVLINK_V2_STX: u8 = 0xFD;
/// Frame header size in bytes (STX..msgid inclusive).
pub const HEADER_LEN: usize = 10;
/// CRC trailer length in bytes.
#[allow(dead_code)] // Used in the AZ-642 integration tests below and by AZ-643 signing math.
pub const CRC_LEN: usize = 2;
/// Signature trailer length when `incompat_flags` bit 0 is set.
pub const SIGNATURE_LEN: usize = 13;
/// Maximum possible payload length (255 per the spec).
pub const MAX_PAYLOAD: usize = 255;
/// Incompat-flag bit indicating a signed frame.
pub const INCOMPAT_FLAG_SIGNED: u8 = 0x01;
#[derive(Debug, thiserror::Error)]
pub enum MavlinkParseError {
#[error("truncated payload (have {have} bytes, need {need})")]
TruncatedPayload { have: usize, need: usize },
#[error("wrong CRC (computed {computed:#06x}, frame {frame:#06x})")]
CrcMismatch { computed: u16, frame: u16 },
#[error("unknown message id {0}")]
UnknownMessageId(u32),
#[error("invalid payload for message id {msg_id}: {reason}")]
InvalidPayload { msg_id: u32, reason: &'static str },
}
@@ -0,0 +1,90 @@
//! Per-kind parse-error counters surfaced in `MavlinkLayer::health()`.
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ParseErrorKind {
/// Frame failed CRC verification.
Crc,
/// Frame payload was shorter than the header advertised.
Truncated,
/// Frame's message id is outside the §7.7 surface.
UnknownId,
/// Per-link sequence number jumped (logged but not fatal).
SequenceGap,
/// Message-specific payload decode failed (e.g. enum out of range).
InvalidPayload,
}
#[derive(Debug, Default)]
pub struct ParseErrors {
crc: AtomicU64,
truncated: AtomicU64,
unknown_id: AtomicU64,
sequence_gap: AtomicU64,
invalid_payload: AtomicU64,
}
impl ParseErrors {
pub fn new() -> Self {
Self::default()
}
pub fn record(&self, kind: ParseErrorKind) {
let cell = match kind {
ParseErrorKind::Crc => &self.crc,
ParseErrorKind::Truncated => &self.truncated,
ParseErrorKind::UnknownId => &self.unknown_id,
ParseErrorKind::SequenceGap => &self.sequence_gap,
ParseErrorKind::InvalidPayload => &self.invalid_payload,
};
cell.fetch_add(1, Ordering::Relaxed);
}
pub fn snapshot(&self) -> ParseErrorsSnapshot {
ParseErrorsSnapshot {
crc: self.crc.load(Ordering::Relaxed),
truncated: self.truncated.load(Ordering::Relaxed),
unknown_id: self.unknown_id.load(Ordering::Relaxed),
sequence_gap: self.sequence_gap.load(Ordering::Relaxed),
invalid_payload: self.invalid_payload.load(Ordering::Relaxed),
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ParseErrorsSnapshot {
pub crc: u64,
pub truncated: u64,
pub unknown_id: u64,
pub sequence_gap: u64,
pub invalid_payload: u64,
}
impl ParseErrorsSnapshot {
pub fn total(&self) -> u64 {
self.crc + self.truncated + self.unknown_id + self.sequence_gap + self.invalid_payload
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_increment_independently() {
// Arrange
let pe = ParseErrors::new();
// Act
pe.record(ParseErrorKind::Crc);
pe.record(ParseErrorKind::UnknownId);
pe.record(ParseErrorKind::UnknownId);
// Assert
let snap = pe.snapshot();
assert_eq!(snap.crc, 1);
assert_eq!(snap.unknown_id, 2);
assert_eq!(snap.total(), 3);
}
}
@@ -0,0 +1,179 @@
//! 1 Hz outbound HEARTBEAT scheduling + inbound-heartbeat timeout tracking.
//!
//! Per AZ-641: emit a HEARTBEAT every 1 s ± 50 ms; if the autopilot stops
//! emitting heartbeats for longer than `link_lost_timeout` (default 3 s),
//! flip the link state to `lost` and fire a typed `LinkLost` signal.
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::broadcast;
use crate::internal::codec::messages::{Heartbeat, MavlinkMessage};
/// MAVLink type byte for "onboard companion computer" / generic offboard.
const MAV_TYPE_ONBOARD_COMPUTER: u8 = 18; // MAV_TYPE_ONBOARD_CONTROLLER
const MAV_AUTOPILOT_INVALID: u8 = 8;
const MAV_STATE_ACTIVE: u8 = 4;
const MAVLINK_VERSION: u8 = 3;
/// Default emit cadence in milliseconds (1 Hz).
pub const HEARTBEAT_PERIOD_MS: u64 = 1000;
/// Default wall-clock timeout before flipping to LinkLost.
pub const DEFAULT_LINK_TIMEOUT_MS: u64 = 3000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkEvent {
/// The link became healthy (first inbound heartbeat after init or recovery).
LinkUp,
/// No inbound heartbeat within timeout.
LinkLost,
}
/// Inbound-heartbeat watchdog shared between the run loop and the health surface.
#[derive(Debug)]
pub struct InboundWatchdog {
/// Last inbound heartbeat at this monotonic ms-since-start (negative = never seen).
last_inbound_ms: AtomicI64,
/// Outbound heartbeats sent since start (for diagnostics).
outbound_count: AtomicU64,
/// Currently considered link-up.
link_up: std::sync::atomic::AtomicBool,
timeout_ms: u64,
started: std::time::Instant,
signal: broadcast::Sender<LinkEvent>,
}
impl InboundWatchdog {
pub fn new(timeout_ms: u64) -> (Arc<Self>, broadcast::Receiver<LinkEvent>) {
let (tx, rx) = broadcast::channel(16);
(
Arc::new(Self {
last_inbound_ms: AtomicI64::new(-1),
outbound_count: AtomicU64::new(0),
link_up: std::sync::atomic::AtomicBool::new(false),
timeout_ms,
started: std::time::Instant::now(),
signal: tx,
}),
rx,
)
}
/// Record that we just observed an inbound heartbeat from the peer.
pub fn note_inbound_heartbeat(&self) {
let now = self.elapsed_ms();
self.last_inbound_ms.store(now, Ordering::Relaxed);
let was_up = self.link_up.swap(true, Ordering::SeqCst);
if !was_up {
let _ = self.signal.send(LinkEvent::LinkUp);
}
}
/// Record an outbound heartbeat we just emitted (used in health detail).
pub fn note_outbound_heartbeat(&self) {
self.outbound_count.fetch_add(1, Ordering::Relaxed);
}
/// Returns true if the link timeout has been exceeded.
pub fn check_timeout_now(&self) -> bool {
let last = self.last_inbound_ms.load(Ordering::Relaxed);
if last < 0 {
// Never seen an inbound heartbeat — count from start so that we
// surface `LinkLost` quickly when the peer is unreachable.
return self.elapsed_ms() > self.timeout_ms as i64;
}
(self.elapsed_ms() - last) > self.timeout_ms as i64
}
/// Trip the link to LinkLost if currently up and timeout has elapsed; idempotent.
pub fn maybe_trip_link_lost(&self) -> bool {
if self.check_timeout_now() {
let was_up = self.link_up.swap(false, Ordering::SeqCst);
if was_up {
let _ = self.signal.send(LinkEvent::LinkLost);
return true;
}
}
false
}
pub fn last_inbound_age_ms(&self) -> Option<u64> {
let last = self.last_inbound_ms.load(Ordering::Relaxed);
if last < 0 {
None
} else {
Some((self.elapsed_ms() - last).max(0) as u64)
}
}
pub fn outbound_total(&self) -> u64 {
self.outbound_count.load(Ordering::Relaxed)
}
pub fn link_up(&self) -> bool {
self.link_up.load(Ordering::Relaxed)
}
pub fn subscribe(&self) -> broadcast::Receiver<LinkEvent> {
self.signal.subscribe()
}
fn elapsed_ms(&self) -> i64 {
self.started.elapsed().as_millis() as i64
}
}
/// Build the canonical outbound HEARTBEAT message announcing our identity to
/// the autopilot peer.
pub fn make_outbound_heartbeat() -> MavlinkMessage {
MavlinkMessage::Heartbeat(Heartbeat {
custom_mode: 0,
mavtype: MAV_TYPE_ONBOARD_COMPUTER,
autopilot: MAV_AUTOPILOT_INVALID,
base_mode: 0,
system_status: MAV_STATE_ACTIVE,
mavlink_version: MAVLINK_VERSION,
})
}
/// Period at which the outbound heartbeat is scheduled.
pub fn heartbeat_period() -> Duration {
Duration::from_millis(HEARTBEAT_PERIOD_MS)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn trips_link_lost_after_timeout() {
// Arrange — 100 ms timeout; we wait 150 ms of real time after one inbound HB.
let (wd, mut rx) = InboundWatchdog::new(100);
wd.note_inbound_heartbeat();
assert_eq!(rx.recv().await.unwrap(), LinkEvent::LinkUp);
assert!(wd.link_up());
// Act
tokio::time::sleep(Duration::from_millis(150)).await;
let tripped = wd.maybe_trip_link_lost();
// Assert
assert!(tripped);
assert!(!wd.link_up());
assert_eq!(rx.recv().await.unwrap(), LinkEvent::LinkLost);
}
#[test]
fn outbound_heartbeat_is_well_formed() {
// Act
let MavlinkMessage::Heartbeat(h) = make_outbound_heartbeat() else {
panic!("expected Heartbeat");
};
// Assert
assert_eq!(h.mavtype, MAV_TYPE_ONBOARD_COMPUTER);
assert_eq!(h.mavlink_version, MAVLINK_VERSION);
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod codec;
pub mod heartbeat;
pub mod retry;
pub mod transport;
pub mod uri;
@@ -0,0 +1,90 @@
//! Bounded exponential backoff helper used by the transport reconnect loop.
//!
//! Caller pattern:
//! ```text
//! let mut backoff = ExponentialBackoff::new(base, cap);
//! loop {
//! match try_open().await {
//! Ok(t) => break t,
//! Err(e) => {
//! tracing::warn!(error = %e, "open failed");
//! tokio::time::sleep(backoff.next_delay()).await;
//! }
//! }
//! }
//! ```
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct ExponentialBackoff {
base: Duration,
cap: Duration,
attempt: u32,
}
impl ExponentialBackoff {
pub fn new(base: Duration, cap: Duration) -> Self {
assert!(base > Duration::ZERO, "backoff base must be positive");
assert!(cap >= base, "backoff cap must be >= base");
Self {
base,
cap,
attempt: 0,
}
}
/// The next delay to sleep for. Doubles each call, capped at `cap`.
pub fn next_delay(&mut self) -> Duration {
let exp = self.attempt.min(31);
let delay = self
.base
.checked_mul(1u32 << exp)
.unwrap_or(self.cap)
.min(self.cap);
self.attempt = self.attempt.saturating_add(1);
delay
}
pub fn reset(&mut self) {
self.attempt = 0;
}
pub fn attempts(&self) -> u32 {
self.attempt
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn doubles_until_cap() {
// Arrange
let mut b = ExponentialBackoff::new(Duration::from_millis(100), Duration::from_secs(2));
// Act / Assert
assert_eq!(b.next_delay(), Duration::from_millis(100));
assert_eq!(b.next_delay(), Duration::from_millis(200));
assert_eq!(b.next_delay(), Duration::from_millis(400));
assert_eq!(b.next_delay(), Duration::from_millis(800));
assert_eq!(b.next_delay(), Duration::from_millis(1600));
assert_eq!(b.next_delay(), Duration::from_secs(2)); // capped
assert_eq!(b.next_delay(), Duration::from_secs(2)); // still capped
}
#[test]
fn reset_returns_to_base() {
// Arrange
let mut b = ExponentialBackoff::new(Duration::from_millis(50), Duration::from_secs(1));
let _ = b.next_delay();
let _ = b.next_delay();
// Act
b.reset();
// Assert
assert_eq!(b.next_delay(), Duration::from_millis(50));
}
}
@@ -0,0 +1,20 @@
//! Abstract async transport trait — implemented by [`udp`] and [`serial`].
pub mod serial;
pub mod udp;
use async_trait::async_trait;
use shared::error::Result;
/// Asynchronous, bidirectional byte transport over UDP or serial.
///
/// Implementations are expected to be cancellation-safe at await points so the
/// run loop can drop them on shutdown.
#[async_trait]
pub trait Transport: Send + Sync {
/// Read up to `buf.len()` bytes; returns the number actually read.
async fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
/// Write the entire `buf` to the peer.
async fn write_all(&mut self, buf: &[u8]) -> Result<()>;
}
@@ -0,0 +1,46 @@
//! POSIX serial transport via `tokio-serial`.
use async_trait::async_trait;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_serial::SerialPortBuilderExt;
use shared::error::{AutopilotError, Result};
use super::Transport;
#[derive(Debug)]
pub struct SerialTransport {
port: tokio_serial::SerialStream,
}
impl SerialTransport {
pub fn open(path: &str, baud: u32) -> Result<Self> {
let port = tokio_serial::new(path, baud)
.timeout(std::time::Duration::from_millis(500))
.data_bits(tokio_serial::DataBits::Eight)
.parity(tokio_serial::Parity::None)
.stop_bits(tokio_serial::StopBits::One)
.flow_control(tokio_serial::FlowControl::None)
.open_native_async()
.map_err(|e| AutopilotError::Network(format!("serial open {path}: {e}")))?;
Ok(Self { port })
}
}
#[async_trait]
impl Transport for SerialTransport {
async fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
self.port
.read(buf)
.await
.map_err(|e| AutopilotError::Network(format!("serial read: {e}")))
}
async fn write_all(&mut self, buf: &[u8]) -> Result<()> {
self.port
.write_all(buf)
.await
.map_err(|e| AutopilotError::Network(format!("serial write: {e}")))?;
Ok(())
}
}
@@ -0,0 +1,58 @@
//! UDP transport.
//!
//! Single-process MAVLink links over UDP behave like a connected datagram pair:
//! the autopilot binds a local port and exchanges datagrams with the peer.
//! Here we bind to the OS-chosen local port `0.0.0.0:0` and `connect` to the
//! configured peer so the socket can be used like a stream.
use async_trait::async_trait;
use tokio::net::UdpSocket;
use shared::error::{AutopilotError, Result};
use super::Transport;
#[derive(Debug)]
pub struct UdpTransport {
socket: UdpSocket,
}
impl UdpTransport {
/// Bind a local UDP socket and `connect` it to `peer`, so subsequent
/// `send` / `recv` calls behave like a stream.
pub async fn connect(peer: &str) -> Result<Self> {
let socket = UdpSocket::bind("0.0.0.0:0")
.await
.map_err(|e| AutopilotError::Network(format!("udp bind failed: {e}")))?;
socket
.connect(peer)
.await
.map_err(|e| AutopilotError::Network(format!("udp connect failed: {e}")))?;
Ok(Self { socket })
}
#[allow(dead_code)] // Used by the AZ-641 UDP integration tests in `tests/`.
pub fn local_addr(&self) -> Result<std::net::SocketAddr> {
self.socket
.local_addr()
.map_err(|e| AutopilotError::Network(format!("udp local_addr failed: {e}")))
}
}
#[async_trait]
impl Transport for UdpTransport {
async fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
self.socket
.recv(buf)
.await
.map_err(|e| AutopilotError::Network(format!("udp recv: {e}")))
}
async fn write_all(&mut self, buf: &[u8]) -> Result<()> {
self.socket
.send(buf)
.await
.map_err(|e| AutopilotError::Network(format!("udp send: {e}")))?;
Ok(())
}
}
+115
View File
@@ -0,0 +1,115 @@
//! Connection URI parser.
//!
//! Supported shapes (picked once at startup — no runtime swap):
//! - `udp://<host>:<port>` — UDP listener / sender pair.
//! - `serial:///dev/<path>` (or `serial:///dev/<path>?baud=<N>`) — POSIX serial.
//!
//! Anything else is a configuration error surfaced via [`AutopilotError::Config`].
use shared::error::{AutopilotError, Result};
/// Default baud rate for ArduPilot serial telem links per the SITL setups we
/// run against (see `architecture.md §10`).
pub const DEFAULT_SERIAL_BAUD: u32 = 57_600;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConnectionUri {
/// UDP. The peer endpoint to which we both bind and send.
Udp { host: String, port: u16 },
/// POSIX serial device.
Serial { path: String, baud: u32 },
}
impl ConnectionUri {
pub fn parse(s: &str) -> Result<Self> {
if let Some(rest) = s.strip_prefix("udp://") {
let (host, port) = rest
.rsplit_once(':')
.ok_or_else(|| AutopilotError::Config(format!("udp uri missing :port — {s}")))?;
let port: u16 = port
.parse()
.map_err(|_| AutopilotError::Config(format!("invalid udp port — {s}")))?;
if host.is_empty() {
return Err(AutopilotError::Config(format!(
"udp uri missing host — {s}"
)));
}
return Ok(Self::Udp {
host: host.to_owned(),
port,
});
}
if let Some(rest) = s.strip_prefix("serial://") {
let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
if path.is_empty() {
return Err(AutopilotError::Config(format!(
"serial uri missing device path — {s}"
)));
}
let mut baud = DEFAULT_SERIAL_BAUD;
for kv in query.split('&').filter(|p| !p.is_empty()) {
let (k, v) = kv.split_once('=').ok_or_else(|| {
AutopilotError::Config(format!("bad serial query token — {kv}"))
})?;
if k == "baud" {
baud = v
.parse()
.map_err(|_| AutopilotError::Config(format!("invalid baud rate — {v}")))?;
}
}
return Ok(Self::Serial {
path: path.to_owned(),
baud,
});
}
Err(AutopilotError::Config(format!(
"unsupported mavlink connection uri scheme — {s}"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_udp() {
// Act
let uri = ConnectionUri::parse("udp://127.0.0.1:14550").unwrap();
// Assert
assert_eq!(
uri,
ConnectionUri::Udp {
host: "127.0.0.1".to_owned(),
port: 14_550,
}
);
}
#[test]
fn parses_serial_with_baud_override() {
// Act
let uri = ConnectionUri::parse("serial:///dev/ttyUSB0?baud=115200").unwrap();
// Assert
assert_eq!(
uri,
ConnectionUri::Serial {
path: "/dev/ttyUSB0".to_owned(),
baud: 115_200,
}
);
}
#[test]
fn rejects_unknown_scheme() {
// Act
let err = ConnectionUri::parse("tcp://host:1234").unwrap_err();
// Assert
assert!(matches!(err, AutopilotError::Config(_)));
}
}
+404 -28
View File
@@ -1,64 +1,426 @@
//! `mavlink_layer` — hand-rolled MAVLink v2 transport.
//! `mavlink_layer` — hand-rolled MAVLink v2 transport + codec.
//!
//! Real implementation lands in:
//! - AZ-641 `mavlink_transport_and_heartbeat`
//! - AZ-642 `mavlink_codec`
//! - AZ-643 `mavlink_ack_demux_and_signing`
//! Public surface (per `module-layout.md`):
//! - [`MavlinkLayer`] — actor; runs the open / reconnect loop and the
//! per-link read+write loop.
//! - [`MavlinkHandle`] — clonable handle; lets callers send outbound
//! messages, subscribe to inbound messages, subscribe to link events, and
//! inspect health.
//! - [`MavlinkConnection`] — typed URI wrapper used by callers that want the
//! stricter form. [`MavlinkLayerOptions`] is the constructor argument.
//! - Codec types (`MavlinkMessage`, the per-message structs) re-exported
//! from `internal::codec`.
//!
//! Real implementation tasks: AZ-641 (transport + heartbeat), AZ-642 (codec),
//! AZ-643 (ack demux + signing — future).
mod internal;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use tokio::sync::{broadcast, mpsc, watch};
use shared::contracts::MavlinkSink;
use shared::error::{AutopilotError, Result};
use shared::health::ComponentHealth;
pub use internal::codec::{
Attitude, CommandAck, CommandLong, Decoder, DecoderEvent, Encoder, ExtendedSysState,
GlobalPositionInt, Heartbeat, MavlinkMessage, MavlinkParseError, MissionAck, MissionClearAll,
MissionCount, MissionCurrent, MissionItemInt, MissionItemReached, MissionRequestInt,
MissionSetCurrent, ParseErrorKind, ParseErrors, SetMode, StatusText, SysStatus,
};
pub use internal::heartbeat::LinkEvent;
pub use internal::uri::{ConnectionUri, DEFAULT_SERIAL_BAUD};
use internal::codec::parse_errors::ParseErrorsSnapshot;
use internal::heartbeat::{heartbeat_period, make_outbound_heartbeat, InboundWatchdog};
use internal::retry::ExponentialBackoff;
use internal::transport::serial::SerialTransport;
use internal::transport::udp::UdpTransport;
use internal::transport::Transport;
const NAME: &str = "mavlink_layer";
/// Default outbound channel capacity (frames).
const OUTBOUND_CHAN_CAP: usize = 64;
/// Default inbound broadcast capacity.
const INBOUND_CHAN_CAP: usize = 256;
/// Connection descriptor — the URI string a caller would put in TOML.
#[derive(Debug, Clone)]
pub struct MavlinkConnection {
pub uri: String,
}
impl MavlinkConnection {
pub fn new(uri: impl Into<String>) -> Self {
Self { uri: uri.into() }
}
}
/// Tunables for the MAVLink actor. Defaults follow AZ-641 §NFR.
#[derive(Debug, Clone)]
pub struct MavlinkLayerOptions {
pub connection: MavlinkConnection,
/// MAVLink sysid this process advertises (default 1).
pub sysid: u8,
/// MAVLink compid this process advertises (default 191 = MAV_COMP_ID_ONBOARD_COMPUTER).
pub compid: u8,
/// Wall-clock budget without an inbound HEARTBEAT before `LinkLost` fires.
pub link_timeout: Duration,
/// Cap for the open-loop exponential backoff.
pub reconnect_cap: Duration,
/// Base delay for the open-loop exponential backoff.
pub reconnect_base: Duration,
/// MAVLink-2 signing flag; plumbed through to health, not enforced here
/// (AZ-643 owns the signing path).
pub signing_enabled: bool,
}
impl MavlinkLayerOptions {
pub fn new(connection: MavlinkConnection) -> Self {
Self {
connection,
sysid: 1,
compid: 191,
link_timeout: Duration::from_millis(internal::heartbeat::DEFAULT_LINK_TIMEOUT_MS),
reconnect_cap: Duration::from_secs(5),
reconnect_base: Duration::from_millis(100),
signing_enabled: false,
}
}
}
#[derive(Debug, Clone)]
pub struct InboundMessage {
pub sysid: u8,
pub compid: u8,
pub seq: u8,
pub message: MavlinkMessage,
}
#[derive(Debug)]
enum OutboundItem {
Message(MavlinkMessage),
RawFrame(Vec<u8>),
}
#[derive(Debug)]
struct LinkState {
encoder: Encoder,
parse_errors: Arc<ParseErrors>,
watchdog: Arc<InboundWatchdog>,
inbound: broadcast::Sender<InboundMessage>,
connected: AtomicBool,
signing_enabled: bool,
}
/// Long-running actor that owns the transport, reconnect loop, and codec.
pub struct MavlinkLayer {
connection: MavlinkConnection,
options: MavlinkLayerOptions,
outbound_rx: mpsc::Receiver<OutboundItem>,
state: Arc<LinkState>,
}
/// Clonable handle to a running `MavlinkLayer`.
#[derive(Debug, Clone)]
pub struct MavlinkHandle {
outbound_tx: mpsc::Sender<OutboundItem>,
state: Arc<LinkState>,
}
impl MavlinkLayer {
pub fn new(connection: MavlinkConnection) -> Self {
Self { connection }
/// Build the layer + handle pair. The layer is **not** yet running —
/// callers must spawn [`MavlinkLayer::run`] from a tokio task.
pub fn new(options: MavlinkLayerOptions) -> (Self, MavlinkHandle) {
let (tx, rx) = mpsc::channel(OUTBOUND_CHAN_CAP);
let (inbound_tx, _inbound_rx) = broadcast::channel(INBOUND_CHAN_CAP);
let (watchdog, _link_rx) = InboundWatchdog::new(options.link_timeout.as_millis() as u64);
let state = Arc::new(LinkState {
encoder: Encoder::new(options.sysid, options.compid),
parse_errors: Arc::new(ParseErrors::new()),
watchdog,
inbound: inbound_tx,
connected: AtomicBool::new(false),
signing_enabled: options.signing_enabled,
});
let layer = Self {
options,
outbound_rx: rx,
state: state.clone(),
};
let handle = MavlinkHandle {
outbound_tx: tx,
state,
};
(layer, handle)
}
pub fn handle(&self) -> MavlinkHandle {
MavlinkHandle::new(self.connection.clone())
/// Run the open / reconnect loop until `shutdown` flips to `true`.
pub async fn run(mut self, mut shutdown: watch::Receiver<bool>) -> Result<()> {
let uri = ConnectionUri::parse(&self.options.connection.uri)?;
let mut backoff =
ExponentialBackoff::new(self.options.reconnect_base, self.options.reconnect_cap);
loop {
if *shutdown.borrow() {
tracing::info!(component = NAME, "shutdown received before transport open");
return Ok(());
}
let open_result = open_transport(&uri).await;
let mut transport: Box<dyn Transport> = match open_result {
Ok(t) => {
backoff.reset();
self.state.connected.store(true, Ordering::SeqCst);
tracing::info!(component = NAME, uri = %self.options.connection.uri, "mavlink transport opened");
t
}
Err(e) => {
let delay = backoff.next_delay();
tracing::warn!(
component = NAME,
error = %e,
attempts = backoff.attempts(),
backoff_ms = delay.as_millis() as u64,
"mavlink transport open failed; retrying"
);
self.state.connected.store(false, Ordering::SeqCst);
tokio::select! {
_ = tokio::time::sleep(delay) => {}
_ = shutdown.changed() => return Ok(()),
}
continue;
}
};
let outcome = self.run_link(&mut *transport, &mut shutdown).await;
self.state.connected.store(false, Ordering::SeqCst);
match outcome {
LinkOutcome::Shutdown => return Ok(()),
LinkOutcome::TransportLost(reason) => {
tracing::warn!(component = NAME, reason = %reason, "mavlink transport lost; reconnecting");
}
}
}
}
async fn run_link(
&mut self,
transport: &mut dyn Transport,
shutdown: &mut watch::Receiver<bool>,
) -> LinkOutcome {
let mut decoder = Decoder::new();
let mut heartbeat_tick = tokio::time::interval(heartbeat_period());
heartbeat_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut watchdog_tick = tokio::time::interval(Duration::from_millis(200));
watchdog_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut read_buf = vec![0u8; 4 * 1024];
let mut pending_outbound: Vec<Vec<u8>> = Vec::new();
let mut wants_heartbeat = false;
loop {
tokio::select! {
biased;
_ = shutdown.changed() => return LinkOutcome::Shutdown,
_ = heartbeat_tick.tick() => {
wants_heartbeat = true;
}
_ = watchdog_tick.tick() => {
self.state.watchdog.maybe_trip_link_lost();
}
msg = self.outbound_rx.recv() => {
match msg {
Some(OutboundItem::Message(m)) => {
let bytes = self.state.encoder.encode(&m);
pending_outbound.push(bytes);
}
Some(OutboundItem::RawFrame(bytes)) => pending_outbound.push(bytes),
None => return LinkOutcome::Shutdown,
}
}
read = transport.read(&mut read_buf) => {
match read {
Ok(0) => return LinkOutcome::TransportLost("eof".into()),
Ok(n) => {
let events = decoder.feed(&read_buf[..n]);
for ev in events {
self.process_decoder_event(ev);
}
// Mirror decoder errors into the layer's own counters.
let snap = decoder.errors.snapshot();
let _ = snap; // counters are owned by the decoder; surfaced via health
}
Err(e) => return LinkOutcome::TransportLost(format!("read: {e}")),
}
}
}
if wants_heartbeat {
wants_heartbeat = false;
let frame = self.state.encoder.encode(&make_outbound_heartbeat());
if let Err(e) = transport.write_all(&frame).await {
return LinkOutcome::TransportLost(format!("write heartbeat: {e}"));
}
self.state.watchdog.note_outbound_heartbeat();
}
while let Some(bytes) = pending_outbound.pop() {
if let Err(e) = transport.write_all(&bytes).await {
return LinkOutcome::TransportLost(format!("write: {e}"));
}
}
}
}
fn process_decoder_event(&self, ev: DecoderEvent) {
match ev {
DecoderEvent::Message {
sysid,
compid,
seq,
message,
} => {
if matches!(message, MavlinkMessage::Heartbeat(_)) {
self.state.watchdog.note_inbound_heartbeat();
}
let _ = self.state.inbound.send(InboundMessage {
sysid,
compid,
seq,
message,
});
}
DecoderEvent::Crc { msg_id, seq } => {
self.state.parse_errors.record(ParseErrorKind::Crc);
tracing::warn!(component = NAME, msg_id, seq, "mavlink crc mismatch");
}
DecoderEvent::UnknownId { msg_id, seq } => {
self.state.parse_errors.record(ParseErrorKind::UnknownId);
tracing::warn!(component = NAME, msg_id, seq, "mavlink unknown message id");
}
DecoderEvent::InvalidPayload {
msg_id,
seq,
reason,
} => {
self.state
.parse_errors
.record(ParseErrorKind::InvalidPayload);
tracing::warn!(
component = NAME,
msg_id,
seq,
reason,
"mavlink invalid payload"
);
}
DecoderEvent::SequenceGap {
sysid,
compid,
expected,
actual,
} => {
self.state.parse_errors.record(ParseErrorKind::SequenceGap);
tracing::warn!(
component = NAME,
sysid,
compid,
expected,
actual,
"mavlink sequence gap"
);
}
}
}
}
#[derive(Debug, Clone)]
pub struct MavlinkHandle {
#[allow(dead_code)]
connection: MavlinkConnection,
async fn open_transport(uri: &ConnectionUri) -> Result<Box<dyn Transport>> {
match uri {
ConnectionUri::Udp { host, port } => {
let t = UdpTransport::connect(&format!("{host}:{port}")).await?;
Ok(Box::new(t))
}
ConnectionUri::Serial { path, baud } => {
let t = SerialTransport::open(path, *baud)?;
Ok(Box::new(t))
}
}
}
#[derive(Debug)]
enum LinkOutcome {
Shutdown,
TransportLost(String),
}
impl MavlinkHandle {
pub(crate) fn new(connection: MavlinkConnection) -> Self {
Self { connection }
/// Send a typed MAVLink message — encoded with the actor's sysid/compid
/// and the next outbound sequence number.
pub async fn send(&self, msg: MavlinkMessage) -> Result<()> {
self.outbound_tx
.send(OutboundItem::Message(msg))
.await
.map_err(|e| AutopilotError::Internal(format!("mavlink send: channel closed ({e})")))
}
pub async fn send_raw(&self, _payload: Vec<u8>) -> Result<()> {
Err(AutopilotError::NotImplemented(
"mavlink_layer::send_raw (AZ-641)",
))
/// Send already-framed bytes verbatim. Used by callers that maintain
/// their own encoder (e.g. tests, or external supervisors that bridge a
/// second MAVLink endpoint).
pub async fn send_raw_bytes(&self, frame: Vec<u8>) -> Result<()> {
self.outbound_tx
.send(OutboundItem::RawFrame(frame))
.await
.map_err(|e| {
AutopilotError::Internal(format!("mavlink send_raw: channel closed ({e})"))
})
}
pub fn subscribe_inbound(&self) -> broadcast::Receiver<InboundMessage> {
self.state.inbound.subscribe()
}
pub fn subscribe_link_events(&self) -> broadcast::Receiver<LinkEvent> {
self.state.watchdog.subscribe()
}
pub fn parse_errors(&self) -> ParseErrorsSnapshot {
self.state.parse_errors.snapshot()
}
pub fn health(&self) -> ComponentHealth {
ComponentHealth::disabled(NAME)
let connected = self.state.connected.load(Ordering::Relaxed);
let age = self.state.watchdog.last_inbound_age_ms();
let detail = format!(
"connected={connected} last_heartbeat_age_ms={} signing_enabled={} outbound={} parse_errors={}",
age.map(|m| m.to_string()).unwrap_or_else(|| "none".into()),
self.state.signing_enabled,
self.state.watchdog.outbound_total(),
self.parse_errors().total(),
);
if !connected {
ComponentHealth::red(NAME, detail)
} else if !self.state.watchdog.link_up() {
ComponentHealth::yellow(NAME, detail)
} else {
ComponentHealth::green(NAME)
}
}
}
#[async_trait]
impl MavlinkSink for MavlinkHandle {
async fn send_raw(&self, msg: Vec<u8>) -> Result<()> {
MavlinkHandle::send_raw(self, msg).await
MavlinkHandle::send_raw_bytes(self, msg).await
}
}
@@ -67,14 +429,28 @@ mod tests {
use super::*;
#[test]
fn it_compiles() {
fn handle_health_is_red_when_never_connected() {
// Arrange / Act
let h = MavlinkLayer::new(MavlinkConnection {
uri: "udp://127.0.0.1:14550".into(),
})
.handle();
let (_layer, handle) = MavlinkLayer::new(MavlinkLayerOptions::new(MavlinkConnection::new(
"udp://127.0.0.1:14550",
)));
// Assert
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
let h = handle.health();
assert_eq!(h.level, shared::health::HealthLevel::Red);
}
#[test]
fn handle_clones() {
// Arrange
let (_layer, h) = MavlinkLayer::new(MavlinkLayerOptions::new(MavlinkConnection::new(
"udp://127.0.0.1:14550",
)));
// Act
let h2 = h.clone();
// Assert
assert_eq!(h.health().level, h2.health().level);
}
}