openlogi_device/inventory.rs
1//! Enumerate connected HID++ receivers and their paired devices.
2
3use std::{
4 collections::{HashMap, HashSet, hash_map::Entry},
5 hash::Hash,
6 sync::Arc,
7 time::{Duration, Instant},
8};
9
10use futures_concurrency::future::Join as _;
11use hidpp::channel::HidppChannel;
12use openlogi_core::device::DeviceInventory;
13use thiserror::Error;
14use tracing::{debug, warn};
15
16use crate::ChannelRegistry;
17use crate::backend::{BackendError, HidBackend, NodeId, NodeInfo};
18use crate::channel::route::{DeviceRoute, find_receiver};
19use crate::host_lock;
20use ledger::{NodeLedger, SettledNode};
21
22mod cache;
23pub mod events;
24mod features;
25pub mod hotplug;
26mod ledger;
27mod mappings;
28pub mod persist;
29mod probe;
30pub mod standalone;
31
32use cache::{CACHE_MISS_GRACE, CacheKey, CacheOutcome, Cached};
33use events::{ChannelEventSubscriptions, EventNotifier, EventSubscriptionHandle};
34use persist::{ProbeCacheSnapshot, ProbeCacheStore};
35use probe::{NodeProbe, PassContext, ProbeVerdict, probe_one};
36
37/// How long to wait for device-arrival event bursts before assuming the
38/// receiver has finished reporting. MX Master 4 (and other devices that may
39/// be asleep) need a generous window to wake and respond to the arrival
40/// ping; we err on the side of waiting.
41const ARRIVAL_DRAIN: Duration = Duration::from_millis(1500);
42
43/// A Unifying receiver can transiently stall the first arrival-trigger write
44/// while its previous scan settles. Retry once inside the same probe instead
45/// of making the inventory ledger treat that single write as a dead channel.
46const UNIFYING_TRIGGER_RETRY_DELAY: Duration = Duration::from_millis(300);
47
48/// One device-arrival trigger addresses the receiver itself and should ACK
49/// immediately. Keep each attempt well inside the enclosing receiver probe
50/// budget so a slow write still retains its liveness-aware probe verdict.
51const UNIFYING_TRIGGER_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(750);
52
53/// Receiver register operations are normally answered in a few milliseconds.
54/// Keep a stalled liveness/notification request from consuming the enclosing
55/// receiver probe budget, so a responsive channel can still report an
56/// `AliveButIncomplete` arrival replay instead of becoming an ordinary probe
57/// timeout.
58const RECEIVER_OPERATION_TIMEOUT: Duration = Duration::from_millis(750);
59
60/// A receiver UID is cache metadata rather than a liveness gate. Give it a
61/// shorter window so a delayed serial-number read cannot crowd out the arrival
62/// replay and feature-walk budgets.
63const RECEIVER_UID_TIMEOUT: Duration = Duration::from_millis(500);
64
65/// Maximum number of pairing slots a Bolt receiver supports. We iterate this
66/// range to surface paired-but-offline devices that won't fire arrival events.
67const MAX_BOLT_SLOTS: u8 = 6;
68
69/// Upper bound on probing one HID node's I/O. `hidpp`'s request/response has
70/// no timeout of its own, so without this a single unresponsive (e.g. asleep)
71/// device wedges the whole enumeration, so a permanent hang would stall every
72/// later event or recovery reconciliation. Time spent waiting for the node's
73/// register phase is not I/O and sits outside it — see [`ProbeDeadlines`].
74///
75/// A timed-out node is skipped and re-probed by the bounded two-second repair
76/// deadline, and the first probe usually wakes the device so the retry succeeds
77/// fast.
78/// Slots are probed concurrently on both receiver paths, so a receiver's worst
79/// case is the 1.5 s arrival drain plus a single slot's [`BOLT_SLOT_PROBE`] /
80/// [`UNIFYING_SLOT_PROBE`] — not their sum — plus, on Bolt only, the
81/// sequential pairing-register pass that precedes the slot walk. This stays
82/// comfortably above that, so awake devices never trip it.
83///
84/// Sized for the Bluetooth-direct feature walk, the long pole: a ~35-entry
85/// table over a link that drops individual reports, which `hidpp::device`
86/// re-asks for per entry. At 6 s one lost report consumed the whole budget and
87/// the walk was abandoned mid-table, surfacing as a mouse that never appeared.
88const PROBE_BUDGET: Duration = Duration::from_secs(25);
89
90/// Probe budget for receiver nodes (Bolt/Unifying/Lightspeed dongles).
91///
92/// The 25 s [`PROBE_BUDGET`] is sized for Bluetooth-direct feature walks that
93/// receivers never perform. Keeping the receiver budget tighter matters
94/// because a full-budget timeout is also the detection path for a channel
95/// whose input-report delivery died (observed on macOS with concurrent opens
96/// of the same node: requests keep being written and answered, but the
97/// replies are delivered only to the other open handle). Until the channel is
98/// replaced every write on it stalls — DPI, SmartShift, ring haptics — so
99/// this budget bounds that outage.
100///
101/// It must still fit a receiver probe's real worst case, which is NOT the
102/// millisecond register reads but a paired device's full HID++ 2.0 feature
103/// walk: 1.5 s arrival drain + the sequential pairing-register pass + one
104/// slot's [`BOLT_SLOT_PROBE`] (10 s). 6 s proved too tight — a legitimate
105/// deep walk tripped the dead-delivery eviction, the surfaced-empty inventory
106/// tore down capture plans, and a pinned stale channel Arc then deadlocked
107/// recovery (dead buttons until restart). 13 s clears the honest worst case
108/// — and only that: the wait for the receiver's register phase, up to
109/// [`host_lock::RECEIVER_REGISTER_WAIT`] on its own, is taken before this
110/// budget starts (see [`ProbeDeadlines`]), or the two together would trip
111/// it on a working receiver.
112const RECEIVER_PROBE_BUDGET: Duration = Duration::from_secs(13);
113
114/// Per-slot budget for the HID++ 2.0 feature walk on a Unifying paired device.
115///
116/// Unifying wireless round-trips are slower than Bolt BTLE: some devices (e.g.
117/// K540) take ~3 s for the version ping to return. Running multiple slow slots
118/// concurrently can still consume the full PROBE_BUDGET and get cancelled
119/// mid-walk — the probe returns nothing rather than partial features. A
120/// per-slot cap ensures each slot's feature walk is bounded independently of
121/// how many other slots are being probed at the same time. A timed-out slot
122/// still surfaces in the inventory (kind + wpid from the arrival event) — it
123/// just lacks capabilities / battery until the next reconciliation.
124const UNIFYING_SLOT_PROBE: Duration = Duration::from_millis(3500);
125
126/// Per-slot budget when a Unifying device already has a fresh immutable probe.
127///
128/// This path normally performs just one battery read. Some Lightspeed devices
129/// occasionally omit that reply even though their receiver has just emitted a
130/// live device-arrival event. Do not let that optional refresh consume the
131/// full first-sight feature-walk budget or delay publication of a known-online
132/// mouse on every reconciliation.
133const UNIFYING_CACHED_SLOT_PROBE: Duration = Duration::from_millis(750);
134
135/// Per-slot budget for the HID++ 2.0 feature walk on a Bolt paired device.
136///
137/// Bounds a single device that stops answering its feature-walk reads (seen on
138/// a recent macOS IOHID stack with a new MX Master 4) so it falls back to its
139/// cached / identity-only data instead of pinning its slot future forever
140/// (#218). Slots walk *concurrently* (mirroring the Unifying path), so this
141/// budget covers the slowest single slot rather than dividing [`PROBE_BUDGET`]
142/// across the slot count. A healthy walk is not always fast either: a
143/// feature-rich device enumerates a large table one round-trip per feature
144/// (the MX Master 4's 45 features take ~1–1.6 s over Bolt even awake), and on
145/// high-latency USB paths (a Bolt receiver behind a KVM's USB emulation) it
146/// takes several seconds — the previous 3 s cap starved every slot there, so a
147/// newly paired device could never acquire model info at all. 10 s is generous
148/// headroom for degraded-but-alive paths while still fitting [`PROBE_BUDGET`]
149/// after the 1.5 s arrival drain and Bolt's sequential pairing-register pass.
150const BOLT_SLOT_PROBE: Duration = Duration::from_secs(10);
151
152/// The deadlines one probe pass runs under, kept together so their
153/// composition — which waits sit inside which budget — is one place to read,
154/// and one value for a test to shrink.
155///
156/// The composition: a receiver probe waits for the node's register phase for
157/// up to `register_lock_wait` *before* its `receiver_budget` starts, and
158/// under that budget runs an `arrival_drain` and slot walks each bounded by
159/// their own slot probe. A direct device runs under `direct_budget` alone.
160#[derive(Clone, Copy, Debug)]
161pub(crate) struct ProbeDeadlines {
162 /// How long a receiver probe waits for another OpenLogi process to
163 /// release the node's register phase before settling as deferred
164 /// ([`probe::ProbeVerdict::Deferred`]). Outside the I/O budget.
165 pub(crate) register_lock_wait: Duration,
166 /// [`RECEIVER_PROBE_BUDGET`].
167 pub(crate) receiver_budget: Duration,
168 /// [`PROBE_BUDGET`].
169 pub(crate) direct_budget: Duration,
170 /// [`ARRIVAL_DRAIN`].
171 pub(crate) arrival_drain: Duration,
172 /// [`BOLT_SLOT_PROBE`].
173 pub(crate) bolt_slot_probe: Duration,
174 /// [`UNIFYING_SLOT_PROBE`].
175 pub(crate) unifying_slot_probe: Duration,
176 /// [`UNIFYING_CACHED_SLOT_PROBE`].
177 pub(crate) unifying_cached_slot_probe: Duration,
178}
179
180impl ProbeDeadlines {
181 /// The production deadlines.
182 pub(crate) const DEFAULT: Self = Self {
183 register_lock_wait: host_lock::RECEIVER_REGISTER_WAIT,
184 receiver_budget: RECEIVER_PROBE_BUDGET,
185 direct_budget: PROBE_BUDGET,
186 arrival_drain: ARRIVAL_DRAIN,
187 bolt_slot_probe: BOLT_SLOT_PROBE,
188 unifying_slot_probe: UNIFYING_SLOT_PROBE,
189 unifying_cached_slot_probe: UNIFYING_CACHED_SLOT_PROBE,
190 };
191}
192
193/// Errors raised while enumerating HID++ devices.
194#[derive(Debug, Error)]
195pub enum InventoryError {
196 /// Underlying HID backend error.
197 #[error("HID transport error")]
198 Hid(#[from] BackendError),
199 /// More than one indistinguishable standalone raw-HID node was found.
200 #[error("multiple indistinguishable standalone raw HID devices found")]
201 AmbiguousRawDevice,
202}
203
204/// Stateful device enumerator: owns persistent channels and the per-device
205/// probe cache so the event-first watcher reuses immutable data across
206/// reconciliations. One-shot callers use the [`enumerate`] free function, which
207/// runs against a fresh (empty) cache.
208pub struct Enumerator {
209 /// The HID stack this enumerator walks. `openlogi-hid` supplies this
210 /// host's; tests and other hosts supply their own.
211 backend: Arc<dyn HidBackend>,
212 cache: HashMap<CacheKey, Cached>,
213 /// Consecutive passes each cached device has been missing, for grace-period
214 /// eviction.
215 misses: HashMap<CacheKey, u8>,
216 /// The cache entries each node has contributed and still holds: what a
217 /// deferred tick holds out of miss aging, since a probe that never ran
218 /// has seen nothing and missed nothing (see [`Self::evict_unseen`]).
219 node_cache_keys: HashMap<NodeId, HashSet<CacheKey>>,
220 /// Open HID++ channels reused across reconciliations, keyed by OS node id.
221 /// Opening (and tearing down) a device every pass is the churn issue #99 is about —
222 /// each open also leaks an `io_service_t` in async-hid's macOS backend — so a
223 /// steadily-connected node is opened once here and reused until it
224 /// disconnects.
225 channels: ChannelCache<NodeId, CachedChannel>,
226 /// Per-node last-good inventory + consecutive-failure counts: replays a
227 /// node's snapshot through transient probe failures and decides when its
228 /// cached channel must be dropped and reopened (see [`crate::inventory::ledger`]).
229 ledger: NodeLedger<NodeId>,
230 /// Optional publication sink used by the persistent Agent watcher. One-shot
231 /// callers keep this `None` and retain the route-opening library behavior.
232 registry: Option<ChannelRegistry>,
233 /// Where the immutable probe cache is kept across restarts, `None` for a
234 /// memory-only enumerator (one-shot CLI calls, tests).
235 store: Option<Arc<dyn ProbeCacheStore>>,
236 /// Whether the persistable cache content changed since the last save —
237 /// fresh full probes and evictions, not per-pass battery refreshes.
238 cache_dirty: bool,
239 /// Whether the most recent pass failed to open at least one HID++ node.
240 open_failures_last_tick: bool,
241 /// Whether the most recent pass needs a bounded fast follow-up to advance
242 /// ledger/channel/cache grace or retry a failed open.
243 retry_needed_last_tick: bool,
244 /// Coalesced lifecycle-event sink installed on newly opened channels.
245 event_notifier: Option<EventNotifier>,
246 /// The deadlines every pass's probes run under.
247 deadlines: ProbeDeadlines,
248}
249
250/// An open channel to a receiver / direct-device HID node, held across
251/// `enumerate` ticks. Evicting it (on disconnect, or when the `Enumerator`
252/// drops) closes the device and joins the channel's read thread via
253/// [`HidppChannel`]'s `Drop`.
254struct CachedChannel {
255 info: NodeInfo,
256 channel: Arc<HidppChannel>,
257 events: Option<ChannelEventSubscriptions>,
258}
259
260type ActiveNode = (NodeInfo, Arc<HidppChannel>, Option<EventSubscriptionHandle>);
261
262struct PreparedNodes {
263 active: Vec<ActiveNode>,
264 open_failures: Vec<NodeId>,
265 retiring: Vec<NodeId>,
266}
267
268/// One channel's place in its lifecycle: serving, or retired and draining
269/// toward quiescence so its node may reopen.
270enum ChannelState<Channel> {
271 Active(Channel),
272 Retiring(Channel),
273}
274
275/// Per-node channel lifecycle, generic so ownership transitions can be tested
276/// without constructing a platform HID node. One map on purpose: a node
277/// holding an active *and* a retiring channel — two live opens of one OS
278/// node, the state the macOS dead-delivery hazard rides on — used to be
279/// representable across two maps and guarded only by a release-silent
280/// `debug_assert`; keyed by node in a single map, it cannot exist.
281struct ChannelCache<Node, Channel> {
282 channels: HashMap<Node, ChannelState<Channel>>,
283}
284
285impl<Node, Channel> Default for ChannelCache<Node, Channel> {
286 fn default() -> Self {
287 Self {
288 channels: HashMap::new(),
289 }
290 }
291}
292
293impl<Node: Eq + Hash + Clone, Channel> ChannelCache<Node, Channel> {
294 fn get(&self, node: &Node) -> Option<&Channel> {
295 match self.channels.get(node)? {
296 ChannelState::Active(channel) => Some(channel),
297 ChannelState::Retiring(_) => None,
298 }
299 }
300
301 /// The active channels, for callers that sweep what is currently serving.
302 fn active_iter(&self) -> impl Iterator<Item = (&Node, &Channel)> {
303 self.channels
304 .iter()
305 .filter_map(|(node, state)| match state {
306 ChannelState::Active(channel) => Some((node, channel)),
307 ChannelState::Retiring(_) => None,
308 })
309 }
310
311 fn insert(&mut self, node: Node, channel: Channel) {
312 match self.channels.entry(node) {
313 Entry::Occupied(mut entry) => match entry.get_mut() {
314 state @ ChannelState::Active(_) => *state = ChannelState::Active(channel),
315 // A retiring channel is still draining toward quiescence, and
316 // `prepare_open` refuses the node until it has — so this arm
317 // is unreachable through current callers. Keeping the
318 // draining channel (and dropping the newcomer, which closes
319 // its OS handle promptly) preserves the one-channel-per-node
320 // invariant either way.
321 ChannelState::Retiring(_) => {
322 warn!("refusing to replace a retiring channel — dropping the fresh open");
323 drop(channel);
324 }
325 },
326 Entry::Vacant(entry) => {
327 entry.insert(ChannelState::Active(channel));
328 }
329 }
330 }
331
332 /// Move an active channel into the retiring state. `false` when the node
333 /// is unknown or already retiring (the original retirement keeps its
334 /// place — and its drain clock).
335 fn retire_node(&mut self, node: &Node) -> bool {
336 let Some(state) = self.channels.get_mut(node) else {
337 return false;
338 };
339 if matches!(state, ChannelState::Retiring(_)) {
340 return false;
341 }
342 let Some(ChannelState::Active(channel) | ChannelState::Retiring(channel)) =
343 self.channels.remove(node)
344 else {
345 return false;
346 };
347 self.channels
348 .insert(node.clone(), ChannelState::Retiring(channel));
349 true
350 }
351
352 /// Whether this node may be opened during the current tick. A quiescent
353 /// retirement is dropped here, but opening remains deferred to a later tick.
354 fn prepare_open(&mut self, node: &Node, is_quiescent: impl FnOnce(&Channel) -> bool) -> bool {
355 let Some(ChannelState::Retiring(channel)) = self.channels.get(node) else {
356 return true;
357 };
358 if is_quiescent(channel) {
359 self.channels.remove(node);
360 }
361 false
362 }
363
364 /// Retire every active node not in `seen`; returns how many retired.
365 fn retire_absent(&mut self, seen: &HashSet<Node>) -> usize {
366 let absent = self
367 .active_iter()
368 .map(|(node, _)| node)
369 .filter(|node| !seen.contains(*node))
370 .cloned()
371 .collect::<Vec<_>>();
372 absent
373 .into_iter()
374 .filter(|node| self.retire_node(node))
375 .count()
376 }
377
378 fn reap_absent(&mut self, seen: &HashSet<Node>, is_quiescent: impl Fn(&Channel) -> bool) {
379 self.channels.retain(|node, state| match state {
380 ChannelState::Active(_) => true,
381 ChannelState::Retiring(channel) => seen.contains(node) || !is_quiescent(channel),
382 });
383 }
384
385 #[cfg(test)]
386 fn is_retiring(&self, node: &Node) -> bool {
387 matches!(self.channels.get(node), Some(ChannelState::Retiring(_)))
388 }
389}
390
391fn routes_for_inventories(inventories: &[DeviceInventory]) -> Vec<DeviceRoute> {
392 inventories
393 .iter()
394 .flat_map(|inventory| {
395 inventory
396 .paired
397 .iter()
398 .filter_map(|paired| DeviceRoute::device_route_for(inventory, paired.slot))
399 })
400 .collect()
401}
402
403/// Fold one probe into the ledger. A deferred probe never touched the node,
404/// so it is replayed without a failure on the ledger's count; its verdict
405/// still fails `all_healthy`, which brings the one-shot retry round again.
406fn settle_probe<Node: Eq + Hash + Clone>(
407 ledger: &mut NodeLedger<Node>,
408 node: &Node,
409 verdict: ProbeVerdict,
410 inventory: Option<DeviceInventory>,
411) -> SettledNode {
412 match verdict {
413 ProbeVerdict::Deferred => ledger.defer(node),
414 ProbeVerdict::AliveButIncomplete => ledger.settle_arrival_replay_failure(node),
415 verdict => ledger.settle(node, verdict.is_healthy(), inventory),
416 }
417}
418
419fn settle_unhealthy_node<Node: Eq + Hash + Clone>(
420 ledger: &mut NodeLedger<Node>,
421 node: &Node,
422 all_complete: &mut bool,
423 all_healthy: &mut bool,
424) -> Option<DeviceInventory> {
425 *all_complete = false;
426 *all_healthy = false;
427 ledger.settle(node, false, None).inventory
428}
429
430/// Enumerate all Logitech HID++ receivers visible to the current process and
431/// the devices paired to each.
432///
433/// Combines two data sources per receiver:
434///
435/// - `trigger_device_arrival` events — the only path to a device's wireless
436/// PID in hidpp 0.2 (the `wpid` field on `BoltDevicePairingInformation` is
437/// private). Only online, responsive devices show up here.
438/// - `get_device_pairing_information` polled per slot — covers paired-but-
439/// offline devices (sleeping mice, devices on a different host) that the
440/// arrival ping doesn't wake. No wpid for these.
441///
442/// We merge the two so an MX Master that's been asleep still shows up with
443/// its codename and kind even before you click it.
444pub async fn enumerate(
445 backend: Arc<dyn HidBackend>,
446) -> Result<Vec<DeviceInventory>, InventoryError> {
447 // The persistent [`Enumerator`] keeps a per-node ledger across passes, so a
448 // transient probe miss replays the node's last good inventory. A one-shot
449 // caller (CLI `list` / `diag`) builds a fresh `Enumerator` whose ledger is
450 // empty, so a miss has nothing to replay and would surface as an empty or
451 // partial list — the two isolated runs in #218 read 3 devices and 0. Retry a
452 // few times instead, reusing the same enumerator so its ledger accumulates a
453 // snapshot a later attempt can replay and the opened channel stays warm.
454 // #226's 5 s request timeout inside `HidppChannel::send` makes a dead probe
455 // fail fast, so a short bounded retry is cheap. Some transports can answer
456 // while still yielding a short device set (for example, a Unifying arrival
457 // event landing just after the drain window). When every node answered this
458 // cycle but that healthy pass is still short, two identical inventories mean
459 // the expected stable Unifying offline drain has settled. A failed/timed-out
460 // probe must keep using the full retry budget so the next attempt can reopen
461 // the channel and recover.
462 let mut enumerator = Enumerator::with_backend(backend);
463 let mut scan = OneShotScan::new();
464 loop {
465 let (inventories, all_complete, all_healthy) =
466 enumerator.enumerate_reporting_completeness().await?;
467 let pass = ScanPass {
468 complete: all_complete,
469 healthy: all_healthy,
470 };
471 if scan.is_settled(&inventories, pass) {
472 return Ok(inventories);
473 }
474 debug!(
475 attempt = scan.attempt,
476 complete = pass.complete,
477 healthy = pass.healthy,
478 "one-shot enumerate inventory incomplete or still changing — retrying"
479 );
480 scan.advance(inventories, pass);
481 tokio::time::sleep(ONESHOT_RETRY_DELAY).await;
482 }
483}
484
485/// What one enumerate pass established about its own trustworthiness.
486#[derive(Clone, Copy)]
487struct ScanPass {
488 /// Every expected device is present in the snapshot.
489 complete: bool,
490 /// Every probe answered — the only kind of pass that counts as stability
491 /// evidence.
492 healthy: bool,
493}
494
495/// The one-shot retry loop's memory: the snapshot that may serve as stability
496/// evidence, and how many passes have run. [`Self::is_settled`] is the stop
497/// rule the tests pin.
498struct OneShotScan {
499 /// The previous pass's snapshot, kept only when that pass was healthy —
500 /// the unchanged-inventory stop only ever compares two consecutive
501 /// healthy snapshots. A failed/timed-out probe (a replayed last-good or
502 /// partial live result) is cleared so it can't count as one of the two
503 /// "stable" reads and short-circuit a later healthy-but-short pass.
504 stable_candidate: Option<Vec<DeviceInventory>>,
505 attempt: u8,
506}
507
508impl OneShotScan {
509 fn new() -> Self {
510 Self {
511 stable_candidate: None,
512 attempt: 1,
513 }
514 }
515
516 /// Stop when the snapshot is complete, when a healthy but short pass has
517 /// stabilized (the expected Unifying offline-drain case), or when the
518 /// attempt cap is reached.
519 fn is_settled(&self, current: &[DeviceInventory], pass: ScanPass) -> bool {
520 pass.complete
521 || (pass.healthy
522 && self
523 .stable_candidate
524 .as_deref()
525 .is_some_and(|previous| previous == current))
526 || self.attempt >= ONESHOT_ATTEMPTS
527 }
528
529 /// Fold one finished, unsettled pass in: a healthy snapshot becomes the
530 /// stability candidate, an unhealthy one clears it.
531 fn advance(&mut self, inventories: Vec<DeviceInventory>, pass: ScanPass) {
532 self.stable_candidate = pass.healthy.then_some(inventories);
533 self.attempt += 1;
534 }
535}
536
537/// Attempts a one-shot [`enumerate`] makes before returning whatever it last
538/// read, when an inventory keeps coming back incomplete or changing.
539const ONESHOT_ATTEMPTS: u8 = 4;
540
541/// Delay between one-shot [`enumerate`] retries. A first probe usually wakes an
542/// asleep device, so a short pause lets the next attempt read it cleanly.
543const ONESHOT_RETRY_DELAY: Duration = Duration::from_millis(300);
544
545/// Nodes that remain valid for this pass: everything the OS enumerated plus
546/// cached channels whose open transport still reports a live connection.
547fn retained_nodes<K>(
548 enumerated: &HashSet<K>,
549 cached_channels: impl IntoIterator<Item = (K, bool)>,
550) -> HashSet<K>
551where
552 K: Clone + Eq + Hash,
553{
554 let mut retained = enumerated.clone();
555 retained.extend(
556 cached_channels
557 .into_iter()
558 .filter_map(|(node, connected)| connected.then_some(node)),
559 );
560 retained
561}
562
563/// Add cached channels omitted by this OS enumeration while their open
564/// transport still reports a live connection.
565fn append_live_cached_channels(
566 nodes: &mut HashSet<NodeId>,
567 channels: &ChannelCache<NodeId, CachedChannel>,
568 active: &mut Vec<ActiveNode>,
569) {
570 let retained = retained_nodes(
571 nodes,
572 channels
573 .active_iter()
574 .map(|(node, open)| (node.clone(), open.channel.is_connected())),
575 );
576 for node in retained.difference(nodes) {
577 if let Some(open) = channels.get(node) {
578 debug!(
579 ?node,
580 name = %open.info.name,
581 "OS enumeration omitted a live HID node; probing cached channel"
582 );
583 active.push((
584 open.info.clone(),
585 Arc::clone(&open.channel),
586 open.events.as_ref().map(ChannelEventSubscriptions::handle),
587 ));
588 }
589 }
590 *nodes = retained;
591}
592
593impl Enumerator {
594 /// Whether the most recent [`enumerate`](Self::enumerate) pass failed to
595 /// open at least one HID++ node. `false` before the first pass.
596 ///
597 /// On macOS a run of passes with this set is the observable signature of a
598 /// denied Input Monitoring grant or a stale permission session — paired
599 /// with the grant state it separates "grant it" from "log out", which the
600 /// bare open error cannot (the denial is silent).
601 #[must_use]
602 pub fn open_failures_last_tick(&self) -> bool {
603 self.open_failures_last_tick
604 }
605
606 /// Whether the last pass needs a bounded fast follow-up to advance a
607 /// probe/channel/cache recovery policy. `false` before the first pass.
608 #[must_use]
609 pub fn retry_needed_last_tick(&self) -> bool {
610 self.retry_needed_last_tick
611 }
612
613 /// An enumerator that walks `backend` — this host's HID stack, a scripted
614 /// device tree in tests, or another host's.
615 #[must_use]
616 pub fn with_backend(backend: Arc<dyn HidBackend>) -> Self {
617 Self {
618 backend,
619 cache: HashMap::new(),
620 misses: HashMap::new(),
621 node_cache_keys: HashMap::new(),
622 channels: ChannelCache::default(),
623 ledger: NodeLedger::default(),
624 registry: None,
625 store: None,
626 cache_dirty: false,
627 open_failures_last_tick: false,
628 retry_needed_last_tick: false,
629 event_notifier: None,
630 deadlines: ProbeDeadlines::DEFAULT,
631 }
632 }
633
634 /// Publish this enumerator's already-open channels into `registry` after
635 /// each settled inventory pass.
636 #[must_use]
637 pub fn with_registry(mut self, registry: ChannelRegistry) -> Self {
638 self.registry = Some(registry);
639 self
640 }
641
642 /// Subscribe this enumerator's already-open channels to lifecycle events
643 /// and coalesce them into reconciliation requests sent through `notifier`.
644 #[must_use]
645 pub fn with_event_notifier(mut self, notifier: EventNotifier) -> Self {
646 self.event_notifier = Some(notifier);
647 self
648 }
649
650 /// Warm-start this enumerator's immutable probe cache from `store`, and
651 /// write it back there whenever its persistable content changes.
652 ///
653 /// A modifier rather than a constructor: persistence is orthogonal to the
654 /// channel registry and the backend, so an enumerator can carry all three.
655 #[must_use]
656 pub fn with_probe_cache(mut self, store: Arc<dyn ProbeCacheStore>) -> Self {
657 let cache = store.load().into_entries();
658 if !cache.is_empty() {
659 debug!(entries = cache.len(), "probe cache warm-started");
660 }
661 self.cache.extend(cache);
662 self.store = Some(store);
663 self
664 }
665
666 async fn prepare_nodes(
667 &mut self,
668 backend: &dyn HidBackend,
669 candidates: Vec<NodeInfo>,
670 ) -> PreparedNodes {
671 let mut active = Vec::new();
672 let mut seen_nodes = HashSet::new();
673 let mut open_failures = Vec::new();
674 let mut retiring = Vec::new();
675 for info in candidates {
676 let node = info.id.clone();
677 seen_nodes.insert(node.clone());
678 if !self
679 .channels
680 .prepare_open(&node, |cached| Arc::strong_count(&cached.channel) == 1)
681 {
682 debug!("node still retiring — waiting for its channel's remaining users to drop");
683 retiring.push(node);
684 continue;
685 }
686 if let Some(open) = self.channels.get(&node) {
687 active.push((
688 open.info.clone(),
689 Arc::clone(&open.channel),
690 open.events.as_ref().map(ChannelEventSubscriptions::handle),
691 ));
692 continue;
693 }
694 match backend.open_hidpp(&info).await {
695 Ok(Some(channel)) => {
696 // A channel that actually opened must not inherit probe or
697 // arrival-replay eviction counts from its predecessor.
698 // Inventory replay remains bounded until a probe produces
699 // a new authoritative snapshot.
700 self.ledger.reset_channel_failures_after_open(&node);
701 // Attach before the first feature/register check. Receiver
702 // events are recognizable immediately; per-device feature
703 // indexes are registered during the ensuing table walk.
704 let events = self.event_notifier.as_ref().map(|notifier| {
705 let protocol = find_receiver(info.vendor_id, info.product_id)
706 .map(|receiver| receiver.protocol);
707 ChannelEventSubscriptions::attach(&channel, protocol, notifier.clone())
708 });
709 let event_handle = events.as_ref().map(ChannelEventSubscriptions::handle);
710 self.channels.insert(
711 node,
712 CachedChannel {
713 info: info.clone(),
714 channel: Arc::clone(&channel),
715 events,
716 },
717 );
718 active.push((info, channel, event_handle));
719 }
720 Ok(None) => {}
721 Err(e) => {
722 warn!(error = ?e, "failed to open HID++ channel — requesting repair");
723 open_failures.push(node);
724 }
725 }
726 }
727
728 // IOHIDManager can temporarily omit a Bluetooth device's vendor HID++
729 // collection while its already-open handle and ordinary mouse link are
730 // still live. Keep probing that cached channel instead of turning one
731 // incomplete OS snapshot into an offline device and stopping capture.
732 append_live_cached_channels(&mut seen_nodes, &self.channels, &mut active);
733
734 if let Some(registry) = &self.registry {
735 registry.retain_nodes(&seen_nodes);
736 }
737 self.channels.retire_absent(&seen_nodes);
738 self.channels.reap_absent(&seen_nodes, |cached| {
739 Arc::strong_count(&cached.channel) == 1
740 });
741 self.ledger.retain_nodes(&seen_nodes);
742 self.node_cache_keys
743 .retain(|node, _| seen_nodes.contains(node));
744
745 PreparedNodes {
746 active,
747 open_failures,
748 retiring,
749 }
750 }
751
752 /// Write the cache through to its store when the persistable content
753 /// changed this pass. Best-effort: a failed write is logged and retried on
754 /// the next cache-dirty pass.
755 fn flush_cache(&mut self) {
756 if !self.cache_dirty {
757 return;
758 }
759 let Some(store) = &self.store else {
760 return;
761 };
762 match store.save(&ProbeCacheSnapshot::of(&self.cache)) {
763 Ok(()) => self.cache_dirty = false,
764 Err(e) => warn!(error = %e, "failed to persist probe cache"),
765 }
766 }
767
768 /// One enumeration pass, reusing the cache from prior passes. Probes every
769 /// HID candidate concurrently (so one asleep node that burns the whole
770 /// `PROBE_BUDGET` can't stall the others), reusing each device's cached
771 /// immutable data when it's present and fresh.
772 ///
773 /// A node the OS still lists but whose probe fails (receiver registers
774 /// unanswered, probe timeout, open failure) is **not** reported as absent:
775 /// its last completed inventory is replayed for a bounded grace and its
776 /// channel is reopened, so a transient HID++ glitch can't masquerade as
777 /// "no devices" (#218) — see the node ledger.
778 pub async fn enumerate(&mut self) -> Result<Vec<DeviceInventory>, InventoryError> {
779 self.enumerate_reporting_completeness()
780 .await
781 .map(|(inv, _, _)| inv)
782 }
783
784 /// [`Self::enumerate`] plus whether every probed node produced a complete
785 /// enough snapshot for the one-shot caller to stop early, and whether every
786 /// probed node answered this cycle. Completeness is separate from per-node
787 /// health: a node can answer cleanly enough for the ledger to accept its
788 /// live inventory while still reporting a known count/list shortfall that
789 /// the one-shot retry should give one more chance to settle. Only healthy
790 /// shortfalls can use the unchanged-inventory early stop; failed probes must
791 /// run through the retry budget so a later attempt can recover.
792 async fn enumerate_reporting_completeness(
793 &mut self,
794 ) -> Result<(Vec<DeviceInventory>, bool, bool), InventoryError> {
795 let now = Instant::now();
796 let backend = Arc::clone(&self.backend);
797 let candidates = backend.enumerate_hidpp().await?;
798 debug!(count = candidates.len(), "HID++ candidate interfaces");
799
800 // Reuse an open channel per node, opening only when no active or
801 // retiring connection owns that OS node.
802 let PreparedNodes {
803 active,
804 open_failures,
805 retiring: retiring_nodes,
806 } = self.prepare_nodes(&*backend, candidates).await;
807 self.open_failures_last_tick = !open_failures.is_empty();
808
809 // Probe each open channel concurrently, sharing `&cache` read-only;
810 // updates are collected and applied afterwards (no `RefCell`). Each
811 // probe bounds its own I/O by the pass's deadlines (`probe_one`).
812 let results = {
813 let cache = &self.cache;
814 let deadlines = &self.deadlines;
815 active
816 .into_iter()
817 .map(|(info, channel, events)| async move {
818 let node = info.id.clone();
819 let pass = PassContext {
820 cache,
821 now,
822 subscriptions: events.as_ref(),
823 deadlines,
824 };
825 let probe = probe_one(info, Arc::clone(&channel), pass).await;
826 (node, channel, probe)
827 })
828 .collect::<Vec<_>>()
829 .join()
830 .await
831 };
832
833 let (mut inventories, mut outcomes) = (Vec::new(), Vec::new());
834 // Aggregates for the one-shot retry. `all_complete` can stop
835 // immediately; `all_healthy` gates the unchanged-inventory shortcut so
836 // failed probes keep retrying. The ledger's own per-node replay is
837 // governed by each probe's verdict.
838 let (mut all_complete, mut all_healthy) = (true, true);
839 // Entries of nodes whose probe was deferred: neither seen nor missed
840 // this pass.
841 let mut frozen_keys = HashSet::new();
842 for (node, channel, probe) in results {
843 all_complete &= probe.verdict.is_complete();
844 all_healthy &= probe.verdict.is_healthy();
845 self.hold_or_note_cache_keys(&node, &probe, &mut frozen_keys);
846 outcomes.extend(probe.outcomes);
847 let settled = settle_probe(&mut self.ledger, &node, probe.verdict, probe.inventory);
848 // Every node waits for the ledger's consecutive-failure threshold,
849 // receivers included. One full-budget timeout is not evidence of
850 // dead delivery: [`RECEIVER_PROBE_BUDGET`] leaves barely a second
851 // over its own documented worst case, so a legitimate deep walk
852 // plus a single lost reply (5 s `SEND_RESPONSE_TIMEOUT`) already
853 // exceeds it. Evicting on that unpublishes *every* device behind
854 // the receiver — a Bolt publishes all six slots under one node —
855 // and tears down each one's capture plan. A channel whose delivery
856 // really is dead times out again on the repair pass and is replaced
857 // then, with the ledger replaying its last-good inventory
858 // meanwhile, so nothing disappears from the GUI in between.
859 if settled.evict_channel {
860 if let Some(registry) = &self.registry {
861 registry.remove_node(&node);
862 }
863 if self.channels.retire_node(&node) {
864 warn!("node probe keeps failing — retiring its channel before reopen");
865 }
866 } else if let Some(registry) = &self.registry {
867 let routes = settled
868 .inventory
869 .as_ref()
870 .map_or_else(Vec::new, |inventory| {
871 routes_for_inventories(std::slice::from_ref(inventory))
872 });
873 if routes.is_empty() {
874 registry.remove_node(&node);
875 } else {
876 registry.replace_node(node.clone(), routes, channel);
877 }
878 }
879 inventories.extend(settled.inventory);
880 }
881 // A listed node whose old connection is still retiring is an unhealthy
882 // probe, not a disconnect: preserve the ledger's normal replay grace.
883 for node in retiring_nodes {
884 inventories.extend(settle_unhealthy_node(
885 &mut self.ledger,
886 &node,
887 &mut all_complete,
888 &mut all_healthy,
889 ));
890 }
891 // Nodes that wouldn't open this pass still replay their last snapshot
892 // (they have no cached channel to evict).
893 for node in open_failures {
894 inventories.extend(settle_unhealthy_node(
895 &mut self.ledger,
896 &node,
897 &mut all_complete,
898 &mut all_healthy,
899 ));
900 }
901
902 let seen_keys = self.apply_outcomes(outcomes);
903 self.evict_unseen(&seen_keys, &frozen_keys);
904 self.retry_needed_last_tick = !all_healthy || !self.misses.is_empty();
905 self.flush_cache();
906 Ok((inventories, all_complete, all_healthy))
907 }
908
909 /// Fold this pass's probe outcomes into the cache, returning the keys seen
910 /// so [`Self::evict_unseen`] can age out the rest.
911 fn apply_outcomes(&mut self, outcomes: Vec<CacheOutcome>) -> HashSet<CacheKey> {
912 let mut seen_keys = HashSet::new();
913 for outcome in outcomes {
914 match outcome {
915 CacheOutcome::Fresh(key, cached) => {
916 seen_keys.insert(key.clone());
917 // A completed full probe of a persistable device is worth
918 // writing through; battery `Update`s are not (they would
919 // rewrite the file every pass for a value that is re-read
920 // live anyway), and neither are keys `persist::save`
921 // filters out — dirtying on those would rewrite an
922 // unchanged file on every refresh of a direct-only system.
923 self.cache_dirty |= persist::is_persistable(&key);
924 self.cache.insert(key, cached);
925 }
926 CacheOutcome::Update(key, cached) => {
927 seen_keys.insert(key.clone());
928 self.cache.insert(key, cached);
929 }
930 CacheOutcome::Seen(key) => {
931 seen_keys.insert(key);
932 }
933 CacheOutcome::Unkeyed => {}
934 }
935 }
936 seen_keys
937 }
938
939 /// Fold one node's probe into the cache's per-node bookkeeping.
940 ///
941 /// A probe that ran records the entries it contributed, so a later
942 /// deferred tick knows which entries are the node's. A deferred probe
943 /// adds those to `frozen`: the entries [`Self::evict_unseen`] holds out
944 /// of miss aging this pass. A tick that never asked the node has seen
945 /// nothing and missed nothing — its empty outcomes are not the node
946 /// reporting its devices gone, and four such ticks must not delete the
947 /// last-good capabilities the ledger is still replaying the inventory
948 /// for, nor persist that deletion.
949 ///
950 /// A node deferred before this process has successfully probed it has no record
951 /// yet, but its entries may well be in the cache: a warm start loads the
952 /// persisted Bolt entries before any receiver answers. Every entry no
953 /// node has claimed is held for it then — nothing that was checked
954 /// contributed them, so nothing that was checked can have found them
955 /// missing — and the node's first healthy probe attributes what is its.
956 fn hold_or_note_cache_keys(
957 &mut self,
958 node: &NodeId,
959 probe: &NodeProbe,
960 frozen: &mut HashSet<CacheKey>,
961 ) {
962 if probe.verdict.is_deferred() {
963 match self.node_cache_keys.get(node) {
964 Some(keys) => frozen.extend(keys.iter().cloned()),
965 None => frozen.extend(self.unattributed_cache_keys()),
966 }
967 return;
968 }
969 // A failed first probe cannot establish cache ownership, even if it
970 // found some slots before failing. Keep unknown attribution distinct
971 // from a healthy probe that positively found no entries.
972 if !probe.verdict.is_healthy() && !self.node_cache_keys.contains_key(node) {
973 return;
974 }
975 let keys = probe.outcomes.iter().filter_map(CacheOutcome::key).cloned();
976 self.node_cache_keys
977 .entry(node.clone())
978 .or_default()
979 .extend(keys);
980 }
981
982 /// Cache entries no node probed by this process has contributed:
983 /// persisted entries loaded at start, until their node's first probe.
984 fn unattributed_cache_keys(&self) -> impl Iterator<Item = CacheKey> + '_ {
985 self.cache
986 .keys()
987 .filter(|key| {
988 !self
989 .node_cache_keys
990 .values()
991 .any(|keys| keys.contains(*key))
992 })
993 .cloned()
994 }
995
996 /// Drop cache entries for devices not seen this pass, after a short grace so
997 /// a transient receiver timeout doesn't discard a still-present device.
998 ///
999 /// Entries in `frozen` — those of nodes whose probe was deferred — are
1000 /// neither seen nor missed: their counters stand until the node is
1001 /// actually probed again.
1002 fn evict_unseen(&mut self, seen_keys: &HashSet<CacheKey>, frozen: &HashSet<CacheKey>) {
1003 for key in seen_keys {
1004 self.misses.remove(key);
1005 }
1006 let missing: Vec<CacheKey> = self
1007 .cache
1008 .keys()
1009 .filter(|k| !seen_keys.contains(*k) && !frozen.contains(*k))
1010 .cloned()
1011 .collect();
1012 for key in missing {
1013 let misses = self.misses.entry(key.clone()).or_insert(0);
1014 *misses += 1;
1015 if *misses > CACHE_MISS_GRACE {
1016 self.cache.remove(&key);
1017 self.misses.remove(&key);
1018 for keys in self.node_cache_keys.values_mut() {
1019 keys.remove(&key);
1020 }
1021 self.cache_dirty |= persist::is_persistable(&key);
1022 }
1023 }
1024 }
1025}
1026
1027#[cfg(test)]
1028mod replay_test_support;
1029#[cfg(test)]
1030mod replay_tests;
1031#[cfg(test)]
1032mod tests;