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 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;