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