Files
autopilot/crates/mission_client/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

257 lines
8.2 KiB
Rust

//! `mission_client` — REST client to the external `missions` API.
//!
//! Public surface (per `module-layout.md`): [`MissionClient`],
//! [`MissionClientHandle`], the typed [`Mission`] DTO, [`FetchError`], and
//! [`MissionClientOptions`].
//!
//! Real implementation tasks: AZ-644 (pull + schema, this file), AZ-645
//! (middle-waypoint POST), AZ-646 (mapobjects pull), AZ-647 (mapobjects push).
mod internal;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use shared::error::{AutopilotError, Result};
use shared::health::ComponentHealth;
use shared::models::mapobject::MapObjectsBundle;
use shared::models::mission::{Coordinate, Geofence, MissionItem};
use uuid::Uuid;
use internal::missions_api::HttpClient;
const NAME: &str = "mission_client";
/// Mission DTO returned by `pull_mission`. Shape matches the JSON wire schema
/// in `shared/contracts/mission-schema.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Mission {
pub mission_id: Uuid,
pub schema_version: String,
pub items: Vec<MissionItem>,
pub geofences: Vec<Geofence>,
pub return_point: Coordinate,
}
/// Errors surfaced by `MissionClientHandle::pull_mission`.
#[derive(Debug, thiserror::Error)]
pub enum FetchError {
/// JSON body did not match the bundled `mission-schema`. Includes a
/// size-capped sample of the raw body for offline analysis.
#[error("mission schema invalid: {}", messages.join("; "))]
SchemaInvalid {
messages: Vec<String>,
sample: String,
},
/// Non-retryable HTTP or transport-level error (4xx, malformed URL, etc.).
#[error("permanent fetch failure: {0}")]
Permanent(String),
/// Retried up to `max_attempts` without success.
#[error("max retries exceeded after {attempts} attempts")]
MaxRetriesExceeded { attempts: u32 },
/// Local bug (deserialisation after schema validation succeeded, etc.).
#[error("internal error: {0}")]
Internal(String),
}
impl From<FetchError> for AutopilotError {
fn from(e: FetchError) -> Self {
match e {
FetchError::SchemaInvalid { messages, .. } => {
AutopilotError::Validation(messages.join("; "))
}
FetchError::Permanent(s) => AutopilotError::Network(s),
FetchError::MaxRetriesExceeded { attempts } => {
AutopilotError::Network(format!("max retries exceeded after {attempts} attempts"))
}
FetchError::Internal(s) => AutopilotError::Internal(s),
}
}
}
/// Tunables for the missions-API client. AZ-644 §NFR defaults: 5 attempts,
/// 200 ms base / 5 s cap, 5 s startup-fetch budget.
#[derive(Debug, Clone)]
pub struct MissionClientOptions {
pub endpoint: String,
pub bearer_token: Option<String>,
pub max_attempts: u32,
pub backoff_base: Duration,
pub backoff_cap: Duration,
pub request_timeout: Duration,
pub connect_timeout: Duration,
}
impl MissionClientOptions {
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
bearer_token: None,
max_attempts: 5,
backoff_base: Duration::from_millis(200),
backoff_cap: Duration::from_secs(5),
request_timeout: Duration::from_secs(5),
connect_timeout: Duration::from_secs(2),
}
}
}
#[derive(Debug, Default)]
struct ClientState {
last_fetch_unix_s: AtomicU64,
fetch_errors_total: AtomicU64,
last_schema_version: std::sync::Mutex<Option<String>>,
last_connection_state: std::sync::Mutex<ConnectionState>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum ConnectionState {
#[default]
Unknown,
Ok,
Error,
}
impl ConnectionState {
fn label(&self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::Ok => "ok",
Self::Error => "error",
}
}
}
/// Public client. Build once at startup and pass the [`MissionClientHandle`]
/// to other components.
#[derive(Debug)]
pub struct MissionClient {
options: MissionClientOptions,
http: HttpClient,
state: Arc<ClientState>,
}
impl MissionClient {
pub fn new(options: MissionClientOptions) -> std::result::Result<Self, FetchError> {
let http = HttpClient::new(&options)?;
Ok(Self {
options,
http,
state: Arc::new(ClientState::default()),
})
}
pub fn handle(&self) -> MissionClientHandle {
MissionClientHandle {
options: self.options.clone(),
http: self.http.clone(),
state: self.state.clone(),
}
}
}
/// Clonable handle. Each clone shares the same HTTP client and counters.
#[derive(Debug, Clone)]
pub struct MissionClientHandle {
options: MissionClientOptions,
http: HttpClient,
state: Arc<ClientState>,
}
impl MissionClientHandle {
/// Fetch + validate a mission by id. Implements bounded exponential
/// backoff and rejects schema-invalid responses without a silent downcast.
pub async fn pull_mission(&self, mission_id: &str) -> std::result::Result<Mission, FetchError> {
match self.http.pull_mission_raw(mission_id, &self.options).await {
Ok(value) => {
let mission: Mission = serde_json::from_value(value)
.map_err(|e| FetchError::Internal(format!("deserialise mission: {e}")))?;
self.state
.last_fetch_unix_s
.store(now_unix_s(), Ordering::Relaxed);
*self.state.last_schema_version.lock().unwrap() =
Some(mission.schema_version.clone());
*self.state.last_connection_state.lock().unwrap() = ConnectionState::Ok;
Ok(mission)
}
Err(e) => {
self.state
.fetch_errors_total
.fetch_add(1, Ordering::Relaxed);
*self.state.last_connection_state.lock().unwrap() = ConnectionState::Error;
Err(e)
}
}
}
pub async fn post_middle_waypoint(&self, _mission_id: &str, _at: Coordinate) -> Result<()> {
Err(AutopilotError::NotImplemented(
"mission_client::post_middle_waypoint (AZ-645)",
))
}
pub async fn pull_mapobjects(&self, _mission_id: &str) -> Result<MapObjectsBundle> {
Err(AutopilotError::NotImplemented(
"mission_client::pull_mapobjects (AZ-646)",
))
}
pub async fn push_mapobjects(&self, _bundle: MapObjectsBundle) -> Result<()> {
Err(AutopilotError::NotImplemented(
"mission_client::push_mapobjects (AZ-647)",
))
}
pub fn health(&self) -> ComponentHealth {
let conn = *self.state.last_connection_state.lock().unwrap();
let last_fetch = self.state.last_fetch_unix_s.load(Ordering::Relaxed);
let errors = self.state.fetch_errors_total.load(Ordering::Relaxed);
let schema_version = self
.state
.last_schema_version
.lock()
.unwrap()
.clone()
.unwrap_or_else(|| "none".to_owned());
let detail = format!(
"last_fetch_ts={} fetch_errors_total={} schema_version={} connection_state={}",
if last_fetch == 0 {
"none".into()
} else {
last_fetch.to_string()
},
errors,
schema_version,
conn.label(),
);
match conn {
ConnectionState::Ok => ComponentHealth::green(NAME),
ConnectionState::Error => ComponentHealth::red(NAME, detail),
ConnectionState::Unknown => ComponentHealth::yellow(NAME, detail),
}
}
}
fn now_unix_s() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fetch_error_maps_to_autopilot_error() {
let e: AutopilotError = FetchError::Permanent("boom".into()).into();
match e {
AutopilotError::Network(s) => assert!(s.contains("boom")),
other => panic!("expected Network, got {other:?}"),
}
}
}