Skip to main content

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}