mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 08:11:09 +00:00
740bf37d76
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>
457 lines
16 KiB
Rust
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);
|
|
}
|
|
}
|