system settings
git clone https://git.lucas.co/cce-system-interface.git
src/power_plan.rs (32.5K)
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 //! A lever absent from a mode is left alone when that mode becomes active —
34 //! "not set" means "don't touch", never "reset to a default". The older
35 //! per-source form of this file (top-level `ac` / `battery` blocks of
36 //! levers, before modes existed) still parses: each block becomes the mode
37 //! that source is assigned to by default, which is exactly the behavior it
38 //! had.
39
40 use std::collections::BTreeMap;
41 use std::path::{Path, PathBuf};
42
43 pub const PLAN_PATH: &str = "/etc/cce/power.kdl";
44 /// Where `ccebuild install-system` puts the helper. The udev rule and the
45 /// system unit both name this path, so its presence is what "automatic
46 /// switching is installed" means to the page.
47 pub const HELPER_SYSTEM_PATH: &str = "/usr/bin/cce-power-apply";
48 pub const UDEV_RULE_PATH: &str = "/etc/udev/rules.d/90-cce-power-apply.rules";
49
50 /// A power-adapter state. Defaults to `Ac`: a host with no Mains supply has
51 /// nothing to unplug.
52 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
53 pub enum Source {
54 #[default]
55 Ac,
56 Battery,
57 }
58
59 impl Source {
60 pub const ALL: [Source; 2] = [Source::Ac, Source::Battery];
61
62 /// The key in the `assign` block, and the CLI spelling.
63 pub fn key(self) -> &'static str {
64 match self {
65 Source::Ac => "ac",
66 Source::Battery => "battery",
67 }
68 }
69
70 pub fn parse(s: &str) -> Option<Source> {
71 Source::ALL.into_iter().find(|v| v.key() == s)
72 }
73
74 pub fn label(self) -> &'static str {
75 match self {
76 Source::Ac => "Plugged In",
77 Source::Battery => "On Battery",
78 }
79 }
80
81 /// The mode a source runs when the plan says nothing about it. These are
82 /// also what the pre-modes file format migrates onto, so an old plan
83 /// keeps behaving exactly as it did.
84 pub fn default_mode(self) -> Mode {
85 match self {
86 Source::Ac => Mode::Performance,
87 Source::Battery => Mode::PowerSaver,
88 }
89 }
90 }
91
92 /// A named set of lever values. The set is fixed rather than user-extensible:
93 /// the page picks a mode from a dropdown, and there is deliberately no
94 /// naming UI to keep a mode's identity stable across the plan file, the
95 /// helper's CLI and the assignment table.
96 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
97 pub enum Mode {
98 Performance,
99 #[default]
100 Balanced,
101 PowerSaver,
102 }
103
104 impl Mode {
105 pub const ALL: [Mode; 3] = [Mode::Performance, Mode::Balanced, Mode::PowerSaver];
106
107 /// The name in the plan file, and the CLI spelling.
108 pub fn key(self) -> &'static str {
109 match self {
110 Mode::Performance => "performance",
111 Mode::Balanced => "balanced",
112 Mode::PowerSaver => "power-saver",
113 }
114 }
115
116 pub fn parse(s: &str) -> Option<Mode> {
117 Mode::ALL.into_iter().find(|m| m.key() == s)
118 }
119
120 pub fn label(self) -> &'static str {
121 match self {
122 Mode::Performance => "Performance",
123 Mode::Balanced => "Balanced",
124 Mode::PowerSaver => "Power Saver",
125 }
126 }
127 }
128
129 /// The levers that make sense per mode. The battery charge limit is
130 /// deliberately not one: it is a charging policy, not something to flip when
131 /// the mode changes.
132 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
133 pub enum Lever {
134 Profile,
135 Epp,
136 Governor,
137 Turbo,
138 IgpuMaxMhz,
139 Aspm,
140 AudioIdleSecs,
141 GpuLimitW,
142 }
143 impl Lever {
144 pub const ALL: [Lever; 8] = [
145 Lever::Profile,
146 Lever::Epp,
147 Lever::Governor,
148 Lever::Turbo,
149 Lever::IgpuMaxMhz,
150 Lever::Aspm,
151 Lever::AudioIdleSecs,
152 Lever::GpuLimitW,
153 ];
154
155 /// The node name in the plan file, and the CLI spelling.
156 pub fn key(self) -> &'static str {
157 match self {
158 Lever::Profile => "profile",
159 Lever::Epp => "epp",
160 Lever::Governor => "governor",
161 Lever::Turbo => "turbo",
162 Lever::IgpuMaxMhz => "igpu_max_mhz",
163 Lever::Aspm => "aspm",
164 Lever::AudioIdleSecs => "audio_idle_secs",
165 Lever::GpuLimitW => "gpu_limit_w",
166 }
167 }
168
169 pub fn parse(s: &str) -> Option<Lever> {
170 Lever::ALL.into_iter().find(|l| l.key() == s)
171 }
172
173 pub fn label(self) -> &'static str {
174 match self {
175 Lever::Profile => "Power Profile",
176 Lever::Epp => "CPU Energy Preference",
177 Lever::Governor => "CPU Governor",
178 Lever::Turbo => "CPU Turbo Boost",
179 Lever::IgpuMaxMhz => "Integrated GPU Max Clock",
180 Lever::Aspm => "PCIe Power Management",
181 Lever::AudioIdleSecs => "Audio Codec Idle",
182 Lever::GpuLimitW => "GPU Power Limit",
183 }
184 }
185
186 /// Numeric levers are stored as KDL integers; the rest as strings.
187 pub fn is_numeric(self) -> bool {
188 matches!(self, Lever::IgpuMaxMhz | Lever::AudioIdleSecs | Lever::GpuLimitW)
189 }
190
191 /// Shape check on a value before it goes anywhere near sysfs or a shell
192 /// line: a sysfs token, an unsigned integer, or on/off for turbo. This is
193 /// the guard `set` and `apply` share; range checks against the hardware
194 /// happen in [`apply_lever`], where the ranges can be read.
195 pub fn value_ok(self, v: &str) -> bool {
196 match self {
197 Lever::Turbo => v == "on" || v == "off",
198 l if l.is_numeric() => v.parse::<u32>().is_ok(),
199 _ => sysfs_token_ok(v),
200 }
201 }
202 }
203
204 /// Sysfs tokens travel into sysfs writes and (from the page) a `pkexec`
205 /// argv, so only the shapes sysfs itself produces are allowed through —
206 /// anything else is dropped, not escaped.
207 pub fn sysfs_token_ok(s: &str) -> bool {
208 !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
209 }
210
211 /// The levers of every mode, plus which mode each adapter state runs.
212 #[derive(Debug, Clone, Default, PartialEq, Eq)]
213 pub struct PowerPlan {
214 modes: BTreeMap<Mode, BTreeMap<Lever, String>>,
215 assign: BTreeMap<Source, Mode>,
216 }
217
218 impl PowerPlan {
219 /// One mode's levers. Absent and empty are the same thing to every
220 /// caller, so a mode nothing has been set on reads as an empty set.
221 pub fn levers(&self, mode: Mode) -> impl Iterator<Item = (Lever, &str)> {
222 self.modes
223 .get(&mode)
224 .into_iter()
225 .flat_map(|m| m.iter().map(|(l, v)| (*l, v.as_str())))
226 }
227
228 pub fn get(&self, mode: Mode, lever: Lever) -> Option<&str> {
229 self.modes.get(&mode)?.get(&lever).map(String::as_str)
230 }
231
232 /// Record a value on a mode, or clear it with `None`. Rejects a
233 /// malformed value rather than storing it.
234 pub fn put(&mut self, mode: Mode, lever: Lever, value: Option<&str>) -> Result<(), String> {
235 match value {
236 None => {
237 if let Some(set) = self.modes.get_mut(&mode) {
238 set.remove(&lever);
239 }
240 }
241 Some(v) if lever.value_ok(v) => {
242 self.modes.entry(mode).or_default().insert(lever, v.to_string());
243 }
244 Some(v) => return Err(format!("{:?} is not a valid value for {}", v, lever.key())),
245 }
246 Ok(())
247 }
248
249 /// The mode an adapter state runs; unassigned falls back to the source's
250 /// own default rather than to "do nothing", so a fresh plan still has a
251 /// mode to edit and apply.
252 pub fn assigned(&self, source: Source) -> Mode {
253 self.assign.get(&source).copied().unwrap_or_else(|| source.default_mode())
254 }
255
256 pub fn assign(&mut self, source: Source, mode: Mode) {
257 self.assign.insert(source, mode);
258 }
259
260 /// No lever set on any mode. The assignment alone is not content: it
261 /// changes nothing until some mode has a lever in it.
262 pub fn is_empty(&self) -> bool {
263 self.modes.values().all(BTreeMap::is_empty)
264 }
265
266 pub fn parse(text: &str) -> Result<PowerPlan, String> {
267 let doc: kdl::KdlDocument = text.parse().map_err(|e: kdl::KdlError| e.to_string())?;
268 let mut plan = PowerPlan::default();
269 for node in doc.nodes() {
270 let name = node.name().value();
271 match name {
272 "mode" => {
273 let key = node
274 .get(0)
275 .and_then(|e| e.value().as_string())
276 .ok_or_else(|| "mode needs a name, e.g. mode \"balanced\"".to_string())?;
277 let mode = Mode::parse(key).ok_or_else(|| format!("unknown mode {:?}", key))?;
278 plan.read_levers(node, mode, key)?;
279 }
280 "assign" => {
281 let Some(children) = node.children() else { continue };
282 for child in children.nodes() {
283 let sname = child.name().value();
284 let source = Source::parse(sname)
285 .ok_or_else(|| format!("unknown power source {:?} under assign", sname))?;
286 let key = child
287 .get(0)
288 .and_then(|e| e.value().as_string())
289 .ok_or_else(|| format!("assign.{} needs a mode name", sname))?;
290 let mode = Mode::parse(key)
291 .ok_or_else(|| format!("unknown mode {:?} assigned to {}", key, sname))?;
292 plan.assign(source, mode);
293 }
294 }
295 // The pre-modes file: a bare block of levers per adapter
296 // state. Each becomes that state's default mode, which is
297 // what it was already doing.
298 _ => match Source::parse(name) {
299 Some(source) => {
300 let mode = source.default_mode();
301 plan.read_levers(node, mode, name)?;
302 plan.assign(source, mode);
303 }
304 None => return Err(format!("unknown block {:?}", name)),
305 },
306 }
307 }
308 Ok(plan)
309 }
310
311 /// The lever children of one block, into `mode`. `what` names the block
312 /// in errors, since the same reader serves both file formats.
313 fn read_levers(&mut self, node: &kdl::KdlNode, mode: Mode, what: &str) -> Result<(), String> {
314 let Some(children) = node.children() else { return Ok(()) };
315 for child in children.nodes() {
316 let name = child.name().value();
317 let Some(lever) = Lever::parse(name) else {
318 return Err(format!("unknown lever {:?} under {}", name, what));
319 };
320 let value = match child.get(0).map(|e| e.value()) {
321 Some(v) if v.as_string().is_some() => v.as_string().unwrap().to_string(),
322 Some(v) if v.as_i64().is_some() => v.as_i64().unwrap().to_string(),
323 _ => return Err(format!("{}.{} needs one string or integer value", what, name)),
324 };
325 self.put(mode, lever, Some(&value))?;
326 }
327 Ok(())
328 }
329
330 pub fn to_kdl(&self) -> String {
331 let mut doc = kdl::KdlDocument::new();
332 for mode in Mode::ALL {
333 let mut block = kdl::KdlNode::new("mode");
334 block.push(kdl::KdlEntry::new(mode.key()));
335 let children = block.ensure_children();
336 for (lever, value) in self.levers(mode) {
337 let mut node = kdl::KdlNode::new(lever.key());
338 if lever.is_numeric() {
339 // Validated on the way in, so this parse cannot fail;
340 // fall back to the string form rather than panicking.
341 match value.parse::<i64>() {
342 Ok(n) => node.push(kdl::KdlEntry::new(n)),
343 Err(_) => node.push(kdl::KdlEntry::new(value)),
344 }
345 } else {
346 node.push(kdl::KdlEntry::new(value));
347 }
348 children.nodes_mut().push(node);
349 }
350 doc.nodes_mut().push(block);
351 }
352 let mut assign = kdl::KdlNode::new("assign");
353 let children = assign.ensure_children();
354 for source in Source::ALL {
355 // Resolved, not just what was stored: the file then says out
356 // loud what the applier will do on every adapter state.
357 let mut node = kdl::KdlNode::new(source.key());
358 node.push(kdl::KdlEntry::new(self.assigned(source).key()));
359 children.nodes_mut().push(node);
360 }
361 doc.nodes_mut().push(assign);
362 doc.fmt();
363 let mut out = String::from(
364 "// Power modes and their adapter-state assignment, edited from the\n\
365 // System Interface's Power page and applied by cce-power-apply (udev,\n\
366 // boot, and on each change). A lever missing from a mode is left\n\
367 // untouched when that mode becomes active.\n",
368 );
369 out.push_str(&doc.to_string());
370 out
371 }
372
373 /// The plan on disk; a missing file is an empty plan, an unreadable or
374 /// malformed one is an error (the applier must not guess at half a plan).
375 pub fn load_from(path: &Path) -> Result<PowerPlan, String> {
376 match std::fs::read_to_string(path) {
377 Ok(text) => PowerPlan::parse(&text).map_err(|e| format!("{}: {}", path.display(), e)),
378 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PowerPlan::default()),
379 Err(e) => Err(format!("{}: {}", path.display(), e)),
380 }
381 }
382
383 pub fn load() -> Result<PowerPlan, String> {
384 Self::load_from(Path::new(PLAN_PATH))
385 }
386
387 /// Write atomically (temp file + rename) so a reader never sees a torn
388 /// plan; the directory is created if this is the first write.
389 pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
390 if let Some(dir) = path.parent() {
391 std::fs::create_dir_all(dir)?;
392 }
393 let tmp = path.with_extension("kdl.tmp");
394 std::fs::write(&tmp, self.to_kdl())?;
395 std::fs::rename(&tmp, path)
396 }
397
398 pub fn save(&self) -> std::io::Result<()> {
399 self.save_to(Path::new(PLAN_PATH))
400 }
401 }
402
403 /// Which source the machine is on, from (type, online) pairs of the
404 /// power_supply class: any Mains supply that is online means plugged in.
405 /// No Mains supply at all (a desktop) reads as plugged in too — there is
406 /// nothing to unplug.
407 pub fn source_from_supplies<'a>(supplies: impl IntoIterator<Item = (&'a str, bool)>) -> Source {
408 let mut saw_mains = false;
409 for (kind, online) in supplies {
410 if kind == "Mains" {
411 saw_mains = true;
412 if online {
413 return Source::Ac;
414 }
415 }
416 }
417 if saw_mains { Source::Battery } else { Source::Ac }
418 }
419
420 fn read_trim(path: &Path) -> Option<String> {
421 std::fs::read_to_string(path).ok().map(|s| s.trim().to_string())
422 }
423
424 /// The live source, from /sys/class/power_supply.
425 pub fn current_source() -> Source {
426 let mut pairs: Vec<(String, bool)> = Vec::new();
427 if let Ok(rd) = std::fs::read_dir("/sys/class/power_supply") {
428 for e in rd.flatten() {
429 let p = e.path();
430 let kind = read_trim(&p.join("type")).unwrap_or_default();
431 let online = read_trim(&p.join("online")).is_some_and(|s| s == "1");
432 pairs.push((kind, online));
433 }
434 }
435 source_from_supplies(pairs.iter().map(|(k, o)| (k.as_str(), *o)))
436 }
437
438 /// The state of the root side that applies a mode on plug and unplug.
439 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
440 pub enum Automation {
441 /// No helper at the system path, or no udev rule to start it. The plan
442 /// is only applied when the page itself changes a lever.
443 #[default]
444 Missing,
445 /// Both installed, but the helper predates modes: it cannot parse a
446 /// plan with `mode` blocks, so it applies nothing on plug or unplug —
447 /// and it rejects the page's own `set`/`assign` calls too.
448 Stale,
449 Ready,
450 }
451
452 /// Whether a helper binary speaks the current, mode-shaped CLI.
453 ///
454 /// Asked by running it with no arguments, which prints its usage and exits
455 /// 2 without touching anything. Deliberately NOT a string search inside the
456 /// file: a hit would prove freshness but a miss proves nothing (link-time
457 /// constant merging eats literals), and a false "stale" is the worse error.
458 ///
459 /// This exists because a stale `/usr/bin/cce-power-apply` fails in the one
460 /// way nothing reports: it takes the pkexec prompt, reads the mode name as
461 /// an adapter state, and exits 2 — so a pick costs the user an
462 /// authentication and changes nothing.
463 pub fn helper_speaks_modes(path: &Path) -> bool {
464 std::process::Command::new(path)
465 .output()
466 .is_ok_and(|out| usage_speaks_modes(&String::from_utf8_lossy(&out.stderr)))
467 }
468
469 /// The usage text of a helper that knows about modes names `apply-mode`;
470 /// the pre-modes one lists only `apply`, `set` and `show`.
471 fn usage_speaks_modes(usage: &str) -> bool {
472 usage.contains("apply-mode")
473 }
474
475 /// Whether the root-side pieces are in place — the helper at its system
476 /// path, the udev rule that starts it, and a helper new enough to read the
477 /// plan this app writes.
478 pub fn automation_status() -> Automation {
479 let helper = Path::new(HELPER_SYSTEM_PATH);
480 if !helper.exists() || !Path::new(UDEV_RULE_PATH).exists() {
481 return Automation::Missing;
482 }
483 if helper_speaks_modes(helper) { Automation::Ready } else { Automation::Stale }
484 }
485
486 // ── Applying (root) ─────────────────────────────────────────────────────
487
488 fn write_sysfs(path: &Path, value: &str) -> Result<(), String> {
489 std::fs::write(path, value).map_err(|e| format!("{}: {}", path.display(), e))
490 }
491
492 /// `/sys/devices/system/cpu/cpu<N>` for every core, sorted.
493 fn cpu_dirs() -> Vec<PathBuf> {
494 let mut v: Vec<PathBuf> = std::fs::read_dir("/sys/devices/system/cpu")
495 .map(|rd| {
496 rd.flatten()
497 .map(|e| e.path())
498 .filter(|p| {
499 p.file_name()
500 .and_then(|n| n.to_str())
501 .is_some_and(|n| n.strip_prefix("cpu").is_some_and(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit())))
502 })
503 .collect()
504 })
505 .unwrap_or_default();
506 v.sort();
507 v
508 }
509
510 /// Every /sys/class/drm/card* exposing the i915/xe clock knob.
511 fn drm_cards_with_freq() -> Vec<PathBuf> {
512 let mut v: Vec<PathBuf> = std::fs::read_dir("/sys/class/drm")
513 .map(|rd| {
514 rd.flatten()
515 .map(|e| e.path())
516 .filter(|p| {
517 p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("card"))
518 && p.join("gt_max_freq_mhz").exists()
519 })
520 .collect()
521 })
522 .unwrap_or_default();
523 v.sort();
524 v
525 }
526
527 /// Write one lever to every interface it covers. Runs as root; the page
528 /// never calls this directly, it goes through the helper under pkexec.
529 /// Errors name the file and the reason so the unit's journal is useful.
530 pub fn apply_lever(lever: Lever, value: &str) -> Result<(), String> {
531 if !lever.value_ok(value) {
532 return Err(format!("{:?} is not a valid value for {}", value, lever.key()));
533 }
534 match lever {
535 Lever::Profile => write_sysfs(Path::new("/sys/firmware/acpi/platform_profile"), value),
536 Lever::Epp | Lever::Governor => {
537 // Every core: both are per-cpu and a partial write would leave the
538 // package split across preferences.
539 let file = if lever == Lever::Epp { "energy_performance_preference" } else { "scaling_governor" };
540 let mut any = false;
541 for cpu in cpu_dirs() {
542 let p = cpu.join("cpufreq").join(file);
543 if p.exists() {
544 any = true;
545 write_sysfs(&p, value)?;
546 }
547 }
548 if any { Ok(()) } else { Err(format!("no cpufreq/{} on this host", file)) }
549 }
550 Lever::Turbo => {
551 let no_turbo = if value == "on" { "0" } else { "1" };
552 write_sysfs(Path::new("/sys/devices/system/cpu/intel_pstate/no_turbo"), no_turbo)
553 }
554 Lever::IgpuMaxMhz => {
555 let mhz: u32 = value.parse().map_err(|_| "bad MHz".to_string())?;
556 let cards = drm_cards_with_freq();
557 if cards.is_empty() {
558 return Err("no DRM card exposes gt_max_freq_mhz".to_string());
559 }
560 for card in cards {
561 // Bounded by the hardware's own reported range, per card.
562 let rd = |n: &str| read_trim(&card.join(n)).and_then(|s| s.parse::<u32>().ok());
563 let lo = rd("gt_RPn_freq_mhz").unwrap_or(0);
564 let hi = rd("gt_RP0_freq_mhz").unwrap_or(u32::MAX);
565 if mhz < lo || mhz > hi {
566 return Err(format!("{} MHz is outside {}'s {}–{} MHz range", mhz, card.display(), lo, hi));
567 }
568 write_sysfs(&card.join("gt_max_freq_mhz"), value)?;
569 }
570 Ok(())
571 }
572 Lever::Aspm => write_sysfs(Path::new("/sys/module/pcie_aspm/parameters/policy"), value),
573 Lever::AudioIdleSecs => {
574 let secs: u32 = value.parse().map_err(|_| "bad seconds".to_string())?;
575 if secs > 3600 {
576 return Err("audio idle timeout above an hour".to_string());
577 }
578 write_sysfs(Path::new("/sys/module/snd_hda_intel/parameters/power_save"), value)
579 }
580 Lever::GpuLimitW => {
581 // nvidia-smi validates the watts against the card's own min/max
582 // and refuses anything outside them, so it is the range check.
583 let out = std::process::Command::new("nvidia-smi")
584 .args(["-pl", value])
585 .output()
586 .map_err(|e| format!("nvidia-smi: {}", e))?;
587 if out.status.success() {
588 Ok(())
589 } else {
590 let msg = String::from_utf8_lossy(&out.stderr);
591 let msg = if msg.trim().is_empty() { String::from_utf8_lossy(&out.stdout) } else { msg };
592 Err(format!("nvidia-smi -pl {}: {}", value, msg.trim()))
593 }
594 }
595 }
596 }
597
598 /// Apply every lever one mode sets. Failures are per lever — a missing
599 /// NVIDIA driver must not stop the CPU profile from landing — and come back
600 /// to the caller, which logs them.
601 pub fn apply_mode(plan: &PowerPlan, mode: Mode) -> Vec<(Lever, Result<(), String>)> {
602 plan.levers(mode)
603 .map(|(lever, value)| (lever, apply_lever(lever, value)))
604 .collect::<Vec<_>>()
605 }
606
607 /// Apply whichever mode is assigned to one adapter state.
608 pub fn apply_source(plan: &PowerPlan, source: Source) -> Vec<(Lever, Result<(), String>)> {
609 apply_mode(plan, plan.assigned(source))
610 }
611
612 #[cfg(test)]
613 mod tests {
614 use super::*;
615
616 fn levers_of(plan: &PowerPlan, mode: Mode) -> Vec<(Lever, String)> {
617 plan.levers(mode).map(|(l, v)| (l, v.to_string())).collect()
618 }
619
620 #[test]
621 fn kdl_round_trip_keeps_modes_assignment_and_types() {
622 let mut plan = PowerPlan::default();
623 plan.put(Mode::Performance, Lever::Profile, Some("performance")).unwrap();
624 plan.put(Mode::Performance, Lever::Turbo, Some("on")).unwrap();
625 plan.put(Mode::PowerSaver, Lever::Profile, Some("low-power")).unwrap();
626 plan.put(Mode::PowerSaver, Lever::IgpuMaxMhz, Some("800")).unwrap();
627 plan.put(Mode::PowerSaver, Lever::GpuLimitW, Some("40")).unwrap();
628 plan.assign(Source::Battery, Mode::Balanced);
629 let text = plan.to_kdl();
630 // Numbers are written as KDL integers, tokens as strings.
631 assert!(text.contains("igpu_max_mhz 800"), "{text}");
632 assert!(text.contains("profile \"low-power\""), "{text}");
633 assert!(text.contains("mode \"power-saver\""), "{text}");
634 assert!(text.contains("battery \"balanced\""), "{text}");
635 // The file says every assignment out loud, so what comes back is the
636 // same plan with the AC default written down — and writing it again
637 // is a fixed point.
638 let back = PowerPlan::parse(&text).unwrap();
639 assert_eq!(levers_of(&back, Mode::Performance), levers_of(&plan, Mode::Performance));
640 assert_eq!(levers_of(&back, Mode::PowerSaver), levers_of(&plan, Mode::PowerSaver));
641 for source in Source::ALL {
642 assert_eq!(back.assigned(source), plan.assigned(source));
643 }
644 assert_eq!(back.to_kdl(), text);
645 }
646
647 #[test]
648 fn parse_accepts_empty_and_partial_files() {
649 assert_eq!(PowerPlan::parse("").unwrap(), PowerPlan::default());
650 let p = PowerPlan::parse("mode \"balanced\" {\n epp \"power\"\n}\n").unwrap();
651 assert_eq!(p.get(Mode::Balanced, Lever::Epp), Some("power"));
652 assert_eq!(p.get(Mode::Performance, Lever::Epp), None);
653 // Nothing assigned: each adapter state keeps its default mode.
654 assert_eq!(p.assigned(Source::Ac), Mode::Performance);
655 assert_eq!(p.assigned(Source::Battery), Mode::PowerSaver);
656 }
657
658 #[test]
659 fn the_pre_modes_file_migrates_onto_the_default_modes() {
660 // What /etc/cce/power.kdl looked like before modes existed: a bare
661 // block of levers per adapter state, applied on plug and unplug.
662 let old = "ac {\n profile \"performance\"\n}\nbattery {\n profile \"low-power\"\n igpu_max_mhz 800\n}\n";
663 let p = PowerPlan::parse(old).unwrap();
664 // Each block landed on the mode its source runs, so the same levers
665 // still apply on the same adapter states.
666 assert_eq!(p.assigned(Source::Ac), Mode::Performance);
667 assert_eq!(p.assigned(Source::Battery), Mode::PowerSaver);
668 assert_eq!(p.get(Mode::Performance, Lever::Profile), Some("performance"));
669 assert_eq!(p.get(Mode::PowerSaver, Lever::IgpuMaxMhz), Some("800"));
670 assert!(levers_of(&p, Mode::Balanced).is_empty());
671 // And it rewrites in the new shape.
672 assert!(p.to_kdl().contains("mode \"performance\""));
673 assert_eq!(PowerPlan::parse(&p.to_kdl()).unwrap(), p);
674 }
675
676 #[test]
677 fn parse_rejects_unknown_names_and_bad_values() {
678 assert!(PowerPlan::parse("mode \"balanced\" {\n brightness 50\n}\n").is_err());
679 assert!(PowerPlan::parse("mode \"turbo-max\" {\n}\n").is_err());
680 assert!(PowerPlan::parse("assign {\n ac \"turbo-max\"\n}\n").is_err());
681 assert!(PowerPlan::parse("assign {\n usb \"balanced\"\n}\n").is_err());
682 assert!(PowerPlan::parse("levers {\n profile \"performance\"\n}\n").is_err());
683 // A shell metacharacter never survives into the plan.
684 assert!(PowerPlan::parse("mode \"balanced\" {\n profile \"x;reboot\"\n}\n").is_err());
685 // Turbo is on/off only.
686 assert!(PowerPlan::parse("mode \"balanced\" {\n turbo \"yes\"\n}\n").is_err());
687 // A numeric lever given a token.
688 assert!(PowerPlan::parse("mode \"balanced\" {\n gpu_limit_w \"max\"\n}\n").is_err());
689 }
690
691 #[test]
692 fn put_none_clears_and_bad_values_are_refused() {
693 let mut plan = PowerPlan::default();
694 plan.put(Mode::Balanced, Lever::Governor, Some("powersave")).unwrap();
695 assert!(plan.put(Mode::Balanced, Lever::Governor, Some("$(rm)")).is_err());
696 assert_eq!(plan.get(Mode::Balanced, Lever::Governor), Some("powersave"));
697 plan.put(Mode::Balanced, Lever::Governor, None).unwrap();
698 assert!(plan.is_empty());
699 // An assignment alone is not content — it changes nothing until a
700 // mode has a lever in it.
701 plan.assign(Source::Ac, Mode::Balanced);
702 assert!(plan.is_empty());
703 }
704
705 #[test]
706 fn assignment_is_per_source_and_two_states_may_share_a_mode() {
707 let mut plan = PowerPlan::default();
708 plan.put(Mode::Balanced, Lever::Epp, Some("balance_power")).unwrap();
709 plan.assign(Source::Ac, Mode::Balanced);
710 plan.assign(Source::Battery, Mode::Balanced);
711 assert_eq!(plan.assigned(Source::Ac), Mode::Balanced);
712 assert_eq!(plan.assigned(Source::Battery), Mode::Balanced);
713 assert_eq!(PowerPlan::parse(&plan.to_kdl()).unwrap(), plan);
714 // Both states named, so nothing was left to a default.
715 assert!(plan.to_kdl().contains("ac \"balanced\""));
716 }
717
718 #[test]
719 fn value_guard_shapes() {
720 assert!(Lever::Profile.value_ok("balance_power"));
721 assert!(Lever::Profile.value_ok("low-power"));
722 assert!(!Lever::Profile.value_ok(""));
723 assert!(!Lever::Profile.value_ok("a b"));
724 assert!(!Lever::Profile.value_ok("x;reboot"));
725 assert!(Lever::Turbo.value_ok("off"));
726 assert!(!Lever::Turbo.value_ok("0"));
727 assert!(Lever::AudioIdleSecs.value_ok("10"));
728 assert!(!Lever::AudioIdleSecs.value_ok("-1"));
729 assert!(!Lever::AudioIdleSecs.value_ok("ten"));
730 }
731
732 #[test]
733 fn source_follows_the_mains_supply() {
734 // Battery discharging, USB-C sources idle, AC offline → battery.
735 let unplugged = [("Battery", false), ("USB", false), ("Mains", false)];
736 assert_eq!(source_from_supplies(unplugged), Source::Battery);
737 let plugged = [("Battery", false), ("Mains", true)];
738 assert_eq!(source_from_supplies(plugged), Source::Ac);
739 // A desktop with no Mains device has nothing to unplug.
740 assert_eq!(source_from_supplies([("Battery", false)]), Source::Ac);
741 assert_eq!(source_from_supplies([]), Source::Ac);
742 }
743
744 #[test]
745 fn the_usage_probe_tells_a_mode_helper_from_a_pre_modes_one() {
746 // What this binary prints today.
747 assert!(usage_speaks_modes(
748 "usage: cce-power-apply apply [ac|battery]\n cce-power-apply apply-mode <mode>\n"
749 ));
750 // What the pre-modes one printed — the copy that silently rejects
751 // every pick the page makes.
752 assert!(!usage_speaks_modes(
753 "usage: cce-power-apply apply [ac|battery]\n cce-power-apply set <ac|battery> <lever> <value|unset>\n cce-power-apply show\n"
754 ));
755 // A helper that cannot be run at all is not a helper that speaks.
756 assert!(!helper_speaks_modes(Path::new("/nonexistent/cce-power-apply")));
757 }
758
759 #[test]
760 fn keys_round_trip() {
761 for l in Lever::ALL {
762 assert_eq!(Lever::parse(l.key()), Some(l));
763 }
764 for s in Source::ALL {
765 assert_eq!(Source::parse(s.key()), Some(s));
766 }
767 for m in Mode::ALL {
768 assert_eq!(Mode::parse(m.key()), Some(m));
769 }
770 assert_eq!(Lever::parse("brightness"), None);
771 assert_eq!(Mode::parse("ac"), None);
772 }
773
774 #[test]
775 fn save_and_load_through_a_temp_dir() {
776 let dir = std::env::temp_dir().join(format!("cce-power-plan-test-{}", std::process::id()));
777 let path = dir.join("nested").join("power.kdl");
778 // Missing file is an empty plan, not an error.
779 assert_eq!(PowerPlan::load_from(&path).unwrap(), PowerPlan::default());
780 let mut plan = PowerPlan::default();
781 plan.put(Mode::PowerSaver, Lever::Aspm, Some("powersave")).unwrap();
782 plan.assign(Source::Ac, Mode::Balanced);
783 plan.assign(Source::Battery, Mode::PowerSaver);
784 plan.save_to(&path).unwrap();
785 assert_eq!(PowerPlan::load_from(&path).unwrap(), plan);
786 // Garbage on disk is reported, not silently emptied.
787 std::fs::write(&path, "mode \"balanced\" {\n profile \n").unwrap();
788 assert!(PowerPlan::load_from(&path).is_err());
789 let _ = std::fs::remove_dir_all(&dir);
790 }
791 }