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::{
11    Arc, Mutex, OnceLock,
12    atomic::{AtomicU64, Ordering},
13};
14
15use cranpose_core::{State, rememberEventStream};
16
17use crate::registry::ServiceRegistry;
18
19/// The operating system's thermal-pressure level.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
21pub enum ThermalState {
22    /// No thermal pressure.
23    #[default]
24    Normal,
25    /// Light pressure; ordinary work may continue.
26    Light,
27    /// Sustained work should be reduced.
28    Moderate,
29    /// Expensive work should pause.
30    Severe,
31    /// The device is close to forced shutdown.
32    Critical,
33    /// Emergency pressure reported by the platform.
34    Emergency,
35    /// The platform is shutting down because of heat.
36    Shutdown,
37}
38
39impl ThermalState {
40    /// Whether sustained, expensive work should stop at this level.
41    pub fn should_pause_work(self) -> bool {
42        self >= ThermalState::Severe
43    }
44}
45
46/// Current battery level and charging state.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub struct BatteryStatus {
49    /// Charge from zero through one hundred percent.
50    pub percent: u8,
51    /// Whether external power is charging or has fully charged the battery.
52    pub charging: bool,
53}
54
55/// One power reading, or the reason there is no value.
56///
57/// The distinction matters: a desktop with no battery is not a device at 100%,
58/// and an application that treats it as one refuses to run its own work on a
59/// machine that is plugged into the wall.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum PowerReading<T> {
62    /// The platform reported this value.
63    Known(T),
64    /// This platform has no API for it.
65    Unsupported,
66    /// The platform has the API but has not reported a value yet.
67    Unknown,
68}
69
70impl<T> PowerReading<T> {
71    /// The value, if the platform reported one.
72    pub fn known(self) -> Option<T> {
73        match self {
74            PowerReading::Known(value) => Some(value),
75            _ => None,
76        }
77    }
78
79    /// Whether this platform can answer at all.
80    pub fn is_supported(&self) -> bool {
81        !matches!(self, PowerReading::Unsupported)
82    }
83
84    /// The value, or `fallback` when there is none.
85    pub fn unwrap_or(self, fallback: T) -> T {
86        self.known().unwrap_or(fallback)
87    }
88}
89
90/// What a power backend can answer on this platform.
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
92pub struct PowerCapabilities {
93    /// Whether [`PowerMonitor::thermal_state`] reports real readings.
94    pub thermal: bool,
95    /// Whether [`PowerMonitor::battery_status`] reports real readings.
96    pub battery: bool,
97    /// Whether the OS has a background-execution restriction to report.
98    pub background_restriction: bool,
99}
100
101/// Everything the platform currently says about power.
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103pub struct PowerState {
104    /// Current thermal pressure.
105    pub thermal: PowerReading<ThermalState>,
106    /// Current battery state.
107    pub battery: PowerReading<BatteryStatus>,
108    /// Whether the OS permits unrestricted background execution.
109    pub unrestricted_background_work: PowerReading<bool>,
110}
111
112impl PowerState {
113    /// The state a platform with no power APIs reports.
114    pub const fn unsupported() -> Self {
115        Self {
116            thermal: PowerReading::Unsupported,
117            battery: PowerReading::Unsupported,
118            unrestricted_background_work: PowerReading::Unsupported,
119        }
120    }
121
122    /// Whether sustained work should stop. A platform that cannot measure heat
123    /// never says stop, because guessing would starve every desktop build.
124    pub fn should_pause_work(&self) -> bool {
125        matches!(self.thermal, PowerReading::Known(level) if level.should_pause_work())
126    }
127}
128
129/// Platform power policy.
130pub trait PowerMonitor: Send + Sync {
131    /// What this backend can answer.
132    fn capabilities(&self) -> PowerCapabilities {
133        PowerCapabilities::default()
134    }
135
136    /// Current thermal pressure.
137    fn thermal_state(&self) -> PowerReading<ThermalState> {
138        PowerReading::Unsupported
139    }
140
141    /// Current battery state.
142    fn battery_status(&self) -> PowerReading<BatteryStatus> {
143        PowerReading::Unsupported
144    }
145
146    /// Whether the OS permits unrestricted background execution for this app.
147    fn unrestricted_background_work(&self) -> PowerReading<bool> {
148        PowerReading::Unsupported
149    }
150
151    /// Opens the platform UI where the user can allow background execution.
152    fn request_unrestricted_background_work(&self) {}
153}
154
155/// Shared power-monitor service.
156pub type PowerMonitorRef = Arc<dyn PowerMonitor>;
157
158struct DefaultPowerMonitor;
159impl PowerMonitor for DefaultPowerMonitor {}
160
161static PLATFORM_POWER_MONITOR: ServiceRegistry<dyn PowerMonitor> = ServiceRegistry::new();
162
163/// Installs the platform power monitor.
164pub fn set_platform_power_monitor(monitor: PowerMonitorRef) {
165    PLATFORM_POWER_MONITOR.set(monitor);
166    publish_power_state(power_state());
167}
168
169/// Removes the platform power monitor.
170pub fn clear_platform_power_monitor() {
171    PLATFORM_POWER_MONITOR.clear();
172    if let Ok(mut observers) = power_observers().lock() {
173        observers.clear();
174    }
175}
176
177/// Returns the platform monitor, or one that answers "unsupported".
178pub fn power_monitor() -> PowerMonitorRef {
179    PLATFORM_POWER_MONITOR
180        .get()
181        .unwrap_or_else(|| Arc::new(DefaultPowerMonitor))
182}
183
184/// The current power state, read from the installed backend.
185pub fn power_state() -> PowerState {
186    let monitor = power_monitor();
187    PowerState {
188        thermal: monitor.thermal_state(),
189        battery: monitor.battery_status(),
190        unrestricted_background_work: monitor.unrestricted_background_work(),
191    }
192}
193
194/// What the installed backend can answer on this platform.
195pub fn power_capabilities() -> PowerCapabilities {
196    power_monitor().capabilities()
197}
198
199type PowerObserver = Arc<dyn Fn(PowerState) + Send + Sync>;
200
201fn power_observers() -> &'static Mutex<Vec<(u64, PowerObserver)>> {
202    static SLOT: OnceLock<Mutex<Vec<(u64, PowerObserver)>>> = OnceLock::new();
203    SLOT.get_or_init(|| Mutex::new(Vec::new()))
204}
205
206static NEXT_OBSERVER: AtomicU64 = AtomicU64::new(1);
207
208/// Keeps a power observer registered until it is dropped.
209pub struct PowerObserverRegistration {
210    id: u64,
211}
212
213impl Drop for PowerObserverRegistration {
214    fn drop(&mut self) {
215        if let Ok(mut observers) = power_observers().lock() {
216            observers.retain(|(id, _)| *id != self.id);
217        }
218    }
219}
220
221/// Registers `observer` for power-state changes. Applications collect
222/// [`rememberPowerState`] instead of calling this.
223pub fn observe_power_state(
224    observer: impl Fn(PowerState) + Send + Sync + 'static,
225) -> PowerObserverRegistration {
226    let id = NEXT_OBSERVER.fetch_add(1, Ordering::Relaxed);
227    if let Ok(mut observers) = power_observers().lock() {
228        observers.push((id, Arc::new(observer)));
229    }
230    PowerObserverRegistration { id }
231}
232
233/// Publishes a new power state. Platform backends call this whenever the OS
234/// reports a thermal or battery change, from whatever thread delivered it.
235pub fn publish_power_state(state: PowerState) {
236    let observers = power_observers()
237        .lock()
238        .map(|observers| {
239            observers
240                .iter()
241                .map(|(_, observer)| Arc::clone(observer))
242                .collect::<Vec<_>>()
243        })
244        .unwrap_or_default();
245    for observer in observers {
246        observer(state);
247    }
248}
249
250/// The device's power state, observed for as long as this call stays in the
251/// composition.
252#[allow(non_snake_case)]
253#[track_caller]
254pub fn rememberPowerState() -> State<PowerState> {
255    let updates = rememberEventStream((), |sender| {
256        observe_power_state(move |state| sender.send(state))
257    });
258    cranpose_core::collectAsState(updates, (), power_state())
259}
260
261#[cfg(test)]
262mod tests {
263    use std::sync::PoisonError;
264
265    use super::*;
266
267    struct DesktopMonitor;
268
269    impl PowerMonitor for DesktopMonitor {
270        fn capabilities(&self) -> PowerCapabilities {
271            PowerCapabilities {
272                thermal: false,
273                battery: false,
274                background_restriction: false,
275            }
276        }
277    }
278
279    struct PhoneMonitor;
280
281    impl PowerMonitor for PhoneMonitor {
282        fn capabilities(&self) -> PowerCapabilities {
283            PowerCapabilities {
284                thermal: true,
285                battery: true,
286                background_restriction: true,
287            }
288        }
289        fn thermal_state(&self) -> PowerReading<ThermalState> {
290            PowerReading::Known(ThermalState::Severe)
291        }
292        fn battery_status(&self) -> PowerReading<BatteryStatus> {
293            PowerReading::Known(BatteryStatus {
294                percent: 12,
295                charging: false,
296            })
297        }
298        fn unrestricted_background_work(&self) -> PowerReading<bool> {
299            PowerReading::Known(false)
300        }
301    }
302
303    #[test]
304    fn a_platform_without_power_apis_says_unsupported_rather_than_full() {
305        let _guard = crate::registry::test_service_guard();
306        clear_platform_power_monitor();
307        let state = power_state();
308        assert_eq!(state, PowerState::unsupported());
309        assert!(!state.thermal.is_supported());
310        assert!(!state.should_pause_work());
311        assert_eq!(power_capabilities(), PowerCapabilities::default());
312    }
313
314    #[test]
315    fn a_backend_that_measures_nothing_still_reports_its_capabilities() {
316        let _guard = crate::registry::test_service_guard();
317        set_platform_power_monitor(Arc::new(DesktopMonitor));
318        assert!(!power_capabilities().thermal);
319        assert_eq!(power_state().battery, PowerReading::Unsupported);
320        clear_platform_power_monitor();
321    }
322
323    #[test]
324    fn severe_thermal_pressure_pauses_sustained_work() {
325        let _guard = crate::registry::test_service_guard();
326        set_platform_power_monitor(Arc::new(PhoneMonitor));
327        let state = power_state();
328        assert!(state.should_pause_work());
329        assert_eq!(
330            state.battery.known().map(|battery| battery.percent),
331            Some(12)
332        );
333        assert!(!state.unrestricted_background_work.unwrap_or(true));
334        clear_platform_power_monitor();
335    }
336
337    #[test]
338    fn observers_see_published_changes_and_stop_when_dropped() {
339        let _guard = crate::registry::test_service_guard();
340        clear_platform_power_monitor();
341        let seen = Arc::new(Mutex::new(Vec::new()));
342        let recorder = Arc::clone(&seen);
343        let registration = observe_power_state(move |state| {
344            recorder
345                .lock()
346                .unwrap_or_else(PoisonError::into_inner)
347                .push(state.thermal);
348        });
349        publish_power_state(PowerState {
350            thermal: PowerReading::Known(ThermalState::Moderate),
351            ..PowerState::unsupported()
352        });
353        assert_eq!(
354            seen.lock()
355                .unwrap_or_else(PoisonError::into_inner)
356                .as_slice(),
357            [PowerReading::Known(ThermalState::Moderate)]
358        );
359        drop(registration);
360        publish_power_state(PowerState::unsupported());
361        assert_eq!(seen.lock().unwrap_or_else(PoisonError::into_inner).len(), 1);
362    }
363}