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

commita9bfa91293e12da6b9d089b206b3dc5f189b4157
parentcd1cba8776
authorLucas Galante <[email protected]>
date2026-08-21 18:45
feat: Power page — battery facts and the host's battery-life levers

Fifteenth page, slotted between Packages and Processes. It reads the battery (charge, status, live W draw, health as energy_full/design) and exposes every power lever this hardware actually has, each as a dropdown that only renders when its interface exists: platform_profile (low-power/balanced/performance), per-cpu energy_performance_preference, charge_control_end_threshold (100/80/60 — capping at 80% is the classic longevity setting; an off-list current value gets its own row rather than a wrong match), and intel_pstate turbo. Choices are discovered from the _choices/_available files, not hardcoded, so a firmware with different profiles just works.

Writes go through pkexec sh -c, the app's standard privileged path (bluetooth/storage/packages precedent), spawned detached so the polkit prompt never blocks the UI. The dropdowns are optimistic and the 5s watcher re-reads sysfs truth — a dismissed prompt reverts the selection with no extra error channel. Tokens travel into the shell line only through a strict [a-z0-9_-] guard and the threshold through a 1..=100 parse, so the write path is safe by construction; EPP writes hit every cpu (a partial write splits the package across preferences).

Phase 6w contract: section_widgets mirrors both the load gate and the per-interface presence gates, so a host without a charge-limit knob never reports the widget it didn't paint. Open dropdowns are left alone on refresh (the default_apps rule) — verified live, a watcher tick updated the battery line under an open menu without yanking it.

Shadow-verified against real sysfs: facts line shows live draw, all four levers present with correct current values, the profile menu opens with the real choice list and highlighted current, zero unregistered-root drops. Writes not exercised in the shadow on purpose: its D-Bus session is the real one, so a selection would pop an auth prompt on the live screen.

Co-Authored-By: Claude <[email protected]>

 src/app.rs         |   6 +
 src/main.rs        |   9 +
 src/pages/mod.rs   |   6 +-
 src/pages/power.rs | 478 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/watchers.rs    |   5 +-
 5 files changed, 502 insertions(+), 2 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 1895302..955498d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2,6 +2,7 @@ use cce_ui::layout::RenderTarget;
 
 use crate::pages::audio;
 use crate::pages::bluetooth;
+use crate::pages::power;
 use crate::pages::default_apps;
 use crate::pages::network;
 use crate::pages::processes;
