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

commitf2c7f911c79677e66b0204345a497554843c350e
parent6f4eacf365
authorLucas Galante <[email protected]>
date2026-08-23 11:23
refactor: one battery display, on the Power page

The same pack was rendered twice — System's Battery section and Power's
battery block — in two formats, from two different sources, each with fields
the other lacked. System had the Wh figures, time-remaining and the pack's
vendor/model; Power had health as a share of design capacity.

Power keeps the display and gains what System had. It stays all-sysfs: the Wh
figures come from energy_now/energy_full, the pack id from manufacturer and
model_name, and time-remaining is computed as energy over draw rather than
taking a D-Bus dependency for one line — sysfs has no time field, and
energy/power is exactly what UPower was doing. The estimate follows the
charge direction: discharging counts what is left, charging counts the gap to
full, and anything else (idle, full, no draw) has no meaningful answer and
reports none rather than a wrong number.

System drops the section and, with it, the last reason this page talked to
UPower at all: both zbus proxy traits, fetch_upower, BatteryInfo, five battery
labels, format_duration and three colour constants go. That page is now what
it reads like — hostname/kernel/uptime, power actions, CPU and GPU, all plain
reads. Four sections.

Shadow-verified against the real battery: Power shows
"34% · Discharging · 48.8 W draw / 25.3 / 74.9 Wh · 31m remaining /
Health: 83% of design capacity / SMP 5B11M90061" — and 25.3 Wh at 48.8 W is
31 minutes, so the estimate is right rather than merely present. System renders
its four sections with no battery. Zero unregistered-root drops on either.

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

 src/pages/power.rs       |  90 +++++++++++++++++++++++
 src/pages/system_info.rs | 182 -----------------------------------------------
 2 files changed, 90 insertions(+), 182 deletions(-)

diff --git a/src/pages/power.rs b/src/pages/power.rs
index 0f1956e..df07646 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -40,6 +40,13 @@ pub struct PowerFacts {
     /// EPP choices in sysfs spelling, and cpu0's current value.
     pub epps: Vec<String>,
     pub epp: String,
+    /// energy_now / energy_full in µWh — the Wh readout, and what the
+    /// time-remaining estimate divides by power_now.
+    pub energy_now_uwh: Option<f64>,
+    pub energy_full_uwh: Option<f64>,
+    /// manufacturer + model_name, the pack's own identification.
+    pub vendor: String,
+    pub model: String,
     /// charge_control_end_threshold, when the battery has one.
     pub charge_limit: Option<u32>,
     /// intel_pstate no_turbo, inverted to "turbo enabled".
@@ -138,6 +145,30 @@ fn pretty(token: &str) -> String {
         .join(" ")
 }
 
+/// Seconds of runtime left while discharging, or to full while charging, from
+/// energy over draw. sysfs has no time field — UPower computes exactly this,
+/// and reading it here is what lets the Power page stay all-sysfs rather than
+/// taking a D-Bus dependency for one line.
+fn battery_seconds(f: &PowerFacts) -> Option<i64> {
+    let now = f.energy_now_uwh?;
+    let full = f.energy_full_uwh?;
+    let draw = f.power_w? as f64 * 1e6;
+    if draw <= 0.0 {
+        return None;
+    }
+    let remaining_uwh = match f.status.as_str() {
+        "Discharging" => now,
+        "Charging" => (full - now).max(0.0),
+        _ => return None,
+    };
+    Some(((remaining_uwh / draw) * 3600.0).round() as i64)
+}
+
+fn humanize_secs(secs: i64) -> String {
+    let (h, m) = (secs / 3600, (secs % 3600) / 60);
+    if h > 0 { format!("{}h {}m", h, m) } else { format!("{}m", m) }
+}
+
 fn read_trim(path: &str) -> Option<String> {
     std::fs::read_to_string(path).ok().map(|s| s.trim().to_string())
 }
@@ -172,6 +203,10 @@ pub async fn fetch_power_state() -> PowerFacts {
             }
         }
         f.power_w = b("power_now").and_then(|s| s.parse::<f64>().ok()).map(|uw| (uw / 1e6) as f32);
