Skip to main content

rigtop_core/linux/
battery.rs

1//! Laptop batteries from `/sys/class/power_supply`.
2//!
3//! Each supply has a `type`; system batteries are `Battery`. Batteries of
4//! peripherals (wireless mice, keyboards) are also `Battery` but have
5//! `scope` set to `Device`, and are left out.
6
7use std::path::Path;
8
9use super::{SysRoots, read_i64, read_trimmed, sorted_entries};
10use crate::snapshot::BatteryStats;
11
12/// Combined state of the system batteries, or `None` without any.
13pub fn read_battery(roots: &SysRoots) -> Option<BatteryStats> {
14    let class_dir = roots.sys.join("class/power_supply");
15    let batteries: Vec<Reading> = sorted_entries(&class_dir)
16        .iter()
17        .filter_map(|entry| read_one(&class_dir.join(entry)))
18        .collect();
19    if batteries.is_empty() {
20        return None;
21    }
22    // Weight the charge by capacity when energies are known (two batteries
23    // of different sizes); otherwise average the percentages.
24    let energy_now: Option<f64> = batteries.iter().map(|b| b.energy_now).sum();
25    let energy_full: Option<f64> = batteries.iter().map(|b| b.energy_full).sum();
26    let percent = match (energy_now, energy_full) {
27        (Some(now), Some(full)) if full > 0.0 => Some((100.0 * now / full) as f32),
28        _ => {
29            let known: Vec<f32> = batteries.iter().filter_map(|b| b.capacity).collect();
30            (!known.is_empty()).then(|| known.iter().sum::<f32>() / known.len() as f32)
31        }
32    };
33    let powers: Vec<f32> = batteries.iter().filter_map(|b| b.power_w).collect();
34    let power_w = (!powers.is_empty()).then(|| powers.iter().sum());
35    let status = ["Discharging", "Charging"]
36        .into_iter()
37        .find(|s| batteries.iter().any(|b| b.status == *s))
38        .map(str::to_owned)
39        .unwrap_or_else(|| batteries[0].status.clone());
40    Some(BatteryStats {
41        percent,
42        status,
43        power_w,
44    })
45}
46
47/// One battery's attributes.
48struct Reading {
49    status: String,
50    capacity: Option<f32>,
51    /// Energy in µWh (or charge in µAh, consistently per battery).
52    energy_now: Option<f64>,
53    energy_full: Option<f64>,
54    power_w: Option<f32>,
55}
56
57fn read_one(dir: &Path) -> Option<Reading> {
58    if read_trimmed(&dir.join("type"))? != "Battery"
59        || read_trimmed(&dir.join("scope")).is_some_and(|s| s == "Device")
60    {
61        return None;
62    }
63    let value = |name: &str| read_i64(&dir.join(name));
64    // Drivers report either energy (µWh) or charge (µAh); power either
65    // directly (µW) or as current (µA) times voltage (µV).
66    let (energy_now, energy_full) = match (value("energy_now"), value("energy_full")) {
67        (Some(now), Some(full)) => (Some(now as f64), Some(full as f64)),
68        _ => (
69            value("charge_now").map(|v| v as f64),
70            value("charge_full").map(|v| v as f64),
71        ),
72    };
73    let power_w = value("power_now")
74        .map(|uw| uw.unsigned_abs() as f32 / 1e6)
75        .or_else(|| {
76            let amps = value("current_now")?.unsigned_abs() as f64 / 1e6;
77            let volts = value("voltage_now")?.unsigned_abs() as f64 / 1e6;
78            Some((amps * volts) as f32)
79        });
80    Some(Reading {
81        status: read_trimmed(&dir.join("status")).unwrap_or_default(),
82        capacity: value("capacity").map(|c| c as f32),
83        energy_now,
84        energy_full,
85        power_w,
86    })
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::linux::testutil::write;
93
94    fn roots(root: &Path) -> SysRoots {
95        SysRoots {
96            sys: root.join("sys"),
97            proc: root.join("proc"),
98        }
99    }
100
101    #[test]
102    fn reads_the_system_battery_and_skips_peripherals() {
103        let tmp = tempfile::tempdir().unwrap();
104        let root = tmp.path();
105        let ps = "sys/class/power_supply";
106        write(root, &format!("{ps}/AC/type"), "Mains\n");
107        write(root, &format!("{ps}/BAT0/type"), "Battery\n");
108        write(root, &format!("{ps}/BAT0/status"), "Discharging\n");
109        write(root, &format!("{ps}/BAT0/capacity"), "72\n");
110        write(root, &format!("{ps}/BAT0/energy_now"), "31500000\n");
111        write(root, &format!("{ps}/BAT0/energy_full"), "43750000\n");
112        write(root, &format!("{ps}/BAT0/power_now"), "12340000\n");
113        // A wireless mouse.
114        write(root, &format!("{ps}/hidpp_battery_0/type"), "Battery\n");
115        write(root, &format!("{ps}/hidpp_battery_0/scope"), "Device\n");
116        write(root, &format!("{ps}/hidpp_battery_0/capacity"), "5\n");
117
118        let battery = read_battery(&roots(root)).unwrap();
119        assert_eq!(battery.status, "Discharging");
120        assert!((battery.percent.unwrap() - 72.0).abs() < 0.1);
121        assert!((battery.power_w.unwrap() - 12.34).abs() < 0.01);
122        assert_eq!(battery.summary(), "72% -12W");
123    }
124
125    #[test]
126    fn charge_and_current_based_drivers() {
127        let tmp = tempfile::tempdir().unwrap();
128        let root = tmp.path();
129        let ps = "sys/class/power_supply";
130        write(root, &format!("{ps}/BAT1/type"), "Battery\n");
131        write(root, &format!("{ps}/BAT1/status"), "Charging\n");
132        write(root, &format!("{ps}/BAT1/charge_now"), "2000000\n");
133        write(root, &format!("{ps}/BAT1/charge_full"), "4000000\n");
134        write(root, &format!("{ps}/BAT1/current_now"), "1500000\n");
135        write(root, &format!("{ps}/BAT1/voltage_now"), "12000000\n");
136        let battery = read_battery(&roots(root)).unwrap();
137        assert!((battery.percent.unwrap() - 50.0).abs() < 0.1);
138        assert!((battery.power_w.unwrap() - 18.0).abs() < 0.01);
139        assert_eq!(battery.summary(), "50% +18W");
140    }
141
142    #[test]
143    fn no_battery_on_a_desktop() {
144        let tmp = tempfile::tempdir().unwrap();
145        write(tmp.path(), "sys/class/power_supply/AC/type", "Mains\n");
146        assert_eq!(read_battery(&roots(tmp.path())), None);
147        // Full and idle: no power figure.
148        let full = BatteryStats {
149            percent: Some(100.0),
150            status: "Full".to_owned(),
151            power_w: Some(0.0),
152        };
153        assert_eq!(full.summary(), "100%");
154    }
155}