use std::path::Path;
use super::{SysRoots, read_i64, read_trimmed, sorted_entries};
use crate::snapshot::BatteryStats;
const RATE_SMOOTHING: f64 = 0.1;
const MIN_RATE: f64 = 1e-6;
pub fn read_battery(roots: &SysRoots) -> Option<BatteryStats> {
BatteryMeter::default().read(roots)
}
#[derive(Debug, Default)]
pub struct BatteryMeter {
rate: Option<(String, f64)>,
}
impl BatteryMeter {
pub fn read(&mut self, roots: &SysRoots) -> Option<BatteryStats> {
let class_dir = roots.sys.join("class/power_supply");
let batteries: Vec<Reading> = sorted_entries(&class_dir)
.iter()
.filter_map(|entry| read_one(&class_dir.join(entry)))
.collect();
if batteries.is_empty() {
self.rate = None;
return None;
}
let energy_now: Option<f64> = batteries.iter().map(|b| b.energy_now).sum();
let energy_full: Option<f64> = batteries.iter().map(|b| b.energy_full).sum();
let percent = match (energy_now, energy_full) {
(Some(now), Some(full)) if full > 0.0 => Some((100.0 * now / full) as f32),
_ => {
let known: Vec<f32> = batteries.iter().filter_map(|b| b.capacity).collect();
(!known.is_empty()).then(|| known.iter().sum::<f32>() / known.len() as f32)
}
};
let powers: Vec<f32> = batteries.iter().filter_map(|b| b.power_w).collect();
let power_w = (!powers.is_empty()).then(|| powers.iter().sum());
let status = ["Discharging", "Charging"]
.into_iter()
.find(|s| batteries.iter().any(|b| b.status == *s))
.map(str::to_owned)
.unwrap_or_else(|| batteries[0].status.clone());
let same_units = batteries
.iter()
.all(|b| b.charge_units == batteries[0].charge_units);
let design: Option<f64> = batteries.iter().map(|b| b.design_full).sum();
let health_percent = match (energy_full, design) {
(Some(full), Some(design)) if same_units && design > 0.0 => {
Some((100.0 * full / design) as f32)
}
_ => None,
};
let rate: Option<f64> = batteries.iter().map(|b| b.rate).sum();
let left = match status.as_str() {
"Discharging" => energy_now,
"Charging" => energy_full
.zip(energy_now)
.map(|(full, now)| (full - now).max(0.0)),
_ => None,
};
let time_left_secs = match (left, rate) {
(Some(left), Some(rate)) if same_units => {
let smoothed = match self.rate.take() {
Some((was, previous)) if was == status => {
previous + RATE_SMOOTHING * (rate - previous)
}
_ => rate,
};
self.rate = Some((status.clone(), smoothed));
(smoothed > MIN_RATE).then(|| (left / smoothed * 3600.0).round() as u64)
}
_ => {
self.rate = None;
None
}
};
Some(BatteryStats {
percent,
status,
power_w,
time_left_secs,
health_percent,
})
}
}
struct Reading {
status: String,
capacity: Option<f32>,
charge_units: bool,
energy_now: Option<f64>,
energy_full: Option<f64>,
design_full: Option<f64>,
rate: Option<f64>,
power_w: Option<f32>,
}
fn read_one(dir: &Path) -> Option<Reading> {
if read_trimmed(&dir.join("type"))? != "Battery"
|| read_trimmed(&dir.join("scope")).is_some_and(|s| s == "Device")
{
return None;
}
let value = |name: &str| read_i64(&dir.join(name));
let float = |name: &str| value(name).map(|v| v.unsigned_abs() as f64);
let volts = float("voltage_now").map(|uv| uv / 1e6);
let power_uw = float("power_now").or_else(|| Some(float("current_now")? * volts?));
let charge_units = !(value("energy_now").is_some() && value("energy_full").is_some());
let (energy_now, energy_full, design_full, rate) = if charge_units {
(
float("charge_now"),
float("charge_full"),
float("charge_full_design"),
float("current_now").or_else(|| Some(power_uw? / volts?)),
)
} else {
(
float("energy_now"),
float("energy_full"),
float("energy_full_design"),
power_uw,
)
};
Some(Reading {
status: read_trimmed(&dir.join("status")).unwrap_or_default(),
capacity: value("capacity").map(|c| c as f32),
charge_units,
energy_now,
energy_full,
design_full,
rate,
power_w: power_uw.map(|uw| (uw / 1e6) as f32),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linux::testutil::write;
fn roots(root: &Path) -> SysRoots {
SysRoots {
sys: root.join("sys"),
proc: root.join("proc"),
}
}
#[test]
fn reads_the_system_battery_and_skips_peripherals() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let ps = "sys/class/power_supply";
write(root, &format!("{ps}/AC/type"), "Mains\n");
write(root, &format!("{ps}/BAT0/type"), "Battery\n");
write(root, &format!("{ps}/BAT0/status"), "Discharging\n");
write(root, &format!("{ps}/BAT0/capacity"), "72\n");
write(root, &format!("{ps}/BAT0/energy_now"), "31500000\n");
write(root, &format!("{ps}/BAT0/energy_full"), "43750000\n");
write(root, &format!("{ps}/BAT0/power_now"), "12340000\n");
write(root, &format!("{ps}/hidpp_battery_0/type"), "Battery\n");
write(root, &format!("{ps}/hidpp_battery_0/scope"), "Device\n");
write(root, &format!("{ps}/hidpp_battery_0/capacity"), "5\n");
let battery = read_battery(&roots(root)).unwrap();
assert_eq!(battery.status, "Discharging");
assert!((battery.percent.unwrap() - 72.0).abs() < 0.1);
assert!((battery.power_w.unwrap() - 12.34).abs() < 0.01);
assert_eq!(battery.time_left_secs, Some(9190));
assert_eq!(battery.summary(), "72% -12W 2h33m");
assert_eq!(battery.health_percent, None);
}
#[test]
fn charge_and_current_based_drivers() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let ps = "sys/class/power_supply";
write(root, &format!("{ps}/BAT1/type"), "Battery\n");
write(root, &format!("{ps}/BAT1/status"), "Charging\n");
write(root, &format!("{ps}/BAT1/charge_now"), "2000000\n");
write(root, &format!("{ps}/BAT1/charge_full"), "4000000\n");
write(root, &format!("{ps}/BAT1/current_now"), "1500000\n");
write(root, &format!("{ps}/BAT1/voltage_now"), "12000000\n");
let battery = read_battery(&roots(root)).unwrap();
assert!((battery.percent.unwrap() - 50.0).abs() < 0.1);
assert!((battery.power_w.unwrap() - 18.0).abs() < 0.01);
assert_eq!(battery.summary(), "50% +18W 1h20m");
}
#[test]
fn time_left_follows_the_smoothed_rate_and_health_shows() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let bat = "sys/class/power_supply/BAT0";
write(root, &format!("{bat}/type"), "Battery\n");
write(root, &format!("{bat}/status"), "Discharging\n");
write(root, &format!("{bat}/energy_now"), "20000000\n");
write(root, &format!("{bat}/energy_full"), "43710000\n");
write(root, &format!("{bat}/energy_full_design"), "57000000\n");
write(root, &format!("{bat}/power_now"), "10000000\n");
let mut meter = BatteryMeter::default();
let first = meter.read(&roots(root)).unwrap();
assert_eq!(first.time_left_secs, Some(7200));
assert_eq!(first.health_note().as_deref(), Some("health 77%"));
write(root, &format!("{bat}/power_now"), "30000000\n");
let burst = meter.read(&roots(root)).unwrap();
assert_eq!(burst.time_left_secs, Some(6000));
write(root, &format!("{bat}/status"), "Charging\n");
let charging = meter.read(&roots(root)).unwrap();
assert_eq!(charging.time_left_secs, Some(2845));
assert!(charging.details().contains("until full"));
write(root, &format!("{bat}/status"), "Full\n");
assert_eq!(meter.read(&roots(root)).unwrap().time_left_secs, None);
}
#[test]
fn no_battery_on_a_desktop() {
let tmp = tempfile::tempdir().unwrap();
write(tmp.path(), "sys/class/power_supply/AC/type", "Mains\n");
assert_eq!(read_battery(&roots(tmp.path())), None);
let full = BatteryStats {
percent: Some(100.0),
status: "Full".to_owned(),
power_w: Some(0.0),
..Default::default()
};
assert_eq!(full.summary(), "100%");
}
}