use core::time::Duration;
use crate::model::{MetricState, UnavailableReason};
use crate::units::Percent;
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TemperatureReading {
pub label: Box<str>,
pub celsius: f32,
pub peak_celsius: Option<f32>,
pub critical_celsius: Option<f32>,
}
impl TemperatureReading {
#[must_use]
pub fn is_critical(&self) -> Option<bool> {
self.critical_celsius
.map(|threshold| self.celsius >= threshold)
}
#[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)
}
}
#[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,
Discharging,
Full,
NotCharging,
#[default]
Unknown,
}
impl ChargeState {
#[must_use]
pub const fn symbol(self) -> char {
match self {
Self::Charging => '+',
Self::Discharging => '-',
Self::Full => '=',
Self::NotCharging => '.',
Self::Unknown => '?',
}
}
#[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",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct BatteryCapacity {
pub design_microwatt_hours: u64,
pub full_microwatt_hours: u64,
}
impl BatteryCapacity {
#[must_use]
pub fn health(self) -> Option<Percent> {
Percent::ratio(self.full_microwatt_hours, self.design_microwatt_hours)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct BatterySnapshot {
pub charge: Percent,
pub state: ChargeState,
pub time_remaining: MetricState<Duration>,
pub cycle_count: MetricState<u32>,
pub capacity: MetricState<BatteryCapacity>,
pub temperature_celsius: MetricState<f32>,
pub power_watts: MetricState<f32>,
}
impl BatterySnapshot {
#[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 },
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)
}
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct SensorSnapshot {
pub temperatures: MetricState<Vec<TemperatureReading>>,
pub battery: MetricState<BatterySnapshot>,
}
impl SensorSnapshot {
#[must_use]
pub const fn warming_up() -> Self {
Self {
temperatures: MetricState::WarmingUp,
battery: MetricState::WarmingUp,
}
}
#[must_use]
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() {
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}");
assert_eq!(reading("pkg", 52.5, Some(0.0)).share_of_critical(), None);
}
#[test]
fn the_peak_is_not_offered_as_a_substitute_scale() {
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);
}
#[test]
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]
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]
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() {
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() {
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:?}");
assert_eq!(health.placeholder(), capacity.placeholder());
}
}
#[test]
fn a_zero_design_capacity_is_unusable_rather_than_zero_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() {
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() {
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);
}
}