# Agent Services Source: https://docs.altnautica.com/architecture/agent-services The multi-process systemd architecture, IPC sockets, circuit breaker, and profile detection. # Agent Services The ADOS Drone Agent uses a multi-process architecture where each service runs as an independent systemd unit. A supervisor service manages the lifecycle. Services communicate through Unix domain sockets and share no memory. The agent is a Rust-first hybrid. Most long-running services are native Rust binaries: the supervisor, the MAVLink router, the cloud relay, the video pipeline, the radio data plane, the ground-side receiver, the physical-UI display, the uplink router, the logging daemon, and the vision host. Port 8080 is owned by the native Rust front, `ados-control`, which answers migrated routes directly and reverse-proxies the rest to a residual FastAPI process on an internal Unix socket. Python stays where the ecosystem lives: AI and vision inference, the plugin runtime, the setup webapp, HAL board detection and first-boot bootstrap, the config layer, the health monitor, and some ground-station hardware glue (Ethernet, WiFi client, modem, buttons, peripherals). The supervisor never spawns these processes itself: it issues `systemctl` against a fixed catalog, so systemd remains the process manager and owns the cgroup, restart, and journald wiring. Many unit names are stable shims that exec a Rust binary, so the commands you type (`systemctl restart ados-oled`, `ados-wfb`) keep working while the implementation underneath is native. ## Why multi-process A single in-process design is simpler, but it has real drawbacks for a drone: * A crashed video encoder takes down the MAVLink proxy. The flight controller loses its companion link. * No per-service resource limits. A memory leak in one service starves the others. * No per-service restart. Fixing a video pipeline issue requires restarting everything. The multi-process design isolates failures. A crashed `ados-video` gets restarted by systemd in 3 seconds. The MAVLink proxy never notices. ## Service tree ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} %%{init: {'theme': 'dark'}}%% flowchart TB Supervisor["ados-supervisor (Rust)
orchestrates systemd"] subgraph Core["Core Services (always running)"] MAVLink["ados-mavlink
runs ados-mavlink-router (Rust)
FC serial router + :8765 WS"] Control["ados-control (Rust)
HTTP front on :8080"] API["ados-api (Python)
residual FastAPI, internal socket"] Cloud["ados-cloud (Rust)
MQTT + Convex relay"] Health["ados-health (Python)
CPU, RAM, temp"] Logd["ados-logd (Rust)
black-box logging :8090"] end subgraph HW["Hardware-Dependent"] Video["ados-video (Rust, drone)
camera + HW encode"] WFB["ados-wfb
runs ados-radio (Rust, drone)
WFB-ng TX"] Vision["ados-vision (Rust)
vision host"] Sensors["ados-peripherals (Python)
USB + sensor mgr"] GPIO["ados-gpio (Rust)
GPIO output, off until enabled"] end subgraph OnDemand["On-Demand"] Discovery["ados-discovery (Python)
mDNS"] Plugins["ados-plugin-host (Rust)
subprocess plugin supervisor"] end subgraph GroundOnly["Ground Station Profile"] WFBRX["ados-wfb-rx
runs ados-groundlink (Rust)"] OLED["ados-oled
runs ados-display (Rust)"] DispProbe["ados-display-probe (Rust)
panel detect"] PIC["ados-pic (Rust)
panel arbiter"] Input["ados-input (Rust)
joystick + touch"] Uplink["ados-uplink-router
runs ados-net (Rust)
uplink matrix: wired, WiFi station,
cellular, CDC-NCM tether"] HostAP["ados-hostapd (C)
WiFi AP"] MediaMTXGS["ados-mediamtx-gs
RTSP + WHEP"] Kiosk["ados-kiosk
Chromium HDMI"] Captive["ados-setup-captive + ados-dnsmasq-gs
first-boot portal"] end Supervisor --> Core Supervisor --> HW Supervisor --> OnDemand Supervisor --> GroundOnly ``` Distributed-receive roles add three more ground-station units when enabled: `ados-batman` (mesh carrier), `ados-wfb-relay` (relay role), and `ados-wfb-receiver` (receiver role). The supervisor starts child services based on the active profile (air or ground-station) and the hardware detected. Services that depend on hardware not present are masked, not started. ## Systemd unit structure Each service has a unit file in `/etc/systemd/system/`. Native Rust services run a binary from `/opt/ados/bin/`; the Python-backed units run through the virtual environment in `/opt/ados/venv/`. The unit name stays stable even when the implementation is native: `ados-mavlink` runs `ados-mavlink-router`, `ados-oled` runs `ados-display`, `ados-wfb` runs `ados-radio`, `ados-uplink-router` runs `ados-net`, and `ados-wfb-rx` runs `ados-groundlink`. ```ini theme={"theme":{"light":"github-light","dark":"github-dark"}} [Unit] Description=ADOS MAVLink Router After=ados-supervisor.service PartOf=ados-supervisor.service [Service] Type=simple User=ados # The ados-mavlink unit execs the native Rust router binary. Python-backed # units (for example ados-api, ados-health) use # /opt/ados/venv/bin/python -m ados.services. instead. ExecStart=/opt/ados/bin/ados-mavlink-router Restart=on-failure RestartSec=3 MemoryMax=128M CPUQuota=50% [Install] WantedBy=ados-supervisor.service ``` Key properties: * **PartOf:** service stops when the supervisor stops * **Restart=on-failure:** automatic restart on crash * **MemoryMax / CPUQuota:** cgroup limits prevent any single service from starving the system ## IPC: Unix domain sockets Services communicate through two Unix domain sockets in `/run/ados/`: ### MAVLink socket (`/run/ados/mavlink.sock`) Binary protocol. Each frame is a 4-byte little-endian length prefix followed by raw MAVLink bytes. ``` | LENGTH (4 bytes, LE) | MAVLink v2 frame (LEN bytes) | ``` The MAVLink router writes FC messages to this socket. Other services (cloud, logging, the HTTP front) read from it. This is a publish-subscribe pattern implemented over a Unix socket. Multiple readers get all messages. Two more sockets round out the IPC surface: `/run/ados/api-internal.sock` (the internal socket the residual FastAPI binds, reached only through the `ados-control` front) and `/run/ados/logd-query.sock` (the on-box query socket for the logging daemon, used by `ados logs query`). ### State socket (`/run/ados/state.sock`) JSON protocol at 10 Hz. Each frame is a newline-delimited JSON object: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} {"ts": 1713270934.5, "mode": "LOITER", "armed": false, "alt": 45.2, "bat": 82, "gps_sats": 12} ``` The health service reads this to compute system metrics. The cloud service reads it for telemetry upload. The OLED service reads it for display rendering. ## Circuit breaker The supervisor implements a circuit breaker pattern for each service. If a service crashes 5 times within 60 seconds, the breaker opens and the service is not restarted until a manual reset or a supervisor restart. ``` Normal → 5 failures in 60s → Breaker open → Manual reset or supervisor restart → Normal ``` When a breaker opens, the supervisor: 1. Logs a CRITICAL event 2. Sends a notification to Mission Control 3. Continues running all other services A single bad service does not bring down the whole agent. ## Profile detection On first boot (or when `agent.profile: auto` is set), the `profile_detect` module runs a score-based hardware fingerprint: | Signal | Ground score | Air score | | --------------------------------------- | ------------ | --------- | | I2C OLED at 0x3C or 0x3D | +3 | 0 | | 4 GPIO buttons with pull-ups | +2 | 0 | | RTL8812EU USB device | +1 | +1 | | MAVLink serial device (ttyACM or UART) | 0 | +3 | | GPS serial device | 0 | +2 | | FC heartbeat received within 10 seconds | 0 | +3 | | Known FC carrier board profile | 0 | +2 | **Decision rules:** * Ground score `>= 4` AND air score `<= 2`: **ground-station** profile * Air score `>= 4` AND ground score `<= 2`: **air** profile * Ambiguous: **unconfigured**, show pick-profile UI The result is written to `/etc/ados/profile.conf` with the full fingerprint snapshot. Explicit `agent.profile:` in config.yaml always overrides detection. ## Service lifecycle per profile Always started: * `ados-supervisor`, `ados-mavlink`, `ados-control`, `ados-api`, `ados-cloud`, `ados-health`, `ados-logd` Hardware-dependent: * `ados-video` (if camera detected) * `ados-wfb` in TX mode (if RTL8812EU detected) * `ados-vision` (if the vision engine is provisioned) * `ados-peripherals` (if USB sensors detected) * `ados-gpio` (off until enabled, for a status buzzer or LED) On-demand: * `ados-discovery`, `ados-plugin-host` Masked (never started): * All ground-station services (hostapd, oled, kiosk, etc.) Always started: * `ados-supervisor`, `ados-control`, `ados-api`, `ados-cloud`, `ados-health`, `ados-logd` * `ados-hostapd`, `ados-oled` * `ados-wfb-rx` (in the `direct` role) * `ados-mediamtx-gs` * `ados-uplink-router` (uplink matrix: wired link, WiFi station, cellular, USB tether), `ados-pic` (front-panel arbiter) * `ados-display-probe` (panel detect) * `ados-setup-captive`, `ados-dnsmasq-gs` (first boot) Hardware-dependent: * `ados-kiosk` (if HDMI output detected) * `ados-input` (if a joystick or touch device is present) * `ados-batman`, `ados-wfb-relay`, `ados-wfb-receiver` (mesh roles only) Masked (never started): * `ados-mavlink` (no FC), `ados-video` (no camera), `ados-wfb` TX mode The `ados-cloud` unit is a single cross-profile service. On the ground-station profile it also runs the cloud-relay bridge that forwards a drone's telemetry and video signaling on to the cloud, so there is no separate relay unit. ## HTTP control surface Port 8080 is served by the native Rust front, `ados-control`. It answers migrated routes directly and reverse-proxies everything else to the residual FastAPI process (the `ados-api` unit) over an internal Unix socket at `/run/ados/api-internal.sock`. From a client's point of view there is one HTTP surface on `:8080`. The split is internal, and the request paths, response bodies, and schemas stay the same across the boundary. The surface provides: * `/api/status` and `/api/status/full` for agent state * `/api/video/*` for video pipeline status and MediaMTX integration * `/api/v1/ground-station/*` for ground-station-specific endpoints (WiFi, pairing, OLED, buttons, uplinks) * `/api/command` for drone commands (arm, disarm, mode change) * `/api/config` for reading and writing agent configuration Authentication uses the `X-ADOS-Key` header with a key stored in `/etc/ados/config.yaml`, generated at install time. The MAVLink WebSocket on `:8765` (served by `ados-mavlink-router`) uses a short-lived HMAC ticket instead. The residual FastAPI keeps the features that stay in Python: AI and vision endpoints, the plugin runtime surface, the setup webapp, the device-discovery and peripherals routes, and the WHEP video bridge. As more routes move to the Rust front, the FastAPI footprint shrinks toward those Python-bound features only. ## HAL board profiles Each supported SBC has a YAML profile in `src/ados/hal/boards/`. The profile defines the SoC, the UART and GPIO map, the video codec support, and the navigation hardware: ```yaml theme={"theme":{"light":"github-light","dark":"github-dark"}} # Example: rpi4b.yaml name: "Raspberry Pi 4B" vendor: "Raspberry Pi" soc: "BCM2711" arch: "aarch64" model_patterns: - "Raspberry Pi 4 Model B" default_tier: 3 uart_paths: - /dev/ttyAMA0 - /dev/ttyS0 gpio_pins: [2, 3, 14, 15, 18] hw_video_codecs: - h264_enc - h264_dec - h265_dec video: csi_ports: 1 max_encode_resolution: "1920x1080" max_encode_fps: 30 encoder_api: v4l2 buses: i2c: - id: i2c1 ``` The profile drives service startup, GPIO mapping, video encoder selection, and feature gating. Detection runs on `/proc/device-tree/model`, `/proc/cpuinfo`, and an optional `/etc/ados/board_override`. Unknown boards fall back to safe `generic-arm64` defaults. ## Resource budget Memory use is dominated by the Python residual (FastAPI), the video encoder buffers, and Chromium when the HDMI kiosk runs. The native Rust services are lean: each orchestrator sits in the tens of MB. Indicative figures for the ground-station profile on a Pi 4B (4 GB RAM): | Service | Typical RAM | | ----------------------------------- | ------------ | | ados-supervisor (Rust) | \~15 MB | | ados-control (Rust front) | \~15 MB | | ados-api (FastAPI, internal socket) | \~40 MB | | ados-wfb-rx (Rust) | \~20 MB | | ados-mediamtx-gs | \~30 MB | | ados-hostapd | \~5 MB | | ados-oled (Rust) | \~10 MB | | ados-health (Python) | \~10 MB | | ados-kiosk (Chromium) | 280-520 MB | | **Total (no kiosk)** | **\~150 MB** | | **Total (with kiosk 720p)** | **\~430 MB** | On a 4 GB Pi 4B this leaves multiple GB free with or without the kiosk. A lean flight node can run a zero-Python core (the MAVLink router, camera encode, radio, and the `ados-control` front) for a much smaller footprint. ## What is next * [Video Stack](/architecture/video-stack) for the camera-to-browser pipeline * [Cloud Infrastructure](/architecture/cloud-infrastructure) for the three relay layers * [Project Structure](/architecture/project-structure) for the codebase layout # Cloud Infrastructure Source: https://docs.altnautica.com/architecture/cloud-infrastructure Three relay layers for remote access: Convex HTTP, MQTT telemetry, and WebRTC video. # Cloud Infrastructure The cloud layer is optional. Every core ADOS function works without internet. But when you want remote monitoring, fleet management, or observer access from across the internet, three relay layers provide increasing levels of real-time capability. ## Three layers ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} %%{init: {'theme': 'dark'}}%% flowchart LR subgraph Agent["Drone/Ground Agent"] Cloud["ados-cloud (Rust)
one service:
HTTP status + MQTT telemetry
+ WebRTC signaling"] end subgraph CloudSvc["Cloud Services"] Convex["Convex Backend
(your-convex.example.com)"] Mosquitto["Mosquitto MQTT
(your-mqtt.example.com)"] Bridge["MQTT-to-Convex bridge
(self-host helper)"] end subgraph GCS["Mission Control (Browser)"] B_Convex["Convex reactive queries"] B_MQTT["MQTT.js subscribe"] B_WebRTC["WebRTC peer connection"] end Cloud -->|HTTPS POST every 5s| Convex Cloud -->|MQTT TLS 8883| Mosquitto Cloud -->|WebRTC signaling over MQTT| Mosquitto Mosquitto -.->|keeps Convex fresh| Bridge Bridge -.->|HTTP POST| Convex Convex -->|Reactive| B_Convex Mosquitto -->|WebSocket 9001| B_MQTT Mosquitto -->|SDP relay| B_WebRTC ``` On the agent, all three layers are the work of one native Rust service, `ados-cloud`: it POSTs status to Convex, publishes telemetry to MQTT, and handles WebRTC signaling over MQTT. The agent reaches Convex directly, so the cloud-side MQTT-to-Convex bridge is an optional helper that keeps Convex fresh from the higher-rate MQTT stream, not a required hop. ## Layer 1: Convex HTTP (baseline) The simplest relay. The agent's native Rust cloud service (`ados-cloud`) POSTs a JSON status payload to the Convex backend every 5 seconds. Mission Control uses Convex's reactive queries to display the data in real time. | Detail | Value | | --------- | -------------------------------------------------------------------------------------------------------------------------------- | | Endpoint | Convex site origin `/agent/status` (`:3211`; managed by Altnautica in cloud mode, or `your-convex.example.com:3211` self-hosted) | | Frequency | Every 5 seconds | | Payload | JSON: mode, armed, battery, GPS, altitude, speed, connection state | | Bandwidth | Under 1 Kbps | | Latency | 5-10 seconds (poll interval) | This layer requires only outbound HTTPS from the agent. No port forwarding, no MQTT, no special setup. If the agent can reach the internet, status shows up in the GCS. ### Convex tables Two custom tables power the cloud relay: * **`cmd_droneStatus`**: Stores the latest status for each device. Upserted on every POST. Reactive query in the browser delivers changes immediately. * **`cmd_droneCommands`**: Command queue. Mission Control enqueues commands (arm, disarm, mode change). The agent polls this table and ACKs each command after execution. ## Layer 2: MQTT (real-time telemetry) For higher-frequency data, the agent publishes to MQTT topics via a Mosquitto broker behind a Cloudflare Tunnel. Mission Control subscribes from the browser using MQTT.js over WebSocket. | Detail | Value | | --------- | ------------------------------------------------------------------------------------------------------- | | Broker | `your-mqtt.example.com` (WebSocket on port 443, Cloudflare Tunnel; managed by Altnautica in cloud mode) | | Topics | `ados/{deviceId}/status`, `ados/{deviceId}/telemetry` | | Frequency | 2 Hz (configurable) | | Payload | JSON telemetry (attitude, GPS, battery, sensors) | | Bandwidth | 5-15 Kbps | | Latency | 100-300 ms | | Auth | Username `ados`, hashed password | The MQTT-to-Convex bridge runs alongside the broker. It subscribes to all `ados/+/status` and `ados/+/telemetry` topics, debounces 3 seconds per device, and POSTs the latest data to Convex. This keeps the Convex tables fresh for clients that use reactive queries instead of direct MQTT. ### Why MQTT, not just Convex polling Convex reactive queries are great for UI updates but the minimum granularity is tied to the 5-second HTTP POST cycle. MQTT gives true 2 Hz telemetry with \~200 ms latency. For a remote operator watching a live mission, the difference between 5-second updates and 500 ms updates is significant. MQTT also handles unreliable connections better. QoS 1 ensures delivery even if the TCP connection momentarily drops. ## Layer 3: WebRTC video (peer-to-peer) Live video does not go through a cloud media server. Instead, the browser and agent establish a direct WebRTC peer-to-peer connection. The MQTT broker acts as the signaling relay. The signaling flow: 1. Browser publishes an SDP offer to `ados/{deviceId}/webrtc/offer` 2. The agent's `ados-cloud` service receives the offer via MQTT 3. Agent creates a WebRTC answer using the local MediaMTX WHEP endpoint 4. Agent publishes the SDP answer to `ados/{deviceId}/webrtc/answer` 5. Browser receives the answer, ICE candidates are exchanged 6. WebRTC media stream flows directly between browser and agent (peer-to-peer) Once the peer connection is established, video flows directly between the two peers. The MQTT broker is only involved during the signaling handshake, not during streaming. P2P WebRTC requires both sides to be able to reach each other after STUN-negotiated NAT traversal. About 85-90% of networks support this. The remaining 10-15% (symmetric NAT on some cellular carriers) need a TURN relay, which is not yet deployed. Those users see a clear error in the transport switcher. ## Infrastructure layout All cloud services are co-located on a single Linux server: | Service | Port | Access | | ------------------------------------ | ---------------------------- | ------------------------------------------------------------------ | | Convex backend (client API) | 3210 | Used by the GCS browser client (`NEXT_PUBLIC_CONVEX_URL`) | | Convex backend (site / HTTP actions) | 3211 | Used by the agent heartbeat and the MQTT bridge (`/agent/status`) | | Mosquitto MQTT | 1883 (TCP), 9001 (WebSocket) | `your-mqtt.example.com` via Cloudflare Tunnel | | MQTT-to-Convex bridge | Internal | Subscribes to Mosquitto, POSTs to the Convex site origin (`:3211`) | Everything routes through Cloudflare Tunnels. No inbound ports are open on the server. The Tunnel client (`cloudflared`) maintains outbound connections to Cloudflare's edge. ## Self-hosting You can run the entire cloud stack on your own hardware using Docker Compose. ### Convex backend ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} git clone https://github.com/altnautica/ADOSMissionControl cd ADOSMissionControl npx convex dev # Starts a local Convex dev server ``` Or deploy to Convex cloud (free tier available) and point your agent at it. ### MQTT broker + bridge The MQTT broker and bridge are in `ADOSMissionControl/tools/mqtt-bridge/`: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} cd tools/mqtt-bridge docker compose up -d ``` This starts Mosquitto with WebSocket support and the MQTT-to-Convex bridge. Edit `.env` to point the bridge at your Convex deployment. ### Agent configuration Point your agent at your own cloud. The agent reads its cloud posture from `server.mode` and a matching block. For a self-hosted backend, set `mode: self_hosted` and fill in `server.self_hosted`: ```yaml theme={"theme":{"light":"github-light","dark":"github-dark"}} # /etc/ados/config.yaml server: mode: self_hosted # local | cloud | self_hosted self_hosted: url: "https://your-convex.example.com:3211" # Convex SITE origin (HTTP actions) mqtt_broker: "your-mqtt.example.com" mqtt_port: 8883 api_key: "" # if your broker requires one pairing: convex_url: "https://your-convex.example.com:3211" # Convex SITE origin (HTTP actions) ``` Set BOTH `server.self_hosted.url` AND `pairing.convex_url` to your Convex **site** origin (the HTTP-actions origin, port `:3211` on a self-hosted backend), not the client-API origin (`:3210`). The status heartbeat resolves its URL from `pairing.convex_url`, while the Python pairing register reads `server.self_hosted.url`. If only one is set, the agent can pair but never beacon (or beacon but never register), and the drone never appears in Mission Control. The agent falls back from one to the other when a value is missing, but setting both explicitly is the safest configuration. The full set of `server` keys (defaults shown): | Key | Default | Purpose | | -------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `server.mode` | `local` | `local` disables the cloud relay; `cloud` uses the Altnautica-managed backend (zero config, no endpoints to set); `self_hosted` points at your own deployment | | `server.self_hosted.url` | `""` | Convex site origin for status POSTs (self-hosted mode) | | `server.self_hosted.mqtt_broker` | `""` | Your MQTT broker host | | `server.self_hosted.mqtt_port` | `8883` | MQTT port (TLS) | | `server.self_hosted.api_key` | `""` | Optional broker credential | | `server.mqtt_transport` | `websockets` | `tcp` or `websockets` | | `server.mqtt_username` | `ados` | MQTT username | | `pairing.convex_url` | managed site origin | Convex site origin the heartbeat POSTs to | There is no `cloud:` top-level section. Keys like `convex_url` or `mqtt_ws_url` do not exist; use the schema above. ## Cloudflare Tunnel setup If you want to expose your self-hosted services without port forwarding: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -L https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare.gpg echo "deb [signed-by=/usr/share/keyrings/cloudflare.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/cloudflared.list sudo apt update && sudo apt install cloudflared ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} cloudflared tunnel login cloudflared tunnel create ados-relay ``` Create `~/.cloudflared/config.yml`: ```yaml theme={"theme":{"light":"github-light","dark":"github-dark"}} tunnel: YOUR_TUNNEL_ID credentials-file: /root/.cloudflared/YOUR_TUNNEL_ID.json ingress: - hostname: convex.yourdomain.com service: http://localhost:3210 - hostname: mqtt.yourdomain.com service: http://localhost:9001 - service: http_status:404 ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} cloudflared tunnel run ados-relay ``` ## Bandwidth and cost | Component | Bandwidth | Monthly cost | | ------------------------ | ---------------------------- | -------------------- | | Convex (self-hosted) | Under 1 GB/month | Free (your hardware) | | Convex (cloud free tier) | Under 1 GB/month | Free | | MQTT telemetry | \~100-500 MB/month per drone | Free (self-hosted) | | Cloudflare Tunnel | Unlimited | Free tier | | STUN (Google/Cloudflare) | Negligible | Free | | Video (P2P WebRTC) | 0 server cost (peer-to-peer) | Free | The entire cloud relay stack can run at zero monthly cost for small deployments. ## What is next * [Video Stack](/architecture/video-stack) for the full video pipeline details * [System Overview](/architecture/system-overview) for the three-tier architecture * [Project Structure](/architecture/project-structure) for where cloud code lives # Contributing Guide Source: https://docs.altnautica.com/architecture/contributing-guide How to set up the dev environment, add features, and submit pull requests. # Contributing Guide ADOS Mission Control and ADOS Drone Agent are open-source under GPLv3. Contributions are welcome. This guide covers the dev setup for both repos, the patterns for adding common feature types, and the pull request process. ## Dev environment setup ### Mission Control (GCS) ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} git clone https://github.com/altnautica/ADOSMissionControl.git cd ADOSMissionControl ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} npm install ``` Requires Node.js 20+ and npm 10+. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} npm run dev ``` Opens at `http://localhost:4000`. Turbopack provides fast hot module replacement. Open the app in your browser. The welcome modal offers a "Try Demo" button that starts 7 simulated drones. No hardware needed. ### SITL testing To test with a real ArduPilot simulator: Follow the [ArduPilot build docs](https://ardupilot.org/dev/docs/building-setup-linux.html). The SITL tool expects the ArduPilot repo at `~/.ardupilot`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} cd tools/sitl npm install npm start ``` This starts ArduPilot SITL with full physics simulation and a TCP-to-WebSocket bridge. Mission Control connects to `ws://localhost:5760`. In Mission Control, click Connect > WebSocket and enter `ws://localhost:5760`. You now have a real autopilot with simulated GPS, IMU, and battery. The agent is a Rust and Python hybrid. The long-running services are Rust binaries in `crates/`; Python carries AI and vision, the plugin runtime, setup, HAL detection, and the residual web API. A full dev setup needs both a Rust toolchain and Python. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} git clone https://github.com/altnautica/ADOSDroneAgent.git cd ADOSDroneAgent ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} python3 -m venv venv source venv/bin/activate pip install -e ".[dev]" ``` Requires Python 3.11+. (`uv` works too if you prefer it.) ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} cd crates cargo build cargo test ``` Requires a recent stable Rust toolchain. The Cargo workspace lives in `crates/`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} ados --help ados status ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} ados ``` The read-only terminal page shows setup URLs and agent status over SSH. ## Adding a configure panel (Mission Control) Configure panels are the most common contribution. Each panel lets the user adjust a group of flight controller parameters. Add a new file in `src/components/configure/`: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} // src/components/configure/MyNewPanel.tsx import { usePanelParams } from "@/hooks/use-panel-params" const PARAMS = [ "MY_PARAM_1", "MY_PARAM_2", "MY_PARAM_3", ] export function MyNewPanel() { const { values, setParam, isLoading } = usePanelParams(PARAMS) if (isLoading) return return (

