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.is_none() && p.percentage.is_some_and(|p| p > 0) =>
{
held(p)
}
(BatteryProbe::Voltage(_) | BatteryProbe::Adc(_), Some(p)) => held(p),
(BatteryProbe::Voltage(_) | BatteryProbe::Adc(_), None) => BatteryInfo {
percentage: None,
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 {
super::probe_cache_is_stale(cached.probed_at, now)
}
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: Option<u8>, status: BatteryStatus) -> BatteryInfo {
BatteryInfo {
percentage,
level: BatteryLevel::Good,
status,
}
}
const UNIFIED: BatteryProbe = BatteryProbe::Unified {
index: 0,
state_of_charge: true,
};
#[test]
fn charging_without_a_percentage_holds_last_known_percentage() {
let legacy = BatteryProbe::Legacy(0);
let held = hold_percentage_while_charging(
battery(None, BatteryStatus::Charging),
Some(&battery(Some(85), BatteryStatus::Discharging)),
legacy,
);
assert_eq!(held.percentage, Some(85));
assert_eq!(held.status, BatteryStatus::Charging);
let discharging = hold_percentage_while_charging(
battery(Some(0), BatteryStatus::Discharging),
Some(&battery(Some(85), BatteryStatus::Discharging)),
legacy,
);
assert_eq!(discharging.percentage, Some(0));
let live = hold_percentage_while_charging(
battery(Some(40), BatteryStatus::Charging),
Some(&battery(Some(85), BatteryStatus::Discharging)),
legacy,
);
assert_eq!(live.percentage, Some(40));
let cold =
hold_percentage_while_charging(battery(None, BatteryStatus::Charging), None, legacy);
assert_eq!(cold.percentage, None);
}
#[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(Some(96), BatteryStatus::Charging),
Some(&battery(Some(65), BatteryStatus::Discharging)),
probe,
);
assert_eq!(held.percentage, Some(65), "{probe:?}");
assert_eq!(held.status, BatteryStatus::Charging, "{probe:?}");
let still = hold_percentage_while_charging(
battery(Some(97), BatteryStatus::ChargingSlow),
Some(&held),
probe,
);
assert_eq!(still.percentage, Some(65), "{probe:?}");
assert_eq!(still.status, BatteryStatus::ChargingSlow, "{probe:?}");
let full = hold_percentage_while_charging(
battery(Some(100), BatteryStatus::Full),
Some(&still),
probe,
);
assert_eq!(full.percentage, Some(100), "{probe:?}");
let unplugged = hold_percentage_while_charging(
battery(Some(66), BatteryStatus::Discharging),
Some(&still),
probe,
);
assert_eq!(unplugged.percentage, Some(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(Some(96), BatteryStatus::Charging),
None,
probe,
);
assert_eq!(cold.percentage, None, "{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(Some(0), BatteryStatus::Charging),
Some(&battery(Some(85), BatteryStatus::Discharging)),
UNIFIED,
);
assert_eq!(live.percentage, Some(0));
}
#[test]
fn a_level_only_unified_reading_passes_through_while_charging() {
let level_only = BatteryProbe::Unified {
index: 0,
state_of_charge: false,
};
let fresh = BatteryInfo {
percentage: None,
level: BatteryLevel::Low,
status: BatteryStatus::Charging,
};
let live = hold_percentage_while_charging(
fresh.clone(),
Some(&battery(None, BatteryStatus::Discharging)),
level_only,
);
assert_eq!(live, fresh);
}
}