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}