git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commit8df9b0bebfef6cd15613d3407fc3d7085695f2a6
parentda8c6ce7ba
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 16:03
power: idle timeouts as per-mode levers

Two levers join the plan: idle_display_off_secs and idle_sleep_secs
(seconds, 0 = never, at most a day). Like the animations switch they
are not sysfs: the applier writes /run/cce/idle_display_off and
/run/cce/idle_sleep as root on plug, unplug and boot, and the
compositor's idle manager lets a present file override its idle { }
block, so battery can darken the display sooner than the desk does.
write_animations became write_run_state and serves all three.

The Power page offers them on every host as "Display Off After" and
"Sleep After", with fixed rungs, and reads the live row from
`ccectl idle status`. Tests cover the value guard, the status parsing,
the duration text and the presence-gate counts.

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

 src/pages/power.rs | 104 ++++++++++++++++++++++++++++++++++++++++++++++-------
 src/power_plan.rs  |  69 ++++++++++++++++++++++++++++-------
 2 files changed, 148 insertions(+), 25 deletions(-)

diff --git a/src/pages/power.rs b/src/pages/power.rs
index e771d38..0e31dc7 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -10,11 +10,15 @@
 //!   charge at 80% is the classic battery-longevity lever
 //! - `/sys/devices/system/cpu/intel_pstate/no_turbo` — turbo boost
 //!
-//! One lever is not a hardware interface: **Animations**, which the helper
-//! records at `cce_ui::motion::STATE_PATH` for the compositor and every
-//! cce-ui client to follow. It is per mode for the same reason the others
-//! are — easing every frame costs power, so it belongs with what changes on
-//! unplug — and it is always offered, since every host has animations.
+//! Three levers are not hardware interfaces: **Animations**, which the
+//! helper records at `cce_ui::motion::STATE_PATH` for the compositor and
+//! every cce-ui client to follow, and the two **idle timeouts** (display
+//! off, sleep), recorded at `power_plan::IDLE_DISPLAY_OFF_PATH` and
+//! `IDLE_SLEEP_PATH` for the compositor's idle manager, which lets them
+//! override its `idle { }` block. They are per mode for the same reason the
+//! others are — easing every frame costs power, and a display left lit on
+//! battery costs more — and they are always offered, since every host has
+//! a display and animations.
 //!
 //! The page is three sections. **Battery** is the pack itself: its facts and
 //! the charge limit, which is a charging policy and so belongs to no mode.
@@ -107,6 +111,10 @@ pub struct PowerFacts {
     /// Whether the session animates right now: the helper's state file, or
     /// on when there is none — which is what the toolkit and compositor do.
     pub animations: bool,
+    /// The compositor's effective idle timeouts in seconds (0 = never), from
+    /// `ccectl idle status`; None when no compositor answered.
+    pub idle_display_off_secs: Option<u32>,
+    pub idle_sleep_secs: Option<u32>,
 }
 
 