@@ -20,6 +21,7 @@ pub struct AppState {
     pub current_page: Page,
     pub audio: audio::AudioState,
     pub bluetooth: bluetooth::BluetoothState,
+    pub power: power::PowerState,
     pub browser: browser::BrowserState,
     pub default_apps: default_apps::DefaultAppsState,
     pub network: network::NetworkState,
@@ -40,6 +42,7 @@ impl Default for AppState {
             current_page: Page::ALL[0],
             audio: audio::AudioState::default(),
             bluetooth: bluetooth::BluetoothState::default(),
+            power: power::PowerState::default(),
             browser: browser::BrowserState::default(),
             default_apps: default_apps::DefaultAppsState::default(),
             network: network::NetworkState::default(),
@@ -62,6 +65,7 @@ impl AppState {
             Page::Accounts => &self.accounts,
             Page::Audio => &self.audio,
             Page::Bluetooth => &self.bluetooth,
+            Page::Power => &self.power,
             Page::Browser => &self.browser,
             Page::DefaultApps => &self.default_apps,
             Page::Packages => &self.packages,
@@ -81,6 +85,7 @@ impl AppState {
             Page::Accounts => &mut self.accounts,
             Page::Audio => &mut self.audio,
             Page::Bluetooth => &mut self.bluetooth,
+            Page::Power => &mut self.power,
             Page::Browser => &mut self.browser,
             Page::DefaultApps => &mut self.default_apps,
             Page::Packages => &mut self.packages,
@@ -112,6 +117,7 @@ pub enum AppAction {
     DefaultApps(default_apps::DefaultAppsMessage),
     Network(network::NetworkMessage),
     Bluetooth(bluetooth::BluetoothMessage),
+    Power(power::PowerMessage),
     Processes(processes::ProcessesMessage),
     Services(services::ServicesMessage),
     SystemInfo(system_info::SystemMessage),
diff --git a/src/main.rs b/src/main.rs
index 8cb56be..a5cf8d3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -59,6 +59,7 @@ struct SystemInterface {
     rx_audio: std::sync::mpsc::Receiver<pages::audio::AudioState>,
     rx_network: std::sync::mpsc::Receiver<pages::network::NetworkState>,
     rx_bluetooth: std::sync::mpsc::Receiver<pages::bluetooth::BluetoothState>,
+    rx_power: std::sync::mpsc::Receiver<pages::power::PowerFacts>,
     pub rx_processes: std::sync::mpsc::Receiver<pages::processes::ProcessesState>,
     rx_system: std::sync::mpsc::Receiver<pages::system_info::SystemInfo>,
     rx_storage: std::sync::mpsc::Receiver<pages::storage::StorageInfo>,
@@ -171,6 +172,7 @@ impl cce_ui::engine::Application for SystemInterface {
             rx_audio: watchers.rx_audio,
             rx_network: watchers.rx_network,
             rx_bluetooth: watchers.rx_bluetooth,
+            rx_power: watchers.rx_power,
             rx_processes: watchers.rx_processes,
             rx_default_apps: watchers.rx_default_apps,
             rx_timers: watchers.rx_timers,
@@ -560,6 +562,12 @@ impl SystemInterface {
                 self.needs_rebuild = true;
             }
         }
+        while let Ok(s) = self.rx_power.try_recv() {
+            pages::power::update(&mut self.app.power, pages::power::PowerMessage::Refreshed(s));
+            if self.app.current_page == Page::Power {
+                self.needs_rebuild = true;
+            }
+        }
         while let Ok(s) = self.rx_system.try_recv() {
             system_info::update(&mut self.app.system_info, system_info::SystemMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::System {
@@ -645,6 +653,7 @@ impl SystemInterface {
             AppAction::Audio(m) => audio::update(&mut self.app.audio, m.clone()),
             AppAction::Network(m) => network::update(&mut self.app.network, m.clone()),
             AppAction::Bluetooth(m) => pages::bluetooth::update(&mut self.app.bluetooth, m.clone()),
+            AppAction::Power(m) => pages::power::update(&mut self.app.power, m.clone()),
             AppAction::Timers(m) => pages::timers::update(&mut self.app.timers, m.clone()),
             AppAction::SystemInfo(m) => system_info::update(&mut self.app.system_info, m.clone(), &mut self.ui_context),
             AppAction::Processes(m) => processes::update(&mut self.app.processes, m.clone()),
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 3698f86..d633337 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -12,6 +12,7 @@ pub mod fonts;
 pub mod accounts;
 pub mod packages;
 pub mod notifications;
+pub mod power;
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum Page {
@@ -22,6 +23,7 @@ pub enum Page {
     DefaultApps,
     Network,
     Notifications,
+    Power,
     Storage,
     System,
     Timers,
@@ -32,7 +34,7 @@ pub enum Page {
 }
 
 impl Page {
-    pub const ALL: [Page; 14] = [
+    pub const ALL: [Page; 15] = [
         Page::Accounts,
         Page::Audio,
         Page::Bluetooth,
@@ -42,6 +44,7 @@ impl Page {
         Page::Network,
         Page::Notifications,
         Page::Packages,
+        Page::Power,
         Page::Processes,
         Page::Services,
         Page::Storage,
@@ -58,6 +61,7 @@ impl Page {
             Page::DefaultApps => "Default Apps",
             Page::Network => "Network",
             Page::Notifications => "Notifications",
+            Page::Power => "Power",
             Page::Storage => "Storage",
             Page::System => "System",
             Page::Timers => "Timers",
diff --git a/src/pages/power.rs b/src/pages/power.rs
new file mode 100644
index 0000000..76b1ee3
--- /dev/null
+++ b/src/pages/power.rs
@@ -0,0 +1,478 @@
+//! Power: battery facts plus the host's real battery-life levers, all sysfs.
+//!
+//! Every control is discovered from the interfaces this machine actually
+//! 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/devices/system/cpu/intel_pstate/no_turbo` — turbo boost
+//!
+//! Reads are plain file reads; writes go through `pkexec sh -c` like every
+//! other privileged action in this app (bluetooth, storage, packages). The
+//! UI is optimistic and the 5s watcher re-reads the truth, so a dismissed
+//! auth prompt reverts the dropdown — honest, with no extra error channel.
+
+use crate::app::{AppAction, PageContent};
+use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
+use cce_ui::widget::{Dropdown, WidgetHost};
+
+const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
+const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+const GOOD: [f32; 4] = [0.56, 0.83, 0.56, 1.0];
+const WARN: [f32; 4] = [0.90, 0.75, 0.45, 1.0];
+
+/// Everything read from sysfs in one pass. Plain data; widget state lives in
+/// [`PowerState`].
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct PowerFacts {
+    pub battery_present: bool,
+    pub status: String,
+    pub capacity_pct: u32,
+    /// energy_full / energy_full_design — how much the cells have aged.
+    pub health_pct: Option<u32>,
+    /// Instantaneous draw in watts, meaningful while discharging.
+    pub power_w: Option<f32>,
+    pub ac_online: Option<bool>,
+    /// platform_profile choices in sysfs spelling, and the active one.
+    pub profiles: Vec<String>,
+    pub profile: String,
+    /// EPP choices in sysfs spelling, and cpu0's current value.
+    pub epps: Vec<String>,
+    pub epp: String,
+    /// charge_control_end_threshold, when the battery has one.
+    pub charge_limit: Option<u32>,
+    /// intel_pstate no_turbo, inverted to "turbo enabled".
+    pub turbo: Option<bool>,
+}
+
+#[derive(Debug, Clone)]
+pub struct PowerState {
+    pub loaded: bool,
+    pub facts: PowerFacts,
+    pub dd_profile: cce_ui::widget::Adapted<Dropdown>,
+    pub dd_epp: cce_ui::widget::Adapted<Dropdown>,
+    pub dd_limit: cce_ui::widget::Adapted<Dropdown>,
+    pub dd_turbo: cce_ui::widget::Adapted<Dropdown>,
+    /// Sysfs value per charge-limit dropdown row (options are display text).
+    pub limit_values: Vec<u32>,
+}
+
+impl Default for PowerState {
+    fn default() -> Self {
+        Self {
+            loaded: false,
+            facts: PowerFacts::default(),
+            dd_profile: Dropdown::new(vec!["—".to_string()], 0).with_label("Power Profile"),
+            dd_epp: Dropdown::new(vec!["—".to_string()], 0).with_label("CPU Energy Preference"),
+            dd_limit: Dropdown::new(vec!["—".to_string()], 0).with_label("Battery Charge Limit"),
+            dd_turbo: Dropdown::new(vec!["Enabled".to_string(), "Disabled".to_string()], 0)
+                .with_label("CPU Turbo Boost"),
+            limit_values: Vec::new(),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub enum PowerMessage {
+    Refreshed(PowerFacts),
+    /// Dropdown picks, by option index.
+    SetProfile(usize),
+    SetEpp(usize),
+    SetLimit(usize),
+    SetTurbo(usize),
+}
+
+/// Sysfs tokens travel into a `pkexec sh -c` line, so only the shapes sysfs
+/// itself produces are allowed through — anything else is dropped, not
+/// escaped. (The lists come from sysfs reads, but the guard makes the write
+/// path safe by construction rather than by data-flow argument.)
+fn sysfs_token_ok(s: &str) -> bool {
+    !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
+}
+
+/// Root sysfs write via pkexec, the app's standard privileged-action 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 write_sysfs(cmd: String) {
+    let _ = std::process::Command::new("pkexec")
+        .args(["sh", "-c", &cmd])
+        .spawn();
+}
+
+/// Display form of a sysfs token: `balance_power` → "Balance Power".
+fn pretty(token: &str) -> String {
+    token
+        .split(['_', '-'])
+        .filter(|w| !w.is_empty())
+        .map(|w| {
+            let mut cs = w.chars();
+            match cs.next() {
+                Some(f) => f.to_uppercase().collect::<String>() + cs.as_str(),
+                None => String::new(),
+            }
+        })
+        .collect::<Vec<_>>()
+        .join(" ")
+}
+
+fn read_trim(path: &str) -> Option<String> {
+    std::fs::read_to_string(path).ok().map(|s| s.trim().to_string())
+}
+
+/// The first battery under /sys/class/power_supply, by convention BAT*.
+fn battery_dir() -> Option<std::path::PathBuf> {
+    let rd = std::fs::read_dir("/sys/class/power_supply").ok()?;
+    let mut bats: Vec<_> = 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")))
+        .collect();
+    bats.sort();
+    bats.into_iter().next()
+}
+
+pub async fn fetch_power_state() -> PowerFacts {
+    let mut f = PowerFacts::default();
+
+    if let Some(bat) = battery_dir() {
+        let b = |name: &str| read_trim(&format!("{}/{}", bat.display(), name));
+        f.battery_present = true;
+        f.status = b("status").unwrap_or_else(|| "Unknown".to_string());
+        f.capacity_pct = b("capacity").and_then(|s| s.parse().ok()).unwrap_or(0);
+        // Health from energy_* or charge_* — batteries report one family.
+        let full: Option<f64> = b("energy_full").or_else(|| b("charge_full")).and_then(|s| s.parse().ok());
+        let design: Option<f64> =
+            b("energy_full_design").or_else(|| b("charge_full_design")).and_then(|s| s.parse().ok());
+        if let (Some(full), Some(design)) = (full, design) {
+            if design > 0.0 {
+                f.health_pct = Some(((full / design) * 100.0).round() as u32);
+            }
+        }
+        f.power_w = b("power_now").and_then(|s| s.parse::<f64>().ok()).map(|uw| (uw / 1e6) as f32);
+        f.charge_limit = b("charge_control_end_threshold").and_then(|s| s.parse().ok());
+    }
+    f.ac_online = read_trim("/sys/class/power_supply/AC/online").map(|s| s == "1");
+
+    if let Some(choices) = read_trim("/sys/firmware/acpi/platform_profile_choices") {
+        f.profiles = choices.split_whitespace().map(String::from).collect();
+        f.profile = read_trim("/sys/firmware/acpi/platform_profile").unwrap_or_default();
+    }
+
+    if let Some(prefs) =
+        read_trim("/sys/devices/system/cpu/cpu0/cpufreq/energy_performance_available_preferences")
+    {
+        f.epps = prefs.split_whitespace().map(String::from).collect();
+        f.epp = read_trim("/sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference")
+            .unwrap_or_default();
+    }
+
+    f.turbo = read_trim("/sys/devices/system/cpu/intel_pstate/no_turbo").map(|s| s == "0");
+
+    f
+}
+
+/// Rebuild every dropdown's options/selection from fresh facts. Skipped per
+/// dropdown while it is open (the default_apps rule: never yank an open menu
+/// out from under the pointer — the next refresh normalizes it).
+fn rebuild_options(state: &mut PowerState) {
+    let f = &state.facts;
+    if !state.dd_profile.open {
+        state.dd_profile.options = f.profiles.iter().map(|p| pretty(p)).collect();
+        state.dd_profile.selected =
+            f.profiles.iter().position(|p| *p == f.profile).unwrap_or(0);
+    }
+    if !state.dd_epp.open {
+        state.dd_epp.options = f.epps.iter().map(|p| pretty(p)).collect();
+        state.dd_epp.selected = f.epps.iter().position(|p| *p == f.epp).unwrap_or(0);
+    }
+    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);
+        state.limit_values = values;
+    }
+    if !state.dd_turbo.open {
+        state.dd_turbo.selected = if f.turbo.unwrap_or(true) { 0 } else { 1 };
+    }
+}
+
+pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut dyn LayoutStrategy, ctx: &mut cce_ui::context::UiContext) -> PageContent {
+    let mut final_pc = PageContent::new();
+    let sec_w = 320.0f32;
+    let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
+
+    builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
+        let sec_w = sec.cw;
+        if !state.loaded {
+            sec.text("Reading power interfaces...", 12.0, 0.0, 12.0, TEXT_DIM);
+            return;
+        }
+        let f = &state.facts;
+
+        // ── Battery facts ──
+        if f.battery_present {
+            let status_color = match f.status.as_str() {
+                "Charging" | "Full" => GOOD,
+                "Discharging" => WARN,
+                _ => TEXT_FG,
+            };
+            let mut line = format!("{}%  ·  {}", f.capacity_pct, f.status);
+            if let (Some(w), "Discharging") = (f.power_w, f.status.as_str()) {
+                line.push_str(&format!("  ·  {:.1} W draw", w));
+            }
+            if let Some(true) = f.ac_online {
+                line.push_str("  ·  on AC");
+            }
+            sec.text("Battery", 13.0, 0.0, 13.0, TEXT_FG);
+            sec.text(&line, 12.0, 0.0, 12.0, status_color);
+            if let Some(h) = f.health_pct {
+                sec.text(
+                    &format!("Health: {}% of design capacity", h),
+                    12.0,
+                    0.0,
+                    12.0,
+                    if h >= 80 { TEXT_DIM } else { WARN },
+                );
+            }
+        } else {
+            sec.text("No battery detected", 12.0, 0.0, 12.0, TEXT_DIM);
+        }
+        sec.spacing(10.0);
+
+        // ── Levers, one dropdown per interface the host exposes ──
+        let mut stack = sec.vstack(8.0);
+        if !state.facts.profiles.is_empty() {
+            state.dd_profile.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
+            stack.add_widget(&mut state.dd_profile, sec_w - 28.0, 44.0, ctx);
+        }
+        if !state.facts.epps.is_empty() {
+            state.dd_epp.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
+            stack.add_widget(&mut state.dd_epp, sec_w - 28.0, 44.0, ctx);
+        }
+        if state.facts.charge_limit.is_some() {
+            state.dd_limit.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
+            stack.add_widget(&mut state.dd_limit, sec_w - 28.0, 44.0, ctx);
+        }
+        if state.facts.turbo.is_some() {
+            state.dd_turbo.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
+            stack.add_widget(&mut state.dd_turbo, sec_w - 28.0, 44.0, ctx);
+        }
+    });
+
+    final_pc
+}
+
+pub fn update(state: &mut PowerState, msg: PowerMessage) {
+    match msg {
+        PowerMessage::Refreshed(facts) => {
+            state.loaded = true;
+            if state.facts != facts {
+                state.facts = facts;
+                rebuild_options(state);
+            }
+        }
+        PowerMessage::SetProfile(idx) => {
+            if let Some(p) = state.facts.profiles.get(idx) {
+                if sysfs_token_ok(p) {
+                    write_sysfs(format!("echo {} > /sys/firmware/acpi/platform_profile", p));
+                    state.facts.profile = p.clone();
+                }
+            }
+        }
+        PowerMessage::SetEpp(idx) => {
+            if let Some(p) = state.facts.epps.get(idx) {
+                if sysfs_token_ok(p) {
+                    // Every core: EPP is per-cpu and a partial write would
+                    // leave the package split across preferences.
+                    write_sysfs(format!(
+                        "for f in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do echo {} > \"$f\"; done",
+                        p
+                    ));
+                    state.facts.epp = p.clone();
+                }
+            }
+        }
+        PowerMessage::SetLimit(idx) => {
+            if let Some(v) = state.limit_values.get(idx).copied() {
+                if (1..=100).contains(&v) {
+                    write_sysfs(format!(
+                        "for f in /sys/class/power_supply/BAT*/charge_control_end_threshold; do echo {} > \"$f\"; done",
+                        v
+                    ));
+                    state.facts.charge_limit = Some(v);
+                }
+            }
+        }
+        PowerMessage::SetTurbo(idx) => {
+            let no_turbo = if idx == 1 { 1 } else { 0 };
+            write_sysfs(format!(
+                "echo {} > /sys/devices/system/cpu/intel_pstate/no_turbo",
+                no_turbo
+            ));
+            state.facts.turbo = Some(no_turbo == 0);
+        }
+    }
+}
+
+impl crate::pages::AppPage for PowerState {
+    // Sections: [Power] — ids mirror the view's load gate AND its
+    // per-interface presence gates (the d13a901 lesson: never report a
+    // widget the view didn't paint).
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
+        if !self.loaded {
+            return vec![Vec::new()];
+        }
+        let mut ids = Vec::new();
+        if !self.facts.profiles.is_empty() {
+            ids.push(self.dd_profile.id());
+        }
+        if !self.facts.epps.is_empty() {
+            ids.push(self.dd_epp.id());
+        }
+        if self.facts.charge_limit.is_some() {
+            ids.push(self.dd_limit.id());
+        }
+        if self.facts.turbo.is_some() {
+            ids.push(self.dd_turbo.id());
+        }
+        vec![ids]
+    }
+
+    fn view(
+        &mut self,
+        cx: f32,
+        cy: f32,
+        cw: f32,
+        ch: f32,
+        root_focused: bool,
+        sec_focused: &[bool],
+        layout: &mut dyn LayoutStrategy,
+        ctx: &mut cce_ui::context::UiContext,
+    ) -> crate::app::PageContent {
+        view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
+    }
+
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
+        if self.dd_profile.take_change() {
+            actions.push(AppAction::Power(PowerMessage::SetProfile(self.dd_profile.selected)));
+        }
+        if self.dd_epp.take_change() {
+            actions.push(AppAction::Power(PowerMessage::SetEpp(self.dd_epp.selected)));
+        }
+        if self.dd_limit.take_change() {
+            actions.push(AppAction::Power(PowerMessage::SetLimit(self.dd_limit.selected)));
+        }
+        if self.dd_turbo.take_change() {
+            actions.push(AppAction::Power(PowerMessage::SetTurbo(self.dd_turbo.selected)));
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::pages::AppPage;
+
+    fn facts() -> PowerFacts {
+        PowerFacts {
+            battery_present: true,
+            status: "Discharging".to_string(),
+            capacity_pct: 92,
+            health_pct: Some(83),
+            power_w: Some(7.2),
+            ac_online: Some(false),
+            profiles: vec!["low-power".into(), "balanced".into(), "performance".into()],
+            profile: "balanced".to_string(),
+            epps: vec!["default".into(), "performance".into(), "balance_power".into(), "power".into()],
+            epp: "balance_power".to_string(),
+            charge_limit: Some(80),
+            turbo: Some(true),
+        }
+    }
+
+    #[test]
+    fn options_rebuild_maps_current_values() {
+        let mut st = PowerState::default();
+        st.loaded = true;
+        st.facts = facts();
+        rebuild_options(&mut st);
+        assert_eq!(st.dd_profile.options, ["Low Power", "Balanced", "Performance"]);
+        assert_eq!(st.dd_profile.selected, 1);
+        assert_eq!(st.dd_epp.selected, 2); // balance_power
+        assert_eq!(st.dd_limit.selected, 1); // 80
+        assert_eq!(st.limit_values, [100, 80, 60]);
+        assert_eq!(st.dd_turbo.selected, 0); // enabled
+
+        // An off-list threshold gets its own row instead of a wrong match.
+        st.facts.charge_limit = Some(75);
+        rebuild_options(&mut st);
+        assert_eq!(st.limit_values, [100, 80, 60, 75]);
+        assert_eq!(st.dd_limit.selected, 3);
+        assert!(st.dd_limit.options[3].contains("75%"));
+    }
+
+    #[test]
+    fn open_dropdown_is_left_alone_on_refresh() {
+        let mut st = PowerState::default();
+        st.loaded = true;
+        st.facts = facts();
+        rebuild_options(&mut st);
+        st.dd_profile.open = true;
+        st.dd_profile.selected = 2;
+        let mut newer = facts();
+        newer.profile = "low-power".to_string();
+        st.facts = newer;
+        rebuild_options(&mut st);
+        // Open menu untouched; the others refreshed.
+        assert_eq!(st.dd_profile.selected, 2);
+    }
+
+    #[test]
+    fn sysfs_token_guard() {
+        assert!(sysfs_token_ok("balance_power"));
+        assert!(sysfs_token_ok("low-power"));
+        assert!(!sysfs_token_ok(""));
+        assert!(!sysfs_token_ok("a b"));
+        assert!(!sysfs_token_ok("x;reboot"));
+        assert!(!sysfs_token_ok("$(rm)"));
+    }
+
+    #[test]
+    fn pretty_prints_tokens() {
+        assert_eq!(pretty("low-power"), "Low Power");
+        assert_eq!(pretty("balance_performance"), "Balance Performance");
+        assert_eq!(pretty("default"), "Default");
+    }
+
+    #[test]
+    fn section_widgets_mirror_presence_gates() {
+        let mut st = PowerState::default();
+        // Not loaded: nothing reported (the view paints only the loading line).
+        assert_eq!(st.section_widgets(), vec![Vec::new()]);
+        st.loaded = true;
+        st.facts = facts();
+        // All four interfaces present: all four dropdowns reported.
+        assert_eq!(st.section_widgets()[0].len(), 4);
+        // A host without a charge-limit knob or turbo file reports fewer.
+        st.facts.charge_limit = None;
+        st.facts.turbo = None;
+        assert_eq!(st.section_widgets()[0].len(), 2);
+    }
+}
diff --git a/src/watchers.rs b/src/watchers.rs
index 5c8c276..0299fc2 100644
--- a/src/watchers.rs
+++ b/src/watchers.rs
@@ -1,12 +1,13 @@
 use std::sync::Arc;
 use std::sync::atomic::{AtomicU8, Ordering};
 use std::sync::mpsc::{channel, Receiver, Sender};
-use crate::pages::{Page, audio, bluetooth, browser, default_apps, network, fonts, processes, services, system_info, storage, packages, accounts, notifications, timers};
+use crate::pages::{Page, audio, bluetooth, browser, default_apps, network, fonts, processes, services, system_info, storage, packages, accounts, notifications, timers, power};
 
 pub struct Watchers {
     pub rx_audio: Receiver<audio::AudioState>,
     pub rx_network: Receiver<network::NetworkState>,
     pub rx_bluetooth: Receiver<bluetooth::BluetoothState>,
+    pub rx_power: Receiver<power::PowerFacts>,
     pub rx_processes: Receiver<processes::ProcessesState>,
     pub rx_system: Receiver<system_info::SystemInfo>,
     pub rx_storage: Receiver<storage::StorageInfo>,
@@ -64,6 +65,7 @@ pub fn spawn_all(
     let rx_audio = spawn_bg_active(current_page_shared.clone(), Page::Audio.index() as u8, 3, || audio::fetch_audio_state());
     let rx_network = spawn_bg_active(current_page_shared.clone(), Page::Network.index() as u8, 5, || network::fetch_network_state());
     let rx_bluetooth = spawn_bg_active(current_page_shared.clone(), Page::Bluetooth.index() as u8, 5, || bluetooth::fetch_bluetooth_page_state());
+    let rx_power = spawn_bg_active(current_page_shared.clone(), Page::Power.index() as u8, 5, || power::fetch_power_state());
 
     let rx_system = spawn_bg_active(current_page_shared.clone(), Page::System.index() as u8, 5, || system_info::fetch_system_state());
     let rx_processes = spawn_bg_active(current_page_shared.clone(), Page::Processes.index() as u8, 3, || processes::fetch_processes_state());
@@ -138,6 +140,7 @@ pub fn spawn_all(
             rx_audio,
             rx_network,
             rx_bluetooth,
+            rx_power,
             rx_processes,
             rx_system,
             rx_storage,