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}
559
560impl ReflexLoop {
561    /// Create a new reflex loop.
562    #[must_use]
563    pub fn new() -> Self {
564        Self {
565            rules: Vec::new(),
566            last_trigger: HashMap::new(),
567        }
568    }
569
570    /// Add a reflex rule.
571    pub fn add_rule(&mut self, rule: ReflexRule) {
572        self.rules.push(rule);
573    }
574
575    /// Evaluate all rules against a set of sensor readings.
576    /// Returns commands to execute.
577    pub fn evaluate(&mut self, readings: &[SensorReading]) -> Vec<ActuatorCommand> {
578        let mut commands = Vec::new();
579        let now = Instant::now();
580
581        for rule in &self.rules {
582            // Check cooldown
583            if let Some(&last) = self.last_trigger.get(&rule.sensor_id) {
584                let elapsed = now.duration_since(last).as_secs_f64();
585                if elapsed < rule.cooldown_secs {
586                    continue;
587                }
588            }
589
590            // Find matching reading
591            if let Some(reading) = readings.iter().find(|r| r.sensor_id == rule.sensor_id) {
592                if rule.is_triggered(reading) {
593                    commands.push(ActuatorCommand::new(
594                        &rule.actuator_id,
595                        rule.actuator_kind,
596                        rule.command_value,
597                    ));
598                    self.last_trigger.insert(rule.sensor_id.clone(), now);
599                }
600            }
601        }
602
603        commands
604    }
605
606    /// Number of rules.
607    #[must_use]
608    pub fn rule_count(&self) -> usize {
609        self.rules.len()
610    }
611}
612
613impl Default for ReflexLoop {
614    fn default() -> Self {
615        Self::new()
616    }
617}
618
619// ── Linux Hardware Sensors ─────────────────────────────────────────────
620
621/// Sensor that reads a single value from a `/sys` filesystem path.
622///
623/// Common uses:
624/// - `/sys/class/thermal/thermal_zone0/temp` (temperature in millidegrees)
625/// - `/sys/class/hwmon/hwmon0/fan1_input` (fan RPM)
626/// - `/sys/class/power_supply/BAT0/capacity` (battery percentage)
627///
628/// The `scale` field divides the raw integer value (e.g., 55000 → 55.0
629/// for millidegrees → degrees).
630pub struct SysfsSensor {
631    id: String,
632    kind: SensorKind,
633    path: String,
634    scale: f64,
635    available: bool,
636}
637
638impl SysfsSensor {
639    /// Create a new sysfs sensor.
640    ///
641    /// - `path`: absolute path to the sysfs file (e.g., `/sys/class/thermal/thermal_zone0/temp`)
642    /// - `scale`: divisor to convert raw integer to float (e.g., 1000.0 for millidegrees)
643    #[must_use]
644    pub fn new(
645        id: impl Into<String>,
646        kind: SensorKind,
647        path: impl Into<String>,
648        scale: f64,
649    ) -> Self {
650        let path = path.into();
651        let available = std::path::Path::new(&path).exists();
652        Self {
653            id: id.into(),
654            kind,
655            path,
656            scale,
657            available,
658        }
659    }
660
661    /// Create a thermal zone sensor (temperature in °C).
662    #[must_use]
663    pub fn thermal(zone: usize) -> Self {
664        Self::new(
665            format!("thermal_zone{zone}"),
666            SensorKind::Temperature,
667            format!("/sys/class/thermal/thermal_zone{zone}/temp"),
668            1000.0,
669        )
670    }
671
672    /// Create a battery capacity sensor (0.0–100.0).
673    #[must_use]
674    pub fn battery(name: &str) -> Self {
675        Self::new(
676            format!("battery_{name}"),
677            SensorKind::Power,
678            format!("/sys/class/power_supply/{name}/capacity"),
679            1.0,
680        )
681    }
682
683    /// Create a fan speed sensor (RPM).
684    #[must_use]
685    pub fn fan(hwmon: usize, fan: usize) -> Self {
686        Self::new(
687            format!("fan{hwmon}_{fan}"),
688            SensorKind::Custom,
689            format!("/sys/class/hwmon/hwmon{hwmon}/fan{fan}_input"),
690            1.0,
691        )
692    }
693}
694
695impl SensorDevice for SysfsSensor {
696    fn id(&self) -> &str {
697        &self.id
698    }
699
700    fn kind(&self) -> SensorKind {
701        self.kind
702    }
703
704    fn read(&self) -> Option<SensorReading> {
705        if !self.available {
706            return None;
707        }
708        let raw = std::fs::read_to_string(&self.path).ok()?;
709        let trimmed = raw.trim();
710        let value: f64 = trimmed.parse().ok()?;
711        let scaled = if self.scale > 0.0 {
712            value / self.scale
713        } else {
714            value
715        };
716        Some(SensorReading::new(&self.id, self.kind, scaled))
717    }
718
719    fn is_available(&self) -> bool {
720        self.available
721    }
722}
723
724impl std::fmt::Debug for SysfsSensor {
725    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
726        f.debug_struct("SysfsSensor")
727            .field("id", &self.id)
728            .field("kind", &self.kind)
729            .field("path", &self.path)
730            .field("scale", &self.scale)
731            .field("available", &self.available)
732            .finish()
733    }
734}
735
736/// Parser function type for extracting a sensor value from file contents.
737pub type SensorParser = Box<dyn Fn(&str) -> Option<f64> + Send + Sync>;
738
739/// Sensor that reads a value from a `/proc` filesystem file by parsing
740/// a key-value format.
741///
742/// Common uses:
743/// - `/proc/loadavg` — CPU load average (first field)
744/// - `/proc/meminfo` — `MemAvailable:` / `MemTotal:` for memory pressure
745/// - `/proc/stat` — CPU time slices
746pub struct ProcfsSensor {
747    id: String,
748    kind: SensorKind,
749    path: String,
750    /// Parser: extracts the value from the file contents.
751    parser: SensorParser,
752    available: bool,
753}
754
755impl ProcfsSensor {
756    /// Create a new procfs sensor with a custom parser.
757    #[must_use]
758    pub fn new(
759        id: impl Into<String>,
760        kind: SensorKind,
761        path: impl Into<String>,
762        parser: SensorParser,
763    ) -> Self {
764        let path = path.into();
765        let available = std::path::Path::new(&path).exists();
766        Self {
767            id: id.into(),
768            kind,
769            path,
770            parser,
771            available,
772        }
773    }
774
775    /// Create a CPU load average sensor from `/proc/loadavg`.
776    #[must_use]
777    pub fn loadavg() -> Self {
778        Self::new(
779            "cpu_loadavg",
780            SensorKind::Custom,
781            "/proc/loadavg",
782            Box::new(|content: &str| {
783                content
784                    .split_whitespace()
785                    .next()
786                    .and_then(|s| s.parse::<f64>().ok())
787            }),
788        )
789    }
790
791    /// Create a memory pressure sensor from `/proc/meminfo`.
792    ///
793    /// Returns `1.0 - (MemAvailable / MemTotal)`, clamped to [0, 1].
794    #[must_use]
795    pub fn mem_pressure() -> Self {
796        Self::new(
797            "mem_pressure",
798            SensorKind::Custom,
799            "/proc/meminfo",
800            Box::new(|content: &str| {
801                let mut mem_total = None;
802                let mut mem_avail = None;
803                for line in content.lines() {
804                    if line.starts_with("MemTotal:") {
805                        mem_total = parse_proc_kb(line);
806                    } else if line.starts_with("MemAvailable:") {
807                        mem_avail = parse_proc_kb(line);
808                    }
809                }
810                match (mem_total, mem_avail) {
811                    (Some(total), Some(avail)) if total > 0 => {
812                        Some(1.0 - (avail as f64 / total as f64).min(1.0))
813                    }
814                    _ => None,
815                }
816            }),
817        )
818    }
819}
820
821impl SensorDevice for ProcfsSensor {
822    fn id(&self) -> &str {
823        &self.id
824    }
825
826    fn kind(&self) -> SensorKind {
827        self.kind
828    }
829
830    fn read(&self) -> Option<SensorReading> {
831        if !self.available {
832            return None;
833        }
834        let content = std::fs::read_to_string(&self.path).ok()?;
835        let value = (self.parser)(&content)?;
836        Some(SensorReading::new(&self.id, self.kind, value))
837    }
838
839    fn is_available(&self) -> bool {
840        self.available
841    }
842}
843
844impl std::fmt::Debug for ProcfsSensor {
845    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
846        f.debug_struct("ProcfsSensor")
847            .field("id", &self.id)
848            .field("kind", &self.kind)
849            .field("path", &self.path)
850            .field("available", &self.available)
851            .finish_non_exhaustive()
852    }
853}
854
855/// Parse a `/proc/meminfo` line like `MemTotal:       16384000 kB` → kB value.
856fn parse_proc_kb(line: &str) -> Option<u64> {
857    line.split(':')
858        .nth(1)
859        .and_then(|s| s.split_whitespace().next())
860        .and_then(|s| s.parse::<u64>().ok())
861}
862
863// ── CPU Usage Sensor ───────────────────────────────────────────────────
864
865/// Sensor that reads CPU usage percentage from `/proc/stat`.
866///
867/// Computes the percentage by comparing idle vs. total time slices
868/// between consecutive reads. The first read returns 0% (no baseline yet).
869/// Returns a value in [0.0, 100.0] representing the aggregate CPU usage.
870pub struct CpuUsageSensor {
871    prev_idle: Option<u64>,
872    prev_total: Option<u64>,
873}
874
875impl CpuUsageSensor {
876    #[must_use]
877    pub const fn new() -> Self {
878        Self {
879            prev_idle: None,
880            prev_total: None,
881        }
882    }
883}
884
885impl Default for CpuUsageSensor {
886    fn default() -> Self {
887        Self::new()
888    }
889}
890
891impl SensorDevice for CpuUsageSensor {
892    fn id(&self) -> &str {
893        "cpu_usage"
894    }
895
896    fn kind(&self) -> SensorKind {
897        SensorKind::Custom
898    }
899
900    fn read(&self) -> Option<SensorReading> {
901        let content = std::fs::read_to_string("/proc/stat").ok()?;
902        let first_line = content.lines().next()?;
903        if !first_line.starts_with("cpu ") {
904            return None;
905        }
906        let fields: Vec<u64> = first_line
907            .split_whitespace()
908            .skip(1)
909            .filter_map(|s| s.parse::<u64>().ok())
910            .collect();
911        if fields.len() < 4 {
912            return None;
913        }
914        let idle = fields[3];
915        let total: u64 = fields.iter().sum();
916        let usage = match (self.prev_idle, self.prev_total) {
917            (Some(prev_idle), Some(prev_total)) => {
918                let idle_delta = idle.saturating_sub(prev_idle) as f64;
919                let total_delta = total.saturating_sub(prev_total) as f64;
920                if total_delta > 0.0 {
921                    ((1.0 - idle_delta / total_delta) * 100.0).clamp(0.0, 100.0)
922                } else {
923                    0.0
924                }
925            }
926            _ => 0.0,
927        };
928        Some(SensorReading::new("cpu_usage", SensorKind::Custom, usage))
929    }
930
931    fn is_available(&self) -> bool {
932        std::path::Path::new("/proc/stat").exists()
933    }
934}
935
936impl std::fmt::Debug for CpuUsageSensor {
937    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
938        f.debug_struct("CpuUsageSensor")
939            .field("prev_idle", &self.prev_idle)
940            .field("prev_total", &self.prev_total)
941            .finish()
942    }
943}
944
945// ── Disk Usage Sensor ──────────────────────────────────────────────────
946
947/// Sensor that reads disk usage percentage for a given mount point.
948///
949/// Uses `statvfs` to determine the fraction of used space.
950/// Returns a value in [0.0, 100.0].
951pub struct DiskUsageSensor {
952    id: String,
953    path: String,
954    available: bool,
955}
956
957impl DiskUsageSensor {
958    #[must_use]
959    pub fn new(id: impl Into<String>, path: impl Into<String>) -> Self {
960        let path = path.into();
961        let available = std::path::Path::new(&path).exists();
962        Self {
963            id: id.into(),
964            path,
965            available,
966        }
967    }
968
969    /// Create a root filesystem usage sensor.
970    #[must_use]
971    pub fn root() -> Self {
972        Self::new("disk_root", "/")
973    }
974}
975
976impl SensorDevice for DiskUsageSensor {
977    fn id(&self) -> &str {
978        &self.id
979    }
980
981    fn kind(&self) -> SensorKind {
982        SensorKind::Custom
983    }
984
985    fn read(&self) -> Option<SensorReading> {
986        if !self.available {
987            return None;
988        }
989        let output = std::process::Command::new("df")
990            .arg("-P")
991            .arg(&self.path)
992            .output()
993            .ok()?;
994        let stdout = String::from_utf8(output.stdout).ok()?;
995        let line = stdout.lines().nth(1)?;
996        let fields: Vec<&str> = line.split_whitespace().collect();
997        if fields.len() < 5 {
998            return None;
999        }
1000        let used: f64 = fields[2].parse().ok()?;
1001        let total: f64 = fields[1].parse().ok()?;
1002        if total > 0.0 {
1003            let pct = (used / total) * 100.0;
1004            Some(SensorReading::new(&self.id, SensorKind::Custom, pct))
1005        } else {
1006            None
1007        }
1008    }
1009
1010    fn is_available(&self) -> bool {
1011        self.available
1012    }
1013}
1014
1015impl std::fmt::Debug for DiskUsageSensor {
1016    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1017        f.debug_struct("DiskUsageSensor")
1018            .field("id", &self.id)
1019            .field("path", &self.path)
1020            .field("available", &self.available)
1021            .finish()
1022    }
1023}
1024
1025// ── Network Throughput Sensor ──────────────────────────────────────────
1026
1027/// Sensor that reads network throughput from `/proc/net/dev`.
1028///
1029/// Returns bytes/sec since the last read. The first read returns 0.
1030/// Monitors a specific interface (e.g., "eth0", "wlan0").
1031pub struct NetworkThroughputSensor {
1032    id: String,
1033    interface: String,
1034    prev_rx_bytes: Option<u64>,
1035    prev_tx_bytes: Option<u64>,
1036    prev_time: Option<Instant>,
1037}
1038
1039impl NetworkThroughputSensor {
1040    #[must_use]
1041    pub fn new(interface: impl Into<String>) -> Self {
1042        let iface = interface.into();
1043        Self {
1044            id: format!("net_{iface}"),
1045            interface: iface,
1046            prev_rx_bytes: None,
1047            prev_tx_bytes: None,
1048            prev_time: None,
1049        }
1050    }
1051
1052    /// Auto-detect the default network interface by reading /proc/net/dev
1053    /// and picking the first non-lo interface.
1054    #[must_use]
1055    pub fn default_interface() -> Self {
1056        let iface = if let Ok(content) = std::fs::read_to_string("/proc/net/dev") {
1057            content
1058                .lines()
1059                .skip(2)
1060                .find_map(|line| {
1061                    let name = line.split(':').next()?.trim();
1062                    if name != "lo" && !name.is_empty() {
1063                        Some(name.to_string())
1064                    } else {
1065                        None
1066                    }
1067                })
1068                .unwrap_or_else(|| "eth0".to_string())
1069        } else {
1070            "eth0".to_string()
1071        };
1072        Self::new(iface)
1073    }
1074}
1075
1076impl SensorDevice for NetworkThroughputSensor {
1077    fn id(&self) -> &str {
1078        &self.id
1079    }
1080
1081    fn kind(&self) -> SensorKind {
1082        SensorKind::Custom
1083    }
1084
1085    fn read(&self) -> Option<SensorReading> {
1086        let content = std::fs::read_to_string("/proc/net/dev").ok()?;
1087        for line in content.lines().skip(2) {
1088            let mut parts = line.split(':');
1089            let name = parts.next()?.trim();
1090            if name != self.interface {
1091                continue;
1092            }
1093            let stats: Vec<u64> = parts
1094                .next()?
1095                .split_whitespace()
1096                .filter_map(|s| s.parse::<u64>().ok())
1097                .collect();
1098            if stats.len() < 9 {
1099                return None;
1100            }
1101            let rx_bytes = stats[0];
1102            let tx_bytes = stats[8];
1103            let now = Instant::now();
1104            let throughput = match (self.prev_rx_bytes, self.prev_tx_bytes, self.prev_time) {
1105                (Some(prev_rx), Some(prev_tx), Some(prev_t)) => {
1106                    let elapsed = now.duration_since(prev_t).as_secs_f64();
1107                    if elapsed > 0.0 {
1108                        let rx_delta = rx_bytes.saturating_sub(prev_rx) as f64;
1109                        let tx_delta = tx_bytes.saturating_sub(prev_tx) as f64;
1110                        (rx_delta + tx_delta) / elapsed
1111                    } else {
1112                        0.0
1113                    }
1114                }
1115                _ => 0.0,
1116            };
1117            return Some(SensorReading::new(&self.id, SensorKind::Custom, throughput));
1118        }
1119        None
1120    }
1121
1122    fn is_available(&self) -> bool {
1123        std::path::Path::new("/proc/net/dev").exists()
1124    }
1125}
1126
1127impl std::fmt::Debug for NetworkThroughputSensor {
1128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1129        f.debug_struct("NetworkThroughputSensor")
1130            .field("id", &self.id)
1131            .field("interface", &self.interface)
1132            .finish_non_exhaustive()
1133    }
1134}
1135
1136// ── CPU Frequency Sensor ───────────────────────────────────────────────
1137
1138/// Sensor that reads CPU frequency from `/sys/devices/system/cpu`.
1139///
1140/// Reads the current frequency for a given CPU core.
1141/// Returns frequency in MHz.
1142pub struct CpuFreqSensor {
1143    id: String,
1144    path: String,
1145    available: bool,
1146}
1147
1148impl CpuFreqSensor {
1149    #[must_use]
1150    pub fn new(core: usize) -> Self {
1151        let path = format!("/sys/devices/system/cpu/cpu{core}/cpufreq/scaling_cur_freq");
1152        let available = std::path::Path::new(&path).exists();
1153        Self {
1154            id: format!("cpu{core}_freq"),
1155            path,
1156            available,
1157        }
1158    }
1159}
1160
1161impl SensorDevice for CpuFreqSensor {
1162    fn id(&self) -> &str {
1163        &self.id
1164    }
1165
1166    fn kind(&self) -> SensorKind {
1167        SensorKind::Custom
1168    }
1169
1170    fn read(&self) -> Option<SensorReading> {
1171        if !self.available {
1172            return None;
1173        }
1174        let raw = std::fs::read_to_string(&self.path).ok()?;
1175        let khz: f64 = raw.trim().parse().ok()?;
1176        Some(SensorReading::new(
1177            &self.id,
1178            SensorKind::Custom,
1179            khz / 1000.0,
1180        ))
1181    }
1182
1183    fn is_available(&self) -> bool {
1184        self.available
1185    }
1186}
1187
1188impl std::fmt::Debug for CpuFreqSensor {
1189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1190        f.debug_struct("CpuFreqSensor")
1191            .field("id", &self.id)
1192            .field("path", &self.path)
1193            .field("available", &self.available)
1194            .finish()
1195    }
1196}
1197
1198// ── Sysfs Actuator ─────────────────────────────────────────────────────
1199
1200/// Actuator that writes a value to a `/sys` filesystem file.
1201///
1202/// Common uses:
1203/// - Fan PWM control: `/sys/class/hwmon/hwmon0/pwm1` (0–255)
1204/// - LED brightness: `/sys/class/leds/led0/brightness` (0–255)
1205/// - CPU governor: `/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor`
1206///
1207/// The `scale` field multiplies the command value before writing
1208/// (e.g., 1.0 for direct, 255.0 if command is 0.0–1.0 and output is 0–255).
1209pub struct SysfsActuator {
1210    id: String,
1211    kind: ActuatorKind,
1212    path: String,
1213    scale: f64,
1214    available: bool,
1215}
1216
1217impl SysfsActuator {
1218    /// Create a new sysfs actuator.
1219    ///
1220    /// - `path`: absolute path to the sysfs file
1221    /// - `scale`: multiplier to convert command value to raw output
1222    #[must_use]
1223    pub fn new(
1224        id: impl Into<String>,
1225        kind: ActuatorKind,
1226        path: impl Into<String>,
1227        scale: f64,
1228    ) -> Self {
1229        let path = path.into();
1230        let available = std::path::Path::new(&path).exists();
1231        Self {
1232            id: id.into(),
1233            kind,
1234            path,
1235            scale,
1236            available,
1237        }
1238    }
1239
1240    /// Create a fan PWM controller for hwmon device.
1241    #[must_use]
1242    pub fn fan_pwm(hwmon: usize, pwm: usize) -> Self {
1243        Self::new(
1244            format!("fan_pwm{hwmon}_{pwm}"),
1245            ActuatorKind::Motor,
1246            format!("/sys/class/hwmon/hwmon{hwmon}/pwm{pwm}"),
1247            1.0,
1248        )
1249    }
1250
1251    /// Create an LED brightness controller.
1252    #[must_use]
1253    pub fn led(name: &str) -> Self {
1254        Self::new(
1255            format!("led_{name}"),
1256            ActuatorKind::Display,
1257            format!("/sys/class/leds/{name}/brightness"),
1258            1.0,
1259        )
1260    }
1261}
1262
1263impl ActuatorDevice for SysfsActuator {
1264    fn id(&self) -> &str {
1265        &self.id
1266    }
1267
1268    fn kind(&self) -> ActuatorKind {
1269        self.kind
1270    }
1271
1272    fn command(&self, cmd: &ActuatorCommand) -> Result<(), String> {
1273        if !self.available {
1274            return Err(format!("actuator '{}' path not available", self.id));
1275        }
1276        let raw_value = cmd.value * self.scale;
1277        let output = format!("{}", raw_value.round() as i64);
1278        std::fs::write(&self.path, output)
1279            .map_err(|e| format!("failed to write to {}: {e}", self.path))
1280    }
1281
1282    fn is_available(&self) -> bool {
1283        self.available
1284    }
1285
1286    fn e_stop(&self) -> Result<(), String> {
1287        if !self.available {
1288            return Err(format!("actuator '{}' path not available", self.id));
1289        }
1290        // Write 0 to disable
1291        std::fs::write(&self.path, "0").map_err(|e| format!("failed to e-stop {}: {e}", self.path))
1292    }
1293}
1294
1295impl std::fmt::Debug for SysfsActuator {
1296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1297        f.debug_struct("SysfsActuator")
1298            .field("id", &self.id)
1299            .field("kind", &self.kind)
1300            .field("path", &self.path)
1301            .field("scale", &self.scale)
1302            .field("available", &self.available)
1303            .finish()
1304    }
1305}
1306
1307// ── Actuator Discovery ─────────────────────────────────────────────────
1308
1309/// Auto-discover fan PWM controllers on Linux.
1310///
1311/// Scans `/sys/class/hwmon/hwmon*/pwm*` for writable PWM entries.
1312#[must_use]
1313pub fn discover_fan_actuators() -> Vec<SysfsActuator> {
1314    let mut actuators = Vec::new();
1315    for hwmon in 0..8 {
1316        for pwm in 1..=4 {
1317            let actuator = SysfsActuator::fan_pwm(hwmon, pwm);
1318            if actuator.is_available() {
1319                actuators.push(actuator);
1320            }
1321        }
1322    }
1323    actuators
1324}
1325
1326/// Auto-discover LED controllers on Linux.
1327///
1328/// Scans common LED names in `/sys/class/leds/`.
1329#[must_use]
1330pub fn discover_led_actuators() -> Vec<SysfsActuator> {
1331    let mut actuators = Vec::new();
1332    for name in [
1333        "input0::scrolllock",
1334        "input0::numlock",
1335        "input0::capslock",
1336        "mmc0::",
1337        "phy0-led",
1338        "eth0-link",
1339        "power",
1340        "charging",
1341        "disk-activity",
1342    ] {
1343        let actuator = SysfsActuator::led(name);
1344        if actuator.is_available() {
1345            actuators.push(actuator);
1346        }
1347    }
1348    actuators
1349}
1350
1351/// Auto-discover available thermal zones on Linux.
1352///
1353/// Scans `/sys/class/thermal/thermal_zone*` and returns sensors for each.
1354#[must_use]
1355pub fn discover_thermal_sensors() -> Vec<SysfsSensor> {
1356    let mut sensors = Vec::new();
1357    for i in 0..16 {
1358        let sensor = SysfsSensor::thermal(i);
1359        if sensor.is_available() {
1360            sensors.push(sensor);
1361        }
1362    }
1363    sensors
1364}
1365
1366/// Auto-discover battery sensors on Linux.
1367///
1368/// Scans `/sys/class/power_supply/BAT*` and returns sensors for each.
1369#[must_use]
1370pub fn discover_battery_sensors() -> Vec<SysfsSensor> {
1371    let mut sensors = Vec::new();
1372    for name in ["BAT0", "BAT1", "BAT2", "BAT3", "BATC", "BATT"] {
1373        let sensor = SysfsSensor::battery(name);
1374        if sensor.is_available() {
1375            sensors.push(sensor);
1376        }
1377    }
1378    sensors
1379}
1380
1381/// Create a `SensorimotorBus` pre-populated with all discovered Linux hardware sensors.
1382///
1383/// On non-Linux platforms, returns an empty bus.
1384#[must_use]
1385pub fn linux_hardware_bus() -> SensorimotorBus {
1386    let mut bus = SensorimotorBus::new(256);
1387
1388    // Thermal sensors
1389    for sensor in discover_thermal_sensors() {
1390        bus.register_sensor(Box::new(sensor));
1391    }
1392
1393    // Battery sensors
1394    for sensor in discover_battery_sensors() {
1395        bus.register_sensor(Box::new(sensor));
1396    }
1397
1398    // /proc sensors (Linux only)
1399    if cfg!(target_os = "linux") {
1400        if std::path::Path::new("/proc/loadavg").exists() {
1401            bus.register_sensor(Box::new(ProcfsSensor::loadavg()));
1402        }
1403        if std::path::Path::new("/proc/meminfo").exists() {
1404            bus.register_sensor(Box::new(ProcfsSensor::mem_pressure()));
1405        }
1406        if std::path::Path::new("/proc/stat").exists() {
1407            bus.register_sensor(Box::new(CpuUsageSensor::new()));
1408        }
1409        if std::path::Path::new("/proc/net/dev").exists() {
1410            bus.register_sensor(Box::new(NetworkThroughputSensor::default_interface()));
1411        }
1412    }
1413
1414    // Disk usage sensor
1415    if std::path::Path::new("/").exists() {
1416        bus.register_sensor(Box::new(DiskUsageSensor::root()));
1417    }
1418
1419    // CPU frequency sensors (up to 16 cores)
1420    for core in 0..16 {
1421        let sensor = CpuFreqSensor::new(core);
1422        if sensor.is_available() {
1423            bus.register_sensor(Box::new(sensor));
1424        }
1425    }
1426
1427    // Fan PWM actuators
1428    for actuator in discover_fan_actuators() {
1429        bus.register_actuator(Box::new(actuator));
1430    }
1431
1432    // LED actuators
1433    for actuator in discover_led_actuators() {
1434        bus.register_actuator(Box::new(actuator));
1435    }
1436
1437    bus
1438}
1439
1440// ── Helpers ────────────────────────────────────────────────────────────
1441
1442/// Get current time in seconds (Unix timestamp).
1443fn now_secs() -> f64 {
1444    std::time::SystemTime::now()
1445        .duration_since(std::time::UNIX_EPOCH)
1446        .map_or(0.0, |d| d.as_secs_f64())
1447}
1448
1449// ── Tests ──────────────────────────────────────────────────────────────
1450
1451#[cfg(test)]
1452mod tests {
1453    use super::*;
1454
1455    #[test]
1456    fn sensor_kind_as_str() {
1457        assert_eq!(SensorKind::Temperature.as_str(), "temperature");
1458        assert_eq!(SensorKind::Imu.as_str(), "imu");
1459        assert_eq!(SensorKind::Camera.as_str(), "camera");
1460    }
1461
1462    #[test]
1463    fn actuator_kind_as_str() {
1464        assert_eq!(ActuatorKind::Motor.as_str(), "motor");
1465        assert_eq!(ActuatorKind::Relay.as_str(), "relay");
1466    }
1467
1468    #[test]
1469    fn sensor_reading_new() {
1470        let r = SensorReading::new("cpu_temp", SensorKind::Temperature, 55.0);
1471        assert_eq!(r.sensor_id, "cpu_temp");
1472        assert_eq!(r.kind, SensorKind::Temperature);
1473        assert!((r.value - 55.0).abs() < f64::EPSILON);
1474        assert!((r.confidence - 1.0).abs() < f64::EPSILON);
1475        assert!(r.extra.is_empty());
1476    }
1477
1478    #[test]
1479    fn sensor_reading_with_extra() {
1480        let r = SensorReading::new("imu0", SensorKind::Imu, 0.0).with_extra(vec![1.0, 2.0, 3.0]);
1481        assert_eq!(r.extra, vec![1.0, 2.0, 3.0]);
1482    }
1483
1484    #[test]
1485    fn sensor_reading_with_confidence() {
1486        let r = SensorReading::new("cam0", SensorKind::Camera, 128.0).with_confidence(0.8);
1487        assert!((r.confidence - 0.8).abs() < f64::EPSILON);
1488    }
1489
1490    #[test]
1491    fn sensor_reading_confidence_clamped() {
1492        let r = SensorReading::new("s0", SensorKind::Custom, 0.0).with_confidence(1.5);
1493        assert!((r.confidence - 1.0).abs() < f64::EPSILON);
1494    }
1495
1496    #[test]
1497    fn actuator_command_new() {
1498        let c = ActuatorCommand::new("motor_l", ActuatorKind::Motor, 0.5);
1499        assert_eq!(c.actuator_id, "motor_l");
1500        assert_eq!(c.kind, ActuatorKind::Motor);
1501        assert!((c.value - 0.5).abs() < f64::EPSILON);
1502    }
1503
1504    #[test]
1505    fn actuator_command_with_params() {
1506        let c =
1507            ActuatorCommand::new("motor_r", ActuatorKind::Motor, 1.0).with_params(vec![0.1, 2.0]);
1508        assert_eq!(c.params, vec![0.1, 2.0]);
1509    }
1510
1511    #[test]
1512    fn stub_sensor_read() {
1513        let s = StubSensor::new("temp0", SensorKind::Temperature, 42.0);
1514        assert_eq!(s.id(), "temp0");
1515        assert_eq!(s.kind(), SensorKind::Temperature);
1516        let r = s.read().unwrap();
1517        assert!((r.value - 42.0).abs() < f64::EPSILON);
1518    }
1519
1520    #[test]
1521    fn stub_actuator_command() {
1522        let a = StubActuator::new("motor0", ActuatorKind::Motor);
1523        let cmd = ActuatorCommand::new("motor0", ActuatorKind::Motor, 0.7);
1524        a.command(&cmd).unwrap();
1525        let last = a.last_command().unwrap();
1526        assert!((last.value - 0.7).abs() < f64::EPSILON);
1527    }
1528
1529    #[test]
1530    fn bus_register_and_poll() {
1531        let mut bus = SensorimotorBus::new(64);
1532        bus.register_sensor(Box::new(StubSensor::new(
1533            "s1",
1534            SensorKind::Temperature,
1535            50.0,
1536        )));
1537        bus.register_sensor(Box::new(StubSensor::new("s2", SensorKind::Distance, 1.5)));
1538
1539        assert_eq!(bus.sensor_count(), 2);
1540        let readings = bus.poll_all();
1541        assert_eq!(readings.len(), 2);
1542        assert_eq!(bus.readings_collected(), 2);
1543    }
1544
1545    #[test]
1546    fn bus_send_command() {
1547        let mut bus = SensorimotorBus::new(64);
1548        bus.register_actuator(Box::new(StubActuator::new("m1", ActuatorKind::Motor)));
1549
1550        let cmd = ActuatorCommand::new("m1", ActuatorKind::Motor, 0.5);
1551        bus.send_command(&cmd).unwrap();
1552        assert_eq!(bus.commands_sent(), 1);
1553    }
1554
1555    #[test]
1556    fn bus_command_unknown_actuator() {
1557        let mut bus = SensorimotorBus::new(64);
1558        let cmd = ActuatorCommand::new("unknown", ActuatorKind::Motor, 0.0);
1559        assert!(bus.send_command(&cmd).is_err());
1560    }
1561
1562    #[test]
1563    fn bus_history() {
1564        let mut bus = SensorimotorBus::new(3);
1565        bus.register_sensor(Box::new(StubSensor::new(
1566            "s1",
1567            SensorKind::Temperature,
1568            50.0,
1569        )));
1570
1571        bus.poll_all();
1572        bus.poll_all();
1573        bus.poll_all();
1574        bus.poll_all(); // 4 polls, history caps at 3
1575
1576        let h = bus.history();
1577        assert_eq!(h.len(), 3);
1578    }
1579
1580    #[test]
1581    fn bus_sensor_ids() {
1582        let mut bus = SensorimotorBus::new(64);
1583        bus.register_sensor(Box::new(StubSensor::new("a", SensorKind::Temperature, 0.0)));
1584        bus.register_sensor(Box::new(StubSensor::new("b", SensorKind::Imu, 0.0)));
1585
1586        let ids = bus.sensor_ids();
1587        assert!(ids.contains(&"a".to_string()));
1588        assert!(ids.contains(&"b".to_string()));
1589    }
1590
1591    #[test]
1592    fn bus_actuator_ids() {
1593        let mut bus = SensorimotorBus::new(64);
1594        bus.register_actuator(Box::new(StubActuator::new("m1", ActuatorKind::Motor)));
1595
1596        let ids = bus.actuator_ids();
1597        assert!(ids.contains(&"m1".to_string()));
1598    }
1599
1600    #[test]
1601    fn bus_e_stop_all() {
1602        let mut bus = SensorimotorBus::new(64);
1603        bus.register_actuator(Box::new(StubActuator::new("m1", ActuatorKind::Motor)));
1604        bus.register_actuator(Box::new(StubActuator::new("m2", ActuatorKind::Motor)));
1605
1606        let errors = bus.e_stop_all();
1607        assert!(errors.is_empty()); // Stubs don't error
1608    }
1609
1610    #[test]
1611    fn reflex_rule_above() {
1612        let rule = ReflexRule::above("temp0", "fan0", ActuatorKind::Motor, 70.0, 1.0, 5.0);
1613        let reading_high = SensorReading::new("temp0", SensorKind::Temperature, 75.0);
1614        let reading_low = SensorReading::new("temp0", SensorKind::Temperature, 60.0);
1615
1616        assert!(rule.is_triggered(&reading_high));
1617        assert!(!rule.is_triggered(&reading_low));
1618    }
1619
1620    #[test]
1621    fn reflex_rule_below() {
1622        let rule = ReflexRule::below("battery", "led", ActuatorKind::Display, 20.0, 1.0, 10.0);
1623        let reading_low = SensorReading::new("battery", SensorKind::Power, 15.0);
1624        let reading_ok = SensorReading::new("battery", SensorKind::Power, 80.0);
1625
1626        assert!(rule.is_triggered(&reading_low));
1627        assert!(!rule.is_triggered(&reading_ok));
1628    }
1629
1630    #[test]
1631    fn reflex_loop_evaluate() {
1632        let mut loop_ = ReflexLoop::new();
1633        loop_.add_rule(ReflexRule::above(
1634            "temp0",
1635            "fan0",
1636            ActuatorKind::Motor,
1637            70.0,
1638            1.0,
1639            0.0, // No cooldown for test
1640        ));
1641
1642        let readings = vec![SensorReading::new("temp0", SensorKind::Temperature, 75.0)];
1643        let commands = loop_.evaluate(&readings);
1644        assert_eq!(commands.len(), 1);
1645        assert_eq!(commands[0].actuator_id, "fan0");
1646    }
1647
1648    #[test]
1649    fn reflex_loop_no_trigger() {
1650        let mut loop_ = ReflexLoop::new();
1651        loop_.add_rule(ReflexRule::above(
1652            "temp0",
1653            "fan0",
1654            ActuatorKind::Motor,
1655            70.0,
1656            1.0,
1657            0.0,
1658        ));
1659
1660        let readings = vec![SensorReading::new("temp0", SensorKind::Temperature, 60.0)];
1661        let commands = loop_.evaluate(&readings);
1662        assert!(commands.is_empty());
1663    }
1664
1665    #[test]
1666    fn reflex_loop_cooldown() {
1667        let mut loop_ = ReflexLoop::new();
1668        loop_.add_rule(ReflexRule::above(
1669            "temp0",
1670            "fan0",
1671            ActuatorKind::Motor,
1672            70.0,
1673            1.0,
1674            100.0, // 100s cooldown
1675        ));
1676
1677        let readings = vec![SensorReading::new("temp0", SensorKind::Temperature, 80.0)];
1678
1679        // First trigger
1680        let cmds1 = loop_.evaluate(&readings);
1681        assert_eq!(cmds1.len(), 1);
1682
1683        // Second trigger — should be on cooldown
1684        let cmds2 = loop_.evaluate(&readings);
1685        assert!(cmds2.is_empty());
1686    }
1687
1688    #[test]
1689    fn reflex_loop_multiple_rules() {
1690        let mut loop_ = ReflexLoop::new();
1691        loop_.add_rule(ReflexRule::above(
1692            "temp0",
1693            "fan0",
1694            ActuatorKind::Motor,
1695            70.0,
1696            1.0,
1697            0.0,
1698        ));
1699        loop_.add_rule(ReflexRule::below(
1700            "battery",
1701            "led0",
1702            ActuatorKind::Display,
1703            20.0,
1704            1.0,
1705            0.0,
1706        ));
1707
1708        let readings = vec![
1709            SensorReading::new("temp0", SensorKind::Temperature, 75.0),
1710            SensorReading::new("battery", SensorKind::Power, 15.0),
1711        ];
1712
1713        let commands = loop_.evaluate(&readings);
1714        assert_eq!(commands.len(), 2);
1715    }
1716
1717    #[test]
1718    fn reflex_loop_rule_count() {
1719        let mut loop_ = ReflexLoop::new();
1720        assert_eq!(loop_.rule_count(), 0);
1721        loop_.add_rule(ReflexRule::above(
1722            "s",
1723            "a",
1724            ActuatorKind::Motor,
1725            1.0,
1726            1.0,
1727            1.0,
1728        ));
1729        assert_eq!(loop_.rule_count(), 1);
1730    }
1731
1732    #[test]
1733    fn bus_default() {
1734        let bus = SensorimotorBus::default();
1735        assert_eq!(bus.sensor_count(), 0);
1736        assert_eq!(bus.actuator_count(), 0);
1737    }
1738
1739    #[test]
1740    fn bus_read_specific_sensor() {
1741        let mut bus = SensorimotorBus::new(64);
1742        bus.register_sensor(Box::new(StubSensor::new(
1743            "s1",
1744            SensorKind::Temperature,
1745            42.0,
1746        )));
1747
1748        let r = bus.read_sensor("s1").unwrap();
1749        assert!((r.value - 42.0).abs() < f64::EPSILON);
1750
1751        assert!(bus.read_sensor("nonexistent").is_none());
1752    }
1753
1754    #[test]
1755    fn sensor_kind_display() {
1756        assert_eq!(format!("{}", SensorKind::Temperature), "temperature");
1757        assert_eq!(format!("{}", ActuatorKind::Motor), "motor");
1758    }
1759
1760    // ── SysfsSensor tests ──────────────────────────────────────────────
1761
1762    #[test]
1763    fn sysfs_sensor_thermal_construction() {
1764        let sensor = SysfsSensor::thermal(0);
1765        assert_eq!(sensor.id(), "thermal_zone0");
1766        assert_eq!(sensor.kind(), SensorKind::Temperature);
1767    }
1768
1769    #[test]
1770    fn sysfs_sensor_battery_construction() {
1771        let sensor = SysfsSensor::battery("BAT0");
1772        assert_eq!(sensor.id(), "battery_BAT0");
1773        assert_eq!(sensor.kind(), SensorKind::Power);
1774    }
1775
1776    #[test]
1777    fn sysfs_sensor_fan_construction() {
1778        let sensor = SysfsSensor::fan(0, 1);
1779        assert_eq!(sensor.id(), "fan0_1");
1780        assert_eq!(sensor.kind(), SensorKind::Custom);
1781    }
1782
1783    #[test]
1784    fn sysfs_sensor_nonexistent_path() {
1785        let sensor = SysfsSensor::new(
1786            "test",
1787            SensorKind::Temperature,
1788            "/nonexistent/path/that/does/not/exist",
1789            1000.0,
1790        );
1791        assert!(!sensor.is_available());
1792        assert!(sensor.read().is_none());
1793    }
1794
1795    #[test]
1796    fn sysfs_sensor_debug_format() {
1797        let sensor = SysfsSensor::thermal(0);
1798        let debug = format!("{sensor:?}");
1799        assert!(debug.contains("SysfsSensor"));
1800        assert!(debug.contains("thermal_zone0"));
1801    }
1802
1803    // ── ProcfsSensor tests ─────────────────────────────────────────────
1804
1805    #[test]
1806    fn procfs_sensor_loadavg_construction() {
1807        let sensor = ProcfsSensor::loadavg();
1808        assert_eq!(sensor.id(), "cpu_loadavg");
1809        assert_eq!(sensor.kind(), SensorKind::Custom);
1810    }
1811
1812    #[test]
1813    fn procfs_sensor_mem_pressure_construction() {
1814        let sensor = ProcfsSensor::mem_pressure();
1815        assert_eq!(sensor.id(), "mem_pressure");
1816        assert_eq!(sensor.kind(), SensorKind::Custom);
1817    }
1818
1819    #[test]
1820    fn procfs_sensor_nonexistent_path() {
1821        let sensor = ProcfsSensor::new(
1822            "test",
1823            SensorKind::Custom,
1824            "/nonexistent/proc/path",
1825            Box::new(|_| Some(1.0)),
1826        );
1827        assert!(!sensor.is_available());
1828        assert!(sensor.read().is_none());
1829    }
1830
1831    #[test]
1832    fn procfs_sensor_debug_format() {
1833        let sensor = ProcfsSensor::loadavg();
1834        let debug = format!("{sensor:?}");
1835        assert!(debug.contains("ProcfsSensor"));
1836        assert!(debug.contains("cpu_loadavg"));
1837    }
1838
1839    #[test]
1840    fn procfs_sensor_custom_parser() {
1841        let sensor = ProcfsSensor::new(
1842            "test_parser",
1843            SensorKind::Custom,
1844            "/proc/loadavg",
1845            Box::new(|content: &str| {
1846                content.split_whitespace().next().and_then(|s| {
1847                    let v: f64 = s.parse().ok()?;
1848                    Some(v * 2.0)
1849                })
1850            }),
1851        );
1852        // Only test if /proc/loadavg exists
1853        if sensor.is_available() {
1854            let reading = sensor.read().unwrap();
1855            assert!(reading.value > 0.0);
1856        }
1857    }
1858
1859    // ── Discovery tests ────────────────────────────────────────────────
1860
1861    #[test]
1862    fn discover_thermal_sensors_returns_vec() {
1863        let sensors = discover_thermal_sensors();
1864        // On Linux, typically at least 1 thermal zone exists
1865        // On non-Linux, returns empty vec
1866        for s in &sensors {
1867            assert!(s.is_available());
1868            assert_eq!(s.kind(), SensorKind::Temperature);
1869        }
1870    }
1871
1872    #[test]
1873    fn discover_battery_sensors_returns_vec() {
1874        let sensors = discover_battery_sensors();
1875        for s in &sensors {
1876            assert!(s.is_available());
1877            assert_eq!(s.kind(), SensorKind::Power);
1878        }
1879    }
1880
1881    // ── linux_hardware_bus tests ───────────────────────────────────────
1882
1883    #[test]
1884    fn linux_hardware_bus_creation() {
1885        let bus = linux_hardware_bus();
1886        // On Linux with /proc, should have at least the loadavg + mem_pressure sensors
1887        let sensor_ids = bus.sensor_ids();
1888        // Just verify it doesn't panic and returns a valid bus
1889        assert!(bus.sensor_count() <= 50); // reasonable upper bound
1890        for id in &sensor_ids {
1891            assert!(!id.is_empty());
1892        }
1893    }
1894
1895    #[test]
1896    fn linux_hardware_bus_poll_all() {
1897        let mut bus = linux_hardware_bus();
1898        let readings = bus.poll_all();
1899        // Readings may be empty on non-Linux, but should not panic
1900        for r in &readings {
1901            assert!(!r.sensor_id.is_empty());
1902        }
1903    }
1904
1905    // ── parse_proc_kb tests ────────────────────────────────────────────
1906
1907    #[test]
1908    fn parse_proc_kb_extracts_value() {
1909        assert_eq!(
1910            parse_proc_kb("MemTotal:       16384000 kB"),
1911            Some(16_384_000)
1912        );
1913        assert_eq!(parse_proc_kb("MemAvailable:   8192000 kB"), Some(8_192_000));
1914        assert_eq!(parse_proc_kb("garbage"), None);
1915    }
1916
1917    // ── CpuUsageSensor tests ────────────────────────────────────────────
1918
1919    #[test]
1920    fn cpu_usage_sensor_construction() {
1921        let sensor = CpuUsageSensor::new();
1922        assert_eq!(sensor.id(), "cpu_usage");
1923        assert_eq!(sensor.kind(), SensorKind::Custom);
1924    }
1925
1926    #[test]
1927    fn cpu_usage_sensor_debug() {
1928        let sensor = CpuUsageSensor::new();
1929        let debug = format!("{sensor:?}");
1930        assert!(debug.contains("CpuUsageSensor"));
1931    }
1932
1933    #[test]
1934    fn cpu_usage_sensor_read_on_linux() {
1935        if !std::path::Path::new("/proc/stat").exists() {
1936            return;
1937        }
1938        let sensor = CpuUsageSensor::new();
1939        assert!(sensor.is_available());
1940        let reading = sensor.read().unwrap();
1941        assert_eq!(reading.sensor_id, "cpu_usage");
1942        assert!(reading.value >= 0.0 && reading.value <= 100.0);
1943    }
1944
1945    // ── DiskUsageSensor tests ───────────────────────────────────────────
1946
1947    #[test]
1948    fn disk_usage_sensor_construction() {
1949        let sensor = DiskUsageSensor::root();
1950        assert_eq!(sensor.id(), "disk_root");
1951        assert_eq!(sensor.kind(), SensorKind::Custom);
1952    }
1953
1954    #[test]
1955    fn disk_usage_sensor_nonexistent() {
1956        let sensor = DiskUsageSensor::new("test", "/nonexistent/mount/point");
1957        assert!(!sensor.is_available());
1958        assert!(sensor.read().is_none());
1959    }
1960
1961    #[test]
1962    fn disk_usage_sensor_debug() {
1963        let sensor = DiskUsageSensor::root();
1964        let debug = format!("{sensor:?}");
1965        assert!(debug.contains("DiskUsageSensor"));
1966    }
1967
1968    // ── NetworkThroughputSensor tests ───────────────────────────────────
1969
1970    #[test]
1971    fn network_sensor_construction() {
1972        let sensor = NetworkThroughputSensor::new("eth0");
1973        assert_eq!(sensor.id(), "net_eth0");
1974        assert_eq!(sensor.kind(), SensorKind::Custom);
1975    }
1976
1977    #[test]
1978    fn network_sensor_default_interface() {
1979        let sensor = NetworkThroughputSensor::default_interface();
1980        assert!(sensor.id().starts_with("net_"));
1981    }
1982
1983    #[test]
1984    fn network_sensor_debug() {
1985        let sensor = NetworkThroughputSensor::new("wlan0");
1986        let debug = format!("{sensor:?}");
1987        assert!(debug.contains("NetworkThroughputSensor"));
1988        assert!(debug.contains("wlan0"));
1989    }
1990
1991    #[test]
1992    fn network_sensor_read_on_linux() {
1993        if !std::path::Path::new("/proc/net/dev").exists() {
1994            return;
1995        }
1996        let sensor = NetworkThroughputSensor::default_interface();
1997        assert!(sensor.is_available());
1998        let reading = sensor.read();
1999        // May return None if interface doesn't match, but shouldn't panic
2000        if let Some(r) = reading {
2001            assert!(r.value >= 0.0);
2002        }
2003    }
2004
2005    // ── CpuFreqSensor tests ─────────────────────────────────────────────
2006
2007    #[test]
2008    fn cpu_freq_sensor_construction() {
2009        let sensor = CpuFreqSensor::new(0);
2010        assert_eq!(sensor.id(), "cpu0_freq");
2011        assert_eq!(sensor.kind(), SensorKind::Custom);
2012    }
2013
2014    #[test]
2015    fn cpu_freq_sensor_nonexistent() {
2016        let sensor = CpuFreqSensor::new(999);
2017        assert!(!sensor.is_available());
2018        assert!(sensor.read().is_none());
2019    }
2020
2021    #[test]
2022    fn cpu_freq_sensor_debug() {
2023        let sensor = CpuFreqSensor::new(0);
2024        let debug = format!("{sensor:?}");
2025        assert!(debug.contains("CpuFreqSensor"));
2026    }
2027
2028    // ── SysfsActuator tests ─────────────────────────────────────────────
2029
2030    #[test]
2031    fn sysfs_actuator_construction() {
2032        let actuator = SysfsActuator::fan_pwm(0, 1);
2033        assert_eq!(actuator.id(), "fan_pwm0_1");
2034        assert_eq!(actuator.kind(), ActuatorKind::Motor);
2035    }
2036
2037    #[test]
2038    fn sysfs_actuator_led_construction() {
2039        let actuator = SysfsActuator::led("power");
2040        assert_eq!(actuator.id(), "led_power");
2041        assert_eq!(actuator.kind(), ActuatorKind::Display);
2042    }
2043
2044    #[test]
2045    fn sysfs_actuator_nonexistent_path() {
2046        let actuator = SysfsActuator::new(
2047            "test",
2048            ActuatorKind::Motor,
2049            "/nonexistent/path/that/does/not/exist",
2050            1.0,
2051        );
2052        assert!(!actuator.is_available());
2053        let cmd = ActuatorCommand::new("test", ActuatorKind::Motor, 1.0);
2054        assert!(actuator.command(&cmd).is_err());
2055    }
2056
2057    #[test]
2058    fn sysfs_actuator_e_stop_nonexistent() {
2059        let actuator = SysfsActuator::new("test", ActuatorKind::Motor, "/nonexistent/path", 1.0);
2060        assert!(actuator.e_stop().is_err());
2061    }
2062
2063    #[test]
2064    fn sysfs_actuator_debug() {
2065        let actuator = SysfsActuator::fan_pwm(0, 1);
2066        let debug = format!("{actuator:?}");
2067        assert!(debug.contains("SysfsActuator"));
2068        assert!(debug.contains("fan_pwm0_1"));
2069    }
2070
2071    #[test]
2072    fn sysfs_actuator_scale_applied() {
2073        // Create actuator pointing to a temp file to verify scaling
2074        let tmp = tempfile::NamedTempFile::new().unwrap();
2075        let path = tmp.path().to_str().unwrap();
2076        let actuator = SysfsActuator::new("test", ActuatorKind::Display, path, 255.0);
2077        assert!(actuator.is_available());
2078        let cmd = ActuatorCommand::new("test", ActuatorKind::Display, 0.5);
2079        actuator.command(&cmd).unwrap();
2080        let written = std::fs::read_to_string(path).unwrap();
2081        assert_eq!(written.trim(), "128"); // 0.5 * 255 = 127.5 → 128
2082    }
2083
2084    // ── Actuator discovery tests ────────────────────────────────────────
2085
2086    #[test]
2087    fn discover_fan_actuators_returns_vec() {
2088        let actuators = discover_fan_actuators();
2089        for a in &actuators {
2090            assert!(a.is_available());
2091            assert_eq!(a.kind(), ActuatorKind::Motor);
2092        }
2093    }
2094
2095    #[test]
2096    fn discover_led_actuators_returns_vec() {
2097        let actuators = discover_led_actuators();
2098        for a in &actuators {
2099            assert!(a.is_available());
2100            assert_eq!(a.kind(), ActuatorKind::Display);
2101        }
2102    }
2103
2104    // ── Enhanced linux_hardware_bus tests ───────────────────────────────
2105
2106    #[test]
2107    fn linux_hardware_bus_includes_new_sensors() {
2108        let bus = linux_hardware_bus();
2109        let ids = bus.sensor_ids();
2110        // On Linux with /proc/stat, cpu_usage should be registered
2111        if std::path::Path::new("/proc/stat").exists() {
2112            assert!(ids.iter().any(|id| id == "cpu_usage"));
2113        }
2114        // Disk root should always be registered on any platform
2115        assert!(ids.iter().any(|id| id == "disk_root"));
2116    }
2117
2118    #[test]
2119    fn linux_hardware_bus_includes_actuators() {
2120        let bus = linux_hardware_bus();
2121        // Actuator count depends on hardware, but bus should be valid
2122        let actuator_ids = bus.actuator_ids();
2123        for id in &actuator_ids {
2124            assert!(!id.is_empty());
2125        }
2126    }
2127}