use std::{ io, sync::{ Arc, atomic::{AtomicU8, Ordering}, mpsc::{self, Receiver, Sender}, }, thread::{self, JoinHandle}, }; #[cfg(not(test))] use std::{sync::mpsc::RecvTimeoutError, time::Duration}; pub(crate) struct Battery { results: Arc, wake: Option>, thread: Option>, } impl Battery { #[cfg(not(test))] pub(crate) fn start() -> io::Result { Self::start_with(read_charge, |wake| { matches!( wake.recv_timeout(Duration::from_secs(30)), Ok(()) | Err(RecvTimeoutError::Timeout) ) }) } fn start_with( mut read: impl FnMut() -> Option + Send + 'static, mut wait: impl FnMut(&Receiver<()>) -> bool + Send + 'static, ) -> io::Result { let results = Arc::new(AtomicU8::new(0)); let latest = Arc::clone(&results); let (wake, requests) = mpsc::channel(); let thread = thread::Builder::new() .name("vamp-battery".into()) .spawn(move || { loop { let charge = read().filter(|charge| *charge <= 100); // A one-slot latest-result mailbox: 0 = no new sample, // 1 = no battery, 2..=102 = charge. Publishing never blocks. latest.store(charge.map_or(1, |charge| charge + 2), Ordering::Relaxed); if !wait(&requests) { break; } } })?; Ok(Self { results, wake: Some(wake), thread: Some(thread), }) } pub(crate) fn poll(&mut self) -> Option> { match self.results.swap(0, Ordering::Relaxed) { 0 => None, 1 => Some(None), charge => Some(Some(charge - 2)), } } } impl Drop for Battery { fn drop(&mut self) { // Disconnecting interrupts recv_timeout instead of waiting for its deadline. self.wake.take(); if let Some(thread) = self.thread.take() { let _ = thread.join(); } } } #[cfg(not(test))] pub(crate) fn read_charge() -> Option { // ponytail: first internal battery on macOS/Linux; aggregate compatible capacities for multi-battery systems. #[cfg(target_os = "macos")] return macos::read_charge(); #[cfg(target_os = "linux")] return linux::read_charge(std::path::Path::new("/sys/class/power_supply")); #[cfg(windows)] return windows::read_charge(); #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] None } #[cfg(any(target_os = "macos", target_os = "linux", test))] fn percentage(current: u64, full: u64) -> Option { if full == 0 || current > full { return None; } Some(((current as u128 * 100) / full as u128) as u8) } #[cfg(target_os = "macos")] mod macos { use std::ffi::{CStr, c_char, c_void}; type Object = *const c_void; #[cfg(not(test))] #[link(name = "IOKit", kind = "framework")] unsafe extern "C" { fn IOPSCopyPowerSourcesInfo() -> Object; fn IOPSCopyPowerSourcesList(info: Object) -> Object; fn IOPSGetPowerSourceDescription(info: Object, source: Object) -> Object; } #[link(name = "CoreFoundation", kind = "framework")] unsafe extern "C" { fn CFRelease(object: Object); fn CFGetTypeID(object: Object) -> usize; #[cfg(not(test))] fn CFEqual(left: Object, right: Object) -> u8; #[cfg(not(test))] fn CFArrayGetTypeID() -> usize; #[cfg(not(test))] fn CFArrayGetCount(array: Object) -> isize; #[cfg(not(test))] fn CFArrayGetValueAtIndex(array: Object, index: isize) -> Object; #[cfg(not(test))] fn CFDictionaryGetTypeID() -> usize; #[cfg(not(test))] fn CFDictionaryGetValue(dictionary: Object, key: Object) -> Object; #[cfg(not(test))] fn CFStringGetTypeID() -> usize; fn CFStringCreateWithCString( allocator: Object, string: *const c_char, encoding: u32, ) -> Object; #[cfg(not(test))] fn CFBooleanGetTypeID() -> usize; #[cfg(not(test))] fn CFBooleanGetValue(boolean: Object) -> u8; fn CFNumberGetTypeID() -> usize; fn CFNumberGetValue(number: Object, kind: isize, value: *mut c_void) -> u8; #[cfg(test)] fn CFNumberCreate(allocator: Object, kind: isize, value: Object) -> Object; } // Only Copy/Create results belong here; power source descriptions are borrowed. struct Owned(Object); impl Owned { fn new(object: Object) -> Option { (!object.is_null()).then(|| Self(object)) } fn string(value: &CStr) -> Option { // kCFStringEncodingUTF8, from CFString.h; a null allocator is the default. Self::new(unsafe { CFStringCreateWithCString(std::ptr::null(), value.as_ptr(), 0x0800_0100) }) } } impl Drop for Owned { fn drop(&mut self) { unsafe { CFRelease(self.0) }; } } unsafe fn is_type(object: Object, kind: usize) -> bool { !object.is_null() && unsafe { CFGetTypeID(object) == kind } } unsafe fn number(object: Object) -> Option { if !unsafe { is_type(object, CFNumberGetTypeID()) } { return None; } let mut value: i64 = 0; // kCFNumberSInt64Type = 4. A lossy/out-of-range conversion returns false. let converted = unsafe { CFNumberGetValue(object, 4, (&mut value as *mut i64).cast()) }; (converted != 0).then_some(value)?.try_into().ok() } #[cfg(not(test))] pub(super) fn read_charge() -> Option { // The opaque snapshot is only passed to IOPS accessors. All owned values // are released on every return path, including failed type checks. unsafe { let info = Owned::new(IOPSCopyPowerSourcesInfo())?; let sources = Owned::new(IOPSCopyPowerSourcesList(info.0))?; if !is_type(sources.0, CFArrayGetTypeID()) { return None; } let type_key = Owned::string(c"Type")?; let internal = Owned::string(c"InternalBattery")?; let present_key = Owned::string(c"Is Present")?; let current_key = Owned::string(c"Current Capacity")?; let full_key = Owned::string(c"Max Capacity")?; for index in 0..CFArrayGetCount(sources.0) { let source = CFArrayGetValueAtIndex(sources.0, index); if source.is_null() { continue; } let description = IOPSGetPowerSourceDescription(info.0, source); if !is_type(description, CFDictionaryGetTypeID()) { continue; } let kind = CFDictionaryGetValue(description, type_key.0); if !is_type(kind, CFStringGetTypeID()) || CFEqual(kind, internal.0) == 0 { continue; } let present = CFDictionaryGetValue(description, present_key.0); if !is_type(present, CFBooleanGetTypeID()) || CFBooleanGetValue(present) == 0 { continue; } let current = number(CFDictionaryGetValue(description, current_key.0))?; let full = number(CFDictionaryGetValue(description, full_key.0))?; return super::percentage(current, full); } None } } #[cfg(test)] mod tests { use super::*; #[test] fn native_values_are_type_checked_and_numbers_must_be_exact_and_nonnegative() { assert!(Owned::new(std::ptr::null()).is_none()); unsafe { assert_eq!(number(std::ptr::null()), None); let text = Owned::string(c"27").unwrap(); assert_eq!(number(text.0), None); for value in [-1_i64, 0, 100] { let object = Owned::new(CFNumberCreate( std::ptr::null(), 4, (&value as *const i64).cast(), )) .unwrap(); assert_eq!(number(object.0), u64::try_from(value).ok()); } let fraction = 1.5_f64; let object = Owned::new(CFNumberCreate( std::ptr::null(), 6, (&fraction as *const f64).cast(), )) .unwrap(); assert_eq!(number(object.0), None); } } } } #[cfg(any(target_os = "linux", test))] mod linux { use std::{fs, io, path::Path}; pub(super) fn read_charge(root: &Path) -> Option { for entry in fs::read_dir(root).ok()? { let path = entry.ok()?.path(); if fs::read_to_string(path.join("type")) .ok() .as_deref() .map(str::trim) != Some("Battery") { continue; } match fs::read_to_string(path.join("scope")) { Ok(scope) if matches!(scope.trim(), "System" | "Unknown") => {} Err(error) if error.kind() == io::ErrorKind::NotFound => {} _ => continue, } if fs::read_to_string(path.join("present")) .ok() .as_deref() .map(str::trim) != Some("1") { continue; } return read_capacity(&path); } None } fn read_capacity(path: &Path) -> Option { match fs::read_to_string(path.join("capacity")) { Ok(capacity) => { return capacity .trim() .parse::() .ok() .filter(|value| *value <= 100); } Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(_) => return None, } // Never mix energy and charge units or use design capacity as full capacity. let (current, full) = match fs::read_to_string(path.join("energy_now")) { Ok(current) => (current, fs::read_to_string(path.join("energy_full")).ok()?), Err(error) if error.kind() == io::ErrorKind::NotFound => ( fs::read_to_string(path.join("charge_now")).ok()?, fs::read_to_string(path.join("charge_full")).ok()?, ), Err(_) => return None, }; super::percentage(current.trim().parse().ok()?, full.trim().parse().ok()?) } } #[cfg(any(windows, test))] fn windows_charge(percent: u8, flags: u8) -> Option { (percent <= 100 && flags & 128 == 0).then_some(percent) } #[cfg(all(windows, not(test)))] mod windows { #[repr(C)] struct SystemPowerStatus { ac_line_status: u8, battery_flag: u8, battery_life_percent: u8, system_status_flag: u8, battery_life_time: u32, battery_full_life_time: u32, } #[link(name = "kernel32")] unsafe extern "system" { fn GetSystemPowerStatus(status: *mut SystemPowerStatus) -> i32; } pub(super) fn read_charge() -> Option { let mut status = std::mem::MaybeUninit::uninit(); if unsafe { GetSystemPowerStatus(status.as_mut_ptr()) } == 0 { return None; } let status = unsafe { status.assume_init() }; super::windows_charge(status.battery_life_percent, status.battery_flag) } } #[cfg(test)] mod tests { use super::*; use std::{ fs, path::PathBuf, sync::atomic::{AtomicBool, AtomicU64, Ordering}, }; #[test] fn percentages_reject_invalid_data_and_include_both_endpoints() { assert_eq!(percentage(0, 100), Some(0)); assert_eq!(percentage(100, 100), Some(100)); assert_eq!(percentage(1, 3), Some(33)); assert_eq!(percentage(u64::MAX, u64::MAX), Some(100)); assert_eq!(percentage(0, 0), None); assert_eq!(percentage(101, 100), None); assert_eq!(windows_charge(0, 4), Some(0)); assert_eq!(windows_charge(100, 8), Some(100)); assert_eq!(windows_charge(101, 0), None); assert_eq!(windows_charge(255, 0), None); assert_eq!(windows_charge(50, 128), None); assert_eq!(windows_charge(50, 255), None); } fn worker(samples: Vec>) -> (Battery, Receiver<()>, Arc) { struct ReaderDropped(Arc); impl Drop for ReaderDropped { fn drop(&mut self) { self.0.store(true, Ordering::Release); } } let dropped = Arc::new(AtomicBool::new(false)); let reader = ReaderDropped(Arc::clone(&dropped)); let mut samples = samples.into_iter(); let (ready, published) = mpsc::channel(); let battery = Battery::start_with( move || { let _ = &reader; samples .next() .expect("test provides every requested sample") }, move |wake| { ready.send(()).unwrap(); // Explicit channel messages replace the production clock entirely. wake.recv().is_ok() }, ) .unwrap(); (battery, published, dropped) } #[test] fn worker_reads_immediately_replaces_stale_samples_and_clears_invalid_data() { let (mut battery, published, dropped) = worker(vec![Some(0), Some(100), None, Some(101)]); published.recv().unwrap(); assert_eq!(battery.poll(), Some(Some(0))); assert_eq!(battery.poll(), None); battery.wake.as_ref().unwrap().send(()).unwrap(); published.recv().unwrap(); battery.wake.as_ref().unwrap().send(()).unwrap(); published.recv().unwrap(); assert_eq!(battery.poll(), Some(None)); assert_eq!(battery.poll(), None); battery.wake.as_ref().unwrap().send(()).unwrap(); published.recv().unwrap(); assert_eq!(battery.poll(), Some(None)); drop(battery); assert!(dropped.load(Ordering::Acquire)); } #[test] fn dropping_interrupts_wait_and_joins_even_with_an_unread_sample() { let (battery, published, dropped) = worker(vec![Some(50)]); published.recv().unwrap(); drop(battery); assert!(dropped.load(Ordering::Acquire)); } static FIXTURE_ID: AtomicU64 = AtomicU64::new(0); struct Fixture(PathBuf); impl Fixture { fn new() -> Self { let path = std::env::temp_dir().join(format!( "vamp-battery-{}-{}", std::process::id(), FIXTURE_ID.fetch_add(1, Ordering::Relaxed) )); fs::create_dir(&path).unwrap(); Self(path) } fn source(&self, name: &str, fields: &[(&str, &str)]) { let path = self.0.join(name); fs::create_dir_all(&path).unwrap(); for (key, value) in fields { fs::write(path.join(key), value).unwrap(); } } fn read(&self) -> Option { linux::read_charge(&self.0) } } impl Drop for Fixture { fn drop(&mut self) { fs::remove_dir_all(&self.0).unwrap(); } } #[test] fn sysfs_reads_present_system_batteries_but_not_external_or_absent_sources() { let fixture = Fixture::new(); assert_eq!(fixture.read(), None); fixture.source("AC", &[("type", "Mains"), ("capacity", "100")]); fixture.source( "mouse", &[ ("type", "Battery"), ("scope", "Device"), ("present", "1"), ("capacity", "100"), ], ); fixture.source( "absent", &[("type", "Battery"), ("present", "0"), ("capacity", "100")], ); assert_eq!(fixture.read(), None); fixture.source( "BAT0", &[ ("type", "Battery\n"), ("scope", "System\n"), ("present", "1\n"), ("capacity", "27\n"), ], ); assert_eq!(fixture.read(), Some(27)); fixture.source("BAT0", &[("capacity", "0")]); assert_eq!(fixture.read(), Some(0)); fixture.source("BAT0", &[("capacity", "100")]); assert_eq!(fixture.read(), Some(100)); for invalid in ["101", "-1", "255", "garbage", ""] { fixture.source("BAT0", &[("capacity", invalid)]); assert_eq!(fixture.read(), None); } fs::remove_file(fixture.0.join("BAT0/capacity")).unwrap(); assert_eq!(fixture.read(), None); } #[test] fn sysfs_uses_matching_actual_energy_or_charge_totals_only() { let energy = Fixture::new(); energy.source( "BAT0", &[ ("type", "Battery"), ("present", "1"), ("energy_now", "27"), ("energy_full", "100"), ], ); assert_eq!(energy.read(), Some(27)); energy.source("BAT0", &[("energy_full", "0")]); assert_eq!(energy.read(), None); energy.source("BAT0", &[("energy_full", "26")]); assert_eq!(energy.read(), None); energy.source("BAT0", &[("energy_full", "garbage")]); assert_eq!(energy.read(), None); let charge = Fixture::new(); charge.source( "BAT0", &[ ("type", "Battery"), ("present", "1"), ("charge_now", "15"), ("charge_full", "20"), ], ); assert_eq!(charge.read(), Some(75)); charge.source("BAT0", &[("energy_now", "5")]); assert_eq!(charge.read(), None); let design = Fixture::new(); design.source( "BAT0", &[ ("type", "Battery"), ("present", "1"), ("energy_now", "20"), ("energy_full_design", "100"), ], ); assert_eq!(design.read(), None); } }