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