mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-22 06:11:11 +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,48 @@
|
||||
[package]
|
||||
name = "autopilot"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "autopilot"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enables the real `vlm_client` IPC path (NanoLLM / VILA1.5-3B over Unix-domain
|
||||
# socket). With the feature off, `VlmProvider` resolves to the disabled no-op.
|
||||
vlm = ["vlm_client/vlm"]
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
mavlink_layer = { workspace = true }
|
||||
mission_client = { workspace = true }
|
||||
frame_ingest = { workspace = true }
|
||||
detection_client = { workspace = true }
|
||||
movement_detector = { workspace = true }
|
||||
semantic_analyzer = { workspace = true }
|
||||
vlm_client = { workspace = true }
|
||||
scan_controller = { workspace = true }
|
||||
mapobjects_store = { workspace = true }
|
||||
gimbal_controller = { workspace = true }
|
||||
operator_bridge = { workspace = true }
|
||||
mission_executor = { workspace = true }
|
||||
telemetry_stream = { workspace = true }
|
||||
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
# Linux-only systemd readiness notification. No-op on other platforms.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
sd-notify = "0.4"
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Pre-flight Built-In Self-Test orchestration.
|
||||
//!
|
||||
//! Today's BIT is a placeholder that confirms basic config sanity. The real BIT
|
||||
//! (MAVLink heartbeat, gimbal probe, RTSP open, missions API reachability,
|
||||
//! disk-quota check) lands in AZ-650 (`mission_executor_bit_f9`). Keeping the
|
||||
//! seam here lets that task slot in without touching `main.rs`.
|
||||
|
||||
use shared::config::Config;
|
||||
|
||||
// AZ-650 will produce `Degraded` and `Block` results once the real BIT lands.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum BitOutcome {
|
||||
/// All prerequisites met — flight allowed.
|
||||
Pass,
|
||||
/// Degraded but flight-allowed (operator acknowledges).
|
||||
Degraded,
|
||||
/// Block — takeoff forbidden.
|
||||
Block,
|
||||
}
|
||||
|
||||
/// Run the pre-flight BIT. Today's implementation only validates the config.
|
||||
pub async fn run_preflight_bit(_config: &Config) -> anyhow::Result<BitOutcome> {
|
||||
// TODO(AZ-650): wire the full BIT — MAVLink heartbeat probe, gimbal status,
|
||||
// RTSP open, missions API reachability, mapobjects_store hydrate dry-run,
|
||||
// disk-quota check (takeoff blocker per architecture.md §5).
|
||||
Ok(BitOutcome::Pass)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! HTTP `/health` endpoint per `containerization.md §7`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{routing::get, Json, Router};
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{error, info};
|
||||
|
||||
use shared::health::AggregatedHealth;
|
||||
|
||||
use crate::runtime::Runtime;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
runtime: Arc<Runtime>,
|
||||
}
|
||||
|
||||
pub struct HealthServerHandle {
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl HealthServerHandle {
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Err(e) = self.task.await {
|
||||
error!(error = ?e, "health server task did not shut down cleanly");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the HTTP health server. Returns once the listener is bound.
|
||||
pub async fn spawn(bind: String, runtime: Arc<Runtime>) -> Result<HealthServerHandle> {
|
||||
let addr: SocketAddr = bind
|
||||
.parse()
|
||||
.with_context(|| format!("invalid health.bind address: {bind}"))?;
|
||||
|
||||
let state = AppState { runtime };
|
||||
let app = Router::new()
|
||||
.route("/health", get(health_handler))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.with_context(|| format!("binding health server to {addr}"))?;
|
||||
|
||||
info!(%addr, "health server listening");
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
{
|
||||
error!(error = %e, "health server exited with error");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(HealthServerHandle {
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
task,
|
||||
})
|
||||
}
|
||||
|
||||
async fn health_handler(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> Json<AggregatedHealth> {
|
||||
Json(state.runtime.health_snapshot())
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! autopilot binary — runtime composition root.
|
||||
//!
|
||||
//! Flow: parse CLI → load config → init tracing → create state dirs → wire
|
||||
//! actors → start health server → signal systemd ready → await shutdown.
|
||||
|
||||
mod bit_runner;
|
||||
mod health_server;
|
||||
mod runtime;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use tokio::signal;
|
||||
use tracing::info;
|
||||
|
||||
use shared::config::{Config, ConfigLoader};
|
||||
use shared::observability::{self, LogFormat};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "autopilot",
|
||||
about = "AZAION autopilot — onboard mission executor"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to the TOML config file. Overrides `AUTOPILOT_CONFIG`.
|
||||
#[arg(
|
||||
long,
|
||||
env = "AUTOPILOT_CONFIG",
|
||||
default_value = "config/autopilot.dev.toml"
|
||||
)]
|
||||
config: PathBuf,
|
||||
|
||||
/// Active mission UUID (per-flight; required at flight time, optional in dev).
|
||||
#[arg(long, env = "AUTOPILOT_MISSION_ID")]
|
||||
mission_id: Option<String>,
|
||||
|
||||
/// Override the configured health-server bind address.
|
||||
#[arg(long, env = "AUTOPILOT_HEALTH_BIND")]
|
||||
health_bind: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let mut config: Config = ConfigLoader::from_path(&cli.config)
|
||||
.with_context(|| format!("loading config from {}", cli.config.display()))?;
|
||||
|
||||
if let Some(addr) = cli.health_bind.as_deref() {
|
||||
config.health.bind = addr.to_string();
|
||||
}
|
||||
|
||||
let log_format = LogFormat::parse(&config.observability.log_format);
|
||||
let _ = observability::init(log_format, &config.observability.default_log_filter);
|
||||
|
||||
info!(
|
||||
config = %cli.config.display(),
|
||||
bind = %config.health.bind,
|
||||
mission_id = ?cli.mission_id,
|
||||
"autopilot starting"
|
||||
);
|
||||
|
||||
runtime::ensure_state_directories(&config.storage.state_dir)
|
||||
.with_context(|| format!("preparing state dir {}", config.storage.state_dir))?;
|
||||
|
||||
let bit = bit_runner::run_preflight_bit(&config).await?;
|
||||
info!(?bit, "pre-flight BIT outcome");
|
||||
|
||||
let runtime = Arc::new(runtime::Runtime::new(config.clone()));
|
||||
let health_handle = health_server::spawn(config.health.bind.clone(), runtime.clone()).await?;
|
||||
|
||||
notify_systemd_ready();
|
||||
|
||||
info!("autopilot is running. Press Ctrl-C to shut down.");
|
||||
wait_for_shutdown_signal().await;
|
||||
|
||||
info!("shutdown signal received");
|
||||
notify_systemd_stopping();
|
||||
health_handle.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_shutdown_signal() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use signal::unix::{signal as unix_signal, SignalKind};
|
||||
let mut sigterm = unix_signal(SignalKind::terminate()).expect("install SIGTERM handler");
|
||||
let mut sigint = unix_signal(SignalKind::interrupt()).expect("install SIGINT handler");
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => {}
|
||||
_ = sigint.recv() => {}
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = signal::ctrl_c().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn notify_systemd_ready() {
|
||||
if let Err(e) = sd_notify::notify(false, &[sd_notify::NotifyState::Ready]) {
|
||||
tracing::warn!(error = %e, "sd_notify READY failed (running outside systemd is fine)");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn notify_systemd_stopping() {
|
||||
if let Err(e) = sd_notify::notify(false, &[sd_notify::NotifyState::Stopping]) {
|
||||
tracing::warn!(error = %e, "sd_notify STOPPING failed");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn notify_systemd_ready() {}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn notify_systemd_stopping() {}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Runtime composition root.
|
||||
//!
|
||||
//! Wires every component handle, owns the actor join-handles, and aggregates
|
||||
//! per-component health for the `/health` endpoint. Per-component construction
|
||||
//! and channel wiring lands in the per-component implementation tasks
|
||||
//! (AZ-641 onwards); today's bootstrap exposes the aggregation surface only.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use shared::config::Config;
|
||||
use shared::health::{AggregatedHealth, ComponentHealth};
|
||||
|
||||
/// Components named in `/_docs/02_document/components/`. The list drives both
|
||||
/// the bootstrap health payload and the eventual per-component wiring.
|
||||
pub const COMPONENT_NAMES: &[&str] = &[
|
||||
"frame_ingest",
|
||||
"detection_client",
|
||||
"movement_detector",
|
||||
"semantic_analyzer",
|
||||
"vlm_client",
|
||||
"scan_controller",
|
||||
"mapobjects_store",
|
||||
"gimbal_controller",
|
||||
"operator_bridge",
|
||||
"mission_executor",
|
||||
"mavlink_layer",
|
||||
"mission_client",
|
||||
"telemetry_stream",
|
||||
];
|
||||
|
||||
/// Owns the configuration and the eventual actor topology.
|
||||
pub struct Runtime {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
// Public for future per-component wiring (AZ-641+).
|
||||
#[allow(dead_code)]
|
||||
pub fn config(&self) -> &Config {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Aggregated health snapshot used by the `/health` endpoint.
|
||||
///
|
||||
/// While the per-component handles are not yet wired (bootstrap phase),
|
||||
/// the snapshot reports every component as `Disabled` so the endpoint shape
|
||||
/// already matches the contract in `containerization.md §7`.
|
||||
pub fn health_snapshot(&self) -> AggregatedHealth {
|
||||
// Every component is `Disabled` during bootstrap. Per-component
|
||||
// wiring (AZ-641+) will return real health levels as actors come up.
|
||||
// VLM stays `Disabled` whenever `config.vlm.enabled = false` even after
|
||||
// wiring.
|
||||
let components = COMPONENT_NAMES
|
||||
.iter()
|
||||
.map(|name| ComponentHealth::disabled(name))
|
||||
.collect();
|
||||
let _ = self.config.vlm.enabled; // keeps the field used until AZ-672 wiring lands
|
||||
AggregatedHealth::aggregate(components)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the persistent state subtree under `state_dir`.
|
||||
/// Subdirectories per `data_model.md §6` and `containerization.md §3`.
|
||||
pub fn ensure_state_directories(state_dir: &str) -> std::io::Result<()> {
|
||||
let root = Path::new(state_dir);
|
||||
for sub in ["mapobjects", "audit", "pending_pushes"] {
|
||||
std::fs::create_dir_all(root.join(sub))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
fn tmp_state_dir() -> std::path::PathBuf {
|
||||
let n = SEQ.fetch_add(1, Ordering::SeqCst);
|
||||
let pid = std::process::id();
|
||||
std::env::temp_dir().join(format!("autopilot-test-state-{pid}-{n}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_state_directories_creates_subdirs() {
|
||||
// Arrange
|
||||
let dir = tmp_state_dir();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// Act
|
||||
ensure_state_directories(dir.to_str().unwrap()).expect("dirs created");
|
||||
|
||||
// Assert
|
||||
for sub in ["mapobjects", "audit", "pending_pushes"] {
|
||||
let path = dir.join(sub);
|
||||
assert!(path.is_dir(), "expected dir {path:?} to exist");
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "detection_client"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Real gRPC stack lands with AZ-660 (`detection_client_grpc_stream`).
|
||||
# tonic / prost dependencies + build.rs + proto/ wiring will be added there.
|
||||
@@ -0,0 +1,58 @@
|
||||
//! `detection_client` — bi-directional gRPC to `../detections`.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-660 `detection_client_grpc_stream`
|
||||
//! - AZ-661 `detection_client_schema_and_health`
|
||||
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::detection::DetectionBatch;
|
||||
use shared::models::frame::Frame;
|
||||
|
||||
const NAME: &str = "detection_client";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DetectionClient {
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
impl DetectionClient {
|
||||
pub fn new(endpoint: String) -> Self {
|
||||
Self { endpoint }
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> DetectionClientHandle {
|
||||
DetectionClientHandle {
|
||||
endpoint: self.endpoint.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DetectionClientHandle {
|
||||
#[allow(dead_code)]
|
||||
endpoint: String,
|
||||
}
|
||||
|
||||
impl DetectionClientHandle {
|
||||
pub async fn request(&self, _frame: Frame) -> Result<DetectionBatch> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"detection_client::request (AZ-660)",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
ComponentHealth::disabled(NAME)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
let h = DetectionClient::new("http://127.0.0.1:50051".into()).handle();
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "frame_ingest"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -0,0 +1,59 @@
|
||||
//! `frame_ingest` — RTSP pull + decode + timestamp.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-657 `frame_ingest_rtsp_session`
|
||||
//! - AZ-658 `frame_ingest_decoder`
|
||||
//! - AZ-659 `frame_ingest_publisher`
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::frame::Frame;
|
||||
|
||||
const NAME: &str = "frame_ingest";
|
||||
|
||||
pub struct FrameIngest {
|
||||
tx: broadcast::Sender<Frame>,
|
||||
}
|
||||
|
||||
impl FrameIngest {
|
||||
pub fn new(channel_capacity: usize) -> Self {
|
||||
let (tx, _rx) = broadcast::channel(channel_capacity);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> FrameIngestHandle {
|
||||
FrameIngestHandle {
|
||||
tx: self.tx.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FrameIngestHandle {
|
||||
tx: broadcast::Sender<Frame>,
|
||||
}
|
||||
|
||||
impl FrameIngestHandle {
|
||||
/// Subscribe to the frame stream. Consumers receive every frame after they
|
||||
/// subscribed; back-pressure is implemented via broadcast channel lag (see
|
||||
/// AZ-659 for the slow-consumer policy).
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Frame> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
ComponentHealth::disabled(NAME)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
let h = FrameIngest::new(8).handle();
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "gimbal_controller"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -0,0 +1,89 @@
|
||||
//! `gimbal_controller` — ViewPro A40 UDP control + smooth-pan primitive.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-653 `gimbal_a40_transport`
|
||||
//! - AZ-654 `gimbal_zoom_out_sweep`
|
||||
//! - AZ-655 `gimbal_smooth_pan_plan`
|
||||
//! - AZ-656 `gimbal_centre_on_target`
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::gimbal::GimbalState;
|
||||
|
||||
const NAME: &str = "gimbal_controller";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GimbalCommand {
|
||||
pub yaw_deg: f32,
|
||||
pub pitch_deg: f32,
|
||||
}
|
||||
|
||||
pub struct GimbalController {
|
||||
state_tx: watch::Sender<GimbalState>,
|
||||
}
|
||||
|
||||
impl GimbalController {
|
||||
pub fn new(initial: GimbalState) -> Self {
|
||||
let (state_tx, _rx) = watch::channel(initial);
|
||||
Self { state_tx }
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> GimbalControllerHandle {
|
||||
GimbalControllerHandle {
|
||||
state_tx: self.state_tx.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GimbalControllerHandle {
|
||||
state_tx: watch::Sender<GimbalState>,
|
||||
}
|
||||
|
||||
impl GimbalControllerHandle {
|
||||
pub async fn set_pose(&self, _command: GimbalCommand) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"gimbal_controller::set_pose (AZ-653)",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn zoom(&self, _level: f32) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"gimbal_controller::zoom (AZ-654)",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn state(&self) -> GimbalState {
|
||||
*self.state_tx.borrow()
|
||||
}
|
||||
|
||||
pub fn state_stream(&self) -> watch::Receiver<GimbalState> {
|
||||
self.state_tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
ComponentHealth::disabled(NAME)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
let initial = GimbalState {
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
zoom: 1.0,
|
||||
ts_monotonic_ns: 0,
|
||||
command_in_flight: false,
|
||||
};
|
||||
let h = GimbalController::new(initial).handle();
|
||||
assert_eq!(h.state().zoom, 1.0);
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "mapobjects_store"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# H3 indexing (h3rs) lands with AZ-665. Engine plug points (Q3) materialise in AZ-668.
|
||||
@@ -0,0 +1,108 @@
|
||||
//! `mapobjects_store` — H3-indexed on-device map of detected objects.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-665 `mapobjects_store_h3_classify`
|
||||
//! - AZ-666 `mapobjects_store_ignored_and_pass_sweep`
|
||||
//! - AZ-667 `mapobjects_store_hydrate_and_pending`
|
||||
//! - AZ-668 `mapobjects_store_persistence`
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::detection::Detection;
|
||||
use shared::models::mapobject::MapObjectsBundle;
|
||||
use shared::models::poi::Poi;
|
||||
|
||||
const NAME: &str = "mapobjects_store";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum Classification {
|
||||
New,
|
||||
Moved,
|
||||
Existing,
|
||||
RemovedCandidate,
|
||||
Ignored,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SyncState {
|
||||
/// Bundle pulled centrally and applied.
|
||||
Hydrated,
|
||||
/// Local-observed records exist but have not been pushed.
|
||||
Pending,
|
||||
/// Push acknowledged centrally.
|
||||
PushedOk,
|
||||
/// Push failed; will retry from `pending_pushes/`.
|
||||
PushDeferred,
|
||||
}
|
||||
|
||||
pub struct MapObjectsStore;
|
||||
|
||||
impl MapObjectsStore {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> MapObjectsStoreHandle {
|
||||
MapObjectsStoreHandle
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MapObjectsStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MapObjectsStoreHandle;
|
||||
|
||||
impl MapObjectsStoreHandle {
|
||||
pub async fn classify(&self, _detection: Detection) -> Result<Classification> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mapobjects_store::classify (AZ-665)",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn apply_decline(&self, _poi: Poi) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mapobjects_store::apply_decline (AZ-666)",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn dump_pending(&self) -> Result<MapObjectsBundle> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mapobjects_store::dump_pending (AZ-667)",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn hydrate(&self, _bundle: MapObjectsBundle) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mapobjects_store::hydrate (AZ-667)",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn set_sync_state(&self, _state: SyncState) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mapobjects_store::set_sync_state (AZ-667)",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
ComponentHealth::disabled(NAME)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
let h = MapObjectsStore::new().handle();
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "mavlink_layer"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
@@ -0,0 +1,80 @@
|
||||
//! `mavlink_layer` — hand-rolled MAVLink v2 transport.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-641 `mavlink_transport_and_heartbeat`
|
||||
//! - AZ-642 `mavlink_codec`
|
||||
//! - AZ-643 `mavlink_ack_demux_and_signing`
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use shared::contracts::MavlinkSink;
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
|
||||
const NAME: &str = "mavlink_layer";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MavlinkConnection {
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MavlinkLayer {
|
||||
connection: MavlinkConnection,
|
||||
}
|
||||
|
||||
impl MavlinkLayer {
|
||||
pub fn new(connection: MavlinkConnection) -> Self {
|
||||
Self { connection }
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> MavlinkHandle {
|
||||
MavlinkHandle::new(self.connection.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MavlinkHandle {
|
||||
#[allow(dead_code)]
|
||||
connection: MavlinkConnection,
|
||||
}
|
||||
|
||||
impl MavlinkHandle {
|
||||
pub(crate) fn new(connection: MavlinkConnection) -> Self {
|
||||
Self { connection }
|
||||
}
|
||||
|
||||
pub async fn send_raw(&self, _payload: Vec<u8>) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mavlink_layer::send_raw (AZ-641)",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
ComponentHealth::disabled(NAME)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MavlinkSink for MavlinkHandle {
|
||||
async fn send_raw(&self, msg: Vec<u8>) -> Result<()> {
|
||||
MavlinkHandle::send_raw(self, msg).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
// Arrange / Act
|
||||
let h = MavlinkLayer::new(MavlinkConnection {
|
||||
uri: "udp://127.0.0.1:14550".into(),
|
||||
})
|
||||
.handle();
|
||||
|
||||
// Assert
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "mission_client"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -0,0 +1,78 @@
|
||||
//! `mission_client` — REST client for the `missions` API.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-644 `mission_client_pull_and_schema`
|
||||
//! - AZ-645 `mission_client_waypoint_post`
|
||||
//! - AZ-646 `mission_client_mapobjects_pull`
|
||||
//! - AZ-647 `mission_client_mapobjects_push`
|
||||
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::mapobject::MapObjectsBundle;
|
||||
use shared::models::mission::{Coordinate, MissionItem};
|
||||
|
||||
const NAME: &str = "mission_client";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MissionClient {
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
impl MissionClient {
|
||||
pub fn new(endpoint: String) -> Self {
|
||||
Self { endpoint }
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> MissionClientHandle {
|
||||
MissionClientHandle {
|
||||
endpoint: self.endpoint.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MissionClientHandle {
|
||||
#[allow(dead_code)]
|
||||
endpoint: String,
|
||||
}
|
||||
|
||||
impl MissionClientHandle {
|
||||
pub async fn pull_mission(&self, _mission_id: &str) -> Result<Vec<MissionItem>> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mission_client::pull_mission (AZ-644)",
|
||||
))
|
||||
}
|
||||
|
||||
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 {
|
||||
ComponentHealth::disabled(NAME)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
let h = MissionClient::new("http://127.0.0.1:8443".into()).handle();
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "mission_executor"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
mavlink_layer = { workspace = true }
|
||||
mission_client = { workspace = true }
|
||||
mapobjects_store = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -0,0 +1,105 @@
|
||||
//! `mission_executor` — multirotor + fixed-wing FSMs, geofence, failsafe.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-648 `mission_executor_state_machine`
|
||||
//! - AZ-649 `mission_executor_telemetry_forwarding`
|
||||
//! - AZ-650 `mission_executor_bit_f9`
|
||||
//! - AZ-651 `mission_executor_lost_link_ladder`
|
||||
//! - AZ-652 `mission_executor_safety_and_resume`
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::mission::{Coordinate, MissionItem};
|
||||
|
||||
const NAME: &str = "mission_executor";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum ExecutorState {
|
||||
Disconnected,
|
||||
PreFlight,
|
||||
Taxi,
|
||||
Climb,
|
||||
Cruise,
|
||||
MiddleWaypointInsert,
|
||||
TargetFollow,
|
||||
Rtl,
|
||||
Land,
|
||||
WaitAuto,
|
||||
Aborted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FailsafeKind {
|
||||
LinkDegraded,
|
||||
LinkLost,
|
||||
LinkLostInFollow,
|
||||
BatteryRtl,
|
||||
BatteryHardFloor,
|
||||
GeofenceInclusion,
|
||||
GeofenceExclusion,
|
||||
}
|
||||
|
||||
pub struct MissionExecutor;
|
||||
|
||||
impl MissionExecutor {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> MissionExecutorHandle {
|
||||
MissionExecutorHandle
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MissionExecutor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MissionExecutorHandle;
|
||||
|
||||
impl MissionExecutorHandle {
|
||||
pub async fn start(&self, _mission: Vec<MissionItem>) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mission_executor::start (AZ-648)",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn insert_middle_waypoint(&self, _at: Coordinate) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mission_executor::insert_middle_waypoint (AZ-652)",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn failsafe_trigger(&self, _kind: FailsafeKind) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mission_executor::failsafe_trigger (AZ-651)",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn state(&self) -> ExecutorState {
|
||||
ExecutorState::Disconnected
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
ComponentHealth::disabled(NAME)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
let h = MissionExecutor::new().handle();
|
||||
assert_eq!(h.state(), ExecutorState::Disconnected);
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "movement_detector"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Learned-CV fallback path per architecture.md Q14. Lands with AZ-664.
|
||||
learned_cv = []
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# OpenCV / homography deps land with AZ-662 (`movement_detector_ego_motion`).
|
||||
@@ -0,0 +1,56 @@
|
||||
//! `movement_detector` — ego-motion compensated residual-motion clustering.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-662 `movement_detector_ego_motion`
|
||||
//! - AZ-663 `movement_detector_clustering_and_emission`
|
||||
//! - AZ-664 `movement_detector_fp_cap_and_q14_fallback`
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::movement::MovementCandidate;
|
||||
|
||||
const NAME: &str = "movement_detector";
|
||||
|
||||
pub struct MovementDetector {
|
||||
tx: broadcast::Sender<MovementCandidate>,
|
||||
}
|
||||
|
||||
impl MovementDetector {
|
||||
pub fn new(channel_capacity: usize) -> Self {
|
||||
let (tx, _rx) = broadcast::channel(channel_capacity);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> MovementDetectorHandle {
|
||||
MovementDetectorHandle {
|
||||
tx: self.tx.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MovementDetectorHandle {
|
||||
tx: broadcast::Sender<MovementCandidate>,
|
||||
}
|
||||
|
||||
impl MovementDetectorHandle {
|
||||
pub fn candidates(&self) -> broadcast::Receiver<MovementCandidate> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
ComponentHealth::disabled(NAME)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
let h = MovementDetector::new(16).handle();
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "operator_bridge"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
mapobjects_store = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -0,0 +1,117 @@
|
||||
//! `operator_bridge` — POI surfacing + operator command authentication.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-678 `operator_bridge_command_auth`
|
||||
//! - AZ-679 `operator_bridge_poi_surface`
|
||||
//! - AZ-680 `operator_bridge_command_dispatch`
|
||||
//! - AZ-681 `operator_bridge_safety_and_bit_ack`
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use shared::contracts::OperatorCommandSink;
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::mission::Coordinate;
|
||||
use shared::models::operator::OperatorCommand;
|
||||
use shared::models::poi::Poi;
|
||||
|
||||
const NAME: &str = "operator_bridge";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OperatorDecision {
|
||||
Confirmed,
|
||||
Declined,
|
||||
TimedOut,
|
||||
StartTargetFollow,
|
||||
ReleaseTargetFollow,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MiddleWaypointHint {
|
||||
pub mission_id: String,
|
||||
pub at: Coordinate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TargetFollowEvent {
|
||||
Start { target_id: String },
|
||||
Release,
|
||||
}
|
||||
|
||||
pub struct OperatorBridge {
|
||||
middle_waypoint_tx: mpsc::Sender<MiddleWaypointHint>,
|
||||
target_follow_tx: mpsc::Sender<TargetFollowEvent>,
|
||||
middle_waypoint_rx: Option<mpsc::Receiver<MiddleWaypointHint>>,
|
||||
target_follow_rx: Option<mpsc::Receiver<TargetFollowEvent>>,
|
||||
}
|
||||
|
||||
impl OperatorBridge {
|
||||
pub fn new(channel_capacity: usize) -> Self {
|
||||
let (mw_tx, mw_rx) = mpsc::channel(channel_capacity);
|
||||
let (tf_tx, tf_rx) = mpsc::channel(channel_capacity);
|
||||
Self {
|
||||
middle_waypoint_tx: mw_tx,
|
||||
target_follow_tx: tf_tx,
|
||||
middle_waypoint_rx: Some(mw_rx),
|
||||
target_follow_rx: Some(tf_rx),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> OperatorBridgeHandle {
|
||||
OperatorBridgeHandle {
|
||||
middle_waypoint_tx: self.middle_waypoint_tx.clone(),
|
||||
target_follow_tx: self.target_follow_tx.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_middle_waypoint_receiver(&mut self) -> Option<mpsc::Receiver<MiddleWaypointHint>> {
|
||||
self.middle_waypoint_rx.take()
|
||||
}
|
||||
|
||||
pub fn take_target_follow_receiver(&mut self) -> Option<mpsc::Receiver<TargetFollowEvent>> {
|
||||
self.target_follow_rx.take()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OperatorBridgeHandle {
|
||||
#[allow(dead_code)]
|
||||
middle_waypoint_tx: mpsc::Sender<MiddleWaypointHint>,
|
||||
#[allow(dead_code)]
|
||||
target_follow_tx: mpsc::Sender<TargetFollowEvent>,
|
||||
}
|
||||
|
||||
impl OperatorBridgeHandle {
|
||||
pub async fn surface_poi(&self, _poi: Poi) -> Result<OperatorDecision> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"operator_bridge::surface_poi (AZ-679)",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
ComponentHealth::disabled(NAME)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OperatorCommandSink for OperatorBridgeHandle {
|
||||
async fn dispatch(&self, _command: OperatorCommand) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"operator_bridge::dispatch (AZ-680)",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
let h = OperatorBridge::new(8).handle();
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "scan_controller"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
mapobjects_store = { workspace = true }
|
||||
gimbal_controller = { workspace = true }
|
||||
semantic_analyzer = { workspace = true }
|
||||
operator_bridge = { workspace = true }
|
||||
mission_executor = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
@@ -0,0 +1,84 @@
|
||||
//! `scan_controller` — central typed state machine.
|
||||
//!
|
||||
//! States per architecture.md §5: `ZoomedOut | ZoomedIn { roi, hold_started_at }
|
||||
//! | TargetFollow { target_id, started_at }`. Full behaviour-tree spec lives in
|
||||
//! `system-flows.md §F4`.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-682 `scan_controller_state_machine`
|
||||
//! - AZ-683 `scan_controller_poi_queue_and_window`
|
||||
//! - AZ-684 `scan_controller_evidence_ladder`
|
||||
//! - AZ-685 `scan_controller_mapobjects_dispatch`
|
||||
//! - AZ-686 `scan_controller_gimbal_issuance`
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::operator::OperatorCommand;
|
||||
|
||||
const NAME: &str = "scan_controller";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum ScanState {
|
||||
ZoomedOut,
|
||||
ZoomedIn { roi: Uuid, hold_started_at_ns: u64 },
|
||||
TargetFollow { target_id: Uuid, started_at_ns: u64 },
|
||||
}
|
||||
|
||||
pub struct ScanController;
|
||||
|
||||
impl ScanController {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> ScanControllerHandle {
|
||||
ScanControllerHandle
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScanController {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ScanControllerHandle;
|
||||
|
||||
impl ScanControllerHandle {
|
||||
pub async fn tick(&self) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"scan_controller::tick (AZ-682)",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn submit_operator_cmd(&self, _command: OperatorCommand) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"scan_controller::submit_operator_cmd (AZ-682)",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn state(&self) -> ScanState {
|
||||
ScanState::ZoomedOut
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
ComponentHealth::disabled(NAME)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
let h = ScanController::new().handle();
|
||||
assert!(matches!(h.state(), ScanState::ZoomedOut));
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "semantic_analyzer"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# TensorRT / ONNX runtime wiring lands with AZ-670.
|
||||
@@ -0,0 +1,56 @@
|
||||
//! `semantic_analyzer` — Tier 2 primitive graph + ROI CNN.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-669 `semantic_analyzer_primitive_graph`
|
||||
//! - AZ-670 `semantic_analyzer_roi_cnn`
|
||||
//! - AZ-671 `semantic_analyzer_action_policy`
|
||||
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::tier2::Tier2Evidence;
|
||||
|
||||
const NAME: &str = "semantic_analyzer";
|
||||
|
||||
pub struct SemanticAnalyzer;
|
||||
|
||||
impl SemanticAnalyzer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> SemanticAnalyzerHandle {
|
||||
SemanticAnalyzerHandle
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SemanticAnalyzer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct SemanticAnalyzerHandle;
|
||||
|
||||
impl SemanticAnalyzerHandle {
|
||||
pub async fn analyze(&self, _roi: Vec<u8>) -> Result<Tier2Evidence> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"semantic_analyzer::analyze (AZ-669)",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
ComponentHealth::disabled(NAME)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
let h = SemanticAnalyzer::new().handle();
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bytes = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "telemetry_stream"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
@@ -0,0 +1,91 @@
|
||||
//! `telemetry_stream` — always-on uplink to the Ground Station + operator-command downlink.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-675 `telemetry_stream_grpc_server`
|
||||
//! - AZ-676 `telemetry_stream_video_path`
|
||||
//! - AZ-677 `telemetry_stream_mapobjects_snapshot`
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use shared::contracts::TelemetrySink;
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::detection::DetectionBatch;
|
||||
use shared::models::frame::Frame;
|
||||
use shared::models::operator::OperatorCommand;
|
||||
|
||||
const NAME: &str = "telemetry_stream";
|
||||
|
||||
pub struct TelemetryStream {
|
||||
commands_tx: mpsc::Sender<OperatorCommand>,
|
||||
commands_rx: Option<mpsc::Receiver<OperatorCommand>>,
|
||||
}
|
||||
|
||||
impl TelemetryStream {
|
||||
pub fn new(downlink_capacity: usize) -> Self {
|
||||
let (commands_tx, commands_rx) = mpsc::channel(downlink_capacity);
|
||||
Self {
|
||||
commands_tx,
|
||||
commands_rx: Some(commands_rx),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> TelemetryStreamHandle {
|
||||
TelemetryStreamHandle {
|
||||
commands_tx: self.commands_tx.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the downlink command receiver. The composition root forwards it to
|
||||
/// `operator_bridge` as `Receiver<OperatorCommand>`.
|
||||
pub fn take_command_receiver(&mut self) -> Option<mpsc::Receiver<OperatorCommand>> {
|
||||
self.commands_rx.take()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TelemetryStreamHandle {
|
||||
commands_tx: mpsc::Sender<OperatorCommand>,
|
||||
}
|
||||
|
||||
impl TelemetryStreamHandle {
|
||||
/// Inject an operator command. Production path is fed by the downlink
|
||||
/// receiver in `internal::downlink/*`; tests can call this directly.
|
||||
pub async fn submit_command(&self, command: OperatorCommand) -> Result<()> {
|
||||
self.commands_tx
|
||||
.send(command)
|
||||
.await
|
||||
.map_err(|_| AutopilotError::Internal("downlink channel closed".into()))
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
ComponentHealth::disabled(NAME)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TelemetrySink for TelemetryStreamHandle {
|
||||
async fn push_frame(&self, _frame: Frame) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"telemetry_stream::push_frame (AZ-676)",
|
||||
))
|
||||
}
|
||||
|
||||
async fn push_detections(&self, _batch: DetectionBatch) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"telemetry_stream::push_detections (AZ-675)",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
let h = TelemetryStream::new(8).handle();
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "vlm_client"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Real NanoLLM/VILA IPC path. With `vlm` off, `VlmClient` returns the disabled
|
||||
# no-op assessment (architecture.md §7.6 Optionality model).
|
||||
vlm = []
|
||||
|
||||
[dependencies]
|
||||
shared = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
@@ -0,0 +1,72 @@
|
||||
//! `vlm_client` — optional Tier 3 NanoLLM/VILA Visual-Language-Model client.
|
||||
//!
|
||||
//! Default impl (`feature = "vlm"` OFF) returns `VlmAssessment::disabled()`.
|
||||
//! Real IPC path lands in:
|
||||
//! - AZ-672 `vlm_client_provider_trait`
|
||||
//! - AZ-673 `vlm_client_nanollm_ipc`
|
||||
//! - AZ-674 `vlm_client_schema_and_model_version`
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use shared::contracts::VlmProvider;
|
||||
use shared::error::Result;
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::vlm::VlmAssessment;
|
||||
|
||||
const NAME: &str = "vlm_client";
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct VlmClient {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl VlmClient {
|
||||
/// Construct the no-op `VlmClient`. Returns `VlmAssessment::disabled()`
|
||||
/// from every `assess()` call.
|
||||
pub fn with_default() -> Self {
|
||||
Self { enabled: false }
|
||||
}
|
||||
|
||||
#[cfg(feature = "vlm")]
|
||||
pub fn enabled() -> Self {
|
||||
Self { enabled: true }
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
if self.enabled {
|
||||
ComponentHealth::green(NAME)
|
||||
} else {
|
||||
ComponentHealth::disabled(NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VlmProvider for VlmClient {
|
||||
async fn assess(&self, _roi: Vec<u8>, _prompt: String) -> Result<VlmAssessment> {
|
||||
// Disabled path always returns the documented no-op assessment.
|
||||
// The real path lands in AZ-673.
|
||||
Ok(VlmAssessment::disabled())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_impl_returns_disabled_assessment() {
|
||||
// Arrange
|
||||
let c = VlmClient::with_default();
|
||||
|
||||
// Act
|
||||
let result = c
|
||||
.assess(Vec::new(), String::new())
|
||||
.await
|
||||
.expect("disabled path is infallible");
|
||||
|
||||
// Assert
|
||||
assert_eq!(result.status, shared::models::vlm::VlmStatus::Disabled);
|
||||
assert_eq!(result.label, shared::models::vlm::VlmLabel::Inconclusive);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user