monitrs-core 1.0.1

Platform-neutral data model, rate engine, history ring, and diagnostics for monitrs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! Temperature and battery readings.
//!
//! Both are optional everywhere: many servers expose no `hwmon` sensors, and
//! §9.3 forbids reaching for private macOS APIs to get them. Missing sensors are
//! [`MetricState::Unsupported`], never zero degrees.
//!
//! A battery is the sharpest case of that rule in the whole model. Every desktop,
//! every server, every CI runner and every container has none, so the *absence* of
//! a battery is the normal reading rather than the exception, and it is
//! [`MetricState::Unsupported`] — a fact about the hardware — rather than a
//! failure, a zero charge, or an empty panel.

use core::time::Duration;

use crate::model::{MetricState, UnavailableReason};
use crate::units::Percent;

/// One temperature sensor reading.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TemperatureReading {
    /// Sensor label, e.g. `coretemp Package id 0`.
    pub label: Box<str>,
    /// Current temperature in degrees Celsius.
    pub celsius: f32,
    /// The highest value this sensor has been seen at, where the platform offers one.
    ///
    /// **Not a threshold, and deliberately not named like one.** The underlying
    /// interface reports either the sensor's declared high limit or the maximum
    /// value observed since the process started, depending on the platform and the
    /// driver, and the two are indistinguishable from here. It is therefore useful
    /// as context — "it has been this hot" — and never usable as a full scale for a
    /// bar or a percentage. Only [`TemperatureReading::critical_celsius`] is a
    /// declared ceiling.
    pub peak_celsius: Option<f32>,
    /// The critical threshold the sensor reports, where available.
    ///
    /// The one figure here that is a genuine ceiling, which is why it is the only
    /// denominator anything is allowed to draw a scale against.
    pub critical_celsius: Option<f32>,
}

impl TemperatureReading {
    /// Whether the reading is at or above the sensor's own critical threshold.
    ///
    /// Returns `None` when the sensor reports no threshold. §11.3 forbids
    /// diagnosing thermal throttling from an ambiguous metric, so this only ever
    /// reports what the *sensor itself* declares critical, and the diagnostic
    /// engine draws no throttling conclusion from it.
    #[must_use]
    pub fn is_critical(&self) -> Option<bool> {
        self.critical_celsius
            .map(|threshold| self.celsius >= threshold)
    }

    /// The reading as a share of the sensor's own declared ceiling.
    ///
    /// `None` when the sensor declares none, which is what stops a caller drawing a
    /// bar: a temperature has no natural full scale, and 62 °C is most of the way to
    /// a laptop's limit while being barely warm for a GPU. Deliberately refuses
    /// [`TemperatureReading::peak_celsius`] as a substitute — a bar scaled against
    /// the highest value seen so far would sit at 100% forever.
    ///
    /// Lives here rather than in the UI so the refusal is the *model's*, and every
    /// screen that wants a thermal bar gets the same answer (§7.4's rule about
    /// utilization without a known capacity, applied to temperature).
    #[must_use]
    pub fn share_of_critical(&self) -> Option<Percent> {
        let ceiling = self.critical_celsius?;
        if ceiling <= 0.0 {
            return None;
        }
        Percent::new(self.celsius / ceiling * 100.0)
    }
}

/// Whether the battery is charging.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum ChargeState {
    /// Charging from external power.
    Charging,
    /// Running on battery.
    Discharging,
    /// At full charge on external power.
    Full,
    /// On external power but deliberately not charging.
    NotCharging,
    /// The platform did not report a state.
    #[default]
    Unknown,
}

impl ChargeState {
    /// A redundant non-color cue (§5.2).
    #[must_use]
    pub const fn symbol(self) -> char {
        match self {
            Self::Charging => '+',
            Self::Discharging => '-',
            Self::Full => '=',
            Self::NotCharging => '.',
            Self::Unknown => '?',
        }
    }

    /// Lower-case label.
    #[must_use]
    pub const fn label(self) -> &'static str {
        match self {
            Self::Charging => "charging",
            Self::Discharging => "discharging",
            Self::Full => "full",
            Self::NotCharging => "not charging",
            Self::Unknown => "unknown",
        }
    }
}

/// A battery's design capacity beside the capacity it can hold today.
///
/// The pair is one metric rather than two, because the only interesting thing
/// either number does is stand next to the other: 48 Wh means nothing until you
/// know the cell shipped holding 52 Wh. Keeping them together also makes
/// [`BatteryCapacity::health`] the *only* way to obtain a wear percentage, so a
/// health figure can never disagree with the capacities it was derived from.
///
/// Micro-watt-hours because that is the unit Linux's `energy_full_design` uses;
/// a collector holding amp-hours converts once, at the point it knows the cell
/// voltage, rather than leaving two possible units in the model.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct BatteryCapacity {
    /// What the cell held when it left the factory, in µWh.
    pub design_microwatt_hours: u64,
    /// What a full charge holds today, in µWh. This is the worn figure.
    pub full_microwatt_hours: u64,
}

