basic battery module/service

This commit is contained in:
2026-09-16 11:06:12 +01:00
parent 120eaf5855
commit 63e1c0ee4c
10 changed files with 639 additions and 1 deletions
+2
View File
@@ -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",
@@ -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<u64>),
}
@@ -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::*;
+41
View File
@@ -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<i32>,
}
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<iced::window::Id>) -> Element<'_, Wire> {
match self.percent {
Some(percent) => text(format!("{percent}%")).into(),
None => text("--").into(),
}
}
fn update(&mut self, msg: Wire) -> Task<ModuleEffect> {
if let Some(BatteryEvent::PercentageChanged(percent)) = msg.downcast::<BatteryEvent>()
{
self.percent = Some(*percent);
}
Task::none()
}
fn services(&self) -> Vec<Service> {
vec![BatteryMsg::Events.key()]
}
}
+2
View File
@@ -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<Box<dyn BarModule>> {
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))),
+1
View File
@@ -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 }
+184
View File
@@ -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.<kind>"`.
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<Wire> {
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<Wire>| 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<Wire>,
key: &'static str,
last: &mut Option<(i32, BatteryStatus, i64, i64)>,
) -> Result<bool, zbus::Error> {
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<Wire>,
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<u64> {
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::zvariant::OwnedObjectPath>;
}
#[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<f64>;
/// Device state (1 = charging, 2 = discharging, 3 = empty, 4 = full).
#[zbus(property)]
fn state(&self) -> zbus::Result<u32>;
/// Seconds until empty (0 when unknown).
#[zbus(property)]
fn time_to_empty(&self) -> zbus::Result<i64>;
/// Seconds until full (0 when unknown).
#[zbus(property)]
fn time_to_full(&self) -> zbus::Result<i64>;
}
+2
View File
@@ -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<Wire> {
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)