system settings
git clone https://git.lucas.co/cce-system-interface.git
src/power_plan.rs (49.7K)
1 //! Power modes and the adapter states they are assigned to: a named set of
2 //! levers per mode, and a small table saying which mode runs when the
3 //! machine is plugged in and which when it runs on battery.
4 //!
5 //! Shared between the two sides of the feature, which is the point of the
6 //! module: the Power page edits the modes and the assignment, and
7 //! `cce-power-apply` (this crate's helper binary) applies them as root —
8 //! from udev when the Mains supply flips, at boot, and on demand when the
9 //! page changes a lever of the mode that is running right now. One parser,
10 //! one apply path, one value guard, so the two sides cannot drift.
11 //!
12 //! The plan lives at [`PLAN_PATH`], root-owned, because the applier runs as
13 //! root outside any session: it has no `$HOME` to look in, and a root daemon
14 //! taking its orders from a user-writable file would be a privilege boundary
15 //! drawn in the wrong place. Writes go through the helper under pkexec, the
16 //! same one-prompt path every lever change in this app already takes.
17 //!
18 //! ```kdl
19 //! mode "performance" {
20 //! profile "performance"
21 //! turbo "on"
22 //! }
23 //! mode "power-saver" {
24 //! profile "low-power"
25 //! igpu_max_mhz 800
26 //! }
27 //! assign {
28 //! ac "performance"
29 //! battery "power-saver"
30 //! }
31 //! ```
32 //!
33 //! Two levers are not sysfs: **animations** and the two **idle timeouts**
34 //! (`idle_display_off_secs`, `idle_sleep_secs`). The applier records them
35 //! as files under `/run/cce` ([`cce_ui::motion::STATE_PATH`],
36 //! [`IDLE_DISPLAY_OFF_PATH`], [`IDLE_SLEEP_PATH`]) that the compositor and
37 //! the toolkit follow without a reload; the idle files override the
38 //! compositor's `idle { }` block while they exist, so battery can darken
39 //! the display sooner than the desk does.
40 //!
41 //! The battery **charge limit** is in the plan too, but belongs to no mode
42 //! (`charge_limit { start 75; end 80 }`, [`ChargeLimit`]): it is a charging
43 //! policy, and one that changed on every plug and unplug would defeat it.
44 //! The applier writes it on every `apply` — at boot, on each adapter change
45 //! and after every wake — because the firmware does not keep it: until
46 //! 2026-10-06 the Power page wrote sysfs once and recorded nothing, and a
47 //! battery that ran flat came back charging to 100%.
48 //!
49 //! A lever absent from a mode is left alone when that mode becomes active —
50 //! "not set" means "don't touch", never "reset to a default". The older
51 //! per-source form of this file (top-level `ac` / `battery` blocks of
52 //! levers, before modes existed) still parses: each block becomes the mode
53 //! that source is assigned to by default, which is exactly the behavior it
54 //! had.
55
56 use std::collections::BTreeMap;
57 use std::path::{Path, PathBuf};
58
59 pub const PLAN_PATH: &str = "/etc/cce/power.kdl";
60 /// Where `ccebuild install-system` puts the helper. The udev rule and the
61 /// system unit both name this path, so its presence is what "automatic
62 /// switching is installed" means to the page.
63 pub const HELPER_SYSTEM_PATH: &str = "/usr/bin/cce-power-apply";
64 pub const UDEV_RULE_PATH: &str = "/etc/udev/rules.d/90-cce-power-apply.rules";
65
66 /// A power-adapter state. Defaults to `Ac`: a host with no Mains supply has
67 /// nothing to unplug.
68 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
69 pub enum Source {
70 #[default]
71 Ac,
72 Battery,
73 }
74
75 impl Source {
76 pub const ALL: [Source; 2] = [Source::Ac, Source::Battery];
77
78 /// The key in the `assign` block, and the CLI spelling.
79 pub fn key(self) -> &'static str {
80 match self {
81 Source::Ac => "ac",
82 Source::Battery => "battery",
83 }
84 }
85
86 pub fn parse(s: &str) -> Option<Source> {
87 Source::ALL.into_iter().find(|v| v.key() == s)
88 }
89
90 pub fn label(self) -> &'static str {
91 match self {
92 Source::Ac => "Plugged In",
93 Source::Battery => "On Battery",
94 }
95 }
96
97 /// The mode a source runs when the plan says nothing about it. These are
98 /// also what the pre-modes file format migrates onto, so an old plan
99 /// keeps behaving exactly as it did.
100 pub fn default_mode(self) -> Mode {
101 match self {
102 Source::Ac => Mode::Performance,
103 Source::Battery => Mode::PowerSaver,
104 }
105 }
106 }
107
108 /// A named set of lever values. The set is fixed rather than user-extensible:
109 /// the page picks a mode from a dropdown, and there is deliberately no
110 /// naming UI to keep a mode's identity stable across the plan file, the
111 /// helper's CLI and the assignment table.
112 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
113 pub enum Mode {
114 Performance,
115 #[default]
116 Balanced,
117 PowerSaver,
118 }
119
120 impl Mode {
121 pub const ALL: [Mode; 3] = [Mode::Performance, Mode::Balanced, Mode::PowerSaver];
122
123 /// The name in the plan file, and the CLI spelling.
124 pub fn key(self) -> &'static str {
125 match self {
126 Mode::Performance => "performance",
127 Mode::Balanced => "balanced",
128 Mode::PowerSaver => "power-saver",
129 }
130 }
131
132 pub fn parse(s: &str) -> Option<Mode> {
133 Mode::ALL.into_iter().find(|m| m.key() == s)
134 }
135
136 pub fn label(self) -> &'static str {
137 match self {
138 Mode::Performance => "Performance",
139 Mode::Balanced => "Balanced",
140 Mode::PowerSaver => "Power Saver",
141 }
142 }
143 }
144
145 /// The levers that make sense per mode. The battery charge limit is
146 /// deliberately not one: it is a charging policy, not something to flip when
147 /// the mode changes, so it is plan-wide ([`ChargeLimit`]).
148 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
149 pub enum Lever {
150 Profile,
151 Epp,
152 Governor,
153 Turbo,
154 IgpuMaxMhz,
155 Aspm,
156 AudioIdleSecs,
157 GpuLimitW,
158 Animations,
159 IdleDisplayOffSecs,
160 IdleSleepSecs,
161 }
162 impl Lever {
163 pub const ALL: [Lever; 11] = [
164 Lever::Profile,
165 Lever::Epp,
166 Lever::Governor,
167 Lever::Turbo,
168 Lever::IgpuMaxMhz,
169 Lever::Aspm,
170 Lever::AudioIdleSecs,
171 Lever::GpuLimitW,
172 Lever::Animations,
173 Lever::IdleDisplayOffSecs,
174 Lever::IdleSleepSecs,
175 ];
176
177 /// The node name in the plan file, and the CLI spelling.
178 pub fn key(self) -> &'static str {
179 match self {
180 Lever::Profile => "profile",
181 Lever::Epp => "epp",
182 Lever::Governor => "governor",
183 Lever::Turbo => "turbo",
184 Lever::IgpuMaxMhz => "igpu_max_mhz",
185 Lever::Aspm => "aspm",
186 Lever::AudioIdleSecs => "audio_idle_secs",
187 Lever::GpuLimitW => "gpu_limit_w",
188 Lever::Animations => "animations",
189 Lever::IdleDisplayOffSecs => "idle_display_off_secs",
190 Lever::IdleSleepSecs => "idle_sleep_secs",
191 }
192 }
193
194 pub fn parse(s: &str) -> Option<Lever> {
195 Lever::ALL.into_iter().find(|l| l.key() == s)
196 }
197
198 pub fn label(self) -> &'static str {
199 match self {
200 Lever::Profile => "Power Profile",
201 Lever::Epp => "CPU Energy Preference",
202 Lever::Governor => "CPU Governor",
203 Lever::Turbo => "CPU Turbo Boost",
204 Lever::IgpuMaxMhz => "Integrated GPU Max Clock",
205 Lever::Aspm => "PCIe Power Management",
206 Lever::AudioIdleSecs => "Audio Codec Idle",
207 Lever::GpuLimitW => "GPU Power Limit",
208 Lever::Animations => "Animations",
209 Lever::IdleDisplayOffSecs => "Display Off After",
210 Lever::IdleSleepSecs => "Sleep After",
211 }
212 }
213
214 /// Numeric levers are stored as KDL integers; the rest as strings.
215 pub fn is_numeric(self) -> bool {
216 matches!(
217 self,
218 Lever::IgpuMaxMhz
219 | Lever::AudioIdleSecs
220 | Lever::GpuLimitW
221 | Lever::IdleDisplayOffSecs
222 | Lever::IdleSleepSecs
223 )
224 }
225
226 /// Shape check on a value before it goes anywhere near sysfs or a shell
227 /// line: a sysfs token, an unsigned integer, or on/off for turbo and
228 /// animations. This is
229 /// the guard `set` and `apply` share; range checks against the hardware
230 /// happen in [`apply_lever`], where the ranges can be read.
231 pub fn value_ok(self, v: &str) -> bool {
232 match self {
233 Lever::Turbo | Lever::Animations => v == "on" || v == "off",
234 l if l.is_numeric() => v.parse::<u32>().is_ok(),
235 _ => sysfs_token_ok(v),
236 }
237 }
238 }
239
240 /// Sysfs tokens travel into sysfs writes and (from the page) a `pkexec`
241 /// argv, so only the shapes sysfs itself produces are allowed through —
242 /// anything else is dropped, not escaped.
243 pub fn sysfs_token_ok(s: &str) -> bool {
244 !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
245 }
246
247 /// The battery's charge window, in whole percent: charging stops at `end`
248 /// and, once stopped, resumes only below `start` — so a pack held at 80%
249 /// is not topped up from 79% every few minutes. Either half absent means
250 /// "leave that threshold alone", like an absent lever.
251 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
252 pub struct ChargeLimit {
253 pub start: Option<u32>,
254 pub end: Option<u32>,
255 }
256
257 impl ChargeLimit {
258 pub fn is_unset(&self) -> bool {
259 self.start.is_none() && self.end.is_none()
260 }
261
262 /// The kernel's ranges (start 0–99, end 1–100), and a start below the
263 /// end, since a window that resumes at or above where it stops is not
264 /// one the firmware will take.
265 pub fn check(&self) -> Result<(), String> {
266 if self.start.is_some_and(|s| s > 99) {
267 return Err("charge_limit start must be 0–99".to_string());
268 }
269 if self.end.is_some_and(|e| e == 0 || e > 100) {
270 return Err("charge_limit end must be 1–100".to_string());
271 }
272 if let (Some(s), Some(e)) = (self.start, self.end) {
273 if s >= e {
274 return Err(format!("charge_limit start {} must be below end {}", s, e));
275 }
276 }
277 Ok(())
278 }
279 }
280
281 impl std::fmt::Display for ChargeLimit {
282 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283 match (self.start, self.end) {
284 (Some(s), Some(e)) => write!(f, "start {}%, end {}%", s, e),
285 (Some(s), None) => write!(f, "start {}%", s),
286 (None, Some(e)) => write!(f, "end {}%", e),
287 (None, None) => write!(f, "not set"),
288 }
289 }
290 }
291
292 /// The levers of every mode, which mode each adapter state runs, and the
293 /// plan-wide battery charge limit.
294 #[derive(Debug, Clone, Default, PartialEq, Eq)]
295 pub struct PowerPlan {
296 modes: BTreeMap<Mode, BTreeMap<Lever, String>>,
297 assign: BTreeMap<Source, Mode>,
298 charge: ChargeLimit,
299 }
300
301 impl PowerPlan {
302 /// One mode's levers. Absent and empty are the same thing to every
303 /// caller, so a mode nothing has been set on reads as an empty set.
304 pub fn levers(&self, mode: Mode) -> impl Iterator<Item = (Lever, &str)> {
305 self.modes
306 .get(&mode)
307 .into_iter()
308 .flat_map(|m| m.iter().map(|(l, v)| (*l, v.as_str())))
309 }
310
311 pub fn get(&self, mode: Mode, lever: Lever) -> Option<&str> {
312 self.modes.get(&mode)?.get(&lever).map(String::as_str)
313 }
314
315 /// Record a value on a mode, or clear it with `None`. Rejects a
316 /// malformed value rather than storing it.
317 pub fn put(&mut self, mode: Mode, lever: Lever, value: Option<&str>) -> Result<(), String> {
318 match value {
319 None => {
320 if let Some(set) = self.modes.get_mut(&mode) {
321 set.remove(&lever);
322 }
323 }
324 Some(v) if lever.value_ok(v) => {
325 self.modes.entry(mode).or_default().insert(lever, v.to_string());
326 }
327 Some(v) => return Err(format!("{:?} is not a valid value for {}", v, lever.key())),
328 }
329 Ok(())
330 }
331
332 /// The mode an adapter state runs; unassigned falls back to the source's
333 /// own default rather than to "do nothing", so a fresh plan still has a
334 /// mode to edit and apply.
335 pub fn assigned(&self, source: Source) -> Mode {
336 self.assign.get(&source).copied().unwrap_or_else(|| source.default_mode())
337 }
338
339 pub fn assign(&mut self, source: Source, mode: Mode) {
340 self.assign.insert(source, mode);
341 }
342
343 pub fn charge_limit(&self) -> ChargeLimit {
344 self.charge
345 }
346
347 /// Record the charge window. Rejects one that fails
348 /// [`ChargeLimit::check`] rather than storing it.
349 pub fn set_charge_limit(&mut self, limit: ChargeLimit) -> Result<(), String> {
350 limit.check()?;
351 self.charge = limit;
352 Ok(())
353 }
354
355 /// No lever set on any mode. The assignment alone is not content: it
356 /// changes nothing until some mode has a lever in it.
357 pub fn is_empty(&self) -> bool {
358 self.modes.values().all(BTreeMap::is_empty)
359 }
360
361 pub fn parse(text: &str) -> Result<PowerPlan, String> {
362 let doc: kdl::KdlDocument = text.parse().map_err(|e: kdl::KdlError| e.to_string())?;
363 let mut plan = PowerPlan::default();
364 for node in doc.nodes() {
365 let name = node.name().value();
366 match name {
367 "mode" => {
368 let key = node
369 .get(0)
370 .and_then(|e| e.value().as_string())
371 .ok_or_else(|| "mode needs a name, e.g. mode \"balanced\"".to_string())?;
372 let mode = Mode::parse(key).ok_or_else(|| format!("unknown mode {:?}", key))?;
373 plan.read_levers(node, mode, key)?;
374 }
375 "assign" => {
376 let Some(children) = node.children() else { continue };
377 for child in children.nodes() {
378 let sname = child.name().value();
379 let source = Source::parse(sname)
380 .ok_or_else(|| format!("unknown power source {:?} under assign", sname))?;
381 let key = child
382 .get(0)
383 .and_then(|e| e.value().as_string())
384 .ok_or_else(|| format!("assign.{} needs a mode name", sname))?;
385 let mode = Mode::parse(key)
386 .ok_or_else(|| format!("unknown mode {:?} assigned to {}", key, sname))?;
387 plan.assign(source, mode);
388 }
389 }
390 "charge_limit" => {
391 let mut limit = ChargeLimit::default();
392 if let Some(children) = node.children() {
393 for child in children.nodes() {
394 let cname = child.name().value();
395 let pct = child
396 .get(0)
397 .and_then(|e| e.value().as_i64())
398 .and_then(|n| u32::try_from(n).ok())
399 .ok_or_else(|| format!("charge_limit.{} needs one whole percent", cname))?;
400 match cname {
401 "start" => limit.start = Some(pct),
402 "end" => limit.end = Some(pct),
403 _ => return Err(format!("unknown setting {:?} under charge_limit", cname)),
404 }
405 }
406 }
407 plan.set_charge_limit(limit)?;
408 }
409 // The pre-modes file: a bare block of levers per adapter
410 // state. Each becomes that state's default mode, which is
411 // what it was already doing.
412 _ => match Source::parse(name) {
413 Some(source) => {
414 let mode = source.default_mode();
415 plan.read_levers(node, mode, name)?;
416 plan.assign(source, mode);
417 }
418 None => return Err(format!("unknown block {:?}", name)),
419 },
420 }
421 }
422 Ok(plan)
423 }
424
425 /// The lever children of one block, into `mode`. `what` names the block
426 /// in errors, since the same reader serves both file formats.
427 fn read_levers(&mut self, node: &kdl::KdlNode, mode: Mode, what: &str) -> Result<(), String> {
428 let Some(children) = node.children() else { return Ok(()) };
429 for child in children.nodes() {
430 let name = child.name().value();
431 let Some(lever) = Lever::parse(name) else {
432 return Err(format!("unknown lever {:?} under {}", name, what));
433 };
434 let value = match child.get(0).map(|e| e.value()) {
435 Some(v) if v.as_string().is_some() => v.as_string().unwrap().to_string(),
436 Some(v) if v.as_i64().is_some() => v.as_i64().unwrap().to_string(),
437 _ => return Err(format!("{}.{} needs one string or integer value", what, name)),
438 };
439 self.put(mode, lever, Some(&value))?;
440 }
441 Ok(())
442 }
443
444 pub fn to_kdl(&self) -> String {
445 let mut doc = kdl::KdlDocument::new();
446 for mode in Mode::ALL {
447 let mut block = kdl::KdlNode::new("mode");
448 block.push(kdl::KdlEntry::new(mode.key()));
449 let children = block.ensure_children();
450 for (lever, value) in self.levers(mode) {
451 let mut node = kdl::KdlNode::new(lever.key());
452 if lever.is_numeric() {
453 // Validated on the way in, so this parse cannot fail;
454 // fall back to the string form rather than panicking.
455 match value.parse::<i64>() {
456 Ok(n) => node.push(kdl::KdlEntry::new(n)),
457 Err(_) => node.push(kdl::KdlEntry::new(value)),
458 }
459 } else {
460 node.push(kdl::KdlEntry::new(value));
461 }
462 children.nodes_mut().push(node);
463 }
464 doc.nodes_mut().push(block);
465 }
466 let mut assign = kdl::KdlNode::new("assign");
467 let children = assign.ensure_children();
468 for source in Source::ALL {
469 // Resolved, not just what was stored: the file then says out
470 // loud what the applier will do on every adapter state.
471 let mut node = kdl::KdlNode::new(source.key());
472 node.push(kdl::KdlEntry::new(self.assigned(source).key()));
473 children.nodes_mut().push(node);
474 }
475 doc.nodes_mut().push(assign);
476 if !self.charge.is_unset() {
477 let mut block = kdl::KdlNode::new("charge_limit");
478 let children = block.ensure_children();
479 for (name, pct) in [("start", self.charge.start), ("end", self.charge.end)] {
480 if let Some(pct) = pct {
481 let mut node = kdl::KdlNode::new(name);
482 node.push(kdl::KdlEntry::new(i64::from(pct)));
483 children.nodes_mut().push(node);
484 }
485 }
486 doc.nodes_mut().push(block);
487 }
488 doc.fmt();
489 let mut out = String::from(
490 "// Power modes and their adapter-state assignment, edited from the\n\
491 // System Interface's Power page and applied by cce-power-apply (udev,\n\
492 // boot, and on each change). A lever missing from a mode is left\n\
493 // untouched when that mode becomes active. The charge limit belongs\n\
494 // to no mode and is re-applied on every change.\n",
495 );
496 out.push_str(&doc.to_string());
497 out
498 }
499
500 /// The plan on disk; a missing file is an empty plan, an unreadable or
501 /// malformed one is an error (the applier must not guess at half a plan).
502 pub fn load_from(path: &Path) -> Result<PowerPlan, String> {
503 match std::fs::read_to_string(path) {
504 Ok(text) => PowerPlan::parse(&text).map_err(|e| format!("{}: {}", path.display(), e)),
505 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PowerPlan::default()),
506 Err(e) => Err(format!("{}: {}", path.display(), e)),
507 }
508 }
509
510 pub fn load() -> Result<PowerPlan, String> {
511 Self::load_from(Path::new(PLAN_PATH))
512 }
513
514 /// Write atomically (temp file + rename) so a reader never sees a torn
515 /// plan; the directory is created if this is the first write.
516 pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
517 if let Some(dir) = path.parent() {
518 std::fs::create_dir_all(dir)?;
519 }
520 let tmp = path.with_extension("kdl.tmp");
521 std::fs::write(&tmp, self.to_kdl())?;
522 std::fs::rename(&tmp, path)
523 }
524
525 pub fn save(&self) -> std::io::Result<()> {
526 self.save_to(Path::new(PLAN_PATH))
527 }
528 }
529
530 /// Which source the machine is on, from (type, online) pairs of the
531 /// power_supply class: any Mains supply that is online means plugged in.
532 /// No Mains supply at all (a desktop) reads as plugged in too — there is
533 /// nothing to unplug.
534 pub fn source_from_supplies<'a>(supplies: impl IntoIterator<Item = (&'a str, bool)>) -> Source {
535 let mut saw_mains = false;
536 for (kind, online) in supplies {
537 if kind == "Mains" {
538 saw_mains = true;
539 if online {
540 return Source::Ac;
541 }
542 }
543 }
544 if saw_mains { Source::Battery } else { Source::Ac }
545 }
546
547 fn read_trim(path: &Path) -> Option<String> {
548 std::fs::read_to_string(path).ok().map(|s| s.trim().to_string())
549 }
550
551 /// The live source, from /sys/class/power_supply.
552 pub fn current_source() -> Source {
553 let mut pairs: Vec<(String, bool)> = Vec::new();
554 if let Ok(rd) = std::fs::read_dir("/sys/class/power_supply") {
555 for e in rd.flatten() {
556 let p = e.path();
557 let kind = read_trim(&p.join("type")).unwrap_or_default();
558 let online = read_trim(&p.join("online")).is_some_and(|s| s == "1");
559 pairs.push((kind, online));
560 }
561 }
562 source_from_supplies(pairs.iter().map(|(k, o)| (k.as_str(), *o)))
563 }
564
565 /// The state of the root side that applies a mode on plug and unplug.
566 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
567 pub enum Automation {
568 /// No helper at the system path, or no udev rule to start it. The plan
569 /// is only applied when the page itself changes a lever.
570 #[default]
571 Missing,
572 /// Both installed, but the helper predates modes or one of the levers:
573 /// it cannot parse a plan that uses them, so it applies nothing on plug
574 /// or unplug — and it rejects the page's own `set`/`assign` calls too.
575 Stale,
576 Ready,
577 }
578
579 /// Whether a helper binary speaks the current CLI: modes, and every lever
580 /// this page can send it.
581 ///
582 /// Asked by running it with no arguments, which prints its usage and exits
583 /// 2 without touching anything. Deliberately NOT a string search inside the
584 /// file: a hit would prove freshness but a miss proves nothing (link-time
585 /// constant merging eats literals), and a false "stale" is the worse error.
586 ///
587 /// This exists because a stale `/usr/bin/cce-power-apply` fails in the one
588 /// way nothing reports: it takes the pkexec prompt, reads the mode name as
589 /// an adapter state, and exits 2 — so a pick costs the user an
590 /// authentication and changes nothing. A helper that knows modes but
591 /// predates a lever fails the same way for that lever alone, which is why
592 /// the lever list is part of the check.
593 ///
594 /// The answer is kept per path and modification time: the Power page asks
595 /// on every five-second refresh, and until 2026-10-05 each one ran the
596 /// helper. A reinstalled helper has a new mtime and is asked again.
597 pub fn helper_speaks_modes(path: &Path) -> bool {
598 type Seen = Vec<(PathBuf, Option<std::time::SystemTime>, bool)>;
599 static SEEN: std::sync::Mutex<Seen> = std::sync::Mutex::new(Vec::new());
600 let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
601 if let Some(&(_, _, ok)) = SEEN.lock().unwrap().iter().find(|(p, t, _)| p == path && *t == mtime) {
602 return ok;
603 }
604 let ok = std::process::Command::new(path)
605 .output()
606 .is_ok_and(|out| usage_speaks_modes(&String::from_utf8_lossy(&out.stderr)));
607 let mut seen = SEEN.lock().unwrap();
608 seen.retain(|(p, _, _)| p != path);
609 seen.push((path.to_path_buf(), mtime, ok));
610 ok
611 }
612
613 /// The usage text of a helper that knows about modes names `apply-mode`
614 /// (the pre-modes one lists only `apply`, `set` and `show`), and its
615 /// `levers:` line names every lever it accepts. It must also name
616 /// `charge-limit`: a helper from before the charge limit cannot parse a
617 /// plan holding one, so it would apply nothing on plug or unplug.
618 fn usage_speaks_modes(usage: &str) -> bool {
619 let levers: Vec<&str> = usage
620 .lines()
621 .find_map(|l| l.trim().strip_prefix("levers:"))
622 .map(|l| l.split_whitespace().collect())
623 .unwrap_or_default();
624 usage.contains("apply-mode")
625 && usage.contains("charge-limit")
626 && Lever::ALL.iter().all(|l| levers.contains(&l.key()))
627 }
628
629 /// Whether the root-side pieces are in place — the helper at its system
630 /// path, the udev rule that starts it, and a helper new enough to read the
631 /// plan this app writes.
632 pub fn automation_status() -> Automation {
633 let helper = Path::new(HELPER_SYSTEM_PATH);
634 if !helper.exists() || !Path::new(UDEV_RULE_PATH).exists() {
635 return Automation::Missing;
636 }
637 if helper_speaks_modes(helper) { Automation::Ready } else { Automation::Stale }
638 }
639
640 // ── Applying (root) ─────────────────────────────────────────────────────
641
642 fn write_sysfs(path: &Path, value: &str) -> Result<(), String> {
643 std::fs::write(path, value).map_err(|e| format!("{}: {}", path.display(), e))
644 }
645
646 /// `/sys/devices/system/cpu/cpu<N>` for every core, sorted.
647 fn cpu_dirs() -> Vec<PathBuf> {
648 let mut v: Vec<PathBuf> = std::fs::read_dir("/sys/devices/system/cpu")
649 .map(|rd| {
650 rd.flatten()
651 .map(|e| e.path())
652 .filter(|p| {
653 p.file_name()
654 .and_then(|n| n.to_str())
655 .is_some_and(|n| n.strip_prefix("cpu").is_some_and(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit())))
656 })
657 .collect()
658 })
659 .unwrap_or_default();
660 v.sort();
661 v
662 }
663
664 /// Every /sys/class/drm/card* exposing the i915/xe clock knob.
665 fn drm_cards_with_freq() -> Vec<PathBuf> {
666 let mut v: Vec<PathBuf> = std::fs::read_dir("/sys/class/drm")
667 .map(|rd| {
668 rd.flatten()
669 .map(|e| e.path())
670 .filter(|p| {
671 p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("card"))
672 && p.join("gt_max_freq_mhz").exists()
673 })
674 .collect()
675 })
676 .unwrap_or_default();
677 v.sort();
678 v
679 }
680
681 /// Write one lever to every interface it covers. Runs as root; the page
682 /// never calls this directly, it goes through the helper under pkexec.
683 /// Errors name the file and the reason so the unit's journal is useful.
684 pub fn apply_lever(lever: Lever, value: &str) -> Result<(), String> {
685 if !lever.value_ok(value) {
686 return Err(format!("{:?} is not a valid value for {}", value, lever.key()));
687 }
688 match lever {
689 Lever::Profile => write_sysfs(Path::new("/sys/firmware/acpi/platform_profile"), value),
690 Lever::Epp | Lever::Governor => {
691 // Every core: both are per-cpu and a partial write would leave the
692 // package split across preferences.
693 let file = if lever == Lever::Epp { "energy_performance_preference" } else { "scaling_governor" };
694 let mut any = false;
695 for cpu in cpu_dirs() {
696 let p = cpu.join("cpufreq").join(file);
697 if p.exists() {
698 any = true;
699 write_sysfs(&p, value)?;
700 }
701 }
702 if any { Ok(()) } else { Err(format!("no cpufreq/{} on this host", file)) }
703 }
704 Lever::Turbo => {
705 let no_turbo = if value == "on" { "0" } else { "1" };
706 write_sysfs(Path::new("/sys/devices/system/cpu/intel_pstate/no_turbo"), no_turbo)
707 }
708 Lever::IgpuMaxMhz => {
709 let mhz: u32 = value.parse().map_err(|_| "bad MHz".to_string())?;
710 let cards = drm_cards_with_freq();
711 if cards.is_empty() {
712 return Err("no DRM card exposes gt_max_freq_mhz".to_string());
713 }
714 for card in cards {
715 // Bounded by the hardware's own reported range, per card.
716 let rd = |n: &str| read_trim(&card.join(n)).and_then(|s| s.parse::<u32>().ok());
717 let lo = rd("gt_RPn_freq_mhz").unwrap_or(0);
718 let hi = rd("gt_RP0_freq_mhz").unwrap_or(u32::MAX);
719 if mhz < lo || mhz > hi {
720 return Err(format!("{} MHz is outside {}'s {}–{} MHz range", mhz, card.display(), lo, hi));
721 }
722 write_sysfs(&card.join("gt_max_freq_mhz"), value)?;
723 }
724 Ok(())
725 }
726 Lever::Aspm => write_sysfs(Path::new("/sys/module/pcie_aspm/parameters/policy"), value),
727 Lever::AudioIdleSecs => {
728 let secs: u32 = value.parse().map_err(|_| "bad seconds".to_string())?;
729 if secs > 3600 {
730 return Err("audio idle timeout above an hour".to_string());
731 }
732 write_sysfs(Path::new("/sys/module/snd_hda_intel/parameters/power_save"), value)
733 }
734 Lever::Animations => write_run_state(Path::new(cce_ui::motion::STATE_PATH), value),
735 Lever::IdleDisplayOffSecs | Lever::IdleSleepSecs => {
736 let secs: u32 = value.parse().map_err(|_| "bad seconds".to_string())?;
737 if secs > 86_400 {
738 return Err("idle timeout above a day".to_string());
739 }
740 let path = if lever == Lever::IdleDisplayOffSecs { IDLE_DISPLAY_OFF_PATH } else { IDLE_SLEEP_PATH };
741 write_run_state(Path::new(path), value)
742 }
743 Lever::GpuLimitW => {
744 // nvidia-smi validates the watts against the card's own min/max
745 // and refuses anything outside them, so it is the range check.
746 let out = std::process::Command::new("nvidia-smi")
747 .args(["-pl", value])
748 .output()
749 .map_err(|e| format!("nvidia-smi: {}", e))?;
750 if out.status.success() {
751 Ok(())
752 } else {
753 let msg = String::from_utf8_lossy(&out.stderr);
754 let msg = if msg.trim().is_empty() { String::from_utf8_lossy(&out.stdout) } else { msg };
755 Err(format!("nvidia-smi -pl {}: {}", value, msg.trim()))
756 }
757 }
758 }
759 }
760
761 /// Every battery with a charge-limit knob, `/sys/class/power_supply/BAT*`,
762 /// sorted.
763 fn charge_batteries() -> Vec<PathBuf> {
764 let mut v: Vec<PathBuf> = std::fs::read_dir("/sys/class/power_supply")
765 .map(|rd| {
766 rd.flatten()
767 .map(|e| e.path())
768 .filter(|p| {
769 p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("BAT"))
770 && p.join("charge_control_end_threshold").exists()
771 })
772 .collect()
773 })
774 .unwrap_or_default();
775 v.sort();
776 v
777 }
778
779 /// Whether the start threshold goes in before the end. The firmware
780 /// (thinkpad_acpi) refuses a start above the end in force and an end below
781 /// the start in force, so a window moving up must raise its end first and
782 /// one moving down must lower its start first; `current_end` is what the
783 /// battery reports now. Written start-then-end blindly, 75/80 → 85/90
784 /// fails on the start.
785 fn start_first(limit: ChargeLimit, current_end: Option<u32>) -> bool {
786 match (limit.start, current_end) {
787 (Some(s), Some(cur)) => s <= cur,
788 _ => true,
789 }
790 }
791
792 /// Write the charge window to every battery that has one. A threshold
793 /// already at its value is not rewritten; a start the battery has no file
794 /// for is reported after the end has landed, since the end is the half that
795 /// protects the pack.
796 pub fn apply_charge_limit(limit: ChargeLimit) -> Result<(), String> {
797 limit.check()?;
798 if limit.is_unset() {
799 return Ok(());
800 }
801 let batteries = charge_batteries();
802 if batteries.is_empty() {
803 return Err("no battery exposes charge_control_end_threshold".to_string());
804 }
805 const START: &str = "charge_control_start_threshold";
806 const END: &str = "charge_control_end_threshold";
807 let mut missing_start = Vec::new();
808 for bat in batteries {
809 let read = |file: &str| read_trim(&bat.join(file)).and_then(|s| s.parse::<u32>().ok());
810 let start = match limit.start {
811 Some(s) if bat.join(START).exists() => Some((START, s)),
812 Some(_) => {
813 missing_start.push(bat.display().to_string());
814 None
815 }
816 None => None,
817 };
818 let end = limit.end.map(|e| (END, e));
819 let order = if start_first(limit, read(END)) { [start, end] } else { [end, start] };
820 for (file, pct) in order.into_iter().flatten() {
821 if read(file) != Some(pct) {
822 write_sysfs(&bat.join(file), &pct.to_string())?;
823 }
824 }
825 }
826 if missing_start.is_empty() {
827 Ok(())
828 } else {
829 Err(format!("no {} on {}; only the end was applied", START, missing_start.join(", ")))
830 }
831 }
832
833 /// Where the idle-timeout levers land, in seconds (0 = never). The
834 /// compositor's idle manager polls both and lets a present file override
835 /// its `idle { }` block; a missing file means "the config's value". The
836 /// paths are repeated in `cce-fx`'s `idle.rs` (it cannot depend on this
837 /// crate), so a rename must land on both sides.
838 pub const IDLE_DISPLAY_OFF_PATH: &str = "/run/cce/idle_display_off";
839 pub const IDLE_SLEEP_PATH: &str = "/run/cce/idle_sleep";
840
841 /// Record a session-wide switch where every session reads it: the
842 /// animations file ([`cce_ui::motion::STATE_PATH`]) and the idle-timeout
843 /// files. Not sysfs, but the same shape of lever: root writes it when the
844 /// mode changes, and the compositor and every cce-ui client follow it
845 /// without a restart. Under /run, not in anyone's config: this runs as root
846 /// with no session and no `$HOME`, and a tmpfs file is rewritten at boot by
847 /// the same coldplug run that applies the rest of the mode, so it can never
848 /// outlive the plan that set it.
849 fn write_run_state(path: &Path, value: &str) -> Result<(), String> {
850 use std::os::unix::fs::PermissionsExt;
851 let dir = path.parent().expect("run-state path has a parent");
852 std::fs::create_dir_all(dir).map_err(|e| format!("{}: {}", dir.display(), e))?;
853 // Temp + rename so a reader never sees a torn value; world-readable
854 // because every session's processes read it.
855 let tmp = path.with_extension("tmp");
856 write_sysfs(&tmp, value)?;
857 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644))
858 .map_err(|e| format!("{}: {}", tmp.display(), e))?;
859 std::fs::rename(&tmp, path).map_err(|e| format!("{}: {}", path.display(), e))
860 }
861
862 /// The order levers are written in. The governor goes first: intel_pstate
863 /// refuses any energy preference but "performance" while the `performance`
864 /// governor is in force (EBUSY), and switching governors resets the
865 /// preference to the one it cached. Written in plan order (alphabetical by
866 /// `Lever`), the battery mode's `epp "power"` failed on every unplug and
867 /// left all cores at EPP=performance under the powersave governor. Every
868 /// other lever is independent and keeps plan order.
869 fn apply_order<'a>(levers: impl Iterator<Item = (Lever, &'a str)>) -> Vec<(Lever, &'a str)> {
870 let mut v: Vec<(Lever, &str)> = levers.collect();
871 v.sort_by_key(|(lever, _)| (*lever != Lever::Governor) as u8);
872 v
873 }
874
875 /// Apply every lever one mode sets. Failures are per lever — a missing
876 /// NVIDIA driver must not stop the CPU profile from landing — and come back
877 /// to the caller, which logs them.
878 pub fn apply_mode(plan: &PowerPlan, mode: Mode) -> Vec<(Lever, Result<(), String>)> {
879 apply_order(plan.levers(mode))
880 .into_iter()
881 .map(|(lever, value)| (lever, apply_lever(lever, value)))
882 .collect::<Vec<_>>()
883 }
884
885 /// Apply whichever mode is assigned to one adapter state.
886 pub fn apply_source(plan: &PowerPlan, source: Source) -> Vec<(Lever, Result<(), String>)> {
887 apply_mode(plan, plan.assigned(source))
888 }
889
890 #[cfg(test)]
891 mod tests {
892 use super::*;
893
894 fn levers_of(plan: &PowerPlan, mode: Mode) -> Vec<(Lever, String)> {
895 plan.levers(mode).map(|(l, v)| (l, v.to_string())).collect()
896 }
897
898 #[test]
899 fn governor_is_written_before_epp() {
900 // The power-saver mode as shipped: plan order puts epp before
901 // governor, and intel_pstate rejects that pairing.
902 let mut plan = PowerPlan::default();
903 plan.put(Mode::PowerSaver, Lever::Profile, Some("low-power")).unwrap();
904 plan.put(Mode::PowerSaver, Lever::Epp, Some("power")).unwrap();
905 plan.put(Mode::PowerSaver, Lever::Governor, Some("powersave")).unwrap();
906 plan.put(Mode::PowerSaver, Lever::Turbo, Some("off")).unwrap();
907 let order: Vec<Lever> = apply_order(plan.levers(Mode::PowerSaver)).into_iter().map(|(l, _)| l).collect();
908 assert_eq!(order[0], Lever::Governor, "{order:?}");
909 // The rest keep plan order, and nothing is dropped or duplicated.
910 assert_eq!(order, vec![Lever::Governor, Lever::Profile, Lever::Epp, Lever::Turbo]);
911 // A mode without a governor is untouched.
912 let mut bare = PowerPlan::default();
913 bare.put(Mode::Balanced, Lever::Epp, Some("balance_power")).unwrap();
914 let order: Vec<Lever> = apply_order(bare.levers(Mode::Balanced)).into_iter().map(|(l, _)| l).collect();
915 assert_eq!(order, vec![Lever::Epp]);
916 }
917
918 #[test]
919 fn kdl_round_trip_keeps_modes_assignment_and_types() {
920 let mut plan = PowerPlan::default();
921 plan.put(Mode::Performance, Lever::Profile, Some("performance")).unwrap();
922 plan.put(Mode::Performance, Lever::Turbo, Some("on")).unwrap();
923 plan.put(Mode::PowerSaver, Lever::Profile, Some("low-power")).unwrap();
924 plan.put(Mode::PowerSaver, Lever::IgpuMaxMhz, Some("800")).unwrap();
925 plan.put(Mode::PowerSaver, Lever::GpuLimitW, Some("40")).unwrap();
926 plan.assign(Source::Battery, Mode::Balanced);
927 let text = plan.to_kdl();
928 // Numbers are written as KDL integers, tokens as strings.
929 assert!(text.contains("igpu_max_mhz 800"), "{text}");
930 assert!(text.contains("profile \"low-power\""), "{text}");
931 assert!(text.contains("mode \"power-saver\""), "{text}");
932 assert!(text.contains("battery \"balanced\""), "{text}");
933 // The file says every assignment out loud, so what comes back is the
934 // same plan with the AC default written down — and writing it again
935 // is a fixed point.
936 let back = PowerPlan::parse(&text).unwrap();
937 assert_eq!(levers_of(&back, Mode::Performance), levers_of(&plan, Mode::Performance));
938 assert_eq!(levers_of(&back, Mode::PowerSaver), levers_of(&plan, Mode::PowerSaver));
939 for source in Source::ALL {
940 assert_eq!(back.assigned(source), plan.assigned(source));
941 }
942 assert_eq!(back.to_kdl(), text);
943 }
944
945 #[test]
946 fn parse_accepts_empty_and_partial_files() {
947 assert_eq!(PowerPlan::parse("").unwrap(), PowerPlan::default());
948 let p = PowerPlan::parse("mode \"balanced\" {\n epp \"power\"\n}\n").unwrap();
949 assert_eq!(p.get(Mode::Balanced, Lever::Epp), Some("power"));
950 assert_eq!(p.get(Mode::Performance, Lever::Epp), None);
951 // Nothing assigned: each adapter state keeps its default mode.
952 assert_eq!(p.assigned(Source::Ac), Mode::Performance);
953 assert_eq!(p.assigned(Source::Battery), Mode::PowerSaver);
954 }
955
956 #[test]
957 fn the_pre_modes_file_migrates_onto_the_default_modes() {
958 // What /etc/cce/power.kdl looked like before modes existed: a bare
959 // block of levers per adapter state, applied on plug and unplug.
960 let old = "ac {\n profile \"performance\"\n}\nbattery {\n profile \"low-power\"\n igpu_max_mhz 800\n}\n";
961 let p = PowerPlan::parse(old).unwrap();
962 // Each block landed on the mode its source runs, so the same levers
963 // still apply on the same adapter states.
964 assert_eq!(p.assigned(Source::Ac), Mode::Performance);
965 assert_eq!(p.assigned(Source::Battery), Mode::PowerSaver);
966 assert_eq!(p.get(Mode::Performance, Lever::Profile), Some("performance"));
967 assert_eq!(p.get(Mode::PowerSaver, Lever::IgpuMaxMhz), Some("800"));
968 assert!(levers_of(&p, Mode::Balanced).is_empty());
969 // And it rewrites in the new shape.
970 assert!(p.to_kdl().contains("mode \"performance\""));
971 assert_eq!(PowerPlan::parse(&p.to_kdl()).unwrap(), p);
972 }
973
974 #[test]
975 fn parse_rejects_unknown_names_and_bad_values() {
976 assert!(PowerPlan::parse("mode \"balanced\" {\n brightness 50\n}\n").is_err());
977 assert!(PowerPlan::parse("mode \"turbo-max\" {\n}\n").is_err());
978 assert!(PowerPlan::parse("assign {\n ac \"turbo-max\"\n}\n").is_err());
979 assert!(PowerPlan::parse("assign {\n usb \"balanced\"\n}\n").is_err());
980 assert!(PowerPlan::parse("levers {\n profile \"performance\"\n}\n").is_err());
981 // A shell metacharacter never survives into the plan.
982 assert!(PowerPlan::parse("mode \"balanced\" {\n profile \"x;reboot\"\n}\n").is_err());
983 // Turbo is on/off only.
984 assert!(PowerPlan::parse("mode \"balanced\" {\n turbo \"yes\"\n}\n").is_err());
985 // A numeric lever given a token.
986 assert!(PowerPlan::parse("mode \"balanced\" {\n gpu_limit_w \"max\"\n}\n").is_err());
987 }
988
989 #[test]
990 fn put_none_clears_and_bad_values_are_refused() {
991 let mut plan = PowerPlan::default();
992 plan.put(Mode::Balanced, Lever::Governor, Some("powersave")).unwrap();
993 assert!(plan.put(Mode::Balanced, Lever::Governor, Some("$(rm)")).is_err());
994 assert_eq!(plan.get(Mode::Balanced, Lever::Governor), Some("powersave"));
995 plan.put(Mode::Balanced, Lever::Governor, None).unwrap();
996 assert!(plan.is_empty());
997 // An assignment alone is not content — it changes nothing until a
998 // mode has a lever in it.
999 plan.assign(Source::Ac, Mode::Balanced);
1000 assert!(plan.is_empty());
1001 }
1002
1003 #[test]
1004 fn assignment_is_per_source_and_two_states_may_share_a_mode() {
1005 let mut plan = PowerPlan::default();
1006 plan.put(Mode::Balanced, Lever::Epp, Some("balance_power")).unwrap();
1007 plan.assign(Source::Ac, Mode::Balanced);
1008 plan.assign(Source::Battery, Mode::Balanced);
1009 assert_eq!(plan.assigned(Source::Ac), Mode::Balanced);
1010 assert_eq!(plan.assigned(Source::Battery), Mode::Balanced);
1011 assert_eq!(PowerPlan::parse(&plan.to_kdl()).unwrap(), plan);
1012 // Both states named, so nothing was left to a default.
1013 assert!(plan.to_kdl().contains("ac \"balanced\""));
1014 }
1015
1016 #[test]
1017 fn value_guard_shapes() {
1018 assert!(Lever::Profile.value_ok("balance_power"));
1019 assert!(Lever::Profile.value_ok("low-power"));
1020 assert!(!Lever::Profile.value_ok(""));
1021 assert!(!Lever::Profile.value_ok("a b"));
1022 assert!(!Lever::Profile.value_ok("x;reboot"));
1023 assert!(Lever::Turbo.value_ok("off"));
1024 assert!(!Lever::Turbo.value_ok("0"));
1025 assert!(Lever::Animations.value_ok("on"));
1026 assert!(!Lever::Animations.value_ok("false"));
1027 assert!(Lever::AudioIdleSecs.value_ok("10"));
1028 assert!(!Lever::AudioIdleSecs.value_ok("-1"));
1029 assert!(!Lever::AudioIdleSecs.value_ok("ten"));
1030 assert!(Lever::IdleDisplayOffSecs.value_ok("0"));
1031 assert!(Lever::IdleSleepSecs.value_ok("1800"));
1032 assert!(!Lever::IdleSleepSecs.value_ok("30m"));
1033 assert!(Lever::IdleDisplayOffSecs.is_numeric());
1034 }
1035
1036 #[test]
1037 fn source_follows_the_mains_supply() {
1038 // Battery discharging, USB-C sources idle, AC offline → battery.
1039 let unplugged = [("Battery", false), ("USB", false), ("Mains", false)];
1040 assert_eq!(source_from_supplies(unplugged), Source::Battery);
1041 let plugged = [("Battery", false), ("Mains", true)];
1042 assert_eq!(source_from_supplies(plugged), Source::Ac);
1043 // A desktop with no Mains device has nothing to unplug.
1044 assert_eq!(source_from_supplies([("Battery", false)]), Source::Ac);
1045 assert_eq!(source_from_supplies([]), Source::Ac);
1046 }
1047
1048 #[test]
1049 fn the_usage_probe_tells_a_mode_helper_from_a_pre_modes_one() {
1050 // What this binary prints today.
1051 let levers = Lever::ALL.iter().map(|l| l.key()).collect::<Vec<_>>().join(" ");
1052 let current = format!(
1053 "usage: cce-power-apply apply [ac|battery]\n cce-power-apply apply-mode <mode>\n \
1054 cce-power-apply charge-limit <start|unset> <end|unset>\n \
1055 modes: performance balanced power-saver\n levers: {levers}\n"
1056 );
1057 assert!(usage_speaks_modes(¤t));
1058 // One that knows modes but predates a lever takes the prompt and
1059 // rejects that lever — as stale, for that pick, as a pre-modes one.
1060 let older = current.replace(" animations", "");
1061 assert!(!usage_speaks_modes(&older), "{older}");
1062 // One from before the charge limit cannot even parse a plan that
1063 // holds one.
1064 let no_charge = current.replace("charge-limit", "");
1065 assert!(!usage_speaks_modes(&no_charge), "{no_charge}");
1066 // What the pre-modes one printed — the copy that silently rejects
1067 // every pick the page makes.
1068 assert!(!usage_speaks_modes(
1069 "usage: cce-power-apply apply [ac|battery]\n cce-power-apply set <ac|battery> <lever> <value|unset>\n cce-power-apply show\n"
1070 ));
1071 // A helper that cannot be run at all is not a helper that speaks.
1072 assert!(!helper_speaks_modes(Path::new("/nonexistent/cce-power-apply")));
1073 }
1074
1075 #[test]
1076 fn the_charge_limit_is_plan_wide_and_round_trips() {
1077 let text = "mode \"performance\" { aspm \"performance\"; }\n\
1078 charge_limit { start 75; end 80; }\n";
1079 let plan = PowerPlan::parse(text).unwrap();
1080 assert_eq!(plan.charge_limit(), ChargeLimit { start: Some(75), end: Some(80) });
1081 // It is in no mode's levers, and survives a write and a re-read.
1082 assert_eq!(levers_of(&plan, Mode::Performance), [(Lever::Aspm, "performance".to_string())]);
1083 let again = PowerPlan::parse(&plan.to_kdl()).unwrap();
1084 assert_eq!(again.charge_limit(), plan.charge_limit());
1085 assert_eq!(levers_of(&again, Mode::Performance), levers_of(&plan, Mode::Performance));
1086 // Half a window keeps only that half; none writes no block at all.
1087 let end_only = PowerPlan::parse("charge_limit { end 60; }").unwrap();
1088 assert_eq!(end_only.charge_limit(), ChargeLimit { start: None, end: Some(60) });
1089 assert_eq!(PowerPlan::parse(&end_only.to_kdl()).unwrap().charge_limit(), end_only.charge_limit());
1090 assert!(!PowerPlan::default().to_kdl().contains("charge_limit"));
1091 }
1092
1093 #[test]
1094 fn a_charge_window_must_be_one_the_firmware_takes() {
1095 for bad in [
1096 "charge_limit { start 80; end 80; }",
1097 "charge_limit { start 90; end 80; }",
1098 "charge_limit { end 0; }",
1099 "charge_limit { end 101; }",
1100 "charge_limit { start 100; }",
1101 "charge_limit { start -1; }",
1102 "charge_limit { end \"80\"; }",
1103 "charge_limit { stop 80; }",
1104 ] {
1105 assert!(PowerPlan::parse(bad).is_err(), "{bad}");
1106 }
1107 let mut plan = PowerPlan::default();
1108 assert!(plan.set_charge_limit(ChargeLimit { start: Some(85), end: Some(80) }).is_err());
1109 assert!(plan.charge_limit().is_unset(), "a refused window is not stored");
1110 plan.set_charge_limit(ChargeLimit { start: Some(0), end: Some(100) }).unwrap();
1111 }
1112
1113 #[test]
1114 fn a_charge_window_moving_up_writes_its_end_first() {
1115 let window = |s, e| ChargeLimit { start: Some(s), end: Some(e) };
1116 // 75/80 → 85/90: a start of 85 over an end of 80 is refused.
1117 assert!(!start_first(window(85, 90), Some(80)));
1118 // 75/80 → 55/60, and the factory 0/100 → 75/80: start first, or the
1119 // new end would sit below the old start.
1120 assert!(start_first(window(55, 60), Some(80)));
1121 assert!(start_first(window(75, 80), Some(100)));
1122 // An end the battery does not report, or no start to order.
1123 assert!(start_first(window(75, 80), None));
1124 assert!(start_first(ChargeLimit { start: None, end: Some(80) }, Some(60)));
1125 }
1126
1127 #[test]
1128 fn keys_round_trip() {
1129 for l in Lever::ALL {
1130 assert_eq!(Lever::parse(l.key()), Some(l));
1131 }
1132 for s in Source::ALL {
1133 assert_eq!(Source::parse(s.key()), Some(s));
1134 }
1135 for m in Mode::ALL {
1136 assert_eq!(Mode::parse(m.key()), Some(m));
1137 }
1138 assert_eq!(Lever::parse("brightness"), None);
1139 assert_eq!(Mode::parse("ac"), None);
1140 }
1141
1142 #[test]
1143 fn save_and_load_through_a_temp_dir() {
1144 let dir = std::env::temp_dir().join(format!("cce-power-plan-test-{}", std::process::id()));
1145 let path = dir.join("nested").join("power.kdl");
1146 // Missing file is an empty plan, not an error.
1147 assert_eq!(PowerPlan::load_from(&path).unwrap(), PowerPlan::default());
1148 let mut plan = PowerPlan::default();
1149 plan.put(Mode::PowerSaver, Lever::Aspm, Some("powersave")).unwrap();
1150 plan.assign(Source::Ac, Mode::Balanced);
1151 plan.assign(Source::Battery, Mode::PowerSaver);
1152 plan.save_to(&path).unwrap();
1153 assert_eq!(PowerPlan::load_from(&path).unwrap(), plan);
1154 // Garbage on disk is reported, not silently emptied.
1155 std::fs::write(&path, "mode \"balanced\" {\n profile \n").unwrap();
1156 assert!(PowerPlan::load_from(&path).is_err());
1157 let _ = std::fs::remove_dir_all(&dir);
1158 }
1159 }