Skip to main content

retch_sysinfo/
battery.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
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 raw = crate::macos_ffi::get_battery_raw()?;
139
140    let max_cap = raw.max_mah? as f32;
141    let cur_cap = raw.current_mah? as f32;
142
143    let percentage = if max_cap > 0.0 {
144        (cur_cap / max_cap) * 100.0
145    } else {
146        0.0
147    };
148
149    let health = raw.design_mah.and_then(|design| {
150        if design == 0 {
151            return None;
152        }
153        let h_max = raw.raw_max_mah.or(raw.max_mah)? as f32;
154        Some((h_max / design as f32) * 100.0)
155    });
156
157    let state = if raw.fully_charged {
158        BatteryState::Full
159    } else if raw.is_charging {
160        BatteryState::Charging
161    } else {
162        BatteryState::Discharging
163    };
164
165    let time_remaining = raw
166        .time_remaining_mins
167        .map(|m| std::time::Duration::from_secs(m * 60));
168
169    Some(BatteryInfo {
170        percentage,
171        health,
172        state,
173        time_remaining,
174        vendor: raw.vendor,
175        model: raw.model,
176    })
177}
178
179#[cfg(target_os = "windows")]
180mod win32 {
181    #[repr(C)]
182    pub struct SYSTEM_POWER_STATUS {
183        pub ac_line_status: u8,
184        pub battery_flag: u8,
185        pub battery_life_percent: u8,
186        pub system_status: u8,
187        pub battery_life_time: u32,
188        pub battery_full_life_time: u32,
189    }
190
191    #[link(name = "kernel32")]
192    extern "system" {
193        pub fn GetSystemPowerStatus(lpSystemPowerStatus: *mut SYSTEM_POWER_STATUS) -> i32;
194    }
195}
196
197#[cfg(target_os = "windows")]
198pub fn get_battery_info() -> Option<BatteryInfo> {
199    let mut status = win32::SYSTEM_POWER_STATUS {
200        ac_line_status: 255,
201        battery_flag: 255,
202        battery_life_percent: 255,
203        system_status: 0,
204        battery_life_time: 0xffffffff,
205        battery_full_life_time: 0xffffffff,
206    };
207
208    let res = unsafe { win32::GetSystemPowerStatus(&mut status) };
209    if res == 0 || status.battery_life_percent == 255 {
210        return None;
211    }
212
213    let percentage = status.battery_life_percent as f32;
214    let state = match status.ac_line_status {
215        1 => {
216            if percentage >= 100.0 {
217                BatteryState::Full
218            } else {
219                BatteryState::Charging
220            }
221        }
222        0 => BatteryState::Discharging,
223        _ => BatteryState::Unknown,
224    };
225
226    let time_remaining = if status.battery_life_time != 0xffffffff {
227        Some(std::time::Duration::from_secs(
228            status.battery_life_time as u64,
229        ))
230    } else {
231        None
232    };
233
234    let mut info = BatteryInfo {
235        percentage,
236        health: None,
237        state,
238        time_remaining,
239        vendor: None,
240        model: None,
241    };
242
243    // Health, vendor and model come from the battery device itself. This used to spawn
244    // `powershell -Command "Get-CimInstance Win32_Battery ..."`, which a per-field sweep
245    // measured at ~2531 ms against a ~322 ms process-startup floor - the slowest remaining
246    // field on Windows once `dns` went native in v0.11.2. It also returned *less*: on the
247    // machine this was written against, Win32_Battery reported DesignCapacity,
248    // FullChargeCapacity and Manufacturer as EMPTY, so the whole spawn bought a model name
249    // and nothing else.
250    if let Some(device) = win_battery::first_battery() {
251        if let (Some(design), Some(full)) = (device.designed_capacity, device.full_charged_capacity)
252        {
253            if design > 0 {
254                info.health = Some((full as f32 / design as f32) * 100.0);
255            }
256        }
257        info.vendor = device.manufacturer;
258        info.model = device.device_name;
259    }
260
261    Some(info)
262}
263
264/// Battery details read straight from the device via `IOCTL_BATTERY_QUERY_INFORMATION`.
265///
266/// Every field is optional because a battery miniport may answer some queries and not
267/// others; a partial answer is reported as such rather than discarded.
268#[cfg(target_os = "windows")]
269#[derive(Default)]
270struct WinBattery {
271    designed_capacity: Option<u32>,
272    full_charged_capacity: Option<u32>,
273    manufacturer: Option<String>,
274    device_name: Option<String>,
275}
276
277/// Native battery interrogation over the `GUID_DEVICE_BATTERY` device interface.
278///
279/// **Access rights were established by measurement, not copied from the MSDN sample.**
280/// The battery IOCTLs are `FILE_READ_ACCESS`, and a handle opened with **zero** desired
281/// access fails them with `ERROR_ACCESS_DENIED` (5) - unlike the storage IOCTLs in
282/// `disk.rs`, which are `FILE_ANY_ACCESS` and work on a zero-access handle. `GENERIC_READ`
283/// alone is sufficient and is what this uses; the common sample code asks for
284/// `GENERIC_READ | GENERIC_WRITE`, which is more than the work requires. No elevation is
285/// needed either way - confirmed from an unelevated shell.
286#[cfg(target_os = "windows")]
287mod win_battery {
288    use super::WinBattery;
289    use crate::win_setupapi::Guid;
290    use std::ffi::{c_void, OsStr};
291    use std::mem::size_of;
292    use std::os::windows::ffi::OsStrExt;
293    use std::ptr;
294
295    type Handle = *mut c_void;
296    const INVALID_HANDLE_VALUE: Handle = -1isize as Handle;
297    const GENERIC_READ: u32 = 0x8000_0000;
298    const FILE_SHARE_READ: u32 = 0x0000_0001;
299    const FILE_SHARE_WRITE: u32 = 0x0000_0002;
300    const OPEN_EXISTING: u32 = 3;
301
302    /// `GUID_DEVICE_BATTERY` = {72631e54-78A4-11d0-bcf7-00aa00b7b32a}.
303    const GUID_DEVICE_BATTERY: Guid = Guid {
304        data1: 0x7263_1e54,
305        data2: 0x78A4,
306        data3: 0x11d0,
307        data4: [0xbc, 0xf7, 0x00, 0xaa, 0x00, 0xb7, 0xb3, 0x2a],
308    };
309
310    // CTL_CODE(FILE_DEVICE_BATTERY = 0x29, function, METHOD_BUFFERED, FILE_READ_ACCESS).
311    const IOCTL_BATTERY_QUERY_TAG: u32 = 0x0029_4040;
312    const IOCTL_BATTERY_QUERY_INFORMATION: u32 = 0x0029_4044;
313
314    // BATTERY_QUERY_INFORMATION_LEVEL values.
315    const BATTERY_INFORMATION_LEVEL: u32 = 0;
316    const BATTERY_DEVICE_NAME: u32 = 4;
317    const BATTERY_MANUFACTURE_NAME: u32 = 6;
318
319    /// `BATTERY_QUERY_INFORMATION` - the input every information query takes.
320    #[repr(C)]
321    struct BatteryQueryInformation {
322        battery_tag: u32,
323        information_level: u32,
324        at_rate: i32,
325    }
326
327    /// `BATTERY_INFORMATION`.
328    ///
329    /// **36 bytes, not 32** - `Technology` plus its 3 reserved bytes and the 4-byte
330    /// `Chemistry` array fill two words before the six `ULONG`s. A probe written against a
331    /// predicted 32 printed the correct capacities anyway, which is the useful part: the
332    /// values validated the layout, the prediction did not.
333    #[repr(C)]
334    #[derive(Default)]
335    struct BatteryInformation {
336        capabilities: u32,
337        technology: u8,
338        reserved: [u8; 3],
339        chemistry: [u8; 4],
340        designed_capacity: u32,
341        full_charged_capacity: u32,
342        default_alert1: u32,
343        default_alert2: u32,
344        critical_bias: u32,
345        cycle_count: u32,
346    }
347
348    extern "system" {
349        fn CreateFileW(
350            lp_file_name: *const u16,
351            dw_desired_access: u32,
352            dw_share_mode: u32,
353            lp_security_attributes: *mut c_void,
354            dw_creation_disposition: u32,
355            dw_flags_and_attributes: u32,
356            h_template_file: Handle,
357        ) -> Handle;
358        fn DeviceIoControl(
359            h_device: Handle,
360            dw_io_control_code: u32,
361            lp_in_buffer: *const c_void,
362            n_in_buffer_size: u32,
363            lp_out_buffer: *mut c_void,
364            n_out_buffer_size: u32,
365            lp_bytes_returned: *mut u32,
366            lp_overlapped: *mut c_void,
367        ) -> i32;
368        fn CloseHandle(h_object: Handle) -> i32;
369    }
370
371    /// The first battery that answers, or `None` on a machine with none.
372    ///
373    /// A desktop reports no battery interfaces at all, which is a normal answer rather
374    /// than a failure - the field is simply absent, as it was before.
375    pub fn first_battery() -> Option<WinBattery> {
376        crate::win_setupapi::present_interface_device_paths(&GUID_DEVICE_BATTERY)
377            .into_iter()
378            .find_map(|path| read_battery(&path))
379    }
380
381    fn read_battery(path: &str) -> Option<WinBattery> {
382        let wide: Vec<u16> = OsStr::new(path).encode_wide().chain(Some(0)).collect();
383        // SAFETY: `wide` is a valid NUL-terminated wide string; the handle is checked
384        // before use and closed on every path below.
385        let handle = unsafe {
386            CreateFileW(
387                wide.as_ptr(),
388                GENERIC_READ,
389                FILE_SHARE_READ | FILE_SHARE_WRITE,
390                ptr::null_mut(),
391                OPEN_EXISTING,
392                0,
393                ptr::null_mut(),
394            )
395        };
396        if handle == INVALID_HANDLE_VALUE || handle.is_null() {
397            return None;
398        }
399        let out = read_with_handle(handle);
400        // SAFETY: `handle` came from a successful CreateFileW and is closed exactly once.
401        unsafe {
402            CloseHandle(handle);
403        }
404        out
405    }
406
407    fn read_with_handle(handle: Handle) -> Option<WinBattery> {
408        // Every information query is keyed by the battery tag, which changes when the
409        // battery is swapped - so it must be fetched first and cannot be cached.
410        let mut tag: u32 = 0;
411        let mut returned: u32 = 0;
412        let wait: u32 = 0;
413        // SAFETY: both buffers are locals of the sizes declared to the call.
414        let ok = unsafe {
415            DeviceIoControl(
416                handle,
417                IOCTL_BATTERY_QUERY_TAG,
418                &wait as *const u32 as *const c_void,
419                size_of::<u32>() as u32,
420                &mut tag as *mut u32 as *mut c_void,
421                size_of::<u32>() as u32,
422                &mut returned,
423                ptr::null_mut(),
424            )
425        };
426        if ok == 0 || tag == 0 {
427            return None;
428        }
429
430        let mut battery = WinBattery {
431            manufacturer: query_string(handle, tag, BATTERY_MANUFACTURE_NAME),
432            device_name: query_string(handle, tag, BATTERY_DEVICE_NAME),
433            ..Default::default()
434        };
435
436        let query = BatteryQueryInformation {
437            battery_tag: tag,
438            information_level: BATTERY_INFORMATION_LEVEL,
439            at_rate: 0,
440        };
441        let mut info = BatteryInformation::default();
442        // SAFETY: `query` is a fully initialised input of the declared size and `info` a
443        // writable output of its own size.
444        let ok = unsafe {
445            DeviceIoControl(
446                handle,
447                IOCTL_BATTERY_QUERY_INFORMATION,
448                &query as *const _ as *const c_void,
449                size_of::<BatteryQueryInformation>() as u32,
450                &mut info as *mut _ as *mut c_void,
451                size_of::<BatteryInformation>() as u32,
452                &mut returned,
453                ptr::null_mut(),
454            )
455        };
456        if ok != 0 {
457            // Zero means "not reported" for these, and a zero design capacity would make
458            // the health calculation a division by zero rather than a useful number.
459            battery.designed_capacity =
460                (info.designed_capacity > 0).then_some(info.designed_capacity);
461            battery.full_charged_capacity =
462                (info.full_charged_capacity > 0).then_some(info.full_charged_capacity);
463        }
464        Some(battery)
465    }
466
467    /// Read one of the string information levels, or `None` when unreported.
468    fn query_string(handle: Handle, tag: u32, level: u32) -> Option<String> {
469        let query = BatteryQueryInformation {
470            battery_tag: tag,
471            information_level: level,
472            at_rate: 0,
473        };
474        let mut buf = [0u16; 128];
475        let mut returned: u32 = 0;
476        // SAFETY: `query` is a valid input of the declared size; `buf` is written with its
477        // own byte length as the bound.
478        let ok = unsafe {
479            DeviceIoControl(
480                handle,
481                IOCTL_BATTERY_QUERY_INFORMATION,
482                &query as *const _ as *const c_void,
483                size_of::<BatteryQueryInformation>() as u32,
484                buf.as_mut_ptr() as *mut c_void,
485                std::mem::size_of_val(&buf) as u32,
486                &mut returned,
487                ptr::null_mut(),
488            )
489        };
490        if ok == 0 || returned == 0 {
491            return None;
492        }
493        let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
494        let s = String::from_utf16_lossy(&buf[..end]).trim().to_string();
495        (!s.is_empty()).then_some(s)
496    }
497
498    #[cfg(test)]
499    mod layout {
500        use std::mem::{offset_of, size_of};
501
502        // These are passed to a driver by pointer and read by offset. The capacities sit
503        // at 12 and 16 only because Technology + Reserved + Chemistry fill exactly two
504        // words ahead of them, which is the part that is easy to get wrong.
505        #[test]
506        fn ffi_struct_layout() {
507            assert_eq!(size_of::<super::BatteryQueryInformation>(), 12);
508            assert_eq!(size_of::<super::BatteryInformation>(), 36);
509            assert_eq!(offset_of!(super::BatteryInformation, chemistry), 8);
510            assert_eq!(offset_of!(super::BatteryInformation, designed_capacity), 12);
511            assert_eq!(
512                offset_of!(super::BatteryInformation, full_charged_capacity),
513                16
514            );
515        }
516    }
517}
518
519#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
520pub fn get_battery_info() -> Option<BatteryInfo> {
521    None
522}
523
524// Helpers
525#[cfg(target_os = "linux")]
526fn read_file_to_string<P: AsRef<Path>>(path: P) -> Option<String> {
527    fs::read_to_string(path).ok().map(|s| s.trim().to_string())
528}
529
530#[cfg(target_os = "linux")]
531fn read_file_to_num<T: std::str::FromStr, P: AsRef<Path>>(path: P) -> Option<T> {
532    read_file_to_string(path).and_then(|s| s.parse().ok())
533}