diff --git a/crates/services/src/battery.rs b/crates/services/src/battery.rs index 3d8bf7c..d8b9c6f 100644 --- a/crates/services/src/battery.rs +++ b/crates/services/src/battery.rs @@ -15,6 +15,11 @@ pub const NAMESPACE: &str = "battery."; /// How long to wait before retrying when UPower is unreachable. const RETRY: Duration = Duration::from_secs(30); +/// EMA weight for raw UPower estimates; lower = smoother but laggier. +// ponytail: fixed alpha, make configurable if laggy/jittery on real hardware. +const ETA_ALPHA: f64 = 0.3; +/// Minimum change before republishing ETA; matches the minute-resolution display. +const ETA_DEADBAND_SECS: u64 = 60; /// UPower device states (`org.freedesktop.UPower.Device.State`). const CHARGING: u32 = 1; @@ -34,8 +39,9 @@ impl BatteryService { 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; + let mut eta_filter = EtaFilter::default(); loop { - match serve(&mut sender, key, &mut last).await { + match serve(&mut sender, key, &mut last, &mut eta_filter).await { // Subscriber gone: stop the service. Ok(false) => return, Ok(true) => tracing::debug!(key, "upower stream ended; reconnecting"), @@ -54,12 +60,13 @@ async fn serve( sender: &mut mpsc::Sender, key: &'static str, last: &mut Option<(i32, BatteryStatus, i64, i64)>, + eta_filter: &mut EtaFilter, ) -> 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 { + if !emit(sender, key, last, eta_filter, read(&device).await?).await { return Ok(false); } @@ -77,7 +84,7 @@ async fn serve( Some(change) = time_to_full.next() => (percent, status, change.get().await?, to_empty), else => return Ok(true), }; - if !emit(sender, key, last, current).await { + if !emit(sender, key, last, eta_filter, current).await { return Ok(false); } } @@ -89,6 +96,7 @@ async fn emit( sender: &mut mpsc::Sender, key: &'static str, last: &mut Option<(i32, BatteryStatus, i64, i64)>, + eta_filter: &mut EtaFilter, (percent, status, to_full, to_empty): (i32, BatteryStatus, i64, i64), ) -> bool { let mut events = Vec::new(); @@ -100,11 +108,9 @@ async fn emit( 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)); + if let Some(smoothed) = eta_filter.update(status, to_full, to_empty) { + tracing::debug!(key, ?smoothed, "publishing battery eta"); + events.push(BatteryEvent::EtaChanged(smoothed)); } *last = Some((percent, status, to_full, to_empty)); for event in events { @@ -127,6 +133,60 @@ async fn read(device: &DeviceProxy<'_>) -> Result<(i32, BatteryStatus, i64, i64) )) } +/// Low-pass + deadband filter for UPower's jittery time estimates. +/// `update` returns `Some` only when the module should be notified. +#[derive(Default)] +struct EtaFilter { + smooth: Option, + status: Option, + published: Option>, +} + +impl EtaFilter { + fn update( + &mut self, + status: BatteryStatus, + to_full: i64, + to_empty: i64, + ) -> Option> { + let raw = eta(status, to_full, to_empty); + let Some(raw) = raw else { + self.smooth = None; + self.status = Some(status); + // First run with no estimate: nothing to report (matches unfiltered behavior). + let changed = self.published.is_some_and(|p| p.is_some()); + self.published = Some(None); + return changed.then_some(None); + }; + // Estimate source switches with charge state, so past smoothing is stale. + let reset = self.status != Some(status) || self.smooth.is_none(); + self.status = Some(status); + let smooth = if reset { + raw as f64 + } else { + ETA_ALPHA * raw as f64 + (1.0 - ETA_ALPHA) * self.smooth.unwrap_or(raw as f64) + }; + self.smooth = Some(smooth); + let candidate = smooth.round() as u64; + match self.published { + // First sample: publish immediately. + None => { + self.published = Some(Some(candidate)); + Some(Some(candidate)) + } + Some(published) => { + let drift = published.map_or(u64::MAX, |p| p.abs_diff(candidate)); + if reset || drift >= ETA_DEADBAND_SECS { + self.published = Some(Some(candidate)); + Some(Some(candidate)) + } else { + None + } + } + } + } +} + /// 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`. @@ -182,3 +242,48 @@ trait Device { #[zbus(property)] fn time_to_full(&self) -> zbus::Result; } + +#[cfg(test)] +mod tests { + use super::*; + + const DIS: BatteryStatus = BatteryStatus::Discharging; + + #[test] + fn jitter_within_deadband_is_suppressed() { + let mut f = EtaFilter::default(); + assert_eq!(f.update(DIS, 0, 3600), Some(Some(3600))); + assert_eq!(f.update(DIS, 0, 3610), None); + assert_eq!(f.update(DIS, 0, 3590), None); + } + + #[test] + fn large_step_publishes_smoothed_value() { + let mut f = EtaFilter::default(); + assert_eq!(f.update(DIS, 0, 3600), Some(Some(3600))); + // EMA: 0.3*1800 + 0.7*3600 = 3060, drift 540 >= 60. + assert_eq!(f.update(DIS, 0, 1800), Some(Some(3060))); + } + + #[test] + fn status_flip_resets_and_publishes() { + let mut f = EtaFilter::default(); + assert_eq!(f.update(DIS, 0, 3600), Some(Some(3600))); + assert_eq!(f.update(BatteryStatus::Charging, 1200, 0), Some(Some(1200))); + } + + #[test] + fn loss_and_return_of_estimate_publishes() { + let mut f = EtaFilter::default(); + assert_eq!(f.update(DIS, 0, 3600), Some(Some(3600))); + assert_eq!(f.update(DIS, 0, 0), Some(None)); + assert_eq!(f.update(DIS, 0, 0), None); + assert_eq!(f.update(DIS, 0, 3500), Some(Some(3500))); + } + + #[test] + fn first_run_without_estimate_stays_silent() { + let mut f = EtaFilter::default(); + assert_eq!(f.update(BatteryStatus::Full, 0, 0), None); + } +}