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}