From 63e1c0ee4cab898150e6a550d11ed9842bf3f393 Mon Sep 17 00:00:00 2001 From: Doloro1978 Date: Wed, 16 Sep 2026 11:06:12 +0100 Subject: [PATCH] basic battery module/service --- Cargo.lock | 360 ++++++++++++++++++ crates/common/src/config.rs | 2 + .../common/src/messages/services/battery.rs | 44 +++ crates/common/src/messages/services/mod.rs | 2 + crates/modules/src/battery.rs | 41 ++ crates/modules/src/lib.rs | 2 + crates/services/Cargo.toml | 1 + crates/services/src/battery.rs | 184 +++++++++ crates/services/src/lib.rs | 2 + test.toml | 2 +- 10 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 crates/common/src/messages/services/battery.rs create mode 100644 crates/modules/src/battery.rs create mode 100644 crates/services/src/battery.rs diff --git a/Cargo.lock b/Cargo.lock index 1b4c869..55d59e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,6 +145,120 @@ dependencies = [ "libloading", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -167,6 +281,23 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -256,6 +387,19 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.8.1" @@ -854,6 +998,12 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enumflags2" version = "0.7.12" @@ -861,6 +1011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ "enumflags2_derive", + "serde", ] [[package]] @@ -915,6 +1066,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1082,7 +1253,10 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ + "fastrand", "futures-core", + "futures-io", + "parking", "pin-project-lite", ] @@ -1354,6 +1528,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hexf-parse" version = "0.2.1" @@ -2200,6 +2380,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "metal" version = "0.32.0" @@ -2748,6 +2937,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -2757,6 +2956,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2830,6 +3035,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pipewire-native" version = "0.1.4" @@ -3529,6 +3745,7 @@ dependencies = [ "serde_json", "tokio", "tracing", + "zbus", ] [[package]] @@ -3546,6 +3763,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -4204,6 +4431,17 @@ dependencies = [ "core_maths", ] +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -4282,6 +4520,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -5164,6 +5413,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zeno" version = "0.3.3" @@ -5255,3 +5574,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow", +] diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index b1885cd..f988ba1 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -17,6 +17,7 @@ pub struct BarbarConfig { #[serde(rename_all = "lowercase")] pub enum Modules { Audio, + Battery, Clock, Weather, Workspaces, @@ -26,6 +27,7 @@ impl Modules { pub fn to_string(&self) -> &str { match self { Modules::Audio => "audio", + Modules::Battery => "battery", Modules::Clock => "clock", Modules::Weather => "weather", Modules::Workspaces => "workspaces", diff --git a/crates/common/src/messages/services/battery.rs b/crates/common/src/messages/services/battery.rs new file mode 100644 index 0000000..ba76198 --- /dev/null +++ b/crates/common/src/messages/services/battery.rs @@ -0,0 +1,44 @@ +//! Battery service messages. + +use crate::Service; + +/// Route key a module subscribes under to receive battery events. One +/// stream carries every event; the [`BatteryEvent`] payload tags which one, +/// so modules filter on it. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum BatteryMsg { + Events, +} + +impl BatteryMsg { + /// Route key a module subscribes under to receive this service's events. + pub fn key(self) -> Service { + Service(match self { + Self::Events => "battery.events", + }) + } +} + +/// Charge state of the first battery, without depending on D-Bus types +/// outside the service itself. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)] +pub enum BatteryStatus { + #[default] + Unknown, + Charging, + Discharging, + Empty, + Full, +} + +/// A battery event, published to subscribers of the events key. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum BatteryEvent { + /// Charge percentage changed (0..=100). + PercentageChanged(i32), + /// Charging state changed. + StatusChanged(BatteryStatus), + /// Seconds until full (charging) or empty (discharging) changed; + /// `None` when unknown or not applicable. + EtaChanged(Option), +} diff --git a/crates/common/src/messages/services/mod.rs b/crates/common/src/messages/services/mod.rs index 4bb301a..1ba02b5 100644 --- a/crates/common/src/messages/services/mod.rs +++ b/crates/common/src/messages/services/mod.rs @@ -1,10 +1,12 @@ //! Messages owned by services. +mod battery; mod datetime; mod hyprland; mod pipewire; mod weather; +pub use battery::*; pub use datetime::*; pub use hyprland::*; pub use pipewire::*; diff --git a/crates/modules/src/battery.rs b/crates/modules/src/battery.rs new file mode 100644 index 0000000..32b7de1 --- /dev/null +++ b/crates/modules/src/battery.rs @@ -0,0 +1,41 @@ +//! Battery module: prints the charge percentage from the battery service. + +use iced::widget::text; +use iced::{Element, Task}; + +use common::{BarModule, BatteryEvent, BatteryMsg, ModuleEffect, Service, Wire}; + +pub struct Battery { + percent: Option, +} + +impl Battery { + pub fn new() -> Self { + Self { percent: None } + } +} + +impl BarModule for Battery { + fn id(&self) -> &'static str { + "battery" + } + + fn view(&self, _window_id: Option) -> Element<'_, Wire> { + match self.percent { + Some(percent) => text(format!("{percent}%")).into(), + None => text("--").into(), + } + } + + fn update(&mut self, msg: Wire) -> Task { + if let Some(BatteryEvent::PercentageChanged(percent)) = msg.downcast::() + { + self.percent = Some(*percent); + } + Task::none() + } + + fn services(&self) -> Vec { + vec![BatteryMsg::Events.key()] + } +} diff --git a/crates/modules/src/lib.rs b/crates/modules/src/lib.rs index 296b227..4e20107 100644 --- a/crates/modules/src/lib.rs +++ b/crates/modules/src/lib.rs @@ -2,6 +2,7 @@ //! a new file under `src/` plus one entry in `all()` — core never changes. mod audio; +mod battery; mod clock; mod weather; mod workspaces; @@ -15,6 +16,7 @@ pub use weather::*; pub fn all(module_config: Table) -> Vec> { vec![ Box::new(audio::Audio::new()), + Box::new(battery::Battery::new()), Box::new(clock::Clock::new(Some(module_config.clone()))), Box::new(weather::WeatherModule::default()), Box::new(workspaces::Workspaces::new(Some(module_config))), diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index 5408099..9ac37e6 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -9,6 +9,7 @@ path = "src/lib.rs" [dependencies] common = { path = "../common" } +zbus = "5" iced = { workspace = true } tokio = { workspace = true, features = ["net", "io-util"] } tracing = { workspace = true } diff --git a/crates/services/src/battery.rs b/crates/services/src/battery.rs new file mode 100644 index 0000000..3d8bf7c --- /dev/null +++ b/crates/services/src/battery.rs @@ -0,0 +1,184 @@ +//! Battery service: follows UPower's display device over D-Bus and +//! republishes each aspect whenever UPower pushes a change — no polling. +//! +//! Machines without a battery (or without UPower) simply never publish; +//! the service keeps retrying in case UPower shows up later. + +use std::time::Duration; + +use common::{BatteryEvent, BatteryStatus, Endpoint, Inbox, Wire}; +use iced::futures::{channel::mpsc, SinkExt, StreamExt}; +use iced::Subscription; + +/// Route-key namespace owned by this service; keys are `"battery."`. +pub const NAMESPACE: &str = "battery."; + +/// How long to wait before retrying when UPower is unreachable. +const RETRY: Duration = Duration::from_secs(30); + +/// UPower device states (`org.freedesktop.UPower.Device.State`). +const CHARGING: u32 = 1; +const DISCHARGING: u32 = 2; +const EMPTY: u32 = 3; +const FULLY_CHARGED: u32 = 4; + +pub struct BatteryService; + +impl BatteryService { + pub fn run(inbox: Inbox) -> Subscription { + Subscription::run_with(inbox, |inbox| { + let key = inbox.key(); + // We dont need to rx any msgs from modules + let mut _rx = inbox.take().expect("inbox receiver is taken once"); + + iced::stream::channel(0, move |mut sender: mpsc::Sender| async move { + tracing::info!(key, "battery service started"); + let mut last: Option<(i32, BatteryStatus, i64, i64)> = None; + loop { + match serve(&mut sender, key, &mut last).await { + // Subscriber gone: stop the service. + Ok(false) => return, + Ok(true) => tracing::debug!(key, "upower stream ended; reconnecting"), + Err(err) => tracing::debug!(key, %err, "upower unavailable; retrying"), + } + tokio::time::sleep(RETRY).await; + } + }) + }) + } +} + +/// Follows the display device until UPower goes away. Returns whether the +/// subscriber is still alive (`false` stops the service, `true` reconnects). +async fn serve( + sender: &mut mpsc::Sender, + key: &'static str, + last: &mut Option<(i32, BatteryStatus, i64, i64)>, +) -> Result { + let conn = zbus::Connection::system().await?; + let display = UPowerProxy::new(&conn).await?.get_display_device().await?; + let device = DeviceProxy::builder(&conn).path(display)?.build().await?; + + if !emit(sender, key, last, read(&device).await?).await { + return Ok(false); + } + + let mut percentage = device.receive_percentage_changed().await; + let mut state = device.receive_state_changed().await; + let mut time_to_empty = device.receive_time_to_empty_changed().await; + let mut time_to_full = device.receive_time_to_full_changed().await; + loop { + // `last` is always `Some` here: the initial read published first. + let (percent, status, to_full, to_empty) = last.unwrap_or_default(); + let current = tokio::select! { + Some(change) = percentage.next() => (change.get().await?.round() as i32, status, to_full, to_empty), + Some(change) = state.next() => (percent, map_state(change.get().await?), to_full, to_empty), + Some(change) = time_to_empty.next() => (percent, status, to_full, change.get().await?), + Some(change) = time_to_full.next() => (percent, status, change.get().await?, to_empty), + else => return Ok(true), + }; + if !emit(sender, key, last, current).await { + return Ok(false); + } + } +} + +/// Sends the aspects that differ from `last`; `false` when the subscriber +/// is gone and the service should stop. +async fn emit( + sender: &mut mpsc::Sender, + key: &'static str, + last: &mut Option<(i32, BatteryStatus, i64, i64)>, + (percent, status, to_full, to_empty): (i32, BatteryStatus, i64, i64), +) -> bool { + let mut events = Vec::new(); + if last.map(|(p, _, _, _)| p) != Some(percent) { + tracing::debug!(key, percent, "publishing battery percentage"); + events.push(BatteryEvent::PercentageChanged(percent)); + } + if last.map(|(_, s, _, _)| s) != Some(status) { + tracing::debug!(key, ?status, "publishing battery status"); + events.push(BatteryEvent::StatusChanged(status)); + } + let old_eta = last.map(|(_, s, f, e)| eta(s, f, e)).unwrap_or_default(); + let new_eta = eta(status, to_full, to_empty); + if old_eta != new_eta { + tracing::debug!(key, ?new_eta, "publishing battery eta"); + events.push(BatteryEvent::EtaChanged(new_eta)); + } + *last = Some((percent, status, to_full, to_empty)); + for event in events { + let wire = Wire::topic(Endpoint::service(key), key, event); + if sender.send(wire).await.is_err() { + tracing::warn!(key, "battery send failed; stopping service"); + return false; + } + } + true +} + +/// Current percentage, status, and raw UPower time estimates of the display device. +async fn read(device: &DeviceProxy<'_>) -> Result<(i32, BatteryStatus, i64, i64), zbus::Error> { + Ok(( + device.percentage().await?.round() as i32, + map_state(device.state().await?), + device.time_to_full().await?, + device.time_to_empty().await?, + )) +} + +/// Seconds until full while charging, or until empty while discharging. +/// UPower reports 0 when there is no estimate, and neither state applies +/// once full, empty, or unknown — all of those surface as `None`. +fn eta(status: BatteryStatus, to_full: i64, to_empty: i64) -> Option { + let secs = match status { + BatteryStatus::Charging => to_full, + BatteryStatus::Discharging => to_empty, + _ => return None, + }; + (secs > 0).then_some(secs as u64) +} + +/// UPower reports pending/unknown states our protocol has no variant for; +/// those surface as [`BatteryStatus::Unknown`]. +fn map_state(state: u32) -> BatteryStatus { + match state { + CHARGING => BatteryStatus::Charging, + DISCHARGING => BatteryStatus::Discharging, + EMPTY => BatteryStatus::Empty, + FULLY_CHARGED => BatteryStatus::Full, + _ => BatteryStatus::Unknown, + } +} + +#[zbus::proxy( + interface = "org.freedesktop.UPower", + default_service = "org.freedesktop.UPower", + default_path = "/org/freedesktop/UPower" +)] +trait UPower { + /// Path of the composite display device aggregating all batteries. + fn get_display_device(&self) -> zbus::Result; +} + +#[zbus::proxy( + interface = "org.freedesktop.UPower.Device", + default_service = "org.freedesktop.UPower" +)] +trait Device { + /// Charge percentage (0..=100). + #[zbus(property)] + fn percentage(&self) -> zbus::Result; + + /// Device state (1 = charging, 2 = discharging, 3 = empty, 4 = full). + #[zbus(property)] + fn state(&self) -> zbus::Result; + + /// Seconds until empty (0 when unknown). + #[zbus(property)] + fn time_to_empty(&self) -> zbus::Result; + + /// Seconds until full (0 when unknown). + #[zbus(property)] + fn time_to_full(&self) -> zbus::Result; +} diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index 5b8e04a..d83360d 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -6,6 +6,7 @@ use iced::Subscription; use common::{Inbox, Service, Wire}; +pub mod battery; pub mod compositors; pub mod datetime; pub mod pipewire; @@ -24,6 +25,7 @@ impl IntoSubscription for Service { fn into_subscription(self, inbox: Inbox) -> Subscription { let key = self.0; match key { + k if k.starts_with(battery::NAMESPACE) => battery::BatteryService::run(inbox), k if k.starts_with(datetime::NAMESPACE) => datetime::ClockTicker::run(inbox), k if k.starts_with(compositors::hyprland::NAMESPACE) => { compositors::hyprland::HyprlandService::run(inbox) diff --git a/test.toml b/test.toml index eaac046..6e45ef1 100644 --- a/test.toml +++ b/test.toml @@ -16,7 +16,7 @@ theme = 'example_base16.yaml' [order] left = ["workspaces", "weather"] middle = [] -right = ["audio", "clock"] +right = ["battery", "audio", "clock"] [modules.clock] show_seconds = false