Skip to main content

retch_sysinfo/
battery.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 l1a
3
4#[cfg(target_os = "linux")]
5use std::fs;
6#[cfg(target_os = "linux")]
7use std::path::Path;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum BatteryState {
11    Charging,
12    Discharging,
13    Full,
14    Unknown,
15}
16
17#[derive(Debug, Clone)]
18pub struct BatteryInfo {
19    pub percentage: f32,
20    pub health: Option<f32>,
21    pub state: BatteryState,
22    pub time_remaining: Option<std::time::Duration>,
23    pub vendor: Option<String>,
24    pub model: Option<String>,
25}
26
27#[cfg(target_os = "linux")]
28pub fn get_battery_info() -> Option<BatteryInfo> {
29    let power_supply = Path::new("/sys/class/power_supply");
30    if !power_supply.exists() {
31        return None;
32    }
33
34    let entries = fs::read_dir(power_supply).ok()?;
35    for entry in entries.flatten() {
36        let path = entry.path();
37        let name = path.file_name()?.to_string_lossy();
38        if name.starts_with("BAT") || name.starts_with("sb-") {
39            // Read type to confirm it's a battery
40            if let Some(supply_type) = read_file_to_string(path.join("type")) {
41                if supply_type != "Battery" {
42                    continue;
43                }
44            }
45
46            // Read capacity
47            let percentage = read_file_to_num::<f32, _>(path.join("capacity"))?;
48
49            // Read status
50            let state_str = read_file_to_string(path.join("status")).unwrap_or_default();
51            let state = match state_str.as_str() {
52                "Charging" => BatteryState::Charging,
53                "Discharging" => BatteryState::Discharging,
54                "Full" => BatteryState::Full,
55                _ => BatteryState::Unknown,
56            };
57
58            // Read vendor & model
59            let vendor = read_file_to_string(path.join("manufacturer"))
60                .or_else(|| read_file_to_string(path.join("vendor")));
61            let model = read_file_to_string(path.join("model_name"))
62                .or_else(|| read_file_to_string(path.join("model")));
63
64            // Compute health
65            let mut health = None;
66            if let (Some(full), Some(design)) = (
67                read_file_to_num::<f32, _>(path.join("energy_full")),
68                read_file_to_num::<f32, _>(path.join("energy_full_design")),
69            ) {
70                if design > 0.0 {
71                    health = Some((full / design) * 100.0);
72                }
73            } else if let (Some(full), Some(design)) = (
74                read_file_to_num::<f32, _>(path.join("charge_full")),
75                read_file_to_num::<f32, _>(path.join("charge_full_design")),
76            ) {
77                if design > 0.0 {
78                    health = Some((full / design) * 100.0);
79                }
80            }
81
82            // Compute time remaining
83            let mut time_remaining = None;
84            if state == BatteryState::Charging || state == BatteryState::Discharging {
85                if let (Some(power), Some(energy_now)) = (
86                    read_file_to_num::<f64, _>(path.join("power_now")),
87                    read_file_to_num::<f64, _>(path.join("energy_now")),
88                ) {
89                    if power > 0.0 {
90                        let hours = match state {
91                            BatteryState::Discharging => energy_now / power,
92                            BatteryState::Charging => {
93                                let energy_full =
94                                    read_file_to_num::<f64, _>(path.join("energy_full"))
95                                        .unwrap_or(energy_now);
96                                (energy_full - energy_now).max(0.0) / power
97                            }
98                            _ => 0.0,
99                        };
100                        time_remaining = Some(std::time::Duration::from_secs_f64(hours * 3600.0));
101                    }
102                } else if let (Some(current), Some(charge_now)) = (
103                    read_file_to_num::<f64, _>(path.join("current_now")),
104                    read_file_to_num::<f64, _>(path.join("charge_now")),
105                ) {
106                    if current > 0.0 {
107                        let hours = match state {
108                            BatteryState::Discharging => charge_now / current,
109                            BatteryState::Charging => {
110                                let charge_full =
111                                    read_file_to_num::<f64, _>(path.join("charge_full"))
112                                        .unwrap_or(charge_now);
113                                (charge_full - charge_now).max(0.0) / current
114                            }
115                            _ => 0.0,
116                        };
117                        time_remaining = Some(std::time::Duration::from_secs_f64(hours * 3600.0));
118                    }
119                }
120            }
121
122            return Some(BatteryInfo {
123                percentage,
124                health,
125                state,
126                time_remaining,
127                vendor,
128                model,
129            });
130        }
131    }
132
133    None
134}
135
136#[cfg(target_os = "macos")]
137pub fn get_battery_info() -> Option<BatteryInfo> {
138    let output = std::process::Command::new("ioreg")
139        .args(["-r", "-c", "AppleSmartBattery"])
140        .output()
141        .ok()?;
142
143    if !output.status.success() {
144        return None;
145    }
146
147    let stdout = String::from_utf8_lossy(&output.stdout);
148    if stdout.trim().is_empty() {
149        return None;
150    }
151
152    let mut current_capacity: Option<f32> = None;
153    let mut max_capacity: Option<f32> = None;
154    let mut raw_max_capacity: Option<f32> = None;
155    let mut design_capacity: Option<f32> = None;
156    let mut is_charging = false;
157    let mut fully_charged = false;
158    let mut time_remaining_mins: Option<i32> = None;
159    let mut vendor: Option<String> = None;
160    let mut model: Option<String> = None;
161
162    for line in stdout.lines() {
163        if let Some(val) = parse_ioreg_line(line, "CurrentCapacity") {
164            current_capacity = val.parse().ok();
165        } else if let Some(val) = parse_ioreg_line(line, "MaxCapacity") {
166            max_capacity = val.parse().ok();
167        } else if let Some(val) = parse_ioreg_line(line, "AppleRawMaxCapacity") {
168            raw_max_capacity = val.parse().ok();
169        } else if let Some(val) = parse_ioreg_line(line, "DesignCapacity") {
170            design_capacity = val.parse().ok();
171        } else if let Some(val) = parse_ioreg_line(line, "IsCharging") {
172            is_charging = val == "Yes";
173        } else if let Some(val) = parse_ioreg_line(line, "FullyCharged") {
174            fully_charged = val == "Yes";
175        } else if let Some(val) = parse_ioreg_line(line, "TimeRemaining") {
176            time_remaining_mins = val.parse().ok();
177        } else if let Some(val) = parse_ioreg_line(line, "Manufacturer") {
178            vendor = Some(val);
179        } else if let Some(val) = parse_ioreg_line(line, "DeviceName") {
180            model = Some(val);
181        }
182    }
183
184    let max_cap = max_capacity?;
185    let cur_cap = current_capacity?;
186
187    let percentage = if max_cap > 0.0 {
188        (cur_cap / max_cap) * 100.0
189    } else {
190        0.0
191    };
192
193    let mut health = None;
194    if let Some(design_cap) = design_capacity {
195        if design_cap > 0.0 {
196            let h_max = raw_max_capacity.or(max_capacity);
197            if let Some(health_max) = h_max {
198                health = Some((health_max / design_cap) * 100.0);
199            }
200        }
201    }
202
203    let state = if fully_charged {
204        BatteryState::Full
205    } else if is_charging {
206        BatteryState::Charging
207    } else {
208        BatteryState::Discharging
209    };
210
211    let mut time_remaining = None;
212    if let Some(mins) = time_remaining_mins {
213        if mins > 0 && mins < 65535 {
214            time_remaining = Some(std::time::Duration::from_secs((mins * 60) as u64));
215        }
216    }
217
218    Some(BatteryInfo {
219        percentage,
220        health,
221        state,
222        time_remaining,
223        vendor,
224        model,
225    })
226}
227
228#[cfg(target_os = "macos")]
229fn parse_ioreg_line(line: &str, key: &str) -> Option<String> {
230    if let Some(pos) = line.find(&format!("\"{}\"", key)) {
231        let after_key = &line[pos + key.len() + 2..];
232        if let Some(equals_pos) = after_key.find('=') {
233            let val = &after_key[equals_pos + 1..];
234            let val = val.trim().trim_matches('"');
235            return Some(val.to_string());
236        }
237    }
238    None
239}
240
241#[cfg(target_os = "windows")]
242mod win32 {
243    #[repr(C)]
244    pub struct SYSTEM_POWER_STATUS {
245        pub ac_line_status: u8,
246        pub battery_flag: u8,
247        pub battery_life_percent: u8,
248        pub system_status: u8,
249        pub battery_life_time: u32,
250        pub battery_full_life_time: u32,
251    }
252
253    #[link(name = "kernel32")]
254    extern "system" {
255        pub fn GetSystemPowerStatus(lpSystemPowerStatus: *mut SYSTEM_POWER_STATUS) -> i32;
256    }
257}
258
259#[cfg(target_os = "windows")]
260pub fn get_battery_info() -> Option<BatteryInfo> {
261    let mut status = win32::SYSTEM_POWER_STATUS {
262        ac_line_status: 255,
263        battery_flag: 255,
264        battery_life_percent: 255,
265        system_status: 0,
266        battery_life_time: 0xffffffff,
267        battery_full_life_time: 0xffffffff,
268    };
269
270    let res = unsafe { win32::GetSystemPowerStatus(&mut status) };
271    if res == 0 || status.battery_life_percent == 255 {
272        return None;
273    }
274
275    let percentage = status.battery_life_percent as f32;
276    let state = match status.ac_line_status {
277        1 => {
278            if percentage >= 100.0 {
279                BatteryState::Full
280            } else {
281                BatteryState::Charging
282            }
283        }
284        0 => BatteryState::Discharging,
285        _ => BatteryState::Unknown,
286    };
287
288    let time_remaining = if status.battery_life_time != 0xffffffff {
289        Some(std::time::Duration::from_secs(
290            status.battery_life_time as u64,
291        ))
292    } else {
293        None
294    };
295
296    let mut info = BatteryInfo {
297        percentage,
298        health: None,
299        state,
300        time_remaining,
301        vendor: None,
302        model: None,
303    };
304
305    if let Ok(output) = std::process::Command::new("powershell")
306        .args([
307            "-Command",
308            "Get-CimInstance Win32_Battery | Select-Object DesignCapacity,FullChargeCapacity,Manufacturer,Name | Format-List",
309        ])
310        .output()
311    {
312        if let Ok(stdout) = String::from_utf8(output.stdout) {
313            let mut design_capacity: Option<f32> = None;
314            let mut full_charge_capacity: Option<f32> = None;
315
316            for line in stdout.lines() {
317                let line = line.trim();
318                if let Some(pos) = line.find(':') {
319                    let key = line[..pos].trim();
320                    let val = line[pos + 1..].trim();
321                    if !val.is_empty() {
322                        match key {
323                            "DesignCapacity" => design_capacity = val.parse().ok(),
324                            "FullChargeCapacity" => full_charge_capacity = val.parse().ok(),
325                            "Manufacturer" => info.vendor = Some(val.to_string()),
326                            "Name" => info.model = Some(val.to_string()),
327                            _ => {}
328                        }
329                    }
330                }
331            }
332
333            if let (Some(full), Some(design)) = (full_charge_capacity, design_capacity) {
334                if design > 0.0 {
335                    info.health = Some((full / design) * 100.0);
336                }
337            }
338        }
339    }
340
341    Some(info)
342}
343
344#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
345pub fn get_battery_info() -> Option<BatteryInfo> {
346    None
347}
348
349// Helpers
350#[cfg(target_os = "linux")]
351fn read_file_to_string<P: AsRef<Path>>(path: P) -> Option<String> {
352    fs::read_to_string(path).ok().map(|s| s.trim().to_string())
353}
354
355#[cfg(target_os = "linux")]
356fn read_file_to_num<T: std::str::FromStr, P: AsRef<Path>>(path: P) -> Option<T> {
357    read_file_to_string(path).and_then(|s| s.parse().ok())
358}
359
360#[cfg(test)]
361mod tests {
362    #[cfg(target_os = "macos")]
363    use super::parse_ioreg_line;
364
365    #[test]
366    #[cfg(target_os = "macos")]
367    fn test_parse_ioreg_line() {
368        let line = r#"    | "MaxCapacity" = 5000"#;
369        assert_eq!(
370            parse_ioreg_line(line, "MaxCapacity"),
371            Some("5000".to_string())
372        );
373
374        let line_str = r#"    | "DeviceName" = "DELL BATTERY""#;
375        assert_eq!(
376            parse_ioreg_line(line_str, "DeviceName"),
377            Some("DELL BATTERY".to_string())
378        );
379
380        assert_eq!(parse_ioreg_line(line, "MissingKey"), None);
381    }
382}