Skip to main content
The agent runs each function as its own systemd unit. A supervisor decides which units this node should run, starts them through systemctl, and watches them. The units share no memory; they talk over Unix domain sockets in /run/ados/ and over the HTTP front on port 8080. This page explains the design. The unit list per profile is on Systemd services, the four profiles and how a node picks one are on Node profiles, and board profiles are on HAL and tiers.

Why multi-process

One process would be simpler, but on an aircraft it fails badly:
  • A crash in the video encoder would take the MAVLink link to the flight controller down with it.
  • There would be no per-service memory or CPU limit, so one leak starves everything.
  • Fixing one service would mean restarting all of them.
With one unit per function, a crashed ados-video restarts on its own and the MAVLink router never notices.

Rust and Python

The long-running and safety-critical services are native Rust binaries in /opt/ados/bin/: the supervisor, the MAVLink router, the HTTP front, the cloud relay, the video pipeline, the radio, the ground receiver, the uplink router, the display, the logging daemon and the vision engine. Python, in the virtual environment at /opt/ados/venv/, carries the residual API behind the HTTP front, the setup webapp, HAL board detection and profile seeding, and AI sidecars such as the RKNN inference unit. Many unit names are older than their Rust binaries and stay stable so the commands you type keep working: On a ground station, ados-net owns the wired link, the Wi-Fi station and the cellular modem in one process, and ados-pic reads the front-panel buttons. There are no separate units for those.

The supervisor

ados-supervisor never spawns service processes itself. It issues systemctl against a fixed catalog (SERVICE_REGISTRY in crates/ados-supervisor/src/registry.rs, derived from the unit table UNITS in crates/ados-protocol/src/units.rs), so systemd stays the process manager and owns the cgroups, restarts and journald wiring. Each catalog row has:
  • A category. Core units start first, then hardware units, then on-demand units. Shutdown runs in reverse.
  • A profile gate. A row scoped to drone, ground_station or both is skipped on any other profile. A row with no gate runs everywhere.
  • A role gate. On a ground station, some rows run only in one distributed-receive role (direct, relay or receiver).
  • A headless flag. In headless mode only the lean flight core runs: the MAVLink router, the swarm bus, camera encode, the radio, the logging daemon and the HTTP front.
  • An idle rule. Some units exit 0 on purpose when the node has nothing for them to do (no fleet slot, no LCD, a feature marker absent). The supervisor records these as idle, not as a failure, and does not restart them.
The supervisor reports each unit as stopped, starting, running, failed, crash_looping or idle.

Crash-loop handling

When a unit fails 5 times inside 60 seconds, the supervisor labels it crash_looping. The label is for the operator; it never blocks a start. A unit parked in failed or crash_looping is retried every 5 seconds, so it recovers by itself once the cause goes away (for example a camera plugged back in). The supervisor also clears systemd’s start-limit-hit latch with systemctl reset-failed, so no unit is left permanently stopped.

Systemd unit structure

Unit files ship in the agent’s data/systemd/ and are installed to /etc/systemd/system/. This is the MAVLink router’s unit, trimmed to the lines that matter:
  • PartOf stops and restarts the unit with the supervisor.
  • Slice=ados.slice puts every agent unit in one accounting slice, so per-unit memory and CPU are observable.
  • Restart=always with StartLimitIntervalSec=0 keeps the only flight-controller command path retrying instead of giving up.
  • MemoryMax and CPUQuota stop one service from starving the rest. Each unit sets its own limits.

IPC sockets

All sockets live in /run/ados/ (override with ADOS_RUN_DIR). Every length-prefixed socket uses the same framing primitive: a 4-byte big-endian length followed by that many bytes. The contracts and their version numbers are registered in crates/ados-protocol/contracts.toml.
The router fans every frame from the flight controller out to all connected readers, such as the cloud relay and the HTTP front. It is a publish-subscribe socket: every reader gets every frame. Versioning follows MAVLink itself.

Vehicle-state socket

state.sock is the canonical vehicle-state feed. The format is contract state.v2, body version 4.
  • The router publishes a snapshot about 10 times a second. A new reader receives the last snapshot as soon as it connects.
  • s is an open map of telemetry: attitude, position, GPS, battery, mode, armed state and link statistics. Once the flight controller’s heartbeat is latched it also carries fc_system_id and fc_component_id.
  • A reading the flight controller has not reported (battery voltage, current, remaining and temperature, GPS eph and epv, RC RSSI) is null, not the MAVLink sentinel value.
  • v is an exact-match version. A reader skips any frame whose v differs from its own and gives up on the connection after 8 mismatched frames in a row. Every change to the body shape bumps the version, including an added field.
  • The largest accepted frame is 1 MiB.
The older state.v1 format (one JSON object per line, no version field) is legacy. The shared readers still decode a stray v1 line, choosing the format per frame from the leading byte, but nothing current produces it.

HTTP control surface

Port 8080 belongs to the native Rust front, ados-control. It answers its own routes directly (including the WebRTC /whep endpoint), relays /hls/* to MediaMTX on loopback, serves the dashboard and on-box cockpit bundles from disk, and forwards a fixed set of path prefixes to the residual Python API over api-internal.sock. Any other /api path returns 404. The forwarded prefixes are PERMANENT_PYTHON_PREFIXES in crates/ados-control/src/routing.rs:
  • /api/vision
  • /api/v1/setup
  • /api/peripherals
  • /api/v1/peripherals
  • /api/v1/display
A client sees one HTTP surface on :8080; the split is internal. Native routes go through the front’s own rate limiter, pairing gate and MCP scope check. Forwarded routes are authenticated by the front before they are passed on. Requests authenticate with the X-ADOS-Key header. The key is the pairing key in /etc/ados/pairing.json (mode 0600), created when the node is paired. The MAVLink WebSocket on :8765, served by ados-mavlink-router, uses a short-lived single-use HMAC ticket instead. The full route list and auth rules are on REST API.

How a node picks its profile

The install-time choice decides. The installer’s --profile flag, or the role picked in the setup wizard, is written to /etc/ados/profile.conf. An explicit agent.profile in /etc/ados/config.yaml overrides it. Hardware detection is only a fallback seed. When agent.profile is auto (or unset) and profile.conf names no profile, the residual API runs profile_detect at boot and writes the result. Its decision is a strict argmax over hardware probes, with a tie broken by the last saved profile and a final default of drone. If the probes keep tying, the seed stops and you set the profile with ados profile set or the setup wizard. Details are on Node profiles.

Next steps

Systemd services

Every unit, its profile gate and what it does.

Node profiles

The four profiles and how each one is chosen.

Video stack

The camera-to-browser path the video units implement.

Project structure

Where each crate and module lives.
Last modified on October 11, 2026