feat: show live battery charge by default

This commit is contained in:
Valdemar Lorenzen
2026-10-12 12:02:10 +02:00
parent 0d3fbc01f8
commit 8c6a050171
11 changed files with 746 additions and 41 deletions
+542
View File
@@ -0,0 +1,542 @@
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<AtomicU8>,
wake: Option<Sender<()>>,
thread: Option<JoinHandle<()>>,
}
impl Battery {
#[cfg(not(test))]
pub(crate) fn start() -> io::Result<Self> {
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<u8> + Send + 'static,
mut wait: impl FnMut(&Receiver<()>) -> bool + Send + 'static,
) -> io::Result<Self> {
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<Option<u8>> {
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<u8> {
// 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<u8> {
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<Self> {
(!object.is_null()).then(|| Self(object))
}
fn string(value: &CStr) -> Option<Self> {
// 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<u64> {
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<u8> {
// 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<u8> {
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<u8> {
match fs::read_to_string(path.join("capacity")) {
Ok(capacity) => {
return capacity
.trim()
.parse::<u8>()
.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<u8> {
(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<u8> {
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<Option<u8>>) -> (Battery, Receiver<()>, Arc<AtomicBool>) {
struct ReaderDropped(Arc<AtomicBool>);
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<u8> {
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);
}
}