system settings
git clone https://git.lucas.co/cce-system-interface.git
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(¤t));
@@ -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 {