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.
|
/// How long to wait before retrying when UPower is unreachable.
|
||||||
const RETRY: Duration = Duration::from_secs(30);
|
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`).
|
/// UPower device states (`org.freedesktop.UPower.Device.State`).
|
||||||
const CHARGING: u32 = 1;
|
const CHARGING: u32 = 1;
|
||||||
@@ -34,8 +39,9 @@ impl BatteryService {
|
|||||||
iced::stream::channel(0, move |mut sender: mpsc::Sender<Wire>| async move {
|
iced::stream::channel(0, move |mut sender: mpsc::Sender<Wire>| async move {
|
||||||
tracing::info!(key, "battery service started");
|
tracing::info!(key, "battery service started");
|
||||||
let mut last: Option<(i32, BatteryStatus, i64, i64)> = None;
|
let mut last: Option<(i32, BatteryStatus, i64, i64)> = None;
|
||||||
|
let mut eta_filter = EtaFilter::default();
|
||||||
loop {
|
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.
|
// Subscriber gone: stop the service.
|
||||||
Ok(false) => return,
|
Ok(false) => return,
|
||||||
Ok(true) => tracing::debug!(key, "upower stream ended; reconnecting"),
|
Ok(true) => tracing::debug!(key, "upower stream ended; reconnecting"),
|
||||||
@@ -54,12 +60,13 @@ async fn serve(
|
|||||||
sender: &mut mpsc::Sender<Wire>,
|
sender: &mut mpsc::Sender<Wire>,
|
||||||
key: &'static str,
|
key: &'static str,
|
||||||
last: &mut Option<(i32, BatteryStatus, i64, i64)>,
|
last: &mut Option<(i32, BatteryStatus, i64, i64)>,
|
||||||
|
eta_filter: &mut EtaFilter,
|
||||||
) -> Result<bool, zbus::Error> {
|
) -> Result<bool, zbus::Error> {
|
||||||
let conn = zbus::Connection::system().await?;
|
let conn = zbus::Connection::system().await?;
|
||||||
let display = UPowerProxy::new(&conn).await?.get_display_device().await?;
|
let display = UPowerProxy::new(&conn).await?.get_display_device().await?;
|
||||||
let device = DeviceProxy::builder(&conn).path(display)?.build().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);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +84,7 @@ async fn serve(
|
|||||||
Some(change) = time_to_full.next() => (percent, status, change.get().await?, to_empty),
|
Some(change) = time_to_full.next() => (percent, status, change.get().await?, to_empty),
|
||||||
else => return Ok(true),
|
else => return Ok(true),
|
||||||
};
|
};
|
||||||
if !emit(sender, key, last, current).await {
|
if !emit(sender, key, last, eta_filter, current).await {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +96,7 @@ async fn emit(
|
|||||||
sender: &mut mpsc::Sender<Wire>,
|
sender: &mut mpsc::Sender<Wire>,
|
||||||
key: &'static str,
|
key: &'static str,
|
||||||
last: &mut Option<(i32, BatteryStatus, i64, i64)>,
|
last: &mut Option<(i32, BatteryStatus, i64, i64)>,
|
||||||
|
eta_filter: &mut EtaFilter,
|
||||||
(percent, status, to_full, to_empty): (i32, BatteryStatus, i64, i64),
|
(percent, status, to_full, to_empty): (i32, BatteryStatus, i64, i64),
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
@@ -100,11 +108,9 @@ async fn emit(
|
|||||||
tracing::debug!(key, ?status, "publishing battery status");
|
tracing::debug!(key, ?status, "publishing battery status");
|
||||||
events.push(BatteryEvent::StatusChanged(status));
|
events.push(BatteryEvent::StatusChanged(status));
|
||||||
}
|
}
|
||||||
let old_eta = last.map(|(_, s, f, e)| eta(s, f, e)).unwrap_or_default();
|
if let Some(smoothed) = eta_filter.update(status, to_full, to_empty) {
|
||||||
let new_eta = eta(status, to_full, to_empty);
|
tracing::debug!(key, ?smoothed, "publishing battery eta");
|
||||||
if old_eta != new_eta {
|
events.push(BatteryEvent::EtaChanged(smoothed));
|
||||||
tracing::debug!(key, ?new_eta, "publishing battery eta");
|
|
||||||
events.push(BatteryEvent::EtaChanged(new_eta));
|
|
||||||
}
|
}
|
||||||
*last = Some((percent, status, to_full, to_empty));
|
*last = Some((percent, status, to_full, to_empty));
|
||||||
for event in events {
|
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.
|
/// Seconds until full while charging, or until empty while discharging.
|
||||||
/// UPower reports 0 when there is no estimate, and neither state applies
|
/// UPower reports 0 when there is no estimate, and neither state applies
|
||||||
/// once full, empty, or unknown — all of those surface as `None`.
|
/// once full, empty, or unknown — all of those surface as `None`.
|
||||||
@@ -182,3 +242,48 @@ trait Device {
|
|||||||
#[zbus(property)]
|
#[zbus(property)]
|
||||||
fn time_to_full(&self) -> zbus::Result<i64>;
|
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