battery eta smoothing

This commit is contained in:
2026-09-16 14:22:28 +01:00
parent 489e7ff326
commit 5f338c4278
+113 -8
View File
@@ -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<Wire>| 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<Wire>,
key: &'static str,
last: &mut Option<(i32, BatteryStatus, i64, i64)>,
eta_filter: &mut EtaFilter,
) -> 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 {
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<Wire>,
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<f64>,
status: Option<BatteryStatus>,
published: Option<Option<u64>>,
}
impl EtaFilter {
fn update(
&mut self,
status: BatteryStatus,
to_full: i64,
to_empty: i64,
) -> Option<Option<u64>> {
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<i64>;
}
#[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);
}
}