@@ -385,6 +393,16 @@ pub async fn fetch_power_state() -> PowerFacts {
     f.hda_idle_secs =
         read_trim("/sys/module/snd_hda_intel/parameters/power_save").and_then(|s| s.parse().ok());
 
+    // The compositor reports its effective timeouts (plan override or config)
+    // in `key=<secs>s` fields; no compositor, no rows worth a live value.
+    if let Ok(out) = tokio::process::Command::new("ccectl").args(["idle", "status"]).output().await {
+        if out.status.success() {
+            let text = String::from_utf8_lossy(&out.stdout);
+            f.idle_display_off_secs = idle_status_field(&text, "display_off");
+            f.idle_sleep_secs = idle_status_field(&text, "sleep");
+        }
+    }
+
     if let Some(govs) = read_trim("/sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors") {
         f.governors = govs.split_whitespace().map(String::from).collect();
         f.governor = read_trim("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor").unwrap_or_default();
@@ -414,6 +432,21 @@ pub async fn fetch_power_state() -> PowerFacts {
     f
 }
 
+/// One `<key>=<n>s` field of `ccectl idle status`.
+fn idle_status_field(status: &str, key: &str) -> Option<u32> {
+    status
+        .split_whitespace()
+        .find_map(|tok| tok.strip_prefix(key)?.strip_prefix('='))
+        .and_then(|v| v.strip_suffix('s'))
+        .and_then(|v| v.parse().ok())
+}
+
+/// The idle timeouts a mode may pick, in seconds. Fixed rungs rather than a
+/// free field: a dropdown is what every other lever is, and these cover the
+/// range anyone sets (0 = never).
+const IDLE_DISPLAY_OFF_CHOICES: [u32; 7] = [0, 60, 120, 300, 600, 900, 1800];
+const IDLE_SLEEP_CHOICES: [u32; 7] = [0, 300, 600, 900, 1800, 3600, 7200];
+
 // ── Lever rows ──────────────────────────────────────────────────────────
 
 /// The choices this host offers for a lever, as (plan value, display text)
@@ -457,6 +490,14 @@ fn choices(lever: Lever, f: &PowerFacts) -> Vec<(String, String)> {
                 Vec::new()
             }
         }
+        Lever::IdleDisplayOffSecs => IDLE_DISPLAY_OFF_CHOICES
+            .iter()
+            .map(|v| (v.to_string(), display_of(Lever::IdleDisplayOffSecs, &v.to_string())))
+            .collect(),
+        Lever::IdleSleepSecs => IDLE_SLEEP_CHOICES
+            .iter()
+            .map(|v| (v.to_string(), display_of(Lever::IdleSleepSecs, &v.to_string())))
+            .collect(),
         Lever::GpuLimitW => {
             let mut out: Vec<(String, String)> = Vec::new();
             if let Some(d) = f.gpu_default_w {
@@ -485,6 +526,8 @@ fn live(lever: Lever, f: &PowerFacts) -> Option<String> {
         Lever::AudioIdleSecs => f.hda_idle_secs.map(|v| v.to_string()),
         Lever::GpuLimitW => f.gpu_limit_w.map(|v| v.to_string()),
         Lever::Animations => Some(if f.animations { "on" } else { "off" }.to_string()),
+        Lever::IdleDisplayOffSecs => f.idle_display_off_secs.map(|v| v.to_string()),
+        Lever::IdleSleepSecs => f.idle_sleep_secs.map(|v| v.to_string()),
     }
 }
 
@@ -499,10 +542,22 @@ fn display_of(lever: Lever, value: &str) -> String {
             if value == "0" { "Never suspend".to_string() } else { format!("After {} s idle", value) }
         }
         Lever::GpuLimitW => format!("{} W", value),
+        Lever::IdleDisplayOffSecs | Lever::IdleSleepSecs => idle_secs_text(value),
         _ => pretty(value),
     }
 }
 
+/// "Never", or the duration in the unit it reads best in.
+fn idle_secs_text(value: &str) -> String {
+    match value.parse::<u32>() {
+        Ok(0) => "Never".to_string(),
+        Ok(s) if s % 3600 == 0 => format!("After {} h", s / 3600),
+        Ok(s) if s % 60 == 0 => format!("After {} min", s / 60),
+        Ok(s) => format!("After {} s", s),
+        Err(_) => value.to_string(),
+    }
+}
+
 /// Reflect a value the user just applied into the live facts, so the next
 /// watcher read (which will say the same thing) does not rebuild the menus.
 fn set_live(lever: Lever, value: &str, f: &mut PowerFacts) {
@@ -516,6 +571,8 @@ fn set_live(lever: Lever, value: &str, f: &mut PowerFacts) {
         Lever::AudioIdleSecs => f.hda_idle_secs = value.parse().ok(),
         Lever::GpuLimitW => f.gpu_limit_w = value.parse().ok(),
         Lever::Animations => f.animations = value == "on",
+        Lever::IdleDisplayOffSecs => f.idle_display_off_secs = value.parse().ok(),
+        Lever::IdleSleepSecs => f.idle_sleep_secs = value.parse().ok(),
     }
 }
 
@@ -988,6 +1045,8 @@ mod tests {
             aspm: "default".to_string(),
             hda_idle_secs: Some(10),
             animations: true,
+            idle_display_off_secs: Some(600),
+            idle_sleep_secs: Some(1800),
         }
     }
 
@@ -1164,32 +1223,33 @@ mod tests {
         rebuild_options(&mut st);
         let counts = |st: &mut PowerState| st.section_widgets().iter().map(Vec::len).collect::<Vec<_>>();
         // Every interface present: the charge limit, the mode picker plus all
-        // nine levers, and one assignment per adapter state.
-        assert_eq!(counts(&mut st), [1, 10, 2]);
+        // eleven levers, and one assignment per adapter state.
+        assert_eq!(counts(&mut st), [1, 12, 2]);
         // A host without a charge-limit knob or turbo file reports fewer.
         st.facts.charge_limit = None;
         st.facts.turbo = None;
         rebuild_options(&mut st);
-        assert_eq!(counts(&mut st), [0, 9, 2]);
+        assert_eq!(counts(&mut st), [0, 11, 2]);
         // No cpufreq governors and no NVIDIA driver: both drop out too.
         st.facts.governors.clear();
         st.facts.gpu_limit_w = None;
         st.facts.gpu_default_w = None;
         st.facts.gpu_min_w = None;
         rebuild_options(&mut st);
-        assert_eq!(counts(&mut st), [0, 7, 2]);
+        assert_eq!(counts(&mut st), [0, 9, 2]);
         // No Intel render clocks, an ASPM-less kernel and no snd_hda_intel is
-        // down to profile, epp and animations — which no host lacks.
+        // down to profile, epp, animations and the two idle timeouts — which
+        // no host lacks.
         st.facts.igpu_max_mhz = None;
         st.facts.igpu_min_mhz = None;
         st.facts.aspm_policies.clear();
         st.facts.hda_idle_secs = None;
         rebuild_options(&mut st);
-        assert_eq!(counts(&mut st), [0, 4, 2]);
+        assert_eq!(counts(&mut st), [0, 6, 2]);
         // A desktop: one adapter state, so there is nothing to assign.
         st.facts.battery_present = false;
         rebuild_options(&mut st);
-        assert_eq!(counts(&mut st), [0, 4]);
+        assert_eq!(counts(&mut st), [0, 6]);
     }
 
     #[test]
