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//!
7//! A battery is the sharpest case of that rule in the whole model. Every desktop,
8//! every server, every CI runner and every container has none, so the *absence* of
9//! a battery is the normal reading rather than the exception, and it is
10//! [`MetricState::Unsupported`] — a fact about the hardware — rather than a
11//! failure, a zero charge, or an empty panel.
12
13use core::time::Duration;
14
15use crate::model::{MetricState, UnavailableReason};
16use crate::units::Percent;
17
18/// One temperature sensor reading.
19#[derive(Clone, Debug, PartialEq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize))]
21pub struct TemperatureReading {
22    /// Sensor label, e.g. `coretemp Package id 0`.
23    pub label: Box<str>,
24    /// Current temperature in degrees Celsius.
25    pub celsius: f32,
26    /// The highest value this sensor has been seen at, where the platform offers one.
27    ///
28    /// **Not a threshold, and deliberately not named like one.** The underlying
29    /// interface reports either the sensor's declared high limit or the maximum
30    /// value observed since the process started, depending on the platform and the
31    /// driver, and the two are indistinguishable from here. It is therefore useful
32    /// as context — "it has been this hot" — and never usable as a full scale for a
33    /// bar or a percentage. Only [`TemperatureReading::critical_celsius`] is a
34    /// declared ceiling.
35    pub peak_celsius: Option<f32>,
36    /// The critical threshold the sensor reports, where available.
37    ///
38    /// The one figure here that is a genuine ceiling, which is why it is the only
39    /// denominator anything is allowed to draw a scale against.
40    pub critical_celsius: Option<f32>,
41}
42
43impl TemperatureReading {
44    /// Whether the reading is at or above the sensor's own critical threshold.
45    ///
46    /// Returns `None` when the sensor reports no threshold. §11.3 forbids
47    /// diagnosing thermal throttling from an ambiguous metric, so this only ever
48    /// reports what the *sensor itself* declares critical, and the diagnostic
49    /// engine draws no throttling conclusion from it.
50    #[must_use]
51    pub fn is_critical(&self) -> Option<bool> {
52        self.critical_celsius
53            .map(|threshold| self.celsius >= threshold)
54    }
55
56    /// The reading as a share of the sensor's own declared ceiling.
57    ///
58    /// `None` when the sensor declares none, which is what stops a caller drawing a
59    /// bar: a temperature has no natural full scale, and 62 °C is most of the way to
60    /// a laptop's limit while being barely warm for a GPU. Deliberately refuses
61    /// [`TemperatureReading::peak_celsius`] as a substitute — a bar scaled against
62    /// the highest value seen so far would sit at 100% forever.
63    ///
64    /// Lives here rather than in the UI so the refusal is the *model's*, and every
65    /// screen that wants a thermal bar gets the same answer (§7.4's rule about
66    /// utilization without a known capacity, applied to temperature).
67    #[must_use]
68    pub fn share_of_critical(&self) -> Option<Percent> {
69        let ceiling = self.critical_celsius?;
70        if ceiling <= 0.0 {
71            return None;
72        }
73        Percent::new(self.celsius / ceiling * 100.0)
74    }
75}
76
77/// Whether the battery is charging.
78#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
79#[cfg_attr(feature = "serde", derive(serde::Serialize))]
80#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
81pub enum ChargeState {
82    /// Charging from external power.
83    Charging,
84    /// Running on battery.
85    Discharging,
86    /// At full charge on external power.
87    Full,
88    /// On external power but deliberately not charging.
89    NotCharging,
90    /// The platform did not report a state.
91    #[default]
92    Unknown,
93}
94
95impl ChargeState {
96    /// A redundant non-color cue (§5.2).
97    #[must_use]
98    pub const fn symbol(self) -> char {
99        match self {
100            Self::Charging => '+',
101            Self::Discharging => '-',
102            Self::Full => '=',
103            Self::NotCharging => '.',
104            Self::Unknown => '?',
105        }
106    }
107
108    /// Lower-case label.
109    #[must_use]
110    pub const fn label(self) -> &'static str {
111        match self {
112            Self::Charging => "charging",
113            Self::Discharging => "discharging",
114            Self::Full => "full",
115            Self::NotCharging => "not charging",
116            Self::Unknown => "unknown",
117        }
118    }
119}
120
121/// A battery's design capacity beside the capacity it can hold today.
122///
123/// The pair is one metric rather than two, because the only interesting thing
124/// either number does is stand next to the other: 48 Wh means nothing until you
125/// know the cell shipped holding 52 Wh. Keeping them together also makes
126/// [`BatteryCapacity::health`] the *only* way to obtain a wear percentage, so a
127/// health figure can never disagree with the capacities it was derived from.
128///
129/// Micro-watt-hours because that is the unit Linux's `energy_full_design` uses;
130/// a collector holding amp-hours converts once, at the point it knows the cell
131/// voltage, rather than leaving two possible units in the model.
132#[derive(Clone, Copy, Debug, Eq, PartialEq)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize))]
134pub struct BatteryCapacity {
135    /// What the cell held when it left the factory, in µWh.
136    pub design_microwatt_hours: u64,
137    /// What a full charge holds today, in µWh. This is the worn figure.
138    pub full_microwatt_hours: u64,
139}
140
141impl BatteryCapacity {
142    /// Today's full charge as a share of the design capacity: battery health.
143    ///
144    /// `None` when the design capacity is zero, which is not 0% health but an
145    /// unusable pair of numbers (§4). Deliberately *not* clamped to 100: a cell
146    /// whose first full charge measures above its design capacity is a real and
147    /// common reading, and clamping it would hide a working battery behind a
148    /// suspiciously exact figure.
149    #[must_use]
150    pub fn health(self) -> Option<Percent> {
151        Percent::ratio(self.full_microwatt_hours, self.design_microwatt_hours)
152    }
153}
154
155/// Battery state.
156#[derive(Clone, Copy, Debug, PartialEq)]
157#[cfg_attr(feature = "serde", derive(serde::Serialize))]
158pub struct BatterySnapshot {
159    /// Charge level.
160    pub charge: Percent,
161    /// Charging state.
162    pub state: ChargeState,
163    /// Time to empty while discharging, or to full while charging.
164    ///
165    /// Only ever what the platform itself reports. §4 forbids deriving one from a
166    /// single sample: a figure computed from one instantaneous current reading
167    /// swings by hours between consecutive samples, and a monitor that showed it
168    /// would be inventing the one number users trust most.
169    pub time_remaining: MetricState<Duration>,
170    /// Charge cycles, where reported.
171    pub cycle_count: MetricState<u32>,
172    /// Design capacity beside present full-charge capacity, i.e. wear.
173    pub capacity: MetricState<BatteryCapacity>,
174    /// Cell temperature in degrees Celsius, where the pack reports one.
175    ///
176    /// Separate from [`SensorSnapshot::temperatures`] because it is not a machine
177    /// sensor: it describes the pack, and a battery pack at 45 °C means something
178    /// quite different from a CPU package at 45 °C.
179    pub temperature_celsius: MetricState<f32>,
180    /// Instantaneous power flowing through the pack, in watts.
181    ///
182    /// A magnitude, never signed. Direction is [`BatterySnapshot::state`]'s job:
183    /// the sign of Linux's `current_now` is driver-dependent, so a signed watt
184    /// figure here would mean "out" on one laptop and "in" on the next.
185    pub power_watts: MetricState<f32>,
186}
187
188impl BatterySnapshot {
189    /// Battery health, derived from the capacity pair and from nothing else.
190    ///
191    /// A method rather than a field so there is no way to store a health figure
192    /// that contradicts the capacities beside it. An unavailable capacity keeps
193    /// its own reason, so "no capacity reported" and "capacity refused" stay
194    /// distinguishable on screen (§4).
195    #[must_use]
196    pub fn health(&self) -> MetricState<Percent> {
197        match self.capacity.map(BatteryCapacity::health) {
198            MetricState::Available(Some(health)) => MetricState::Available(health),
199            MetricState::Stale {
200                value: Some(health),
201                age,
202            } => MetricState::Stale { value: health, age },
203            // A design capacity of zero is an unusable pair, not 0% health.
204            MetricState::Available(None) | MetricState::Stale { value: None, .. } => {
205                MetricState::TemporarilyUnavailable(UnavailableReason::ParseFailed)
206            }
207            MetricState::WarmingUp => MetricState::WarmingUp,
208            MetricState::PermissionDenied => MetricState::PermissionDenied,
209            MetricState::Unsupported => MetricState::Unsupported,
210            MetricState::TemporarilyUnavailable(reason) => {
211                MetricState::TemporarilyUnavailable(reason)
212            }
213        }
214    }
215}
216
217/// All sensor readings.
218#[derive(Clone, Debug, PartialEq)]
219#[cfg_attr(feature = "serde", derive(serde::Serialize))]
220pub struct SensorSnapshot {
221    /// Temperature sensors.
222    pub temperatures: MetricState<Vec<TemperatureReading>>,
223    /// Battery, on systems that have one.
224    pub battery: MetricState<BatterySnapshot>,
225}
226
227impl SensorSnapshot {
228    /// A snapshot with nothing measured yet.
229    #[must_use]
230    pub const fn warming_up() -> Self {
231        Self {
232            temperatures: MetricState::WarmingUp,
233            battery: MetricState::WarmingUp,
234        }
235    }
236
237    /// The hottest reading, for the compact overview summary (§7.1).
238    #[must_use]
239    pub fn hottest(&self) -> Option<&TemperatureReading> {
240        self.temperatures
241            .fresh()?
242            .iter()
243            .max_by(|a, b| a.celsius.total_cmp(&b.celsius))
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn reading(label: &str, celsius: f32, critical: Option<f32>) -> TemperatureReading {
252        TemperatureReading {
253            label: label.into(),
254            celsius,
255            peak_celsius: None,
256            critical_celsius: critical,
257        }
258    }
259
260    #[test]
261    fn criticality_is_unknown_without_a_sensor_reported_threshold() {
262        assert_eq!(reading("pkg", 95.0, None).is_critical(), None);
263        assert_eq!(reading("pkg", 95.0, Some(100.0)).is_critical(), Some(false));
264        assert_eq!(reading("pkg", 101.0, Some(100.0)).is_critical(), Some(true));
265    }
266
267    #[test]
268    fn a_temperature_has_no_scale_without_a_declared_critical_threshold() {
269        // The rule that stops a thermal bar being drawn against a made-up ceiling.
270        // Real Apple Silicon sensors report no critical threshold at all, so on that
271        // machine every one of these is `None` — and the screen shows the figure
272        // without a bar rather than a bar without a meaning.
273        assert_eq!(reading("ambient", 62.5, None).share_of_critical(), None);
274        let scaled = reading("pkg", 52.5, Some(105.0))
275            .share_of_critical()
276            .expect("a declared ceiling");
277        assert!((scaled.value() - 50.0).abs() < 0.01, "{scaled}");
278        // A zero or negative ceiling is not a scale either; it is a broken sensor.
279        assert_eq!(reading("pkg", 52.5, Some(0.0)).share_of_critical(), None);
280    }
281
282    #[test]
283    fn the_peak_is_not_offered_as_a_substitute_scale() {
284        // `peak_celsius` is the highest value *seen* on macOS and a declared limit on
285        // some Linux drivers, and the two are indistinguishable here. A bar scaled
286        // against the highest value seen would sit at 100% for the whole run.
287        let mut hot = reading("pkg", 71.2, None);
288        hot.peak_celsius = Some(72.1);
289        assert_eq!(hot.share_of_critical(), None);
290        assert_eq!(hot.is_critical(), None);
291    }
292
293    #[test]
294    fn missing_sensors_are_unsupported_not_zero_degrees() {
295        let sensors = SensorSnapshot::warming_up();
296        assert!(sensors.hottest().is_none());
297        assert!(sensors.temperatures.fresh().is_none());
298    }
299
300    #[test]
301    fn hottest_finds_the_maximum_reading() {
302        let sensors = SensorSnapshot {
303            temperatures: MetricState::Available(vec![
304                reading("efficiency", 44.0, None),
305                reading("performance", 78.5, None),
306                reading("ambient", 31.0, None),
307            ]),
308            battery: MetricState::Unsupported,
309        };
310        let hottest = sensors.hottest().expect("three readings");
311        assert_eq!(&*hottest.label, "performance");
312    }
313
314    #[test]
315    fn an_empty_sensor_list_has_no_hottest_reading() {
316        let sensors = SensorSnapshot {
317            temperatures: MetricState::Available(Vec::new()),
318            battery: MetricState::Unsupported,
319        };
320        assert!(sensors.hottest().is_none());
321    }
322
323    fn battery(capacity: MetricState<BatteryCapacity>) -> BatterySnapshot {
324        BatterySnapshot {
325            charge: Percent::new(82.0).unwrap_or(Percent::ZERO),
326            state: ChargeState::Discharging,
327            time_remaining: MetricState::Unsupported,
328            cycle_count: MetricState::Unsupported,
329            capacity,
330            temperature_celsius: MetricState::Unsupported,
331            power_watts: MetricState::Unsupported,
332        }
333    }
334
335    #[test]
336    fn health_is_the_worn_capacity_against_the_design_capacity() {
337        // The number that tells a user the pack is worn. 48.2 of 52.6 Wh is a
338        // four-year-old laptop; the figure has to come out of those two and not
339        // out of a separate field that could drift away from them.
340        let capacity = BatteryCapacity {
341            design_microwatt_hours: 52_600_000,
342            full_microwatt_hours: 48_200_000,
343        };
344        let health = capacity.health().expect("a non-zero design capacity");
345        assert!((health.value() - 91.6).abs() < 0.1, "{health}");
346        assert_eq!(
347            battery(MetricState::Available(capacity)).health(),
348            MetricState::Available(health)
349        );
350    }
351
352    #[test]
353    fn a_battery_reporting_no_capacity_reports_no_health_rather_than_zero_percent() {
354        // §4: the one thing a worn-battery figure must never do is claim a pack is
355        // 0% healthy because the platform declined to say how big it is.
356        for capacity in [
357            MetricState::Unsupported,
358            MetricState::PermissionDenied,
359            MetricState::WarmingUp,
360        ] {
361            let health = battery(capacity).health();
362            assert!(health.fresh().is_none(), "{health:?}");
363            assert!(health.displayable().is_none(), "{health:?}");
364            // The reason survives the derivation, so "no such thing here" and
365            // "the OS refused" stay distinguishable on screen.
366            assert_eq!(health.placeholder(), capacity.placeholder());
367        }
368    }
369
370    #[test]
371    fn a_zero_design_capacity_is_unusable_rather_than_zero_health() {
372        // Some ACPI firmware reports a design capacity of zero. Dividing by it
373        // would either panic or produce infinity; either way it is not 0% health.
374        let health = battery(MetricState::Available(BatteryCapacity {
375            design_microwatt_hours: 0,
376            full_microwatt_hours: 48_200_000,
377        }))
378        .health();
379        assert!(health.fresh().is_none());
380        assert_eq!(health.placeholder(), Some("unparsable data"));
381    }
382
383    #[test]
384    fn health_above_one_hundred_percent_is_reported_as_measured() {
385        // A new cell often measures above its design capacity. Clamping would
386        // replace a real reading with a suspiciously exact one.
387        let health = battery(MetricState::Available(BatteryCapacity {
388            design_microwatt_hours: 50_000_000,
389            full_microwatt_hours: 51_500_000,
390        }))
391        .health();
392        let value = health.fresh().expect("measured").value();
393        assert!(value > 100.0, "{value}");
394    }
395
396    #[test]
397    fn a_stale_capacity_yields_a_stale_health_carrying_the_same_age() {
398        // §4: a retained value may only be displayed with its age, and a figure
399        // derived from a retained value is no fresher than its input.
400        let age = Duration::from_secs(7);
401        let stale = MetricState::Available(BatteryCapacity {
402            design_microwatt_hours: 52_600_000,
403            full_microwatt_hours: 48_200_000,
404        })
405        .into_stale(age);
406        let health = battery(stale).health();
407        assert!(health.is_stale());
408        assert!(health.fresh().is_none());
409        assert_eq!(health.displayable().map(|(_, age)| age), Some(age));
410    }
411
412    #[test]
413    fn charge_state_symbols_are_distinguishable_without_color() {
414        let mut symbols: Vec<char> = [
415            ChargeState::Charging,
416            ChargeState::Discharging,
417            ChargeState::Full,
418            ChargeState::NotCharging,
419            ChargeState::Unknown,
420        ]
421        .iter()
422        .map(|s| s.symbol())
423        .collect();
424        symbols.sort_unstable();
425        symbols.dedup();
426        assert_eq!(symbols.len(), 5);
427    }
428}