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}