use std::sync::RwLock;
use super::types::ThermalState;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct PlatformState {
pub battery_pct: Option<u8>,
pub thermal_state: Option<ThermalState>,
}
impl PlatformState {
pub const EMPTY: Self = Self {
battery_pct: None,
thermal_state: None,
};
}
static GLOBAL: RwLock<PlatformState> = RwLock::new(PlatformState::EMPTY);
pub fn current_platform_state() -> PlatformState {
GLOBAL.read().map(|g| *g).unwrap_or(PlatformState::EMPTY)
}
pub fn set_platform_state(state: PlatformState) {
if let Ok(mut g) = GLOBAL.write() {
*g = state;
}
}
pub fn set_battery_level(pct: u8) {
if let Ok(mut g) = GLOBAL.write() {
g.battery_pct = Some(pct.min(100));
}
}
pub fn clear_battery_level() {
if let Ok(mut g) = GLOBAL.write() {
g.battery_pct = None;
}
}
pub fn set_thermal_state(state: ThermalState) {
if let Ok(mut g) = GLOBAL.write() {
g.thermal_state = Some(state);
}
}
pub fn clear_thermal_state() {
if let Ok(mut g) = GLOBAL.write() {
g.thermal_state = None;
}
}
pub fn refresh_native_platform_state() {
#[cfg(target_os = "linux")]
linux::refresh();
#[cfg(any(target_os = "macos", target_os = "ios"))]
apple::refresh();
#[cfg(target_os = "windows")]
windows::refresh();
}
#[cfg(target_os = "linux")]
mod linux {
use super::{set_battery_level, set_thermal_state, ThermalState};
use std::fs;
pub(super) fn refresh() {
if let Some(pct) = read_battery_pct() {
set_battery_level(pct);
}
if let Some(state) = read_thermal_state() {
set_thermal_state(state);
}
}
fn read_battery_pct() -> Option<u8> {
const PATHS: &[&str] = &[
"/sys/class/power_supply/BAT0/capacity",
"/sys/class/power_supply/BAT1/capacity",
];
for path in PATHS {
if let Ok(contents) = fs::read_to_string(path) {
if let Ok(pct) = contents.trim().parse::<u8>() {
return Some(pct.min(100));
}
}
}
None
}
fn read_thermal_state() -> Option<ThermalState> {
const PATHS: &[&str] = &[
"/sys/class/thermal/thermal_zone0/temp",
"/sys/class/thermal/thermal_zone1/temp",
"/sys/class/hwmon/hwmon0/temp1_input",
];
for path in PATHS {
if let Ok(contents) = fs::read_to_string(path) {
if let Ok(milli) = contents.trim().parse::<i32>() {
let celsius = milli as f32 / 1000.0;
return Some(thermal_from_celsius(celsius));
}
}
}
None
}
fn thermal_from_celsius(c: f32) -> ThermalState {
if c >= 80.0 {
ThermalState::Critical
} else if c >= 70.0 {
ThermalState::Hot
} else if c >= 60.0 {
ThermalState::Warm
} else {
ThermalState::Normal
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn celsius_bands_match_thermal_state_docs() {
assert_eq!(thermal_from_celsius(25.0), ThermalState::Normal);
assert_eq!(thermal_from_celsius(59.9), ThermalState::Normal);
assert_eq!(thermal_from_celsius(60.0), ThermalState::Warm);
assert_eq!(thermal_from_celsius(69.9), ThermalState::Warm);
assert_eq!(thermal_from_celsius(70.0), ThermalState::Hot);
assert_eq!(thermal_from_celsius(79.9), ThermalState::Hot);
assert_eq!(thermal_from_celsius(80.0), ThermalState::Critical);
assert_eq!(thermal_from_celsius(95.0), ThermalState::Critical);
}
}
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
mod apple {
use objc2_foundation::NSProcessInfo;
#[cfg(target_os = "macos")]
use core::ffi::{c_void, CStr};
#[cfg(target_os = "macos")]
use objc2_core_foundation::{CFDictionary, CFNumber, CFString, CFType};
#[cfg(target_os = "macos")]
use objc2_io_kit::{
kIOPSCurrentCapacityKey, kIOPSMaxCapacityKey, IOPSCopyPowerSourcesInfo,
IOPSCopyPowerSourcesList, IOPSGetPowerSourceDescription,
};
#[cfg(target_os = "macos")]
use super::set_battery_level;
use super::{set_thermal_state, ThermalState};
pub(super) fn refresh() {
set_thermal_state(read_thermal_state());
#[cfg(target_os = "macos")]
if let Some(pct) = read_battery_pct() {
set_battery_level(pct);
}
}
fn read_thermal_state() -> ThermalState {
let info = NSProcessInfo::processInfo();
let raw = info.thermalState().0 as i64;
thermal_from_nsprocessinfo(raw)
}
fn thermal_from_nsprocessinfo(raw: i64) -> ThermalState {
match raw {
0 => ThermalState::Normal,
1 => ThermalState::Warm,
2 => ThermalState::Hot,
3 => ThermalState::Critical,
_ => ThermalState::Normal,
}
}
#[cfg(target_os = "macos")]
fn read_battery_pct() -> Option<u8> {
let blob = IOPSCopyPowerSourcesInfo()?;
let sources = unsafe { IOPSCopyPowerSourcesList(Some(&blob)) }?;
let count = sources.count();
if count == 0 {
return None;
}
for i in 0..count {
let raw = unsafe { sources.value_at_index(i) };
if raw.is_null() {
continue;
}
let ps: &CFType = unsafe { &*(raw as *const CFType) };
let Some(desc) = (unsafe { IOPSGetPowerSourceDescription(Some(&blob), Some(ps)) })
else {
continue;
};
let Some(current) = lookup_int(&desc, kIOPSCurrentCapacityKey) else {
continue;
};
let Some(max) = lookup_int(&desc, kIOPSMaxCapacityKey) else {
continue;
};
if let Some(pct) = compute_pct(current, max) {
return Some(pct);
}
}
None
}
#[cfg(target_os = "macos")]
fn lookup_int(dict: &CFDictionary, key_cstr: &CStr) -> Option<i64> {
let key_str = key_cstr.to_str().ok()?;
let cf_key = CFString::from_str(key_str);
let key_ptr: *const c_void = (&*cf_key as *const CFString).cast();
let raw = unsafe { dict.value(key_ptr) };
if raw.is_null() {
return None;
}
let cf: &CFType = unsafe { &*(raw as *const CFType) };
let num = cf.downcast_ref::<CFNumber>()?;
num.as_i64()
}
#[cfg(target_os = "macos")]
fn compute_pct(current: i64, max: i64) -> Option<u8> {
if max <= 0 || current < 0 {
return None;
}
let raw = current.saturating_mul(100) / max;
Some(raw.clamp(0, 100) as u8)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thermal_mapping_matches_apple_constants() {
assert_eq!(thermal_from_nsprocessinfo(0), ThermalState::Normal);
assert_eq!(thermal_from_nsprocessinfo(1), ThermalState::Warm);
assert_eq!(thermal_from_nsprocessinfo(2), ThermalState::Hot);
assert_eq!(thermal_from_nsprocessinfo(3), ThermalState::Critical);
}
#[test]
fn thermal_mapping_unknown_falls_back_to_normal() {
assert_eq!(thermal_from_nsprocessinfo(99), ThermalState::Normal);
assert_eq!(thermal_from_nsprocessinfo(-1), ThermalState::Normal);
}
#[test]
fn read_thermal_state_does_not_panic() {
let state = read_thermal_state();
let _ = state;
}
#[cfg(target_os = "macos")]
#[test]
fn compute_pct_handles_normal_values() {
assert_eq!(compute_pct(0, 100), Some(0));
assert_eq!(compute_pct(50, 100), Some(50));
assert_eq!(compute_pct(100, 100), Some(100));
assert_eq!(compute_pct(75, 100), Some(75));
assert_eq!(compute_pct(4_200, 5_000), Some(84));
}
#[cfg(target_os = "macos")]
#[test]
fn compute_pct_zero_or_negative_max_is_none() {
assert_eq!(compute_pct(50, 0), None);
assert_eq!(compute_pct(50, -100), None);
}
#[cfg(target_os = "macos")]
#[test]
fn compute_pct_negative_current_is_none() {
assert_eq!(compute_pct(-1, 100), None);
}
#[cfg(target_os = "macos")]
#[test]
fn compute_pct_clamps_above_max() {
assert_eq!(compute_pct(105, 100), Some(100));
assert_eq!(compute_pct(200, 100), Some(100));
}
#[cfg(target_os = "macos")]
#[test]
fn read_battery_pct_returns_none_or_valid_percent() {
if let Some(pct) = read_battery_pct() {
assert!(pct <= 100, "battery percent out of range: {}", pct);
}
}
}
}
#[cfg(target_os = "windows")]
mod windows {
use std::sync::OnceLock;
use std::thread;
use std::time::Duration;
use windows::core::{BSTR, PCWSTR};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED,
};
use windows::Win32::System::Variant::{VariantClear, VARIANT, VT_I4, VT_UI4};
use windows::Win32::System::Wmi::{
IEnumWbemClassObject, IWbemClassObject, IWbemLocator, IWbemServices, WbemLocator,
WBEM_FLAG_FORWARD_ONLY, WBEM_FLAG_RETURN_IMMEDIATELY, WBEM_INFINITE,
};
use windows_sys::Win32::System::Power::{GetSystemPowerStatus, SYSTEM_POWER_STATUS};
use super::{set_battery_level, set_thermal_state, ThermalState};
const BATTERY_PERCENTAGE_UNKNOWN: u8 = 255;
const THERMAL_POLL_INTERVAL: Duration = Duration::from_secs(3);
const VT_I4_RAW: u16 = VT_I4.0;
const VT_UI4_RAW: u16 = VT_UI4.0;
pub(super) fn refresh() {
if let Some(pct) = read_battery_pct() {
set_battery_level(pct);
}
ensure_thermal_poller();
}
fn read_battery_pct() -> Option<u8> {
let mut status: SYSTEM_POWER_STATUS = unsafe { std::mem::zeroed() };
let ok = unsafe { GetSystemPowerStatus(&mut status) };
if ok == 0 {
return None;
}
battery_pct_from_status(status.BatteryLifePercent)
}
fn battery_pct_from_status(raw: u8) -> Option<u8> {
if raw == BATTERY_PERCENTAGE_UNKNOWN || raw > 100 {
None
} else {
Some(raw)
}
}
fn thermal_from_dk(deci_kelvin: u32) -> ThermalState {
let celsius = (deci_kelvin as f32 / 10.0) - 273.15;
if celsius >= 80.0 {
ThermalState::Critical
} else if celsius >= 70.0 {
ThermalState::Hot
} else if celsius >= 60.0 {
ThermalState::Warm
} else {
ThermalState::Normal
}
}
fn ensure_thermal_poller() {
static POLLER: OnceLock<()> = OnceLock::new();
POLLER.get_or_init(|| {
let spawn = thread::Builder::new()
.name("xybrid-wmi-thermal".into())
.spawn(thermal_poller_main);
if let Err(err) = spawn {
log::warn!("xybrid-wmi-thermal: failed to spawn poller thread: {err}");
}
});
}
fn thermal_poller_main() {
let init = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) };
if init.is_err() {
log::warn!(
"xybrid-wmi-thermal: CoInitializeEx failed ({init:?}), thermal poller exiting"
);
return;
}
loop {
match poll_once() {
Ok(Some(state)) => set_thermal_state(state),
Ok(None) => {}
Err(err) => {
log::debug!("xybrid-wmi-thermal: query failed (continuing): {err:?}");
}
}
thread::sleep(THERMAL_POLL_INTERVAL);
}
}
fn poll_once() -> windows::core::Result<Option<ThermalState>> {
let locator: IWbemLocator =
unsafe { CoCreateInstance(&WbemLocator, None, CLSCTX_INPROC_SERVER)? };
let services: IWbemServices = unsafe {
locator.ConnectServer(
&BSTR::from("ROOT\\WMI"),
&BSTR::new(),
&BSTR::new(),
&BSTR::new(),
0,
&BSTR::new(),
None,
)?
};
let enumerator: IEnumWbemClassObject = unsafe {
services.ExecQuery(
&BSTR::from("WQL"),
&BSTR::from("SELECT CurrentTemperature FROM MSAcpi_ThermalZoneTemperature"),
WBEM_FLAG_FORWARD_ONLY | WBEM_FLAG_RETURN_IMMEDIATELY,
None,
)?
};
let mut warmest_dk: Option<u32> = None;
loop {
let mut row: [Option<IWbemClassObject>; 1] = [None];
let mut returned: u32 = 0;
let _hr = unsafe { enumerator.Next(WBEM_INFINITE, &mut row, &mut returned) };
if returned == 0 {
break;
}
if let Some(obj) = &row[0] {
if let Some(dk) = read_current_temperature(obj) {
warmest_dk = Some(warmest_dk.map_or(dk, |w| w.max(dk)));
}
}
}
Ok(warmest_dk.map(thermal_from_dk))
}
fn read_current_temperature(obj: &IWbemClassObject) -> Option<u32> {
let name: [u16; 19] = [
b'C' as u16,
b'u' as u16,
b'r' as u16,
b'r' as u16,
b'e' as u16,
b'n' as u16,
b't' as u16,
b'T' as u16,
b'e' as u16,
b'm' as u16,
b'p' as u16,
b'e' as u16,
b'r' as u16,
b'a' as u16,
b't' as u16,
b'u' as u16,
b'r' as u16,
b'e' as u16,
0,
];
let mut value = VARIANT::default();
let res = unsafe { obj.Get(PCWSTR(name.as_ptr()), 0, &mut value, None, None) };
if res.is_err() {
return None;
}
let extracted = unsafe {
let inner = &value.Anonymous.Anonymous;
match inner.vt.0 {
VT_I4_RAW => Some(inner.Anonymous.lVal as u32),
VT_UI4_RAW => Some(inner.Anonymous.ulVal),
_ => None,
}
};
let _ = unsafe { VariantClear(&mut value) };
extracted
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_battery_in_range_round_trips() {
assert_eq!(battery_pct_from_status(0), Some(0));
assert_eq!(battery_pct_from_status(50), Some(50));
assert_eq!(battery_pct_from_status(100), Some(100));
}
#[test]
fn unknown_sentinel_maps_to_none() {
assert_eq!(battery_pct_from_status(BATTERY_PERCENTAGE_UNKNOWN), None);
}
#[test]
fn out_of_range_maps_to_none() {
assert_eq!(battery_pct_from_status(101), None);
assert_eq!(battery_pct_from_status(200), None);
assert_eq!(battery_pct_from_status(254), None);
}
#[test]
fn read_battery_pct_does_not_panic() {
if let Some(pct) = read_battery_pct() {
assert!(pct <= 100, "battery percent out of range: {}", pct);
}
}
#[test]
fn deci_kelvin_bands_match_thermal_state_docs() {
assert_eq!(thermal_from_dk(2731), ThermalState::Normal); assert_eq!(thermal_from_dk(3231), ThermalState::Normal); assert_eq!(thermal_from_dk(3331), ThermalState::Normal); assert_eq!(thermal_from_dk(3332), ThermalState::Warm); assert_eq!(thermal_from_dk(3431), ThermalState::Warm); assert_eq!(thermal_from_dk(3432), ThermalState::Hot); assert_eq!(thermal_from_dk(3531), ThermalState::Hot); assert_eq!(thermal_from_dk(3532), ThermalState::Critical); assert_eq!(thermal_from_dk(3731), ThermalState::Critical); }
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn reset() {
set_platform_state(PlatformState::EMPTY);
}
#[test]
fn empty_state_when_nothing_pushed() {
let _g = TEST_LOCK.lock().unwrap();
reset();
let s = current_platform_state();
assert_eq!(s.battery_pct, None);
assert_eq!(s.thermal_state, None);
}
#[test]
fn set_and_clear_battery() {
let _g = TEST_LOCK.lock().unwrap();
reset();
set_battery_level(75);
assert_eq!(current_platform_state().battery_pct, Some(75));
clear_battery_level();
assert_eq!(current_platform_state().battery_pct, None);
}
#[test]
fn set_and_clear_thermal() {
let _g = TEST_LOCK.lock().unwrap();
reset();
set_thermal_state(ThermalState::Hot);
assert_eq!(
current_platform_state().thermal_state,
Some(ThermalState::Hot)
);
clear_thermal_state();
assert_eq!(current_platform_state().thermal_state, None);
}
#[test]
fn set_battery_clamps_to_100() {
let _g = TEST_LOCK.lock().unwrap();
reset();
set_battery_level(255);
assert_eq!(current_platform_state().battery_pct, Some(100));
}
#[test]
fn whole_struct_push_replaces_all_fields() {
let _g = TEST_LOCK.lock().unwrap();
reset();
set_battery_level(40);
set_thermal_state(ThermalState::Warm);
set_platform_state(PlatformState {
battery_pct: Some(80),
thermal_state: None,
});
let s = current_platform_state();
assert_eq!(s.battery_pct, Some(80));
assert_eq!(s.thermal_state, None);
}
#[test]
fn battery_and_thermal_are_independent() {
let _g = TEST_LOCK.lock().unwrap();
reset();
set_battery_level(50);
set_thermal_state(ThermalState::Warm);
clear_battery_level();
let s = current_platform_state();
assert_eq!(s.battery_pct, None);
assert_eq!(s.thermal_state, Some(ThermalState::Warm));
}
#[test]
fn resource_monitor_snapshot_reflects_pushed_state() {
use crate::device::ResourceMonitor;
use std::time::Duration;
let _g = TEST_LOCK.lock().unwrap();
reset();
let monitor = ResourceMonitor::new();
set_battery_level(42);
set_thermal_state(ThermalState::Hot);
let after = monitor.current_snapshot(Duration::ZERO);
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
assert_eq!(after.battery_pct, Some(42));
assert_eq!(after.thermal_state, ThermalState::Hot);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
assert!(
after.battery_pct.map(|p| p <= 100).unwrap_or(true),
"battery_pct out of range: {:?}",
after.battery_pct
);
let _ = after.thermal_state;
}
reset();
}
}