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}