+        f.energy_now_uwh = b("energy_now").and_then(|s| s.parse().ok());
+        f.energy_full_uwh = full;
+        f.vendor = b("manufacturer").unwrap_or_default();
+        f.model = b("model_name").unwrap_or_default();
         f.charge_limit = b("charge_control_end_threshold").and_then(|s| s.parse().ok());
     }
     f.ac_online = read_trim("/sys/class/power_supply/AC/online").map(|s| s == "1");
@@ -317,6 +352,24 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
             }
             sec.text("Battery", 13.0, 0.0, 13.0, TEXT_FG);
             sec.text(&line, 12.0, 0.0, 12.0, status_color);
+
+            // Folded in from the System page's battery section, which showed
+            // the same pack in a second place until 2026-08-23.
+            let mut detail = String::new();
+            if let (Some(now), Some(full)) = (f.energy_now_uwh, f.energy_full_uwh) {
+                detail.push_str(&format!("{:.1} / {:.1} Wh", now / 1e6, full / 1e6));
+            }
+            if let Some(secs) = battery_seconds(f) {
+                if !detail.is_empty() {
+                    detail.push_str("  ·  ");
+                }
+                let what = if f.status == "Charging" { "to full" } else { "remaining" };
+                detail.push_str(&format!("{} {}", humanize_secs(secs), what));
+            }
+            if !detail.is_empty() {
+                sec.text(&detail, 12.0, 0.0, 12.0, TEXT_DIM);
+            }
+
             if let Some(h) = f.health_pct {
                 sec.text(
                     &format!("Health: {}% of design capacity", h),
@@ -326,6 +379,10 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
                     if h >= 80 { TEXT_DIM } else { WARN },
                 );
             }
+            let pack = format!("{} {}", f.vendor, f.model);
+            if !pack.trim().is_empty() {
+                sec.text(pack.trim(), 11.0, 0.0, 11.0, TEXT_DIM);
+            }
         } else {
             sec.text("No battery detected", 12.0, 0.0, 12.0, TEXT_DIM);
         }