@@ -1303,6 +1363,8 @@ mod tests {
             plan: st.facts.plan.clone(),
             source: Source::Battery,
             animations: false,
+            idle_display_off_secs: None,
+            idle_sleep_secs: None,
             ..PowerFacts::default()
         };
         rebuild_options(&mut st);
@@ -1330,4 +1392,22 @@ mod tests {
         assert_eq!(live(Lever::GpuLimitW, &f).as_deref(), Some("40"));
         assert_eq!(live(Lever::Animations, &f).as_deref(), Some("off"));
     }
+
+    #[test]
+    fn idle_timeouts_read_the_status_line_and_print_as_durations() {
+        let line = "display_off=600s sleep=1800s command=\"systemctl suspend\" idle=4s inhibited=false inhibited_by=\"\" displays_off=false sleeping=false\n";
+        assert_eq!(idle_status_field(line, "display_off"), Some(600));
+        assert_eq!(idle_status_field(line, "sleep"), Some(1800));
+        assert_eq!(idle_status_field(line, "idle"), Some(4));
+        assert_eq!(idle_status_field("garbage", "sleep"), None);
+        assert_eq!(idle_secs_text("0"), "Never");
+        assert_eq!(idle_secs_text("90"), "After 90 s");
+        assert_eq!(idle_secs_text("600"), "After 10 min");
+        assert_eq!(idle_secs_text("7200"), "After 2 h");
+        // Both levers are offered on every host, and the live row follows
+        // the compositor's report.
+        let f = facts();
+        assert_eq!(choices(Lever::IdleDisplayOffSecs, &f).len(), IDLE_DISPLAY_OFF_CHOICES.len());
+        assert_eq!(live(Lever::IdleSleepSecs, &f), Some("1800".to_string()));
+    }
 }
diff --git a/src/power_plan.rs b/src/power_plan.rs
index fccc331..d616c33 100644
--- a/src/power_plan.rs
+++ b/src/power_plan.rs
@@ -30,6 +30,14 @@
 //! }
 //! ```
 //!
+//! Two levers are not sysfs: **animations** and the two **idle timeouts**
+//! (`idle_display_off_secs`, `idle_sleep_secs`). The applier records them
+//! as files under `/run/cce` ([`cce_ui::motion::STATE_PATH`],
+//! [`IDLE_DISPLAY_OFF_PATH`], [`IDLE_SLEEP_PATH`]) that the compositor and
+//! the toolkit follow without a reload; the idle files override the
+//! compositor's `idle { }` block while they exist, so battery can darken
+//! the display sooner than the desk does.
+//!
 //! A lever absent from a mode is left alone when that mode becomes active —
 //! "not set" means "don't touch", never "reset to a default". The older
 //! per-source form of this file (top-level `ac` / `battery` blocks of
@@ -140,9 +148,11 @@ pub enum Lever {
     AudioIdleSecs,
     GpuLimitW,
     Animations,
+    IdleDisplayOffSecs,
+    IdleSleepSecs,
 }
 impl Lever {
-    pub const ALL: [Lever; 9] = [
+    pub const ALL: [Lever; 11] = [
         Lever::Profile,
         Lever::Epp,
         Lever::Governor,
@@ -152,6 +162,8 @@ impl Lever {
         Lever::AudioIdleSecs,
         Lever::GpuLimitW,
         Lever::Animations,
+        Lever::IdleDisplayOffSecs,
+        Lever::IdleSleepSecs,
     ];
 
     /// The node name in the plan file, and the CLI spelling.
@@ -166,6 +178,8 @@ impl Lever {
             Lever::AudioIdleSecs => "audio_idle_secs",
             Lever::GpuLimitW => "gpu_limit_w",
             Lever::Animations => "animations",
+            Lever::IdleDisplayOffSecs => "idle_display_off_secs",
+            Lever::IdleSleepSecs => "idle_sleep_secs",
         }
     }
 
@@ -184,12 +198,21 @@ impl Lever {
             Lever::AudioIdleSecs => "Audio Codec Idle",
             Lever::GpuLimitW => "GPU Power Limit",
             Lever::Animations => "Animations",
+            Lever::IdleDisplayOffSecs => "Display Off After",
+            Lever::IdleSleepSecs => "Sleep After",
         }
     }
 
     /// Numeric levers are stored as KDL integers; the rest as strings.
     pub fn is_numeric(self) -> bool {
-        matches!(self, Lever::IgpuMaxMhz | Lever::AudioIdleSecs | Lever::GpuLimitW)
+        matches!(
+            self,
+            Lever::IgpuMaxMhz
+                | Lever::AudioIdleSecs
+                | Lever::GpuLimitW
+                | Lever::IdleDisplayOffSecs
+                | Lever::IdleSleepSecs
+        )
     }
 
     /// Shape check on a value before it goes anywhere near sysfs or a shell
@@ -591,7 +614,15 @@ pub fn apply_lever(lever: Lever, value: &str) -> Result<(), String> {
             }
             write_sysfs(Path::new("/sys/module/snd_hda_intel/parameters/power_save"), value)
         }
