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