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