My New Panel

setParam("MY_PARAM_1", v)} /> {/* ... more inputs */}
) } ```
Add the panel to the navigation in `src/components/configure/DroneConfigureTab.tsx`: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} { id: "my-new-panel", label: "My New Panel", icon: MyIcon, component: lazy(() => import("./MyNewPanel")), requiredCapability: "supportsParams", } ``` Launch SITL, connect, and verify the panel loads, reads parameters, and writes them back.
The `usePanelParams` hook handles all protocol details. It works with MAVLink native parameters (ArduPilot, PX4) and MSP virtual parameters (Betaflight) without any changes to your panel code. ## Adding a MAVLink decoder (Mission Control) When you need to handle a new MAVLink message type: In `src/lib/protocol/mavlink-crc-extra.ts`, add the message ID, CRC\_EXTRA, and payload length: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} export const MSG_MY_NEW_MSG = 999 export const CRC_EXTRA: Record = { // ... existing entries [MSG_MY_NEW_MSG]: 0xAB, // from MAVLink XML definition } export const PAYLOAD_LENGTHS: Record = { // ... existing entries [MSG_MY_NEW_MSG]: 24, } ``` In `src/lib/protocol/mavlink-adapter.ts`, add a case to `handleMessage()`: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} case MSG_MY_NEW_MSG: { const field1 = view.getFloat32(0, true) const field2 = view.getUint16(4, true) this._onMyNewMsg.forEach((cb) => cb({ field1, field2 })) break } ``` In `src/lib/protocol/drone-protocol.ts`: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} onMyNewMsg(cb: (msg: MyNewMsg) => void): Unsubscribe ``` In `src/stores/drone-manager.ts` inside `bridgeTelemetry()`: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} protocol.onMyNewMsg?.((msg) => { myStore.update(msg) }) ``` ## Adding a Zustand store (Mission Control) ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} // src/stores/my-new-store.ts import { create } from "zustand" interface MyNewState { value: number setValue: (v: number) => void } export const useMyNewStore = create((set) => ({ value: 0, setValue: (v) => set({ value: v }), })) ``` ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} const value = useMyNewStore((s) => s.value) ``` Always select specific fields. Never destructure the entire store. If the store needs to persist across page reloads, use the `persist` middleware with a version number: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} import { persist } from "zustand/middleware" export const useMyNewStore = create()( persist( (set) => ({ value: 0, setValue: (v) => set({ value: v }), }), { name: "my-new-store", version: 1, } ) ) ``` ## Adding a board profile (Drone Agent) ```yaml theme={"theme":{"light":"github-light","dark":"github-dark"}} # src/ados/hal/boards/my-board.yaml name: "My Board Name" vendor: "Board Manufacturer" soc: "RK3566" arch: "aarch64" model_patterns: - "My Board Model String" default_tier: 3 uart_paths: - /dev/ttyS0 gpio_pins: [5, 6, 13, 19] hw_video_codecs: - h264_enc - h264_dec video: csi_ports: 1 max_encode_resolution: "1920x1080" encoder_api: rkmpp buses: i2c: - id: i2c3 ``` `model_patterns` are matched against `/proc/device-tree/model` and `/proc/cpuinfo` for auto-detection. On the target board, run: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} ados status --json ``` This prints setup, service, board, and runtime status for inspection. ## Adding an agent service (Drone Agent) New long-running or safety-critical services are written as Rust crates under `crates/` and run a binary from `/opt/ados/bin/`. An ancillary Python-backed service is still fine for ecosystem-bound work (AI, drivers, setup glue); the steps below show the Python case. Either way, the unit is registered in the supervisor catalog. Add a new file in `src/ados/services/my_service/`: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # src/ados/services/my_service/__main__.py import asyncio import structlog log = structlog.get_logger() async def main(): log.info("my_service.started") while True: # Service logic here await asyncio.sleep(1) if __name__ == "__main__": asyncio.run(main()) ``` Add `data/systemd/ados-my-service.service`: ```ini theme={"theme":{"light":"github-light","dark":"github-dark"}} [Unit] Description=ADOS My Service After=ados-supervisor.service PartOf=ados-supervisor.service [Service] Type=simple User=ados ExecStart=/opt/ados/venv/bin/python -m ados.services.my_service Restart=on-failure RestartSec=3 MemoryMax=64M CPUQuota=25% [Install] WantedBy=ados-supervisor.service ``` Add the unit to the `SERVICE_REGISTRY` in `crates/ados-supervisor/src/registry.rs` with its category (core, hardware, on-demand), its profile gate, and any ground-station role gate. The supervisor drives the unit through `systemctl`; it does not spawn the process itself. ## Pull request guidelines ### Before submitting * Run `tsc --noEmit` for Mission Control (zero errors required) * Run `python -m py_compile` on any modified Python files * Run `cargo fmt --check`, `cargo clippy`, and `cargo test` for any Rust crate changes * Check for em dashes in any user-facing strings (there should be none) * Test with demo mode or SITL for Mission Control changes * Bump the version in `src/ados/__init__.py` for agent changes ### PR format ```markdown theme={"theme":{"light":"github-light","dark":"github-dark"}} ## What Brief description of the change. ## Why What problem does this solve or what feature does it add. ## How to test Steps to verify the change works. ## Screenshots If the change affects the UI, include before/after screenshots. ``` ### Branch naming * `feature/short-description` for new features * `fix/short-description` for bug fixes * `docs/short-description` for documentation ### Review process 1. Open a PR against `main` 2. Automated checks run (TypeScript build, linting) 3. A maintainer reviews the code 4. Once approved, the maintainer merges For large features, open a draft PR early with a description of what you plan to build. This helps avoid wasted effort if the design needs changes. ## Code style **Mission Control:** Follow the existing patterns. Zustand for state, `usePanelParams` for configure panels, selectors for subscriptions. Tailwind for styling. No CSS modules. **Drone Agent (Python):** Use `structlog` for logging, `asyncio` for async code, Pydantic for config models. Type hints on all function signatures. `black` for formatting. **Drone Agent (Rust):** Format with `cargo fmt`, lint with `cargo clippy`, and keep the IPC wire contracts defined in the `ados-protocol` crate as the single source of truth. ## Getting help * [Discord](https://discord.gg/uxbvuD4d5q) for questions, architecture discussion, and PR review * [GitHub Issues](https://github.com/altnautica/ADOSMissionControl/issues) for bug reports and feature requests with a clear use case # MAVLink Protocol Source: https://docs.altnautica.com/architecture/mavlink-protocol How ADOS Mission Control speaks MAVLink v2 and MSP, with the adapter pattern that supports multiple firmware. # MAVLink Protocol Layer ADOS Mission Control talks to flight controllers using the MAVLink v2 protocol (for ArduPilot and PX4) and the MSP protocol (for Betaflight). A `DroneProtocol` TypeScript interface abstracts the differences so the rest of the app does not care which firmware is on the other end. ## MAVLink v2 basics MAVLink v2 is a binary protocol. Each message has a fixed structure: ``` | STX (0xFD) | LEN | INC | CMP | SEQ | SYS | COMP | MSG_ID (3 bytes) | PAYLOAD | CRC-16 | ``` * **STX** is always `0xFD` for v2 (v1 uses `0xFE`) * **LEN** is the payload length in bytes * **MSG\_ID** is 3 bytes (v2 supports up to 16 million message types, v1 only 256) * **CRC-16** uses the X.25 algorithm with a per-message **CRC\_EXTRA** byte ### CRC\_EXTRA Every MAVLink message definition has a CRC\_EXTRA constant that acts as a schema version check. The transmitter and receiver must agree on the message layout. If a message was redefined (fields added or reordered), the CRC\_EXTRA changes and the receiver rejects the packet. ADOS Mission Control ships CRC\_EXTRA values for the decoded message types. These are defined in `src/lib/protocol/mavlink-crc-extra.ts` alongside the expected payload lengths. ## Parser architecture The MAVLink parser is a streaming state machine in `src/lib/protocol/mavlink-parser.ts`. It processes raw bytes from the WebSocket or WebSerial connection: ``` Bytes in -> Find STX -> Read header -> Read payload -> Verify CRC -> Emit message ``` The parser handles: * **Interleaved v1 and v2 packets** on the same stream * **Partial reads** (bytes arrive in arbitrary chunks over WebSocket) * **Zero-copy parsing** for performance-critical paths (attitude, GPS, battery messages arrive at 10-50 Hz) ## The DroneProtocol interface `DroneProtocol` is the TypeScript interface that every protocol adapter must implement. It defines about 50 methods covering connection lifecycle, parameter management, telemetry callbacks, and command execution. Key method groups: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} interface DroneProtocol { // Connection connect(transport: Transport): Promise disconnect(): void // Parameters getParameter(name: string): Promise setParameter(name: string, value: number): Promise commitParamsToFlash(): void // Commands sendCommand(cmd: number, params: number[]): Promise arm(): Promise disarm(): Promise setMode(mode: number): Promise takeoff(altitude: number): Promise // Telemetry callbacks onAttitude(cb: (msg: AttitudeMsg) => void): Unsubscribe onGps(cb: (msg: GpsMsg) => void): Unsubscribe onBattery(cb: (msg: BatteryMsg) => void): Unsubscribe onHeartbeat(cb: (msg: HeartbeatMsg) => void): Unsubscribe // ... ~46 more callbacks // Capabilities capabilities: ProtocolCapabilities } ``` The `ProtocolCapabilities` type tells the UI which features are available: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} type ProtocolCapabilities = { supportsParams: boolean supportsParamExtended: boolean supportsMission: boolean supportsCalibration: boolean supportsVtol: boolean supportsFirmwareFlash: boolean supportsOsd: boolean // ... more } ``` Configure panels in the UI check capabilities before rendering. A Betaflight-connected drone does not see the MAVLink-only failsafe panel. ## MAVLink adapter `MAVLinkAdapter` implements `DroneProtocol` for ArduPilot and PX4. It contains: * **83 message decoders** in the `handleMessage()` switch statement, each pulling typed fields from the binary payload * **33 MAV\_CMD handlers** for arm, disarm, takeoff, land, set mode, calibrate, RTL, waypoint commands, VTOL transition, ROI targeting, and more * **Parameter protocol** with `PARAM_REQUEST_LIST`, `PARAM_SET`, and `PARAM_VALUE` message handling. ArduPilot auto-saves parameters to EEPROM on `PARAM_SET`, so `commitParamsToFlash()` is fire-and-forget * **Mission protocol** with `MISSION_REQUEST_LIST`, `MISSION_ITEM_INT`, `MISSION_COUNT`, and `MISSION_ACK` for uploading waypoints ### Firmware-specific behavior ArduPilot and PX4 share the MAVLink protocol but differ in parameter names, mode numbers, and some command semantics. The adapter handles this through a `firmware` field set during heartbeat detection: * **ArduPilot:** 200+ mapped parameters, 18 flight modes, 9 calibration types * **PX4:** 63+ mapped parameters, 18 flight modes, 3 PX4-specific panels (Airframe, Actuator, MavlinkShell) ## MSP adapter (Betaflight) `MSPAdapter` implements `DroneProtocol` for Betaflight and iNav. MSP (MultiWii Serial Protocol) is fundamentally different from MAVLink: * Binary with MSPv1 and MSPv2 framing * CRC-8 DVB-S2 for v2 (XOR checksum for v1) * One message in flight at a time (serial queue) * No parameter names, only numeric configuration blocks The MSP implementation includes: * **34 message decoders** for status, attitude, GPS, battery, motor output, PID, rates, OSD, VTX, and more * **21 message encoders** for setting PIDs, rates, OSD layout, VTX config, and serial ports * **\~105 virtual parameters** that map MSP configuration blocks to the `usePanelParams` hook interface, so existing configure panels work with Betaflight without code changes * **19-state streaming parser** handling MSPv1, MSPv2, and jumbo frames ### Virtual parameters Betaflight does not have a parameter-value store like ArduPilot. Instead, it has binary configuration blocks (PID profile, rate profile, mixer config, etc.). The MSP adapter maps these blocks to virtual parameter names like `BF_PID_ROLL_P`, `BF_RATE_RC_EXPO`, and `BF_OSD_ITEM_0_POS`. This lets the `usePanelParams` hook (which powers every configure panel) work identically with MAVLink and MSP connections. Panel code never touches protocol details. ## Encoder architecture MAVLink message encoding is split across eight modules under `src/lib/protocol/encoders/`: | Module | Purpose | | ---------------- | --------------------------------------------------------------------------- | | `core.ts` | Heartbeat, system status, statustext | | `params.ts` | PARAM\_SET, PARAM\_REQUEST\_LIST, PARAM\_REQUEST\_READ | | `control.ts` | MANUAL\_CONTROL (50 Hz stick input), SET\_MODE | | `mission.ts` | MISSION\_COUNT, MISSION\_ITEM\_INT, MISSION\_REQUEST\_LIST, MISSION\_ACK | | `frame.ts` | MAVLink v2 frame builder (header + CRC-16 with CRC\_EXTRA) | | `peripheral.ts` | MAV\_CMD\_PREFLIGHT\_CALIBRATION, MAV\_CMD\_DO\_SET\_SERVO, gimbal commands | | `ekf-source.ts` | EKF source selection for navigation | | `can-forward.ts` | CAN bus frame forwarding | The `index.ts` barrel re-exports every module, and `mavlink-encoder.ts` re-exports them for convenience. ## Transport layer The protocol adapters do not care how bytes arrive. A `Transport` abstraction handles the connection: | Transport | Use case | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | WebSocket | Remote connection to drone agent or ground station. Default for LAN and cloud. The agent serves the MAVLink WebSocket on `:8765` with HMAC-ticket auth | | WebSerial | Direct USB connection to a flight controller from the browser. Used for configuration and firmware flashing | | SITL TCP-to-WS bridge | ArduPilot SITL simulator for development. The bundled `tools/sitl/` bridges TCP to WebSocket | | Mock | Demo mode. Generates synthetic telemetry from 7 simulated drones | ## The agent-side router On the aircraft, MAVLink does not reach the browser directly. The drone agent runs a native Rust router (`ados-mavlink-router`, the `ados-mavlink` unit) that reads the flight controller's serial link, fans frames out on the `/run/ados/mavlink.sock` IPC socket for other agent services, and exposes the stream to Mission Control over a WebSocket on `:8765`. That WebSocket is gated by a short-lived HMAC ticket, so a client proves it is authorized before the router bridges it to the flight controller. The framing, message decoding, and adapter logic described below run in the browser on whatever stream the transport delivers. ## Message flow example A typical telemetry flow from flight controller to browser: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} %%{init: {'theme': 'dark'}}%% sequenceDiagram participant FC as Flight Controller participant Agent as Drone Agent participant GS as Ground Agent participant Browser as Mission Control FC->>Agent: UART MAVLink ATTITUDE (msg 30) Agent->>Agent: Parse, write to /run/ados/mavlink.sock Agent->>GS: WFB-ng (binary MAVLink) GS->>Browser: WebSocket (binary MAVLink) Browser->>Browser: MAVLink parser extracts fields Browser->>Browser: MAVLinkAdapter.handleMessage() Browser->>Browser: onAttitude callback fires Browser->>Browser: telemetryStore updates Browser->>Browser: React re-renders HUD ``` ## Adding a new decoder To add support for a new MAVLink message: 1. Add the message ID, CRC\_EXTRA, and payload length to `mavlink-crc-extra.ts` 2. Add a `case` in `MAVLinkAdapter.handleMessage()` that decodes the payload bytes 3. Add a callback method to `DroneProtocol` (e.g., `onNewMessage`) 4. Subscribe to the callback in `DroneManager.bridgeTelemetry()` 5. Update the relevant Zustand store with the decoded data See [Contributing Guide](/architecture/contributing-guide) for a detailed walkthrough. ## What is next * [State Management](/architecture/state-management) for how telemetry reaches the UI * [Agent Services](/architecture/agent-services) for the server-side MAVLink proxy * [Video Stack](/architecture/video-stack) for the video pipeline # Project Structure Source: https://docs.altnautica.com/architecture/project-structure Directory maps and key files for both ADOS repositories. # Project Structure ADOS lives in two repositories. Both are open-source under GPLv3. ## ADOS Mission Control (GCS) **Repository:** [github.com/altnautica/ADOSMissionControl](https://github.com/altnautica/ADOSMissionControl) **Stack:** Next.js 16, React 19, TypeScript, Zustand, Tailwind CSS, Leaflet, CesiumJS ``` ADOSMissionControl/ ├── src/ │ ├── app/ # Next.js App Router pages │ │ ├── page.tsx # Dashboard (home) │ │ ├── command/ # Drone Command tab │ │ ├── plan/ # Mission Planning tab │ │ ├── simulate/ # Simulation tab (Cesium 3D preview/replay) │ │ ├── config/ # App configuration │ │ ├── pair/ # Add-a-node local pairing flow │ │ ├── flight-logs/ # Flight log review │ │ ├── hud/ # Standalone HUD (kiosk mode) │ │ ├── community/ # Changelog, roadmap, kanban │ │ └── api/ # Route handlers (auth, LAN-pairing proxy) │ │ │ ├── components/ # React components │ │ ├── command/ # Command tab components │ │ ├── fc/ # Flight controller config panels (60+ panels) │ │ ├── drone-detail/ # Per-drone detail tabs (overview, parameters, configure, ...) │ │ ├── planner/ # Mission planning components │ │ ├── simulation/ # Simulation components │ │ ├── hardware/ # Ground station Hardware tab │ │ ├── map/ # Leaflet map layers │ │ ├── hud/ # HUD overlay widgets │ │ ├── plugins/ # Plugin host and UI slots │ │ ├── vision/ # Vision engine UI │ │ ├── fleet/ # Fleet sidebar and drone cards │ │ ├── dashboard/ # Dashboard panels and drone-detail tab descriptors │ │ ├── shared/ # Shared components │ │ └── ui/ # Shared UI primitives │ │ │ ├── stores/ # Zustand state stores (more than 70 files) │ │ ├── telemetry-store.ts │ │ ├── drone-manager.ts │ │ ├── video-store.ts │ │ ├── ground-station-store.ts │ │ ├── settings-store.ts │ │ ├── parameter-store.ts │ │ └── ... │ │ │ ├── lib/ # Core libraries │ │ ├── protocol/ # MAVLink + MSP protocol layer │ │ │ ├── mavlink-parser.ts # Streaming v1/v2 parser │ │ │ ├── mavlink-adapter.ts # MAVLinkAdapter (83 decoders) │ │ │ ├── msp-adapter.ts # MSPAdapter (34 decoders, 21 encoders) │ │ │ ├── drone-protocol.ts # DroneProtocol interface │ │ │ ├── mavlink-constants.ts # Sensor-status maps and parsing │ │ │ ├── mavlink-crc-extra.ts # CRC_EXTRA, payload lengths │ │ │ └── encoders/ # 8 encoder modules │ │ ├── video/ │ │ │ ├── webrtc-client.ts # WHEP + P2P MQTT WebRTC │ │ │ └── mse-player.ts # MediaSource fallback │ │ ├── agent/ # Local-first pairing client + capability inference │ │ ├── plugins/ # GCS plugin capabilities and slot registry │ │ └── utils/ │ │ │ ├── hooks/ # Custom React hooks │ │ ├── use-panel-params.ts # Parameter read/write for panels │ │ ├── use-visible-tabs.ts # Capability-driven tab visibility │ │ └── use-ground-station-subscriptions.ts │ │ │ ├── mock/ # Demo-mode mock engine + 7 simulated drones │ │ │ └── ... (locales live at the repo root) │ ├── locales/ # i18n translations (16 languages) │ ├── en.json │ ├── hi.json │ ├── ja.json │ └── ... │ ├── convex/ # Convex backend (OSS standalone) │ ├── schema.ts # auth tables plus custom application tables │ ├── profiles.ts │ ├── communityChangelog.ts │ ├── cmdDroneStatus.ts # Cloud relay drone status │ ├── cmdDroneCommands.ts # Cloud relay command queue │ └── ... │ ├── tools/ │ ├── sitl/ # ArduPilot SITL launcher + TCP-to-WS bridge │ ├── mqtt-bridge/ # Mosquitto + MQTT-to-Convex bridge (Docker) │ └── video-relay/ # RTSP-to-fMP4 relay (Docker) │ ├── public/ # Static assets ├── electron/ # Electron wrapper for desktop builds ├── package.json ├── next.config.ts ├── tailwind.config.ts └── tsconfig.json ``` ### Key files to know | File | What it does | | ------------------------------------- | --------------------------------------------------------------------------- | | `src/lib/protocol/drone-protocol.ts` | The interface every protocol adapter implements | | `src/lib/protocol/mavlink-adapter.ts` | MAVLink v2 adapter with 83 decoders and 33 command handlers | | `src/lib/protocol/msp-adapter.ts` | MSP adapter for Betaflight with 34 decoders and 105 virtual params | | `src/stores/drone-manager.ts` | Central coordinator between protocol and stores | | `src/stores/telemetry-store.ts` | Ring-buffered telemetry with attitude, GPS, battery history | | `src/lib/video/webrtc-client.ts` | WHEP and P2P MQTT WebRTC client | | `src/hooks/use-panel-params.ts` | Universal parameter hook used by every flight controller config panel (60+) | | `src/stores/settings-store.ts` | Persisted user settings | | `convex/schema.ts` | Convex database schema for the OSS standalone backend | ## ADOS Drone Agent **Repository:** [github.com/altnautica/ADOSDroneAgent](https://github.com/altnautica/ADOSDroneAgent) **Stack:** Rust (Cargo workspace, tokio, axum) plus Python 3.11+ (FastAPI, structlog), systemd-managed. The long-running and safety-critical services are Rust binaries in `crates/`; Python handles AI and vision, the plugin runtime, setup, HAL detection, and the residual web API. ``` ADOSDroneAgent/ ├── crates/ # Rust services and shared libraries (Cargo workspace lives here) │ ├── ados-supervisor/ # Process orchestrator: gates and supervises the systemd units │ ├── ados-mavlink-router/ # FC serial link, MAVLink IPC fan-out, vehicle-state snapshot │ ├── ados-control/ # Native HTTP front on :8080 (status/pairing/command API) │ ├── ados-cloud/ # Cloud relay: heartbeat push, command poll, MQTT + WebRTC signaling │ ├── ados-video/ # Video pipeline: ffmpeg / MediaMTX / wfb_tee supervisor │ ├── ados-radio/ # WFB-ng TX manager: monitor mode, FHSS hop, watchdogs (drone) │ ├── ados-groundlink/ # WFB receive, channel acquisition, video fan-out, mesh (ground) │ ├── ados-net/ # Ground-station uplink matrix: priority failover + health probe │ ├── ados-display/ # OLED / SPI-LCD render engine, drivers, boot-time probe │ ├── ados-hid/ # Touch calibration, PIC arbiter, button + gamepad daemons │ ├── ados-gpio/ # GPIO output substrate (buzzer / LED, software PWM) │ ├── ados-vision/ # Vision host: frame rings, model registry, plugin bridge │ ├── ados-plugin-host/ # Plugin RPC host: per-plugin socket + capability-token gating │ ├── ados-logd/ # Durable SQLite log + telemetry store, queryable on :8090 │ ├── ados-macpin/ # Stable-MAC pinning for adapters that randomize each boot │ ├── ados-hal-probe/ # Probe-first hardware-capability detection │ ├── ados-protocol/ # IPC wire contracts: framing, plugin RPC, vehicle-state codec │ ├── ados-capabilities-codegen/ # Generates the capability catalog (Python/Rust/TS) from capabilities.toml │ ├── ados-sdk/ # Rust plugin-author SDK (IPC client, driver traits, runner) │ ├── ados-installer/ # Step-graph installer that turns a fresh SBC into a paired agent │ └── ados-tui/ # Terminal dashboard launched by `ados` with no subcommand │ ├── src/ │ └── ados/ # Python: AI/vision, plugin runtime, setup, HAL, residual API │ ├── __init__.py # Version (single source of truth) │ ├── core/ # IPC helpers, config models, identity, pairing, health probe │ ├── services/ # Python-backed units and driver/manager glue │ │ ├── api/ # Residual FastAPI (internal socket, behind ados-control) │ │ ├── health/ # CPU / RAM / temperature monitor │ │ ├── peripherals/ # USB and sensor manager │ │ ├── discovery/ # mDNS advertisement │ │ ├── ground_station/ # Ethernet, WiFi-client, modem managers │ │ ├── setup_webapp/ # First-boot setup and captive portal │ │ └── ... │ ├── api/ │ │ ├── runtime.py # API runtime facade │ │ └── routes/ # FastAPI route modules (config, pairing, plugins, ...) │ ├── hal/ │ │ └── boards/ # Board profile YAMLs (17 profiles) │ ├── plugins/ # Plugin manifest, signing, IPC (Python plugin runtime) │ ├── sdk/ # Python plugin-author SDK (driver, vision, testing) │ ├── setup/ # Universal setup facade and models │ ├── cli/ # Click CLI (ados, status, update, ...) │ └── bootstrap/ │ └── profile_detect.py # Hardware fingerprint scoring │ ├── data/ │ └── systemd/ # All systemd unit files (~39 units) │ ├── ados-supervisor.service │ ├── ados-mavlink.service │ ├── ados-control.service │ ├── ados-api.service │ ├── ados-cloud.service │ ├── ados-logd.service │ ├── ados-video.service │ ├── ados-wfb.service │ ├── ados-hostapd.service │ ├── ados-oled.service │ └── ... │ ├── scripts/ │ ├── install.sh # One-line installer bootstrap (fetches the Rust installer) │ ├── drivers/ # Out-of-tree driver build helpers │ └── ... │ ├── docs/ │ ├── oem/ # Integrator deployment and provisioning notes │ └── ground-station/ # Ground station reference │ ├── pyproject.toml ├── README.md └── CONTRIBUTING.md ``` ### Key files to know | File | What it does | | ---------------------------------------- | -------------------------------------------------------------------------------- | | `src/ados/__init__.py` | Version string used by package metadata, API status, and setup status | | `crates/ados-supervisor/src/registry.rs` | The service catalog: profile gates, role gates, and the circuit breaker | | `crates/ados-mavlink-router/` | Reads FC serial, fans MAVLink to `/run/ados/mavlink.sock`, serves the `:8765` WS | | `crates/ados-control/` | The native HTTP front on `:8080` | | `crates/ados-video/` | Camera encode and MediaMTX supervision (drone) | | `src/ados/api/runtime.py` | Residual FastAPI runtime facade, reached behind the Rust front | | `src/ados/bootstrap/profile_detect.py` | Score-based hardware fingerprint for air vs ground | | `src/ados/hal/boards/*.yaml` | Board-specific GPIO, UART, video, and navigation config | | `scripts/install.sh` | The one-line installer bootstrap | ## Config files on a deployed system | Path | Purpose | | ----------------------------- | --------------------------------------------------------- | | `/etc/ados/config.yaml` | Main agent configuration (profile, cloud, network, video) | | `/etc/ados/profile.conf` | Detected profile with fingerprint snapshot | | `/opt/ados/bin/` | Installed Rust service binaries | | `/opt/ados/venv/` | Installed Python virtual environment | | `/run/ados/mavlink.sock` | Runtime MAVLink IPC socket | | `/run/ados/state.sock` | Runtime JSON telemetry socket | | `/run/ados/api-internal.sock` | Internal FastAPI socket behind the Rust front | | `/var/ados/logd/logs.db` | Durable log and telemetry store (`ados logs query`) | ## What is next * [Contributing Guide](/architecture/contributing-guide) for how to add features to both repos * [Agent Services](/architecture/agent-services) for the systemd architecture * [MAVLink Protocol](/architecture/mavlink-protocol) for the protocol layer # State Management Source: https://docs.altnautica.com/architecture/state-management Zustand stores, ring buffers, and how telemetry flows from protocol to pixel. # State Management ADOS Mission Control uses Zustand for all client-side state. There are more than 70 stores covering telemetry, drone management, mission planning, video, settings, and more. Each store is a small, focused slice of state with its own actions and selectors. ## Why Zustand Zustand is a minimal state manager for React. It has no boilerplate, no context providers, and no reducers. A store is a plain function that returns state and actions. Components subscribe to specific fields and only re-render when those fields change. This matters for a GCS because telemetry arrives at 10-50 Hz. A full React context re-render at 50 Hz would freeze the browser. Zustand's fine-grained subscriptions keep the frame rate stable even under heavy telemetry load. ## Store categories The stores group into six broad categories (representative examples shown, not the full list): | Category | Examples | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | Telemetry | `telemetry-store`, `battery-store`, `gps-store`, `attitude-store`, `sensor-store`, `rc-channels-store`, `servo-output-store`, `esc-store` | | Drone management | `drone-manager`, `protocol-store`, `connection-store`, `demo-store`, `fleet-store` | | Mission planning | `planner-store`, `drawing-store`, `pattern-store`, `geofence-store`, `rally-store`, `plan-library-store`, `simulation-history-store` | | Configuration | `parameter-store`, `calibration-store`, `firmware-store`, `osd-store`, `failsafe-store`, `ports-store` | | Video and comms | `video-store`, `ground-station-store`, `cloud-status-store`, `mqtt-store` | | UI and settings | `settings-store`, `ui-store`, `panel-cache-store`, `changelog-notification-store`, and more | ## Ring buffers for telemetry High-frequency telemetry stores use ring buffers instead of growing arrays. A ring buffer holds a fixed number of samples (typically 300) and overwrites the oldest when full. This keeps memory bounded regardless of flight duration. ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} // Simplified ring buffer pattern used in telemetry stores interface RingBuffer { data: T[] head: number capacity: number } function push(buffer: RingBuffer, item: T) { buffer.data[buffer.head] = item buffer.head = (buffer.head + 1) % buffer.capacity } ``` Stores that use ring buffers: | Store | Buffer size | Update rate | Purpose | | ------------------- | ----------- | ----------- | -------------------------------------------- | | `telemetry-store` | 300 | 10 Hz | Attitude, altitude, speed history for charts | | `battery-store` | 300 | 2 Hz | Voltage and current history | | `gps-store` | 300 | 5 Hz | Position history for trail rendering | | `rc-channels-store` | 60 | 10 Hz | RC input history for the stick visualizer | ## The drone manager bridge `DroneManager` is the central coordinator between protocol adapters and stores. When a connection is established, `bridgeTelemetry()` subscribes to all protocol callbacks and routes the data to the correct stores: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} // Simplified from drone-manager.ts bridgeTelemetry() function bridgeTelemetry(protocol: DroneProtocol) { protocol.onHeartbeat((msg) => { connectionStore.setHeartbeat(msg) droneManagerStore.setMode(msg.customMode) }) protocol.onAttitude((msg) => { telemetryStore.pushAttitude(msg) attitudeStore.set(msg) }) protocol.onGps((msg) => { gpsStore.set(msg) telemetryStore.pushPosition(msg) }) protocol.onBattery((msg) => { batteryStore.set(msg) telemetryStore.pushBattery(msg) }) // ... 26 core callbacks + 15 optional capability-gated callbacks } ``` The bridge subscribes to 26 core callbacks that every protocol supports, plus up to 15 optional callbacks gated by `protocol.capabilities`. For example, `onGimbalManagerStatus` only subscribes if `capabilities.supportsGimbalV2` is true. Each subscription returns an unsubscribe function. When the connection closes, all subscriptions are cleaned up. ## Parameter store The parameter store holds the flight controller's full parameter set (1,000+ parameters for ArduPilot). It supports: * **Batch loading** via `PARAM_REQUEST_LIST` (receives all parameters over 5-15 seconds) * **Individual reads** via `PARAM_REQUEST_READ` * **Writes** via `PARAM_SET` with optimistic UI update and rollback on failure * **Search and filtering** by parameter name, group, or description The `usePanelParams` hook is the primary interface for configure panels: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} function usePanelParams(paramNames: string[]) { // Returns { values, setParam, isLoading, isDirty } // Automatically subscribes to the parameter store // Handles both MAVLink native params and MSP virtual params } ``` Every configure panel (failsafe, PID tuning, power, OSD, ports, etc.) uses this hook. Panel code never touches protocol details directly. ## Demo mode and the mock engine Demo mode runs seven simulated drones with realistic telemetry, including two iNav vehicles (a quad and a fixed-wing). The mock engine generates synthetic MAVLink messages: * Attitude oscillates with configurable rates * GPS follows circular or waypoint paths * Battery drains over time * Mode transitions happen on a timer The mock engine implements the `DroneProtocol` interface, so the rest of the app cannot tell the difference between a real drone and a simulated one. This is useful for development, demos, and testing UI without hardware. Demo mode activates from the welcome modal or the settings page. It runs entirely in the browser with no backend. ## Connection lifecycle ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} %%{init: {'theme': 'dark'}}%% stateDiagram-v2 [*] --> Disconnected Disconnected --> Connecting: User clicks Connect Connecting --> Detecting: Transport open Detecting --> Connected: Heartbeat received Detecting --> Disconnected: Timeout (10s) Connected --> Disconnected: Transport closed Connected --> Reconnecting: Heartbeat lost (5s) Reconnecting --> Connected: Heartbeat restored Reconnecting --> Disconnected: Timeout (30s) ``` The `connection-store` tracks this state machine. UI components subscribe to the connection state to show appropriate indicators (green dot, yellow reconnecting, red disconnected). ## Persist and hydration Some stores persist across reloads via Zustand's `persist` middleware, backed by IndexedDB or browser local storage depending on the store: | Store | What persists | Version | | -------------------------- | ---------------------------------------------------------------- | ------- | | `settings-store` | Video transport mode, theme, units, language, recent connections | 35 | | `plan-library-store` | Saved mission plans | 1 | | `simulation-history-store` | Past simulation results | 1 | | `local-nodes-store` | LAN-paired nodes and their API keys | 4 | Each persisted store has a version number. When the schema changes, a migration function converts the old format to the new one. The version is bumped in the same commit that changes the schema. Stores that hold ephemeral telemetry (attitude, GPS, battery) never persist. They reset to defaults on page load. ## Selectors and performance Components use Zustand selectors to subscribe to specific fields: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} // Good: only re-renders when altitude changes const altitude = useTelemetryStore((s) => s.altitude) // Bad: re-renders on ANY store change const everything = useTelemetryStore() ``` For computed values that depend on multiple fields, `useShallow` prevents unnecessary re-renders: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} const { lat, lng, alt } = useGpsStore( useShallow((s) => ({ lat: s.lat, lng: s.lng, alt: s.alt })) ) ``` ## Notifications The `notifications-store` handles transient alerts (arm/disarm, mode changes, failsafe triggers, parameter save confirmations). Notifications auto-dismiss after 5 seconds. Critical notifications (failsafe, low battery) stay until acknowledged. The store caps at 50 notifications and drops the oldest when full. ## What is next * [MAVLink Protocol](/architecture/mavlink-protocol) for the protocol adapter layer * [Agent Services](/architecture/agent-services) for the server-side state * [Project Structure](/architecture/project-structure) for where stores live in the codebase # System Overview Source: https://docs.altnautica.com/architecture/system-overview The three-tier ADOS architecture and how drone, ground station, and cloud fit together. # System Overview ADOS is a three-tier system: a drone agent on the aircraft, a ground agent on a nearby SBC, and optional cloud services for remote access. Each tier is independent. You can fly with just the first two and no internet at all. ## The three tiers ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} %%{init: {'theme': 'dark'}}%% flowchart TB subgraph Drone["Tier 1: Drone"] FC["Flight Controller
(ArduPilot / PX4 / Betaflight)"] Agent["ADOS Drone Agent
(Rust + Python, systemd)"] Camera["Camera
(CSI or USB)"] WFB_TX["WFB-ng TX
(RTL8812EU, 5 GHz)"] ELRS["ELRS RX
(2.4 GHz control)"] Modem_Air["4G Modem
(optional)"] FC <-->|UART MAVLink| Agent Camera -->|V4L2| Agent Agent -->|ffmpeg H.264| WFB_TX Agent -->|MQTT| Modem_Air ELRS -->|CRSF| FC end subgraph Ground["Tier 2: Ground Station"] WFB_RX["WFB-ng RX
(RTL8812EU, 5 GHz)"] GS_Agent["ADOS Ground Agent
(same codebase, ground profile)"] MediaMTX["MediaMTX
(RTSP + WHEP)"] WiFi_AP["WiFi AP
(2.4 GHz)"] USB["USB Tether
(CDC-NCM)"] HDMI["HDMI Kiosk
(Chromium + cage)"] OLED["OLED + Buttons"] WFB_RX --> GS_Agent GS_Agent --> MediaMTX MediaMTX -->|WebRTC WHEP| WiFi_AP MediaMTX -->|WebRTC WHEP| USB MediaMTX -->|localhost| HDMI GS_Agent --> OLED end subgraph Cloud["Tier 3: Cloud (optional)"] Convex["Convex Backend
(HTTP API, reactive queries)"] MQTT["Mosquitto
(MQTT broker)"] Video_Relay["Video Relay
(RTSP-to-fMP4)"] end subgraph Clients["Clients"] Laptop["Laptop Browser"] Phone["Android App"] Monitor["HDMI Monitor"] Remote["Remote Observer"] end WFB_TX -->|5 GHz radio link| WFB_RX WiFi_AP --> Laptop WiFi_AP --> Phone HDMI --> Monitor USB --> Laptop GS_Agent -.->|WiFi/Ethernet/4G| MQTT GS_Agent -.->|HTTP POST| Convex Agent -.->|MQTT| MQTT Agent -.->|HTTP POST| Convex Convex --> Remote MQTT --> Remote ``` ## Deployment models ADOS supports three deployment models depending on what you need. ### Field mode (Tier 1 + Tier 2) The drone and ground station communicate directly over WFB-ng radio. No internet. No cloud. Latency is 50-100 ms glass-to-glass. This is the default for field operations. ### Cloud mode (Tier 1 + Tier 3) The drone has its own 4G modem and pushes telemetry and video to the cloud. A remote operator uses Mission Control at `command.altnautica.com` to monitor or control. Latency is 200-500 ms. No ground station needed, but you lose the low-latency WFB-ng path. ### Hybrid mode (Tier 1 + Tier 2 + Tier 3) The ground station receives WFB-ng for local low-latency flight, and simultaneously bridges telemetry to the cloud for remote observers. This is the best-of-both-worlds setup for commercial operations. ### Distributed Receive (multiple Tier 2 nodes) When one ground station cannot see the whole flight area (terrain, obstructions, long corridor), two or three Ground Agents can be deployed together. They form a small private mesh over batman-adv on a second USB WiFi dongle. One node takes the `receiver` role and serves as the hub; every other node is a `relay` that forwards WFB-ng fragments it heard. The receiver runs WFB-ng's native FEC combine across the merged stream and republishes the clean video on the same downstream pipeline a single-node setup uses. [Read the Mesh & Distributed Receive overview](/ground-agent/mesh-overview) for when to deploy mesh and how it works. ## Protocol stack Each connection between components uses a specific protocol: | Connection | Protocol | Format | Typical rate | | ----------------------------- | ------------------------------------------------ | ----------------------------- | -------------------- | | FC to Agent | UART MAVLink v2 | Binary, CRC-16 | 10-50 Hz per message | | Agent IPC (MAVLink) | Unix socket `/run/ados/mavlink.sock` | 4-byte length prefix + binary | All FC messages | | Agent IPC (state) | Unix socket `/run/ados/state.sock` | JSON | 10 Hz | | Agent to WFB-ng | Pipe to `wfb_tx` | Raw H.264 NAL units | 4-8 Mbps | | WFB-ng air to ground | 5 GHz monitor mode (IEEE 802.11) | FEC-encoded packets | 4-8 Mbps | | Ground to browser (video) | WebRTC WHEP | H.264 RTP | 4-8 Mbps | | Ground to browser (telemetry) | WebSocket | JSON MAVLink | 10-30 Kbps | | GCS to agent (control) | HTTP REST on `:8080` (Rust front `ados-control`) | JSON | On demand | | GCS to agent (MAVLink) | WebSocket on `:8765` (HMAC ticket) | Binary MAVLink | Streaming | | Agent log/telemetry query | HTTP on `:8090` + unix socket (`ados-logd`) | JSON | On demand | | Agent to cloud (status) | HTTPS POST to Convex | JSON | Every 5 s | | Agent to cloud (telemetry) | MQTT (TLS) | JSON | 2 Hz | | Agent to cloud (video) | WebRTC P2P via MQTT signaling | H.264 RTP | 4-8 Mbps | ## Two repos, one system The entire ADOS stack lives in two public repositories: | Repository | Language | Purpose | | --------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------- | | [altnautica/ADOSMissionControl](https://github.com/altnautica/ADOSMissionControl) | TypeScript (Next.js 16, React 19, Zustand 5) | Ground control station, web app | | [altnautica/ADOSDroneAgent](https://github.com/altnautica/ADOSDroneAgent) | Rust + Python hybrid | Drone agent and ground agent | Both are GPLv3. The agent runs on the drone and the ground station (same code, different profile). Mission Control runs in a browser and talks to both. The agent is Rust-first. The long-running and safety-critical services (MAVLink router, cloud relay, video pipeline, radio, supervisor, logging, the HTTP front) are native Rust binaries; Python stays for AI and vision, the plugin runtime, setup, HAL detection, and some ground-station hardware glue. See [Agent Services](/architecture/agent-services) for the full breakdown. ## Key architectural decisions **Multi-process over single-process.** The drone agent runs each service (MAVLink, video, cloud, health) as a separate systemd unit with its own cgroup resource limits. A crashed video encoder does not take down the MAVLink proxy. See [Agent Services](/architecture/agent-services). **Web over native.** Mission Control is a browser app, not a desktop application. WebSerial for FC communication, WebRTC for video, Web Gamepad API for flight controls. Electron wraps it for desktop distribution with full Chromium capabilities. See [State Management](/architecture/state-management). **WFB-ng over WiFi.** The radio link uses WFB-ng (WiFi Broadcast next generation), which puts the radio in monitor mode and broadcasts FEC-encoded packets. This is not standard WiFi. There is no association, no handshake, no retransmission. The result is consistent low latency at ranges up to 50 km. See [Video Stack](/architecture/video-stack). **Profile over fork.** The ground station is not a separate codebase. It is the same ADOS Drone Agent with a different profile selected at boot. This means one install script, one upgrade path, and one test matrix. See [Agent Services](/architecture/agent-services). ## Latency budget End-to-end latency from camera sensor to browser pixel: | Stage | Duration | | ------------------------- | ------------- | | V4L2 capture | 5-10 ms | | ffmpeg H.264 encode | 10-20 ms | | WFB-ng TX + air | 2-5 ms | | WFB-ng RX + reassembly | 2-5 ms | | MediaMTX RTSP ingest | 1-2 ms | | WebRTC WHEP to browser | 10-20 ms | | Browser decode and render | 10-15 ms | | **Total (LAN/USB)** | **40-77 ms** | | **Total (WiFi AP)** | **60-100 ms** | ## What is next * [MAVLink Protocol](/architecture/mavlink-protocol) for the protocol layer * [Agent Services](/architecture/agent-services) for the systemd architecture * [Video Stack](/architecture/video-stack) for the full video pipeline * [Cloud Infrastructure](/architecture/cloud-infrastructure) for the three relay layers # Video Stack Source: https://docs.altnautica.com/architecture/video-stack The full video pipeline from camera sensor to browser pixel, including WFB-ng, MediaMTX, and WebRTC. # Video Stack The ADOS video pipeline carries live HD video from the drone's camera to your browser with 40-100 ms latency, depending on the connection. It uses standard open-source tools at every stage: V4L2 for capture, ffmpeg for encoding, WFB-ng for radio transport, MediaMTX for local serving, and WebRTC for browser delivery. On the aircraft, the native Rust video service (`ados-video`, the `ados-video` unit) supervises the encoder and the local MediaMTX instance; the ground station receives the stream through `ados-mediamtx-gs`. ## Full pipeline ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} %%{init: {'theme': 'dark'}}%% sequenceDiagram participant Camera as Camera (CSI/USB) participant V4L2 as V4L2 Driver participant FFmpeg as ffmpeg Encoder participant MTX_Air as MediaMTX (Air) participant WFB_TX as wfb_tx (5 GHz) participant WFB_RX as wfb_rx (5 GHz) participant MTX_GS as MediaMTX (Ground) participant Browser as Browser (WebRTC) Camera->>V4L2: Raw frames V4L2->>FFmpeg: YUV420p via V4L2 device FFmpeg->>MTX_Air: H.264 RTSP push MTX_Air->>WFB_TX: RTSP read + raw NALs Note over WFB_TX,WFB_RX: 5 GHz monitor mode
FEC encoded packets
No WiFi association WFB_TX->>WFB_RX: Radio link (50 km range) WFB_RX->>MTX_GS: Decoded NALs MTX_GS->>Browser: WebRTC WHEP Note over Browser: Hardware H.264 decode
MediaSource or WebRTC ``` ## Stage 1: Capture The drone agent detects cameras at boot by scanning `/dev/video*` devices. It supports: * **MIPI CSI cameras** via V4L2 (Radxa Camera 4K, Arducam modules) * **USB UVC cameras** via V4L2 (any standard webcam) The video service configures resolution, framerate, and pixel format before starting the encoder. Default: 1080p at 30 fps, YUV420p. The encoder choice comes from the board's HAL profile. On boards with a Rockchip media engine (RK3588, RK3576), the agent can use `rkmpp` (Rockchip Media Process Platform) for zero-copy encode directly from the camera ISP. Boards that expose a V4L2 hardware encoder use it through ffmpeg. On boards without a usable hardware encoder, it falls back to `libx264`. ## Stage 2: Encode ffmpeg encodes the raw camera output to H.264: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} ffmpeg \ -f v4l2 -input_format yuyv422 -video_size 1920x1080 -framerate 30 \ -fflags nobuffer -flags low_delay \ -probesize 32 -analyzeduration 0 \ -i /dev/video0 \ -c:v libx264 -preset ultrafast -tune zerolatency \ -profile:v high -level 4.1 \ -b:v 6000k -maxrate 6000k -bufsize 3000k \ -g 30 -keyint_min 30 \ -f rtsp rtsp://localhost:8554/ados ``` Key flags for low latency: | Flag | Purpose | | ------------------------------------- | ----------------------------------------- | | `-fflags nobuffer` | Disable input buffering | | `-flags low_delay` | Minimize encoder latency | | `-probesize 32` | Tiny probe size (faster startup) | | `-analyzeduration 0` | Skip format analysis | | `-preset ultrafast -tune zerolatency` | Fastest possible x264 encode | | `-g 30 -keyint_min 30` | Keyframe every 1 second (30 fps / 30 GOP) | The encoded stream pushes to a local MediaMTX instance over RTSP. ## Stage 3: WFB-ng transport WFB-ng (WiFi Broadcast next generation) uses an RTL8812EU adapter in monitor mode to broadcast FEC-encoded packets on 5 GHz. This is not standard WiFi. There is no association, no handshake, no retransmission, and no CSMA/CA backoff. Key properties: | Property | Value | | --------------- | ---------------------------------- | | Radio mode | 802.11 monitor mode (injection) | | Frequency | 5 GHz (channels 36-165) | | FEC | Reed-Solomon, configurable ratio | | Encryption | WFB-ng key exchange (AES) | | Max range | 50+ km (with directional antennas) | | Typical latency | 2-5 ms one-way | On the air side, `wfb_tx` reads the RTSP stream from MediaMTX and broadcasts it. On the ground side, `wfb_rx` receives and reassembles the stream, feeding it back into a ground-side MediaMTX instance. The agent supervises `wfb_tx` and `wfb_rx` from native Rust services: the radio TX service (`ados-radio`, the `ados-wfb` unit) on the drone and the receiver service (`ados-groundlink`, the `ados-wfb-rx` unit) on the ground station. It does not use OpenHD as a runtime dependency. WFB-ng is the transport protocol, and the agent controls the underlying `wfb_tx` / `wfb_rx` binaries directly. ## Stage 4: Ground serving The ground-side MediaMTX instance ingests the reassembled stream from WFB-ng RX and serves it to clients over WebRTC WHEP (WebRTC-HTTP Egress Protocol). WHEP is an HTTP-based WebRTC signaling protocol. The browser sends a POST to the WHEP endpoint, and MediaMTX responds with an SDP answer. No custom signaling server needed. | Endpoint | URL | Protocol | | -------- | ------------------------------------- | ------------------------- | | WHEP | `http://:8889/ados/whep` | WebRTC (H.264 RTP) | | RTSP | `rtsp://:8554/ados` | RTSP (for tools like VLC) | MediaMTX uses copy-codec (zero transcoding). The H.264 stream from WFB-ng passes through to WebRTC without re-encoding. CPU overhead is minimal: \~15% of one core on Pi 4B. ## Stage 5: Browser decode The browser receives H.264 RTP over WebRTC and decodes it using the platform's hardware decoder. Chrome, Edge, Firefox, and Safari all support hardware H.264 decode on modern hardware. The `webrtc-client.ts` module in Mission Control handles: * WHEP negotiation (POST to the endpoint, receive SDP answer) * ICE candidate gathering (STUN servers for NAT traversal) * Track attachment to a `