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};