mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 18:41:10 +00:00
[AZ-640] Bootstrap Rust workspace, CI/Docker, observability scaffold
ci/woodpecker/push/build-arm Pipeline failed
ci/woodpecker/push/build-arm Pipeline failed
Lands the first task of the implementation epic AZ-626: a cargo workspace
with 14 crates (shared + autopilot binary + 12 component crates), a
multi-stage Dockerfile + dev/test compose stacks, a Woodpecker CI pipeline,
the on-airframe systemd unit with flight-gate wiring, three environment
TOML configs, and the canonical entity catalogue from data_model.md as
`shared::models`.
Per-AC verification (full detail in
_docs/03_implementation/batch_01_cycle1_report.md):
- AC-1 cargo check --workspace clean
- AC-2 cargo test --workspace passes; per-crate it_compiles() <0.01 s
- AC-6 cargo build/test --no-default-features clean; VlmClient default
impl returns VlmAssessment::disabled()
- AC-9 tracing-subscriber emits JSON logs with ts/level/target/fields
- AC-10 runtime::ensure_state_directories creates mapobjects/, audit/,
pending_pushes/ under storage.state_dir
Deferred to external infra (artifacts written, verification re-runs in CI
and in downstream tasks):
- AC-3 Woodpecker runner; CI yml in place
- AC-4 docker-compose mocks land with AZ-660/AZ-644/AZ-675
- AC-5 SITL conformance lands with AZ-641/AZ-648/AZ-652
- AC-7 aarch64 cross-compile via cargo-zigbuild stage
- AC-8 systemd unit (Linux + systemd host)
Layering invariants from module-layout.md hold: shared (L1) imports
nothing; Layer 2 actor crates import only shared; Layer 3 coordinators
(operator_bridge, mission_executor) import only their documented Layer 2
deps; Layer 4 (scan_controller) imports its documented Layer 2 + Layer 3
deps; the autopilot binary (L5) is the only consumer of every component.
cargo fmt --all --check + cargo clippy --all-targets -- -D warnings both
clean. Jira AZ-640 transitioned to In Progress at the start of this batch;
the matching In Testing transition follows this commit.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
//! Monotonic and wall-clock binding.
|
||||
//!
|
||||
//! `MonoClock` is authoritative for tick budgets, telemetry-skew compensation,
|
||||
//! and inter-frame correlation. `WallClock` is GPS-bound when locked and NTP at
|
||||
//! boot. Drift > 200 ms surfaces as yellow health on the affected component.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClockSource {
|
||||
Gnss,
|
||||
Host,
|
||||
Coast,
|
||||
}
|
||||
|
||||
/// Process-monotonic clock — never goes backwards, immune to NTP adjustments.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MonoClock {
|
||||
boot: Instant,
|
||||
}
|
||||
|
||||
impl MonoClock {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
boot: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nanoseconds since this clock was constructed.
|
||||
pub fn elapsed_ns(&self) -> u64 {
|
||||
self.boot.elapsed().as_nanos() as u64
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MonoClock {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wall-clock binding — produced from `MonoClock` via the active `ClockSource`.
|
||||
/// Drift beyond the threshold MUST be surfaced as a yellow health detail.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WallClock {
|
||||
pub source: ClockSource,
|
||||
}
|
||||
|
||||
impl WallClock {
|
||||
pub fn new(source: ClockSource) -> Self {
|
||||
Self { source }
|
||||
}
|
||||
|
||||
pub fn now(&self) -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::Utc::now()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mono_clock_is_monotonic() {
|
||||
let clock = MonoClock::new();
|
||||
let t1 = clock.elapsed_ns();
|
||||
let t2 = clock.elapsed_ns();
|
||||
|
||||
assert!(t2 >= t1, "monotonic clock went backwards: {t1} -> {t2}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! TOML configuration loader.
|
||||
//!
|
||||
//! All non-secret configuration lives in `config/<env>.toml`. Secrets come from
|
||||
//! environment variables (named by `*_env` keys), never from the TOML itself.
|
||||
//! See `_docs/02_document/deployment/containerization.md §6`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{AutopilotError, Result};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub health: HealthConfig,
|
||||
pub observability: ObservabilityConfig,
|
||||
pub storage: StorageConfig,
|
||||
pub rtsp: RtspConfig,
|
||||
pub gimbal: GimbalConfig,
|
||||
pub mavlink: MavlinkConfig,
|
||||
pub missions_api: MissionsApiConfig,
|
||||
pub ground_station: GroundStationConfig,
|
||||
pub detections: DetectionsConfig,
|
||||
pub vlm: VlmConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthConfig {
|
||||
pub bind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObservabilityConfig {
|
||||
pub log_format: String,
|
||||
pub default_log_filter: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StorageConfig {
|
||||
pub state_dir: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RtspConfig {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GimbalConfig {
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MavlinkConfig {
|
||||
pub connection: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MissionsApiConfig {
|
||||
pub endpoint: String,
|
||||
pub auth_env: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GroundStationConfig {
|
||||
pub endpoint: String,
|
||||
pub auth_env: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DetectionsConfig {
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VlmConfig {
|
||||
pub enabled: bool,
|
||||
pub ipc_socket: String,
|
||||
}
|
||||
|
||||
pub struct ConfigLoader;
|
||||
|
||||
impl ConfigLoader {
|
||||
/// Load + parse a TOML config from disk.
|
||||
pub fn from_path(path: impl AsRef<Path>) -> Result<Config> {
|
||||
let raw = std::fs::read_to_string(path.as_ref())
|
||||
.map_err(|e| AutopilotError::Config(format!("cannot read {:?}: {e}", path.as_ref())))?;
|
||||
let config: Config = toml::from_str(&raw)?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DEV_CONFIG: &str = r#"
|
||||
[health]
|
||||
bind = "127.0.0.1:8080"
|
||||
|
||||
[observability]
|
||||
log_format = "json"
|
||||
default_log_filter = "info"
|
||||
|
||||
[storage]
|
||||
state_dir = "/var/lib/autopilot"
|
||||
|
||||
[rtsp]
|
||||
url = "rtsp://127.0.0.1:8554/mock"
|
||||
|
||||
[gimbal]
|
||||
endpoint = "127.0.0.1:6000"
|
||||
|
||||
[mavlink]
|
||||
connection = "udp://127.0.0.1:14550"
|
||||
|
||||
[missions_api]
|
||||
endpoint = "http://127.0.0.1:8443"
|
||||
auth_env = "MISSIONS_API_TOKEN"
|
||||
|
||||
[ground_station]
|
||||
endpoint = "http://127.0.0.1:8444"
|
||||
auth_env = "GROUND_STATION_TOKEN"
|
||||
|
||||
[detections]
|
||||
endpoint = "http://127.0.0.1:50051"
|
||||
|
||||
[vlm]
|
||||
enabled = false
|
||||
ipc_socket = "/var/run/vila/ipc.sock"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn config_parses_canonical_layout() {
|
||||
// Act
|
||||
let config: Config = toml::from_str(DEV_CONFIG).expect("dev config must parse");
|
||||
|
||||
// Assert
|
||||
assert_eq!(config.health.bind, "127.0.0.1:8080");
|
||||
assert!(!config.vlm.enabled);
|
||||
assert_eq!(config.missions_api.auth_env, "MISSIONS_API_TOKEN");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Cross-component traits.
|
||||
//!
|
||||
//! These traits let one component push into another's transport without
|
||||
//! importing the receiving crate. The composition root in
|
||||
//! `crates/autopilot/src/runtime.rs` wires concrete implementations.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models::detection::DetectionBatch;
|
||||
use crate::models::frame::Frame;
|
||||
use crate::models::operator::OperatorCommand;
|
||||
use crate::models::vlm::VlmAssessment;
|
||||
|
||||
/// Telemetry uplink. Implemented by `telemetry_stream`, consumed by
|
||||
/// `operator_bridge` (for overlay/POI surfacing) and `mavlink_layer` (for
|
||||
/// piggybacked flight telemetry).
|
||||
#[async_trait]
|
||||
pub trait TelemetrySink: Send + Sync {
|
||||
async fn push_frame(&self, frame: Frame) -> Result<()>;
|
||||
async fn push_detections(&self, batch: DetectionBatch) -> Result<()>;
|
||||
}
|
||||
|
||||
/// MAVLink command surface. Implemented by `mavlink_layer`, consumed by
|
||||
/// `mission_executor` and other components that need to emit MAVLink commands.
|
||||
#[async_trait]
|
||||
pub trait MavlinkSink: Send + Sync {
|
||||
async fn send_raw(&self, msg: Vec<u8>) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Tier-3 visual-language-model provider. Default impl in `vlm_client` returns
|
||||
/// `VlmAssessment { status: Disabled, label: Inconclusive, ... }` when the
|
||||
/// `vlm` feature is off, satisfying the optionality contract.
|
||||
#[async_trait]
|
||||
pub trait VlmProvider: Send + Sync {
|
||||
async fn assess(&self, roi: Vec<u8>, prompt: String) -> Result<VlmAssessment>;
|
||||
}
|
||||
|
||||
/// Operator-command dispatch. Implemented by `operator_bridge`, fed by the
|
||||
/// composition root from `telemetry_stream`'s downlink.
|
||||
#[async_trait]
|
||||
pub trait OperatorCommandSink: Send + Sync {
|
||||
async fn dispatch(&self, command: OperatorCommand) -> Result<()>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Workspace-wide error type and result alias.
|
||||
//!
|
||||
//! Specific component errors funnel into `AutopilotError` at crate boundaries;
|
||||
//! internal modules may use their own narrower error types but MUST convert at
|
||||
//! the public API surface.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AutopilotError {
|
||||
#[error("configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("serialization error: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
#[error("missing dependency: {0}")]
|
||||
MissingDependency(String),
|
||||
|
||||
#[error("not implemented: {0}")]
|
||||
NotImplemented(&'static str),
|
||||
|
||||
#[error("network error: {0}")]
|
||||
Network(String),
|
||||
|
||||
#[error("protocol error: {0}")]
|
||||
Protocol(String),
|
||||
|
||||
#[error("validation failed: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AutopilotError>;
|
||||
|
||||
impl From<serde_json::Error> for AutopilotError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
AutopilotError::Serialization(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<toml::de::Error> for AutopilotError {
|
||||
fn from(value: toml::de::Error) -> Self {
|
||||
AutopilotError::Config(value.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Per-component health model.
|
||||
//!
|
||||
//! Each component exposes `health() -> ComponentHealth`. `autopilot::health_server`
|
||||
//! aggregates these into the `/health` JSON shape documented in
|
||||
//! `_docs/02_document/deployment/containerization.md §7`.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum HealthLevel {
|
||||
Green,
|
||||
Yellow,
|
||||
Red,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ComponentHealth {
|
||||
pub level: HealthLevel,
|
||||
pub component: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
impl ComponentHealth {
|
||||
pub fn green(component: &'static str) -> Self {
|
||||
Self {
|
||||
level: HealthLevel::Green,
|
||||
component,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn yellow(component: &'static str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
level: HealthLevel::Yellow,
|
||||
component,
|
||||
detail: Some(detail.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn red(component: &'static str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
level: HealthLevel::Red,
|
||||
component,
|
||||
detail: Some(detail.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disabled(component: &'static str) -> Self {
|
||||
Self {
|
||||
level: HealthLevel::Disabled,
|
||||
component,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AggregatedHealth {
|
||||
pub status: HealthLevel,
|
||||
pub components: Vec<ComponentHealth>,
|
||||
pub last_state_change: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl AggregatedHealth {
|
||||
/// Aggregate per-component readings into a single status.
|
||||
///
|
||||
/// A component in `Disabled` does not affect aggregation. Otherwise:
|
||||
/// any `Red` → `Red`; else any `Yellow` → `Yellow`; else `Green`.
|
||||
pub fn aggregate(components: Vec<ComponentHealth>) -> Self {
|
||||
let mut status = HealthLevel::Green;
|
||||
for c in &components {
|
||||
match c.level {
|
||||
HealthLevel::Red => {
|
||||
status = HealthLevel::Red;
|
||||
break;
|
||||
}
|
||||
HealthLevel::Yellow if status != HealthLevel::Red => {
|
||||
status = HealthLevel::Yellow;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
status,
|
||||
components,
|
||||
last_state_change: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aggregate_red_dominates() {
|
||||
// Arrange
|
||||
let inputs = vec![
|
||||
ComponentHealth::green("a"),
|
||||
ComponentHealth::yellow("b", "lagging"),
|
||||
ComponentHealth::red("c", "down"),
|
||||
];
|
||||
|
||||
// Act
|
||||
let agg = AggregatedHealth::aggregate(inputs);
|
||||
|
||||
// Assert
|
||||
assert_eq!(agg.status, HealthLevel::Red);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_yellow_when_no_red() {
|
||||
// Arrange
|
||||
let inputs = vec![
|
||||
ComponentHealth::green("a"),
|
||||
ComponentHealth::yellow("b", "lagging"),
|
||||
];
|
||||
|
||||
// Act
|
||||
let agg = AggregatedHealth::aggregate(inputs);
|
||||
|
||||
// Assert
|
||||
assert_eq!(agg.status, HealthLevel::Yellow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_green_when_all_green_or_disabled() {
|
||||
// Arrange
|
||||
let inputs = vec![
|
||||
ComponentHealth::green("a"),
|
||||
ComponentHealth::disabled("vlm"),
|
||||
];
|
||||
|
||||
// Act
|
||||
let agg = AggregatedHealth::aggregate(inputs);
|
||||
|
||||
// Assert
|
||||
assert_eq!(agg.status, HealthLevel::Green);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Shared foundation crate for the autopilot workspace.
|
||||
//!
|
||||
//! Owns canonical DTOs, configuration, error type, health model, observability,
|
||||
//! clock binding, and cross-component traits. Every other crate depends on
|
||||
//! `shared`; `shared` depends on nothing else in the workspace.
|
||||
|
||||
pub mod clock;
|
||||
pub mod config;
|
||||
pub mod contracts;
|
||||
pub mod error;
|
||||
pub mod health;
|
||||
pub mod models;
|
||||
pub mod observability;
|
||||
|
||||
pub use error::{AutopilotError, Result};
|
||||
@@ -0,0 +1,24 @@
|
||||
//! `Detection`, `DetectionBatch` — per `data_model.md §2 Perception entities`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::frame::BoundingBox;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Detection {
|
||||
pub class_id: u32,
|
||||
pub class_name: String,
|
||||
pub confidence: f32,
|
||||
pub bbox_normalized: BoundingBox,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mask_or_polyline: Option<Vec<u8>>,
|
||||
pub source_frame_seq: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DetectionBatch {
|
||||
pub frame_seq: u64,
|
||||
pub detections: Vec<Detection>,
|
||||
pub latency_ms: u32,
|
||||
pub model_version: String,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! `Frame`, `BoundingBox` — per `data_model.md §2 Perception entities`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PixelFormat {
|
||||
Nv12,
|
||||
Yuv420p,
|
||||
Rgb24,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frame {
|
||||
pub seq: u64,
|
||||
pub capture_ts_monotonic_ns: u64,
|
||||
pub decode_ts_monotonic_ns: u64,
|
||||
pub pixels: Arc<Bytes>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pix_fmt: PixelFormat,
|
||||
pub ai_locked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BoundingBox {
|
||||
pub x_min: f32,
|
||||
pub y_min: f32,
|
||||
pub x_max: f32,
|
||||
pub y_max: f32,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! `GimbalState` — per `data_model.md §4 Action / piloting entities`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GimbalState {
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub zoom: f32,
|
||||
pub ts_monotonic_ns: u64,
|
||||
pub command_in_flight: bool,
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! `MapObject`, `MapObjectObservation`, `MapObjectsBundle`, `IgnoredItem` —
|
||||
//! per `data_model.md §3 Decision entities`.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::mission::Coordinate;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MapObjectSource {
|
||||
CentralPulled,
|
||||
LocalObserved,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MapObject {
|
||||
pub h3_cell: u64,
|
||||
pub mgrs_key: String,
|
||||
pub class: String,
|
||||
pub class_group: String,
|
||||
pub gps_lat: f64,
|
||||
pub gps_lon: f64,
|
||||
pub size_width_m: f32,
|
||||
pub size_length_m: f32,
|
||||
pub confidence: f32,
|
||||
pub first_seen: DateTime<Utc>,
|
||||
pub last_seen: DateTime<Utc>,
|
||||
pub mission_id: String,
|
||||
pub source: MapObjectSource,
|
||||
pub pending_upload: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum DiffKind {
|
||||
New,
|
||||
Moved,
|
||||
Existing,
|
||||
RemovedCandidate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MapObjectObservation {
|
||||
pub id: Uuid,
|
||||
pub h3_cell: u64,
|
||||
pub class: String,
|
||||
pub class_group: String,
|
||||
pub mission_id: String,
|
||||
pub uav_id: String,
|
||||
pub observed_at_monotonic_ns: u64,
|
||||
pub observed_at_wallclock: DateTime<Utc>,
|
||||
pub gps_lat: f64,
|
||||
pub gps_lon: f64,
|
||||
pub mgrs: String,
|
||||
pub size_width_m: f32,
|
||||
pub size_length_m: f32,
|
||||
pub confidence: f32,
|
||||
pub diff_kind: DiffKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub photo_ref: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_evidence: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RetentionScope {
|
||||
Mission,
|
||||
Session,
|
||||
UntilExpiry,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IgnoredItemSource {
|
||||
CentralPulled,
|
||||
LocalAppended,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IgnoredItem {
|
||||
pub id: Uuid,
|
||||
pub mgrs: String,
|
||||
pub h3_cell: u64,
|
||||
pub class_group: String,
|
||||
pub decline_time: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub operator_id: Option<String>,
|
||||
pub mission_id: String,
|
||||
pub retention_scope: RetentionScope,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub source: IgnoredItemSource,
|
||||
pub pending_upload: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BundleFreshness {
|
||||
Fresh,
|
||||
Stale,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MapObjectsBundle {
|
||||
pub schema_version: String,
|
||||
pub mission_id: String,
|
||||
/// `[NW, SE]` bounding box.
|
||||
pub bbox: [Coordinate; 2],
|
||||
#[serde(default)]
|
||||
pub map_objects: Vec<MapObject>,
|
||||
#[serde(default)]
|
||||
pub observations: Vec<MapObjectObservation>,
|
||||
#[serde(default)]
|
||||
pub ignored_items: Vec<IgnoredItem>,
|
||||
pub as_of: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub freshness: Option<BundleFreshness>,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! `Coordinate`, `Geofence`, `MissionItem`, `MissionWaypoint` — per
|
||||
//! `data_model.md §4 Action / piloting entities`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Coordinate {
|
||||
pub latitude: f64,
|
||||
pub longitude: f64,
|
||||
pub altitude_m: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum GeofenceKind {
|
||||
Inclusion,
|
||||
Exclusion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Geofence {
|
||||
pub kind: GeofenceKind,
|
||||
pub vertices: Vec<Coordinate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MissionItemKind {
|
||||
Waypoint,
|
||||
Search,
|
||||
RegionSearch,
|
||||
Return,
|
||||
TargetFollowBreakpoint,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MissionItem {
|
||||
pub id: Uuid,
|
||||
pub kind: MissionItemKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub at: Option<Coordinate>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub region: Vec<Coordinate>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cruise_speed_mps: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub target_classes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum MavFrame {
|
||||
MavFrameGlobalRelativeAlt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum MavCommand {
|
||||
MavCmdNavTakeoff,
|
||||
MavCmdNavWaypoint,
|
||||
MavCmdNavLand,
|
||||
MavCmdDoChangeSpeed,
|
||||
MavCmdNavReturnToLaunch,
|
||||
MavCmdDoSetMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MissionWaypoint {
|
||||
pub seq: u16,
|
||||
pub frame: MavFrame,
|
||||
pub command: MavCommand,
|
||||
pub current: bool,
|
||||
pub auto_continue: bool,
|
||||
pub param_1: f32,
|
||||
pub param_2: f32,
|
||||
pub param_3: f32,
|
||||
pub param_4: f32,
|
||||
pub lat_deg_e7: i32,
|
||||
pub lon_deg_e7: i32,
|
||||
pub alt_m: f32,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Canonical entity catalogue per `_docs/02_document/data_model.md`.
|
||||
//!
|
||||
//! One submodule per entity grouping. Every other crate imports types from here
|
||||
//! rather than redefining them.
|
||||
|
||||
pub mod detection;
|
||||
pub mod frame;
|
||||
pub mod gimbal;
|
||||
pub mod mapobject;
|
||||
pub mod mission;
|
||||
pub mod movement;
|
||||
pub mod operator;
|
||||
pub mod poi;
|
||||
pub mod tier2;
|
||||
pub mod vlm;
|
||||
@@ -0,0 +1,42 @@
|
||||
//! `MovementCandidate` — per `data_model.md §2 Perception entities`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::frame::BoundingBox;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ResidualVelocity {
|
||||
/// Image-coordinate direction; unit vector.
|
||||
pub dx: f32,
|
||||
pub dy: f32,
|
||||
/// Magnitude in normalised image units per second.
|
||||
pub magnitude: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TelemetryQuality {
|
||||
Synced,
|
||||
Degraded,
|
||||
Unsynced,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ZoomBand {
|
||||
/// `Level 1` wide sweep.
|
||||
ZoomedOut,
|
||||
/// `Level 2` zoom-in hold.
|
||||
ZoomedIn,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MovementCandidate {
|
||||
pub frame_seq: u64,
|
||||
pub bbox_normalized: BoundingBox,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub residual_velocity_estimate: Option<ResidualVelocity>,
|
||||
pub telemetry_quality: TelemetryQuality,
|
||||
pub source_frame_ts_monotonic_ns: u64,
|
||||
pub source_zoom_band: ZoomBand,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! `OperatorCommand` — per `data_model.md §4 Action / piloting entities`.
|
||||
//!
|
||||
//! Every operator command carries an authenticated envelope. The signature
|
||||
//! scheme is open (architecture.md Q9); `operator_bridge::internal::auth`
|
||||
//! validates the envelope before any handler sees the decoded payload.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OperatorCommandKind {
|
||||
ConfirmPoi,
|
||||
DeclinePoi,
|
||||
StartTargetFollow,
|
||||
ReleaseTargetFollow,
|
||||
AcknowledgeBitDegraded,
|
||||
SafetyOverride,
|
||||
MissionAbort,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OperatorCommand {
|
||||
pub command_id: Uuid,
|
||||
pub session_token: String,
|
||||
pub sequence_number: u64,
|
||||
pub issued_at_wallclock: DateTime<Utc>,
|
||||
pub kind: OperatorCommandKind,
|
||||
pub payload: serde_json::Value,
|
||||
/// Signature over (session_token, sequence_number, kind, payload). Scheme
|
||||
/// TBD per architecture.md Q9.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! `POI` — per `data_model.md §3 Decision entities`.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::tier2::Tier2Evidence;
|
||||
use super::vlm::VlmStatus;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VlmPipelineStatus {
|
||||
NotRequested,
|
||||
Pending,
|
||||
Ok,
|
||||
Timeout,
|
||||
SchemaInvalid,
|
||||
IpcError,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl From<VlmStatus> for VlmPipelineStatus {
|
||||
fn from(s: VlmStatus) -> Self {
|
||||
match s {
|
||||
VlmStatus::Ok => Self::Ok,
|
||||
VlmStatus::Timeout => Self::Timeout,
|
||||
VlmStatus::SchemaInvalid => Self::SchemaInvalid,
|
||||
VlmStatus::IpcError => Self::IpcError,
|
||||
VlmStatus::Disabled => Self::Disabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Poi {
|
||||
pub id: Uuid,
|
||||
pub confidence: f32,
|
||||
pub mgrs: String,
|
||||
pub class: String,
|
||||
pub class_group: String,
|
||||
pub source_detection_ids: Vec<Uuid>,
|
||||
pub enqueued_at: DateTime<Utc>,
|
||||
pub priority: f32,
|
||||
pub decline_suppressed: bool,
|
||||
pub vlm_status: VlmPipelineStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tier2_evidence: Option<Tier2Evidence>,
|
||||
pub deadline: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! `Tier2Evidence` — per `data_model.md §2 Perception entities`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RecommendedNextAction {
|
||||
PanFollowFootpath,
|
||||
HoldEndpoint,
|
||||
PanBroad,
|
||||
ReturnToZoomOut,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Tier2Status {
|
||||
Ok,
|
||||
Timeout,
|
||||
Oversize,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tier2Evidence {
|
||||
pub roi_id: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path_freshness: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint_score: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub concealment_score: Option<f32>,
|
||||
pub recommended_next_action: RecommendedNextAction,
|
||||
pub source_detections: Vec<Uuid>,
|
||||
pub status: Tier2Status,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! `VlmAssessment` — per `data_model.md §2 Perception entities`.
|
||||
//!
|
||||
//! Status semantics: any value other than `Ok` MUST produce
|
||||
//! `label = Inconclusive` (or `Error` for a critical failure). The
|
||||
//! `scan_controller` MUST NOT promote a POI to a confirmed target on a non-`Ok`
|
||||
//! `VlmAssessment`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VlmLabel {
|
||||
ConfirmedConcealedPosition,
|
||||
Rejected,
|
||||
Inconclusive,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VlmStatus {
|
||||
Ok,
|
||||
Timeout,
|
||||
SchemaInvalid,
|
||||
IpcError,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VlmAssessment {
|
||||
pub label: VlmLabel,
|
||||
pub confidence: f32,
|
||||
pub evidence_spans: Vec<String>,
|
||||
pub reason: String,
|
||||
pub status: VlmStatus,
|
||||
pub latency_ms: u32,
|
||||
pub model_version: String,
|
||||
}
|
||||
|
||||
impl VlmAssessment {
|
||||
/// The `vlm_disabled` no-op assessment returned by the default
|
||||
/// `VlmProvider` impl when the binary is built without `--features vlm`
|
||||
/// or `vlm.enabled = false` in config.
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
label: VlmLabel::Inconclusive,
|
||||
confidence: 0.0,
|
||||
evidence_spans: Vec::new(),
|
||||
reason: "vlm disabled".into(),
|
||||
status: VlmStatus::Disabled,
|
||||
latency_ms: 0,
|
||||
model_version: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Observability initialisation.
|
||||
//!
|
||||
//! Per `_docs/02_document/deployment/observability.md`, the autopilot emits
|
||||
//! JSON-formatted log records to stdout containing at least: `ts`, `ts_mono_ns`,
|
||||
//! `level`, `target`, `event`. Initialisation reads the `RUST_LOG` env var (or
|
||||
//! the `default_log_filter` config fallback) and the `log_format` setting.
|
||||
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
/// Output format for the tracing layer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LogFormat {
|
||||
/// Structured JSON to stdout — production default.
|
||||
Json,
|
||||
/// Human-readable colour output — dev shells only.
|
||||
Pretty,
|
||||
}
|
||||
|
||||
impl LogFormat {
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"json" => LogFormat::Json,
|
||||
"pretty" => LogFormat::Pretty,
|
||||
_ => LogFormat::Json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialise `tracing-subscriber` with the configured format and filter.
|
||||
///
|
||||
/// `default_filter` is used when the `RUST_LOG` env var is unset.
|
||||
/// Safe to call exactly once at startup.
|
||||
pub fn init(
|
||||
format: LogFormat,
|
||||
default_filter: &str,
|
||||
) -> Result<(), tracing_subscriber::util::TryInitError> {
|
||||
let env_filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter));
|
||||
|
||||
let registry = tracing_subscriber::registry().with(env_filter);
|
||||
|
||||
match format {
|
||||
LogFormat::Json => registry
|
||||
.with(
|
||||
fmt::layer()
|
||||
.json()
|
||||
.with_target(true)
|
||||
.with_current_span(false)
|
||||
.with_span_list(false),
|
||||
)
|
||||
.try_init(),
|
||||
LogFormat::Pretty => registry.with(fmt::layer().with_target(true)).try_init(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical log field constants (mirrors observability.md §2).
|
||||
pub mod fields {
|
||||
pub const TS: &str = "ts";
|
||||
pub const TS_MONO_NS: &str = "ts_mono_ns";
|
||||
pub const LEVEL: &str = "level";
|
||||
pub const TARGET: &str = "target";
|
||||
pub const EVENT: &str = "event";
|
||||
pub const FRAME_SEQ: &str = "frame_seq";
|
||||
pub const POI_ID: &str = "poi_id";
|
||||
pub const COMMAND_ID: &str = "command_id";
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn log_format_parses_known_values() {
|
||||
assert_eq!(LogFormat::parse("json"), LogFormat::Json);
|
||||
assert_eq!(LogFormat::parse("pretty"), LogFormat::Pretty);
|
||||
// Unknown values fall back to JSON (the production-safe default).
|
||||
assert_eq!(LogFormat::parse("xml"), LogFormat::Json);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user