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}