-        Lever::Animations => write_animations(value),
+        Lever::Animations => write_run_state(Path::new(cce_ui::motion::STATE_PATH), value),
+        Lever::IdleDisplayOffSecs | Lever::IdleSleepSecs => {
+            let secs: u32 = value.parse().map_err(|_| "bad seconds".to_string())?;
+            if secs > 86_400 {
+                return Err("idle timeout above a day".to_string());
+            }
+            let path = if lever == Lever::IdleDisplayOffSecs { IDLE_DISPLAY_OFF_PATH } else { IDLE_SLEEP_PATH };
+            write_run_state(Path::new(path), value)
+        }
         Lever::GpuLimitW => {
             // nvidia-smi validates the watts against the card's own min/max
             // and refuses anything outside them, so it is the range check.
@@ -610,17 +641,25 @@ pub fn apply_lever(lever: Lever, value: &str) -> Result<(), String> {
     }
 }
 
-/// Record the animations switch where every session reads it
-/// ([`cce_ui::motion::STATE_PATH`]). Not sysfs, but the same shape of lever:
-/// root writes it when the mode changes, and the compositor and every cce-ui
-/// client follow it without a restart. Under /run, not in anyone's config:
-/// this runs as root with no session and no `$HOME`, and a tmpfs file is
-/// rewritten at boot by the same coldplug run that applies the rest of the
-/// mode, so it can never outlive the plan that set it.
-fn write_animations(value: &str) -> Result<(), String> {
+/// Where the idle-timeout levers land, in seconds (0 = never). The
+/// compositor's idle manager polls both and lets a present file override
+/// its `idle { }` block; a missing file means "the config's value". The
+/// paths are repeated in `cce-fx`'s `idle.rs` (it cannot depend on this
+/// crate), so a rename must land on both sides.
+pub const IDLE_DISPLAY_OFF_PATH: &str = "/run/cce/idle_display_off";
+pub const IDLE_SLEEP_PATH: &str = "/run/cce/idle_sleep";
+
+/// Record a session-wide switch where every session reads it: the
+/// animations file ([`cce_ui::motion::STATE_PATH`]) and the idle-timeout
+/// files. Not sysfs, but the same shape of lever: root writes it when the
+/// mode changes, and the compositor and every cce-ui client follow it
+/// without a restart. Under /run, not in anyone's config: this runs as root
+/// with no session and no `$HOME`, and a tmpfs file is rewritten at boot by
+/// the same coldplug run that applies the rest of the mode, so it can never
+/// outlive the plan that set it.
+fn write_run_state(path: &Path, value: &str) -> Result<(), String> {
     use std::os::unix::fs::PermissionsExt;
-    let path = Path::new(cce_ui::motion::STATE_PATH);
-    let dir = path.parent().expect("STATE_PATH has a parent");
+    let dir = path.parent().expect("run-state path has a parent");
     std::fs::create_dir_all(dir).map_err(|e| format!("{}: {}", dir.display(), e))?;
     // Temp + rename so a reader never sees a torn value; world-readable
     // because every session's processes read it.
@@ -799,6 +838,10 @@ mod tests {
         assert!(Lever::AudioIdleSecs.value_ok("10"));
         assert!(!Lever::AudioIdleSecs.value_ok("-1"));
         assert!(!Lever::AudioIdleSecs.value_ok("ten"));
+        assert!(Lever::IdleDisplayOffSecs.value_ok("0"));
+        assert!(Lever::IdleSleepSecs.value_ok("1800"));
+        assert!(!Lever::IdleSleepSecs.value_ok("30m"));
+        assert!(Lever::IdleDisplayOffSecs.is_numeric());
     }
 
     #[test]