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

commit9cfaf0196d8b9881cd22887ec26e7ecade87ff56
parent907f207352
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 08:30
Keep the battery charge limit in the power plan

The Power page's charge limit was one sysfs write, recorded nowhere. The
firmware does not keep it either: after the battery ran flat on
2026-10-03 the thresholds read 0/100 and the pack charged to full.

The limit is now a plan-wide `charge_limit { start; end }` block in
/etc/cce/power.kdl, in no mode, since a charging policy that flipped on
every plug and unplug would defeat itself. `cce-power-apply
charge-limit <start|unset> <end|unset>` records and applies it, and
every `apply` (boot, plug and unplug, wake) re-applies it, writing the
end first when the window moves up because thinkpad_acpi refuses a
start above the end in force.

The page's presets are windows (stop at 80, resume below 75) so a held
pack is not topped up a percent at a time, and picks go through the
helper. A helper without `charge-limit` cannot parse such a plan, so
the usage probe now counts it stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/bin/cce-power-apply.rs |  55 +++++++++-
 src/pages/power.rs         | 169 +++++++++++++++++++++----------
 src/power_plan.rs          | 244 +++++++++++++++++++++++++++++++++++++++++++--
 3 files changed, 409 insertions(+), 59 deletions(-)

diff --git a/src/bin/cce-power-apply.rs b/src/bin/cce-power-apply.rs
index 752562e..ab83b78 100644
--- a/src/bin/cce-power-apply.rs
+++ b/src/bin/cce-power-apply.rs
@@ -14,7 +14,8 @@
 //!   `cce-power-apply-resume.service` after every wake. It reads the source
 //!   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.
+//!   hide the CPU profile that did land. The plan's battery charge limit,
+//!   which belongs to no mode, is re-applied by every run.
 //! - `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
@@ -25,6 +26,8 @@
 //!   under pkexec, the app's standard privileged path.
 //! - `assign <ac|battery> <mode>` — point an adapter state at a mode and,
 //!   when that state is the live one, apply the mode now.
+//! - `charge-limit <start|unset> <end|unset>` — record the battery's charge
+//!   window (whole percent) and apply it now, whatever is plugged in.
 //! - `show` — print the plan and the live adapter state.
 //!
 //! Installed to `/usr/bin` by `ccebuild install-system` (the udev rule and
