1use std::path::Path;
8
9use super::{SysRoots, read_i64, read_trimmed, sorted_entries};
10use crate::snapshot::BatteryStats;
11
12const RATE_SMOOTHING: f64 = 0.1;
16
17const MIN_RATE: f64 = 1e-6;
19
20pub fn read_battery(roots: &SysRoots) -> Option<BatteryStats> {
23 BatteryMeter::default().read(roots)
24}
25
26#[derive(Debug, Default)]
29pub struct BatteryMeter {
30 rate: Option<(String, f64)>,
32}
33
34impl BatteryMeter {
35 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 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 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 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
111struct Reading {
113 status: String,
114 capacity: Option<f32>,
115 charge_units: bool,
118 energy_now: Option<f64>,
119 energy_full: Option<f64>,
120 design_full: Option<f64>,
121 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 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 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 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 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 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 write(root, &format!("{bat}/status"), "Charging\n");
246 let charging = meter.read(&roots(root)).unwrap();
247 assert_eq!(charging.time_left_secs, Some(2845));
249 assert!(charging.details().contains("until full"));
250
251 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 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}