impl BatteryCapacity {
    /// Today's full charge as a share of the design capacity: battery health.
    ///
    /// `None` when the design capacity is zero, which is not 0% health but an
    /// unusable pair of numbers (§4). Deliberately *not* clamped to 100: a cell
    /// whose first full charge measures above its design capacity is a real and
    /// common reading, and clamping it would hide a working battery behind a
    /// suspiciously exact figure.
    #[must_use]
    pub fn health(self) -> Option<Percent> {
        Percent::ratio(self.full_microwatt_hours, self.design_microwatt_hours)
    }
}

/// Battery state.
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct BatterySnapshot {
    /// Charge level.
    pub charge: Percent,
    /// Charging state.
    pub state: ChargeState,
    /// Time to empty while discharging, or to full while charging.
    ///
    /// Only ever what the platform itself reports. §4 forbids deriving one from a
    /// single sample: a figure computed from one instantaneous current reading
    /// swings by hours between consecutive samples, and a monitor that showed it
    /// would be inventing the one number users trust most.
    pub time_remaining: MetricState<Duration>,
    /// Charge cycles, where reported.
    pub cycle_count: MetricState<u32>,
    /// Design capacity beside present full-charge capacity, i.e. wear.
    pub capacity: MetricState<BatteryCapacity>,
    /// Cell temperature in degrees Celsius, where the pack reports one.
    ///
    /// Separate from [`SensorSnapshot::temperatures`] because it is not a machine
    /// sensor: it describes the pack, and a battery pack at 45 °C means something
    /// quite different from a CPU package at 45 °C.
    pub temperature_celsius: MetricState<f32>,
    /// Instantaneous power flowing through the pack, in watts.
    ///
    /// A magnitude, never signed. Direction is [`BatterySnapshot::state`]'s job:
    /// the sign of Linux's `current_now` is driver-dependent, so a signed watt
    /// figure here would mean "out" on one laptop and "in" on the next.
    pub power_watts: MetricState<f32>,
}

impl BatterySnapshot {
    /// Battery health, derived from the capacity pair and from nothing else.
    ///
    /// A method rather than a field so there is no way to store a health figure
    /// that contradicts the capacities beside it. An unavailable capacity keeps
    /// its own reason, so "no capacity reported" and "capacity refused" stay
    /// distinguishable on screen (§4).
    #[must_use]
    pub fn health(&self) -> MetricState<Percent> {
        match self.capacity.map(BatteryCapacity::health) {
            MetricState::Available(Some(health)) => MetricState::Available(health),
            MetricState::Stale {
                value: Some(health),
                age,
            } => MetricState::Stale { value: health, age },
            // A design capacity of zero is an unusable pair, not 0% health.
            MetricState::Available(None) | MetricState::Stale { value: None, .. } => {
                MetricState::TemporarilyUnavailable(UnavailableReason::ParseFailed)
            }
            MetricState::WarmingUp => MetricState::WarmingUp,
            MetricState::PermissionDenied => MetricState::PermissionDenied,
            MetricState::Unsupported => MetricState::Unsupported,
            MetricState::TemporarilyUnavailable(reason) => {
                MetricState::TemporarilyUnavailable(reason)
            }
        }
    }
}

/// All sensor readings.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct SensorSnapshot {
    /// Temperature sensors.
    pub temperatures: MetricState<Vec<TemperatureReading>>,
    /// Battery, on systems that have one.
    pub battery: MetricState<BatterySnapshot>,
}

impl SensorSnapshot {
    /// A snapshot with nothing measured yet.
    #[must_use]
    pub const fn warming_up() -> Self {
        Self {
            temperatures: MetricState::WarmingUp,
            battery: MetricState::WarmingUp,
        }
    }