@@ -33,7 +36,8 @@
 //! side is installed.
 
 use cce_settings::power_plan::{
-    apply_lever, apply_mode, current_source, Lever, Mode, PowerPlan, Source, PLAN_PATH,
+    apply_charge_limit, apply_lever, apply_mode, current_source, ChargeLimit, Lever, Mode, PowerPlan, Source,
+    PLAN_PATH,
 };
 
 fn usage() -> ! {
@@ -43,6 +47,7 @@ fn usage() -> ! {
                 cce-power-apply apply-mode <mode>\n       \
                 cce-power-apply set <mode> <lever> <value|unset>\n       \
                 cce-power-apply assign <ac|battery> <mode>\n       \
+                cce-power-apply charge-limit <start|unset> <end|unset>\n       \
                 cce-power-apply show\n\
          modes:  {}\n\
          levers: {}",
@@ -76,6 +81,25 @@ fn run_mode(plan: &PowerPlan, mode: Mode, what: &str) -> i32 {
     0
 }
 
+/// Apply the plan's charge window and report it. Nothing to say when the
+/// plan has none: the battery's thresholds are then not this binary's.
+fn run_charge_limit(plan: &PowerPlan) -> i32 {
+    let limit = plan.charge_limit();
+    if limit.is_unset() {
+        return 0;
+    }
+    match apply_charge_limit(limit) {
+        Ok(()) => {
+            println!("charge limit: {}", limit);
+            0
+        }
+        Err(e) => {
+            eprintln!("cce-power-apply: charge limit ({}): {}", limit, e);
+            1
+        }
+    }
+}
+
 /// How many times one `apply` follows the source changing under it before
 /// it gives up and leaves the next udev event to finish the job. A charger
 /// with a bad contact can flap for as long as it likes.
@@ -86,6 +110,11 @@ fn cmd_apply(forced: Option<&str>) -> i32 {
         Ok(p) => p,
         Err(code) => return code,
     };
+    // Every run, not only when the page sets it: the firmware forgets the
+    // thresholds (a pack that runs flat comes back at 0/100), and this run
+    // is what boot, every plug and unplug, and every wake already start.
+    // Its failure is logged and, like a lever's, does not fail the run.
+    run_charge_limit(&plan);
     if let Some(s) = forced {
         let source = Source::parse(s).unwrap_or_else(|| usage());
         return run_mode(&plan, plan.assigned(source), source.key());
@@ -225,6 +254,27 @@ fn cmd_assign(rest: &[String]) -> i32 {
     0
 }
 
+fn cmd_charge_limit(rest: &[String]) -> i32 {
+    let [start, end] = rest else { usage() };
+    let pct = |s: &str| -> Option<u32> {
+        if s == "unset" { None } else { Some(s.parse().unwrap_or_else(|_| usage())) }
+    };
+    let limit = ChargeLimit { start: pct(start), end: pct(end) };
+    let mut plan = match load_plan() {
+        Ok(p) => p,
+        Err(code) => return code,
+    };
+    if let Err(e) = plan.set_charge_limit(limit) {
+        eprintln!("cce-power-apply: {}", e);
+        return 2;
+    }
+    if let Err(e) = plan.save() {
+        eprintln!("cce-power-apply: writing {}: {}", PLAN_PATH, e);
+        return 1;
+    }
+    run_charge_limit(&plan)
+}
+
 fn cmd_show() -> i32 {
     match load_plan() {
         Ok(plan) => {
@@ -245,6 +295,7 @@ fn main() {
         Some("apply-mode") => cmd_apply_mode(&args[1..]),
         Some("set") => cmd_set(&args[1..]),
         Some("assign") => cmd_assign(&args[1..]),
+        Some("charge-limit") => cmd_charge_limit(&args[1..]),
         Some("show") => cmd_show(),
         _ => usage(),
     };
diff --git a/src/pages/power.rs b/src/pages/power.rs
index 0e93a6f..fe4df2e 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -6,8 +6,8 @@
 //! exposes (missing ones render as absent, not as dead widgets):
 //! - `/sys/firmware/acpi/platform_profile` — firmware power profile
 //! - `/sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference`
-//! - `/sys/class/power_supply/BAT*/charge_control_end_threshold` — capping
-//!   charge at 80% is the classic battery-longevity lever
+//! - `/sys/class/power_supply/BAT*/charge_control_{start,end}_threshold` —
+//!   capping charge at 80% is the classic battery-longevity lever
 //! - `/sys/devices/system/cpu/intel_pstate/no_turbo` — turbo boost
 //!
 //! Three levers are not hardware interfaces: **Animations**, which the
@@ -22,6 +22,10 @@
 //!
 //! 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.
+//! Each choice is a window — stop at 80%, resume below 75% — kept in the plan
+//! and re-applied by the helper on every run, because the firmware forgets
+//! it (until 2026-10-06 a pick here was one sysfs write, gone the first
+//! time the battery ran flat).
 //! **Power Mode** edits one mode's levers, chosen by the dropdown at the top
 //! of the section — one section rather than one per mode, so the levers sit
 //! in the same place whichever mode is being edited. **Mode Assignment**
@@ -39,7 +43,7 @@
 //! honest, with no extra error channel.
 
 use crate::app::{AppAction, PageContent};
-use crate::power_plan::{self, Automation, Lever, Mode, PowerPlan, Source};
+use crate::power_plan::{self, Automation, ChargeLimit, Lever, Mode, PowerPlan, Source};
 use cce_ui::layout::{LayoutStrategy, PageLayoutBuilder};
 use cce_ui::widget::{Adapted, Dropdown, WidgetHost};
 use std::path::{Path, PathBuf};
@@ -82,6 +86,8 @@ pub struct PowerFacts {
     pub model: String,
     /// charge_control_end_threshold, when the battery has one.
     pub charge_limit: Option<u32>,
+    /// charge_control_start_threshold, when the battery has one too.
+    pub charge_start: Option<u32>,
     /// intel_pstate no_turbo, inverted to "turbo enabled".
     pub turbo: Option<bool>,
     /// scaling_available_governors, and cpu0's active one. Moved here from the
@@ -152,8 +158,9 @@ pub struct PowerState {
     pub loaded: bool,
     pub facts: PowerFacts,
     pub dd_limit: Adapted<Dropdown>,
-    /// Sysfs value per charge-limit dropdown row (options are display text).
-    pub limit_values: Vec<u32>,
+    /// The charge window per charge-limit dropdown row (options are display
+    /// text).
+    pub limit_values: Vec<ChargeLimit>,
     /// Which mode the lever section is editing. Page state, not plan state:
     /// it says what is on screen, never what the machine runs.
     pub editing: Mode,
@@ -212,15 +219,6 @@ pub enum PowerMessage {
     Assign { source: Source, idx: usize },
 }
 
-/// Root action via pkexec, the app's standard privileged path. Detached: the
-/// polkit prompt runs in its own process, the UI never blocks, and the
-/// watcher's next read reports what actually happened.
-fn run_privileged(cmd: String) {
-    let _ = std::process::Command::new("pkexec")
-        .args(["sh", "-c", &cmd])
-        .spawn();
-}
-
 /// The helper that records and applies the plan: the system copy when
 /// `ccebuild install-system` has put a current one there, else the one
 /// installed beside this binary (`~/.local/bin`) — which pkexec will still
@@ -339,6 +337,7 @@ pub async fn fetch_power_state() -> PowerFacts {
         f.vendor = b("manufacturer").unwrap_or_default();
         f.model = b("model_name").unwrap_or_default();
         f.charge_limit = b("charge_control_end_threshold").and_then(|s| s.parse().ok());
+        f.charge_start = b("charge_control_start_threshold").and_then(|s| s.parse().ok());
     }
     f.ac_online = read_trim("/sys/class/power_supply/AC/online").map(|s| s == "1");
     f.source = power_plan::current_source();
@@ -663,29 +662,47 @@ fn source_index(source: Source) -> usize {
     Source::ALL.iter().position(|s| *s == source).unwrap()
 }
 
+/// The charge-limit rows: each preset is a window that stops at its end and
+/// resumes five points below it, so a pack held there is not topped up by a
+/// percent at a time. A battery with no start threshold gets the ends
+/// alone. The row shown is the plan's window, or — before anything has been
+/// planned — what the battery reports; a window that is neither preset gets
+/// its own row rather than silently matching the wrong one.
+fn charge_rows(f: &PowerFacts) -> (Vec<ChargeLimit>, Vec<String>, usize) {
+    let has_start = f.charge_start.is_some();
+    let window = |start: u32, end: u32| ChargeLimit { start: has_start.then_some(start), end: Some(end) };
+    let mut values = vec![window(0, 100), window(75, 80), window(55, 60)];
+    let mut options = vec![
+        "100% — full capacity".to_string(),
+        "80% — longevity".to_string(),
+        "60% — max longevity".to_string(),
+    ];
+    let planned = f.plan.charge_limit();
+    let current = if planned.is_unset() { ChargeLimit { start: f.charge_start, end: f.charge_limit } } else { planned };
+    let selected = match values.iter().position(|v| *v == current) {
+        Some(i) => i,
+        None => match current.end {
+            Some(end) => {
+                values.push(current);
+                options.push(match current.start {
+                    Some(start) if start > 0 => format!("{}% — current, from {}%", end, start),
+                    _ => format!("{}% — current", end),
+                });
+                values.len() - 1
+            }
+            None => 0,
+        },
+    };
+    (values, options, selected)
+}
+
 /// Rebuild every dropdown's options/selection from fresh facts.
 fn rebuild_options(state: &mut PowerState) {
     let f = &state.facts;
     if !state.dd_limit.open {
-        let mut values = vec![100u32, 80, 60];
-        if let Some(cur) = f.charge_limit {
-            if !values.contains(&cur) {
-                values.push(cur);
-            }
-        }
-        state.dd_limit.options = values
-            .iter()
-            .map(|v| match v {
-                100 => "100% — full capacity".to_string(),
-                80 => "80% — longevity".to_string(),
-                60 => "60% — max longevity".to_string(),
-                other => format!("{}% — current", other),
-            })
-            .collect();
-        state.dd_limit.selected = f
-            .charge_limit
-            .and_then(|cur| values.iter().position(|v| *v == cur))
-            .unwrap_or(0);
+        let (values, options, selected) = charge_rows(f);
+        state.dd_limit.options = options;
+        state.dd_limit.selected = selected;
         state.limit_values = values;
     }
     if !state.dd_mode.open {
@@ -885,15 +902,34 @@ pub fn update(state: &mut PowerState, msg: PowerMessage) {
             }
         }
         PowerMessage::SetLimit(idx) => {
-            if let Some(v) = state.limit_values.get(idx).copied() {
-                if (1..=100).contains(&v) {
-                    run_privileged(format!(
-                        "for f in /sys/class/power_supply/BAT*/charge_control_end_threshold; do echo {} > \"$f\"; done",
-                        v
-                    ));
-                    state.facts.charge_limit = Some(v);
-                }
+            let Some(limit) = state.limit_values.get(idx).copied() else {
+                return;
+            };
+            // The rows are built from presets and sysfs reads, but the check
+            // makes the argv safe by construction, as the lever guard does.
+            if limit.check().is_err() || limit.end.is_none() {
+                return;
+            }
+            let Some(helper) = helper_path() else {
+                log::error!("[power] cce-power-apply not found at {} or beside this binary", power_plan::HELPER_SYSTEM_PATH);
+                return;
+            };
+            let arg = |pct: Option<u32>| pct.map_or_else(|| "unset".to_string(), |p| p.to_string());
+            // Through the helper rather than a one-off sysfs write: it records
+            // the window in the plan, which every later run re-applies.
+            let _ = std::process::Command::new("pkexec")
+                .arg(&helper)
+                .arg("charge-limit")
+                .arg(arg(limit.start))
+                .arg(arg(limit.end))
+                .spawn();
+            // Optimistic mirror of what the helper will make true.
+            let _ = state.facts.plan.set_charge_limit(limit);
+            state.facts.charge_limit = limit.end;
+            if limit.start.is_some() {
+                state.facts.charge_start = limit.start;
             }
+            rebuild_options(state);
         }
         PowerMessage::EditMode(idx) => {
             // Page-local: switching which mode is on screen writes nothing
@@ -919,9 +955,11 @@ pub fn update(state: &mut PowerState, msg: PowerMessage) {
                 log::error!("[power] cce-power-apply not found at {} or beside this binary", power_plan::HELPER_SYSTEM_PATH);
                 return;
             };
-            // Detached, like run_privileged: the helper records the pick and,
-            // when this mode is the running one, applies it; the watcher's
-            // next read reports what actually happened.
+            // Under pkexec, the app's standard privileged path, and detached:
+            // the polkit prompt runs in its own process and the UI never
+            // blocks. The helper records the pick and, when this mode is the
+            // running one, applies it; the watcher's next read reports what
+            // actually happened.
             let _ = std::process::Command::new("pkexec")
                 .arg(&helper)
                 .arg("set")
@@ -1068,6 +1106,7 @@ mod tests {
             epps: vec!["default".into(), "performance".into(), "balance_power".into(), "power".into()],
             epp: "balance_power".to_string(),
             charge_limit: Some(80),
+            charge_start: Some(75),
             turbo: Some(true),
             governors: vec!["performance".into(), "powersave".into()],
             governor: "powersave".to_string(),
@@ -1187,16 +1226,44 @@ mod tests {
     }
 
     #[test]
-    fn charge_limit_rows_map_current_and_off_list_values() {
+    fn charge_limit_rows_are_windows_and_map_current_and_off_list_values() {
+        let w = |start, end| ChargeLimit { start: Some(start), end: Some(end) };
         let mut st = loaded();
-        assert_eq!(st.dd_limit.selected, 1); // 80
-        assert_eq!(st.limit_values, [100, 80, 60]);
-        // An off-list threshold gets its own row instead of a wrong match.
-        st.facts.charge_limit = Some(75);
+        // Nothing planned: the battery's own 75/80 is the longevity preset.
+        assert_eq!(st.limit_values, [w(0, 100), w(75, 80), w(55, 60)]);
+        assert_eq!(st.dd_limit.selected, 1);
+        // An off-list window gets its own row instead of a wrong match.
+        st.facts.charge_limit = Some(70);
+        st.facts.charge_start = Some(65);
         rebuild_options(&mut st);
-        assert_eq!(st.limit_values, [100, 80, 60, 75]);
+        assert_eq!(st.limit_values[3], w(65, 70));
         assert_eq!(st.dd_limit.selected, 3);
-        assert!(st.dd_limit.options[3].contains("75%"));
+        assert_eq!(st.dd_limit.options[3], "70% — current, from 65%");
+        // Once planned, the plan is what the row shows — the firmware
+        // having forgotten it (0/100) is what the helper puts right.
+        st.facts.plan.set_charge_limit(w(55, 60)).unwrap();
+        st.facts.charge_limit = Some(100);
+        st.facts.charge_start = Some(0);
+        rebuild_options(&mut st);
+        assert_eq!(st.dd_limit.selected, 2);
+        // A battery with no start threshold is offered the ends alone.
+        st.facts.plan = PowerPlan::default();
+        st.facts.charge_start = None;
+        st.facts.charge_limit = Some(80);
+        rebuild_options(&mut st);
+        assert_eq!(st.limit_values[1], ChargeLimit { start: None, end: Some(80) });
+        assert_eq!(st.dd_limit.selected, 1);
+    }
+
+    #[test]
+    fn a_charge_limit_pick_is_recorded_in_the_plan() {
+        let mut st = loaded();
+        update(&mut st, PowerMessage::SetLimit(0));
+        assert_eq!(st.facts.plan.charge_limit(), ChargeLimit { start: Some(0), end: Some(100) });
+        assert_eq!((st.facts.charge_start, st.facts.charge_limit), (Some(0), Some(100)));
+        assert_eq!(st.dd_limit.selected, 0);
+        // No mode gained a lever from it.
+        assert!(Mode::ALL.iter().all(|m| st.facts.plan.levers(*m).all(|(l, _)| l == Lever::Profile)));
     }
 
     #[test]
diff --git a/src/power_plan.rs b/src/power_plan.rs
index 232e7a5..721de6c 100644
--- a/src/power_plan.rs
+++ b/src/power_plan.rs
@@ -38,6 +38,14 @@
 //! compositor's `idle { }` block while they exist, so battery can darken
 //! the display sooner than the desk does.
 //!
+//! The battery **charge limit** is in the plan too, but belongs to no mode
+//! (`charge_limit { start 75; end 80 }`, [`ChargeLimit`]): it is a charging
+//! policy, and one that changed on every plug and unplug would defeat it.
+//! The applier writes it on every `apply` — at boot, on each adapter change
+//! and after every wake — because the firmware does not keep it: until
+//! 2026-10-06 the Power page wrote sysfs once and recorded nothing, and a
+//! battery that ran flat came back charging to 100%.
+//!
 //! 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
@@ -136,7 +144,7 @@ impl Mode {
 
 /// The levers that make sense per mode. The battery charge limit is
 /// deliberately not one: it is a charging policy, not something to flip when
-/// the mode changes.
+/// the mode changes, so it is plan-wide ([`ChargeLimit`]).
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
 pub enum Lever {
     Profile,
@@ -236,11 +244,58 @@ pub fn sysfs_token_ok(s: &str) -> bool {
     !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
 }
 
-/// The levers of every mode, plus which mode each adapter state runs.
+/// The battery's charge window, in whole percent: charging stops at `end`
+/// and, once stopped, resumes only below `start` — so a pack held at 80%
+/// is not topped up from 79% every few minutes. Either half absent means
+/// "leave that threshold alone", like an absent lever.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct ChargeLimit {
+    pub start: Option<u32>,
+    pub end: Option<u32>,
+}
+
+impl ChargeLimit {
+    pub fn is_unset(&self) -> bool {
+        self.start.is_none() && self.end.is_none()
+    }
+
+    /// The kernel's ranges (start 0–99, end 1–100), and a start below the
+    /// end, since a window that resumes at or above where it stops is not
+    /// one the firmware will take.
+    pub fn check(&self) -> Result<(), String> {
+        if self.start.is_some_and(|s| s > 99) {
+            return Err("charge_limit start must be 0–99".to_string());
+        }
+        if self.end.is_some_and(|e| e == 0 || e > 100) {
+            return Err("charge_limit end must be 1–100".to_string());
+        }
+        if let (Some(s), Some(e)) = (self.start, self.end) {
+            if s >= e {
+                return Err(format!("charge_limit start {} must be below end {}", s, e));
+            }
+        }
+        Ok(())
+    }
+}
+
+impl std::fmt::Display for ChargeLimit {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match (self.start, self.end) {
+            (Some(s), Some(e)) => write!(f, "start {}%, end {}%", s, e),
+            (Some(s), None) => write!(f, "start {}%", s),
+            (None, Some(e)) => write!(f, "end {}%", e),
+            (None, None) => write!(f, "not set"),
+        }
+    }
+}
+
+/// The levers of every mode, which mode each adapter state runs, and the
+/// plan-wide battery charge limit.
 #[derive(Debug, Clone, Default, PartialEq, Eq)]
 pub struct PowerPlan {
     modes: BTreeMap<Mode, BTreeMap<Lever, String>>,
     assign: BTreeMap<Source, Mode>,
+    charge: ChargeLimit,
 }
 
 impl PowerPlan {
@@ -285,6 +340,18 @@ impl PowerPlan {
         self.assign.insert(source, mode);
     }
 
+    pub fn charge_limit(&self) -> ChargeLimit {
+        self.charge
+    }
+
+    /// Record the charge window. Rejects one that fails
+    /// [`ChargeLimit::check`] rather than storing it.
+    pub fn set_charge_limit(&mut self, limit: ChargeLimit) -> Result<(), String> {
+        limit.check()?;
+        self.charge = limit;
+        Ok(())
+    }
+
     /// No lever set on any mode. The assignment alone is not content: it
     /// changes nothing until some mode has a lever in it.
     pub fn is_empty(&self) -> bool {
@@ -320,6 +387,25 @@ impl PowerPlan {
                         plan.assign(source, mode);
                     }
                 }
+                "charge_limit" => {
+                    let mut limit = ChargeLimit::default();
+                    if let Some(children) = node.children() {
+                        for child in children.nodes() {
+                            let cname = child.name().value();
+                            let pct = child
+                                .get(0)
+                                .and_then(|e| e.value().as_i64())
+                                .and_then(|n| u32::try_from(n).ok())
+                                .ok_or_else(|| format!("charge_limit.{} needs one whole percent", cname))?;
+                            match cname {
+                                "start" => limit.start = Some(pct),
+                                "end" => limit.end = Some(pct),
+                                _ => return Err(format!("unknown setting {:?} under charge_limit", cname)),
+                            }
+                        }
+                    }
+                    plan.set_charge_limit(limit)?;
+                }
                 // The pre-modes file: a bare block of levers per adapter
                 // state. Each becomes that state's default mode, which is
                 // what it was already doing.
@@ -387,12 +473,25 @@ impl PowerPlan {
             children.nodes_mut().push(node);
         }
         doc.nodes_mut().push(assign);
+        if !self.charge.is_unset() {
+            let mut block = kdl::KdlNode::new("charge_limit");
+            let children = block.ensure_children();
+            for (name, pct) in [("start", self.charge.start), ("end", self.charge.end)] {
+                if let Some(pct) = pct {
+                    let mut node = kdl::KdlNode::new(name);
+                    node.push(kdl::KdlEntry::new(i64::from(pct)));
+                    children.nodes_mut().push(node);
+                }
+            }
+            doc.nodes_mut().push(block);
+        }
         doc.fmt();
         let mut out = String::from(
             "// Power modes and their adapter-state assignment, edited from the\n\
              // System Interface's Power page and applied by cce-power-apply (udev,\n\
              // boot, and on each change). A lever missing from a mode is left\n\
-             // untouched when that mode becomes active.\n",
+             // untouched when that mode becomes active. The charge limit belongs\n\
+             // to no mode and is re-applied on every change.\n",
         );
         out.push_str(&doc.to_string());
         out
@@ -513,14 +612,18 @@ pub fn helper_speaks_modes(path: &Path) -> bool {
 
 /// The usage text of a helper that knows about modes names `apply-mode`
 /// (the pre-modes one lists only `apply`, `set` and `show`), and its
-/// `levers:` line names every lever it accepts.
+/// `levers:` line names every lever it accepts. It must also name
+/// `charge-limit`: a helper from before the charge limit cannot parse a
+/// plan holding one, so it would apply nothing on plug or unplug.
 fn usage_speaks_modes(usage: &str) -> bool {
     let levers: Vec<&str> = usage
         .lines()
         .find_map(|l| l.trim().strip_prefix("levers:"))
         .map(|l| l.split_whitespace().collect())
         .unwrap_or_default();
-    usage.contains("apply-mode") && Lever::ALL.iter().all(|l| levers.contains(&l.key()))
+    usage.contains("apply-mode")
+        && usage.contains("charge-limit")
+        && Lever::ALL.iter().all(|l| levers.contains(&l.key()))
 }
 
 /// Whether the root-side pieces are in place — the helper at its system
@@ -655,6 +758,78 @@ pub fn apply_lever(lever: Lever, value: &str) -> Result<(), String> {
     }
 }
 
+/// Every battery with a charge-limit knob, `/sys/class/power_supply/BAT*`,
+/// sorted.
+fn charge_batteries() -> Vec<PathBuf> {
+    let mut v: Vec<PathBuf> = std::fs::read_dir("/sys/class/power_supply")
+        .map(|rd| {
+            rd.flatten()
+                .map(|e| e.path())
+                .filter(|p| {
+                    p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("BAT"))
+                        && p.join("charge_control_end_threshold").exists()
+                })
+                .collect()
+        })
+        .unwrap_or_default();
+    v.sort();
+    v
+}
+
+/// Whether the start threshold goes in before the end. The firmware
+/// (thinkpad_acpi) refuses a start above the end in force and an end below
+/// the start in force, so a window moving up must raise its end first and
+/// one moving down must lower its start first; `current_end` is what the
+/// battery reports now. Written start-then-end blindly, 75/80 → 85/90
+/// fails on the start.
+fn start_first(limit: ChargeLimit, current_end: Option<u32>) -> bool {
+    match (limit.start, current_end) {
+        (Some(s), Some(cur)) => s <= cur,
+        _ => true,
+    }
+}
+
+/// Write the charge window to every battery that has one. A threshold
+/// already at its value is not rewritten; a start the battery has no file
+/// for is reported after the end has landed, since the end is the half that
+/// protects the pack.
+pub fn apply_charge_limit(limit: ChargeLimit) -> Result<(), String> {
+    limit.check()?;
+    if limit.is_unset() {
+        return Ok(());
+    }
+    let batteries = charge_batteries();
+    if batteries.is_empty() {
+        return Err("no battery exposes charge_control_end_threshold".to_string());
+    }
+    const START: &str = "charge_control_start_threshold";
+    const END: &str = "charge_control_end_threshold";
+    let mut missing_start = Vec::new();
+    for bat in batteries {
+        let read = |file: &str| read_trim(&bat.join(file)).and_then(|s| s.parse::<u32>().ok());
+        let start = match limit.start {
+            Some(s) if bat.join(START).exists() => Some((START, s)),
+            Some(_) => {
+                missing_start.push(bat.display().to_string());
+                None
+            }
+            None => None,
+        };
+        let end = limit.end.map(|e| (END, e));
+        let order = if start_first(limit, read(END)) { [start, end] } else { [end, start] };
+        for (file, pct) in order.into_iter().flatten() {
+            if read(file) != Some(pct) {
+                write_sysfs(&bat.join(file), &pct.to_string())?;
+            }
+        }
+    }
+    if missing_start.is_empty() {
+        Ok(())
+    } else {
+        Err(format!("no {} on {}; only the end was applied", START, missing_start.join(", ")))
+    }
+}
+
 /// 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
@@ -875,7 +1050,8 @@ mod tests {
         // What this binary prints today.
         let levers = Lever::ALL.iter().map(|l| l.key()).collect::<Vec<_>>().join(" ");
         let current = format!(
-            "usage: cce-power-apply apply [ac|battery]\n       cce-power-apply apply-mode <mode>\n \
+            "usage: cce-power-apply apply [ac|battery]\n       cce-power-apply apply-mode <mode>\n       \
+             cce-power-apply charge-limit <start|unset> <end|unset>\n \
              modes:  performance balanced power-saver\n levers: {levers}\n"
         );
         assert!(usage_speaks_modes(&current));
@@ -883,6 +1059,10 @@ mod tests {
         // rejects that lever — as stale, for that pick, as a pre-modes one.
         let older = current.replace(" animations", "");
         assert!(!usage_speaks_modes(&older), "{older}");
+        // One from before the charge limit cannot even parse a plan that
+        // holds one.
+        let no_charge = current.replace("charge-limit", "");
+        assert!(!usage_speaks_modes(&no_charge), "{no_charge}");
         // What the pre-modes one printed — the copy that silently rejects
         // every pick the page makes.
         assert!(!usage_speaks_modes(
@@ -892,6 +1072,58 @@ mod tests {
         assert!(!helper_speaks_modes(Path::new("/nonexistent/cce-power-apply")));
     }
 
+    #[test]
+    fn the_charge_limit_is_plan_wide_and_round_trips() {
+        let text = "mode \"performance\" { aspm \"performance\"; }\n\
+                    charge_limit { start 75; end 80; }\n";
+        let plan = PowerPlan::parse(text).unwrap();
+        assert_eq!(plan.charge_limit(), ChargeLimit { start: Some(75), end: Some(80) });
+        // It is in no mode's levers, and survives a write and a re-read.
+        assert_eq!(levers_of(&plan, Mode::Performance), [(Lever::Aspm, "performance".to_string())]);
+        let again = PowerPlan::parse(&plan.to_kdl()).unwrap();
+        assert_eq!(again.charge_limit(), plan.charge_limit());
+        assert_eq!(levers_of(&again, Mode::Performance), levers_of(&plan, Mode::Performance));
+        // Half a window keeps only that half; none writes no block at all.
+        let end_only = PowerPlan::parse("charge_limit { end 60; }").unwrap();
+        assert_eq!(end_only.charge_limit(), ChargeLimit { start: None, end: Some(60) });
+        assert_eq!(PowerPlan::parse(&end_only.to_kdl()).unwrap().charge_limit(), end_only.charge_limit());
+        assert!(!PowerPlan::default().to_kdl().contains("charge_limit"));
+    }
+
+    #[test]
+    fn a_charge_window_must_be_one_the_firmware_takes() {
+        for bad in [
+            "charge_limit { start 80; end 80; }",
+            "charge_limit { start 90; end 80; }",
+            "charge_limit { end 0; }",
+            "charge_limit { end 101; }",
+            "charge_limit { start 100; }",
+            "charge_limit { start -1; }",
+            "charge_limit { end \"80\"; }",
+            "charge_limit { stop 80; }",
+        ] {
+            assert!(PowerPlan::parse(bad).is_err(), "{bad}");
+        }
+        let mut plan = PowerPlan::default();
+        assert!(plan.set_charge_limit(ChargeLimit { start: Some(85), end: Some(80) }).is_err());
+        assert!(plan.charge_limit().is_unset(), "a refused window is not stored");
+        plan.set_charge_limit(ChargeLimit { start: Some(0), end: Some(100) }).unwrap();
+    }
+
+    #[test]
+    fn a_charge_window_moving_up_writes_its_end_first() {
+        let window = |s, e| ChargeLimit { start: Some(s), end: Some(e) };
+        // 75/80 → 85/90: a start of 85 over an end of 80 is refused.
+        assert!(!start_first(window(85, 90), Some(80)));
+        // 75/80 → 55/60, and the factory 0/100 → 75/80: start first, or the
+        // new end would sit below the old start.
+        assert!(start_first(window(55, 60), Some(80)));
+        assert!(start_first(window(75, 80), Some(100)));
+        // An end the battery does not report, or no start to order.
+        assert!(start_first(window(75, 80), None));
+        assert!(start_first(ChargeLimit { start: None, end: Some(80) }, Some(60)));
+    }
+
     #[test]
     fn keys_round_trip() {
         for l in Lever::ALL {