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    ///
239    /// **Deprecated in 1.0.0, and still behaves exactly as it always did.** It
240    /// returns only a *freshly measured* reading: it filters to
241    /// [`MetricState::fresh`], so a retained (`Stale`) list answers `None` here
242    /// rather than handing back an aged value with no way to say how old it is.
243    ///
244    /// That filter used to be a safeguard and is now a trap. Sensors are read as
245    /// their own group on their own cadence (§8.6): every 30 seconds while nobody
246    /// is looking at the Battery screen, which is the state a running monitrs is
247    /// in almost all of the time. Between reads the collectors carry the last list
248    /// forward as `Stale { value, age }`, so `Stale` is the **normal** shape of
249    /// `temperatures` at idle, not an exception — and a caller that reaches for
250    /// this method to show "the hottest temperature" therefore gets `None` for
251    /// most of the run, silently, with nothing in the type to warn it.
252    ///
253    /// Read [`SensorSnapshot::temperatures`] through the metric's own state
254    /// instead — [`MetricState::displayable`] yields the retained value *together
255    /// with its age*, so a reading that is 28 seconds old can be shown and dated
256    /// rather than dropped. `temperature_display` in
257    /// `crates/monitrs-tui/src/views/mod.rs` is that pattern in full: it takes the
258    /// maximum inside `states::describe`, so the age travels with the figure onto
259    /// the screen (`temp 62.5C ~00:28`) instead of being discarded here.
260    ///
261    /// Nothing in this workspace calls it any more. It is kept because 1.0.0
262    /// freezes this crate's API and removal is a major bump; expect it to go in
263    /// 2.0.0 at the earliest.
264    #[must_use]
265    #[deprecated(
266        since = "1.0.0",
267        note = "filters to freshly measured readings, so it answers None for the retained \
268                (Stale) temperature list that is normal at idle — read \
269                SensorSnapshot::temperatures through MetricState::displayable instead, which \
270                yields the value together with its age"
271    )]
272    pub fn hottest(&self) -> Option<&TemperatureReading> {
273        self.temperatures
274            .fresh()?
275            .iter()
276            .max_by(|a, b| a.celsius.total_cmp(&b.celsius))
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn reading(label: &str, celsius: f32, critical: Option<f32>) -> TemperatureReading {
285        TemperatureReading {
286            label: label.into(),
287            celsius,
288            peak_celsius: None,
289            critical_celsius: critical,
290        }
291    }
292
293    #[test]
294    fn criticality_is_unknown_without_a_sensor_reported_threshold() {
295        assert_eq!(reading("pkg", 95.0, None).is_critical(), None);
296        assert_eq!(reading("pkg", 95.0, Some(100.0)).is_critical(), Some(false));
297        assert_eq!(reading("pkg", 101.0, Some(100.0)).is_critical(), Some(true));
298    }
299
300    #[test]
301    fn a_temperature_has_no_scale_without_a_declared_critical_threshold() {
302        // The rule that stops a thermal bar being drawn against a made-up ceiling.
303        // Real Apple Silicon sensors report no critical threshold at all, so on that
304        // machine every one of these is `None` — and the screen shows the figure
305        // without a bar rather than a bar without a meaning.
306        assert_eq!(reading("ambient", 62.5, None).share_of_critical(), None);
307        let scaled = reading("pkg", 52.5, Some(105.0))
308            .share_of_critical()
309            .expect("a declared ceiling");
310        assert!((scaled.value() - 50.0).abs() < 0.01, "{scaled}");
311        // A zero or negative ceiling is not a scale either; it is a broken sensor.
312        assert_eq!(reading("pkg", 52.5, Some(0.0)).share_of_critical(), None);
313    }
314
315    #[test]
316    fn the_peak_is_not_offered_as_a_substitute_scale() {
317        // `peak_celsius` is the highest value *seen* on macOS and a declared limit on
318        // some Linux drivers, and the two are indistinguishable here. A bar scaled
319        // against the highest value seen would sit at 100% for the whole run.
320        let mut hot = reading("pkg", 71.2, None);
321        hot.peak_celsius = Some(72.1);
322        assert_eq!(hot.share_of_critical(), None);
323        assert_eq!(hot.is_critical(), None);
324    }
325
326    // The three tests below are the only remaining callers of
327    // `SensorSnapshot::hottest`, which 1.0.0 deprecated *without* changing what it
328    // returns. They still earn their place: they pin the exact behaviour the
329    // deprecation note describes — `fresh()`-only filtering, and `None` for an
330    // empty list — and that behaviour has to keep holding for as long as the method
331    // exists, which is at least one minor cycle (`CONTRIBUTING.md`).
332    //
333    // The lint is expected on each test individually rather than on this module or
334    // the crate. Silencing it wider would also silence a *new* deprecated call
335    // somewhere else in monitrs-core, and `-D warnings` would no longer catch it.
336    // `expect` rather than `allow` so that the day `hottest` goes away, these
337    // attributes report themselves as unnecessary instead of lingering.
338
339    #[test]
340    #[expect(
341        deprecated,
342        reason = "pins the deprecated method's own documented behaviour; see the note above"
343    )]
344    fn missing_sensors_are_unsupported_not_zero_degrees() {
345        let sensors = SensorSnapshot::warming_up();
346        assert!(sensors.hottest().is_none());
347        assert!(sensors.temperatures.fresh().is_none());
348    }
349
350    #[test]
351    #[expect(
352        deprecated,
353        reason = "pins the deprecated method's own documented behaviour; see the note above"
354    )]
355    fn hottest_finds_the_maximum_reading() {
356        let sensors = SensorSnapshot {
357            temperatures: MetricState::Available(vec![
358                reading("efficiency", 44.0, None),
359                reading("performance", 78.5, None),
360                reading("ambient", 31.0, None),
361            ]),
362            battery: MetricState::Unsupported,
363        };
364        let hottest = sensors.hottest().expect("three readings");
365        assert_eq!(&*hottest.label, "performance");
366    }
367
368    #[test]
369    #[expect(
370        deprecated,
371        reason = "pins the deprecated method's own documented behaviour; see the note above"
372    )]
373    fn an_empty_sensor_list_has_no_hottest_reading() {
374        let sensors = SensorSnapshot {
375            temperatures: MetricState::Available(Vec::new()),
376            battery: MetricState::Unsupported,
377        };
378        assert!(sensors.hottest().is_none());
379    }
380
381    fn battery(capacity: MetricState<BatteryCapacity>) -> BatterySnapshot {
382        BatterySnapshot {
383            charge: Percent::new(82.0).unwrap_or(Percent::ZERO),
384            state: ChargeState::Discharging,
385            time_remaining: MetricState::Unsupported,
386            cycle_count: MetricState::Unsupported,
387            capacity,
388            temperature_celsius: MetricState::Unsupported,
389            power_watts: MetricState::Unsupported,
390        }
391    }
392
393    #[test]
394    fn health_is_the_worn_capacity_against_the_design_capacity() {
395        // The number that tells a user the pack is worn. 48.2 of 52.6 Wh is a
396        // four-year-old laptop; the figure has to come out of those two and not
397        // out of a separate field that could drift away from them.
398        let capacity = BatteryCapacity {
399            design_microwatt_hours: 52_600_000,
400            full_microwatt_hours: 48_200_000,
401        };
402        let health = capacity.health().expect("a non-zero design capacity");
403        assert!((health.value() - 91.6).abs() < 0.1, "{health}");
404        assert_eq!(
405            battery(MetricState::Available(capacity)).health(),
406            MetricState::Available(health)
407        );
408    }
409
410    #[test]
411    fn a_battery_reporting_no_capacity_reports_no_health_rather_than_zero_percent() {
412        // §4: the one thing a worn-battery figure must never do is claim a pack is
413        // 0% healthy because the platform declined to say how big it is.
414        for capacity in [
415            MetricState::Unsupported,
416            MetricState::PermissionDenied,
417            MetricState::WarmingUp,
418        ] {
419            let health = battery(capacity).health();
420            assert!(health.fresh().is_none(), "{health:?}");
421            assert!(health.displayable().is_none(), "{health:?}");
422            // The reason survives the derivation, so "no such thing here" and
423            // "the OS refused" stay distinguishable on screen.
424            assert_eq!(health.placeholder(), capacity.placeholder());
425        }
426    }
427
428    #[test]
429    fn a_zero_design_capacity_is_unusable_rather_than_zero_health() {
430        // Some ACPI firmware reports a design capacity of zero. Dividing by it
431        // would either panic or produce infinity; either way it is not 0% health.
432        let health = battery(MetricState::Available(BatteryCapacity {
433            design_microwatt_hours: 0,
434            full_microwatt_hours: 48_200_000,
435        }))
436        .health();
437        assert!(health.fresh().is_none());
438        assert_eq!(health.placeholder(), Some("unparsable data"));
439    }
440
441    #[test]
442    fn health_above_one_hundred_percent_is_reported_as_measured() {
443        // A new cell often measures above its design capacity. Clamping would
444        // replace a real reading with a suspiciously exact one.
445        let health = battery(MetricState::Available(BatteryCapacity {
446            design_microwatt_hours: 50_000_000,
447            full_microwatt_hours: 51_500_000,
448        }))
449        .health();
450        let value = health.fresh().expect("measured").value();
451        assert!(value > 100.0, "{value}");
452    }
453
454    #[test]
455    fn a_stale_capacity_yields_a_stale_health_carrying_the_same_age() {
456        // §4: a retained value may only be displayed with its age, and a figure
457        // derived from a retained value is no fresher than its input.
458        let age = Duration::from_secs(7);
459        let stale = MetricState::Available(BatteryCapacity {
460            design_microwatt_hours: 52_600_000,
461            full_microwatt_hours: 48_200_000,
462        })
463        .into_stale(age);
464        let health = battery(stale).health();
465        assert!(health.is_stale());
466        assert!(health.fresh().is_none());
467        assert_eq!(health.displayable().map(|(_, age)| age), Some(age));
468    }
469
470    #[test]
471    fn charge_state_symbols_are_distinguishable_without_color() {
472        let mut symbols: Vec<char> = [
473            ChargeState::Charging,
474            ChargeState::Discharging,
475            ChargeState::Full,
476            ChargeState::NotCharging,
477            ChargeState::Unknown,
478        ]
479        .iter()
480        .map(|s| s.symbol())
481        .collect();
482        symbols.sort_unstable();
483        symbols.dedup();
484        assert_eq!(symbols.len(), 5);
485    }
486}