Skip to main content

openlogi_device/inventory/
persist.rs

1//! The immutable probe cache's persistable form, and the port that keeps it.
2//!
3//! A device's expensive probe result (model info, capabilities, feature
4//! indexes) is immutable, so it only ever needs to be read once per device —
5//! but the in-memory cache dies with the process, forcing every agent restart
6//! to re-interview every device. Persisting the cache means a device that was
7//! fully probed once keeps its identity across restarts, even on transports
8//! where a fresh walk is slow or failing (see `BOLT_SLOT_PROBE`).
9//!
10//! Only Bolt identities are persisted, because only they are keyed on the
11//! device's *own* identity (the pairing-register unit id), which no re-pairing
12//! can silently reassign. A `CacheKey::UnifyingSlot` is `receiver + slot`: a
13//! different device paired into that slot while the agent is down would
14//! inherit the previous occupant's probe on warm start. A `CacheKey::Direct`
15//! is an OS-runtime node id with no cross-boot stability. Loaded entries get
16//! `probed_tick = 0`, so the regular `cache::REFRESH_TICKS`
17//! self-healing pass re-walks them on schedule; until (and unless) that walk
18//! succeeds, the persisted data serves exactly like an in-memory cache hit.
19//!
20//! *Where* a snapshot is kept is the host's business, not this module's: the
21//! enumerator writes through a [`ProbeCacheStore`]; `openlogi-hid` supplies
22//! the file-backed one every native build uses.
23
24use std::collections::HashMap;
25
26use serde::{Deserialize, Serialize};
27use thiserror::Error;
28
29use super::cache::{CacheKey, Cached};
30use super::features::{BatteryProbe, ProbedFeatures};
31
32/// Bumped when the persisted shape changes; a mismatched snapshot is discarded
33/// (the cache is a warm-start optimization, not data anyone must keep).
34/// v2 dropped the `UnifyingSlot` key (slot-keyed, so not re-pair-safe).
35const SCHEMA_VERSION: u32 = 2;
36
37impl ProbeCacheError {
38    /// Report why a store could not keep a snapshot.
39    #[must_use]
40    pub fn new(reason: impl std::fmt::Display) -> Self {
41        Self(reason.to_string())
42    }
43}
44
45/// A probe-cache store could not keep a snapshot.
46///
47/// Carries only a message: nothing branches on why a best-effort write failed,
48/// and the reasons differ per store (a filesystem error, a browser storage
49/// quota).
50#[derive(Debug, Error)]
51#[error("{0}")]
52pub struct ProbeCacheError(String);
53
54/// Where an [`Enumerator`](super::Enumerator)'s probe cache lives between runs.
55///
56/// A port, like [`HidBackend`](crate::backend::HidBackend): the enumerator
57/// knows what is worth keeping and when it changed, and nothing about where it
58/// goes. `openlogi-hid` supplies the file-backed one native builds use.
59pub trait ProbeCacheStore: Send + Sync {
60    /// The last snapshot saved here, or an empty one.
61    ///
62    /// Never fails: an absent, torn or foreign-schema store is a cold start,
63    /// which the enumerator handles by re-probing — not an error worth
64    /// propagating into device discovery.
65    fn load(&self) -> ProbeCacheSnapshot;
66
67    /// Persist `snapshot`.
68    ///
69    /// An `Err` is logged by the caller and the snapshot retried on the next
70    /// tick that dirties the cache, so a store may fail freely.
71    fn save(&self, snapshot: &ProbeCacheSnapshot) -> Result<(), ProbeCacheError>;
72}
73
74/// The persistable subset of the probe cache, in the shape a store keeps.
75#[derive(Serialize, Deserialize)]
76pub struct ProbeCacheSnapshot {
77    version: u32,
78    entries: Vec<PersistedEntry>,
79}
80
81#[derive(Serialize, Deserialize)]
82struct PersistedEntry {
83    key: PersistedKey,
84    probe: ProbedFeatures,
85    battery: Option<BatteryProbe>,
86}
87
88/// The persistable subset of [`CacheKey`] — Bolt only (see the module docs).
89#[derive(Clone, Copy, Serialize, Deserialize)]
90enum PersistedKey {
91    Bolt { unit_id: [u8; 4] },
92}
93
94fn persistable(key: &CacheKey) -> Option<PersistedKey> {
95    match key {
96        CacheKey::Bolt { unit_id } => Some(PersistedKey::Bolt { unit_id: *unit_id }),
97        CacheKey::UnifyingSlot { .. } | CacheKey::Direct(_) => None,
98    }
99}
100
101/// Whether a cache change under `key` affects the persisted snapshot at all —
102/// gates `cache_dirty` so churn on never-persisted keys (e.g. a direct-only
103/// system's full refresh) doesn't rewrite an unchanged snapshot every pass.
104pub(super) fn is_persistable(key: &CacheKey) -> bool {
105    persistable(key).is_some()
106}
107
108fn runtime_key(key: PersistedKey) -> CacheKey {
109    match key {
110        PersistedKey::Bolt { unit_id } => CacheKey::Bolt { unit_id },
111    }
112}
113
114impl ProbeCacheSnapshot {
115    /// A snapshot carrying nothing — what a store with no readable content
116    /// returns, and a cold start for the enumerator.
117    #[must_use]
118    pub fn empty() -> Self {
119        Self {
120            version: SCHEMA_VERSION,
121            entries: Vec::new(),
122        }
123    }
124
125    /// Whether this snapshot carries nothing — a store may skip writing one.
126    #[must_use]
127    pub fn is_empty(&self) -> bool {
128        self.entries.is_empty()
129    }
130
131    /// Everything in `cache` worth keeping across restarts.
132    pub(super) fn of(cache: &HashMap<CacheKey, Cached>) -> Self {
133        let entries = cache
134            .iter()
135            .filter_map(|(key, cached)| {
136                persistable(key).map(|key| {
137                    // The battery *reading* is volatile and re-read live on
138                    // every cache hit — persisting it would resurrect a stale
139                    // value after a restart. The battery *feature index*
140                    // (`PersistedEntry::battery`) is immutable and kept.
141                    let mut probe = cached.probe.clone();
142                    probe.battery = None;
143                    PersistedEntry {
144                        key,
145                        probe,
146                        battery: cached.battery,
147                    }
148                })
149            })
150            .collect();
151        Self {
152            version: SCHEMA_VERSION,
153            entries,
154        }
155    }
156
157    /// Fold this snapshot back into runtime cache entries.
158    ///
159    /// A snapshot written by another schema version yields nothing: the shape
160    /// it describes is not the one this build reads, and re-probing is always
161    /// correct.
162    pub(super) fn into_entries(self) -> HashMap<CacheKey, Cached> {
163        if self.version != SCHEMA_VERSION {
164            tracing::debug!(
165                version = self.version,
166                "probe cache from another schema — starting cold"
167            );
168            return HashMap::new();
169        }
170        self.entries
171            .into_iter()
172            .map(|entry| {
173                (
174                    runtime_key(entry.key),
175                    Cached {
176                        probe: entry.probe,
177                        battery: entry.battery,
178                        // Restart the refresh clock: the entry serves
179                        // immediately as a cache hit, and the periodic
180                        // self-healing re-walk decides when it is due for a
181                        // fresh read.
182                        probed_tick: 0,
183                    },
184                )
185            })
186            .collect()
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use std::collections::HashMap;
193
194    use openlogi_core::device::{
195        BatteryInfo, BatteryLevel, BatteryStatus, DeviceModelInfo, DeviceTransports,
196    };
197
198    use super::super::cache::{CacheKey, Cached};
199    use super::super::features::{BatteryProbe, ProbedFeatures};
200    use super::{ProbeCacheSnapshot, SCHEMA_VERSION};
201
202    /// A device fully probed once keeps its identity across restarts — that is
203    /// the whole point of the snapshot — but only the parts that are actually
204    /// immutable, and only for keys a re-pair cannot silently reassign.
205    #[test]
206    fn a_snapshot_keeps_bolt_identity_and_drops_the_volatile_reading() {
207        let model = DeviceModelInfo {
208            entity_count: 1,
209            serial_number: Some("TESTSERIAL01".into()),
210            unit_id: [0xaa, 0xbb, 0xcc, 0xdd],
211            transports: DeviceTransports::default(),
212            model_ids: [0xb042, 0, 0],
213            extended_model_id: 0,
214        };
215        let mut cache = HashMap::new();
216        cache.insert(
217            CacheKey::Bolt {
218                unit_id: [0xaa, 0xbb, 0xcc, 0xdd],
219            },
220            Cached {
221                probe: ProbedFeatures {
222                    model_info: Some(model.clone()),
223                    // A live reading at snapshot time.
224                    battery: Some(BatteryInfo {
225                        percentage: 55,
226                        level: BatteryLevel::Good,
227                        status: BatteryStatus::Discharging,
228                    }),
229                    ..Default::default()
230                },
231                battery: Some(BatteryProbe::Unified(9)),
232                probed_tick: 7,
233            },
234        );
235        cache.insert(
236            CacheKey::UnifyingSlot {
237                receiver_uid: "DA2699E1".into(),
238                slot: 2,
239            },
240            Cached {
241                probe: ProbedFeatures::default(),
242                battery: None,
243                probed_tick: 3,
244            },
245        );
246
247        let restored = ProbeCacheSnapshot::of(&cache).into_entries();
248
249        let bolt = restored
250            .get(&CacheKey::Bolt {
251                unit_id: [0xaa, 0xbb, 0xcc, 0xdd],
252            })
253            .expect("a Bolt entry survives");
254        assert_eq!(bolt.probe.model_info.as_ref(), Some(&model));
255        assert_eq!(
256            bolt.battery,
257            Some(BatteryProbe::Unified(9)),
258            "the battery *feature index* is immutable and kept"
259        );
260        assert!(
261            bolt.probe.battery.is_none(),
262            "the battery *reading* is volatile — restoring it would resurrect a stale value"
263        );
264        assert_eq!(
265            bolt.probed_tick, 0,
266            "a restored entry restarts the refresh clock"
267        );
268        assert!(
269            !restored.contains_key(&CacheKey::UnifyingSlot {
270                receiver_uid: "DA2699E1".into(),
271                slot: 2,
272            }),
273            "unifying entries are slot-keyed, so a re-pair while the agent is \
274             down could hand them to a different device — never persisted"
275        );
276    }
277
278    /// A snapshot written by another schema describes a shape this build does
279    /// not read. Re-probing is always correct; guessing is not.
280    #[test]
281    fn a_foreign_schema_yields_a_cold_start() {
282        let mut snapshot = ProbeCacheSnapshot::of(&HashMap::new());
283        snapshot.version = SCHEMA_VERSION + 1;
284
285        assert!(snapshot.into_entries().is_empty());
286    }
287}