Skip to main content

cranpose_services/
power.rs

1//! Device power and thermal state: observable, capability-aware, and explicit
2//! about what this platform cannot answer.
3//!
4//! A snapshot query cannot tell "the battery is at 100%" from "this platform
5//! has no battery API", and an application that guesses gets the decision
6//! wrong on the platform it was not written for. Every reading here is either a
7//! value or [`PowerReading::Unsupported`], and the whole state is observable so
8//! a screen reacts to thermal pressure instead of sampling it.
9
10use std::sync::{Arc, Mutex, OnceLock, atomic::Ordering};
11
12use cranpose_core::{State, rememberEventStream};
13use portable_atomic::AtomicU64;
14
15use crate::registry::ServiceRegistry;
16
17/// The operating system's thermal-pressure level.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
19pub enum ThermalState {
20    /// No thermal pressure.
21    #[default]
22    Normal,
23    /// Light pressure; ordinary work may continue.
24    Light,
25    /// Sustained work should be reduced.
26    Moderate,
27    /// Expensive work should pause.
28    Severe,
29    /// The device is close to forced shutdown.
30    Critical,
31    /// Emergency pressure reported by the platform.
32    Emergency,
33    /// The platform is shutting down because of heat.
34    Shutdown,
35}
36
37impl ThermalState {
38    /// Whether sustained, expensive work should stop at this level.
39    pub fn should_pause_work(self) -> bool {
40        self >= ThermalState::Severe
41    }
42}
43
44/// Current battery level and charging state.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct BatteryStatus {
47    /// Charge from zero through one hundred percent.
48    pub percent: u8,
49    /// Whether external power is charging or has fully charged the battery.
50    pub charging: bool,
51}
52
53/// One power reading, or the reason there is no value.
54///
55/// The distinction matters: a desktop with no battery is not a device at 100%,
56/// and an application that treats it as one refuses to run its own work on a
57/// machine that is plugged into the wall.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum PowerReading<T> {
60    /// The platform reported this value.
61    Known(T),
62    /// This platform has no API for it.
63    Unsupported,
64    /// The platform has the API but has not reported a value yet.
65    Unknown,
66}
67
68impl<T> PowerReading<T> {
69    /// The value, if the platform reported one.
70    pub fn known(self) -> Option<T> {
71        match self {
72            PowerReading::Known(value) => Some(value),
73            _ => None,
74        }
75    }
76
77    /// Whether this platform can answer at all.
78    pub fn is_supported(&self) -> bool {
79        !matches!(self, PowerReading::Unsupported)
80    }
81
82    /// The value, or `fallback` when there is none.
83    pub fn unwrap_or(self, fallback: T) -> T {
84        self.known().unwrap_or(fallback)
85    }
86}
87
88/// What a power backend can answer on this platform.
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub struct PowerCapabilities {
91    /// Whether [`PowerMonitor::thermal_state`] reports real readings.
92    pub thermal: bool,
93    /// Whether [`PowerMonitor::battery_status`] reports real readings.
94    pub battery: bool,
95    /// Whether the OS has a background-execution restriction to report.
96    pub background_restriction: bool,
97}
98
99/// Everything the platform currently says about power.
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub struct PowerState {
102    /// Current thermal pressure.
103    pub thermal: PowerReading<ThermalState>,
104    /// Current battery state.
105    pub battery: PowerReading<BatteryStatus>,
106    /// Whether the OS permits unrestricted background execution.
107    pub unrestricted_background_work: PowerReading<bool>,
108}
109
110impl PowerState {
111    /// The state a platform with no power APIs reports.
112    pub const fn unsupported() -> Self {
113        Self {
114            thermal: PowerReading::Unsupported,
115            battery: PowerReading::Unsupported,
116            unrestricted_background_work: PowerReading::Unsupported,
117        }
118    }
119
120    /// Whether sustained work should stop. A platform that cannot measure heat
121    /// never says stop, because guessing would starve every desktop build.
122    pub fn should_pause_work(&self) -> bool {
123        matches!(self.thermal, PowerReading::Known(level) if level.should_pause_work())
124    }
125}
126
127/// Platform power policy.
128pub trait PowerMonitor: Send + Sync {
129    /// What this backend can answer.
130    fn capabilities(&self) -> PowerCapabilities {
131        PowerCapabilities::default()
132    }
133
134    /// Current thermal pressure.
135    fn thermal_state(&self) -> PowerReading<ThermalState> {
136        PowerReading::Unsupported
137    }
138
139    /// Current battery state.
140    fn battery_status(&self) -> PowerReading<BatteryStatus> {
141        PowerReading::Unsupported
142    }
143
144    /// Whether the OS permits unrestricted background execution for this app.
145    fn unrestricted_background_work(&self) -> PowerReading<bool> {
146        PowerReading::Unsupported
147    }
148
149    /// Opens the platform UI where the user can allow background execution.
150    fn request_unrestricted_background_work(&self) {}
151}
152
153/// Shared power-monitor service.
154pub type PowerMonitorRef = Arc<dyn PowerMonitor>;
155
156struct DefaultPowerMonitor;
157impl PowerMonitor for DefaultPowerMonitor {}
158
159static PLATFORM_POWER_MONITOR: ServiceRegistry<dyn PowerMonitor> = ServiceRegistry::new();
160
161/// Installs the platform power monitor.
162pub fn set_platform_power_monitor(monitor: PowerMonitorRef) {
163    PLATFORM_POWER_MONITOR.set(monitor);
164    publish_power_state(power_state());
165}
166
167/// Removes the platform power monitor.
168pub fn clear_platform_power_monitor() {
169    PLATFORM_POWER_MONITOR.clear();
170    if let Ok(mut observers) = power_observers().lock() {
171        observers.clear();
172    }
173}
174
175/// Returns the platform monitor, or one that answers "unsupported".
176pub fn power_monitor() -> PowerMonitorRef {
177    PLATFORM_POWER_MONITOR
178        .get()
179        .unwrap_or_else(|| Arc::new(DefaultPowerMonitor))
180}
181
182/// The current power state, read from the installed backend.
183pub fn power_state() -> PowerState {
184    let monitor = power_monitor();
185    PowerState {
186        thermal: monitor.thermal_state(),
187        battery: monitor.battery_status(),
188        unrestricted_background_work: monitor.unrestricted_background_work(),
189    }
190}
191
192/// What the installed backend can answer on this platform.
193pub fn power_capabilities() -> PowerCapabilities {
194    power_monitor().capabilities()
195}
196
197type PowerObserver = Arc<dyn Fn(PowerState) + Send + Sync>;
198
199fn power_observers() -> &'static Mutex<Vec<(u64, PowerObserver)>> {
200    static SLOT: OnceLock<Mutex<Vec<(u64, PowerObserver)>>> = OnceLock::new();
201    SLOT.get_or_init(|| Mutex::new(Vec::new()))
202}
203
204static NEXT_OBSERVER: AtomicU64 = AtomicU64::new(1);
205
206/// Keeps a power observer registered until it is dropped.
207pub struct PowerObserverRegistration {
208    id: u64,
209}
210
211impl Drop for PowerObserverRegistration {
212    fn drop(&mut self) {
213        if let Ok(mut observers) = power_observers().lock() {
214            observers.retain(|(id, _)| *id != self.id);
215        }
216    }
217}
218
219/// Registers `observer` for power-state changes. Applications collect
220/// [`rememberPowerState`] instead of calling this.
221pub fn observe_power_state(
222    observer: impl Fn(PowerState) + Send + Sync + 'static,
223) -> PowerObserverRegistration {
224    let id = NEXT_OBSERVER.fetch_add(1, Ordering::Relaxed);
225    if let Ok(mut observers) = power_observers().lock() {
226        observers.push((id, Arc::new(observer)));
227    }
228    PowerObserverRegistration { id }
229}
230
231/// Publishes a new power state. Platform backends call this whenever the OS
232/// reports a thermal or battery change, from whatever thread delivered it.
233pub fn publish_power_state(state: PowerState) {
234    let observers = power_observers()
235        .lock()
236        .map(|observers| {
237            observers
238                .iter()
239                .map(|(_, observer)| Arc::clone(observer))
240                .collect::<Vec<_>>()
241        })
242        .unwrap_or_default();
243    for observer in observers {
244        observer(state);
245    }
246}
247
248/// The device's power state, observed for as long as this call stays in the
249/// composition.
250#[expect(non_snake_case)]
251#[track_caller]
252pub fn rememberPowerState() -> State<PowerState> {
253    let updates = rememberEventStream((), |sender| {
254        observe_power_state(move |state| sender.send(state))
255    });
256    cranpose_core::collectAsState(updates, (), power_state())
257}
258
259#[cfg(test)]
260#[path = "tests/power_tests.rs"]
261mod tests;