Skip to main content

sioul_sync/
power.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What this computer says of itself, for work it may do while you are away
5//! (the spam filter's training by itself: docs/spam-filter.md, "Training by
6//! itself"): on mains power or on its battery, saving power or not, idle or
7//! in use, its connection metered or not. Each fact is `None` when this
8//! computer cannot tell it (no such service, no answer in time, a system not
9//! read here): whoever decides counts an unknown fact as not holding.
10//!
11//! - **Linux and the BSDs**, over D-Bus: UPower's `OnBattery`;
12//!   power-profiles-daemon's `ActiveProfile`, under both of its names
13//!   (`org.freedesktop.UPower.PowerProfiles`, the older
14//!   `net.hadess.PowerProfiles`); the session's idle time (the desktop's
15//!   `org.freedesktop.ScreenSaver`, in milliseconds as KDE gives it, GNOME's
16//!   `org.gnome.Mutter.IdleMonitor`) and whether it is locked (both screen
17//!   savers, logind's `LockedHint`; logind's `IdleHint` only as a word for
18//!   idle, since some desktops never set it); NetworkManager's `Metered`.
19//! - **Windows**: `GetSystemPowerStatus` (its AC line, its battery saver),
20//!   `GetLastInputInfo`. The connection's cost is not read: unknown.
21//! - **macOS**: IOKit's power sources, Low Power Mode (as `pmset -g` says
22//!   it), `CGEventSourceSecondsSinceLastEventType`. The connection's cost is
23//!   not read: unknown.
24//! - **Android**: nothing; a phone never trains.
25
26use std::time::Duration;
27
28/// What this computer says of itself now.
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub struct Power {
31    /// On mains power, not on its battery.
32    pub on_mains: Option<bool>,
33    /// Saving power: a power-saver profile, Windows' battery saver, Low Power Mode.
34    pub power_saver: Option<bool>,
35    /// Nobody's hands on it for `IDLE` at least, or its session locked.
36    pub idle: Option<bool>,
37    /// Its connection metered (NetworkManager: yes, or guessed yes).
38    pub metered: Option<bool>,
39}
40
41impl Power {
42    /// Its power supply allows long work: on mains power, and not saving
43    /// power, both known.
44    pub fn supplied(&self) -> bool {
45        self.on_mains == Some(true) && self.power_saver == Some(false)
46    }
47}
48
49/// How long without anyone's hands on the computer counts as idle.
50pub const IDLE: Duration = Duration::from_secs(15 * 60);
51
52/// How long one question to the system may take.
53#[cfg_attr(not(all(unix, not(any(target_os = "macos", target_os = "android")))), allow(dead_code))]
54const ASK: Duration = Duration::from_secs(1);
55
56/// Every fact, read now: a few questions to the system, a second each at most.
57pub fn read() -> Power {
58    imp::read(true)
59}
60
61/// Its power supply alone (mains, saving power): what a training running asks
62/// again and again, without the session's questions.
63pub fn supply() -> Power {
64    imp::read(false)
65}
66
67/// NetworkManager's `Metered` (`NMMetered`): yes or guessed yes, metered; no
68/// or guessed no, not; unknown (0) or anything else, unknown.
69pub fn metered_of(value: u32) -> Option<bool> {
70    match value {
71        1 | 3 => Some(true),
72        2 | 4 => Some(false),
73        _ => None,
74    }
75}
76
77/// Idle, from what the session said: locked, idle; else the shortest time
78/// without input any source gave (the newest touch wins); nothing said, unknown.
79pub fn idle_of(locked: bool, shortest: Option<Duration>) -> Option<bool> {
80    if locked {
81        return Some(true);
82    }
83    shortest.map(|d| d >= IDLE)
84}
85
86#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
87mod imp {
88    use super::{ASK, Power, idle_of, metered_of};
89    use dbus::blocking::Connection;
90    use dbus::blocking::stdintf::org_freedesktop_dbus::Properties;
91    use std::time::Duration;
92
93    pub(super) fn read(all: bool) -> Power {
94        let Ok(system) = Connection::new_system() else { return Power::default() };
95        let mut power = Power { on_mains: on_mains(&system), power_saver: power_saver(&system), ..Power::default() };
96        if all {
97            power.metered = metered(&system);
98            power.idle = idle(&system);
99        }
100        power
101    }
102
103    fn on_mains(system: &Connection) -> Option<bool> {
104        let on_battery: bool = system.with_proxy("org.freedesktop.UPower", "/org/freedesktop/UPower", ASK).get("org.freedesktop.UPower", "OnBattery").ok()?;
105        Some(!on_battery)
106    }
107
108    /// power-profiles-daemon under its name, else under its older one.
109    fn power_saver(system: &Connection) -> Option<bool> {
110        [("org.freedesktop.UPower.PowerProfiles", "/org/freedesktop/UPower/PowerProfiles"), ("net.hadess.PowerProfiles", "/net/hadess/PowerProfiles")]
111            .into_iter()
112            .find_map(|(name, path)| system.with_proxy(name, path, ASK).get::<String>(name, "ActiveProfile").ok())
113            .map(|profile| profile == "power-saver")
114    }
115
116    fn metered(system: &Connection) -> Option<bool> {
117        let value: u32 = system.with_proxy("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager", ASK).get("org.freedesktop.NetworkManager", "Metered").ok()?;
118        metered_of(value)
119    }
120
121    fn idle(system: &Connection) -> Option<bool> {
122        let mut locked = false;
123        let mut times: Vec<Duration> = Vec::new();
124        if let Ok(session) = Connection::new_session() {
125            let saver = session.with_proxy("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", ASK);
126            let active: Result<(bool,), _> = saver.method_call("org.freedesktop.ScreenSaver", "GetActive", ());
127            locked |= active.is_ok_and(|(on,)| on);
128            // Milliseconds, as KDE gives them: a desktop giving seconds reads as never idle, never as idle too soon.
129            let since: Result<(u32,), _> = saver.method_call("org.freedesktop.ScreenSaver", "GetSessionIdleTime", ());
130            times.extend(since.ok().map(|(ms,)| Duration::from_millis(u64::from(ms))));
131            let mutter: Result<(u64,), _> = session.with_proxy("org.gnome.Mutter.IdleMonitor", "/org/gnome/Mutter/IdleMonitor/Core", ASK).method_call("org.gnome.Mutter.IdleMonitor", "GetIdletime", ());
132            times.extend(mutter.ok().map(|(ms,)| Duration::from_millis(ms)));
133            let gnome: Result<(bool,), _> = session.with_proxy("org.gnome.ScreenSaver", "/org/gnome/ScreenSaver", ASK).method_call("org.gnome.ScreenSaver", "GetActive", ());
134            locked |= gnome.is_ok_and(|(on,)| on);
135        }
136        let login = system.with_proxy("org.freedesktop.login1", "/org/freedesktop/login1/session/auto", ASK);
137        locked |= login.get::<bool>("org.freedesktop.login1.Session", "LockedHint").unwrap_or(false);
138        // Idle since a time (microseconds of the real clock): a word for idle only.
139        if login.get::<bool>("org.freedesktop.login1.Session", "IdleHint").unwrap_or(false)
140            && let Ok(since) = login.get::<u64>("org.freedesktop.login1.Session", "IdleSinceHint")
141            && let Ok(now) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
142        {
143            times.push(now.saturating_sub(Duration::from_micros(since)));
144        }
145        idle_of(locked, times.into_iter().min())
146    }
147}
148
149#[cfg(windows)]
150mod imp {
151    use super::{IDLE, Power};
152
153    /// `SYSTEM_POWER_STATUS`.
154    #[repr(C)]
155    #[derive(Default)]
156    struct PowerStatus {
157        ac_line_status: u8,
158        battery_flag: u8,
159        battery_life_percent: u8,
160        /// 1 while the battery saver is on.
161        system_status_flag: u8,
162        battery_life_time: u32,
163        battery_full_life_time: u32,
164    }
165
166    /// `LASTINPUTINFO`.
167    #[repr(C)]
168    struct LastInput {
169        size: u32,
170        time: u32,
171    }
172
173    #[link(name = "kernel32")]
174    unsafe extern "system" {
175        fn GetSystemPowerStatus(status: *mut PowerStatus) -> i32;
176        fn GetTickCount() -> u32;
177    }
178
179    #[link(name = "user32")]
180    unsafe extern "system" {
181        fn GetLastInputInfo(info: *mut LastInput) -> i32;
182    }
183
184    pub(super) fn read(all: bool) -> Power {
185        let mut status = PowerStatus::default();
186        // SAFETY: it fills the structure it is given, laid out as Windows declares it.
187        let read = unsafe { GetSystemPowerStatus(&mut status) } != 0;
188        let on_mains = if read {
189            match status.ac_line_status {
190                0 => Some(false),
191                1 => Some(true),
192                _ => None,
193            }
194        } else {
195            None
196        };
197        let power_saver = read.then_some(status.system_status_flag == 1);
198        let mut power = Power { on_mains, power_saver, ..Power::default() };
199        if all {
200            let mut input = LastInput { size: std::mem::size_of::<LastInput>() as u32, time: 0 };
201            // SAFETY: it fills the structure, its size set; the tick count reads a counter.
202            power.idle = (unsafe { GetLastInputInfo(&mut input) } != 0).then(|| {
203                let now = unsafe { GetTickCount() };
204                u128::from(now.wrapping_sub(input.time)) >= IDLE.as_millis()
205            });
206        }
207        power
208    }
209}
210
211#[cfg(target_os = "macos")]
212mod imp {
213    use super::{IDLE, Power};
214    use std::ffi::{CStr, c_char, c_void};
215
216    #[link(name = "IOKit", kind = "framework")]
217    unsafe extern "C" {
218        fn IOPSCopyPowerSourcesInfo() -> *const c_void;
219        fn IOPSGetProvidingPowerSourceType(snapshot: *const c_void) -> *const c_void;
220    }
221
222    #[link(name = "CoreFoundation", kind = "framework")]
223    unsafe extern "C" {
224        fn CFStringGetCString(string: *const c_void, buffer: *mut c_char, size: isize, encoding: u32) -> u8;
225        fn CFRelease(object: *const c_void);
226    }
227
228    #[link(name = "CoreGraphics", kind = "framework")]
229    unsafe extern "C" {
230        fn CGEventSourceSecondsSinceLastEventType(state: i32, event_type: u32) -> f64;
231    }
232
233    /// `kCFStringEncodingUTF8`.
234    const UTF8: u32 = 0x0800_0100;
235
236    pub(super) fn read(all: bool) -> Power {
237        let mut power = Power { on_mains: on_mains(), power_saver: low_power_mode(), ..Power::default() };
238        if all {
239            // The session's every input (`kCGEventSourceStateCombinedSessionState`, `kCGAnyInputEventType`).
240            // SAFETY: it only reads the session's state.
241            let seconds = unsafe { CGEventSourceSecondsSinceLastEventType(0, u32::MAX) };
242            power.idle = (seconds.is_finite() && seconds >= 0.0).then(|| seconds >= IDLE.as_secs_f64());
243        }
244        power
245    }
246
247    /// What provides the power now, as IOKit names it: "AC Power", "Battery Power", "UPS Power".
248    fn on_mains() -> Option<bool> {
249        // SAFETY: the snapshot is ours, released once; the type it names is
250        // borrowed from it and read before that, into a buffer of ours.
251        unsafe {
252            let snapshot = IOPSCopyPowerSourcesInfo();
253            if snapshot.is_null() {
254                return None;
255            }
256            let kind = IOPSGetProvidingPowerSourceType(snapshot);
257            let mut buffer = [0 as c_char; 64];
258            let read = !kind.is_null() && CFStringGetCString(kind, buffer.as_mut_ptr(), buffer.len() as isize, UTF8) != 0;
259            let name = read.then(|| CStr::from_ptr(buffer.as_ptr()).to_string_lossy().into_owned());
260            CFRelease(snapshot);
261            name.map(|n| n == "AC Power")
262        }
263    }
264
265    /// Low Power Mode, as `pmset -g` says it ("lowpowermode 1"); a Mac without it says nothing of it: off.
266    fn low_power_mode() -> Option<bool> {
267        let out = std::process::Command::new("pmset").arg("-g").output().ok().filter(|o| o.status.success())?;
268        let text = String::from_utf8_lossy(&out.stdout);
269        Some(text.lines().any(|line| {
270            let mut words = line.split_whitespace();
271            words.next() == Some("lowpowermode") && words.next() == Some("1")
272        }))
273    }
274}
275
276#[cfg(any(target_os = "android", not(any(unix, windows))))]
277mod imp {
278    pub(super) fn read(_all: bool) -> super::Power {
279        super::Power::default()
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    /// NetworkManager's five answers: a guess counts as what it guesses.
288    #[test]
289    fn metered_as_networkmanager_says() {
290        assert_eq!([0, 1, 2, 3, 4, 9].map(metered_of), [None, Some(true), Some(false), Some(true), Some(false), None]);
291    }
292
293    /// Locked is idle; else the newest touch says it; nothing heard, unknown.
294    #[test]
295    fn idle_as_the_session_says() {
296        let minutes = |m: u64| Some(Duration::from_secs(m * 60));
297        assert_eq!(idle_of(true, None), Some(true));
298        assert_eq!(idle_of(true, minutes(1)), Some(true));
299        assert_eq!(idle_of(false, minutes(15)), Some(true));
300        assert_eq!(idle_of(false, minutes(14)), Some(false));
301        assert_eq!(idle_of(false, None), None);
302    }
303
304    /// Long work needs mains power and no power saving, both known.
305    #[test]
306    fn supplied_needs_both_known() {
307        let p = |on_mains, power_saver| Power { on_mains, power_saver, ..Power::default() };
308        assert!(p(Some(true), Some(false)).supplied());
309        for (on_mains, saver) in [(Some(false), Some(false)), (Some(true), Some(true)), (None, Some(false)), (Some(true), None), (None, None)] {
310            assert!(!p(on_mains, saver).supplied(), "{on_mains:?} {saver:?}");
311        }
312    }
313}