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}