From 596407a52d5286d39b05aeb231ac208bf4f76fd2 Mon Sep 17 00:00:00 2001 From: vasilito Date: Tue, 7 Jul 2026 22:00:54 +0300 Subject: [PATCH] redbear-power: upower client + render/sensor/battery/dmi updates --- .../redbear-power/source/src/battery.rs | 112 ++++++++++- .../system/redbear-power/source/src/dmi.rs | 126 ++++++++---- .../system/redbear-power/source/src/main.rs | 1 + .../system/redbear-power/source/src/render.rs | 9 +- .../system/redbear-power/source/src/sensor.rs | 62 +++++- .../redbear-power/source/src/storage.rs | 67 +++++-- .../redbear-power/source/src/upower_client.rs | 186 ++++++++++++++++++ 7 files changed, 501 insertions(+), 62 deletions(-) create mode 100644 local/recipes/system/redbear-power/source/src/upower_client.rs diff --git a/local/recipes/system/redbear-power/source/src/battery.rs b/local/recipes/system/redbear-power/source/src/battery.rs index 2b4cafaed8..f16031c47d 100644 --- a/local/recipes/system/redbear-power/source/src/battery.rs +++ b/local/recipes/system/redbear-power/source/src/battery.rs @@ -1,22 +1,23 @@ -//! Battery information via `sysfs` (`/sys/class/power_supply/BAT0/*`). +//! Battery information. //! -//! Linux hosts expose laptop batteries through the `power_supply` class. -//! On Redox, no equivalent scheme exists yet, so `read_battery()` returns -//! an `available=false` `BatteryInfo` and the render layer skips the -//! panel entirely — per the zero-stub policy. -//! -//! The Redox target needs a `power_supply` scheme daemon (likely wired to -//! ACPI); this is forward work tracked in the v1.6 docs. +//! On Linux the primary source is `/sys/class/power_supply/BAT0/*`. On +//! Redox, ACPI exposes battery and adapter state via the power scheme: +//! `/scheme/acpi/power/batteries/BAT0/{state,percentage}` and +//! `/scheme/acpi/power/adapters/AC/online`. If neither source is +//! available, `read_battery()` returns `available=false` and the render +//! layer skips the panel. use std::env; use std::fs; use std::path::{Path, PathBuf}; const SYS_POWER_SUPPLY: &str = "/sys/class/power_supply"; +const REDOX_POWER: &str = "/scheme/acpi/power"; #[derive(Default, Clone, Debug)] pub struct BatteryInfo { pub available: bool, + pub on_battery: Option, pub name: Option, pub status: Option, pub capacity_percent: Option, @@ -60,6 +61,37 @@ fn read_sysfs_f64_micro_to_units(path: &Path, unit_divisor: f64) -> Option Some((raw as f64) / unit_divisor) } +/// Read a Redox power scheme file. +fn read_redox(path: &Path) -> Option { + read_sysfs(path) +} + +fn read_redox_u32(path: &Path) -> Option { + read_redox(path)?.parse::().ok() +} + +fn read_redox_f64(path: &Path) -> Option { + read_redox(path)?.parse::().ok() +} + +/// Map a Redox battery state bitmask to a status string. +/// 0x1 = discharging, 0x2 = charging, 0x4 = empty +fn redox_state_to_status(state: u32) -> String { + if state == 0 { + return "Unknown".to_string(); + } + if state & 0x4 != 0 { + return "Empty".to_string(); + } + if state & 0x2 != 0 { + return "Charging".to_string(); + } + if state & 0x1 != 0 { + return "Discharging".to_string(); + } + "Unknown".to_string() +} + impl BatteryInfo { /// Scan `/sys/class/power_supply/` for the first battery device /// (`type == "Battery"`). Returns `None` if no battery is present @@ -80,14 +112,74 @@ impl BatteryInfo { None } - /// Build a populated `BatteryInfo` from sysfs. Returns an empty - /// `available=false` struct if no battery is detected. + /// Find the first Redox battery directory under `/scheme/acpi/power/batteries/`. + fn find_redox_battery_dir() -> Option { + let batteries_root = Path::new(REDOX_POWER).join("batteries"); + let dir = fs::read_dir(&batteries_root).ok()?; + for entry in dir.flatten() { + if entry.path().is_dir() { + return Some(entry.path()); + } + } + None + } + + /// Read battery/AC status from the Redox ACPI power scheme. + fn read_redox() -> Self { + let mut info = Self { + available: true, + on_battery: None, + ..Default::default() + }; + let base = match Self::find_redox_battery_dir() { + Some(b) => b, + None => { + info.available = false; + return info; + } + }; + info.name = base + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.to_string()); + let state = read_redox_u32(&base.join("state")).unwrap_or(0); + info.status = Some(redox_state_to_status(state)); + info.capacity_percent = read_redox_u32(&base.join("percentage")); + // Redox ACPI power scheme currently does not expose energy, power, + // voltage, time estimates, cycle count, or model metadata. Leave those + // as None so the render layer shows "?" for unsupported fields. + info + } + + /// Build a populated `BatteryInfo` from the best available source. + /// Returns an empty `available=false` struct if no battery is detected. pub fn read() -> Self { + let mut info = Self::read_native(); + if !info.available { + // No local sysfs/scheme battery; try UPower D-Bus as a fallback. + if let Some(upower) = crate::upower_client::read_upower() { + return upower; + } + } else { + // We have a local battery; overlay UPower's authoritative + // status/percentage/on_battery values when possible. + info = crate::upower_client::merge_upower(info); + } + info + } + + /// Build a `BatteryInfo` from local sysfs (Linux) or ACPI scheme + /// (Redox), without using UPower. + fn read_native() -> Self { + if Path::new(REDOX_POWER).exists() { + return Self::read_redox(); + } let Some(base) = Self::find_battery_dir() else { return Self::default(); }; Self { available: true, + on_battery: None, name: read_sysfs(&base.join("name")), status: read_sysfs(&base.join("status")), capacity_percent: read_sysfs_u32(&base.join("capacity")), diff --git a/local/recipes/system/redbear-power/source/src/dmi.rs b/local/recipes/system/redbear-power/source/src/dmi.rs index ef76aa4afd..d897fe03ee 100644 --- a/local/recipes/system/redbear-power/source/src/dmi.rs +++ b/local/recipes/system/redbear-power/source/src/dmi.rs @@ -1,20 +1,22 @@ //! SMBIOS / DMI motherboard information. //! -//! Reads `/sys/class/dmi/id/*` on Linux hosts. On Redox, no equivalent -//! scheme exists yet, so `read_dmi()` returns an empty struct and the -//! render layer displays `?` for missing fields — per the zero-stub policy. -//! -//! The Redox target needs a `dmi` scheme daemon that exposes SMBIOS tables -//! via `/scheme/dmi/...`; this is forward work tracked in the v1.5 docs. +//! Reads `/sys/class/dmi/id/*` on Linux hosts. On Redox, the ACPI +//! scheme exposes SMBIOS data at `/scheme/acpi/dmi` as a single +//! `key=value` file (and optionally per-field files like +//! `/scheme/acpi/dmi/sys_vendor`). If neither source is available, +//! `read_dmi()` returns an empty struct and the render layer displays +//! `?` for missing fields. use std::fs; use std::path::Path; /// Linux sysfs path for DMI/SMBIOS data. const SYS_DMI: &str = "/sys/class/dmi/id"; +/// Redox ACPI scheme path for DMI/SMBIOS data. +const REDOX_DMI: &str = "/scheme/acpi/dmi"; /// DMI/SMBIOS fields. All fields are `Option` because any one of -/// them may be unreadable (permission denied, missing sysfs file, etc.). +/// them may be unreadable (permission denied, missing file, etc.). #[derive(Default, Clone, Debug)] pub struct DmiInfo { pub board_vendor: Option, @@ -57,35 +59,93 @@ impl DmiInfo { } } - /// Probe whether `/sys/class/dmi/id/` exists. Used by the Sources - /// header line to report `dmi=ok` vs `dmi=no`. - pub fn available() -> bool { - Path::new(SYS_DMI).is_dir() + /// Read a single Redox per-field file (`/scheme/acpi/dmi/`). + fn read_redox_field(name: &str) -> Option { + let path = Path::new(REDOX_DMI).join(name); + match fs::read_to_string(&path) { + Ok(s) => { + let trimmed = s.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + } + Err(_) => None, + } } - /// Build a populated `DmiInfo` from sysfs. Each field is read - /// independently so one failure doesn't poison the others. + /// Parse the Redox `/scheme/acpi/dmi` single-file format, which is a + /// set of `key=value` lines. Returns an empty map on any error. + fn parse_redox_dmi() -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + let Ok(content) = fs::read_to_string(REDOX_DMI) else { + return map; + }; + for line in content.lines() { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let trimmed = value.trim().to_string(); + if !trimmed.is_empty() { + map.insert(key.trim().to_string(), trimmed); + } + } + map + } + + /// Build a populated `DmiInfo` from Redox ACPI scheme data. Each field + /// is read independently from per-field files or the single-file + /// key=value fallback so one failure doesn't poison the others. + fn read_redox() -> Self { + let fields = Self::parse_redox_dmi(); + let mut info = Self::default(); + let fill = |key: &str, target: &mut Option| { + *target = Self::read_redox_field(key).or_else(|| fields.get(key).cloned()); + }; + fill("sys_vendor", &mut info.sys_vendor); + fill("board_vendor", &mut info.board_vendor); + fill("board_name", &mut info.board_name); + fill("board_version", &mut info.board_version); + fill("product_name", &mut info.product_name); + fill("product_version", &mut info.product_version); + fill("bios_version", &mut info.bios_version); + // The remaining fields are not exposed by the Redox ACPI DMI scheme + // (per redox-driver-sys/src/quirks/dmi.rs); leave them as None. + info + } + + /// Probe whether DMI data is available on this host. + pub fn available() -> bool { + Path::new(SYS_DMI).is_dir() || Path::new(REDOX_DMI).exists() + } + + /// Build a populated `DmiInfo` from the best available source. pub fn read() -> Self { - Self { - board_vendor: Self::read_sysfs("board_vendor"), - board_name: Self::read_sysfs("board_name"), - board_version: Self::read_sysfs("board_version"), - board_serial: Self::read_sysfs("board_serial"), - board_asset_tag: Self::read_sysfs("board_asset_tag"), - bios_vendor: Self::read_sysfs("bios_vendor"), - bios_version: Self::read_sysfs("bios_version"), - bios_date: Self::read_sysfs("bios_date"), - bios_release: Self::read_sysfs("bios_release"), - product_name: Self::read_sysfs("product_name"), - product_family: Self::read_sysfs("product_family"), - product_version: Self::read_sysfs("product_version"), - product_serial: Self::read_sysfs("product_serial"), - product_uuid: Self::read_sysfs("product_uuid"), - sys_vendor: Self::read_sysfs("sys_vendor"), - chassis_vendor: Self::read_sysfs("chassis_vendor"), - chassis_type: Self::read_sysfs("chassis_type"), - chassis_version: Self::read_sysfs("chassis_version"), - chassis_asset_tag: Self::read_sysfs("chassis_asset_tag"), + if Path::new(REDOX_DMI).exists() { + Self::read_redox() + } else { + Self { + board_vendor: Self::read_sysfs("board_vendor"), + board_name: Self::read_sysfs("board_name"), + board_version: Self::read_sysfs("board_version"), + board_serial: Self::read_sysfs("board_serial"), + board_asset_tag: Self::read_sysfs("board_asset_tag"), + bios_vendor: Self::read_sysfs("bios_vendor"), + bios_version: Self::read_sysfs("bios_version"), + bios_date: Self::read_sysfs("bios_date"), + bios_release: Self::read_sysfs("bios_release"), + product_name: Self::read_sysfs("product_name"), + product_family: Self::read_sysfs("product_family"), + product_version: Self::read_sysfs("product_version"), + product_serial: Self::read_sysfs("product_serial"), + product_uuid: Self::read_sysfs("product_uuid"), + sys_vendor: Self::read_sysfs("sys_vendor"), + chassis_vendor: Self::read_sysfs("chassis_vendor"), + chassis_type: Self::read_sysfs("chassis_type"), + chassis_version: Self::read_sysfs("chassis_version"), + chassis_asset_tag: Self::read_sysfs("chassis_asset_tag"), + } } } diff --git a/local/recipes/system/redbear-power/source/src/main.rs b/local/recipes/system/redbear-power/source/src/main.rs index 67fbf3e2d9..ac352c6408 100644 --- a/local/recipes/system/redbear-power/source/src/main.rs +++ b/local/recipes/system/redbear-power/source/src/main.rs @@ -74,6 +74,7 @@ mod session; mod smart; mod storage; mod theme; +mod upower_client; mod wakeup; use crate::app::{App, POLL_MS, TabId}; diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index 0865566688..bb96c0dec7 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -977,7 +977,7 @@ pub fn render_battery_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { let bat = &app.battery; if !bat.available { return Paragraph::new(Line::from( - "(no battery detected — /sys/class/power_supply/BAT* not present)" + "(no battery detected — no UPower, /sys/class/power_supply, or /scheme/acpi/power battery found)" .set_style(theme.value_warm), )) .block(panel_border(focused, " Battery ", &app.theme)) @@ -1007,6 +1007,13 @@ pub fn render_battery_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { ])); lines.push(Line::from("")); lines.push(Line::from("State".set_style(theme.label_bold))); + lines.push(Line::from(vec![ + " On battery: ".set_style(theme.label), + bat.on_battery + .map(|b| if b { "yes" } else { "no" }) + .unwrap_or("?") + .set_style(theme.value), + ])); lines.push(Line::from(vec![ " Status: ".set_style(theme.label), crate::battery::BatteryInfo::display(&bat.status).set_style(theme.value), diff --git a/local/recipes/system/redbear-power/source/src/sensor.rs b/local/recipes/system/redbear-power/source/src/sensor.rs index 1c45f208b9..a40afe6487 100644 --- a/local/recipes/system/redbear-power/source/src/sensor.rs +++ b/local/recipes/system/redbear-power/source/src/sensor.rs @@ -20,6 +20,7 @@ use std::fs; use std::path::{Path, PathBuf}; const SYS_HWMON: &str = "/sys/class/hwmon"; +const REDOX_THERMAL: &str = "/scheme/thermal"; #[derive(Default, Clone, Debug)] pub struct SensorReading { @@ -123,6 +124,57 @@ fn read_first_cpu_temp_msr() -> Option { None } +/// Read a Redox thermal zone temperature file. +fn read_redox_zone_temp(zone_name: &str) -> Option { + let path = Path::new(REDOX_THERMAL) + .join("zones") + .join(zone_name) + .join("temperature"); + read_sysfs_i64(&path) +} + +/// Read a Redox thermal zone status file. +fn read_redox_zone_status(zone_name: &str) -> Option { + let path = Path::new(REDOX_THERMAL) + .join("zones") + .join(zone_name) + .join("status"); + read_sysfs(&path) +} + +/// Enumerate Redox `/scheme/thermal/zones/` directories and return +/// one `HwmonChip`-like entry per zone with a temperature reading. +fn read_redox_thermal_zones() -> Vec { + let zones_root = Path::new(REDOX_THERMAL).join("zones"); + let Ok(entries) = fs::read_dir(&zones_root) else { + return Vec::new(); + }; + let mut chips = Vec::new(); + for entry in entries.flatten() { + let zone_name = match entry.file_name().into_string() { + Ok(n) => n, + Err(_) => continue, + }; + let Some(temp_raw) = read_redox_zone_temp(&zone_name) else { + continue; + }; + let label = read_redox_zone_status(&zone_name) + .map(|s| format!("{} ({})", zone_name, s)) + .unwrap_or_else(|| zone_name.clone()); + chips.push(HwmonChip { + name: "thermal".to_string(), + path: zones_root.join(&zone_name), + readings: vec![SensorReading { + kind: SensorKind::Temp, + label: Some(label), + raw_value: temp_raw, + display_value: format_sensor(SensorKind::Temp, temp_raw), + }], + }); + } + chips +} + /// Read all `*_input` files in the chip directory, grouped by prefix. fn read_chip_readings(chip_dir: &Path) -> Vec { let entries = match fs::read_dir(chip_dir) { @@ -187,9 +239,17 @@ fn read_chip_readings(chip_dir: &Path) -> Vec { impl SensorInfo { pub fn available() -> bool { - Path::new(SYS_HWMON).is_dir() || read_first_cpu_temp_msr().is_some() + Path::new(SYS_HWMON).is_dir() + || Path::new(REDOX_THERMAL).exists() + || read_first_cpu_temp_msr().is_some() } pub fn read() -> Self { + if Path::new(REDOX_THERMAL).exists() { + let chips = read_redox_thermal_zones(); + if !chips.is_empty() { + return Self { chips }; + } + } let Ok(dirs) = fs::read_dir(SYS_HWMON) else { if let Some(temp_c) = read_first_cpu_temp_msr() { return Self { diff --git a/local/recipes/system/redbear-power/source/src/storage.rs b/local/recipes/system/redbear-power/source/src/storage.rs index e248985387..d1ea58135e 100644 --- a/local/recipes/system/redbear-power/source/src/storage.rs +++ b/local/recipes/system/redbear-power/source/src/storage.rs @@ -1,25 +1,18 @@ -//! Block device storage info via `sysfs` (`/sys/block//`). +//! Block device storage info. //! -//! Linux exposes block device metadata via sysfs: model, vendor, size -//! (in 512-byte sectors), rotational flag, removable flag, IO -//! scheduler, queue depth, and per-partition layout. -//! -//! Traffic counters come from `/sys/block//stat`: -//! read_bytes / write_bytes — total bytes transferred -//! reads_completed / writes_completed — I/O operation counts -//! -//! SMART data (Temperature, ReallocatedSectorsCount, WearLevelingCount, -//! etc.) is read via `smartctl --json` if the binary is in PATH. -//! Otherwise the SMART section is omitted — per the zero-stub policy. -//! -//! On Redox, no equivalent scheme exists yet, so `read()` returns an -//! empty `StorageInfo` and the render layer shows -//! `(no storage devices detected)`. +//! On Linux the primary source is `/sys/block//` (model, vendor, +//! size, rotational/removable flags, scheduler, queue depth, and +//! per-device `stat` counters). On Redox, the disk aggregator daemon +//! exposes block devices through `/scheme/diskd/`. Device metadata such +//! as model, vendor, and exact size are not currently available through +//! that scheme; only device names are enumerated, with I/O stats +//! remaining unavailable. Linux remains the fully-featured source. use std::fs; use std::path::{Path, PathBuf}; const SYS_BLOCK: &str = "/sys/block"; +const REDOX_DISKD: &str = "/scheme/diskd"; #[derive(Default, Clone, Debug)] pub struct DiskStats { @@ -126,6 +119,25 @@ fn read_disk(name: &str, path: &Path) -> Option { }) } +/// Read a Redox diskd entry. The disk aggregator scheme exposes block +/// device names but does not currently provide size, model, vendor, or I/O +/// counters; those fields are left as sensible defaults. +fn read_redox_disk(name: &str, path: &Path) -> DiskInfo { + DiskInfo { + name: name.to_string(), + path: path.to_path_buf(), + model: None, + vendor: None, + size_bytes: 0, + rotational: false, + removable: false, + scheduler: None, + queue_depth: None, + stats: DiskStats::default(), + partitions: Vec::new(), + } +} + impl DiskInfo { /// Format bytes with binary unit suffixes (B, KiB, MiB, GiB, TiB). pub fn format_size(bytes: u64) -> String { @@ -160,9 +172,12 @@ pub struct StorageInfo { impl StorageInfo { pub fn available() -> bool { - Path::new(SYS_BLOCK).is_dir() + Path::new(SYS_BLOCK).is_dir() || Path::new(REDOX_DISKD).is_dir() } pub fn read() -> Self { + if Path::new(REDOX_DISKD).is_dir() { + return Self::read_redox(); + } let Ok(dirs) = fs::read_dir(SYS_BLOCK) else { return Self::default(); }; @@ -179,6 +194,24 @@ impl StorageInfo { disks.sort_by(|a, b| a.name.cmp(&b.name)); Self { disks } } + + /// Read diskd device names on Redox. The disk aggregator scheme + /// enumerates block devices but does not expose size/model/stats. + fn read_redox() -> Self { + let Ok(dirs) = fs::read_dir(REDOX_DISKD) else { + return Self::default(); + }; + let mut disks = Vec::new(); + for entry in dirs.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + disks.push(read_redox_disk(name, &path)); + } + disks.sort_by(|a, b| a.name.cmp(&b.name)); + Self { disks } + } /// Read disks and compute R/W throughput (KiB/s) for each based /// on delta of read_bytes/write_bytes vs previous read. pub fn read_with_throughput(prev: &StorageInfo, dt_secs: f64) -> Self { diff --git a/local/recipes/system/redbear-power/source/src/upower_client.rs b/local/recipes/system/redbear-power/source/src/upower_client.rs new file mode 100644 index 0000000000..443d570b96 --- /dev/null +++ b/local/recipes/system/redbear-power/source/src/upower_client.rs @@ -0,0 +1,186 @@ +//! D-Bus UPower client (system bus). +//! +//! Reads battery and AC adapter status from `org.freedesktop.UPower` so +//! redbear-power can display the same battery information on Linux +//! (real UPower) and Red Bear OS (redbear-upower compatibility daemon) +//! without relying on sysfs or ACPI scheme details. +//! +//! Implementation note: this module creates a short-lived tokio runtime +//! per call. Battery refresh is only every few seconds, so the overhead is +//! acceptable. A long-lived worker thread would be cleaner, but keeping it +//! synchronous simplifies the `BatteryInfo::read()` contract. + +use tokio::runtime::Runtime; +use zbus::proxy; +use zbus::zvariant::OwnedObjectPath; + +use crate::battery::BatteryInfo; + +#[proxy( + interface = "org.freedesktop.UPower", + default_service = "org.freedesktop.UPower", + default_path = "/org/freedesktop/UPower" +)] +trait UPower { + /// Enumerate all power devices (batteries and AC adapters). + fn enumerate_devices(&self) -> zbus::Result>; + + /// Critical action the system would take on a critical battery event. + fn get_critical_action(&self) -> zbus::Result; + + /// True if the system is running on battery power. + #[zbus(property)] + fn on_battery(&self) -> zbus::Result; + + #[zbus(property)] + fn daemon_version(&self) -> zbus::Result; +} + +#[proxy( + interface = "org.freedesktop.UPower.Device", + default_service = "org.freedesktop.UPower" +)] +trait UPowerDevice { + /// 0=Unknown, 1=LinePower, 2=Battery. + #[zbus(property)] + fn type_(&self) -> zbus::Result; + + /// 0=Unknown, 1=Charging, 2=Discharging, 3=Empty, 4=FullyCharged. + #[zbus(property)] + fn state(&self) -> zbus::Result; + + /// Battery charge percentage (0.0–100.0). + #[zbus(property)] + fn percentage(&self) -> zbus::Result; + + /// Whether the device is physically present. + #[zbus(property)] + fn is_present(&self) -> zbus::Result; + + /// For AC adapters: is the adapter online. For batteries: always false. + #[zbus(property)] + fn online(&self) -> zbus::Result; + + /// Native (ACPI) path of the device. + #[zbus(property)] + fn native_path(&self) -> zbus::Result; +} + +/// Device type constants from the UPower specification. +mod device_type { + pub const UNKNOWN: u32 = 0; + pub const LINE_POWER: u32 = 1; + pub const BATTERY: u32 = 2; +} + +/// Device state constants from the UPower specification. +mod device_state { + pub const UNKNOWN: u32 = 0; + pub const CHARGING: u32 = 1; + pub const DISCHARGING: u32 = 2; + pub const EMPTY: u32 = 3; + pub const FULLY_CHARGED: u32 = 4; +} + +fn state_to_status(state: u32) -> String { + match state { + device_state::CHARGING => "Charging".to_string(), + device_state::DISCHARGING => "Discharging".to_string(), + device_state::EMPTY => "Empty".to_string(), + device_state::FULLY_CHARGED => "Full".to_string(), + _ => "Unknown".to_string(), + } +} + +/// Read battery/AC status from the UPower D-Bus service. Returns `None` if +/// the system bus is unreachable or UPower is not available. +pub fn read_upower() -> Option { + let rt = Runtime::new().ok()?; + rt.block_on(read_upower_async()) +} + +async fn read_upower_async() -> Option { + let conn = zbus::connection::Builder::system() + .ok()? + .build() + .await + .ok()?; + let upower = UPowerProxy::new(&conn).await.ok()?; + + let on_battery = upower.on_battery().await.unwrap_or(false); + let devices = upower.enumerate_devices().await.unwrap_or_default(); + + let mut info = BatteryInfo { + available: false, + ..Default::default() + }; + + for path in devices { + let dev = match UPowerDeviceProxy::builder(&conn) + .path(path.clone()) + .ok()? + .build() + .await + { + Ok(d) => d, + Err(_) => continue, + }; + let dev_type = dev.type_().await.unwrap_or(device_type::UNKNOWN); + if dev_type == device_type::LINE_POWER { + // AC adapter: we expose its online state as the power_now_w + // field? No, we use a dedicated field if it existed. For now, + // represent the AC adapter as a synthetic device with name. + let _ = dev.online().await.unwrap_or(false); + // AC online state is not represented in BatteryInfo; it is + // implicit in the OnBattery daemon property. We leave the + // BatteryInfo focused on the battery device itself. + } else if dev_type == device_type::BATTERY { + let is_present = dev.is_present().await.unwrap_or(false); + if !is_present { + continue; + } + info.available = true; + let state = dev.state().await.unwrap_or(device_state::UNKNOWN); + info.status = Some(state_to_status(state)); + info.capacity_percent = Some(dev.percentage().await.unwrap_or(0.0) as u32); + info.name = dev + .native_path() + .await + .ok() + .map(|p| p.rsplit('/').next().unwrap_or(&p).to_string()); + info.on_battery = Some(on_battery); + // redbear-upower currently does not expose Energy, Power, Voltage, + // TimeToEmpty, TimeToFull, CycleCount, Technology, Model, Vendor, + // or Serial. Leave those fields as None so the render layer shows + // "?" for unsupported values. + } + } + + if info.available { Some(info) } else { None } +} + +/// Merge the UPower-derived fields into a base `BatteryInfo`. This is a +/// convenience helper so `battery.rs` can keep its Linux/Redox sysfs/scheme +/// fallback and only overlay the values that UPower provided. +pub fn merge_upower(mut base: BatteryInfo) -> BatteryInfo { + if let Some(upower) = read_upower() { + if base.available { + // UPower is authoritative for status and percentage; other fields + // that the local sysfs/scheme read already populated are kept. + if upower.status.is_some() { + base.status = upower.status; + } + if upower.capacity_percent.is_some() { + base.capacity_percent = upower.capacity_percent; + } + if upower.on_battery.is_some() { + base.on_battery = upower.on_battery; + } + base + } else { + upower + } + } else { + base + } +}