    /// The hottest reading, for the compact overview summary (§7.1).
    ///
    /// **Deprecated in 1.0.0, and still behaves exactly as it always did.** It
    /// returns only a *freshly measured* reading: it filters to
    /// [`MetricState::fresh`], so a retained (`Stale`) list answers `None` here
    /// rather than handing back an aged value with no way to say how old it is.
    ///
    /// That filter used to be a safeguard and is now a trap. Sensors are read as
    /// their own group on their own cadence (§8.6): every 30 seconds while nobody
    /// is looking at the Battery screen, which is the state a running monitrs is
    /// in almost all of the time. Between reads the collectors carry the last list
    /// forward as `Stale { value, age }`, so `Stale` is the **normal** shape of
    /// `temperatures` at idle, not an exception — and a caller that reaches for
    /// this method to show "the hottest temperature" therefore gets `None` for
    /// most of the run, silently, with nothing in the type to warn it.
    ///
    /// Read [`SensorSnapshot::temperatures`] through the metric's own state
    /// instead — [`MetricState::displayable`] yields the retained value *together
    /// with its age*, so a reading that is 28 seconds old can be shown and dated
    /// rather than dropped. `temperature_display` in
    /// `crates/monitrs-tui/src/views/mod.rs` is that pattern in full: it takes the
    /// maximum inside `states::describe`, so the age travels with the figure onto
    /// the screen (`temp 62.5C ~00:28`) instead of being discarded here.
    ///
    /// Nothing in this workspace calls it any more. It is kept because 1.0.0
    /// freezes this crate's API and removal is a major bump; expect it to go in
    /// 2.0.0 at the earliest.
    #[must_use]
    #[deprecated(
        since = "1.0.0",
        note = "filters to freshly measured readings, so it answers None for the retained \
                (Stale) temperature list that is normal at idle — read \
                SensorSnapshot::temperatures through MetricState::displayable instead, which \
                yields the value together with its age"
    )]
    pub fn hottest(&self) -> Option<&TemperatureReading> {
        self.temperatures
            .fresh()?
            .iter()
            .max_by(|a, b| a.celsius.total_cmp(&b.celsius))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn reading(label: &str, celsius: f32, critical: Option<f32>) -> TemperatureReading {
        TemperatureReading {
            label: label.into(),
            celsius,
            peak_celsius: None,
            critical_celsius: critical,
        }
    }

    #[test]
    fn criticality_is_unknown_without_a_sensor_reported_threshold() {
        assert_eq!(reading("pkg", 95.0, None).is_critical(), None);
        assert_eq!(reading("pkg", 95.0, Some(100.0)).is_critical(), Some(false));
        assert_eq!(reading("pkg", 101.0, Some(100.0)).is_critical(), Some(true));
    }

    #[test]
    fn a_temperature_has_no_scale_without_a_declared_critical_threshold() {
        // The rule that stops a thermal bar being drawn against a made-up ceiling.
        // Real Apple Silicon sensors report no critical threshold at all, so on that
        // machine every one of these is `None` — and the screen shows the figure
        // without a bar rather than a bar without a meaning.
        assert_eq!(reading("ambient", 62.5, None).share_of_critical(), None);
        let scaled = reading("pkg", 52.5, Some(105.0))
            .share_of_critical()
            .expect("a declared ceiling");
        assert!((scaled.value() - 50.0).abs() < 0.01, "{scaled}");
        // A zero or negative ceiling is not a scale either; it is a broken sensor.
        assert_eq!(reading("pkg", 52.5, Some(0.0)).share_of_critical(), None);
    }

    #[test]
    fn the_peak_is_not_offered_as_a_substitute_scale() {
        // `peak_celsius` is the highest value *seen* on macOS and a declared limit on
        // some Linux drivers, and the two are indistinguishable here. A bar scaled
        // against the highest value seen would sit at 100% for the whole run.
        let mut hot = reading("pkg", 71.2, None);
        hot.peak_celsius = Some(72.1);
        assert_eq!(hot.share_of_critical(), None);
        assert_eq!(hot.is_critical(), None);
    }

    // The three tests below are the only remaining callers of
    // `SensorSnapshot::hottest`, which 1.0.0 deprecated *without* changing what it
    // returns. They still earn their place: they pin the exact behaviour the
    // deprecation note describes — `fresh()`-only filtering, and `None` for an
    // empty list — and that behaviour has to keep holding for as long as the method
    // exists, which is at least one minor cycle (`CONTRIBUTING.md`).
    //
    // The lint is expected on each test individually rather than on this module or
    // the crate. Silencing it wider would also silence a *new* deprecated call
    // somewhere else in monitrs-core, and `-D warnings` would no longer catch it.
    // `expect` rather than `allow` so that the day `hottest` goes away, these
    // attributes report themselves as unnecessary instead of lingering.

    #[test]
    #[expect(
        deprecated,
        reason = "pins the deprecated method's own documented behaviour; see the note above"
    )]
    fn missing_sensors_are_unsupported_not_zero_degrees() {
        let sensors = SensorSnapshot::warming_up();
        assert!(sensors.hottest().is_none());
        assert!(sensors.temperatures.fresh().is_none());
    }

    #[test]
    #[expect(
        deprecated,
        reason = "pins the deprecated method's own documented behaviour; see the note above"
    )]
    fn hottest_finds_the_maximum_reading() {
        let sensors = SensorSnapshot {
            temperatures: MetricState::Available(vec![
                reading("efficiency", 44.0, None),
                reading("performance", 78.5, None),
                reading("ambient", 31.0, None),
            ]),
            battery: MetricState::Unsupported,
        };
        let hottest = sensors.hottest().expect("three readings");
        assert_eq!(&*hottest.label, "performance");
    }

    #[test]
    #[expect(
        deprecated,
        reason = "pins the deprecated method's own documented behaviour; see the note above"
    )]
    fn an_empty_sensor_list_has_no_hottest_reading() {
        let sensors = SensorSnapshot {
            temperatures: MetricState::Available(Vec::new()),
            battery: MetricState::Unsupported,
        };
        assert!(sensors.hottest().is_none());
    }

    fn battery(capacity: MetricState<BatteryCapacity>) -> BatterySnapshot {
        BatterySnapshot {
            charge: Percent::new(82.0).unwrap_or(Percent::ZERO),
            state: ChargeState::Discharging,
            time_remaining: MetricState::Unsupported,
            cycle_count: MetricState::Unsupported,
            capacity,
            temperature_celsius: MetricState::Unsupported,
            power_watts: MetricState::Unsupported,
        }
    }

    #[test]
    fn health_is_the_worn_capacity_against_the_design_capacity() {
        // The number that tells a user the pack is worn. 48.2 of 52.6 Wh is a
        // four-year-old laptop; the figure has to come out of those two and not
        // out of a separate field that could drift away from them.
        let capacity = BatteryCapacity {
            design_microwatt_hours: 52_600_000,
            full_microwatt_hours: 48_200_000,
        };
        let health = capacity.health().expect("a non-zero design capacity");
        assert!((health.value() - 91.6).abs() < 0.1, "{health}");
        assert_eq!(
            battery(MetricState::Available(capacity)).health(),
            MetricState::Available(health)
        );
    }

    #[test]
    fn a_battery_reporting_no_capacity_reports_no_health_rather_than_zero_percent() {
        // §4: the one thing a worn-battery figure must never do is claim a pack is
        // 0% healthy because the platform declined to say how big it is.
        for capacity in [
            MetricState::Unsupported,
            MetricState::PermissionDenied,
            MetricState::WarmingUp,
        ] {
            let health = battery(capacity).health();
            assert!(health.fresh().is_none(), "{health:?}");
            assert!(health.displayable().is_none(), "{health:?}");
            // The reason survives the derivation, so "no such thing here" and
            // "the OS refused" stay distinguishable on screen.
            assert_eq!(health.placeholder(), capacity.placeholder());
        }
    }

    #[test]
    fn a_zero_design_capacity_is_unusable_rather_than_zero_health() {
        // Some ACPI firmware reports a design capacity of zero. Dividing by it
        // would either panic or produce infinity; either way it is not 0% health.
        let health = battery(MetricState::Available(BatteryCapacity {
            design_microwatt_hours: 0,
            full_microwatt_hours: 48_200_000,
        }))
        .health();
        assert!(health.fresh().is_none());
        assert_eq!(health.placeholder(), Some("unparsable data"));
    }

    #[test]
    fn health_above_one_hundred_percent_is_reported_as_measured() {
        // A new cell often measures above its design capacity. Clamping would
        // replace a real reading with a suspiciously exact one.
        let health = battery(MetricState::Available(BatteryCapacity {
            design_microwatt_hours: 50_000_000,
            full_microwatt_hours: 51_500_000,
        }))
        .health();
        let value = health.fresh().expect("measured").value();
        assert!(value > 100.0, "{value}");
    }

    #[test]
    fn a_stale_capacity_yields_a_stale_health_carrying_the_same_age() {
        // §4: a retained value may only be displayed with its age, and a figure
        // derived from a retained value is no fresher than its input.
        let age = Duration::from_secs(7);
        let stale = MetricState::Available(BatteryCapacity {
            design_microwatt_hours: 52_600_000,
            full_microwatt_hours: 48_200_000,
        })
        .into_stale(age);
        let health = battery(stale).health();
        assert!(health.is_stale());
        assert!(health.fresh().is_none());
        assert_eq!(health.displayable().map(|(_, age)| age), Some(age));
    }

    #[test]
    fn charge_state_symbols_are_distinguishable_without_color() {
        let mut symbols: Vec<char> = [
            ChargeState::Charging,
            ChargeState::Discharging,
            ChargeState::Full,
            ChargeState::NotCharging,
            ChargeState::Unknown,
        ]
        .iter()
        .map(|s| s.symbol())
        .collect();
        symbols.sort_unstable();
        symbols.dedup();
        assert_eq!(symbols.len(), 5);
    }
}