git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit36ee3d8b2808544abc7f77df8826638e13e37524
parent142d04d45e
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 16:03
idle: the power plan can override either timeout per mode

The idle manager keeps the idle { } values apart from the values in
force and polls /run/cce/idle_display_off and /run/cce/idle_sleep once a
second (stat only; re-read on change). A present file overrides the
config, an absent or unparsable one means the config's value, so an
unplug shortens the countdown within a second and nothing reloads.
cce-power-apply writes the files from the Power page's Display Off
After and Sleep After levers.

`ccectl idle status` reports plan_display_off= and plan_sleep= beside
the values in force; `ccectl idle timeouts` still edits the config base
and its reply says so while a plan override holds. CCE_IDLE_PLAN_DIR
moves the directory for one process, for shadow testing: /run/cce is
root's, and a shadow must not follow the live machine's files.
WORKSPACE.md documents it beside the animations switch.

Verified in a shadow: override, base edit under override, unparsable
file, and removal each landed within one poll.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 WORKSPACE.md       |  19 +++++
 src/cce_ctl.rs     |   4 +-
 src/server/idle.rs | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++---
 3 files changed, 224 insertions(+), 13 deletions(-)

diff --git a/WORKSPACE.md b/WORKSPACE.md
index 59f35386..0ea14170 100644
--- a/WORKSPACE.md
+++ b/WORKSPACE.md
@@ -384,6 +384,25 @@ border-reveal/adjust-dim fades, and the fullscreen-toggle resize. **A new
 animation should ask `enabled()` too.** Like the close fade, the cce-ui half
 reaches a client only once that client has been rebuilt against the toolkit.
 
+## Idle timeouts per power mode
+
+The compositor's display-off and sleep countdowns come from `idle { }` in
+config.kdl, and the Power page can override either per power mode with the
+**Display Off After** and **Sleep After** levers (`idle_display_off_secs`,
+`idle_sleep_secs` in `/etc/cce/power.kdl`). Same mechanism as the
+animations switch: `cce-power-apply` writes **`/run/cce/idle_display_off`**
+and **`/run/cce/idle_sleep`** as root (seconds on a line, 0 = never), and the
+idle manager (`idle.rs`, `PLAN_DIR` + `PLAN_DISPLAY_OFF_FILE` /
+`PLAN_SLEEP_FILE`) polls both once a second, so an unplug shortens the
+countdown within a second and nothing reloads. `CCE_IDLE_PLAN_DIR=<dir>`
+moves the directory for one compositor, for testing in a shadow, which must
+not follow the live machine's files. A file that is absent means the config's value, and
+`ccectl idle timeouts` edits that config base, which the plan keeps
+overriding while its mode holds the lever; `ccectl idle status` reports the
+values in force plus `plan_display_off=` / `plan_sleep=` (`none` or seconds).
+The paths are spelled in both crates (the app cannot be a compositor
+dependency), so a rename must land on both sides.
+
 ## Repo hygiene
 
 The repo root and `cce-compositor/scratch/` are littered with **ad-hoc debugging artifacts** — many
diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index bea4e2e0..2522a4dd 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -64,9 +64,9 @@ fn usage(name: &str, to_stderr: bool) {
     print("  overview                   # toggle; the exit lands on the focused window, not the pointer");
     print("  windows [--json]           # list windows; --json emits one JSON object per line");
     print("  outputs [--json]           # list outputs: mode, scale, logical size, physical mm, px/mm, and where the mm came from");
-    print("  idle [status]              # idle timeouts: configured seconds, idle time, inhibited and by whom (app ids), displays_off/sleeping");
+    print("  idle [status]              # idle timeouts in force, idle time, inhibited and by whom (app ids), displays_off/sleeping, and the power plan's override of each (plan_*)");
     print("  idle wake|sleep|display on|display off   # act now: wake darkened outputs, run the sleep command, darken/wake outputs");
-    print("  idle timeouts <display_off_s> <sleep_s>  # set the timeouts live (0 = off); config.kdl `idle { }` on reload");
+    print("  idle timeouts <display_off_s> <sleep_s>  # set the config timeouts live (0 = off); config.kdl `idle { }` on reload; a Power-plan file under /run/cce overrides either while it exists");
     print("  status-hide-mode [true|false]");
     print("  adjust-position-mode [true|false|query]");
     print("  exit [force]               # log out; waits for windows to close, cancels if one stays (a save prompt); force skips the wait");
diff --git a/src/server/idle.rs b/src/server/idle.rs
index e8e0c8b6..1309cb95 100644
--- a/src/server/idle.rs
+++ b/src/server/idle.rs
@@ -10,6 +10,13 @@
 //! inhibitor on a mapped surface (a video player) pauses both timers, the
 //! same signal `wlr_idle_notifier_v1_set_inhibited` gets.
 //!
+//! The timeouts come from `idle { }` in config.kdl, but the System
+//! Interface's Power plan can override either per power mode: its applier
+//! writes [`PLAN_DISPLAY_OFF_FILE`] / [`PLAN_SLEEP_FILE`] under [`PLAN_DIR`] as root on plug,
+//! unplug and boot (seconds, 0 = never), and this module polls both once a
+//! second, so battery can darken the display sooner than the desk does
+//! without a reload. A missing file means the config's value.
+//!
 //! "Display off" is the soft-disable the wlr-output-power-management
 //! protocol already drives (`OutputStateValue::DisabledSoft` — the output
 //! stays in the layout, nothing is re-arranged, and no frame events fire
@@ -48,13 +55,73 @@ impl Default for IdleConfig {
 /// measured in minutes.
 const REARM_MIN_MS: u64 = 1000;
 
+/// The Power plan's per-mode timeouts, written by `cce-power-apply`
+/// (`cce_settings::power_plan::IDLE_DISPLAY_OFF_PATH` / `IDLE_SLEEP_PATH`;
+/// the paths are repeated here because that crate is an app, not a
+/// dependency). Seconds, 0 = never; absent = use the config.
+pub const PLAN_DIR: &str = "/run/cce";
+pub const PLAN_DISPLAY_OFF_FILE: &str = "idle_display_off";
+pub const PLAN_SLEEP_FILE: &str = "idle_sleep";
+
+/// Where one plan file lives. `CCE_IDLE_PLAN_DIR` moves the directory for
+/// one process, for testing: /run/cce is root's, and a shadow session must
+/// not read the live machine's plan files either.
+fn plan_path(file: &str) -> String {
+    let dir = std::env::var("CCE_IDLE_PLAN_DIR").ok().filter(|d| !d.is_empty()).unwrap_or_else(|| PLAN_DIR.to_string());
+    format!("{}/{}", dir, file)
+}
+
+/// How often the plan files are stat'ed. A mode change is a plug or an
+/// unplug, so a second is instant to a person and two stats a second is
+/// nothing.
+const PLAN_POLL_MS: i32 = 1000;
+
+/// One plan file's contents as a timeout: seconds on a line, nothing else.
+pub fn parse_plan_secs(text: &str) -> Option<i64> {
+    text.trim().parse::<u32>().ok().map(i64::from)
+}
+
+/// The timeout in force: the plan's when it has one, else the config's.
+fn effective_ms(cfg_ms: i64, plan_ms: Option<i64>) -> i64 {
+    plan_ms.unwrap_or(cfg_ms)
+}
+
+/// A change stamp for one plan file: its mtime in ns plus one, or 0 when it
+/// is absent, so appearing, vanishing and rewriting all read as a change.
+fn plan_stamp(path: &str) -> u128 {
+    std::fs::metadata(path)
+        .ok()
+        .and_then(|m| m.modified().ok())
+        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
+        .map(|d| d.as_nanos() + 1)
+        .unwrap_or(0)
+}
+
+fn read_plan_ms(path: &str) -> Option<i64> {
+    parse_plan_secs(&std::fs::read_to_string(path).ok()?).map(|s| s * 1000)
+}
+
 pub struct IdleManager {
     pub server: *mut Server,
     display_timer: *mut ffi::wl_event_source,
     sleep_timer: *mut ffi::wl_event_source,
-    /// Timeouts in ms; 0 = disabled.
+    /// The timeouts in force, in ms; 0 = disabled. The plan's when it has
+    /// one, else the config's (`effective_ms`).
     display_off_ms: i64,
     sleep_ms: i64,
+    /// The `idle { }` block's own values, kept apart so a plan override can
+    /// be lifted again when its file goes away.
+    cfg_display_off_ms: i64,
+    cfg_sleep_ms: i64,
+    /// The Power plan's per-mode override for each, from the files under
+    /// /run/cce; None while a file is absent or unparsable.
+    plan_display_off_ms: Option<i64>,
+    plan_sleep_ms: Option<i64>,
+    /// Polls the plan files; see `PLAN_POLL_MS`.
+    plan_timer: *mut ffi::wl_event_source,
+    /// `plan_stamp` of each file at the last poll: the files are only
+    /// re-read when one changes.
+    plan_stamps: [u128; 2],
     /// `None` means `DEFAULT_SLEEP_COMMAND`. (An `Option<String>` is
     /// null-niche safe under `Server::new`'s zeroed init; a bare `String`
     /// is not.)
@@ -103,8 +170,26 @@ impl IdleManager {
             self.display_timer = std::ptr::null_mut();
             return Err("Failed to create idle sleep timer");
         }
+        self.plan_timer = ffi::wl_event_loop_add_timer(
+            event_loop,
+            Some(handle_plan_poll),
+            self as *mut IdleManager as *mut _,
+        );
+        if self.plan_timer.is_null() {
+            ffi::wl_event_source_remove(self.display_timer);
+            ffi::wl_event_source_remove(self.sleep_timer);
+            self.display_timer = std::ptr::null_mut();
+            self.sleep_timer = std::ptr::null_mut();
+            return Err("Failed to create idle plan-poll timer");
+        }
         self.display_off_ms = 0;
         self.sleep_ms = 0;
+        self.cfg_display_off_ms = 0;
+        self.cfg_sleep_ms = 0;
+        self.plan_display_off_ms = None;
+        self.plan_sleep_ms = None;
+        self.plan_stamps = [0, 0];
+        ffi::wl_event_source_timer_update(self.plan_timer, PLAN_POLL_MS);
         self.sleep_command = None;
         self.inhibited = false;
         self.inhibitors = None;
@@ -137,18 +222,72 @@ impl IdleManager {
             ffi::wl_event_source_remove(self.sleep_timer);
             self.sleep_timer = std::ptr::null_mut();
         }
+        if !self.plan_timer.is_null() {
+            ffi::wl_event_source_remove(self.plan_timer);
+            self.plan_timer = std::ptr::null_mut();
+        }
     }
 
-    /// Apply an `idle { }` block (config load and `ccectl reload`).
+    /// Apply an `idle { }` block (config load and `ccectl reload`). A plan
+    /// override in force stays in force: the config is the base it lifts to.
     pub unsafe fn configure(&mut self, cfg: &IdleConfig) {
-        self.display_off_ms = cfg.display_off_s.max(0) * 1000;
-        self.sleep_ms = cfg.sleep_s.max(0) * 1000;
+        self.cfg_display_off_ms = cfg.display_off_s.max(0) * 1000;
+        self.cfg_sleep_ms = cfg.sleep_s.max(0) * 1000;
         self.sleep_command = cfg.sleep_command.clone();
+        self.refresh_effective();
+        log::info!(
+            "idle timeouts: display_off={}s sleep={}s command={:?}{}",
+            self.display_off_ms / 1000,
+            self.sleep_ms / 1000,
+            self.sleep_command(),
+            self.plan_note()
+        );
+        self.rearm(true);
+    }
+
+    /// Recompute the timeouts in force from the config and the plan.
+    fn refresh_effective(&mut self) {
+        self.display_off_ms = effective_ms(self.cfg_display_off_ms, self.plan_display_off_ms);
+        self.sleep_ms = effective_ms(self.cfg_sleep_ms, self.plan_sleep_ms);
+    }
+
+    /// True when the Power plan overrides at least one timeout.
+    fn plan_active(&self) -> bool {
+        self.plan_display_off_ms.is_some() || self.plan_sleep_ms.is_some()
+    }
+
+    /// For log lines: which values the plan is imposing, or nothing.
+    fn plan_note(&self) -> String {
+        if !self.plan_active() {
+            return String::new();
+        }
+        let show = |v: Option<i64>| v.map(|ms| format!("{}s", ms / 1000)).unwrap_or_else(|| "config".to_string());
+        format!(
+            " (power plan: display_off={} sleep={}, config {}s/{}s)",
+            show(self.plan_display_off_ms),
+            show(self.plan_sleep_ms),
+            self.cfg_display_off_ms / 1000,
+            self.cfg_sleep_ms / 1000
+        )
+    }
+
+    /// From `plan_timer`: re-read the plan files when either changed, and
+    /// put the new timeouts in force from now.
+    pub unsafe fn poll_plan(&mut self) {
+        let (off_path, sleep_path) = (plan_path(PLAN_DISPLAY_OFF_FILE), plan_path(PLAN_SLEEP_FILE));
+        let stamps = [plan_stamp(&off_path), plan_stamp(&sleep_path)];
+        if stamps == self.plan_stamps {
+            return;
+        }
+        self.plan_stamps = stamps;
+        self.plan_display_off_ms = read_plan_ms(&off_path);
+        self.plan_sleep_ms = read_plan_ms(&sleep_path);
+        self.refresh_effective();
         log::info!(
-            "idle timeouts: display_off={}s sleep={}s command={:?}",
-            cfg.display_off_s.max(0),
-            cfg.sleep_s.max(0),
-            self.sleep_command()
+            "idle timeouts: display_off={}s sleep={}s{}",
+            self.display_off_ms / 1000,
+            self.sleep_ms / 1000,
+            if self.plan_active() { self.plan_note() } else { " (power plan lifted, config values)".to_string() }
         );
         self.rearm(true);
     }
@@ -281,7 +420,7 @@ impl IdleManager {
     pub fn status(&self) -> String {
         let idle_s = now_ms().saturating_sub(self.last_activity_ms) / 1000;
         format!(
-            "display_off={}s sleep={}s command={:?} idle={}s inhibited={} inhibited_by={:?} displays_off={} sleeping={}\n",
+            "display_off={}s sleep={}s command={:?} idle={}s inhibited={} inhibited_by={:?} displays_off={} sleeping={} plan_display_off={} plan_sleep={}\n",
             self.display_off_ms / 1000,
             self.sleep_ms / 1000,
             self.sleep_command(),
@@ -289,7 +428,9 @@ impl IdleManager {
             self.inhibited,
             self.inhibited_by(),
             self.displays_off,
-            self.sleeping
+            self.sleeping,
+            plan_field(self.plan_display_off_ms),
+            plan_field(self.plan_sleep_ms)
         )
     }
 
@@ -318,7 +459,15 @@ impl IdleManager {
                     (Ok(d), Ok(s)) if d >= 0 && s >= 0 => {
                         let cfg = IdleConfig { display_off_s: d, sleep_s: s, sleep_command: self.sleep_command.clone() };
                         self.configure(&cfg);
-                        "ok\n".to_string()
+                        if self.plan_active() {
+                            format!(
+                                "ok, as the config base; the power plan keeps display_off={}s sleep={}s in force until its mode changes\n",
+                                self.display_off_ms / 1000,
+                                self.sleep_ms / 1000
+                            )
+                        } else {
+                            "ok\n".to_string()
+                        }
                     }
                     _ => "error: idle timeouts <display_off_s> <sleep_s> (non-negative seconds, 0 = off)\n".to_string(),
                 }
@@ -328,6 +477,21 @@ impl IdleManager {
     }
 }
 
+/// `none`, or the plan's seconds, for the status line.
+fn plan_field(ms: Option<i64>) -> String {
+    ms.map(|v| format!("{}s", v / 1000)).unwrap_or_else(|| "none".to_string())
+}
+
+unsafe extern "C" fn handle_plan_poll(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+    let idle = &mut *(data as *mut IdleManager);
+    idle.poll_plan();
+    // wl timers fire once; re-arm for the next look.
+    if !idle.plan_timer.is_null() {
+        ffi::wl_event_source_timer_update(idle.plan_timer, PLAN_POLL_MS);
+    }
+    0
+}
+
 unsafe extern "C" fn handle_display_timeout(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
     let idle = &mut *(data as *mut IdleManager);
     if !idle.inhibited && !idle.displays_off {
@@ -355,3 +519,31 @@ unsafe extern "C" fn handle_session_active(listener: *mut ffi::wl_listener, _dat
     log::info!("idle: session active (resume / VT switch), waking");
     idle.on_activity();
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn a_plan_file_is_seconds_and_nothing_else() {
+        assert_eq!(parse_plan_secs("600\n"), Some(600));
+        assert_eq!(parse_plan_secs(" 0 "), Some(0));
+        assert_eq!(parse_plan_secs("-5"), None);
+        assert_eq!(parse_plan_secs("10m"), None);
+        assert_eq!(parse_plan_secs(""), None);
+    }
+
+    #[test]
+    fn the_plan_wins_while_present_and_the_config_returns_after() {
+        assert_eq!(effective_ms(600_000, Some(120_000)), 120_000);
+        assert_eq!(effective_ms(600_000, Some(0)), 0);
+        assert_eq!(effective_ms(600_000, None), 600_000);
+        assert_eq!(plan_field(Some(120_000)), "120s");
+        assert_eq!(plan_field(None), "none");
+    }
+
+    #[test]
+    fn an_absent_plan_file_stamps_as_zero() {
+        assert_eq!(plan_stamp("/nonexistent/cce/idle_display_off"), 0);
+    }
+}