use std::sync::Arc;
use std::time::{Duration, Instant};
use hidpp::channel::HidppChannel;
use openlogi_core::device::{BatteryInfo, BatteryLevel, BatteryStatus};
use super::events::{EventFeatureIndices, EventSubscriptionHandle};
use super::features::{BatteryProbe, BatteryRead, ProbedFeatures, probe_features, read_battery};
use crate::backend::NodeId;
pub(super) const REFRESH_INTERVAL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(super) enum CacheKey {
Bolt { unit_id: [u8; 4] },
UnifyingSlot { receiver_uid: String, slot: u8 },
Direct(NodeId),
}
pub(super) const CACHE_MISS_GRACE: u8 = 3;
#[derive(Clone)]
pub(super) struct Cached {
pub(super) probe: ProbedFeatures,
pub(super) battery: Option<BatteryProbe>,
pub(super) events: EventFeatureIndices,
pub(super) probed_at: Instant,
}
fn hold_percentage_while_charging(
fresh: BatteryInfo,
prev: Option<&BatteryInfo>,
probe: BatteryProbe,
) -> BatteryInfo {
let charging = matches!(
fresh.status,
BatteryStatus::Charging | BatteryStatus::ChargingSlow
);
if !charging {
return fresh;
}
let held = |p: &BatteryInfo| BatteryInfo {
percentage: p.percentage,
level: p.level,
status: fresh.status,
};
match (probe, prev) {
(BatteryProbe::Legacy(_), Some(p)) if fresh.percentage == 0 && p.percentage > 0 => held(p),
(BatteryProbe::Voltage(_) | BatteryProbe::Adc(_), Some(p)) => held(p),
(BatteryProbe::Voltage(_) | BatteryProbe::Adc(_), None) => BatteryInfo {
percentage: 0,
level: BatteryLevel::Unknown,
status: fresh.status,
},
_ => fresh,
}
}
async fn refresh_battery(
channel: &Arc<HidppChannel>,
index: u8,
probe: BatteryProbe,
previous: Option<&ProbedFeatures>,
) -> (Option<BatteryInfo>, bool) {
match read_battery(channel, index, probe).await {
BatteryRead::Reading(battery) => (
Some(hold_percentage_while_charging(
battery,
previous.and_then(|known| known.battery.as_ref()),
probe,
)),
false,
),
BatteryRead::Unlinked => (None, true),
BatteryRead::Unavailable => previous.map_or((None, false), |known| {
(known.battery.clone(), known.unlinked)
}),
}
}
pub(super) enum CacheOutcome {
Fresh(CacheKey, Cached),
Update(CacheKey, Cached),
Seen(CacheKey),
Unkeyed,
}
impl CacheOutcome {
pub(super) fn key(&self) -> Option<&CacheKey> {
match self {
Self::Fresh(key, _) | Self::Update(key, _) | Self::Seen(key) => Some(key),
Self::Unkeyed => None,
}
}
}
pub(super) fn seen(id: Option<CacheKey>) -> CacheOutcome {
id.map_or(CacheOutcome::Unkeyed, CacheOutcome::Seen)
}
pub(super) fn is_stale(cached: &Cached, now: Instant) -> bool {
now.saturating_duration_since(cached.probed_at) >= REFRESH_INTERVAL
}
pub(super) async fn probe_or_reuse(
channel: &Arc<HidppChannel>,
index: u8,
id: Option<CacheKey>,
cached: Option<&Cached>,
online: bool,
now: Instant,
subscriptions: Option<&EventSubscriptionHandle>,
) -> (ProbedFeatures, CacheOutcome) {
if let (Some(cached), Some(subscriptions)) = (cached, subscriptions) {
subscriptions.register_device(index, cached.events);
}
if online && cached.is_none_or(|c| is_stale(c, now)) {
let (mut fresh, battery, events) = probe_features(channel, index, subscriptions).await;
if let Some(probe) = battery {
(fresh.battery, fresh.unlinked) =
refresh_battery(channel, index, probe, cached.map(|known| &known.probe)).await;
}
if fresh.capabilities.is_some() {
if let Some(c) = cached {
backfill_identity(&mut fresh, &c.probe);
}
if fresh.identity_incomplete && cached.is_none() {
return (fresh, seen(id));
}
if fresh.capabilities_incomplete {
if let Some(c) = cached {
keep_known_capabilities(&mut fresh, &c.probe);
}
return (fresh, seen(id));
}
return match id {
Some(key) => {
let value = Cached {
probe: fresh.clone(),
battery,
events,
probed_at: now,
};
(fresh, CacheOutcome::Fresh(key, value))
}
None => (fresh, CacheOutcome::Unkeyed),
};
}
return match cached {
Some(c) => (c.probe.clone(), seen(id)),
None => (fresh, seen(id)),
};
}
match cached {
Some(c) => {
if online
&& let Some(probe) = c.battery
&& let Some(key) = id.clone()
{
let mut entry = c.clone();
(entry.probe.battery, entry.probe.unlinked) =
refresh_battery(channel, index, probe, Some(&c.probe)).await;
return (entry.probe.clone(), CacheOutcome::Update(key, entry));
}
(c.probe.clone(), seen(id))
}
None => (ProbedFeatures::default(), seen(id)),
}
}
pub(super) fn keep_known_capabilities(fresh: &mut ProbedFeatures, cached: &ProbedFeatures) {
if fresh.capabilities_incomplete && cached.capabilities.is_some() {
fresh.capabilities.clone_from(&cached.capabilities);
}
}
pub(super) fn backfill_identity(fresh: &mut ProbedFeatures, cached: &ProbedFeatures) {
if fresh.kind.is_none() {
fresh.kind = cached.kind;
}
if fresh.marketing_name.is_none() {
fresh.marketing_name.clone_from(&cached.marketing_name);
}
if !fresh.identity_incomplete {
return;
}
match (fresh.model_info.as_mut(), cached.model_info.as_ref()) {
(None, Some(previous)) => {
fresh.model_info = Some(previous.clone());
fresh.identity_incomplete = false;
}
(Some(now), Some(previous))
if now.serial_number.is_none() && previous.serial_number.is_some() =>
{
now.serial_number.clone_from(&previous.serial_number);
fresh.identity_incomplete = false;
}
_ => {}
}
}
#[cfg(test)]
mod hold_tests {
use openlogi_core::device::{BatteryInfo, BatteryLevel, BatteryStatus};
use super::{BatteryProbe, hold_percentage_while_charging};
fn battery(percentage: u8, status: BatteryStatus) -> BatteryInfo {
BatteryInfo {
percentage,
level: BatteryLevel::Good,
status,
}
}
#[test]
fn charging_zero_holds_last_known_percentage() {
let legacy = BatteryProbe::Legacy(0);
let held = hold_percentage_while_charging(
battery(0, BatteryStatus::Charging),
Some(&battery(85, BatteryStatus::Discharging)),
legacy,
);
assert_eq!(held.percentage, 85);
assert_eq!(held.status, BatteryStatus::Charging);
let discharging = hold_percentage_while_charging(
battery(0, BatteryStatus::Discharging),
Some(&battery(85, BatteryStatus::Discharging)),
legacy,
);
assert_eq!(discharging.percentage, 0);
let live = hold_percentage_while_charging(
battery(40, BatteryStatus::Charging),
Some(&battery(85, BatteryStatus::Discharging)),
legacy,
);
assert_eq!(live.percentage, 40);
let cold =
hold_percentage_while_charging(battery(0, BatteryStatus::Charging), None, legacy);
assert_eq!(cold.percentage, 0);
}
#[test]
fn voltage_estimates_hold_the_last_reading_while_charging() {
for probe in [BatteryProbe::Voltage(0), BatteryProbe::Adc(0)] {
let held = hold_percentage_while_charging(
battery(96, BatteryStatus::Charging),
Some(&battery(65, BatteryStatus::Discharging)),
probe,
);
assert_eq!(held.percentage, 65, "{probe:?}");
assert_eq!(held.status, BatteryStatus::Charging, "{probe:?}");
let still = hold_percentage_while_charging(
battery(97, BatteryStatus::ChargingSlow),
Some(&held),
probe,
);
assert_eq!(still.percentage, 65, "{probe:?}");
assert_eq!(still.status, BatteryStatus::ChargingSlow, "{probe:?}");
let full = hold_percentage_while_charging(
battery(100, BatteryStatus::Full),
Some(&still),
probe,
);
assert_eq!(full.percentage, 100, "{probe:?}");
let unplugged = hold_percentage_while_charging(
battery(66, BatteryStatus::Discharging),
Some(&still),
probe,
);
assert_eq!(unplugged.percentage, 66, "{probe:?}");
}
}
#[test]
fn voltage_estimate_charging_from_cold_start_has_no_reading() {
for probe in [BatteryProbe::Voltage(0), BatteryProbe::Adc(0)] {
let cold =
hold_percentage_while_charging(battery(96, BatteryStatus::Charging), None, probe);
assert_eq!(cold.percentage, 0, "{probe:?}");
assert_eq!(cold.level, BatteryLevel::Unknown, "{probe:?}");
assert_eq!(cold.status, BatteryStatus::Charging, "{probe:?}");
}
}
#[test]
fn unified_charging_zero_is_not_held() {
let live = hold_percentage_while_charging(
battery(0, BatteryStatus::Charging),
Some(&battery(85, BatteryStatus::Discharging)),
BatteryProbe::Unified(0),
);
assert_eq!(live.percentage, 0);
}
}