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