ados_protocol/hwcaps.rs
1//! Probed hardware-capability contract.
2//!
3//! The agent must not trust hardware *declarations* (a board YAML, an ffmpeg
4//! `-encoders` listing, a vendor wrapper that is present but backed by no real
5//! device). It must derive capabilities by *probing the real silicon*: read the
6//! sysfs / device-tree node, issue the ioctl, or trial-init the device, and
7//! record the evidence that produced the answer.
8//!
9//! This module is the typed result shape that crosses process boundaries:
10//! every probe returns a [`Probed<T>`], and a full snapshot is a
11//! [`HardwareCapabilities`]. The probes themselves (the I/O) live in the
12//! `ados-hal-probe` crate; this crate only owns the contract so any service —
13//! Rust or, over the state socket, Python — can read it.
14//!
15//! ## Why `Probed<T>` and not `Option<T>`
16//! An `Option` collapses "we looked and it is genuinely not there" with "we did
17//! not look" and with "the thing advertised itself but failed when we trial-ran
18//! it". Those three answers drive different agent behavior (fall back to
19//! software, defer the probe, blacklist a lying wrapper), so they are distinct
20//! variants here and each carries the evidence or the reason behind it.
21
22use serde::{Deserialize, Serialize};
23
24/// The outcome of one capability probe.
25///
26/// `Present` always carries the [`Evidence`] that justified the answer, so a
27/// reader can audit *why* the agent believes a device exists. `Absent` carries
28/// the [`AbsenceReason`] so a reader can tell "looked, not there" apart from
29/// "advertised, but lied". `NotProbed` is the only honest answer when the probe
30/// has not run yet (e.g. a destructive probe deferred out of the runtime phase).
31#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(tag = "status", rename_all = "snake_case")]
33pub enum Probed<T> {
34 /// The capability is present; `evidence` records what proved it.
35 Present { value: T, evidence: Evidence },
36 /// The capability is genuinely absent; `reason` records why we concluded so.
37 Absent { reason: AbsenceReason },
38 /// The probe has not run (deferred, or this phase forbids it).
39 #[default]
40 NotProbed,
41}
42
43impl<T> Probed<T> {
44 /// True only for [`Probed::Present`].
45 pub fn is_present(&self) -> bool {
46 matches!(self, Probed::Present { .. })
47 }
48
49 /// The probed value, if present.
50 pub fn value(&self) -> Option<&T> {
51 match self {
52 Probed::Present { value, .. } => Some(value),
53 _ => None,
54 }
55 }
56
57 /// Construct a `Present` carrying its evidence.
58 pub fn present(value: T, evidence: Evidence) -> Self {
59 Probed::Present { value, evidence }
60 }
61
62 /// Construct an `Absent` carrying its reason.
63 pub fn absent(reason: AbsenceReason) -> Self {
64 Probed::Absent { reason }
65 }
66}
67
68/// What concretely proved a [`Probed::Present`] answer.
69///
70/// Evidence is ordered weakest-to-strongest: a sysfs path or a device-tree
71/// compatible string is a *declaration the kernel exposes*, an ioctl readback
72/// confirms the kernel driver answered, and a [`Evidence::TrialInit`] is the
73/// strongest — the device accepted a real (bounded) initialization.
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(tag = "kind", rename_all = "snake_case")]
76pub enum Evidence {
77 /// A node was found at this sysfs path.
78 SysfsPath(String),
79 /// `/proc/device-tree/compatible` (or an `of_node`) carried this string.
80 DeviceTreeCompatible(String),
81 /// An ioctl on `node` (e.g. `VIDIOC_QUERYCAP`) answered successfully.
82 Ioctl { node: String, call: String },
83 /// The device accepted a bounded trial init on `node`, completing in `ms`.
84 TrialInit { node: String, ms: u32 },
85 /// A core cluster's MIDR (main ID register) read from `/proc/cpuinfo`.
86 ProcCpuinfo { midr: u32 },
87}
88
89/// Why a capability was concluded [`Probed::Absent`].
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91#[serde(tag = "reason", rename_all = "snake_case")]
92pub enum AbsenceReason {
93 /// No node / device file existed to probe.
94 NodeMissing,
95 /// A node existed but enumerated no usable format (e.g. no H.264 output).
96 FormatUnsupported,
97 /// The device advertised itself but a trial init failed (exited) after
98 /// `exit_after_ms` milliseconds.
99 TrialInitFailed { exit_after_ms: u32 },
100 /// The trial init hung (never reaped within the bounded window); the probe
101 /// runner SIGKILLed the process group and gave up so the agent survives.
102 TrialInitHung,
103 /// A host quirk explicitly denied this capability under `key`.
104 DeniedByQuirk { key: String },
105}
106
107/// When a probe runs.
108///
109/// Some probes are read-only (sysfs, device-tree, ioctl-query) and safe in any
110/// phase. Others are *destructive or stateful* — a video encoder trial-init
111/// opens and configures a real device, anything that toggles a GPIO or resets a
112/// peripheral mutates hardware state. **Destructive / stateful probes are
113/// FORBIDDEN unless `phase != Runtime` while the vehicle is armed.** Run them at
114/// [`ProbePhase::Setup`] or [`ProbePhase::BootPreArm`], cache the result, and
115/// read the cache during armed runtime.
116#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(rename_all = "snake_case")]
118pub enum ProbePhase {
119 /// Operator-driven setup (the safest window; full trial-init allowed).
120 Setup,
121 /// Boot, before the vehicle can arm (trial-init allowed).
122 BootPreArm,
123 /// Live runtime. Read-only probes only; destructive probes must read the
124 /// cached Setup / BootPreArm result, never re-run while armed.
125 Runtime,
126}
127
128impl ProbePhase {
129 /// True if a destructive / stateful probe (encoder trial-init, anything
130 /// that toggles a device) may run in this phase.
131 pub fn allows_destructive(&self) -> bool {
132 !matches!(self, ProbePhase::Runtime)
133 }
134}
135
136/// A CPU main-ID-register value (`MIDR_EL1`-style), as read from
137/// `/proc/cpuinfo`'s `CPU implementer` / `CPU part` fields.
138#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
139pub struct Midr(pub u32);
140
141impl Midr {
142 /// Map the implementer (`0x41` = Arm) + part bits onto a Cortex name.
143 ///
144 /// Only the part field is consulted; the table covers the cores the agent's
145 /// supported boards ship. An unknown part returns `None` so the caller can
146 /// surface the raw MIDR rather than guess.
147 pub fn cortex_name(&self) -> Option<&'static str> {
148 // part number lives in bits [15:4] of the MIDR.
149 let part = (self.0 >> 4) & 0xFFF;
150 Some(match part {
151 0xd0b => "Cortex-A76",
152 0xd05 => "Cortex-A55",
153 0xd03 => "Cortex-A53",
154 0xd08 => "Cortex-A72",
155 0xc07 => "Cortex-A7",
156 _ => return None,
157 })
158 }
159}
160
161/// One homogeneous cluster of CPU cores (a big.LITTLE rig has more than one).
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163pub struct CoreCluster {
164 /// Resolved Cortex name (e.g. `Cortex-A76`), or the raw MIDR hex when the
165 /// part is not in the table.
166 pub cortex: String,
167 /// The MIDR that identified the cluster.
168 pub midr: Midr,
169 /// How many cores share this MIDR.
170 pub count: u8,
171}
172
173/// The SoC's `/proc/device-tree/compatible` strings (most-specific first).
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175pub struct SocCompatible(pub Vec<String>);
176
177/// A V4L2 node found under `/dev`, with the formats it enumerates on its
178/// output (the format a memory-to-memory encoder *produces*).
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180pub struct V4l2Node {
181 /// The device path, e.g. `/dev/video11`.
182 pub path: String,
183 /// True if `VIDIOC_QUERYCAP` reports the memory-to-memory capability
184 /// (`V4L2_CAP_VIDEO_M2M*`) — the shape a stateful encoder has.
185 pub is_m2m: bool,
186 /// FourCCs enumerated on the node's output (capture-from-encoder) side.
187 pub output_fourccs: Vec<[u8; 4]>,
188}
189
190/// A confirmed hardware H.264 encoder device: a real node that enumerates an
191/// H.264 output FourCC (`H264`) and accepted a bounded trial init.
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
193pub struct EncoderDevice {
194 /// The encoder device node, e.g. `/dev/video11`.
195 pub node: String,
196 /// The H.264 FourCC the node produces (`*b"H264"`).
197 pub fourcc: [u8; 4],
198}
199
200/// A USB `(idVendor, idProduct)` pair.
201#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
202pub struct UsbId {
203 pub vid: u16,
204 pub pid: u16,
205}
206
207/// Whether a USB `(vid, pid)` identifies a bridge commonly fronting an
208/// ExpressLRS / CRSF RC transmitter module: a CP2102 (`10c4:ea60`), a CH340
209/// (`1a86:7523`), or any Espressif native-USB device (`303a:*`, an
210/// ESP32-S3-based module).
211///
212/// A VID:PID alone CANNOT distinguish an RC module from a flight controller
213/// behind the same bridge (plenty of FCs ship on CP2102/CH340 UART bridges),
214/// so a match is only ever a HINT to be combined with stronger evidence:
215/// an explicit `radio.crsf.device` pin / lane opt-in in the config, or a
216/// failed MAVLink/MSP probe on the port. It must never disqualify a port
217/// outright on its own.
218pub fn is_rc_bridge_usb_id(vid: u16, pid: u16) -> bool {
219 matches!((vid, pid), (0x10C4, 0xEA60) | (0x1A86, 0x7523)) || vid == 0x303A
220}
221
222/// A serial port the agent may attach a flight controller (or other UART
223/// peripheral) to.
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225pub struct SerialPort {
226 /// The device path, e.g. `/dev/ttyACM0`.
227 pub path: String,
228 /// The backing USB id when the port is a USB-serial bridge (CDC-ACM / FTDI).
229 pub usb: Option<UsbId>,
230 /// A heuristic 0-100 score that the port is a flight controller (a higher
231 /// score for known FC USB ids / ACM nodes); the caller picks the best.
232 pub fc_score: u8,
233}
234
235/// A full probed hardware-capability snapshot.
236///
237/// Each field is a [`Probed<T>`] so a reader can tell a genuine absence apart
238/// from "not probed yet". This is the shape the HAL publishes and the encoder /
239/// supervisor / setup surfaces consume.
240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
241pub struct HardwareCapabilities {
242 /// CPU core clusters (big.LITTLE aware).
243 pub cpu: Probed<Vec<CoreCluster>>,
244 /// SoC device-tree compatible strings.
245 pub soc: Probed<SocCompatible>,
246 /// A confirmed hardware H.264 encoder device, if any.
247 pub h264_encoder: Probed<EncoderDevice>,
248 /// USB device ids on the bus.
249 pub usb: Probed<Vec<UsbId>>,
250 /// Serial ports (FC candidates scored).
251 pub serial: Probed<Vec<SerialPort>>,
252}
253
254impl Default for HardwareCapabilities {
255 fn default() -> Self {
256 Self {
257 cpu: Probed::NotProbed,
258 soc: Probed::NotProbed,
259 h264_encoder: Probed::NotProbed,
260 usb: Probed::NotProbed,
261 serial: Probed::NotProbed,
262 }
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn midr_maps_known_cortex_parts() {
272 // implementer bits are ignored; only part [15:4] matters.
273 assert_eq!(Midr(0x410f_d0b0 & 0xFFF0).cortex_name(), Some("Cortex-A76"));
274 assert_eq!(Midr(0xd0b << 4).cortex_name(), Some("Cortex-A76"));
275 assert_eq!(Midr(0xd05 << 4).cortex_name(), Some("Cortex-A55"));
276 assert_eq!(Midr(0xd03 << 4).cortex_name(), Some("Cortex-A53"));
277 assert_eq!(Midr(0xd08 << 4).cortex_name(), Some("Cortex-A72"));
278 assert_eq!(Midr(0xc07 << 4).cortex_name(), Some("Cortex-A7"));
279 assert_eq!(Midr(0xfff << 4).cortex_name(), None);
280 }
281
282 #[test]
283 fn rc_bridge_ids_match_full_pairs_and_espressif_vendor() {
284 // The two UART bridges match on the full (vid, pid) pair only.
285 assert!(is_rc_bridge_usb_id(0x10C4, 0xEA60)); // CP2102
286 assert!(is_rc_bridge_usb_id(0x1A86, 0x7523)); // CH340
287 assert!(!is_rc_bridge_usb_id(0x10C4, 0x0001)); // other CP210x product
288 assert!(!is_rc_bridge_usb_id(0x1A86, 0x0001)); // other WCH product
289 // Espressif native USB matches on the vendor (product ids vary).
290 assert!(is_rc_bridge_usb_id(0x303A, 0x1001));
291 assert!(is_rc_bridge_usb_id(0x303A, 0x0009));
292 // Common FC vendors never match.
293 assert!(!is_rc_bridge_usb_id(0x0483, 0x5740)); // STM native USB
294 assert!(!is_rc_bridge_usb_id(0x0403, 0x6001)); // FTDI
295 assert!(!is_rc_bridge_usb_id(0x1209, 0x5741)); // open-hardware FC
296 }
297
298 #[test]
299 fn phase_gates_destructive_probes() {
300 assert!(ProbePhase::Setup.allows_destructive());
301 assert!(ProbePhase::BootPreArm.allows_destructive());
302 assert!(!ProbePhase::Runtime.allows_destructive());
303 }
304
305 #[test]
306 fn probed_present_carries_evidence() {
307 let p = Probed::present(
308 EncoderDevice {
309 node: "/dev/video11".into(),
310 fourcc: *b"H264",
311 },
312 Evidence::TrialInit {
313 node: "/dev/video11".into(),
314 ms: 42,
315 },
316 );
317 assert!(p.is_present());
318 assert_eq!(p.value().unwrap().node, "/dev/video11");
319 }
320
321 #[test]
322 fn probed_default_is_not_probed() {
323 let p: Probed<u32> = Probed::default();
324 assert!(matches!(p, Probed::NotProbed));
325 assert!(!p.is_present());
326 }
327
328 #[test]
329 fn snapshot_round_trips_json() {
330 let caps = HardwareCapabilities {
331 h264_encoder: Probed::absent(AbsenceReason::TrialInitFailed { exit_after_ms: 130 }),
332 cpu: Probed::present(
333 vec![CoreCluster {
334 cortex: "Cortex-A55".into(),
335 midr: Midr(0xd05 << 4),
336 count: 8,
337 }],
338 Evidence::ProcCpuinfo { midr: 0xd05 << 4 },
339 ),
340 ..Default::default()
341 };
342 let json = serde_json::to_string(&caps).unwrap();
343 let back: HardwareCapabilities = serde_json::from_str(&json).unwrap();
344 assert_eq!(caps, back);
345 // The absent reason survives the round trip with its detail.
346 assert!(matches!(
347 back.h264_encoder,
348 Probed::Absent {
349 reason: AbsenceReason::TrialInitFailed { exit_after_ms: 130 }
350 }
351 ));
352 }
353}