basic battery module/service
This commit is contained in:
@@ -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::*;
|
||||
|
||||
@@ -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,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))),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user