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