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::channel::HidppChannel;
11use openlogi_core::device::DeviceInventory;
12use thiserror::Error;
13use tokio::time::timeout;
14use tracing::{debug, warn};
15
16use crate::node_ledger::NodeLedger;
17use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel};
18
19mod cache;
20mod features;
21mod probe;
22
23use cache::{CACHE_MISS_GRACE, CacheKey, CacheOutcome, Cached};
24use probe::{NodeProbe, probe_one};
25
26/// How long to wait for device-arrival event bursts before assuming the
27/// receiver has finished reporting. MX Master 4 (and other devices that may
28/// be asleep) need a generous window to wake and respond to the arrival
29/// ping; we err on the side of waiting.
30const ARRIVAL_DRAIN: Duration = Duration::from_millis(1500);
31
32/// Maximum number of pairing slots a Bolt receiver supports. We iterate this
33/// range to surface paired-but-offline devices that won't fire arrival events.
34const MAX_BOLT_SLOTS: u8 = 6;
35
36/// Upper bound on probing one HID node. `hidpp`'s request/response has no
37/// timeout of its own, so without this a single unresponsive (e.g. asleep)
38/// device wedges the whole enumeration — and the GUI runs `enumerate` on a
39/// polling watcher, so a permanent hang would stall every later refresh.
40///
41/// Kept short so a snapshot settles quickly: a timed-out node is skipped and
42/// re-probed on the next watcher tick (~2 s), and the first probe usually wakes
43/// the device so the retry succeeds fast. Comfortably above a healthy device's
44/// probe time (the Bolt arrival drain alone is 1.5 s), so awake devices never
45/// trip it.
46const PROBE_BUDGET: Duration = Duration::from_secs(5);
47
48/// Per-slot budget for the HID++ 2.0 feature walk on a Unifying paired device.
49///
50/// Unifying wireless round-trips are slower than Bolt BTLE: some devices (e.g.
51/// K540) take ~3 s for the version ping to return. Running multiple slow slots
52/// concurrently can still consume the full PROBE_BUDGET and get cancelled
53/// mid-walk — the probe returns nothing rather than partial features. A
54/// per-slot cap ensures each slot's feature walk is bounded independently of
55/// how many other slots are being probed at the same time. A timed-out slot
56/// still surfaces in the inventory (kind + wpid from the arrival event) — it
57/// just lacks capabilities / battery until the next tick.
58const UNIFYING_SLOT_PROBE: Duration = Duration::from_millis(3500);
59
60/// Per-slot budget for the HID++ 2.0 feature walk on a Bolt paired device.
61///
62/// The whole receiver shares one [`PROBE_BUDGET`]; without a per-slot cap a
63/// single online device that stops answering its feature-walk reads (seen on a
64/// recent macOS IOHID stack with a new MX Master 4) burns the entire budget, so
65/// `probe_one` times out and the receiver yields *nothing* — every paired device
66/// drops to "No devices" even though its pairing-register identity read fine
67/// (#218). Capping each slot lets a hung device fall back to its cached /
68/// identity-only data while the rest of the receiver still enumerates, mirroring
69/// [`UNIFYING_SLOT_PROBE`]. Bolt BTLE round-trips are fast (a healthy walk is
70/// well under a second), so 1 s is generous headroom yet small enough that three
71/// online slots can hang at once and still fit `PROBE_BUDGET` after the 1.5 s
72/// arrival drain (1.5 + 3×1 = 4.5 s).
73const BOLT_SLOT_PROBE: Duration = Duration::from_secs(1);
74
75/// Errors raised while enumerating HID++ devices.
76#[derive(Debug, Error)]
77pub enum InventoryError {
78 /// Underlying HID backend error.
79 #[error("HID transport error")]
80 Hid(#[from] async_hid::HidError),
81}
82
83/// Stateful device enumerator: holds the per-device probe cache so the polling
84/// watcher reuses immutable data across ticks instead of re-handshaking every
85/// device every ~2s. One-shot callers use the [`enumerate`] free function, which
86/// runs against a fresh (empty) cache.
87#[derive(Default)]
88pub struct Enumerator {
89 cache: HashMap<CacheKey, Cached>,
90 /// Consecutive ticks each cached device has been missing, for grace-period
91 /// eviction.
92 misses: HashMap<CacheKey, u8>,
93 /// Open HID++ channels reused across ticks, keyed by OS node id. Opening (and
94 /// tearing down) a device every ~2s tick is the churn issue #99 is about —
95 /// each open also leaks an `io_service_t` in async-hid's macOS backend — so a
96 /// steadily-connected node is opened once here and reused until it
97 /// disconnects.
98 channels: HashMap<async_hid::DeviceId, CachedChannel>,
99 /// Per-node last-good inventory + consecutive-failure counts: replays a
100 /// node's snapshot through transient probe failures and decides when its
101 /// cached channel must be dropped and reopened (see [`crate::node_ledger`]).
102 ledger: NodeLedger<async_hid::DeviceId>,
103 tick: u64,
104}
105
106/// An open channel to a receiver / direct-device HID node, held across
107/// `enumerate` ticks. Evicting it (on disconnect, or when the `Enumerator`
108/// drops) closes the device and joins the channel's read thread via
109/// [`HidppChannel`]'s `Drop`.
110struct CachedChannel {
111 info: async_hid::DeviceInfo,
112 channel: Arc<HidppChannel>,
113}
114
115/// Enumerate all Logitech HID++ receivers visible to the current process and
116/// the devices paired to each.
117///
118/// Combines two data sources per receiver:
119///
120/// - `trigger_device_arrival` events — the only path to a device's wireless
121/// PID in hidpp 0.2 (the `wpid` field on `BoltDevicePairingInformation` is
122/// private). Only online, responsive devices show up here.
123/// - `get_device_pairing_information` polled per slot — covers paired-but-
124/// offline devices (sleeping mice, devices on a different host) that the
125/// arrival ping doesn't wake. No wpid for these.
126///
127/// We merge the two so an MX Master that's been asleep still shows up with
128/// its codename and kind even before you click it.
129pub async fn enumerate() -> Result<Vec<DeviceInventory>, InventoryError> {
130 // The polling [`Enumerator`] keeps a per-node ledger across ticks, so a
131 // transient probe miss replays the node's last good inventory. A one-shot
132 // caller (CLI `list` / `diag`) builds a fresh `Enumerator` whose ledger is
133 // empty, so a miss has nothing to replay and would surface as an empty or
134 // partial list — the two isolated runs in #218 read 3 devices and 0. Retry a
135 // few times instead, reusing the same enumerator so its ledger accumulates a
136 // snapshot a later attempt can replay and the opened channel stays warm.
137 // #226's 5 s request timeout inside `HidppChannel::send` makes a dead probe
138 // fail fast, so a short bounded retry is cheap. Some transports can answer
139 // while still yielding a short device set (for example, a Unifying arrival
140 // event landing just after the drain window). When every node answered this
141 // cycle but that healthy pass is still short, two identical inventories mean
142 // the expected stable Unifying offline drain has settled. A failed/timed-out
143 // probe must keep using the full retry budget so the next attempt can reopen
144 // the channel and recover.
145 let mut enumerator = Enumerator::default();
146 let mut previous_inventories: Option<Vec<DeviceInventory>> = None;
147 let mut attempt = 1u8;
148 loop {
149 let (inventories, all_complete, all_healthy) =
150 enumerator.enumerate_reporting_completeness().await?;
151 if one_shot_should_stop(
152 previous_inventories.as_deref(),
153 &inventories,
154 all_complete,
155 all_healthy,
156 attempt,
157 ) {
158 return Ok(inventories);
159 }
160 debug!(
161 attempt,
162 all_complete,
163 all_healthy,
164 "one-shot enumerate inventory incomplete or still changing — retrying"
165 );
166 // Only a healthy pass is valid evidence for the unchanged-inventory
167 // stop, so the equality check below only ever compares two consecutive
168 // healthy snapshots. A failed/timed-out probe (replayed last-good or
169 // partial live result) is cleared so it can't count as one of the two
170 // "stable" reads and short-circuit a later healthy-but-short pass.
171 previous_inventories = if all_healthy { Some(inventories) } else { None };
172 tokio::time::sleep(ONESHOT_RETRY_DELAY).await;
173 attempt += 1;
174 }
175}
176
177/// Stop the one-shot retry loop when the snapshot is complete, when a healthy
178/// but short pass has stabilized (the expected Unifying offline-drain case), or
179/// when the explicit attempt cap is reached. An unchanged inventory from a
180/// failed probe is not stable evidence; it must keep retrying until the cap.
181fn one_shot_should_stop(
182 previous: Option<&[DeviceInventory]>,
183 current: &[DeviceInventory],
184 all_complete: bool,
185 all_healthy: bool,
186 attempt: u8,
187) -> bool {
188 all_complete
189 || (all_healthy && previous.is_some_and(|previous| previous == current))
190 || attempt >= ONESHOT_ATTEMPTS
191}
192
193/// Attempts a one-shot [`enumerate`] makes before returning whatever it last
194/// read, when an inventory keeps coming back incomplete or changing.
195const ONESHOT_ATTEMPTS: u8 = 4;
196
197/// Delay between one-shot [`enumerate`] retries. A first probe usually wakes an
198/// asleep device, so a short pause lets the next attempt read it cleanly.
199const ONESHOT_RETRY_DELAY: Duration = Duration::from_millis(300);
200
201impl Enumerator {
202 /// One enumeration pass, reusing the cache from prior passes. Probes every
203 /// HID candidate concurrently (so one asleep node that burns the whole
204 /// `PROBE_BUDGET` can't stall the others), reusing each device's cached
205 /// immutable data when it's present and fresh.
206 ///
207 /// A node the OS still lists but whose probe fails (receiver registers
208 /// unanswered, probe timeout, open failure) is **not** reported as absent:
209 /// its last completed inventory is replayed for a bounded grace and its
210 /// channel is reopened, so a transient HID++ glitch can't masquerade as
211 /// "no devices" (#218) — see the node ledger.
212 pub async fn enumerate(&mut self) -> Result<Vec<DeviceInventory>, InventoryError> {
213 self.enumerate_reporting_completeness()
214 .await
215 .map(|(inv, _, _)| inv)
216 }
217
218 /// [`Self::enumerate`] plus whether every probed node produced a complete
219 /// enough snapshot for the one-shot caller to stop early, and whether every
220 /// probed node answered this cycle. Completeness is separate from per-node
221 /// health: a node can answer cleanly enough for the ledger to accept its
222 /// live inventory while still reporting a known count/list shortfall that
223 /// the one-shot retry should give one more chance to settle. Only healthy
224 /// shortfalls can use the unchanged-inventory early stop; failed probes must
225 /// run through the retry budget so a later attempt can recover.
226 async fn enumerate_reporting_completeness(
227 &mut self,
228 ) -> Result<(Vec<DeviceInventory>, bool, bool), InventoryError> {
229 self.tick = self.tick.wrapping_add(1);
230 let tick = self.tick;
231 let candidates = enumerate_hidpp_devices().await?;
232 debug!(count = candidates.len(), "HID++ candidate interfaces");
233
234 // Reuse an open channel per node, opening one only for a node seen for
235 // the first time. Sequential because opening mutates the channel cache,
236 // but in steady state every node is already cached so this is just
237 // lookups — an actual open happens only when a new device appears.
238 let mut active: Vec<(async_hid::DeviceInfo, Arc<HidppChannel>)> = Vec::new();
239 let mut seen_nodes: HashSet<async_hid::DeviceId> = HashSet::new();
240 let mut open_failures: Vec<async_hid::DeviceId> = Vec::new();
241 for dev in candidates {
242 let node = dev.id.clone();
243 seen_nodes.insert(node.clone());
244 if let Some(open) = self.channels.get(&node) {
245 active.push((open.info.clone(), Arc::clone(&open.channel)));
246 continue;
247 }
248 match open_hidpp_channel(dev).await {
249 Ok(Some((info, channel))) => {
250 self.channels.insert(
251 node,
252 CachedChannel {
253 info: info.clone(),
254 channel: Arc::clone(&channel),
255 },
256 );
257 active.push((info, channel));
258 }
259 Ok(None) => {} // speaks HID but not HID++ — not one of ours
260 // The node is listed but unreachable right now — settled as a
261 // failed probe below, so its last inventory is replayed.
262 Err(e) => {
263 warn!(error = ?e, "failed to open HID++ channel — retrying next tick");
264 open_failures.push(node);
265 }
266 }
267 }
268 // Drop channels for nodes that vanished this tick. A node missing from
269 // the enumeration is a real disconnect (the IOHIDManager device set is
270 // authoritative, unlike a HID++ probe timeout), so close the device and
271 // join its read thread now instead of leaving a dead channel behind; a
272 // reconnect re-opens under a fresh node id. The ledger forgets vanished
273 // nodes for the same reason — a true disconnect must not be replayed.
274 self.channels.retain(|node, _| seen_nodes.contains(node));
275 self.ledger.retain_nodes(&seen_nodes);
276
277 // Probe each open channel concurrently, sharing `&cache` read-only;
278 // updates are collected and applied afterwards (no `RefCell`).
279 let results = {
280 let cache = &self.cache;
281 active
282 .into_iter()
283 .map(|(info, channel)| async move {
284 let node = info.id.clone();
285 let probe = timeout(PROBE_BUDGET, probe_one(info, channel, cache, tick)).await;
286 (node, probe)
287 })
288 .collect::<Vec<_>>()
289 .join()
290 .await
291 };
292
293 let mut inventories = Vec::new();
294 let mut outcomes = Vec::new();
295 // Aggregates for the one-shot retry. `all_complete` can stop
296 // immediately; `all_healthy` gates the unchanged-inventory shortcut so
297 // failed probes keep retrying. The ledger's own per-node replay is
298 // governed by `probe.healthy`.
299 let mut all_complete = true;
300 let mut all_healthy = true;
301 for (node, result) in results {
302 let probe = if let Ok(probe) = result {
303 probe
304 } else {
305 // The probe burned the whole budget — an asleep direct device,
306 // or a channel whose read loop parked on a dead handle (see
307 // `AsyncHidChannel::read_report`). Either way: "couldn't
308 // check", not "nothing there".
309 warn!(budget = ?PROBE_BUDGET, "device probe timed out — treating as a failed probe");
310 NodeProbe::failed()
311 };
312 all_complete &= probe.complete;
313 all_healthy &= probe.healthy;
314 outcomes.extend(probe.outcomes);
315 let settled = self.ledger.settle(&node, probe.healthy, probe.inventory);
316 if settled.evict_channel && self.channels.remove(&node).is_some() {
317 warn!("node probe keeps failing — dropping its channel to reopen next tick");
318 }
319 inventories.extend(settled.inventory);
320 }
321 // Nodes that wouldn't open this tick still replay their last snapshot
322 // (they have no cached channel to evict).
323 for node in open_failures {
324 all_complete = false;
325 all_healthy = false;
326 let settled = self.ledger.settle(&node, false, None);
327 inventories.extend(settled.inventory);
328 }
329
330 // Apply fresh probes and record which devices were seen this tick.
331 let mut seen_keys = HashSet::new();
332 for outcome in outcomes {
333 match outcome {
334 CacheOutcome::Fresh(key, cached) | CacheOutcome::Update(key, cached) => {
335 seen_keys.insert(key.clone());
336 self.cache.insert(key, cached);
337 }
338 CacheOutcome::Seen(key) => {
339 seen_keys.insert(key);
340 }
341 CacheOutcome::Unkeyed => {}
342 }
343 }
344 self.evict_unseen(&seen_keys);
345 Ok((inventories, all_complete, all_healthy))
346 }
347
348 /// Drop cache entries for devices not seen this tick, after a short grace so
349 /// a transient receiver timeout doesn't discard a still-present device.
350 fn evict_unseen(&mut self, seen_keys: &HashSet<CacheKey>) {
351 for key in seen_keys {
352 self.misses.remove(key);
353 }
354 let missing: Vec<CacheKey> = self
355 .cache
356 .keys()
357 .filter(|k| !seen_keys.contains(*k))
358 .cloned()
359 .collect();
360 for key in missing {
361 let misses = self.misses.entry(key.clone()).or_insert(0);
362 *misses += 1;
363 if *misses > CACHE_MISS_GRACE {
364 self.cache.remove(&key);
365 self.misses.remove(&key);
366 }
367 }
368 }
369}
370
371#[cfg(test)]
372mod tests;