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.
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}