Files
autopilot/crates/mavlink_layer/src/lib.rs
T
Oleksandr Bezdieniezhnykh 740bf37d76 [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>
2026-05-19 12:29:49 +03:00

457 lines
16 KiB
Rust

//! `mavlink_layer` — hand-rolled MAVLink v2 transport + codec.
//!
//! 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 {
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 {
/// 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)
}
/// 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"
);
}
}
}
}
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 {
/// 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})")))
}
/// 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 {
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_bytes(self, msg).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handle_health_is_red_when_never_connected() {
// Arrange / Act
let (_layer, handle) = MavlinkLayer::new(MavlinkLayerOptions::new(MavlinkConnection::new(
"udp://127.0.0.1:14550",
)));
// Assert
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);
}
}