Skip to main content

wm_substrate/
sensorimotor.rs

1//! Sensorimotor Weave — hardware I/O abstraction for embodied AI.
2//!
3//! Provides a framework for connecting sensors and actuators to the
4//! cognitive substrate via a unified trait interface. Inspired by:
5//! - copper-rs: deterministic Rust robotics with sub-microsecond IPC
6//! - dora-rs: dataflow-oriented robotic architecture (Zenoh SHM)
7//! - v1 WhiteMagic: embodiment.py HarmonyMonitor, physical_metrics.py
8//!
9//! Architecture:
10//!   Sensor → SensorReading → SensorimotorBus → ActuatorCommand → Actuator
11//!
12//! The `SensorDevice` and `ActuatorDevice` traits are designed to be
13//! implementable via C-ABI FFI (feature-gated `hardware` feature) so that
14//! real hardware drivers can be linked without polluting the core crate.
15//!
16//! When the `hardware` feature is disabled, all operations are stubbed
17//! with sensible defaults — the framework is fully testable on any machine.
18
19#![forbid(unsafe_code)]
20
21use std::collections::HashMap;
22use std::time::Instant;
23
24use serde::{Deserialize, Serialize};
25
26// ── Sensor Types ───────────────────────────────────────────────────────
27
28/// Type of sensor reading.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum SensorKind {
32    /// Temperature sensor (CPU, ambient, motor).
33    Temperature,
34    /// IMU / accelerometer / gyroscope.
35    Imu,
36    /// Camera / vision sensor.
37    Camera,
38    /// Microphone / audio sensor.
39    Audio,
40    /// Distance / range finder (ultrasonic, lidar, IR).
41    Distance,
42    /// Pressure / force sensor.
43    Pressure,
44    /// Encoded motor position / joint angle.
45    Encoder,
46    /// GPS / global position.
47    Gps,
48    /// Power / current / voltage sensor.
49    Power,
50    /// Custom sensor type.
51    Custom,
52}
53
54impl SensorKind {
55    #[must_use]
56    pub const fn as_str(self) -> &'static str {
57        match self {
58            Self::Temperature => "temperature",
59            Self::Imu => "imu",
60            Self::Camera => "camera",
61            Self::Audio => "audio",
62            Self::Distance => "distance",
63            Self::Pressure => "pressure",
64            Self::Encoder => "encoder",
65            Self::Gps => "gps",
66            Self::Power => "power",
67            Self::Custom => "custom",
68        }
69    }
70}
71
72impl std::fmt::Display for SensorKind {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.write_str(self.as_str())
75    }
76}
77
78// ── Actuator Types ─────────────────────────────────────────────────────
79
80/// Type of actuator output.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum ActuatorKind {
84    /// Motor / servo (speed or position control).
85    Motor,
86    /// Relay / digital output.
87    Relay,
88    /// LED / display output.
89    Display,
90    /// Speaker / audio output.
91    Speaker,
92    /// Valve / hydraulic control.
93    Valve,
94    /// Heating / cooling element.
95    Thermal,
96    /// Custom actuator type.
97    Custom,
98}
99
100impl ActuatorKind {
101    #[must_use]
102    pub const fn as_str(self) -> &'static str {
103        match self {
104            Self::Motor => "motor",
105            Self::Relay => "relay",
106            Self::Display => "display",
107            Self::Speaker => "speaker",
108            Self::Valve => "valve",
109            Self::Thermal => "thermal",
110            Self::Custom => "custom",
111        }
112    }
113}
114
115impl std::fmt::Display for ActuatorKind {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.write_str(self.as_str())
118    }
119}
120
121// ── Sensor Reading ─────────────────────────────────────────────────────
122
123/// A single sensor reading with timestamp.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct SensorReading {
126    /// Sensor identifier (e.g., "cpu_temp", "left_motor_encoder").
127    pub sensor_id: String,
128    /// Type of sensor.
129    pub kind: SensorKind,
130    /// Raw value (unit depends on sensor type).
131    pub value: f64,
132    /// Optional secondary values (e.g., 3-axis IMU: x, y, z).
133    pub extra: Vec<f64>,
134    /// Timestamp of reading.
135    pub timestamp: f64,
136    /// Confidence (0.0–1.0), 1.0 = fully trusted.
137    pub confidence: f64,
138}
139
140impl SensorReading {
141    /// Create a new sensor reading with current timestamp and full confidence.
142    #[must_use]
143    pub fn new(sensor_id: impl Into<String>, kind: SensorKind, value: f64) -> Self {
144        Self {
145            sensor_id: sensor_id.into(),
146            kind,
147            value,
148            extra: Vec::new(),
149            timestamp: now_secs(),
150            confidence: 1.0,
151        }
152    }
153
154    /// Add extra values (e.g., 3-axis data).
155    #[must_use]
156    pub fn with_extra(mut self, extra: Vec<f64>) -> Self {
157        self.extra = extra;
158        self
159    }
160
161    /// Set confidence.
162    #[must_use]
163    pub const fn with_confidence(mut self, confidence: f64) -> Self {
164        self.confidence = confidence.clamp(0.0, 1.0);
165        self
166    }
167}
168
169// ── Actuator Command ───────────────────────────────────────────────────
170
171/// A command to an actuator.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ActuatorCommand {
174    /// Actuator identifier (e.g., "left_motor", "valve_3").
175    pub actuator_id: String,
176    /// Type of actuator.
177    pub kind: ActuatorKind,
178    /// Primary command value (e.g., speed, position, duty cycle).
179    pub value: f64,
180    /// Optional secondary parameters (e.g., acceleration limit, duration).
181    pub params: Vec<f64>,
182    /// Command timestamp.
183    pub timestamp: f64,
184}
185
186impl ActuatorCommand {
187    /// Create a new actuator command.
188    #[must_use]
189    pub fn new(actuator_id: impl Into<String>, kind: ActuatorKind, value: f64) -> Self {
190        Self {
191            actuator_id: actuator_id.into(),
192            kind,
193            value,
194            params: Vec::new(),
195            timestamp: now_secs(),
196        }
197    }
198
199    /// Add parameters.
200    #[must_use]
201    pub fn with_params(mut self, params: Vec<f64>) -> Self {
202        self.params = params;
203        self
204    }
205}
206
207// ── Device Traits ──────────────────────────────────────────────────────
208
209/// Trait for sensor devices.
210///
211/// Implementations may wrap real hardware (via C-ABI FFI when the
212/// `hardware` feature is enabled) or provide stub/simulated readings.
213pub trait SensorDevice: Send + Sync {
214    /// Unique sensor identifier.
215    fn id(&self) -> &str;
216
217    /// Sensor type.
218    fn kind(&self) -> SensorKind;
219
220    /// Read current value. Returns `None` if the sensor is unavailable.
221    fn read(&self) -> Option<SensorReading>;
222
223    /// Whether the sensor is currently available/connected.
224    fn is_available(&self) -> bool {
225        true
226    }
227}
228
229/// Trait for actuator devices.
230///
231/// Implementations may wrap real hardware (via C-ABI FFI when the
232/// `hardware` feature is enabled) or provide stub/simulated responses.
233pub trait ActuatorDevice: Send + Sync {
234    /// Unique actuator identifier.
235    fn id(&self) -> &str;
236
237    /// Actuator type.
238    fn kind(&self) -> ActuatorKind;
239
240    /// Send a command to the actuator. Returns `Ok(())` on success.
241    fn command(&self, cmd: &ActuatorCommand) -> Result<(), String>;
242
243    /// Whether the actuator is currently available/connected.
244    fn is_available(&self) -> bool {
245        true
246    }
247
248    /// Emergency stop — immediately disable the actuator.
249    fn e_stop(&self) -> Result<(), String> {
250        Ok(())
251    }
252}
253
254// ── Stub Devices ───────────────────────────────────────────────────────
255
256/// Stub sensor that returns a fixed value. Used for testing and when
257/// the `hardware` feature is disabled.
258pub struct StubSensor {
259    id: String,
260    kind: SensorKind,
261    value: f64,
262}
263
264impl StubSensor {
265    #[must_use]
266    pub fn new(id: impl Into<String>, kind: SensorKind, value: f64) -> Self {
267        Self {
268            id: id.into(),
269            kind,
270            value,
271        }
272    }
273}
274
275impl SensorDevice for StubSensor {
276    fn id(&self) -> &str {
277        &self.id
278    }
279
280    fn kind(&self) -> SensorKind {
281        self.kind
282    }
283
284    fn read(&self) -> Option<SensorReading> {
285        Some(SensorReading::new(&self.id, self.kind, self.value))
286    }
287}
288
289/// Stub actuator that accepts all commands. Used for testing.
290pub struct StubActuator {
291    id: String,
292    kind: ActuatorKind,
293    last_command: std::sync::Mutex<Option<ActuatorCommand>>,
294}
295
296impl StubActuator {
297    #[must_use]
298    pub fn new(id: impl Into<String>, kind: ActuatorKind) -> Self {
299        Self {
300            id: id.into(),
301            kind,
302            last_command: std::sync::Mutex::new(None),
303        }
304    }
305
306    /// Get the last command sent to this actuator (for testing).
307    #[must_use]
308    pub fn last_command(&self) -> Option<ActuatorCommand> {
309        self.last_command.lock().map_or(None, |c| c.clone())
310    }
311}
312
313impl ActuatorDevice for StubActuator {
314    fn id(&self) -> &str {
315        &self.id
316    }
317
318    fn kind(&self) -> ActuatorKind {
319        self.kind
320    }
321
322    fn command(&self, cmd: &ActuatorCommand) -> Result<(), String> {
323        let Ok(mut last) = self.last_command.lock() else {
324            return Err("sensorimotor last-command lock poisoned".to_string());
325        };
326        *last = Some(cmd.clone());
327        Ok(())
328    }
329}
330
331// ── Sensorimotor Bus ───────────────────────────────────────────────────
332
333/// The central bus connecting sensors and actuators to the cognitive system.
334///
335/// Manages device registration, sensor reading aggregation, and actuator
336/// command dispatch. Thread-safe via `RwLock`.
337pub struct SensorimotorBus {
338    sensors: HashMap<String, Box<dyn SensorDevice>>,
339    actuators: HashMap<String, Box<dyn ActuatorDevice>>,
340    /// Recent sensor readings (ring buffer, last N).
341    reading_history: VecDeque<SensorReading>,
342    /// Max history size.
343    max_history: usize,
344    /// Total commands sent.
345    commands_sent: u64,
346    /// Total readings collected.
347    readings_collected: u64,
348}
349
350use std::collections::VecDeque;
351
352impl SensorimotorBus {
353    /// Create a new bus with the given history capacity.
354    #[must_use]
355    pub fn new(max_history: usize) -> Self {
356        Self {
357            sensors: HashMap::new(),
358            actuators: HashMap::new(),
359            reading_history: VecDeque::with_capacity(max_history),
360            max_history,
361            commands_sent: 0,
362            readings_collected: 0,
363        }
364    }
365
366    /// Register a sensor device.
367    pub fn register_sensor(&mut self, sensor: Box<dyn SensorDevice>) {
368        self.sensors.insert(sensor.id().to_string(), sensor);
369    }
370
371    /// Register an actuator device.
372    pub fn register_actuator(&mut self, actuator: Box<dyn ActuatorDevice>) {
373        self.actuators.insert(actuator.id().to_string(), actuator);
374    }
375
376    /// Poll all sensors and collect readings.
377    pub fn poll_all(&mut self) -> Vec<SensorReading> {
378        let readings: Vec<SensorReading> = self.sensors.values().filter_map(|s| s.read()).collect();
379
380        for r in &readings {
381            self.reading_history.push_back(r.clone());
382            if self.reading_history.len() > self.max_history {
383                self.reading_history.pop_front();
384            }
385        }
386
387        self.readings_collected += u64::try_from(readings.len()).unwrap_or(0);
388        readings
389    }
390
391    /// Read from a specific sensor by ID.
392    #[must_use]
393    pub fn read_sensor(&self, sensor_id: &str) -> Option<SensorReading> {
394        self.sensors.get(sensor_id).and_then(|s| s.read())
395    }
396
397    /// Send a command to a specific actuator.
398    pub fn send_command(&mut self, cmd: &ActuatorCommand) -> Result<(), String> {
399        let actuator = self
400            .actuators
401            .get(&cmd.actuator_id)
402            .ok_or_else(|| format!("actuator '{}' not registered", cmd.actuator_id))?;
403
404        actuator.command(cmd)?;
405        self.commands_sent += 1;
406        Ok(())
407    }
408
409    /// Emergency stop all actuators.
410    #[must_use]
411    pub fn e_stop_all(&self) -> Vec<String> {
412        self.actuators
413            .values()
414            .filter_map(|a| a.e_stop().err())
415            .collect()
416    }
417
418    /// Get recent sensor readings.
419    #[must_use]
420    pub const fn recent_readings(&self) -> &[SensorReading] {
421        // Return empty slice — we can't return a reference to VecDeque directly
422        // In practice, callers should use `poll_all` or `read_sensor`
423        &[]
424    }
425
426    /// Get the reading history as a Vec.
427    #[must_use]
428    pub fn history(&self) -> Vec<SensorReading> {
429        self.reading_history.iter().cloned().collect()
430    }
431
432    /// Number of registered sensors.
433    #[must_use]
434    pub fn sensor_count(&self) -> usize {
435        self.sensors.len()
436    }
437
438    /// Number of registered actuators.
439    #[must_use]
440    pub fn actuator_count(&self) -> usize {
441        self.actuators.len()
442    }
443
444    /// Total readings collected since creation.
445    #[must_use]
446    pub const fn readings_collected(&self) -> u64 {
447        self.readings_collected
448    }
449
450    /// Total commands sent since creation.
451    #[must_use]
452    pub const fn commands_sent(&self) -> u64 {
453        self.commands_sent
454    }
455
456    /// List all registered sensor IDs.
457    #[must_use]
458    pub fn sensor_ids(&self) -> Vec<String> {
459        self.sensors.keys().cloned().collect()
460    }
461
462    /// List all registered actuator IDs.
463    #[must_use]
464    pub fn actuator_ids(&self) -> Vec<String> {
465        self.actuators.keys().cloned().collect()
466    }
467}
468
469impl Default for SensorimotorBus {
470    fn default() -> Self {
471        Self::new(256)
472    }
473}
474
475// ── Reflex Loop ────────────────────────────────────────────────────────
476
477/// A simple reflex loop that polls sensors and triggers actuator commands
478/// when sensor values cross thresholds.
479///
480/// Inspired by the biological reflex arc: sensor → spinal cord → actuator,
481/// bypassing the cognitive layer for time-critical responses.
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct ReflexRule {
484    /// Sensor ID to monitor.
485    pub sensor_id: String,
486    /// Actuator ID to trigger.
487    pub actuator_id: String,
488    /// Actuator kind for the command.
489    pub actuator_kind: ActuatorKind,
490    /// Threshold value. If the sensor reading exceeds this, trigger.
491    pub threshold: f64,
492    /// Command value to send when triggered.
493    pub command_value: f64,
494    /// Whether the trigger is `value > threshold` (true) or `value < threshold` (false).
495    pub trigger_above: bool,
496    /// Minimum interval between triggers (cooldown), in seconds.
497    pub cooldown_secs: f64,
498}
499
500impl ReflexRule {
501    /// Create a new reflex rule that triggers when sensor exceeds threshold.
502    #[must_use]
503    pub fn above(
504        sensor_id: impl Into<String>,
505        actuator_id: impl Into<String>,
506        actuator_kind: ActuatorKind,
507        threshold: f64,
508        command_value: f64,
509        cooldown_secs: f64,
510    ) -> Self {
511        Self {
512            sensor_id: sensor_id.into(),
513            actuator_id: actuator_id.into(),
514            actuator_kind,
515            threshold,
516            command_value,
517            trigger_above: true,
518            cooldown_secs,
519        }
520    }
521
522    /// Create a new reflex rule that triggers when sensor drops below threshold.
523    #[must_use]
524    pub fn below(
525        sensor_id: impl Into<String>,
526        actuator_id: impl Into<String>,
527        actuator_kind: ActuatorKind,
528        threshold: f64,
529        command_value: f64,
530        cooldown_secs: f64,
531    ) -> Self {
532        Self {
533            sensor_id: sensor_id.into(),
534            actuator_id: actuator_id.into(),
535            actuator_kind,
536            threshold,
537            command_value,
538            trigger_above: false,
539            cooldown_secs,
540        }
541    }
542
543    /// Check if a sensor reading triggers this rule.
544    #[must_use]
545    pub fn is_triggered(&self, reading: &SensorReading) -> bool {
546        if self.trigger_above {
547            reading.value > self.threshold
548        } else {
549            reading.value < self.threshold
550        }
551    }
552}
553
554/// A reflex loop manager that evaluates rules against sensor readings.
555pub struct ReflexLoop {
556    rules: Vec<ReflexRule>,
557    last_trigger: HashMap<String, Instant>,
558    max_reading_age_secs: f64,
559}
560
561impl ReflexLoop {
562    /// Default freshness bound for sensor readings, in seconds.
563    ///
564    /// A rule never fires from a reading older than this: actuation must
565    /// follow observation, and a cached/stuck snapshot must not actuate.
566    pub const DEFAULT_MAX_READING_AGE_SECS: f64 = 5.0;
567
568    /// Create a new reflex loop.
569    #[must_use]
570    pub fn new() -> Self {
571        Self {
572            rules: Vec::new(),
573            last_trigger: HashMap::new(),
574            max_reading_age_secs: Self::DEFAULT_MAX_READING_AGE_SECS,
575        }
576    }
577
578    /// Override the reading freshness bound.
579    ///
580    /// `secs <= 0.0` disables the freshness check — tests and offline
581    /// benchmarks only; production must keep a positive bound.
582    #[must_use]
583    pub const fn with_max_reading_age(mut self, secs: f64) -> Self {
584        self.max_reading_age_secs = secs;
585        self
586    }
587
588    /// Whether a reading is fresh enough to fire a rule.
589    ///
590    /// Stale readings (older than the bound) and future timestamps (clock
591    /// skew) are refused until a fresh poll replaces them.
592    fn reading_is_fresh(&self, reading: &SensorReading, now: f64) -> bool {
593        if self.max_reading_age_secs <= 0.0 {
594            return true;
595        }
596        let age = now - reading.timestamp;
597        age.is_finite() && (0.0..=self.max_reading_age_secs).contains(&age)
598    }
599
600    /// Add a reflex rule.
601    pub fn add_rule(&mut self, rule: ReflexRule) {
602        self.rules.push(rule);
603    }
604
605    /// Evaluate all rules against a set of sensor readings.
606    /// Returns commands to execute.
607    pub fn evaluate(&mut self, readings: &[SensorReading]) -> Vec<ActuatorCommand> {
608        let mut commands = Vec::new();
609        let now = Instant::now();
610        let wall_now = now_secs();
611
612        for rule in &self.rules {
613            // Check cooldown
614            if let Some(&last) = self.last_trigger.get(&rule.sensor_id) {
615                let elapsed = now.duration_since(last).as_secs_f64();
616                if elapsed < rule.cooldown_secs {
617                    continue;
618                }
619            }
620
621            // Find matching reading
622            if let Some(reading) = readings.iter().find(|r| r.sensor_id == rule.sensor_id) {
623                if !self.reading_is_fresh(reading, wall_now) {
624                    continue;
625                }
626                if rule.is_triggered(reading) {
627                    commands.push(ActuatorCommand::new(
628                        &rule.actuator_id,
629                        rule.actuator_kind,
630                        rule.command_value,
631                    ));
632                    self.last_trigger.insert(rule.sensor_id.clone(), now);
633                }
634            }
635        }
636
637        commands
638    }
639
640    /// Number of rules.
641    #[must_use]
642    pub fn rule_count(&self) -> usize {
643        self.rules.len()
644    }
645}
646
647impl Default for ReflexLoop {
648    fn default() -> Self {
649        Self::new()
650    }
651}
652
653// ── Linux Hardware Sensors ─────────────────────────────────────────────
654
655/// Sensor that reads a single value from a `/sys` filesystem path.
656///
657/// Common uses:
658/// - `/sys/class/thermal/thermal_zone0/temp` (temperature in millidegrees)
659/// - `/sys/class/hwmon/hwmon0/fan1_input` (fan RPM)
660/// - `/sys/class/power_supply/BAT0/capacity` (battery percentage)
661///
662/// The `scale` field divides the raw integer value (e.g., 55000 → 55.0
663/// for millidegrees → degrees).
664pub struct SysfsSensor {
665    id: String,
666    kind: SensorKind,
667    path: String,
668    scale: f64,
669    available: bool,
670}
671
672impl SysfsSensor {
673    /// Create a new sysfs sensor.
674    ///
675    /// - `path`: absolute path to the sysfs file (e.g., `/sys/class/thermal/thermal_zone0/temp`)
676    /// - `scale`: divisor to convert raw integer to float (e.g., 1000.0 for millidegrees)
677    #[must_use]
678    pub fn new(
679        id: impl Into<String>,
680        kind: SensorKind,
681        path: impl Into<String>,
682        scale: f64,
683    ) -> Self {
684        let path = path.into();
685        let available = std::path::Path::new(&path).exists();
686        Self {
687            id: id.into(),
688            kind,
689            path,
690            scale,
691            available,
692        }
693    }
694
695    /// Create a thermal zone sensor (temperature in °C).
696    #[must_use]
697    pub fn thermal(zone: usize) -> Self {
698        Self::new(
699            format!("thermal_zone{zone}"),
700            SensorKind::Temperature,
701            format!("/sys/class/thermal/thermal_zone{zone}/temp"),
702            1000.0,
703        )
704    }
705
706    /// Create a battery capacity sensor (0.0–100.0).
707    #[must_use]
708    pub fn battery(name: &str) -> Self {
709        Self::new(
710            format!("battery_{name}"),
711            SensorKind::Power,
712            format!("/sys/class/power_supply/{name}/capacity"),
713            1.0,
714        )
715    }
716
717    /// Create a fan speed sensor (RPM).
718    #[must_use]
719    pub fn fan(hwmon: usize, fan: usize) -> Self {
720        Self::new(
721            format!("fan{hwmon}_{fan}"),
722            SensorKind::Custom,
723            format!("/sys/class/hwmon/hwmon{hwmon}/fan{fan}_input"),
724            1.0,
725        )
726    }
727}
728
729impl SensorDevice for SysfsSensor {
730    fn id(&self) -> &str {
731        &self.id
732    }
733
734    fn kind(&self) -> SensorKind {
735        self.kind
736    }
737
738    fn read(&self) -> Option<SensorReading> {
739        if !self.available {
740            return None;
741        }
742        let raw = std::fs::read_to_string(&self.path).ok()?;
743        let trimmed = raw.trim();
744        let value: f64 = trimmed.parse().ok()?;
745        let scaled = if self.scale > 0.0 {
746            value / self.scale
747        } else {
748            value
749        };
750        Some(SensorReading::new(&self.id, self.kind, scaled))
751    }
752
753    fn is_available(&self) -> bool {
754        self.available
755    }
756}
757
758impl std::fmt::Debug for SysfsSensor {
759    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
760        f.debug_struct("SysfsSensor")
761            .field("id", &self.id)
762            .field("kind", &self.kind)
763            .field("path", &self.path)
764            .field("scale", &self.scale)
765            .field("available", &self.available)
766            .finish()
767    }
768}
769
770/// Parser function type for extracting a sensor value from file contents.
771pub type SensorParser = Box<dyn Fn(&str) -> Option<f64> + Send + Sync>;
772
773/// Sensor that reads a value from a `/proc` filesystem file by parsing
774/// a key-value format.
775///
776/// Common uses:
777/// - `/proc/loadavg` — CPU load average (first field)
778/// - `/proc/meminfo` — `MemAvailable:` / `MemTotal:` for memory pressure
779/// - `/proc/stat` — CPU time slices
780pub struct ProcfsSensor {
781    id: String,
782    kind: SensorKind,
783    path: String,
784    /// Parser: extracts the value from the file contents.
785    parser: SensorParser,
786    available: bool,
787}
788
789impl ProcfsSensor {
790    /// Create a new procfs sensor with a custom parser.
791    #[must_use]
792    pub fn new(
793        id: impl Into<String>,
794        kind: SensorKind,
795        path: impl Into<String>,
796        parser: SensorParser,
797    ) -> Self {
798        let path = path.into();
799        let available = std::path::Path::new(&path).exists();
800        Self {
801            id: id.into(),
802            kind,
803            path,
804            parser,
805            available,
806        }
807    }
808
809    /// Create a CPU load average sensor from `/proc/loadavg`.
810    #[must_use]
811    pub fn loadavg() -> Self {
812        Self::new(
813            "cpu_loadavg",
814            SensorKind::Custom,
815            "/proc/loadavg",
816            Box::new(|content: &str| {
817                content
818                    .split_whitespace()
819                    .next()
820                    .and_then(|s| s.parse::<f64>().ok())
821            }),
822        )
823    }
824
825    /// Create a memory pressure sensor from `/proc/meminfo`.
826    ///
827    /// Returns `1.0 - (MemAvailable / MemTotal)`, clamped to [0, 1].
828    #[must_use]
829    pub fn mem_pressure() -> Self {
830        Self::new(
831            "mem_pressure",
832            SensorKind::Custom,
833            "/proc/meminfo",
834            Box::new(|content: &str| {
835                let mut mem_total = None;
836                let mut mem_avail = None;
837                for line in content.lines() {
838                    if line.starts_with("MemTotal:") {
839                        mem_total = parse_proc_kb(line);
840                    } else if line.starts_with("MemAvailable:") {
841                        mem_avail = parse_proc_kb(line);
842                    }
843                }
844                match (mem_total, mem_avail) {
845                    (Some(total), Some(avail)) if total > 0 => {
846                        Some(1.0 - (avail as f64 / total as f64).min(1.0))
847                    }
848                    _ => None,
849                }
850            }),
851        )
852    }
853}
854
855impl SensorDevice for ProcfsSensor {
856    fn id(&self) -> &str {
857        &self.id
858    }
859
860    fn kind(&self) -> SensorKind {
861        self.kind
862    }
863
864    fn read(&self) -> Option<SensorReading> {
865        if !self.available {
866            return None;
867        }
868        let content = std::fs::read_to_string(&self.path).ok()?;
869        let value = (self.parser)(&content)?;
870        Some(SensorReading::new(&self.id, self.kind, value))
871    }
872
873    fn is_available(&self) -> bool {
874        self.available
875    }
876}
877
878impl std::fmt::Debug for ProcfsSensor {
879    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
880        f.debug_struct("ProcfsSensor")
881            .field("id", &self.id)
882            .field("kind", &self.kind)
883            .field("path", &self.path)
884            .field("available", &self.available)
885            .finish_non_exhaustive()
886    }
887}
888
889/// Parse a `/proc/meminfo` line like `MemTotal:       16384000 kB` → kB value.
890fn parse_proc_kb(line: &str) -> Option<u64> {
891    line.split(':')
892        .nth(1)
893        .and_then(|s| s.split_whitespace().next())
894        .and_then(|s| s.parse::<u64>().ok())
895}
896
897// ── CPU Usage Sensor ───────────────────────────────────────────────────
898
899/// Sensor that reads CPU usage percentage from `/proc/stat`.
900///
901/// Computes the percentage by comparing idle vs. total time slices
902/// between consecutive reads. The first read returns 0% (no baseline yet).
903/// Returns a value in [0.0, 100.0] representing the aggregate CPU usage.
904pub struct CpuUsageSensor {
905    prev_idle: Option<u64>,
906    prev_total: Option<u64>,
907}
908
909impl CpuUsageSensor {
910    #[must_use]
911    pub const fn new() -> Self {
912        Self {
913            prev_idle: None,
914            prev_total: None,
915        }
916    }
917}
918
919impl Default for CpuUsageSensor {
920    fn default() -> Self {
921        Self::new()
922    }
923}
924
925impl SensorDevice for CpuUsageSensor {
926    fn id(&self) -> &str {
927        "cpu_usage"
928    }
929
930    fn kind(&self) -> SensorKind {
931        SensorKind::Custom
932    }
933
934    fn read(&self) -> Option<SensorReading> {
935        let content = std::fs::read_to_string("/proc/stat").ok()?;
936        let first_line = content.lines().next()?;
937        if !first_line.starts_with("cpu ") {
938            return None;
939        }
940        let fields: Vec<u64> = first_line
941            .split_whitespace()
942            .skip(1)
943            .filter_map(|s| s.parse::<u64>().ok())
944            .collect();
945        if fields.len() < 4 {
946            return None;
947        }
948        let idle = fields[3];
949        let total: u64 = fields.iter().sum();
950        let usage = match (self.prev_idle, self.prev_total) {
951            (Some(prev_idle), Some(prev_total)) => {
952                let idle_delta = idle.saturating_sub(prev_idle) as f64;
953                let total_delta = total.saturating_sub(prev_total) as f64;
954                if total_delta > 0.0 {
955                    ((1.0 - idle_delta / total_delta) * 100.0).clamp(0.0, 100.0)
956                } else {
957                    0.0
958                }
959            }
960            _ => 0.0,
961        };
962        Some(SensorReading::new("cpu_usage", SensorKind::Custom, usage))
963    }
964
965    fn is_available(&self) -> bool {
966        std::path::Path::new("/proc/stat").exists()
967    }
968}
969
970impl std::fmt::Debug for CpuUsageSensor {
971    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
972        f.debug_struct("CpuUsageSensor")
973            .field("prev_idle", &self.prev_idle)
974            .field("prev_total", &self.prev_total)
975            .finish()
976    }
977}
978
979// ── Disk Usage Sensor ──────────────────────────────────────────────────
980
981/// Sensor that reads disk usage percentage for a given mount point.
982///
983/// Uses `statvfs` to determine the fraction of used space.
984/// Returns a value in [0.0, 100.0].
985pub struct DiskUsageSensor {
986    id: String,
987    path: String,
988    available: bool,
989}
990
991impl DiskUsageSensor {
992    #[must_use]
993    pub fn new(id: impl Into<String>, path: impl Into<String>) -> Self {
994        let path = path.into();
995        let available = std::path::Path::new(&path).exists();
996        Self {
997            id: id.into(),
998            path,
999            available,
1000        }
1001    }
1002
1003    /// Create a root filesystem usage sensor.
1004    #[must_use]
1005    pub fn root() -> Self {
1006        Self::new("disk_root", "/")
1007    }
1008}
1009
1010impl SensorDevice for DiskUsageSensor {
1011    fn id(&self) -> &str {
1012        &self.id
1013    }
1014
1015    fn kind(&self) -> SensorKind {
1016        SensorKind::Custom
1017    }
1018
1019    fn read(&self) -> Option<SensorReading> {
1020        if !self.available {
1021            return None;
1022        }
1023        let output = std::process::Command::new("df")
1024            .arg("-P")
1025            .arg(&self.path)
1026            .output()
1027            .ok()?;
1028        let stdout = String::from_utf8(output.stdout).ok()?;
1029        let line = stdout.lines().nth(1)?;
1030        let fields: Vec<&str> = line.split_whitespace().collect();
1031        if fields.len() < 5 {
1032            return None;
1033        }
1034        let used: f64 = fields[2].parse().ok()?;
1035        let total: f64 = fields[1].parse().ok()?;
1036        if total > 0.0 {
1037            let pct = (used / total) * 100.0;
1038            Some(SensorReading::new(&self.id, SensorKind::Custom, pct))
1039        } else {
1040            None
1041        }
1042    }
1043
1044    fn is_available(&self) -> bool {
1045        self.available
1046    }
1047}
1048
1049impl std::fmt::Debug for DiskUsageSensor {
1050    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1051        f.debug_struct("DiskUsageSensor")
1052            .field("id", &self.id)
1053            .field("path", &self.path)
1054            .field("available", &self.available)
1055            .finish()
1056    }
1057}
1058
1059// ── Network Throughput Sensor ──────────────────────────────────────────
1060
1061/// Sensor that reads network throughput from `/proc/net/dev`.
1062///
1063/// Returns bytes/sec since the last read. The first read returns 0.
1064/// Monitors a specific interface (e.g., "eth0", "wlan0").
1065pub struct NetworkThroughputSensor {
1066    id: String,
1067    interface: String,
1068    prev_rx_bytes: Option<u64>,
1069    prev_tx_bytes: Option<u64>,
1070    prev_time: Option<Instant>,
1071}
1072
1073impl NetworkThroughputSensor {
1074    #[must_use]
1075    pub fn new(interface: impl Into<String>) -> Self {
1076        let iface = interface.into();
1077        Self {
1078            id: format!("net_{iface}"),
1079            interface: iface,
1080            prev_rx_bytes: None,
1081            prev_tx_bytes: None,
1082            prev_time: None,
1083        }
1084    }
1085
1086    /// Auto-detect the default network interface by reading /proc/net/dev
1087    /// and picking the first non-lo interface.
1088    #[must_use]
1089    pub fn default_interface() -> Self {
1090        let iface = if let Ok(content) = std::fs::read_to_string("/proc/net/dev") {
1091            content
1092                .lines()
1093                .skip(2)
1094                .find_map(|line| {
1095                    let name = line.split(':').next()?.trim();
1096                    if name != "lo" && !name.is_empty() {
1097                        Some(name.to_string())
1098                    } else {
1099                        None
1100                    }
1101                })
1102                .unwrap_or_else(|| "eth0".to_string())
1103        } else {
1104            "eth0".to_string()
1105        };
1106        Self::new(iface)
1107    }
1108}
1109
1110impl SensorDevice for NetworkThroughputSensor {
1111    fn id(&self) -> &str {
1112        &self.id
1113    }
1114
1115    fn kind(&self) -> SensorKind {
1116        SensorKind::Custom
1117    }
1118
1119    fn read(&self) -> Option<SensorReading> {
1120        let content = std::fs::read_to_string("/proc/net/dev").ok()?;
1121        for line in content.lines().skip(2) {
1122            let mut parts = line.split(':');
1123            let name = parts.next()?.trim();
1124            if name != self.interface {
1125                continue;
1126            }
1127            let stats: Vec<u64> = parts
1128                .next()?
1129                .split_whitespace()
1130                .filter_map(|s| s.parse::<u64>().ok())
1131                .collect();
1132            if stats.len() < 9 {
1133                return None;
1134            }
1135            let rx_bytes = stats[0];
1136            let tx_bytes = stats[8];
1137            let now = Instant::now();
1138            let throughput = match (self.prev_rx_bytes, self.prev_tx_bytes, self.prev_time) {
1139                (Some(prev_rx), Some(prev_tx), Some(prev_t)) => {
1140                    let elapsed = now.duration_since(prev_t).as_secs_f64();
1141                    if elapsed > 0.0 {
1142                        let rx_delta = rx_bytes.saturating_sub(prev_rx) as f64;
1143                        let tx_delta = tx_bytes.saturating_sub(prev_tx) as f64;
1144                        (rx_delta + tx_delta) / elapsed
1145                    } else {
1146                        0.0
1147                    }
1148                }
1149                _ => 0.0,
1150            };
1151            return Some(SensorReading::new(&self.id, SensorKind::Custom, throughput));
1152        }
1153        None
1154    }
1155
1156    fn is_available(&self) -> bool {
1157        std::path::Path::new("/proc/net/dev").exists()
1158    }
1159}
1160
1161impl std::fmt::Debug for NetworkThroughputSensor {
1162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1163        f.debug_struct("NetworkThroughputSensor")
1164            .field("id", &self.id)
1165            .field("interface", &self.interface)
1166            .finish_non_exhaustive()
1167    }
1168}
1169
1170// ── CPU Frequency Sensor ───────────────────────────────────────────────
1171
1172/// Sensor that reads CPU frequency from `/sys/devices/system/cpu`.
1173///
1174/// Reads the current frequency for a given CPU core.
1175/// Returns frequency in MHz.
1176pub struct CpuFreqSensor {
1177    id: String,
1178    path: String,
1179    available: bool,
1180}
1181
1182impl CpuFreqSensor {
1183    #[must_use]
1184    pub fn new(core: usize) -> Self {
1185        let path = format!("/sys/devices/system/cpu/cpu{core}/cpufreq/scaling_cur_freq");
1186        let available = std::path::Path::new(&path).exists();
1187        Self {
1188            id: format!("cpu{core}_freq"),
1189            path,
1190            available,
1191        }
1192    }
1193}
1194
1195impl SensorDevice for CpuFreqSensor {
1196    fn id(&self) -> &str {
1197        &self.id
1198    }
1199
1200    fn kind(&self) -> SensorKind {
1201        SensorKind::Custom
1202    }
1203
1204    fn read(&self) -> Option<SensorReading> {
1205        if !self.available {
1206            return None;
1207        }
1208        let raw = std::fs::read_to_string(&self.path).ok()?;
1209        let khz: f64 = raw.trim().parse().ok()?;
1210        Some(SensorReading::new(
1211            &self.id,
1212            SensorKind::Custom,
1213            khz / 1000.0,
1214        ))
1215    }
1216
1217    fn is_available(&self) -> bool {
1218        self.available
1219    }
1220}
1221
1222impl std::fmt::Debug for CpuFreqSensor {
1223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1224        f.debug_struct("CpuFreqSensor")
1225            .field("id", &self.id)
1226            .field("path", &self.path)
1227            .field("available", &self.available)
1228            .finish()
1229    }
1230}
1231
1232// ── Sysfs Actuator ─────────────────────────────────────────────────────
1233
1234/// Actuator that writes a value to a `/sys` filesystem file.
1235///
1236/// Common uses:
1237/// - Fan PWM control: `/sys/class/hwmon/hwmon0/pwm1` (0–255)
1238/// - LED brightness: `/sys/class/leds/led0/brightness` (0–255)
1239/// - CPU governor: `/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor`
1240///
1241/// The `scale` field multiplies the command value before writing
1242/// (e.g., 1.0 for direct, 255.0 if command is 0.0–1.0 and output is 0–255).
1243pub struct SysfsActuator {
1244    id: String,
1245    kind: ActuatorKind,
1246    path: String,
1247    scale: f64,
1248    available: bool,
1249}
1250
1251impl SysfsActuator {
1252    /// Create a new sysfs actuator.
1253    ///
1254    /// - `path`: absolute path to the sysfs file
1255    /// - `scale`: multiplier to convert command value to raw output
1256    #[must_use]
1257    pub fn new(
1258        id: impl Into<String>,
1259        kind: ActuatorKind,
1260        path: impl Into<String>,
1261        scale: f64,
1262    ) -> Self {
1263        let path = path.into();
1264        let available = std::path::Path::new(&path).exists();
1265        Self {
1266            id: id.into(),
1267            kind,
1268            path,
1269            scale,
1270            available,
1271        }
1272    }
1273
1274    /// Create a fan PWM controller for hwmon device.
1275    #[must_use]
1276    pub fn fan_pwm(hwmon: usize, pwm: usize) -> Self {
1277        Self::new(
1278            format!("fan_pwm{hwmon}_{pwm}"),
1279            ActuatorKind::Motor,
1280            format!("/sys/class/hwmon/hwmon{hwmon}/pwm{pwm}"),
1281            1.0,
1282        )
1283    }
1284
1285    /// Create an LED brightness controller.
1286    #[must_use]
1287    pub fn led(name: &str) -> Self {
1288        Self::new(
1289            format!("led_{name}"),
1290            ActuatorKind::Display,
1291            format!("/sys/class/leds/{name}/brightness"),
1292            1.0,
1293        )
1294    }
1295}
1296
1297impl ActuatorDevice for SysfsActuator {
1298    fn id(&self) -> &str {
1299        &self.id
1300    }
1301
1302    fn kind(&self) -> ActuatorKind {
1303        self.kind
1304    }
1305
1306    fn command(&self, cmd: &ActuatorCommand) -> Result<(), String> {
1307        if !self.available {
1308            return Err(format!("actuator '{}' path not available", self.id));
1309        }
1310        let raw_value = cmd.value * self.scale;
1311        let output = format!("{}", raw_value.round() as i64);
1312        std::fs::write(&self.path, output)
1313            .map_err(|e| format!("failed to write to {}: {e}", self.path))
1314    }
1315
1316    fn is_available(&self) -> bool {
1317        self.available
1318    }
1319
1320    fn e_stop(&self) -> Result<(), String> {
1321        if !self.available {
1322            return Err(format!("actuator '{}' path not available", self.id));
1323        }
1324        // Write 0 to disable
1325        std::fs::write(&self.path, "0").map_err(|e| format!("failed to e-stop {}: {e}", self.path))
1326    }
1327}
1328
1329impl std::fmt::Debug for SysfsActuator {
1330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1331        f.debug_struct("SysfsActuator")
1332            .field("id", &self.id)
1333            .field("kind", &self.kind)
1334            .field("path", &self.path)
1335            .field("scale", &self.scale)
1336            .field("available", &self.available)
1337            .finish()
1338    }
1339}
1340
1341// ── Actuator Discovery ─────────────────────────────────────────────────
1342
1343/// Auto-discover fan PWM controllers on Linux.
1344///
1345/// Scans `/sys/class/hwmon/hwmon*/pwm*` for writable PWM entries.
1346#[must_use]
1347pub fn discover_fan_actuators() -> Vec<SysfsActuator> {
1348    let mut actuators = Vec::new();
1349    for hwmon in 0..8 {
1350        for pwm in 1..=4 {
1351            let actuator = SysfsActuator::fan_pwm(hwmon, pwm);
1352            if actuator.is_available() {
1353                actuators.push(actuator);
1354            }
1355        }
1356    }
1357    actuators
1358}
1359
1360/// Auto-discover LED controllers on Linux.
1361///
1362/// Scans common LED names in `/sys/class/leds/`.
1363#[must_use]
1364pub fn discover_led_actuators() -> Vec<SysfsActuator> {
1365    let mut actuators = Vec::new();
1366    for name in [
1367        "input0::scrolllock",
1368        "input0::numlock",
1369        "input0::capslock",
1370        "mmc0::",
1371        "phy0-led",
1372        "eth0-link",
1373        "power",
1374        "charging",
1375        "disk-activity",
1376    ] {
1377        let actuator = SysfsActuator::led(name);
1378        if actuator.is_available() {
1379            actuators.push(actuator);
1380        }
1381    }
1382    actuators
1383}
1384
1385/// Auto-discover available thermal zones on Linux.
1386///
1387/// Scans `/sys/class/thermal/thermal_zone*` and returns sensors for each.
1388#[must_use]
1389pub fn discover_thermal_sensors() -> Vec<SysfsSensor> {
1390    let mut sensors = Vec::new();
1391    for i in 0..16 {
1392        let sensor = SysfsSensor::thermal(i);
1393        if sensor.is_available() {
1394            sensors.push(sensor);
1395        }
1396    }
1397    sensors
1398}
1399
1400/// Auto-discover battery sensors on Linux.
1401///
1402/// Scans `/sys/class/power_supply/BAT*` and returns sensors for each.
1403#[must_use]
1404pub fn discover_battery_sensors() -> Vec<SysfsSensor> {
1405    let mut sensors = Vec::new();
1406    for name in ["BAT0", "BAT1", "BAT2", "BAT3", "BATC", "BATT"] {
1407        let sensor = SysfsSensor::battery(name);
1408        if sensor.is_available() {
1409            sensors.push(sensor);
1410        }
1411    }
1412    sensors
1413}
1414
1415/// Create a `SensorimotorBus` pre-populated with all discovered Linux hardware sensors.
1416///
1417/// On non-Linux platforms, returns an empty bus.
1418#[must_use]
1419pub fn linux_hardware_bus() -> SensorimotorBus {
1420    let mut bus = SensorimotorBus::new(256);
1421
1422    // Thermal sensors
1423    for sensor in discover_thermal_sensors() {
1424        bus.register_sensor(Box::new(sensor));
1425    }
1426
1427    // Battery sensors
1428    for sensor in discover_battery_sensors() {
1429        bus.register_sensor(Box::new(sensor));
1430    }
1431
1432    // /proc sensors (Linux only)
1433    if cfg!(target_os = "linux") {
1434        if std::path::Path::new("/proc/loadavg").exists() {
1435            bus.register_sensor(Box::new(ProcfsSensor::loadavg()));
1436        }
1437        if std::path::Path::new("/proc/meminfo").exists() {
1438            bus.register_sensor(Box::new(ProcfsSensor::mem_pressure()));
1439        }
1440        if std::path::Path::new("/proc/stat").exists() {
1441            bus.register_sensor(Box::new(CpuUsageSensor::new()));
1442        }
1443        if std::path::Path::new("/proc/net/dev").exists() {
1444            bus.register_sensor(Box::new(NetworkThroughputSensor::default_interface()));
1445        }
1446    }
1447
1448    // Disk usage sensor
1449    if std::path::Path::new("/").exists() {
1450        bus.register_sensor(Box::new(DiskUsageSensor::root()));
1451    }
1452
1453    // CPU frequency sensors (up to 16 cores)
1454    for core in 0..16 {
1455        let sensor = CpuFreqSensor::new(core);
1456        if sensor.is_available() {
1457            bus.register_sensor(Box::new(sensor));
1458        }
1459    }
1460
1461    // Fan PWM actuators
1462    for actuator in discover_fan_actuators() {
1463        bus.register_actuator(Box::new(actuator));
1464    }
1465
1466    // LED actuators
1467    for actuator in discover_led_actuators() {
1468        bus.register_actuator(Box::new(actuator));
1469    }
1470
1471    bus
1472}
1473
1474// ── Helpers ────────────────────────────────────────────────────────────
1475
1476/// Get current time in seconds (Unix timestamp).
1477fn now_secs() -> f64 {
1478    std::time::SystemTime::now()
1479        .duration_since(std::time::UNIX_EPOCH)
1480        .map_or(0.0, |d| d.as_secs_f64())
1481}
1482
1483// ── Tests ──────────────────────────────────────────────────────────────
1484
1485#[cfg(test)]
1486mod tests {
1487    use super::*;
1488
1489    #[test]
1490    fn sensor_kind_as_str() {
1491        assert_eq!(SensorKind::Temperature.as_str(), "temperature");
1492        assert_eq!(SensorKind::Imu.as_str(), "imu");
1493        assert_eq!(SensorKind::Camera.as_str(), "camera");
1494    }
1495
1496    #[test]
1497    fn actuator_kind_as_str() {
1498        assert_eq!(ActuatorKind::Motor.as_str(), "motor");
1499        assert_eq!(ActuatorKind::Relay.as_str(), "relay");
1500    }
1501
1502    #[test]
1503    fn sensor_reading_new() {
1504        let r = SensorReading::new("cpu_temp", SensorKind::Temperature, 55.0);
1505        assert_eq!(r.sensor_id, "cpu_temp");
1506        assert_eq!(r.kind, SensorKind::Temperature);
1507        assert!((r.value - 55.0).abs() < f64::EPSILON);
1508        assert!((r.confidence - 1.0).abs() < f64::EPSILON);
1509        assert!(r.extra.is_empty());
1510    }
1511
1512    #[test]
1513    fn sensor_reading_with_extra() {
1514        let r = SensorReading::new("imu0", SensorKind::Imu, 0.0).with_extra(vec![1.0, 2.0, 3.0]);
1515        assert_eq!(r.extra, vec![1.0, 2.0, 3.0]);
1516    }
1517
1518    #[test]
1519    fn sensor_reading_with_confidence() {
1520        let r = SensorReading::new("cam0", SensorKind::Camera, 128.0).with_confidence(0.8);
1521        assert!((r.confidence - 0.8).abs() < f64::EPSILON);
1522    }
1523
1524    #[test]
1525    fn sensor_reading_confidence_clamped() {
1526        let r = SensorReading::new("s0", SensorKind::Custom, 0.0).with_confidence(1.5);
1527        assert!((r.confidence - 1.0).abs() < f64::EPSILON);
1528    }
1529
1530    #[test]
1531    fn actuator_command_new() {
1532        let c = ActuatorCommand::new("motor_l", ActuatorKind::Motor, 0.5);
1533        assert_eq!(c.actuator_id, "motor_l");
1534        assert_eq!(c.kind, ActuatorKind::Motor);
1535        assert!((c.value - 0.5).abs() < f64::EPSILON);
1536    }
1537
1538    #[test]
1539    fn actuator_command_with_params() {
1540        let c =
1541            ActuatorCommand::new("motor_r", ActuatorKind::Motor, 1.0).with_params(vec![0.1, 2.0]);
1542        assert_eq!(c.params, vec![0.1, 2.0]);
1543    }
1544
1545    #[test]
1546    fn stub_sensor_read() {
1547        let s = StubSensor::new("temp0", SensorKind::Temperature, 42.0);
1548        assert_eq!(s.id(), "temp0");
1549        assert_eq!(s.kind(), SensorKind::Temperature);
1550        let r = s.read().unwrap();
1551        assert!((r.value - 42.0).abs() < f64::EPSILON);
1552    }
1553
1554    #[test]
1555    fn stub_actuator_command() {
1556        let a = StubActuator::new("motor0", ActuatorKind::Motor);
1557        let cmd = ActuatorCommand::new("motor0", ActuatorKind::Motor, 0.7);
1558        a.command(&cmd).unwrap();
1559        let last = a.last_command().unwrap();
1560        assert!((last.value - 0.7).abs() < f64::EPSILON);
1561    }
1562
1563    #[test]
1564    fn bus_register_and_poll() {
1565        let mut bus = SensorimotorBus::new(64);
1566        bus.register_sensor(Box::new(StubSensor::new(
1567            "s1",
1568            SensorKind::Temperature,
1569            50.0,
1570        )));
1571        bus.register_sensor(Box::new(StubSensor::new("s2", SensorKind::Distance, 1.5)));
1572
1573        assert_eq!(bus.sensor_count(), 2);
1574        let readings = bus.poll_all();
1575        assert_eq!(readings.len(), 2);
1576        assert_eq!(bus.readings_collected(), 2);
1577    }
1578
1579    #[test]
1580    fn bus_send_command() {
1581        let mut bus = SensorimotorBus::new(64);
1582        bus.register_actuator(Box::new(StubActuator::new("m1", ActuatorKind::Motor)));
1583
1584        let cmd = ActuatorCommand::new("m1", ActuatorKind::Motor, 0.5);
1585        bus.send_command(&cmd).unwrap();
1586        assert_eq!(bus.commands_sent(), 1);
1587    }
1588
1589    #[test]
1590    fn bus_command_unknown_actuator() {
1591        let mut bus = SensorimotorBus::new(64);
1592        let cmd = ActuatorCommand::new("unknown", ActuatorKind::Motor, 0.0);
1593        assert!(bus.send_command(&cmd).is_err());
1594    }
1595
1596    #[test]
1597    fn bus_history() {
1598        let mut bus = SensorimotorBus::new(3);
1599        bus.register_sensor(Box::new(StubSensor::new(
1600            "s1",
1601            SensorKind::Temperature,
1602            50.0,
1603        )));
1604
1605        bus.poll_all();
1606        bus.poll_all();
1607        bus.poll_all();
1608        bus.poll_all(); // 4 polls, history caps at 3
1609
1610        let h = bus.history();
1611        assert_eq!(h.len(), 3);
1612    }
1613
1614    #[test]
1615    fn bus_sensor_ids() {
1616        let mut bus = SensorimotorBus::new(64);
1617        bus.register_sensor(Box::new(StubSensor::new("a", SensorKind::Temperature, 0.0)));
1618        bus.register_sensor(Box::new(StubSensor::new("b", SensorKind::Imu, 0.0)));
1619
1620        let ids = bus.sensor_ids();
1621        assert!(ids.contains(&"a".to_string()));
1622        assert!(ids.contains(&"b".to_string()));
1623    }
1624
1625    #[test]
1626    fn bus_actuator_ids() {
1627        let mut bus = SensorimotorBus::new(64);
1628        bus.register_actuator(Box::new(StubActuator::new("m1", ActuatorKind::Motor)));
1629
1630        let ids = bus.actuator_ids();
1631        assert!(ids.contains(&"m1".to_string()));
1632    }
1633
1634    #[test]
1635    fn bus_e_stop_all() {
1636        let mut bus = SensorimotorBus::new(64);
1637        bus.register_actuator(Box::new(StubActuator::new("m1", ActuatorKind::Motor)));
1638        bus.register_actuator(Box::new(StubActuator::new("m2", ActuatorKind::Motor)));
1639
1640        let errors = bus.e_stop_all();
1641        assert!(errors.is_empty()); // Stubs don't error
1642    }
1643
1644    #[test]
1645    fn reflex_rule_above() {
1646        let rule = ReflexRule::above("temp0", "fan0", ActuatorKind::Motor, 70.0, 1.0, 5.0);
1647        let reading_high = SensorReading::new("temp0", SensorKind::Temperature, 75.0);
1648        let reading_low = SensorReading::new("temp0", SensorKind::Temperature, 60.0);
1649
1650        assert!(rule.is_triggered(&reading_high));
1651        assert!(!rule.is_triggered(&reading_low));
1652    }
1653
1654    #[test]
1655    fn reflex_rule_below() {
1656        let rule = ReflexRule::below("battery", "led", ActuatorKind::Display, 20.0, 1.0, 10.0);
1657        let reading_low = SensorReading::new("battery", SensorKind::Power, 15.0);
1658        let reading_ok = SensorReading::new("battery", SensorKind::Power, 80.0);
1659
1660        assert!(rule.is_triggered(&reading_low));
1661        assert!(!rule.is_triggered(&reading_ok));
1662    }
1663
1664    #[test]
1665    fn reflex_loop_evaluate() {
1666        let mut loop_ = ReflexLoop::new();
1667        loop_.add_rule(ReflexRule::above(
1668            "temp0",
1669            "fan0",
1670            ActuatorKind::Motor,
1671            70.0,
1672            1.0,
1673            0.0, // No cooldown for test
1674        ));
1675
1676        let readings = vec![SensorReading::new("temp0", SensorKind::Temperature, 75.0)];
1677        let commands = loop_.evaluate(&readings);
1678        assert_eq!(commands.len(), 1);
1679        assert_eq!(commands[0].actuator_id, "fan0");
1680    }
1681
1682    #[test]
1683    fn reflex_loop_no_trigger() {
1684        let mut loop_ = ReflexLoop::new();
1685        loop_.add_rule(ReflexRule::above(
1686            "temp0",
1687            "fan0",
1688            ActuatorKind::Motor,
1689            70.0,
1690            1.0,
1691            0.0,
1692        ));
1693
1694        let readings = vec![SensorReading::new("temp0", SensorKind::Temperature, 60.0)];
1695        let commands = loop_.evaluate(&readings);
1696        assert!(commands.is_empty());
1697    }
1698
1699    #[test]
1700    fn reflex_loop_cooldown() {
1701        let mut loop_ = ReflexLoop::new();
1702        loop_.add_rule(ReflexRule::above(
1703            "temp0",
1704            "fan0",
1705            ActuatorKind::Motor,
1706            70.0,
1707            1.0,
1708            100.0, // 100s cooldown
1709        ));
1710
1711        let readings = vec![SensorReading::new("temp0", SensorKind::Temperature, 80.0)];
1712
1713        // First trigger
1714        let cmds1 = loop_.evaluate(&readings);
1715        assert_eq!(cmds1.len(), 1);
1716
1717        // Second trigger — should be on cooldown
1718        let cmds2 = loop_.evaluate(&readings);
1719        assert!(cmds2.is_empty());
1720    }
1721
1722    #[test]
1723    fn reflex_loop_multiple_rules() {
1724        let mut loop_ = ReflexLoop::new();
1725        loop_.add_rule(ReflexRule::above(
1726            "temp0",
1727            "fan0",
1728            ActuatorKind::Motor,
1729            70.0,
1730            1.0,
1731            0.0,
1732        ));
1733        loop_.add_rule(ReflexRule::below(
1734            "battery",
1735            "led0",
1736            ActuatorKind::Display,
1737            20.0,
1738            1.0,
1739            0.0,
1740        ));
1741
1742        let readings = vec![
1743            SensorReading::new("temp0", SensorKind::Temperature, 75.0),
1744            SensorReading::new("battery", SensorKind::Power, 15.0),
1745        ];
1746
1747        let commands = loop_.evaluate(&readings);
1748        assert_eq!(commands.len(), 2);
1749    }
1750
1751    #[test]
1752    fn reflex_loop_rule_count() {
1753        let mut loop_ = ReflexLoop::new();
1754        assert_eq!(loop_.rule_count(), 0);
1755        loop_.add_rule(ReflexRule::above(
1756            "s",
1757            "a",
1758            ActuatorKind::Motor,
1759            1.0,
1760            1.0,
1761            1.0,
1762        ));
1763        assert_eq!(loop_.rule_count(), 1);
1764    }
1765
1766    #[test]
1767    fn reflex_loop_refuses_stale_readings() {
1768        let mut loop_ = ReflexLoop::new();
1769        loop_.add_rule(ReflexRule::above(
1770            "temp0",
1771            "fan0",
1772            ActuatorKind::Motor,
1773            70.0,
1774            1.0,
1775            0.0,
1776        ));
1777
1778        let mut reading = SensorReading::new("temp0", SensorKind::Temperature, 90.0);
1779        reading.timestamp = now_secs() - 3600.0;
1780        assert!(
1781            loop_.evaluate(&[reading]).is_empty(),
1782            "a one-hour-old reading must not actuate"
1783        );
1784    }
1785
1786    #[test]
1787    fn reflex_loop_refuses_future_timestamps() {
1788        let mut loop_ = ReflexLoop::new();
1789        loop_.add_rule(ReflexRule::above(
1790            "temp0",
1791            "fan0",
1792            ActuatorKind::Motor,
1793            70.0,
1794            1.0,
1795            0.0,
1796        ));
1797
1798        let mut reading = SensorReading::new("temp0", SensorKind::Temperature, 90.0);
1799        reading.timestamp = now_secs() + 3600.0;
1800        assert!(
1801            loop_.evaluate(&[reading]).is_empty(),
1802            "a future-dated reading (clock skew) must not actuate"
1803        );
1804    }
1805
1806    #[test]
1807    fn reflex_loop_max_age_is_configurable() {
1808        let mut loop_ = ReflexLoop::new().with_max_reading_age(0.0);
1809        loop_.add_rule(ReflexRule::above(
1810            "temp0",
1811            "fan0",
1812            ActuatorKind::Motor,
1813            70.0,
1814            1.0,
1815            0.0,
1816        ));
1817
1818        let mut reading = SensorReading::new("temp0", SensorKind::Temperature, 90.0);
1819        reading.timestamp = now_secs() - 3600.0;
1820        assert_eq!(
1821            loop_.evaluate(&[reading]).len(),
1822            1,
1823            "a non-positive bound disables the freshness check (tests only)"
1824        );
1825    }
1826
1827    #[test]
1828    fn bus_default() {
1829        let bus = SensorimotorBus::default();
1830        assert_eq!(bus.sensor_count(), 0);
1831        assert_eq!(bus.actuator_count(), 0);
1832    }
1833
1834    #[test]
1835    fn bus_read_specific_sensor() {
1836        let mut bus = SensorimotorBus::new(64);
1837        bus.register_sensor(Box::new(StubSensor::new(
1838            "s1",
1839            SensorKind::Temperature,
1840            42.0,
1841        )));
1842
1843        let r = bus.read_sensor("s1").unwrap();
1844        assert!((r.value - 42.0).abs() < f64::EPSILON);
1845
1846        assert!(bus.read_sensor("nonexistent").is_none());
1847    }
1848
1849    #[test]
1850    fn sensor_kind_display() {
1851        assert_eq!(format!("{}", SensorKind::Temperature), "temperature");
1852        assert_eq!(format!("{}", ActuatorKind::Motor), "motor");
1853    }
1854
1855    // ── SysfsSensor tests ──────────────────────────────────────────────
1856
1857    #[test]
1858    fn sysfs_sensor_thermal_construction() {
1859        let sensor = SysfsSensor::thermal(0);
1860        assert_eq!(sensor.id(), "thermal_zone0");
1861        assert_eq!(sensor.kind(), SensorKind::Temperature);
1862    }
1863
1864    #[test]
1865    fn sysfs_sensor_battery_construction() {
1866        let sensor = SysfsSensor::battery("BAT0");
1867        assert_eq!(sensor.id(), "battery_BAT0");
1868        assert_eq!(sensor.kind(), SensorKind::Power);
1869    }
1870
1871    #[test]
1872    fn sysfs_sensor_fan_construction() {
1873        let sensor = SysfsSensor::fan(0, 1);
1874        assert_eq!(sensor.id(), "fan0_1");
1875        assert_eq!(sensor.kind(), SensorKind::Custom);
1876    }
1877
1878    #[test]
1879    fn sysfs_sensor_nonexistent_path() {
1880        let sensor = SysfsSensor::new(
1881            "test",
1882            SensorKind::Temperature,
1883            "/nonexistent/path/that/does/not/exist",
1884            1000.0,
1885        );
1886        assert!(!sensor.is_available());
1887        assert!(sensor.read().is_none());
1888    }
1889
1890    #[test]
1891    fn sysfs_sensor_debug_format() {
1892        let sensor = SysfsSensor::thermal(0);
1893        let debug = format!("{sensor:?}");
1894        assert!(debug.contains("SysfsSensor"));
1895        assert!(debug.contains("thermal_zone0"));
1896    }
1897
1898    // ── ProcfsSensor tests ─────────────────────────────────────────────
1899
1900    #[test]
1901    fn procfs_sensor_loadavg_construction() {
1902        let sensor = ProcfsSensor::loadavg();
1903        assert_eq!(sensor.id(), "cpu_loadavg");
1904        assert_eq!(sensor.kind(), SensorKind::Custom);
1905    }
1906
1907    #[test]
1908    fn procfs_sensor_mem_pressure_construction() {
1909        let sensor = ProcfsSensor::mem_pressure();
1910        assert_eq!(sensor.id(), "mem_pressure");
1911        assert_eq!(sensor.kind(), SensorKind::Custom);
1912    }
1913
1914    #[test]
1915    fn procfs_sensor_nonexistent_path() {
1916        let sensor = ProcfsSensor::new(
1917            "test",
1918            SensorKind::Custom,
1919            "/nonexistent/proc/path",
1920            Box::new(|_| Some(1.0)),
1921        );
1922        assert!(!sensor.is_available());
1923        assert!(sensor.read().is_none());
1924    }
1925
1926    #[test]
1927    fn procfs_sensor_debug_format() {
1928        let sensor = ProcfsSensor::loadavg();
1929        let debug = format!("{sensor:?}");
1930        assert!(debug.contains("ProcfsSensor"));
1931        assert!(debug.contains("cpu_loadavg"));
1932    }
1933
1934    #[test]
1935    fn procfs_sensor_custom_parser() {
1936        let sensor = ProcfsSensor::new(
1937            "test_parser",
1938            SensorKind::Custom,
1939            "/proc/loadavg",
1940            Box::new(|content: &str| {
1941                content.split_whitespace().next().and_then(|s| {
1942                    let v: f64 = s.parse().ok()?;
1943                    Some(v * 2.0)
1944                })
1945            }),
1946        );
1947        // Only test if /proc/loadavg exists
1948        if sensor.is_available() {
1949            let reading = sensor.read().unwrap();
1950            assert!(reading.value > 0.0);
1951        }
1952    }
1953
1954    // ── Discovery tests ────────────────────────────────────────────────
1955
1956    #[test]
1957    fn discover_thermal_sensors_returns_vec() {
1958        let sensors = discover_thermal_sensors();
1959        // On Linux, typically at least 1 thermal zone exists
1960        // On non-Linux, returns empty vec
1961        for s in &sensors {
1962            assert!(s.is_available());
1963            assert_eq!(s.kind(), SensorKind::Temperature);
1964        }
1965    }
1966
1967    #[test]
1968    fn discover_battery_sensors_returns_vec() {
1969        let sensors = discover_battery_sensors();
1970        for s in &sensors {
1971            assert!(s.is_available());
1972            assert_eq!(s.kind(), SensorKind::Power);
1973        }
1974    }
1975
1976    // ── linux_hardware_bus tests ───────────────────────────────────────
1977
1978    #[test]
1979    fn linux_hardware_bus_creation() {
1980        let bus = linux_hardware_bus();
1981        // On Linux with /proc, should have at least the loadavg + mem_pressure sensors
1982        let sensor_ids = bus.sensor_ids();
1983        // Just verify it doesn't panic and returns a valid bus
1984        assert!(bus.sensor_count() <= 50); // reasonable upper bound
1985        for id in &sensor_ids {
1986            assert!(!id.is_empty());
1987        }
1988    }
1989
1990    #[test]
1991    fn linux_hardware_bus_poll_all() {
1992        let mut bus = linux_hardware_bus();
1993        let readings = bus.poll_all();
1994        // Readings may be empty on non-Linux, but should not panic
1995        for r in &readings {
1996            assert!(!r.sensor_id.is_empty());
1997        }
1998    }
1999
2000    // ── parse_proc_kb tests ────────────────────────────────────────────
2001
2002    #[test]
2003    fn parse_proc_kb_extracts_value() {
2004        assert_eq!(
2005            parse_proc_kb("MemTotal:       16384000 kB"),
2006            Some(16_384_000)
2007        );
2008        assert_eq!(parse_proc_kb("MemAvailable:   8192000 kB"), Some(8_192_000));
2009        assert_eq!(parse_proc_kb("garbage"), None);
2010    }
2011
2012    // ── CpuUsageSensor tests ────────────────────────────────────────────
2013
2014    #[test]
2015    fn cpu_usage_sensor_construction() {
2016        let sensor = CpuUsageSensor::new();
2017        assert_eq!(sensor.id(), "cpu_usage");
2018        assert_eq!(sensor.kind(), SensorKind::Custom);
2019    }
2020
2021    #[test]
2022    fn cpu_usage_sensor_debug() {
2023        let sensor = CpuUsageSensor::new();
2024        let debug = format!("{sensor:?}");
2025        assert!(debug.contains("CpuUsageSensor"));
2026    }
2027
2028    #[test]
2029    fn cpu_usage_sensor_read_on_linux() {
2030        if !std::path::Path::new("/proc/stat").exists() {
2031            return;
2032        }
2033        let sensor = CpuUsageSensor::new();
2034        assert!(sensor.is_available());
2035        let reading = sensor.read().unwrap();
2036        assert_eq!(reading.sensor_id, "cpu_usage");
2037        assert!(reading.value >= 0.0 && reading.value <= 100.0);
2038    }
2039
2040    // ── DiskUsageSensor tests ───────────────────────────────────────────
2041
2042    #[test]
2043    fn disk_usage_sensor_construction() {
2044        let sensor = DiskUsageSensor::root();
2045        assert_eq!(sensor.id(), "disk_root");
2046        assert_eq!(sensor.kind(), SensorKind::Custom);
2047    }
2048
2049    #[test]
2050    fn disk_usage_sensor_nonexistent() {
2051        let sensor = DiskUsageSensor::new("test", "/nonexistent/mount/point");
2052        assert!(!sensor.is_available());
2053        assert!(sensor.read().is_none());
2054    }
2055
2056    #[test]
2057    fn disk_usage_sensor_debug() {
2058        let sensor = DiskUsageSensor::root();
2059        let debug = format!("{sensor:?}");
2060        assert!(debug.contains("DiskUsageSensor"));
2061    }
2062
2063    // ── NetworkThroughputSensor tests ───────────────────────────────────
2064
2065    #[test]
2066    fn network_sensor_construction() {
2067        let sensor = NetworkThroughputSensor::new("eth0");
2068        assert_eq!(sensor.id(), "net_eth0");
2069        assert_eq!(sensor.kind(), SensorKind::Custom);
2070    }
2071
2072    #[test]
2073    fn network_sensor_default_interface() {
2074        let sensor = NetworkThroughputSensor::default_interface();
2075        assert!(sensor.id().starts_with("net_"));
2076    }
2077
2078    #[test]
2079    fn network_sensor_debug() {
2080        let sensor = NetworkThroughputSensor::new("wlan0");
2081        let debug = format!("{sensor:?}");
2082        assert!(debug.contains("NetworkThroughputSensor"));
2083        assert!(debug.contains("wlan0"));
2084    }
2085
2086    #[test]
2087    fn network_sensor_read_on_linux() {
2088        if !std::path::Path::new("/proc/net/dev").exists() {
2089            return;
2090        }
2091        let sensor = NetworkThroughputSensor::default_interface();
2092        assert!(sensor.is_available());
2093        let reading = sensor.read();
2094        // May return None if interface doesn't match, but shouldn't panic
2095        if let Some(r) = reading {
2096            assert!(r.value >= 0.0);
2097        }
2098    }
2099
2100    // ── CpuFreqSensor tests ─────────────────────────────────────────────
2101
2102    #[test]
2103    fn cpu_freq_sensor_construction() {
2104        let sensor = CpuFreqSensor::new(0);
2105        assert_eq!(sensor.id(), "cpu0_freq");
2106        assert_eq!(sensor.kind(), SensorKind::Custom);
2107    }
2108
2109    #[test]
2110    fn cpu_freq_sensor_nonexistent() {
2111        let sensor = CpuFreqSensor::new(999);
2112        assert!(!sensor.is_available());
2113        assert!(sensor.read().is_none());
2114    }
2115
2116    #[test]
2117    fn cpu_freq_sensor_debug() {
2118        let sensor = CpuFreqSensor::new(0);
2119        let debug = format!("{sensor:?}");
2120        assert!(debug.contains("CpuFreqSensor"));
2121    }
2122
2123    // ── SysfsActuator tests ─────────────────────────────────────────────
2124
2125    #[test]
2126    fn sysfs_actuator_construction() {
2127        let actuator = SysfsActuator::fan_pwm(0, 1);
2128        assert_eq!(actuator.id(), "fan_pwm0_1");
2129        assert_eq!(actuator.kind(), ActuatorKind::Motor);
2130    }
2131
2132    #[test]
2133    fn sysfs_actuator_led_construction() {
2134        let actuator = SysfsActuator::led("power");
2135        assert_eq!(actuator.id(), "led_power");
2136        assert_eq!(actuator.kind(), ActuatorKind::Display);
2137    }
2138
2139    #[test]
2140    fn sysfs_actuator_nonexistent_path() {
2141        let actuator = SysfsActuator::new(
2142            "test",
2143            ActuatorKind::Motor,
2144            "/nonexistent/path/that/does/not/exist",
2145            1.0,
2146        );
2147        assert!(!actuator.is_available());
2148        let cmd = ActuatorCommand::new("test", ActuatorKind::Motor, 1.0);
2149        assert!(actuator.command(&cmd).is_err());
2150    }
2151
2152    #[test]
2153    fn sysfs_actuator_e_stop_nonexistent() {
2154        let actuator = SysfsActuator::new("test", ActuatorKind::Motor, "/nonexistent/path", 1.0);
2155        assert!(actuator.e_stop().is_err());
2156    }
2157
2158    #[test]
2159    fn sysfs_actuator_debug() {
2160        let actuator = SysfsActuator::fan_pwm(0, 1);
2161        let debug = format!("{actuator:?}");
2162        assert!(debug.contains("SysfsActuator"));
2163        assert!(debug.contains("fan_pwm0_1"));
2164    }
2165
2166    #[test]
2167    fn sysfs_actuator_scale_applied() {
2168        // Create actuator pointing to a temp file to verify scaling
2169        let tmp = tempfile::NamedTempFile::new().unwrap();
2170        let path = tmp.path().to_str().unwrap();
2171        let actuator = SysfsActuator::new("test", ActuatorKind::Display, path, 255.0);
2172        assert!(actuator.is_available());
2173        let cmd = ActuatorCommand::new("test", ActuatorKind::Display, 0.5);
2174        actuator.command(&cmd).unwrap();
2175        let written = std::fs::read_to_string(path).unwrap();
2176        assert_eq!(written.trim(), "128"); // 0.5 * 255 = 127.5 → 128
2177    }
2178
2179    // ── Actuator discovery tests ────────────────────────────────────────
2180
2181    #[test]
2182    fn discover_fan_actuators_returns_vec() {
2183        let actuators = discover_fan_actuators();
2184        for a in &actuators {
2185            assert!(a.is_available());
2186            assert_eq!(a.kind(), ActuatorKind::Motor);
2187        }
2188    }
2189
2190    #[test]
2191    fn discover_led_actuators_returns_vec() {
2192        let actuators = discover_led_actuators();
2193        for a in &actuators {
2194            assert!(a.is_available());
2195            assert_eq!(a.kind(), ActuatorKind::Display);
2196        }
2197    }
2198
2199    // ── Enhanced linux_hardware_bus tests ───────────────────────────────
2200
2201    #[test]
2202    fn linux_hardware_bus_includes_new_sensors() {
2203        let bus = linux_hardware_bus();
2204        let ids = bus.sensor_ids();
2205        // On Linux with /proc/stat, cpu_usage should be registered
2206        if std::path::Path::new("/proc/stat").exists() {
2207            assert!(ids.iter().any(|id| id == "cpu_usage"));
2208        }
2209        // Disk root should always be registered on any platform
2210        assert!(ids.iter().any(|id| id == "disk_root"));
2211    }
2212
2213    #[test]
2214    fn linux_hardware_bus_includes_actuators() {
2215        let bus = linux_hardware_bus();
2216        // Actuator count depends on hardware, but bus should be valid
2217        let actuator_ids = bus.actuator_ids();
2218        for id in &actuator_ids {
2219            assert!(!id.is_empty());
2220        }
2221    }
2222}