feat(core): parse_power_state_from_dir reads sysfs power_supply ABI

Pure function takes a directory path and returns OnAc, OnBattery, or
Unknown by reading `type` and `online` files in each supply entry.
Matches the kernel ABI documented at
Documentation/ABI/testing/sysfs-class-power. Failure modes (missing
dir, unreadable files, malformed contents) all fall through to Unknown
rather than panicking.

Seven tests cover: Mains online, battery-only, USB-PD, empty dir,
missing dir, and malformed entries. All pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jars
2026-05-09 11:19:34 +01:00
parent 1f3496e4ed
commit 276e3ca6c1

View File

@@ -14,9 +14,61 @@ pub enum PowerState {
Unknown, Unknown,
} }
use std::fs;
use std::path::Path;
/// Parse a `/sys/class/power_supply/`-style directory and decide
/// whether the machine is on AC, on battery, or in an unknown state.
///
/// Rules (matches `Documentation/ABI/testing/sysfs-class-power`):
/// - Any entry with `type` in {`Mains`, `USB`} and `online == 1` → OnAc.
/// - Else any entry with `type == Battery` → OnBattery.
/// - Else → Unknown.
///
/// Failures (missing dir, unreadable files, malformed contents) all
/// fall through to Unknown rather than panicking. `Unknown` is treated
/// as `OnAc` by the caller, which preserves today's pre-clamp
/// behaviour on platforms or distros where the probe doesn't fire.
pub fn parse_power_state_from_dir(supply_dir: &Path) -> PowerState {
let read_dir = match fs::read_dir(supply_dir) {
Ok(rd) => rd,
Err(_) => return PowerState::Unknown,
};
let mut saw_battery = false;
for entry in read_dir.flatten() {
let path = entry.path();
let ty = read_trimmed(&path.join("type")).unwrap_or_default();
let online = read_trimmed(&path.join("online")).unwrap_or_default();
match ty.as_str() {
"Mains" | "USB" => {
if online == "1" {
return PowerState::OnAc;
}
}
"Battery" => {
saw_battery = true;
}
_ => {}
}
}
if saw_battery {
PowerState::OnBattery
} else {
PowerState::Unknown
}
}
fn read_trimmed(path: &Path) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_owned())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::fs;
use tempfile::tempdir;
#[test] #[test]
fn power_state_variants_are_distinct() { fn power_state_variants_are_distinct() {
@@ -24,4 +76,56 @@ mod tests {
assert_ne!(PowerState::OnAc, PowerState::Unknown); assert_ne!(PowerState::OnAc, PowerState::Unknown);
assert_ne!(PowerState::OnBattery, PowerState::Unknown); assert_ne!(PowerState::OnBattery, PowerState::Unknown);
} }
fn write_supply(dir: &std::path::Path, name: &str, ty: &str, online: &str) {
let entry = dir.join(name);
fs::create_dir_all(&entry).unwrap();
fs::write(entry.join("type"), format!("{ty}\n")).unwrap();
fs::write(entry.join("online"), format!("{online}\n")).unwrap();
}
#[test]
fn parses_mains_online_as_on_ac() {
let dir = tempdir().unwrap();
write_supply(dir.path(), "AC", "Mains", "1");
write_supply(dir.path(), "BAT0", "Battery", "0");
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnAc);
}
#[test]
fn parses_battery_only_as_on_battery() {
let dir = tempdir().unwrap();
write_supply(dir.path(), "AC", "Mains", "0");
write_supply(dir.path(), "BAT0", "Battery", "0");
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnBattery);
}
#[test]
fn parses_usb_pd_online_as_on_ac() {
let dir = tempdir().unwrap();
write_supply(dir.path(), "ucsi-source-psy-USBC000:001", "USB", "1");
write_supply(dir.path(), "BAT0", "Battery", "0");
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnAc);
}
#[test]
fn parses_empty_dir_as_unknown() {
let dir = tempdir().unwrap();
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::Unknown);
}
#[test]
fn parses_missing_dir_as_unknown() {
let path = std::path::Path::new("/no/such/path/should/exist/at/this/inode/123456");
assert_eq!(parse_power_state_from_dir(path), PowerState::Unknown);
}
#[test]
fn parses_malformed_files_as_unknown_gracefully() {
let dir = tempdir().unwrap();
let entry = dir.path().join("garbage");
fs::create_dir_all(&entry).unwrap();
// No type, no online — should not panic.
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::Unknown);
}
} }