Skip to main content

openlogi_hid/
inventory.rs

1//! Enumerate connected HID++ receivers and their paired devices.
2
3use std::{
4    collections::{HashMap, HashSet},
5    sync::Arc,
6    time::Duration,
7};
8
9use futures_concurrency::future::Join as _;
10use hidpp::{
11    channel::HidppChannel,
12    device::Device,
13    feature::{
14        CreatableFeature, device_information::DeviceInformationFeature,
15        device_type_and_name::DeviceTypeAndNameFeature, unified_battery::UnifiedBatteryFeature,
16    },
17    receiver::{
18        self, Receiver,
19        bolt::{
20            DeviceConnection as BoltDeviceConnection, Event as BoltEvent, Receiver as BoltReceiver,
21        },
22        unifying::{
23            DeviceConnection as UnifyingDeviceConnection, Event as UnifyingEvent,
24            Receiver as UnifyingReceiver,
25        },
26    },
27};
28use openlogi_core::device::{
29    BatteryInfo, Capabilities, DeviceInventory, DeviceKind, DeviceModelInfo, DeviceTransports,
30    PairedDevice, ReceiverInfo,
31};
32use thiserror::Error;
33use tokio::time::timeout;
34use tracing::{debug, warn};
35
36use crate::mappings::{
37    map_battery_level, map_battery_status, map_device_type, map_kind, map_unifying_kind,
38    normalize_serial_number, resolve_device_kind,
39};
40use crate::node_ledger::NodeLedger;
41use crate::route::DIRECT_DEVICE_INDEX;
42use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel};
43
44/// How long to wait for device-arrival event bursts before assuming the
45/// receiver has finished reporting. MX Master 4 (and other devices that may
46/// be asleep) need a generous window to wake and respond to the arrival
47/// ping; we err on the side of waiting.
48const ARRIVAL_DRAIN: Duration = Duration::from_millis(1500);
49
50/// Maximum number of pairing slots a Bolt receiver supports. We iterate this
51/// range to surface paired-but-offline devices that won't fire arrival events.
52const MAX_BOLT_SLOTS: u8 = 6;
53
54/// Upper bound on probing one HID node. `hidpp`'s request/response has no
55/// timeout of its own, so without this a single unresponsive (e.g. asleep)
56/// device wedges the whole enumeration — and the GUI runs `enumerate` on a
57/// polling watcher, so a permanent hang would stall every later refresh.
58///
59/// Kept short so a snapshot settles quickly: a timed-out node is skipped and
60/// re-probed on the next watcher tick (~2 s), and the first probe usually wakes
61/// the device so the retry succeeds fast. Comfortably above a healthy device's
62/// probe time (the Bolt arrival drain alone is 1.5 s), so awake devices never
63/// trip it.
64const PROBE_BUDGET: Duration = Duration::from_secs(5);
65
66/// Per-slot budget for the HID++ 2.0 feature walk on a Unifying paired device.
67///
68/// Unifying wireless round-trips are slower than Bolt BTLE: some devices (e.g.
69/// K540) take ~3 s for the version ping to return. Running multiple slow slots
70/// concurrently can still consume the full PROBE_BUDGET and get cancelled
71/// mid-walk — the probe returns nothing rather than partial features.  A
72/// per-slot cap ensures each slot's feature walk is bounded independently of
73/// how many other slots are being probed at the same time.  A timed-out slot
74/// still surfaces in the inventory (kind + wpid from the arrival event) — it
75/// just lacks capabilities / battery until the next tick.
76const UNIFYING_SLOT_PROBE: Duration = Duration::from_millis(3500);
77
78#[derive(Debug, Error)]
79pub enum InventoryError {
80    #[error("HID transport error")]
81    Hid(#[from] async_hid::HidError),
82}
83
84/// How many `enumerate` ticks a device's probe is reused before a fresh read.
85/// The expensive part of a probe (the `enumerate_features` feature-table walk)
86/// reads *immutable* data — model, capabilities, marketing type — so it never
87/// needs re-reading for a known device; the periodic full probe is kept only as
88/// a self-healing pass (e.g. a firmware update reshuffling the feature table).
89/// The volatile battery does NOT ride this window: cache hits re-read it every
90/// tick through the memoized feature index (see [`read_battery`]), so it stays
91/// as fresh as it was before the cache existed (#153).
92const REFRESH_TICKS: u64 = 15;
93
94/// Stable identity used to memoize a device's probe across `enumerate` ticks.
95/// Keyed on the device's *own* identity (never its slot) so a re-paired or
96/// moved device can't inherit another device's cached probe.
97#[derive(Debug, Clone, PartialEq, Eq, Hash)]
98enum CacheKey {
99    /// Bolt: the unit id from the pairing register (cheap, read every tick).
100    Bolt { unit_id: [u8; 4] },
101    /// Unifying: keyed on the full receiver serial number + pairing slot.
102    /// Using the complete serial (not just a prefix) avoids collisions between
103    /// two receivers whose serials share a common prefix (e.g. "DA2699E1" and
104    /// "DA2604F2" share "DA2").
105    UnifyingSlot { receiver_uid: String, slot: u8 },
106    /// Direct (Bluetooth/USB): the OS-assigned HID node id (macOS registry-entry
107    /// id, Linux dev path, Windows interface path). Unique *per node*, so two
108    /// units of the same model never collide, and stable while connected so the
109    /// cache still hits across ticks.
110    Direct(async_hid::DeviceId),
111}
112
113/// Enumeration ticks a device may be missing before its cache entry is evicted.
114/// A small grace rides out a transient receiver timeout without dropping the
115/// device's memoized data.
116const CACHE_MISS_GRACE: u8 = 3;
117
118/// A memoized probe result plus the tick it was taken on.
119#[derive(Clone)]
120struct Cached {
121    probe: ProbedFeatures,
122    /// Runtime index of the `UnifiedBattery` feature in this device's feature
123    /// table, captured by the full probe. Lets cache hits re-read the volatile
124    /// battery in one round-trip — no `Device::new` ping, no table walk.
125    /// `None` when the device exposes no `0x1004`.
126    battery_index: Option<u8>,
127    probed_tick: u64,
128}
129
130/// What a probed device contributes to the cache this tick. The key lets stale
131/// entries be evicted; `Fresh` (a full probe) and `Update` (a cache hit whose
132/// volatile battery was re-read) also carry the value to insert. `Unkeyed` is a
133/// device we can't (or won't) cache — an all-zero unit id, or a rejected
134/// non-peripheral — so its key is neither inserted nor kept alive.
135enum CacheOutcome {
136    Fresh(CacheKey, Cached),
137    Update(CacheKey, Cached),
138    Seen(CacheKey),
139    Unkeyed,
140}
141
142/// `Seen` when the device has a stable key, else `Unkeyed`.
143fn seen(id: Option<CacheKey>) -> CacheOutcome {
144    id.map_or(CacheOutcome::Unkeyed, CacheOutcome::Seen)
145}
146
147/// Whether `cached` is stale enough that the device should be re-probed.
148fn is_stale(cached: &Cached, tick: u64) -> bool {
149    tick.wrapping_sub(cached.probed_tick) >= REFRESH_TICKS
150}
151
152/// Decide a device's probe: reuse a fresh cache, or (online + miss/stale)
153/// re-probe — but keep the last-known immutable data if the re-probe fails
154/// rather than overwriting it with an empty default. An unprobed offline device
155/// with no cache yields a default probe. Returns the probe plus its cache
156/// contribution (only a *successful* probe is cached).
157async fn probe_or_reuse(
158    channel: &Arc<HidppChannel>,
159    index: u8,
160    id: Option<CacheKey>,
161    cached: Option<&Cached>,
162    online: bool,
163    tick: u64,
164) -> (ProbedFeatures, CacheOutcome) {
165    if online && cached.is_none_or(|c| is_stale(c, tick)) {
166        let (fresh, battery_index) = probe_features(channel, index).await;
167        // `capabilities` is `Some` exactly when the feature-table walk succeeded;
168        // only then is the probe worth caching.
169        if fresh.capabilities.is_some() {
170            return match id {
171                Some(key) => {
172                    let value = Cached {
173                        probe: fresh.clone(),
174                        battery_index,
175                        probed_tick: tick,
176                    };
177                    (fresh, CacheOutcome::Fresh(key, value))
178                }
179                None => (fresh, CacheOutcome::Unkeyed),
180            };
181        }
182        // Re-probe failed: don't cache the failure. Fall back to the last-known
183        // data so a transient glitch doesn't drop the device or its battery.
184        // No battery re-read either — the device just proved unresponsive.
185        return match cached {
186            Some(c) => (c.probe.clone(), seen(id)),
187            None => (fresh, seen(id)),
188        };
189    }
190    match cached {
191        Some(c) => {
192            // Cache hit: the immutable data is reused as-is, but the battery is
193            // volatile (#153) — re-read just it through the memoized feature
194            // index and fold the reading back into the cache. A failed read
195            // (asleep, mid-host-switch) keeps the last-known value.
196            if online
197                && let Some(feature_index) = c.battery_index
198                && let Some(key) = id.clone()
199                && let Some(battery) = read_battery(channel, index, feature_index).await
200            {
201                let mut entry = c.clone();
202                entry.probe.battery = Some(battery);
203                return (entry.probe.clone(), CacheOutcome::Update(key, entry));
204            }
205            (c.probe.clone(), seen(id))
206        }
207        None => (ProbedFeatures::default(), seen(id)),
208    }
209}
210
211/// Stateful device enumerator: holds the per-device probe cache so the polling
212/// watcher reuses immutable data across ticks instead of re-handshaking every
213/// device every ~2s. One-shot callers use the [`enumerate`] free function, which
214/// runs against a fresh (empty) cache.
215#[derive(Default)]
216pub struct Enumerator {
217    cache: HashMap<CacheKey, Cached>,
218    /// Consecutive ticks each cached device has been missing, for grace-period
219    /// eviction.
220    misses: HashMap<CacheKey, u8>,
221    /// Open HID++ channels reused across ticks, keyed by OS node id. Opening (and
222    /// tearing down) a device every ~2s tick is the churn issue #99 is about —
223    /// each open also leaks an `io_service_t` in async-hid's macOS backend — so a
224    /// steadily-connected node is opened once here and reused until it
225    /// disconnects.
226    channels: HashMap<async_hid::DeviceId, CachedChannel>,
227    /// Per-node last-good inventory + consecutive-failure counts: replays a
228    /// node's snapshot through transient probe failures and decides when its
229    /// cached channel must be dropped and reopened (see [`crate::node_ledger`]).
230    ledger: NodeLedger<async_hid::DeviceId>,
231    tick: u64,
232}
233
234/// An open channel to a receiver / direct-device HID node, held across
235/// `enumerate` ticks. Evicting it (on disconnect, or when the `Enumerator`
236/// drops) closes the device and joins the channel's read thread via
237/// [`HidppChannel`]'s `Drop`.
238struct CachedChannel {
239    info: async_hid::DeviceInfo,
240    channel: Arc<HidppChannel>,
241}
242
243/// Enumerate all Logitech HID++ receivers visible to the current process and
244/// the devices paired to each.
245///
246/// Combines two data sources per receiver:
247///
248/// - `trigger_device_arrival` events — the only path to a device's wireless
249///   PID in hidpp 0.2 (the `wpid` field on `BoltDevicePairingInformation` is
250///   private). Only online, responsive devices show up here.
251/// - `get_device_pairing_information` polled per slot — covers paired-but-
252///   offline devices (sleeping mice, devices on a different host) that the
253///   arrival ping doesn't wake. No wpid for these.
254///
255/// We merge the two so an MX Master that's been asleep still shows up with
256/// its codename and kind even before you click it.
257pub async fn enumerate() -> Result<Vec<DeviceInventory>, InventoryError> {
258    // The polling [`Enumerator`] keeps a per-node ledger across ticks, so a
259    // transient probe miss replays the node's last good inventory. A one-shot
260    // caller (CLI `list` / `diag`) builds a fresh `Enumerator` whose ledger is
261    // empty, so a miss has nothing to replay and would surface as an empty or
262    // partial list — the two isolated runs in #218 read 3 devices and 0. Retry a
263    // few times instead, reusing the same enumerator so its ledger accumulates a
264    // snapshot a later attempt can replay and the opened channel stays warm.
265    // #226's 5 s request timeout inside `HidppChannel::send` makes a dead probe
266    // fail fast, so a short bounded retry is cheap.
267    let mut enumerator = Enumerator::default();
268    let mut attempt = 1u8;
269    loop {
270        let (inventories, all_healthy) = enumerator.enumerate_reporting_health().await?;
271        if all_healthy || attempt >= ONESHOT_ATTEMPTS {
272            return Ok(inventories);
273        }
274        debug!(
275            attempt,
276            "one-shot enumerate saw an unhealthy node — retrying"
277        );
278        tokio::time::sleep(ONESHOT_RETRY_DELAY).await;
279        attempt += 1;
280    }
281}
282
283/// Attempts a one-shot [`enumerate`] makes before returning whatever it last
284/// read, when a node keeps coming back unhealthy.
285const ONESHOT_ATTEMPTS: u8 = 4;
286
287/// Delay between one-shot [`enumerate`] retries. A first probe usually wakes an
288/// asleep device, so a short pause lets the next attempt read it cleanly.
289const ONESHOT_RETRY_DELAY: Duration = Duration::from_millis(300);
290
291impl Enumerator {
292    /// One enumeration pass, reusing the cache from prior passes. Probes every
293    /// HID candidate concurrently (so one asleep node that burns the whole
294    /// `PROBE_BUDGET` can't stall the others), reusing each device's cached
295    /// immutable data when it's present and fresh.
296    ///
297    /// A node the OS still lists but whose probe fails (receiver registers
298    /// unanswered, probe timeout, open failure) is **not** reported as absent:
299    /// its last completed inventory is replayed for a bounded grace and its
300    /// channel is reopened, so a transient HID++ glitch can't masquerade as
301    /// "no devices" (#218) — see [`crate::node_ledger`].
302    pub async fn enumerate(&mut self) -> Result<Vec<DeviceInventory>, InventoryError> {
303        self.enumerate_reporting_health().await.map(|(inv, _)| inv)
304    }
305
306    /// [`Self::enumerate`] plus whether every probed node answered cleanly this
307    /// pass — `false` if any probe timed out, failed to open, or read short of a
308    /// receiver's pairing count. The polling watcher ignores the flag (the ledger
309    /// already replays a node through a transient miss), but the one-shot
310    /// [`enumerate`] free fn uses it to retry: a fresh `Enumerator` has no ledger
311    /// history to replay, so a transient miss would otherwise surface as an
312    /// empty/partial list (#218).
313    async fn enumerate_reporting_health(
314        &mut self,
315    ) -> Result<(Vec<DeviceInventory>, bool), InventoryError> {
316        self.tick = self.tick.wrapping_add(1);
317        let tick = self.tick;
318        let candidates = enumerate_hidpp_devices().await?;
319        debug!(count = candidates.len(), "HID++ candidate interfaces");
320
321        // Reuse an open channel per node, opening one only for a node seen for
322        // the first time. Sequential because opening mutates the channel cache,
323        // but in steady state every node is already cached so this is just
324        // lookups — an actual open happens only when a new device appears.
325        let mut active: Vec<(async_hid::DeviceInfo, Arc<HidppChannel>)> = Vec::new();
326        let mut seen_nodes: HashSet<async_hid::DeviceId> = HashSet::new();
327        let mut open_failures: Vec<async_hid::DeviceId> = Vec::new();
328        for dev in candidates {
329            let node = dev.id.clone();
330            seen_nodes.insert(node.clone());
331            if let Some(open) = self.channels.get(&node) {
332                active.push((open.info.clone(), Arc::clone(&open.channel)));
333                continue;
334            }
335            match open_hidpp_channel(dev).await {
336                Ok(Some((info, channel))) => {
337                    self.channels.insert(
338                        node,
339                        CachedChannel {
340                            info: info.clone(),
341                            channel: Arc::clone(&channel),
342                        },
343                    );
344                    active.push((info, channel));
345                }
346                Ok(None) => {} // speaks HID but not HID++ — not one of ours
347                // The node is listed but unreachable right now — settled as a
348                // failed probe below, so its last inventory is replayed.
349                Err(e) => {
350                    warn!(error = ?e, "failed to open HID++ channel — retrying next tick");
351                    open_failures.push(node);
352                }
353            }
354        }
355        // Drop channels for nodes that vanished this tick. A node missing from
356        // the enumeration is a real disconnect (the IOHIDManager device set is
357        // authoritative, unlike a HID++ probe timeout), so close the device and
358        // join its read thread now instead of leaving a dead channel behind; a
359        // reconnect re-opens under a fresh node id. The ledger forgets vanished
360        // nodes for the same reason — a true disconnect must not be replayed.
361        self.channels.retain(|node, _| seen_nodes.contains(node));
362        self.ledger.retain_nodes(&seen_nodes);
363
364        // Probe each open channel concurrently, sharing `&cache` read-only;
365        // updates are collected and applied afterwards (no `RefCell`).
366        let results = {
367            let cache = &self.cache;
368            active
369                .into_iter()
370                .map(|(info, channel)| async move {
371                    let node = info.id.clone();
372                    let probe = timeout(PROBE_BUDGET, probe_one(info, channel, cache, tick)).await;
373                    (node, probe)
374                })
375                .collect::<Vec<_>>()
376                .join()
377                .await
378        };
379
380        let mut inventories = Vec::new();
381        let mut outcomes = Vec::new();
382        // Whether every node answered cleanly this pass. Drives the one-shot
383        // `enumerate` retry; the ledger's own per-node replay is unaffected.
384        let mut all_healthy = true;
385        for (node, result) in results {
386            let probe = if let Ok(probe) = result {
387                probe
388            } else {
389                // The probe burned the whole budget — an asleep direct device,
390                // or a channel whose read loop parked on a dead handle (see
391                // `AsyncHidChannel::read_report`). Either way: "couldn't
392                // check", not "nothing there".
393                warn!(budget = ?PROBE_BUDGET, "device probe timed out — treating as a failed probe");
394                NodeProbe::failed()
395            };
396            all_healthy &= probe.healthy;
397            outcomes.extend(probe.outcomes);
398            let settled = self.ledger.settle(&node, probe.healthy, probe.inventory);
399            if settled.evict_channel && self.channels.remove(&node).is_some() {
400                warn!("node probe keeps failing — dropping its channel to reopen next tick");
401            }
402            inventories.extend(settled.inventory);
403        }
404        // Nodes that wouldn't open this tick still replay their last snapshot
405        // (they have no cached channel to evict).
406        for node in open_failures {
407            all_healthy = false;
408            let settled = self.ledger.settle(&node, false, None);
409            inventories.extend(settled.inventory);
410        }
411
412        // Apply fresh probes and record which devices were seen this tick.
413        let mut seen_keys = HashSet::new();
414        for outcome in outcomes {
415            match outcome {
416                CacheOutcome::Fresh(key, cached) | CacheOutcome::Update(key, cached) => {
417                    seen_keys.insert(key.clone());
418                    self.cache.insert(key, cached);
419                }
420                CacheOutcome::Seen(key) => {
421                    seen_keys.insert(key);
422                }
423                CacheOutcome::Unkeyed => {}
424            }
425        }
426        self.evict_unseen(&seen_keys);
427        Ok((inventories, all_healthy))
428    }
429
430    /// Drop cache entries for devices not seen this tick, after a short grace so
431    /// a transient receiver timeout doesn't discard a still-present device.
432    fn evict_unseen(&mut self, seen_keys: &HashSet<CacheKey>) {
433        for key in seen_keys {
434            self.misses.remove(key);
435        }
436        let missing: Vec<CacheKey> = self
437            .cache
438            .keys()
439            .filter(|k| !seen_keys.contains(*k))
440            .cloned()
441            .collect();
442        for key in missing {
443            let misses = self.misses.entry(key.clone()).or_insert(0);
444            *misses += 1;
445            if *misses > CACHE_MISS_GRACE {
446                self.cache.remove(&key);
447                self.misses.remove(&key);
448            }
449        }
450    }
451}
452
453/// One probed node's contribution this tick: its inventory (if any), whether
454/// the node actually answered — the ledger replays the last snapshot when it
455/// didn't (see [`NodeLedger::settle`]) — and each device's cache contribution,
456/// for the caller to apply and to drive eviction.
457struct NodeProbe {
458    inventory: Option<DeviceInventory>,
459    healthy: bool,
460    outcomes: Vec<CacheOutcome>,
461}
462
463impl NodeProbe {
464    /// A probe that got no answer at all (budget timeout).
465    fn failed() -> Self {
466        Self {
467            inventory: None,
468            healthy: false,
469            outcomes: Vec::new(),
470        }
471    }
472}
473
474/// Probe one open HID++ node (channel reused across ticks by the caller).
475async fn probe_one(
476    info: async_hid::DeviceInfo,
477    channel: Arc<HidppChannel>,
478    cache: &HashMap<CacheKey, Cached>,
479    tick: u64,
480) -> NodeProbe {
481    match receiver::detect(Arc::clone(&channel)) {
482        Some(Receiver::Bolt(bolt)) => probe_bolt_receiver(channel, info, bolt, cache, tick).await,
483        Some(Receiver::Unifying(unifying)) => {
484            probe_unifying_receiver(channel, info, unifying, cache, tick).await
485        }
486        None | Some(_) => {
487            // No recognised receiver — this might be a directly-paired device
488            // (Bluetooth-direct, USB-C cable). HID++ at device-index 0xff
489            // addresses the device's own features. Probe in case it answers.
490            // P2.4 — verified path; no Bolt-pairing slot indirection needed.
491            probe_direct(channel, &info, cache, tick).await
492        }
493    }
494}
495
496async fn probe_bolt_receiver(
497    channel: Arc<HidppChannel>,
498    info: async_hid::DeviceInfo,
499    bolt: BoltReceiver,
500    cache: &HashMap<CacheKey, Cached>,
501    tick: u64,
502) -> NodeProbe {
503    let unique_id = bolt.get_unique_id().await.ok();
504    let pairing_count = bolt.count_pairings().await.ok();
505    debug!(?pairing_count, "receiver reports pairing count");
506
507    let connections = drain_device_arrival(&bolt).await;
508    debug!(events = connections.len(), "drained device-arrival events");
509    let by_slot: HashMap<u8, BoltDeviceConnection> =
510        connections.into_iter().map(|c| (c.index, c)).collect();
511
512    let mut paired = Vec::new();
513    let mut outcomes = Vec::new();
514    for slot in 1u8..=MAX_BOLT_SLOTS {
515        if let Some((device, outcome)) =
516            probe_bolt_slot(&channel, &bolt, by_slot.get(&slot), slot, cache, tick).await
517        {
518            paired.push(device);
519            outcomes.push(outcome);
520        }
521    }
522
523    if let Some(count) = pairing_count
524        && paired.len() != usize::from(count)
525    {
526        warn!(
527            expected = count,
528            found = paired.len(),
529            "paired-device count mismatch — some slots may be unreadable"
530        );
531    }
532    // Authoritative only when the pairing-count register answered AND every
533    // counted slot was readable. `None` (the receiver didn't answer — e.g. a
534    // parked channel) or a shortfall is "couldn't fully check": the ledger
535    // then replays the last good snapshot instead of presenting the partial
536    // walk as the new truth (#218).
537    let complete = pairing_count.is_some_and(|count| paired.len() == usize::from(count));
538
539    NodeProbe {
540        inventory: Some(DeviceInventory {
541            receiver: ReceiverInfo {
542                name: "Logi Bolt Receiver".to_string(),
543                vendor_id: info.vendor_id,
544                product_id: info.product_id,
545                unique_id,
546            },
547            paired,
548        }),
549        healthy: complete,
550        outcomes,
551    }
552}
553
554async fn probe_unifying_receiver(
555    channel: Arc<HidppChannel>,
556    info: async_hid::DeviceInfo,
557    unifying: UnifyingReceiver,
558    cache: &HashMap<CacheKey, Cached>,
559    tick: u64,
560) -> NodeProbe {
561    let unique_id = unifying.get_unique_id().await.ok();
562    let pairing_count = unifying.count_pairings().await.ok();
563    debug!(?pairing_count, "receiver reports pairing count");
564
565    // Trigger device-arrival events and collect one event per online device.
566    // Each event carries the slot index, kind, wpid, and online flag — enough
567    // to build a PairedDevice entry for every currently-connected device.
568    //
569    // Note: the Unifying `0xB5/0x5N` pairing-info register uses a different
570    // sub-register base than Bolt, so we don't yet poll offline paired slots.
571    // Online devices are covered by the arrival drain; offline device support
572    // requires resolving the correct sub-register format.
573    //
574    // The drain is therefore the *only* device source on this path, so a
575    // failed arrival trigger is "couldn't check", not "no devices online":
576    // settle it as a failed probe and let the ledger replay the last snapshot.
577    let Some(connections) = drain_device_arrival_unifying(&unifying).await else {
578        return NodeProbe::failed();
579    };
580    debug!(events = connections.len(), "drained device-arrival events");
581
582    // Probe all online slots concurrently so a slow HID++ 2.0 feature walk on
583    // one device doesn't push the next slot past the PROBE_BUDGET deadline.
584    // Pass the receiver UID so each slot's cache key is scoped to this specific
585    // receiver — two Unifying receivers sharing a slot number must not share a
586    // cache entry (different devices, different capabilities).
587    let receiver_uid_fallback;
588    let receiver_uid = if let Some(uid) = unique_id.as_deref() {
589        uid
590    } else {
591        // UID fetch failed — use the product ID as a weaker discriminant so
592        // two receivers with the same PID still collide, but a receiver and a
593        // direct device never share a cache entry.
594        tracing::warn!("Unifying receiver UID unavailable; cache isolation may be degraded");
595        receiver_uid_fallback = format!("pid:{:04x}", info.product_id);
596        &receiver_uid_fallback
597    };
598    let slot_results = connections
599        .iter()
600        .map(|conn| probe_unifying_slot(&channel, conn, receiver_uid, cache, tick))
601        .collect::<Vec<_>>()
602        .join()
603        .await;
604
605    let (paired, outcomes): (Vec<_>, Vec<_>) = slot_results.into_iter().flatten().unzip();
606
607    if let Some(count) = pairing_count
608        && paired.len() != usize::from(count)
609    {
610        debug!(
611            expected = count,
612            found = paired.len(),
613            "online devices differ from pairing count; offline devices not yet surfaced for Unifying"
614        );
615    }
616    // Unlike Bolt, a count/list shortfall is *expected* here (offline paired
617    // devices aren't enumerable yet), so completeness can't ride on it. The
618    // health signal is the pairing-count register answering at all: that
619    // proves the receiver round-trip worked this cycle, while `None` (e.g. a
620    // parked channel) is "couldn't fully check" — the ledger then replays the
621    // last good snapshot instead of presenting a possibly-empty list (#218).
622    let healthy = pairing_count.is_some();
623
624    NodeProbe {
625        inventory: Some(DeviceInventory {
626            receiver: ReceiverInfo {
627                name: "Unifying Receiver".to_string(),
628                vendor_id: info.vendor_id,
629                product_id: info.product_id,
630                unique_id,
631            },
632            paired,
633        }),
634        healthy,
635        outcomes,
636    }
637}
638
639/// Probe a single Bolt pairing slot. Returns `None` when the slot is empty or
640/// unreadable, otherwise the device plus its cache contribution this tick.
641async fn probe_bolt_slot(
642    channel: &Arc<HidppChannel>,
643    bolt: &BoltReceiver,
644    event: Option<&BoltDeviceConnection>,
645    slot: u8,
646    cache: &HashMap<CacheKey, Cached>,
647    tick: u64,
648) -> Option<(PairedDevice, CacheOutcome)> {
649    let pairing = match bolt.get_device_pairing_information(slot).await {
650        Ok(p) => p,
651        Err(e) => {
652            debug!(slot, error = ?e, "slot empty or unreadable");
653            return None;
654        }
655    };
656    let codename = read_codename(channel, slot).await;
657    // Prefer event data when present — it's a live response. Fall back to the
658    // pairing register for sleeping devices that didn't reply.
659    let online = event.map_or(pairing.online, |c| c.online);
660    let bolt_kind = event.map_or(pairing.kind, |c| c.kind);
661    let wpid = event.map(|c| c.wpid);
662    debug!(
663        slot,
664        online,
665        ?wpid,
666        ?bolt_kind,
667        has_event = event.is_some(),
668        codename = ?codename,
669        "paired slot"
670    );
671
672    // The pairing register gives the device's unit id cheaply every tick — its
673    // stable cache identity. An all-zero id is treated as unidentifiable (don't
674    // cache; always probe when online).
675    let id = (pairing.unit_id != [0u8; 4]).then_some(CacheKey::Bolt {
676        unit_id: pairing.unit_id,
677    });
678    let cached = id.as_ref().and_then(|i| cache.get(i));
679    let register_kind = map_kind(bolt_kind);
680
681    let (probe, outcome) = probe_or_reuse(channel, slot, id, cached, online, tick).await;
682    if matches!(outcome, CacheOutcome::Fresh(..))
683        && let Some(probed) = probe.kind
684        && probed != DeviceKind::Unknown
685        && register_kind != DeviceKind::Unknown
686        && probed != register_kind
687    {
688        debug!(
689            slot,
690            ?register_kind,
691            ?probed,
692            "device-kind sources disagree — trusting 0x0005"
693        );
694    }
695
696    let device = PairedDevice {
697        slot,
698        codename,
699        wpid,
700        // Prefer the device's own `0x0005` type; the register kind is the
701        // offline fallback.
702        kind: resolve_device_kind(probe.kind, register_kind),
703        online,
704        battery: probe.battery,
705        model_info: probe.model_info,
706        capabilities: probe.capabilities,
707    };
708    Some((device, outcome))
709}
710
711/// Probe a HID++ channel that doesn't host a Bolt receiver — for
712/// Bluetooth-direct, USB-C, or otherwise wired devices that present
713/// themselves as a HID++ device rather than a receiver (P2.4).
714///
715/// Addresses the device at index `0xff` (HID++'s "self" slot) and reads
716/// the same battery + model-info features the Bolt path uses. Yields no
717/// inventory when the channel doesn't respond to HID++ at `0xff` (in which
718/// case it's neither a receiver nor a direct device we recognise) — healthy
719/// only if that rejection rests on a completed feature walk, so a device
720/// that merely failed to answer is settled as a failed probe instead.
721async fn probe_direct(
722    channel: Arc<HidppChannel>,
723    info: &async_hid::DeviceInfo,
724    cache: &HashMap<CacheKey, Cached>,
725    tick: u64,
726) -> NodeProbe {
727    let id = CacheKey::Direct(info.id.clone());
728    let cached = cache.get(&id);
729    // A direct device is always "present" (its HID node is the candidate), so
730    // treat it as online: reuse the cached probe while fresh, otherwise probe.
731    let (probe, outcome) =
732        probe_or_reuse(&channel, DIRECT_DEVICE_INDEX, Some(id), cached, true, tick).await;
733    // Hybrid peripheral discriminator. A genuine directly-attached device is
734    // either wireless/Bluetooth — which reports a battery — or exposes a
735    // configuration feature (buttons / pointer / lighting). A Bolt receiver's
736    // secondary HID interface also answers DeviceInformation at 0xff, but
737    // exposes neither battery nor those features, so it's filtered out here.
738    // Without this guard a Bolt setup ends up with two entries in `device_list`:
739    // the real mouse (via the Bolt path) and a phantom "direct device" pointing
740    // at the receiver, which sits at index 0 and steals every DPI / SmartShift
741    // write attempt. We reuse the capabilities the probe already derived from
742    // the feature table — no extra round-trip.
743    // A completed feature-table walk is what makes this probe's verdict
744    // trustworthy: without it (the device never answered) a rejection below
745    // would be indistinguishable from a transient glitch, so the node is
746    // settled as a failed probe and its last inventory replayed.
747    let walk_succeeded = probe.capabilities.is_some();
748    let caps = probe.capabilities.unwrap_or_default();
749    let is_peripheral = probe.battery.is_some() || caps.buttons || caps.pointer || caps.lighting;
750    if !is_peripheral {
751        debug!(
752            vid = format_args!("{:04x}", info.vendor_id),
753            pid = format_args!("{:04x}", info.product_id),
754            has_model = probe.model_info.is_some(),
755            "slot 0xff exposes no battery or config feature — likely a receiver \
756             secondary interface; skipping"
757        );
758        // Don't cache or keep a rejected non-peripheral — `Unkeyed` lets any
759        // prior entry for this node be evicted.
760        return NodeProbe {
761            inventory: None,
762            healthy: walk_succeeded,
763            outcomes: vec![CacheOutcome::Unkeyed],
764        };
765    }
766
767    // Without a Bolt receiver we don't have a wpid, codename, or pairing
768    // info — those live on the receiver registers. Use the HID name as
769    // the display fallback and leave wpid empty.
770    debug!(name = %info.name, "BT-direct / wired device recognised");
771    let inventory = DeviceInventory {
772        receiver: ReceiverInfo {
773            name: info.name.clone(),
774            vendor_id: info.vendor_id,
775            product_id: info.product_id,
776            unique_id: None,
777        },
778        paired: vec![PairedDevice {
779            slot: DIRECT_DEVICE_INDEX,
780            codename: Some(info.name.clone()),
781            wpid: None,
782            // No receiver pairing register here, so `0x0005` is the only kind
783            // hint — but kind is just identity now; the UI gates on the
784            // capabilities below, so a misread kind can't hide the panels (#127).
785            kind: resolve_device_kind(probe.kind, DeviceKind::Unknown),
786            online: true,
787            battery: probe.battery,
788            model_info: probe.model_info,
789            capabilities: probe.capabilities,
790        }],
791    };
792    NodeProbe {
793        inventory: Some(inventory),
794        healthy: true,
795        outcomes: vec![outcome],
796    }
797}
798
799async fn drain_device_arrival(bolt: &BoltReceiver) -> Vec<BoltDeviceConnection> {
800    let rx = bolt.listen();
801    if let Err(e) = bolt.trigger_device_arrival().await {
802        debug!(error = ?e, "trigger_device_arrival failed; receiver may report no devices");
803        return Vec::new();
804    }
805
806    let mut out = Vec::new();
807    loop {
808        match timeout(ARRIVAL_DRAIN, rx.recv()).await {
809            Ok(Ok(BoltEvent::DeviceConnection(c))) => out.push(c),
810            Ok(Ok(_)) => {} // BoltEvent is non_exhaustive; ignore future variants
811            Ok(Err(_)) | Err(_) => break,
812        }
813    }
814    out
815}
816
817/// `None` when the arrival trigger itself failed: unlike Bolt (whose paired
818/// list comes from the slot registers), the drain is the only Unifying device
819/// source, so the caller must treat that as a failed probe rather than an
820/// empty receiver.
821async fn drain_device_arrival_unifying(
822    unifying: &UnifyingReceiver,
823) -> Option<Vec<UnifyingDeviceConnection>> {
824    let rx = unifying.listen();
825    if let Err(e) = unifying.trigger_device_arrival().await {
826        debug!(error = ?e, "trigger_device_arrival failed; receiver may report no devices");
827        return None;
828    }
829
830    let mut out = Vec::new();
831    loop {
832        match timeout(ARRIVAL_DRAIN, rx.recv()).await {
833            Ok(Ok(UnifyingEvent::DeviceConnection(c))) => out.push(c),
834            Ok(Ok(_)) => {}
835            Ok(Err(_)) | Err(_) => break,
836        }
837    }
838    Some(out)
839}
840
841/// Probe a Unifying slot from a live device-connection event.
842///
843/// Device-arrival events carry the slot index, kind, wpid, and online status —
844/// enough to surface an entry for every currently-connected device. The
845/// unit_id (needed for stable caching across ticks) is not available without a
846/// working `get_device_pairing_information` call; we derive a stable cache key
847/// from the receiver UID + slot so the feature-table walk is amortised at ~30s
848/// and two receivers sharing a slot number don't collide in the cache.
849async fn probe_unifying_slot(
850    channel: &Arc<HidppChannel>,
851    event: &UnifyingDeviceConnection,
852    receiver_uid: &str,
853    cache: &HashMap<CacheKey, Cached>,
854    tick: u64,
855) -> Option<(PairedDevice, CacheOutcome)> {
856    let slot = event.index;
857    let codename = read_codename(channel, slot).await;
858    debug!(
859        slot,
860        online = event.online,
861        wpid = format_args!("{:04x}", event.wpid),
862        kind = ?event.kind,
863        codename = ?codename,
864        "unifying paired slot"
865    );
866
867    // Cache key: full receiver serial + slot so two Unifying receivers with
868    // a device on the same slot number never share a cache entry.
869    let id = CacheKey::UnifyingSlot {
870        receiver_uid: receiver_uid.to_string(),
871        slot,
872    };
873    let cached = cache.get(&id);
874    let register_kind = map_unifying_kind(event.kind);
875
876    let probe_result = timeout(
877        UNIFYING_SLOT_PROBE,
878        probe_or_reuse(channel, slot, Some(id.clone()), cached, event.online, tick),
879    )
880    .await;
881    let (probe, outcome) = if let Ok(r) = probe_result {
882        r
883    } else {
884        debug!(slot, budget = ?UNIFYING_SLOT_PROBE,
885            "Unifying slot probe timed out; using cached data if available");
886        let probe = cached.map_or_else(ProbedFeatures::default, |c| c.probe.clone());
887        (probe, CacheOutcome::Seen(id))
888    };
889
890    let device = PairedDevice {
891        slot,
892        codename,
893        wpid: Some(event.wpid),
894        kind: resolve_device_kind(probe.kind, register_kind),
895        online: event.online,
896        battery: probe.battery,
897        model_info: probe.model_info,
898        capabilities: probe.capabilities,
899    };
900    Some((device, outcome))
901}
902
903/// Reads a paired device's codename, working around a slicing bug in
904/// `hidpp 0.2`'s `BoltReceiver::get_device_codename` that truncates names
905/// longer than 8 characters (it treats `response[2]` as an end-index when it
906/// is actually the byte length — see Solaar's `device_codename` for the
907/// correct slice). 16-byte long-register response is `[sub, chunk, len,
908/// data..13]`; we cap at 13 to stay in-bounds. Long names (>13 chars) would
909/// need multi-chunk reads with chunk param > 0x01; not needed for v0.0.x.
910async fn read_codename(channel: &HidppChannel, slot: u8) -> Option<String> {
911    // 0xFF = receiver device index, 0xB5 = ReceiverInfo register,
912    // 0x60+slot = DeviceCodename sub-register, 0x01 = first chunk.
913    let response = channel
914        .read_long_register(0xFF, 0xB5, [0x60 + slot, 0x01, 0x00])
915        .await
916        .ok()?;
917    let len = usize::from(response[2]).min(13);
918    core::str::from_utf8(&response[3..3 + len])
919        .ok()
920        .map(str::to_string)
921}
922
923/// Everything a single device probe yields. Any field is `None` when the
924/// device doesn't expose that feature or the read failed.
925#[derive(Default, Clone)]
926struct ProbedFeatures {
927    battery: Option<BatteryInfo>,
928    model_info: Option<DeviceModelInfo>,
929    /// Marketing type from HID++ `0x0005` — an identity hint only.
930    kind: Option<DeviceKind>,
931    /// Configuration capabilities derived from the device's feature table.
932    capabilities: Option<Capabilities>,
933}
934
935/// Read just the battery by addressing the `UnifiedBattery` feature at its
936/// known runtime `feature_index` — one round-trip, with no `Device::new` ping
937/// and no feature-table walk. This is both the full probe's battery read (the
938/// walk just produced the index) and the cheap per-tick refresh for cache hits.
939/// `None` when the device doesn't answer (asleep, switched hosts).
940async fn read_battery(
941    channel: &Arc<HidppChannel>,
942    slot: u8,
943    feature_index: u8,
944) -> Option<BatteryInfo> {
945    let feature = UnifiedBatteryFeature::new(Arc::clone(channel), slot, feature_index);
946    feature
947        .get_battery_info()
948        .await
949        .ok()
950        .map(|info| BatteryInfo {
951            percentage: info.charging_percentage,
952            level: map_battery_level(info.level),
953            status: map_battery_status(info.status),
954        })
955}
956
957/// Runtime index of the `UnifiedBattery` feature in an enumerated feature-ID
958/// table, for [`read_battery`]. The table is 1-based (index 0 is the implicit
959/// root feature, which enumeration omits).
960fn battery_feature_index(ids: impl IntoIterator<Item = u16>) -> Option<u8> {
961    ids.into_iter()
962        .position(|id| id == UnifiedBatteryFeature::ID)
963        // A feature table holds at most `u8::MAX` entries (its count is a u8),
964        // so the 1-based index always fits.
965        .and_then(|pos| u8::try_from(pos + 1).ok())
966}
967
968/// Open a HID++ session for `slot` and read everything we care about (battery,
969/// device-information, `0x0005` device type, and the feature table that drives
970/// [`Capabilities`]) in one shot. Device sessions are expensive (multi-round-
971/// trip) so we fold every read through the same `Device::new` +
972/// `enumerate_features` — the feature table is the Vec that enumeration already
973/// returns, so capabilities cost no extra round-trip.
974///
975/// Also returns the `UnifiedBattery` runtime index found by the walk, so later
976/// ticks can refresh the battery without repeating it.
977///
978/// Only online, responsive devices reach here.
979async fn probe_features(channel: &Arc<HidppChannel>, slot: u8) -> (ProbedFeatures, Option<u8>) {
980    let mut device = match Device::new(Arc::clone(channel), slot).await {
981        Ok(d) => d,
982        Err(e) => {
983            debug!(slot, error = ?e, "Device::new failed");
984            return (ProbedFeatures::default(), None);
985        }
986    };
987    // The enumeration response IS the device's feature-ID table — capture it
988    // for capability derivation instead of discarding it.
989    let mut battery_index = None;
990    let capabilities = match device.enumerate_features().await {
991        Ok(Some(features)) => {
992            let ids: Vec<u16> = features.iter().map(|f| f.id).collect();
993            battery_index = battery_feature_index(ids.iter().copied());
994            Some(Capabilities::from_feature_ids(&ids))
995        }
996        Ok(None) => None,
997        Err(e) => {
998            debug!(slot, error = ?e, "enumerate_features failed");
999            return (ProbedFeatures::default(), None);
1000        }
1001    };
1002
1003    let battery = match battery_index {
1004        Some(feature_index) => read_battery(channel, slot, feature_index).await,
1005        None => None,
1006    };
1007
1008    let model_info = match device.get_feature::<DeviceInformationFeature>() {
1009        Some(feature) => match feature.get_device_info().await {
1010            Ok(info) => {
1011                let serial_number = if info.capabilities.serial_number {
1012                    match feature.get_serial_number().await {
1013                        Ok(serial) => normalize_serial_number(&serial),
1014                        Err(e) => {
1015                            debug!(slot, error = ?e, "DeviceInformation serial read failed");
1016                            None
1017                        }
1018                    }
1019                } else {
1020                    None
1021                };
1022                Some(DeviceModelInfo {
1023                    entity_count: info.entity_count,
1024                    serial_number,
1025                    unit_id: info.unit_id,
1026                    transports: DeviceTransports {
1027                        usb: info.transport.usb,
1028                        equad: info.transport.e_quad,
1029                        btle: info.transport.btle,
1030                        bluetooth: info.transport.bluetooth,
1031                    },
1032                    model_ids: info.model_id,
1033                    extended_model_id: info.extended_model_id,
1034                })
1035            }
1036            Err(e) => {
1037                debug!(slot, error = ?e, "DeviceInformation read failed");
1038                None
1039            }
1040        },
1041        None => None,
1042    };
1043
1044    // `0x0005` reports the device's own marketing type (mouse, keyboard, …) —
1045    // the authoritative kind signal. On the direct path it's the only one; on
1046    // the Bolt path it corrects a pairing register that reported the wrong (or
1047    // `Unknown`) kind.
1048    let kind = match device.get_feature::<DeviceTypeAndNameFeature>() {
1049        Some(feature) => match feature.get_device_type().await {
1050            Ok(ty) => Some(map_device_type(ty)),
1051            Err(e) => {
1052                debug!(slot, error = ?e, "DeviceType read failed");
1053                None
1054            }
1055        },
1056        None => None,
1057    };
1058
1059    (
1060        ProbedFeatures {
1061            battery,
1062            model_info,
1063            kind,
1064            capabilities,
1065        },
1066        battery_index,
1067    )
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use std::collections::HashSet;
1073
1074    use super::{
1075        CACHE_MISS_GRACE, CacheKey, Cached, Enumerator, ProbedFeatures, REFRESH_TICKS,
1076        UnifiedBatteryFeature, battery_feature_index, is_stale,
1077    };
1078    use hidpp::feature::CreatableFeature as _;
1079
1080    fn cache_entry(probed_tick: u64) -> Cached {
1081        Cached {
1082            probe: ProbedFeatures::default(),
1083            battery_index: None,
1084            probed_tick,
1085        }
1086    }
1087
1088    #[test]
1089    fn cache_entry_survives_grace_then_evicts() {
1090        let mut e = Enumerator::default();
1091        let key = CacheKey::Bolt {
1092            unit_id: [1, 2, 3, 4],
1093        };
1094        e.cache.insert(key.clone(), cache_entry(0));
1095        let nobody = HashSet::new();
1096        // Missing for the whole grace window: kept.
1097        for _ in 0..CACHE_MISS_GRACE {
1098            e.evict_unseen(&nobody);
1099            assert!(
1100                e.cache.contains_key(&key),
1101                "evicted inside the grace window"
1102            );
1103        }
1104        // One miss past the grace: evicted.
1105        e.evict_unseen(&nobody);
1106        assert!(
1107            !e.cache.contains_key(&key),
1108            "should evict past the grace window"
1109        );
1110    }
1111
1112    #[test]
1113    fn being_seen_resets_the_miss_counter() {
1114        let mut e = Enumerator::default();
1115        let key = CacheKey::Bolt { unit_id: [9; 4] };
1116        e.cache.insert(key.clone(), cache_entry(0));
1117        let nobody = HashSet::new();
1118        let seen: HashSet<CacheKey> = std::iter::once(key.clone()).collect();
1119        e.evict_unseen(&nobody); // miss 1
1120        e.evict_unseen(&seen); // seen → counter reset
1121        for _ in 0..CACHE_MISS_GRACE {
1122            e.evict_unseen(&nobody);
1123        }
1124        assert!(
1125            e.cache.contains_key(&key),
1126            "counter reset by a sighting, so still within grace"
1127        );
1128    }
1129
1130    #[test]
1131    fn cached_probe_is_reused_until_refresh_ticks() {
1132        let cached = Cached {
1133            probe: ProbedFeatures::default(),
1134            battery_index: None,
1135            probed_tick: 10,
1136        };
1137        assert!(!is_stale(&cached, 10), "same tick is fresh");
1138        assert!(
1139            !is_stale(&cached, 10 + REFRESH_TICKS - 1),
1140            "just under the window is still fresh"
1141        );
1142        assert!(
1143            is_stale(&cached, 10 + REFRESH_TICKS),
1144            "at the window the probe is refreshed"
1145        );
1146    }
1147
1148    #[test]
1149    fn battery_index_is_one_based_in_the_enumerated_table() {
1150        // `enumerate_features` omits the root feature (index 0), so the first
1151        // enumerated entry sits at runtime index 1.
1152        let table = [0x0001, UnifiedBatteryFeature::ID, 0x2201];
1153        assert_eq!(battery_feature_index(table), Some(2));
1154        assert_eq!(
1155            battery_feature_index([UnifiedBatteryFeature::ID]),
1156            Some(1),
1157            "first entry maps to index 1, not 0"
1158        );
1159    }
1160
1161    #[test]
1162    fn no_battery_feature_means_no_index() {
1163        assert_eq!(battery_feature_index([0x0001, 0x2201, 0x1b04]), None);
1164        assert_eq!(battery_feature_index([]), None);
1165    }
1166}