Skip to main content

ados_protocol/
units.rs

1//! The systemd unit table: every unit the agent ships in `data/systemd`, with
2//! the facts each consumer needs about it.
3//!
4//! The supervisor's managed catalog, its ground-station role sets, the
5//! installer's enable / start / teardown / health lists, the REST restart
6//! allowlist, the armed interlock's link-dropping class and the published unit
7//! reference are all derived from [`UNITS`]. A shipped unit with no row here, or
8//! a row with no shipped unit file, fails the bijection test.
9//!
10//! Row order is meaningful: the supervisor's catalog (and so its start order
11//! within a tier) and every derived installer list follow it.
12
13/// Supervisor tier. Start order is core first, then hardware, then on-demand.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Category {
16    /// Always running on the profiles the unit applies to.
17    Core,
18    /// Started when its hardware or opt-in is present.
19    Hardware,
20    /// Brought up by its own enablement; the supervisor adopts it.
21    OnDemand,
22}
23
24impl Category {
25    /// The lowercase wire string used in the unit reference.
26    pub const fn as_str(self) -> &'static str {
27        match self {
28            Category::Core => "core",
29            Category::Hardware => "hardware",
30            Category::OnDemand => "on_demand",
31        }
32    }
33}
34
35/// How the installer enable-links a unit.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Install {
38    /// Enabled on every install, whatever the profile. Each such unit
39    /// self-gates (a `ConditionPathExists`, or an `ExecStart` guard), so the
40    /// link is a clean no-op on a board without its hardware.
41    Universal,
42    /// Enabled on an install of one of the unit's `profiles`.
43    Profile,
44    /// Not enable-linked from the table: the supervisor starts it, or a
45    /// dedicated install step owns its enablement (the opt-in logging store,
46    /// the power re-assert oneshot).
47    None,
48}
49
50impl Install {
51    /// The lowercase wire string used in the unit reference.
52    pub const fn as_str(self) -> &'static str {
53        match self {
54            Install::Universal => "universal",
55            Install::Profile => "profile",
56            Install::None => "none",
57        }
58    }
59}
60
61/// One shipped systemd unit.
62#[derive(Debug, Clone, Copy)]
63pub struct UnitSpec {
64    /// The unit file name, `ados-<name>.service`.
65    pub name: &'static str,
66    /// The supervisor tier, or `None` for a unit the supervisor does not
67    /// manage (the supervisor itself, boot oneshots, units pulled up by
68    /// `WantedBy=`).
69    pub category: Option<Category>,
70    /// The agent profiles the unit applies to. Empty means every profile.
71    pub profiles: &'static [&'static str],
72    /// The ground-station roles that own the unit. Empty means any role.
73    pub roles: &'static [&'static str],
74    /// How the installer enable-links it.
75    pub install: Install,
76    /// The install's `start` step kicks it with `--no-block` on its profiles.
77    pub start_on_install: bool,
78    /// The install's health gate requires it enabled on its profiles.
79    pub health_check: bool,
80    /// `POST /api/services/{name}/restart` may restart it.
81    pub api_restartable: bool,
82    /// Restarting it drops the command, telemetry, video, RC, mesh,
83    /// access-point or cloud link, so the armed interlock refuses it.
84    pub drops_link: bool,
85    /// In the lean headless keep set: the minimal services a zero-Python
86    /// flight node keeps running.
87    pub headless_keep: bool,
88    /// A clean `exit 0` means "nothing to run on this node", not a death (the
89    /// unit file carries `RestartPreventExitStatus=0`).
90    pub idle_ok: bool,
91    /// A `Type=oneshot` unit that exits on its own once its work is done.
92    pub self_terminating: bool,
93    /// An install of a profile outside `profiles` stops and disables it.
94    pub teardown: bool,
95}
96
97impl UnitSpec {
98    /// The unit name without the `.service` suffix (`ados-video`).
99    pub fn stem(&self) -> &'static str {
100        let name = self.name;
101        name.strip_suffix(".service").unwrap_or(name)
102    }
103
104    /// Whether the unit applies to `profile` (underscore form).
105    pub fn on_profile(&self, profile: &str) -> bool {
106        self.profiles.is_empty() || self.profiles.contains(&profile)
107    }
108}
109
110/// Every agent profile, the expansion of an empty `profiles`. Generated from
111/// the `[profiles]` table in `contracts.toml`.
112pub const ALL_PROFILES: &[&str] = crate::contracts::PROFILES;
113
114const DRONE: &[&str] = &["drone"];
115const GS: &[&str] = &["ground_station"];
116/// The flight-controller-bearing profiles that run the swarm and RC lanes.
117const FC: &[&str] = &["drone", "ground_station"];
118/// The profiles that talk MAVLink to a flight controller or a stage-A hub.
119const FC_VISOR: &[&str] = &["drone", "ground_station", "visor"];
120/// The camera-owning profiles.
121const DRONE_VISOR: &[&str] = &["drone", "visor"];
122/// The profiles with a local access point, status panel and buttons.
123const GS_VISOR: &[&str] = &["ground_station", "visor"];
124
125/// The all-false row every entry starts from.
126const BASE: UnitSpec = UnitSpec {
127    name: "",
128    category: None,
129    profiles: &[],
130    roles: &[],
131    install: Install::None,
132    start_on_install: false,
133    health_check: false,
134    api_restartable: true,
135    drops_link: false,
136    headless_keep: false,
137    idle_ok: false,
138    self_terminating: false,
139    teardown: false,
140};
141
142/// A boot oneshot the installer enables everywhere. Nothing to restart.
143const fn boot_oneshot(name: &'static str) -> UnitSpec {
144    UnitSpec {
145        name,
146        install: Install::Universal,
147        api_restartable: false,
148        self_terminating: true,
149        ..BASE
150    }
151}
152
153/// A ground-station unit the installer enables, starts and tears down.
154const fn gs_unit(name: &'static str, category: Option<Category>) -> UnitSpec {
155    UnitSpec {
156        name,
157        category,
158        profiles: GS,
159        install: Install::Profile,
160        start_on_install: true,
161        teardown: true,
162        ..BASE
163    }
164}
165
166/// A sensor-plane unit the supervisor starts on its profiles and an install
167/// of any other profile tears down. Each exits 0 when it has nothing to run.
168const fn sensor_unit(name: &'static str, profiles: &'static [&'static str]) -> UnitSpec {
169    UnitSpec {
170        name,
171        category: Some(Category::Hardware),
172        profiles,
173        idle_ok: true,
174        teardown: true,
175        ..BASE
176    }
177}
178
179use Category::{Core, Hardware, OnDemand};
180
181/// Every unit in `data/systemd`, in canonical order.
182pub const UNITS: &[UnitSpec] = &[
183    // The top-level unit that owns the agent process tree. It has its own
184    // restart route, and the start step starts it explicitly.
185    UnitSpec {
186        name: "ados-supervisor.service",
187        install: Install::Universal,
188        health_check: true,
189        api_restartable: false,
190        drops_link: true,
191        ..BASE
192    },
193    // The sole command-and-control path to the flight controller. Not on the
194    // FC-less profiles, which never fetch the router binary.
195    UnitSpec {
196        name: "ados-mavlink.service",
197        category: Some(Core),
198        profiles: FC_VISOR,
199        drops_link: true,
200        headless_keep: true,
201        ..BASE
202    },
203    // The swarm state bus, the same seam as the router seen from either end.
204    // A drone with no fleet slot yet exits 0 and idles.
205    UnitSpec {
206        name: "ados-swarmbus.service",
207        category: Some(Core),
208        profiles: FC,
209        drops_link: true,
210        headless_keep: true,
211        idle_ok: true,
212        ..BASE
213    },
214    UnitSpec {
215        name: "ados-api.service",
216        category: Some(Core),
217        ..BASE
218    },
219    UnitSpec {
220        name: "ados-cloud.service",
221        category: Some(Core),
222        drops_link: true,
223        ..BASE
224    },
225    // The durable logging store. Opt-in: the installer masks it when
226    // `logging.store.enabled` is off, and the supervisor gates on the same key.
227    UnitSpec {
228        name: "ados-logd.service",
229        category: Some(Core),
230        headless_keep: true,
231        idle_ok: true,
232        ..BASE
233    },
234    // The clock and sensor-bus broker, first in the hardware tier so the bus
235    // is bound before its producers; then the USB sensor hub (marker-gated)
236    // and the directly attached GNSS receivers.
237    sensor_unit("ados-clock.service", FC_VISOR),
238    sensor_unit("ados-sensorhub.service", DRONE_VISOR),
239    sensor_unit("ados-gnss.service", GS_VISOR),
240    // The encode pipeline (air side on a drone, preview legs on a visor); exits
241    // 0 and idles on other profiles or with video off.
242    UnitSpec {
243        name: "ados-video.service",
244        category: Some(Hardware),
245        profiles: DRONE_VISOR,
246        drops_link: true,
247        headless_keep: true,
248        idle_ok: true,
249        teardown: true,
250        ..BASE
251    },
252    // NPU inference sidecar (RKNN); self-gates on the NPU runtime library.
253    UnitSpec {
254        name: "ados-vision-rknn.service",
255        category: Some(Hardware),
256        profiles: DRONE,
257        ..BASE
258    },
259    // NPU inference sidecar (Hailo); `ConditionPathExists=/dev/hailo0`.
260    UnitSpec {
261        name: "ados-vision-hailo.service",
262        category: Some(Hardware),
263        profiles: DRONE,
264        install: Install::Profile,
265        teardown: true,
266        ..BASE
267    },
268    // The drone-side radio TX manager.
269    UnitSpec {
270        name: "ados-wfb.service",
271        category: Some(Hardware),
272        profiles: DRONE,
273        drops_link: true,
274        headless_keep: true,
275        teardown: true,
276        ..BASE
277    },
278    // The onboard vision engine; exits cleanly while vision is off.
279    UnitSpec {
280        name: "ados-vision.service",
281        category: Some(Hardware),
282        profiles: DRONE_VISOR,
283        ..BASE
284    },
285    // The native HTTP control surface, the LAN front on every profile. This
286    // process serves the restart route, so it cannot restart itself.
287    UnitSpec {
288        name: "ados-control.service",
289        category: Some(OnDemand),
290        install: Install::Universal,
291        start_on_install: true,
292        api_restartable: false,
293        headless_keep: true,
294        ..BASE
295    },
296    boot_oneshot("ados-fbcon-detach.service"),
297    boot_oneshot("ados-display-probe.service"),
298    // Confirms a staged CSI overlay or restores the pre-overlay boot config;
299    // the probation marker, not the profile, decides whether it does anything.
300    boot_oneshot("ados-camera-probe.service"),
301    boot_oneshot("ados-usb-otg-host.service"),
302    // The native plugin host, pulled up by `WantedBy=` the supervisor.
303    UnitSpec {
304        name: "ados-plugin-host.service",
305        install: Install::Universal,
306        start_on_install: true,
307        ..BASE
308    },
309    // GPIO-output substrate; execs /bin/true until the opt-in marker exists.
310    UnitSpec {
311        name: "ados-gpio.service",
312        category: Some(Hardware),
313        install: Install::Universal,
314        idle_ok: true,
315        ..BASE
316    },
317    // CRSF / ExpressLRS RC lane, gated on the crsf-enabled marker.
318    UnitSpec {
319        name: "ados-crsf.service",
320        category: Some(Hardware),
321        profiles: FC,
322        install: Install::Universal,
323        drops_link: true,
324        idle_ok: true,
325        ..BASE
326    },
327    // Config-over-radio channel, gated on the tunnel-enabled marker.
328    UnitSpec {
329        name: "ados-tunnel-config.service",
330        category: Some(Hardware),
331        profiles: FC,
332        install: Install::Universal,
333        idle_ok: true,
334        ..BASE
335    },
336    // LoRa beacon and mesh lane, gated on the lora-enabled marker.
337    UnitSpec {
338        name: "ados-lora.service",
339        category: Some(Hardware),
340        profiles: FC,
341        install: Install::Universal,
342        idle_ok: true,
343        ..BASE
344    },
345    // Boot re-assert of the power knobs; the power-hardening step enables it
346    // once its helper script is in place.
347    UnitSpec {
348        name: "ados-power.service",
349        self_terminating: true,
350        ..BASE
351    },
352    // The single-node receive plane, owned by the direct role.
353    UnitSpec {
354        roles: &["direct"],
355        health_check: true,
356        drops_link: true,
357        idle_ok: true,
358        ..gs_unit("ados-wfb-rx.service", Some(Hardware))
359    },
360    // The pairing daemon every REST pairing call reaches over its socket.
361    UnitSpec {
362        health_check: true,
363        ..gs_unit("ados-mesh-pairing.service", None)
364    },
365    UnitSpec {
366        health_check: true,
367        drops_link: true,
368        ..gs_unit("ados-mediamtx-gs.service", Some(Hardware))
369    },
370    UnitSpec {
371        profiles: GS_VISOR,
372        ..gs_unit("ados-oled.service", Some(Hardware))
373    },
374    // The optional I2C status panel self-skips when no panel answers, so the
375    // install enables it without kicking it.
376    UnitSpec {
377        profiles: GS_VISOR,
378        start_on_install: false,
379        ..gs_unit("ados-oled-i2c.service", Some(Hardware))
380    },
381    UnitSpec {
382        profiles: GS_VISOR,
383        health_check: true,
384        drops_link: true,
385        ..gs_unit("ados-hostapd.service", Some(Hardware))
386    },
387    UnitSpec {
388        profiles: GS_VISOR,
389        health_check: true,
390        drops_link: true,
391        ..gs_unit("ados-dnsmasq-gs.service", Some(Hardware))
392    },
393    UnitSpec {
394        health_check: true,
395        idle_ok: true,
396        ..gs_unit("ados-setup-captive.service", Some(OnDemand))
397    },
398    // Exits 0 when the configured display is an LCD or none.
399    UnitSpec {
400        idle_ok: true,
401        ..gs_unit("ados-kiosk.service", Some(Hardware))
402    },
403    UnitSpec {
404        profiles: GS_VISOR,
405        drops_link: true,
406        ..gs_unit("ados-input.service", Some(Hardware))
407    },
408    // The front-panel button reader lives in-process here, so a visor runs it
409    // too.
410    UnitSpec {
411        profiles: GS_VISOR,
412        drops_link: true,
413        ..gs_unit("ados-pic.service", Some(Hardware))
414    },
415    // The ados-net uplink daemon: uplinks, the Wi-Fi station (it serves the
416    // Wi-Fi command socket) and the access-point config the hostapd unit
417    // gates on. A visor's AP needs it, so it runs there too.
418    UnitSpec {
419        profiles: GS_VISOR,
420        drops_link: true,
421        ..gs_unit("ados-uplink-router.service", Some(Hardware))
422    },
423    // Distributed-receive units, owned by the relay and receiver roles. The
424    // supervisor starts them on a role transition; nothing enables them.
425    UnitSpec {
426        name: "ados-batman.service",
427        category: Some(Hardware),
428        profiles: GS,
429        roles: &["relay", "receiver"],
430        drops_link: true,
431        idle_ok: true,
432        teardown: true,
433        ..BASE
434    },
435    UnitSpec {
436        name: "ados-wfb-relay.service",
437        category: Some(Hardware),
438        profiles: GS,
439        roles: &["relay"],
440        drops_link: true,
441        idle_ok: true,
442        ..BASE
443    },
444    UnitSpec {
445        name: "ados-wfb-receiver.service",
446        category: Some(Hardware),
447        profiles: GS,
448        roles: &["receiver"],
449        drops_link: true,
450        idle_ok: true,
451        ..BASE
452    },
453];
454
455/// Units a prior release shipped and has since deleted. The installer prunes
456/// each on every run so an upgrade leaves no orphaned unit whose code is gone.
457/// An explicit list, not a "remove what is not shipped" sweep: the runtime
458/// plugin slice and the per-plugin units live outside `data/systemd`.
459pub const RETIRED: &[&str] = &[
460    "ados-scripting.service",
461    "ados-cloud-relay.service",
462    // The native ados-pic reads the front-panel buttons in-process.
463    "ados-buttons.service",
464    // The native net daemon owns both uplinks.
465    "ados-ethernet.service",
466    "ados-modem.service",
467    // The native net daemon composes the USB gadget in-process.
468    "ados-usb-gadget-setup.service",
469    "ados-usb-gadget.service",
470    // The REST API reads the peripheral registry in-process.
471    "ados-peripherals.service",
472    // The native control surface owns the `_ados._tcp` advert.
473    "ados-discovery.service",
474    // The logging store reads its own hardware snapshot.
475    "ados-health.service",
476    // The native net daemon owns the WiFi station.
477    "ados-wifi-client.service",
478];
479
480mod lookup;
481mod roles;
482pub use lookup::{find, net_daemon_owns_wifi, supervised, units_for_profile};
483pub use roles::{role_unit_count, role_unit_names};