Skip to main content

monitrs_core/model/
sensors.rs

1//! Temperature and battery readings.
2//!
3//! Both are optional everywhere: many servers expose no `hwmon` sensors, and
4//! §9.3 forbids reaching for private macOS APIs to get them. Missing sensors are
5//! [`MetricState::Unsupported`], never zero degrees.
6
7use core::time::Duration;
8
9use crate::model::MetricState;
10use crate::units::Percent;
11
12/// One temperature sensor reading.
13#[derive(Clone, Debug, PartialEq)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize))]
15pub struct TemperatureReading {
16    /// Sensor label, e.g. `coretemp Package id 0`.
17    pub label: Box<str>,
18    /// Current temperature in degrees Celsius.
19    pub celsius: f32,
20    /// The high threshold the sensor reports, where available.
21    pub high_celsius: Option<f32>,
22    /// The critical threshold the sensor reports, where available.
23    pub critical_celsius: Option<f32>,
24}
25
26impl TemperatureReading {
27    /// Whether the reading is at or above the sensor's own critical threshold.
28    ///
29    /// Returns `None` when the sensor reports no threshold. §11.3 forbids
30    /// diagnosing thermal throttling from an ambiguous metric, so this only ever
31    /// reports what the *sensor itself* declares critical, and the diagnostic
32    /// engine draws no throttling conclusion from it.
33    #[must_use]
34    pub fn is_critical(&self) -> Option<bool> {
35        self.critical_celsius
36            .map(|threshold| self.celsius >= threshold)
37    }
38}
39
40/// Whether the battery is charging.
41#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize))]
43#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
44pub enum ChargeState {
45    /// Charging from external power.
46    Charging,
47    /// Running on battery.
48    Discharging,
49    /// At full charge on external power.
50    Full,
51    /// On external power but deliberately not charging.
52    NotCharging,
53    /// The platform did not report a state.
54    #[default]
55    Unknown,
56}
57
58impl ChargeState {
59    /// A redundant non-color cue (§5.2).
60    #[must_use]
61    pub const fn symbol(self) -> char {
62        match self {
63            Self::Charging => '+',
64            Self::Discharging => '-',
65            Self::Full => '=',
66            Self::NotCharging => '.',
67            Self::Unknown => '?',
68        }
69    }
70
71    /// Lower-case label.
72    #[must_use]
73    pub const fn label(self) -> &'static str {
74        match self {
75            Self::Charging => "charging",
76            Self::Discharging => "discharging",
77            Self::Full => "full",
78            Self::NotCharging => "not charging",
79            Self::Unknown => "unknown",
80        }
81    }
82}
83
84/// Battery state.
85#[derive(Clone, Copy, Debug, PartialEq)]
86#[cfg_attr(feature = "serde", derive(serde::Serialize))]
87pub struct BatterySnapshot {
88    /// Charge level.
89    pub charge: Percent,
90    /// Charging state.
91    pub state: ChargeState,
92    /// Estimated time to empty or to full.
93    pub time_remaining: MetricState<Duration>,
94    /// Charge cycles, where reported.
95    pub cycle_count: MetricState<u32>,
96    /// Full-charge capacity as a share of design capacity, i.e. battery health.
97    pub health: MetricState<Percent>,
98}
99
100/// All sensor readings.
101#[derive(Clone, Debug, PartialEq)]
102#[cfg_attr(feature = "serde", derive(serde::Serialize))]
103pub struct SensorSnapshot {
104    /// Temperature sensors.
105    pub temperatures: MetricState<Vec<TemperatureReading>>,
106    /// Battery, on systems that have one.
107    pub battery: MetricState<BatterySnapshot>,
108}
109
110impl SensorSnapshot {
111    /// A snapshot with nothing measured yet.
112    #[must_use]
113    pub const fn warming_up() -> Self {
114        Self {
115            temperatures: MetricState::WarmingUp,
116            battery: MetricState::WarmingUp,
117        }
118    }
119
120    /// The hottest reading, for the compact overview summary (§7.1).
121    #[must_use]
122    pub fn hottest(&self) -> Option<&TemperatureReading> {
123        self.temperatures
124            .fresh()?
125            .iter()
126            .max_by(|a, b| a.celsius.total_cmp(&b.celsius))
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn reading(label: &str, celsius: f32, critical: Option<f32>) -> TemperatureReading {
135        TemperatureReading {
136            label: label.into(),
137            celsius,
138            high_celsius: None,
139            critical_celsius: critical,
140        }
141    }
142
143    #[test]
144    fn criticality_is_unknown_without_a_sensor_reported_threshold() {
145        assert_eq!(reading("pkg", 95.0, None).is_critical(), None);
146        assert_eq!(reading("pkg", 95.0, Some(100.0)).is_critical(), Some(false));
147        assert_eq!(reading("pkg", 101.0, Some(100.0)).is_critical(), Some(true));
148    }
149
150    #[test]
151    fn missing_sensors_are_unsupported_not_zero_degrees() {
152        let sensors = SensorSnapshot::warming_up();
153        assert!(sensors.hottest().is_none());
154        assert!(sensors.temperatures.fresh().is_none());
155    }
156
157    #[test]
158    fn hottest_finds_the_maximum_reading() {
159        let sensors = SensorSnapshot {
160            temperatures: MetricState::Available(vec![
161                reading("efficiency", 44.0, None),
162                reading("performance", 78.5, None),
163                reading("ambient", 31.0, None),
164            ]),
165            battery: MetricState::Unsupported,
166        };
167        let hottest = sensors.hottest().expect("three readings");
168        assert_eq!(&*hottest.label, "performance");
169    }
170
171    #[test]
172    fn an_empty_sensor_list_has_no_hottest_reading() {
173        let sensors = SensorSnapshot {
174            temperatures: MetricState::Available(Vec::new()),
175            battery: MetricState::Unsupported,
176        };
177        assert!(sensors.hottest().is_none());
178    }
179
180    #[test]
181    fn charge_state_symbols_are_distinguishable_without_color() {
182        let mut symbols: Vec<char> = [
183            ChargeState::Charging,
184            ChargeState::Discharging,
185            ChargeState::Full,
186            ChargeState::NotCharging,
187            ChargeState::Unknown,
188        ]
189        .iter()
190        .map(|s| s.symbol())
191        .collect();
192        symbols.sort_unstable();
193        symbols.dedup();
194        assert_eq!(symbols.len(), 5);
195    }
196}