Skip to main content

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