system settings
git clone https://git.lucas.co/cce-system-interface.git
Lower PCIe ASPM before every sleep
A suspend begun on the charger slept under the AC mode, because user
space is frozen and an unplug while asleep reaches no udev rule. Its
`aspm "performance"` keeps the PCIe links out of L1 substates and the
package out of S0ix, and this machine has only s2idle: sleeps begun on
AC drew up to 4 W against about 1 W for those begun on battery, and on
2026-10-03 a full battery was empty within 34 hours of closing the lid.
`cce-power-apply sleep` drops ASPM to powersupersave when the running
mode sets anything else, run by the new cce-power-apply-sleep.service
(before sleep.target, X-CceEnable). The resume unit's `apply` restores
the mode's value; a mode that sets no ASPM is left alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cce-power-apply-sleep.service | 26 ++++++++++++++++++++++
src/bin/cce-power-apply.rs | 50 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 76 insertions(+)
diff --git a/cce-power-apply-sleep.service b/cce-power-apply-sleep.service
new file mode 100644
index 0000000..c84a57c
--- /dev/null
+++ b/cce-power-apply-sleep.service
@@ -0,0 +1,26 @@
+[Unit]
+Description=Lower PCIe power management to its deepest before sleeping
+Documentation=file:/etc/cce/power.kdl
+# The mode a suspend begins under stays in force until the machine wakes:
+# user space is frozen, so an unplug while asleep reaches no udev rule and
+# no cce-power-apply run. A suspend begun on AC therefore slept under the
+# AC mode's `aspm "performance"`, which keeps the PCIe links out of L1
+# substates and the package out of S0ix -- this machine has only s2idle --
+# and on 2026-10-03 a full battery was empty within 34 hours of closing the
+# lid. `cce-power-apply sleep` drops ASPM to powersupersave first;
+# cce-power-apply-resume.service puts the running mode's value back.
+#
+# Wanted by and ordered before sleep.target, which every sleep verb pulls in
+# ahead of its systemd-*.service, so the oneshot finishes before user space
+# is frozen. Like the resume unit it does nothing until enabled, which
+# X-CceEnable tells `ccebuild install-system` to do (systemd ignores X- keys).
+Before=sleep.target
+ConditionPathExists=/etc/cce/power.kdl
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/cce-power-apply sleep
+
+[Install]
+WantedBy=sleep.target
+X-CceEnable=yes
diff --git a/src/bin/cce-power-apply.rs b/src/bin/cce-power-apply.rs
index 080c3c2..752562e 100644
--- a/src/bin/cce-power-apply.rs
+++ b/src/bin/cce-power-apply.rs
@@ -15,6 +15,10 @@
//! again when it finishes and re-applies if it moved. Per-lever failures
//! are logged and do not fail the run: a missing NVIDIA driver must not
//! hide the CPU profile that did land.
+//! - `sleep` — lower PCIe ASPM to `powersupersave` before the machine
+//! sleeps, when the running mode sets it to anything else. Run by
+//! `cce-power-apply-sleep.service`; the resume unit's `apply` puts the
+//! mode's own value back.
//! - `apply-mode <mode>` — apply one mode by name, whatever is plugged in.
//! - `set <mode> <lever> <value|unset>` — record one lever on a mode and,
//! when that mode is the one running, apply it now. Run by the Power page
@@ -35,6 +39,7 @@ use cce_settings::power_plan::{
fn usage() -> ! {
eprintln!(
"usage: cce-power-apply apply [ac|battery]\n \
+ cce-power-apply sleep\n \
cce-power-apply apply-mode <mode>\n \
cce-power-apply set <mode> <lever> <value|unset>\n \
cce-power-apply assign <ac|battery> <mode>\n \
@@ -115,6 +120,50 @@ fn cmd_apply(forced: Option<&str>) -> i32 {
}
}
+/// The ASPM policy every sleep starts under, whatever mode is running.
+const SLEEP_ASPM: &str = "powersupersave";
+
+/// Ready the machine to sleep. This laptop has only s2idle, where the
+/// package reaches S0ix only if its PCIe links can drop into L1 substates,
+/// and the AC mode's `aspm "performance"` forbids that. Nothing re-applies
+/// the plan while the machine sleeps, so a suspend begun on the charger kept
+/// that mode through an unplug: the battery history before 2026-10-04 shows
+/// sleeps begun on battery drawing about 1 W, and those begun on AC up to
+/// 4 W, enough to empty a full battery in a day and a half closed.
+///
+/// Only ASPM moves. The rest of a mode either has no effect in s2idle or,
+/// like the NVIDIA power limit, costs seconds of nvidia-smi on every lid
+/// close. And only when the running mode sets it: the resume unit's `apply`
+/// then restores that mode's value, so a mode that leaves ASPM alone is not
+/// woken into a policy it never chose.
+fn cmd_sleep() -> i32 {
+ let plan = match load_plan() {
+ Ok(p) => p,
+ Err(code) => return code,
+ };
+ let source = current_source();
+ let mode = plan.assigned(source);
+ match plan.get(mode, Lever::Aspm) {
+ None => {
+ println!("cce-power-apply: sleep: {} [{}] sets no aspm; left as is", source.key(), mode.key());
+ 0
+ }
+ Some(v) if v == SLEEP_ASPM => 0,
+ Some(v) => match apply_lever(Lever::Aspm, SLEEP_ASPM) {
+ Ok(()) => {
+ println!("cce-power-apply: sleep: aspm {} -> {} ({} [{}] restores it on wake)", v, SLEEP_ASPM, source.key(), mode.key());
+ 0
+ }
+ Err(e) => {
+ // Never fail the unit: a sleep that drains faster beats one
+ // systemd refuses to start.
+ eprintln!("cce-power-apply: sleep: aspm: {}", e);
+ 0
+ }
+ },
+ }
+}
+
fn cmd_apply_mode(rest: &[String]) -> i32 {
let [mode] = rest else { usage() };
let mode = Mode::parse(mode).unwrap_or_else(|| usage());
@@ -192,6 +241,7 @@ fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let code = match args.first().map(String::as_str) {
Some("apply") => cmd_apply(args.get(1).map(String::as_str)),
+ Some("sleep") => cmd_sleep(),
Some("apply-mode") => cmd_apply_mode(&args[1..]),
Some("set") => cmd_set(&args[1..]),
Some("assign") => cmd_assign(&args[1..]),