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/// Weight of a new rate reading in the smoothed rate: about 10 samples
13/// (seconds at the default interval) to follow a change, so the time left
14/// does not jump with every short load.
15const RATE_SMOOTHING: f64 = 0.1;
16
17/// Shortest time left shown; below a few seconds' worth the rate is noise.
18const MIN_RATE: f64 = 1e-6;
19
20/// Combined state of the system batteries, or `None` without any, with the
21/// time left at the current rate (see [`BatteryMeter`] for a steadier one).
22pub fn read_battery(roots: &SysRoots) -> Option<BatteryStats> {
23    BatteryMeter::default().read(roots)
24}
25
26/// Reads the batteries and smooths the charge or discharge rate between
27/// readings for the time left.
28#[derive(Debug, Default)]
29pub struct BatteryMeter {
30    /// Smoothed rate, and the status it was measured in.
31    rate: Option<(String, f64)>,
32}
33
34impl BatteryMeter {
35    /// Combined state of the system batteries, or `None` without any.
36    pub fn read(&mut self, roots: &SysRoots) -> Option<BatteryStats> {
37        let class_dir = roots.sys.join("class/power_supply");
38        let batteries: Vec<Reading> = sorted_entries(&class_dir)
39            .iter()
40            .filter_map(|entry| read_one(&class_dir.join(entry)))
41            .collect();
42        if batteries.is_empty() {
43            self.rate = None;
44            return None;
45        }
46        // Weight the charge by capacity when energies are known (two batteries
47        // of different sizes); otherwise average the percentages.
48        let energy_now: Option<f64> = batteries.iter().map(|b| b.energy_now).sum();
49        let energy_full: Option<f64> = batteries.iter().map(|b| b.energy_full).sum();
50        let percent = match (energy_now, energy_full) {
51            (Some(now), Some(full)) if full > 0.0 => Some((100.0 * now / full) as f32),
52            _ => {
53                let known: Vec<f32> = batteries.iter().filter_map(|b| b.capacity).collect();
54                (!known.is_empty()).then(|| known.iter().sum::<f32>() / known.len() as f32)
55            }
56        };
57        let powers: Vec<f32> = batteries.iter().filter_map(|b| b.power_w).collect();
58        let power_w = (!powers.is_empty()).then(|| powers.iter().sum());
59        let status = ["Discharging", "Charging"]
60            .into_iter()
61            .find(|s| batteries.iter().any(|b| b.status == *s))
62            .map(str::to_owned)
63            .unwrap_or_else(|| batteries[0].status.clone());
64
65        // Amounts and rates add up only in the same unit (energy or charge).
66        let same_units = batteries
67            .iter()
68            .all(|b| b.charge_units == batteries[0].charge_units);
69        let design: Option<f64> = batteries.iter().map(|b| b.design_full).sum();
70        let health_percent = match (energy_full, design) {
71            (Some(full), Some(design)) if same_units && design > 0.0 => {
72                Some((100.0 * full / design) as f32)
73            }
74            _ => None,
75        };
76        let rate: Option<f64> = batteries.iter().map(|b| b.rate).sum();
77        let left = match status.as_str() {
78            "Discharging" => energy_now,
79            "Charging" => energy_full
80                .zip(energy_now)
81                .map(|(full, now)| (full - now).max(0.0)),
82            _ => None,
83        };
84        let time_left_secs = match (left, rate) {
85            (Some(left), Some(rate)) if same_units => {
86                let smoothed = match self.rate.take() {
87                    // A new status (plugged in, unplugged) starts over.
88                    Some((was, previous)) if was == status => {
89                        previous + RATE_SMOOTHING * (rate - previous)
90                    }
91                    _ => rate,
92                };
93                self.rate = Some((status.clone(), smoothed));
94                (smoothed > MIN_RATE).then(|| (left / smoothed * 3600.0).round() as u64)
95            }
96            _ => {
97                self.rate = None;
98                None
99            }
100        };
101        Some(BatteryStats {
102            percent,
103            status,
104            power_w,
105            time_left_secs,
106            health_percent,
107        })
108    }
109}
110
111/// One battery's attributes.
112struct Reading {
113    status: String,
114    capacity: Option<f32>,
115    /// Energy in µWh, or charge in µAh when `charge_units` (consistently
116    /// per battery, design capacity and rate included).
117    charge_units: bool,
118    energy_now: Option<f64>,
119    energy_full: Option<f64>,
120    design_full: Option<f64>,
121    /// Charge or discharge rate in µW (µA with `charge_units`), unsigned.
122    rate: Option<f64>,
123    power_w: Option<f32>,
124}
125
126fn read_one(dir: &Path) -> Option<Reading> {
127    if read_trimmed(&dir.join("type"))? != "Battery"
128        || read_trimmed(&dir.join("scope")).is_some_and(|s| s == "Device")
129    {
130        return None;
131    }
132    let value = |name: &str| read_i64(&dir.join(name));
133    let float = |name: &str| value(name).map(|v| v.unsigned_abs() as f64);
134    // Drivers report either energy (µWh) or charge (µAh); power either
135    // directly (µW) or as current (µA) times voltage (µV).
136    let volts = float("voltage_now").map(|uv| uv / 1e6);
137    let power_uw = float("power_now").or_else(|| Some(float("current_now")? * volts?));
138    let charge_units = !(value("energy_now").is_some() && value("energy_full").is_some());
139    let (energy_now, energy_full, design_full, rate) = if charge_units {
140        (
141            float("charge_now"),
142            float("charge_full"),
143            float("charge_full_design"),
144            float("current_now").or_else(|| Some(power_uw? / volts?)),
145        )
146    } else {
147        (
148            float("energy_now"),
149            float("energy_full"),
150            float("energy_full_design"),
151            power_uw,
152        )
153    };
154    Some(Reading {
155        status: read_trimmed(&dir.join("status")).unwrap_or_default(),
156        capacity: value("capacity").map(|c| c as f32),
157        charge_units,
158        energy_now,
159        energy_full,
160        design_full,
161        rate,
162        power_w: power_uw.map(|uw| (uw / 1e6) as f32),
163    })
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::linux::testutil::write;
170
171    fn roots(root: &Path) -> SysRoots {
172        SysRoots {
173            sys: root.join("sys"),
174            proc: root.join("proc"),
175        }
176    }
177
178    #[test]
179    fn reads_the_system_battery_and_skips_peripherals() {
180        let tmp = tempfile::tempdir().unwrap();
181        let root = tmp.path();
182        let ps = "sys/class/power_supply";
183        write(root, &format!("{ps}/AC/type"), "Mains\n");
184        write(root, &format!("{ps}/BAT0/type"), "Battery\n");
185        write(root, &format!("{ps}/BAT0/status"), "Discharging\n");
186        write(root, &format!("{ps}/BAT0/capacity"), "72\n");
187        write(root, &format!("{ps}/BAT0/energy_now"), "31500000\n");
188        write(root, &format!("{ps}/BAT0/energy_full"), "43750000\n");
189        write(root, &format!("{ps}/BAT0/power_now"), "12340000\n");
190        // A wireless mouse.
191        write(root, &format!("{ps}/hidpp_battery_0/type"), "Battery\n");
192        write(root, &format!("{ps}/hidpp_battery_0/scope"), "Device\n");
193        write(root, &format!("{ps}/hidpp_battery_0/capacity"), "5\n");
194
195        let battery = read_battery(&roots(root)).unwrap();
196        assert_eq!(battery.status, "Discharging");
197        assert!((battery.percent.unwrap() - 72.0).abs() < 0.1);
198        assert!((battery.power_w.unwrap() - 12.34).abs() < 0.01);
199        // 31.5 Wh at 12.34 W: 2h33m.
200        assert_eq!(battery.time_left_secs, Some(9190));
201        assert_eq!(battery.summary(), "72% -12W 2h33m");
202        assert_eq!(battery.health_percent, None);
203    }
204
205    #[test]
206    fn charge_and_current_based_drivers() {
207        let tmp = tempfile::tempdir().unwrap();
208        let root = tmp.path();
209        let ps = "sys/class/power_supply";
210        write(root, &format!("{ps}/BAT1/type"), "Battery\n");
211        write(root, &format!("{ps}/BAT1/status"), "Charging\n");
212        write(root, &format!("{ps}/BAT1/charge_now"), "2000000\n");
213        write(root, &format!("{ps}/BAT1/charge_full"), "4000000\n");
214        write(root, &format!("{ps}/BAT1/current_now"), "1500000\n");
215        write(root, &format!("{ps}/BAT1/voltage_now"), "12000000\n");
216        let battery = read_battery(&roots(root)).unwrap();
217        assert!((battery.percent.unwrap() - 50.0).abs() < 0.1);
218        assert!((battery.power_w.unwrap() - 18.0).abs() < 0.01);
219        // 2 Ah to go at 1.5 A: 1h20m.
220        assert_eq!(battery.summary(), "50% +18W 1h20m");
221    }
222
223    #[test]
224    fn time_left_follows_the_smoothed_rate_and_health_shows() {
225        let tmp = tempfile::tempdir().unwrap();
226        let root = tmp.path();
227        let bat = "sys/class/power_supply/BAT0";
228        write(root, &format!("{bat}/type"), "Battery\n");
229        write(root, &format!("{bat}/status"), "Discharging\n");
230        write(root, &format!("{bat}/energy_now"), "20000000\n");
231        write(root, &format!("{bat}/energy_full"), "43710000\n");
232        write(root, &format!("{bat}/energy_full_design"), "57000000\n");
233        write(root, &format!("{bat}/power_now"), "10000000\n");
234        let mut meter = BatteryMeter::default();
235        let first = meter.read(&roots(root)).unwrap();
236        assert_eq!(first.time_left_secs, Some(7200));
237        assert_eq!(first.health_note().as_deref(), Some("health 77%"));
238
239        // A short burst of 30 W moves the estimate only a little.
240        write(root, &format!("{bat}/power_now"), "30000000\n");
241        let burst = meter.read(&roots(root)).unwrap();
242        assert_eq!(burst.time_left_secs, Some(6000));
243
244        // Plugged in: the rate starts over.
245        write(root, &format!("{bat}/status"), "Charging\n");
246        let charging = meter.read(&roots(root)).unwrap();
247        // 23.71 Wh to go at 30 W.
248        assert_eq!(charging.time_left_secs, Some(2845));
249        assert!(charging.details().contains("until full"));
250
251        // Full: no time left.
252        write(root, &format!("{bat}/status"), "Full\n");
253        assert_eq!(meter.read(&roots(root)).unwrap().time_left_secs, None);
254    }
255
256    #[test]
257    fn no_battery_on_a_desktop() {
258        let tmp = tempfile::tempdir().unwrap();
259        write(tmp.path(), "sys/class/power_supply/AC/type", "Mains\n");
260        assert_eq!(read_battery(&roots(tmp.path())), None);
261        // Full and idle: no power figure.
262        let full = BatteryStats {
263            percent: Some(100.0),
264            status: "Full".to_owned(),
265            power_w: Some(0.0),
266            ..Default::default()
267        };
268        assert_eq!(full.summary(), "100%");
269    }
270}