mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 08:21:10 +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>
257 lines
8.2 KiB
Rust
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:?}"),
|
|
}
|
|
}
|
|
}
|