battery eta smoothing
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user