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    atomic::{AtomicU64, Ordering},
12    Arc, Mutex, OnceLock,
13};
14
15use cranpose_core::{rememberEventStream, State};
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)]
253pub fn rememberPowerState() -> State<PowerState> {
254    let updates = rememberEventStream((), |sender| {
255        observe_power_state(move |state| sender.send(state))
256    });
257    cranpose_core::collectAsState(updates, (), power_state())
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    struct DesktopMonitor;
265
266    impl PowerMonitor for DesktopMonitor {
267        fn capabilities(&self) -> PowerCapabilities {
268            PowerCapabilities {
269                thermal: false,
270                battery: false,
271                background_restriction: false,
272            }
273        }
274    }
275
276    struct PhoneMonitor;
277
278    impl PowerMonitor for PhoneMonitor {
279        fn capabilities(&self) -> PowerCapabilities {
280            PowerCapabilities {
281                thermal: true,
282                battery: true,
283                background_restriction: true,
284            }
285        }
286        fn thermal_state(&self) -> PowerReading<ThermalState> {
287            PowerReading::Known(ThermalState::Severe)
288        }
289        fn battery_status(&self) -> PowerReading<BatteryStatus> {
290            PowerReading::Known(BatteryStatus {
291                percent: 12,
292                charging: false,
293            })
294        }
295        fn unrestricted_background_work(&self) -> PowerReading<bool> {
296            PowerReading::Known(false)
297        }
298    }
299
300    #[test]
301    fn a_platform_without_power_apis_says_unsupported_rather_than_full() {
302        let _guard = crate::registry::test_service_guard();
303        clear_platform_power_monitor();
304        let state = power_state();
305        assert_eq!(state, PowerState::unsupported());
306        assert!(!state.thermal.is_supported());
307        assert!(!state.should_pause_work());
308        assert_eq!(power_capabilities(), PowerCapabilities::default());
309    }
310
311    #[test]
312    fn a_backend_that_measures_nothing_still_reports_its_capabilities() {
313        let _guard = crate::registry::test_service_guard();
314        set_platform_power_monitor(Arc::new(DesktopMonitor));
315        assert!(!power_capabilities().thermal);
316        assert_eq!(power_state().battery, PowerReading::Unsupported);
317        clear_platform_power_monitor();
318    }
319
320    #[test]
321    fn severe_thermal_pressure_pauses_sustained_work() {
322        let _guard = crate::registry::test_service_guard();
323        set_platform_power_monitor(Arc::new(PhoneMonitor));
324        let state = power_state();
325        assert!(state.should_pause_work());
326        assert_eq!(
327            state.battery.known().map(|battery| battery.percent),
328            Some(12)
329        );
330        assert!(!state.unrestricted_background_work.unwrap_or(true));
331        clear_platform_power_monitor();
332    }
333
334    #[test]
335    fn observers_see_published_changes_and_stop_when_dropped() {
336        let _guard = crate::registry::test_service_guard();
337        clear_platform_power_monitor();
338        let seen = Arc::new(Mutex::new(Vec::new()));
339        let recorder = Arc::clone(&seen);
340        let registration = observe_power_state(move |state| {
341            recorder
342                .lock()
343                .unwrap_or_else(|error| error.into_inner())
344                .push(state.thermal)
345        });
346        publish_power_state(PowerState {
347            thermal: PowerReading::Known(ThermalState::Moderate),
348            ..PowerState::unsupported()
349        });
350        assert_eq!(
351            seen.lock().unwrap_or_else(|e| e.into_inner()).as_slice(),
352            [PowerReading::Known(ThermalState::Moderate)]
353        );
354        drop(registration);
355        publish_power_state(PowerState::unsupported());
356        assert_eq!(seen.lock().unwrap_or_else(|e| e.into_inner()).len(), 1);
357    }
358}