@@ -518,6 +575,10 @@ mod tests {
             health_pct: Some(83),
             power_w: Some(7.2),
             ac_online: Some(false),
+            energy_now_uwh: Some(44_900_000.0),
+            energy_full_uwh: Some(74_900_000.0),
+            vendor: "SMP".to_string(),
+            model: "5B11M90061".to_string(),
             profiles: vec!["low-power".into(), "balanced".into(), "performance".into()],
             profile: "balanced".to_string(),
             epps: vec!["default".into(), "performance".into(), "balance_power".into(), "power".into()],
@@ -579,6 +640,35 @@ mod tests {
         assert!(!sysfs_token_ok("$(rm)"));
     }
 
+    #[test]
+    fn battery_time_is_energy_over_draw_and_follows_direction() {
+        // sysfs has no time field — this replaces what UPower used to compute
+        // for the System page's battery section.
+        let mut f = facts();
+        f.status = "Discharging".to_string();
+        f.power_w = Some(50.0);
+        // 44.9 Wh left at 50 W ≈ 53.9 min.
+        assert_eq!(humanize_secs(battery_seconds(&f).unwrap()), "53m");
+
+        // Charging counts the GAP to full, not what is already in the pack.
+        f.status = "Charging".to_string();
+        // (74.9 - 44.9) = 30 Wh at 50 W = 36 min.
+        assert_eq!(humanize_secs(battery_seconds(&f).unwrap()), "36m");
+
+        // Idle on AC, or no draw at all, has no meaningful estimate.
+        f.status = "Full".to_string();
+        assert_eq!(battery_seconds(&f), None);
+        f.status = "Discharging".to_string();
+        f.power_w = Some(0.0);
+        assert_eq!(battery_seconds(&f), None);
+    }
+
+    #[test]
+    fn humanize_secs_splits_hours() {
+        assert_eq!(humanize_secs(54 * 60), "54m");
+        assert_eq!(humanize_secs(3 * 3600 + 7 * 60), "3h 7m");
+    }
+
     #[test]
     fn pretty_prints_tokens() {
         assert_eq!(pretty("low-power"), "Low Power");
diff --git a/src/pages/system_info.rs b/src/pages/system_info.rs
index ea91d89..5c719aa 100644
--- a/src/pages/system_info.rs
+++ b/src/pages/system_info.rs
@@ -2,18 +2,6 @@ use crate::app::{AppAction, PageContent, SectionContextExt};
 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy};
 use cce_ui::widget::{Label, WidgetHost, Button};
 
-#[derive(Debug, Clone, Default)]
-pub struct BatteryInfo {
-    pub percentage: f32,
-    pub state: String,
-    pub energy: f64,
-    pub energy_full: f64,
-    pub energy_rate: f64,
-    pub time_to_empty: i64,
-    pub time_to_full: i64,
-    pub vendor: String,
-    pub model: String,
-}
 
 #[derive(Debug, Clone)]
 pub struct NotificationsConfig {
@@ -40,8 +28,6 @@ pub struct SystemState {
     pub cpu_temp_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
 
     // Power-related fields
-    pub battery: BatteryInfo,
-    pub on_ac: bool,
 
     // Native layout tracking and widgets
     pub initialized: bool,
@@ -49,11 +35,6 @@ pub struct SystemState {
     pub hostname_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
     pub uptime_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
 
-    pub battery_label_pct: cce_ui::widget::Adapted<cce_ui::widget::Label>,
-    pub battery_label_state: cce_ui::widget::Adapted<cce_ui::widget::Label>,
-    pub battery_label_time: cce_ui::widget::Adapted<cce_ui::widget::Label>,
-    pub battery_label_details: cce_ui::widget::Adapted<cce_ui::widget::Label>,
-    pub battery_label_ac: cce_ui::widget::Adapted<cce_ui::widget::Label>,
 
 
     pub suspend_btn: cce_ui::widget::Adapted<cce_ui::widget::Button>,
@@ -93,19 +74,12 @@ impl Default for SystemState {
             cpu_usage_label: Label::new("CPU Usage"),
             cpu_temp_label: Label::new("CPU Temp"),
 
-            battery: BatteryInfo::default(),
-            on_ac: true,
 
             initialized: false,
             sender: None,
             hostname_label: Label::new(""),
             uptime_label: Label::new(""),
 
-            battery_label_pct: Label::new("").with_font_size(24.0).with_color([92, 143, 97]),
-            battery_label_state: Label::new("").with_font_size(12.0).with_color([135, 135, 153]),
-            battery_label_time: Label::new("").with_font_size(12.0).with_color([135, 135, 153]),
-            battery_label_details: Label::new("").with_font_size(11.0).with_color([135, 135, 153]),
-            battery_label_ac: Label::new("").with_font_size(14.0).with_color([212, 212, 212]),
 
 
             suspend_btn: Button::new(0.0, 0.0, 0.0, 32.0)
@@ -148,83 +122,9 @@ pub enum SystemMessage {
 
 }
 
-// ── zbus proxies ────────────────────────────────────────────────────
-
-#[zbus::proxy(
-    interface = "org.freedesktop.UPower.Device",
-    default_service = "org.freedesktop.UPower",
-    default_path = "/org/freedesktop/UPower/devices/battery_BAT0"
-)]
-trait UpowerBattery {
-    #[zbus(property)]
-    fn percentage(&self) -> zbus::Result<f64>;
-    #[zbus(property)]
-    fn state(&self) -> zbus::Result<u32>;
-    #[zbus(property)]
-    fn energy(&self) -> zbus::Result<f64>;
-    #[zbus(property)]
-    fn energy_full(&self) -> zbus::Result<f64>;
-    #[zbus(property)]
-    fn energy_rate(&self) -> zbus::Result<f64>;
-    #[zbus(property)]
-    fn time_to_empty(&self) -> zbus::Result<i64>;
-    #[zbus(property)]
-    fn time_to_full(&self) -> zbus::Result<i64>;
-    #[zbus(property)]
-    fn vendor(&self) -> zbus::Result<String>;
-    #[zbus(property)]
-    fn model(&self) -> zbus::Result<String>;
-}
-
-#[zbus::proxy(
-    interface = "org.freedesktop.UPower",
-    default_service = "org.freedesktop.UPower",
-    default_path = "/org/freedesktop/UPower"
-)]
-trait UpowerDaemon {
-    #[zbus(property, name = "OnBattery")]
-    fn on_battery(&self) -> zbus::Result<bool>;
-}
-
 // ── Helpers ─────────────────────────────────────────────────────────
 
-fn format_duration(secs: i64) -> String {
-    let h = secs / 3600;
-    let m = (secs % 3600) / 60;
-    if h > 0 { format!("{}h {}m", h, m) } else { format!("{}m", m) }
-}
 
-async fn fetch_upower() -> (BatteryInfo, bool) {
-    let conn = match zbus::Connection::system().await {
-        Ok(c) => c,
-        Err(_) => return (BatteryInfo::default(), true),
-    };
-
-    let battery = match UpowerBatteryProxy::new(&conn).await {
-        Ok(proxy) => BatteryInfo {
-            percentage: proxy.percentage().await.unwrap_or(0.0) as f32,
-            state: {
-                let s = proxy.state().await.unwrap_or(0);
-                match s { 1 => "charging", 2 => "discharging", 4 => "fully-charged", _ => "unknown" }.into()
-            },
-            energy: proxy.energy().await.unwrap_or(0.0),
-            energy_full: proxy.energy_full().await.unwrap_or(0.0),
-            energy_rate: proxy.energy_rate().await.unwrap_or(0.0),
-            time_to_empty: proxy.time_to_empty().await.unwrap_or(0),
-            time_to_full: proxy.time_to_full().await.unwrap_or(0),
-            vendor: proxy.vendor().await.unwrap_or_default(),
-            model: proxy.model().await.unwrap_or_default(),
-        },
-        Err(_) => BatteryInfo::default(),
-    };
-
-    let on_ac = match UpowerDaemonProxy::new(&conn).await {
-        Ok(proxy) => !proxy.on_battery().await.unwrap_or(false),
-        Err(_) => true,
-    };
-
-    (battery, on_ac)
-}
 
 fn read_cpu_temp() -> Option<f32> {
     if let Ok(entries) = std::fs::read_dir("/sys/class/hwmon") {
@@ -308,8 +208,6 @@ pub struct SystemInfo {
     pub cpu_usage: f32,
     pub gpus: Vec<String>,
     pub gpu_strings: Vec<String>,
-    pub battery: BatteryInfo,
-    pub on_ac: bool,
 }
 
 pub async fn fetch_system_state() -> SystemInfo {
@@ -401,7 +299,6 @@ pub async fn fetch_system_state() -> SystemInfo {
         format!("GPU  {}{}", gpu_name, temp_str)
     }).collect();
 
-    let (battery, on_ac) = fetch_upower().await;
 
     SystemInfo {
         hostname,
@@ -412,16 +309,11 @@ pub async fn fetch_system_state() -> SystemInfo {
         cpu_cores,
         gpus,
         gpu_strings,
-        battery,
-        on_ac,
     }
 }
 
 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 ACCENT: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
-const RED: [f32; 4] = [1.0, 0.33, 0.33, 1.0];
-const ORANGE: [f32; 4] = [1.0, 0.73, 0.20, 1.0];
 const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
 const DANGER_BG: [f32; 4] = [0.67, 0.20, 0.20, 1.0];
 const SAFE_BG: [f32; 4] = [0.20, 0.33, 0.22, 1.0];
@@ -508,45 +400,6 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
 
     // ── 6. GPU Power Section ──
 
-    // ── 5. Battery Section ──
-    builder.add_section(&mut final_pc, "Battery", sec_focused.get(4).copied().unwrap_or(false), |sec_bat| {
-        if !state.loaded {
-            sec_bat.text("Loading battery status...", 12.0, 0.0, 12.0, TEXT_DIM);
-        } else {
-            let bat = &state.battery;
-            let bat_icon = match bat.state.as_str() {
-                "charging" => "+",
-                "fully-charged" => "=",
-                _ => "",
-            };
-
-            let pct_color = if bat.percentage < 20.0 { RED }
-                else if bat.percentage < 50.0 { ORANGE }
-                else { ACCENT };
-
-            let pct_str = format!("{} {:.0}%", bat_icon, bat.percentage);
-            sec_bat.text(&pct_str, 12.0, 0.0, 24.0, pct_color);
-
-            let state_str = format!("{}  •  {:.1}W  •  {:.1}/{:.1} Wh",
-                bat.state, bat.energy_rate, bat.energy, bat.energy_full);
-            sec_bat.text(&state_str, 12.0, 0.0, 12.0, TEXT_DIM);
-
-            let time_str = if bat.time_to_empty > 0 {
-                format!("Time remaining: {}", format_duration(bat.time_to_empty))
-            } else if bat.time_to_full > 0 {
-                format!("Time to full: {}", format_duration(bat.time_to_full))
-            } else { String::new() };
-            if !time_str.is_empty() {
-                sec_bat.text(&time_str, 12.0, 0.0, 12.0, TEXT_DIM);
-            }
-
-            let detail_str = format!("{}  {}", bat.vendor, bat.model);
-            sec_bat.text(&detail_str, 12.0, 0.0, 11.0, TEXT_DIM);
-
-            let ac_str = if state.on_ac { "On AC Power" } else { "On Battery" };
-            sec_bat.text(ac_str, 12.0, 0.0, 14.0, TEXT_FG);
-        }
-    });
 
 
 
@@ -567,9 +420,6 @@ pub fn update(state: &mut SystemState, msg: SystemMessage, ctx: &mut cce_ui::con
             state.gpus = new.gpus;
             state.gpu_strings = new.gpu_strings.clone();
 
-            state.battery = new.battery;
-            state.on_ac = new.on_ac;
-
             if state.loaded {
                 state.hostname_label.set_text(&format!("{}  —  Linux {}", state.hostname, state.kernel));
                 state.uptime_label.set_text(&format!("Uptime: {}", state.uptime));
@@ -583,38 +433,6 @@ pub fn update(state: &mut SystemState, msg: SystemMessage, ctx: &mut cce_ui::con
                 state.cpu_temp_label.set_text(&cpu_temp_text);
 
 
-                // Update battery labels
-                let bat = &state.battery;
-                let bat_icon = match bat.state.as_str() {
-                    "charging" => "+",
-                    "fully-charged" => "=",
-                    _ => "",
-                };
-
-                let pct_color = if bat.percentage < 20.0 { [255, 84, 84] }
-                    else if bat.percentage < 50.0 { [255, 186, 51] }
-                    else { [92, 143, 97] };
-
-                let pct_str = format!("{} {:.0}%", bat_icon, bat.percentage);
-                state.battery_label_pct.set_text(&pct_str);
-                state.battery_label_pct.set_color(pct_color);
-
-                let state_str = format!("{}  •  {:.1}W  •  {:.1}/{:.1} Wh",
-                    bat.state, bat.energy_rate, bat.energy, bat.energy_full);
-                state.battery_label_state.set_text(&state_str);
-
-                let time_str = if bat.time_to_empty > 0 {
-                    format!("Time remaining: {}", format_duration(bat.time_to_empty))
-                } else if bat.time_to_full > 0 {
-                    format!("Time to full: {}", format_duration(bat.time_to_full))
-                } else { String::new() };
-                state.battery_label_time.set_text(&time_str);
-
-                let detail_str = format!("{}  {}", bat.vendor, bat.model);
-                state.battery_label_details.set_text(&detail_str);
-
-                let ac_str = if state.on_ac { "On AC Power" } else { "On Battery" };
-                state.battery_label_ac.set_text(ac_str);
 
                 state.hostname_label.mark_dirty(ctx);
             }