From ab71592c91cdc6c3ad2bc57c9f8e55f6266341a8 Mon Sep 17 00:00:00 2001 From: David Budnick Date: Thu, 2 Jul 2026 16:00:44 -0500 Subject: [PATCH 1/5] feat(hid): expose HID hotplug events --- crates/openlogi-hid/src/hotplug.rs | 24 ++++++++++++++++++++++++ crates/openlogi-hid/src/lib.rs | 2 ++ crates/openlogi-hid/src/transport.rs | 5 +++++ 3 files changed, 31 insertions(+) create mode 100644 crates/openlogi-hid/src/hotplug.rs diff --git a/crates/openlogi-hid/src/hotplug.rs b/crates/openlogi-hid/src/hotplug.rs new file mode 100644 index 00000000..4ba2ad9c --- /dev/null +++ b/crates/openlogi-hid/src/hotplug.rs @@ -0,0 +1,24 @@ +//! OS HID hotplug events, bridged from the shared `async-hid` backend. + +use futures_lite::{Stream, StreamExt as _}; + +use crate::inventory::InventoryError; +use crate::transport::hid_backend; + +/// A HID node appeared on or vanished from the OS device tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HotplugEvent { + /// A device node was connected. + Connected, + /// A device node was disconnected. + Disconnected, +} + +/// Subscribe to OS HID hotplug events through the shared process-wide backend. +pub fn watch_hotplug() -> Result + Send + Unpin, InventoryError> { + let stream = hid_backend().watch().map_err(InventoryError::Hid)?; + Ok(stream.map(|event| match event { + async_hid::DeviceEvent::Connected(_) => HotplugEvent::Connected, + async_hid::DeviceEvent::Disconnected(_) => HotplugEvent::Disconnected, + })) +} diff --git a/crates/openlogi-hid/src/lib.rs b/crates/openlogi-hid/src/lib.rs index 6d0418d8..7dccf2a8 100644 --- a/crates/openlogi-hid/src/lib.rs +++ b/crates/openlogi-hid/src/lib.rs @@ -21,6 +21,7 @@ mod windows_hid; pub mod gesture; mod hires_wheel; +pub mod hotplug; pub mod inventory; pub mod pairing; pub mod reprog_controls; @@ -30,6 +31,7 @@ pub mod write; pub use gesture::{CaptureChannel, CapturedInput, GestureError, run_capture_session}; pub use hires_wheel::{set_scroll_inversion, set_scroll_inversion_on}; +pub use hotplug::{HotplugEvent, watch_hotplug}; pub use inventory::{Enumerator, InventoryError, enumerate}; pub use pairing::{ Click, DiscoveredDevice, PairingCommand, PairingError, PairingEvent, PairingReceiver, diff --git a/crates/openlogi-hid/src/transport.rs b/crates/openlogi-hid/src/transport.rs index 2e8e0e0a..76e4a65d 100644 --- a/crates/openlogi-hid/src/transport.rs +++ b/crates/openlogi-hid/src/transport.rs @@ -105,6 +105,11 @@ fn is_long_only_collection(usage_page: u16, usage_id: u16) -> bool { /// single long-lived `IOHIDManager` across threads is the model hidapi uses too. static HID_BACKEND: LazyLock = LazyLock::new(HidBackend::default); +/// The process-wide HID backend shared by enumeration and hotplug watching. +pub(crate) fn hid_backend() -> &'static HidBackend { + &HID_BACKEND +} + pub(crate) async fn enumerate_hidpp_devices() -> Result, async_hid::HidError> { let all: Vec = HID_BACKEND.enumerate().await?.collect().await; From 4ea2d409529dd25b55e3003bfc9380b158b91c1d Mon Sep 17 00:00:00 2001 From: David Budnick Date: Thu, 2 Jul 2026 16:00:44 -0500 Subject: [PATCH 2/5] feat(agent): wake inventory watcher on hotplug events --- Cargo.lock | 1 + crates/openlogi-agent-core/Cargo.toml | 1 + .../src/watchers/inventory.rs | 51 ++++++++++++++++--- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34574838..eaf27643 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4862,6 +4862,7 @@ version = "0.6.18" dependencies = [ "async-hid", "bincode", + "futures-lite 2.6.1", "interprocess", "openlogi-core", "openlogi-hid", diff --git a/crates/openlogi-agent-core/Cargo.toml b/crates/openlogi-agent-core/Cargo.toml index 430a8daf..98b17381 100644 --- a/crates/openlogi-agent-core/Cargo.toml +++ b/crates/openlogi-agent-core/Cargo.toml @@ -15,6 +15,7 @@ openlogi-core = { path = "../openlogi-core" } openlogi-inject = { path = "../openlogi-inject" } openlogi-hid = { path = "../openlogi-hid" } openlogi-hook = { path = "../openlogi-hook" } +futures-lite = { workspace = true } serde = { workspace = true } tarpc = { version = "0.37", features = ["serde1", "serde-transport", "serde-transport-bincode", "tokio1"] } interprocess = { workspace = true } diff --git a/crates/openlogi-agent-core/src/watchers/inventory.rs b/crates/openlogi-agent-core/src/watchers/inventory.rs index b5abae03..b4ecfa5c 100644 --- a/crates/openlogi-agent-core/src/watchers/inventory.rs +++ b/crates/openlogi-agent-core/src/watchers/inventory.rs @@ -1,19 +1,20 @@ -//! Polling HID inventory watcher. +//! HID inventory watcher: periodic polling, woken early by hotplug events. //! //! Spawns a dedicated OS thread with a one-shot tokio runtime that calls //! `openlogi_hid::enumerate` every `period` and forwards each completed //! snapshot over an unbounded mpsc to the agent's select loop, which applies //! it via `Orchestrator::refresh_inventory`. //! -//! Polling beats hot-plug event registration on simplicity: HID transport -//! crates ship different listener APIs across platforms, and `async-hid 0.4` -//! does not expose any. A 2 s tick is cheap (one HID enumerate per cycle ≤ -//! a few hundred milliseconds) and matches the human-perceptible reconnect -//! latency budget in PLAN.md. +//! An OS hotplug event (`openlogi_hid::watch_hotplug`) cuts the wait short so +//! a just-plugged device is probed — and gets its persisted settings applied — +//! within a settle delay instead of a full period. The periodic tick stays as +//! the reconciliation pass (battery refresh, missed events, platforms where +//! the hotplug stream is unavailable). use std::thread; use std::time::{Duration, SystemTime}; +use futures_lite::StreamExt as _; use openlogi_core::device::DeviceInventory; use tokio::sync::mpsc; use tracing::{debug, info, warn}; @@ -25,6 +26,10 @@ use tracing::{debug, info, warn}; /// live inventory. const INITIAL_FAILURE_LIMIT: u8 = 3; +/// Pause between a hotplug event and the early enumerate, so a just-connected +/// node finishes registering with the OS before the probe opens it. +const HOTPLUG_SETTLE: Duration = Duration::from_millis(400); + /// Wall-clock slack past `period` before a late tick is read as a sleep/wake /// gap. Generously above the worst honest iteration (period + a fully /// timed-out probe pass), so only a genuine suspend trips it; a rare false @@ -126,6 +131,13 @@ pub fn spawn(period: Duration) -> mpsc::UnboundedReceiver { let mut enumerator = openlogi_hid::Enumerator::default(); let mut state = WatchState::default(); let mut last_tick = SystemTime::now(); + let mut hotplug = match openlogi_hid::watch_hotplug() { + Ok(stream) => Some(stream), + Err(e) => { + warn!(error = ?e, "hotplug watch unavailable — polling only"); + None + } + }; loop { // A tick arriving far past its period means the system slept; // `duration_since` errs when the clock stepped backwards, in @@ -147,7 +159,32 @@ pub fn spawn(period: Duration) -> mpsc::UnboundedReceiver { debug!("inventory watcher receiver dropped — exiting"); return; } - thread::sleep(period); + let stream_alive = rt.block_on(async { + let Some(stream) = hotplug.as_mut() else { + tokio::time::sleep(period).await; + return true; + }; + tokio::select! { + () = tokio::time::sleep(period) => true, + event = stream.next() => match event { + Some(event) => { + debug!(?event, "hotplug event — enumerating early"); + tokio::time::sleep(HOTPLUG_SETTLE).await; + // Drain the burst: one enumerate covers every node that just arrived. + while futures_lite::future::poll_once(stream.next()) + .await + .flatten() + .is_some() + {} + true + } + None => false, + }, + } + }); + if !stream_alive && hotplug.take().is_some() { + warn!("hotplug stream ended — falling back to pure polling"); + } } }); if let Err(e) = spawn_result { From 83d18e8e0ba535b7fc943bb05491b2a061cc22d1 Mon Sep 17 00:00:00 2001 From: David Budnick Date: Thu, 2 Jul 2026 16:00:44 -0500 Subject: [PATCH 3/5] fix(agent): confirm volatile settings after first sighting --- .../openlogi-agent-core/src/orchestrator.rs | 90 ++++++++++++++++++- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 019d08bb..d5b57581 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -10,7 +10,7 @@ //! [`DpiCycleState::capabilities`] stays `None` and presets cycle at their raw //! (still valid) values — exactly the GUI's "window never opened" behaviour. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::{Arc, RwLock}; @@ -80,6 +80,9 @@ pub struct Orchestrator { /// set/route/online state looks identical across the sleep gap, so the /// next refresh re-applies volatile settings to every online device. reapply_all_next_refresh: bool, + /// Config keys of devices first sighted last refresh, due one confirming + /// re-apply: the first write can race the device's own boot and be lost. + reapply_followup: HashSet, shared: SharedRuntime, } @@ -117,6 +120,7 @@ impl Orchestrator { current_app: None, inventory: InventoryState::Pending, reapply_all_next_refresh: false, + reapply_followup: HashSet::new(), shared, }; orch.rebuild(); @@ -200,7 +204,11 @@ impl Orchestrator { // (new route), a wake from device sleep (offline→online), or — via the // flag — a system wake where none of those are observable. let reapply_all = std::mem::take(&mut self.reapply_all_next_refresh); - for idx in reapply_targets(&self.devices, &devices, reapply_all) { + let followup = std::mem::take(&mut self.reapply_followup); + let (targets, next_followup) = + plan_reapply(&self.devices, &devices, &followup, reapply_all); + self.reapply_followup = next_followup; + for idx in targets { self.reapply_volatile_settings(&devices[idx]); } let changed = devices.len() != self.devices.len() @@ -433,6 +441,36 @@ fn reapply_targets(prev: &[AgentDevice], next: &[AgentDevice], reapply_all: bool .collect() } +/// Plan this refresh's volatile-settings writes: the [`reapply_targets`] set +/// plus one confirming re-apply for devices first sighted last refresh, and +/// the follow-up keys to confirm next refresh. +fn plan_reapply( + prev: &[AgentDevice], + next: &[AgentDevice], + followup: &HashSet, + reapply_all: bool, +) -> (Vec, HashSet) { + let mut targets = reapply_targets(prev, next, reapply_all); + let next_followup = targets + .iter() + .filter(|&&idx| { + let id = stable_id(&next[idx]); + !prev.iter().any(|p| stable_id(p) == id) + }) + .map(|&idx| next[idx].config_key.clone()) + .collect(); + for (idx, dev) in next.iter().enumerate() { + if dev.online + && dev.route.is_some() + && followup.contains(&dev.config_key) + && !targets.contains(&idx) + { + targets.push(idx); + } + } + (targets, next_followup) +} + /// Index of the selected device: the one whose `config_key` matches the saved /// selection, else the first. `build_devices` sorts by the same canonical key /// the GUI carousel uses, so "the first" is the same physical device in both @@ -455,7 +493,7 @@ fn write_value(lock: &RwLock, value: T, name: &str) { #[cfg(test)] mod tests { - use super::{AgentDevice, InventoryHealth, Orchestrator, reapply_targets}; + use super::{AgentDevice, InventoryHealth, Orchestrator, plan_reapply, reapply_targets}; use openlogi_core::config::Config; use openlogi_hid::DeviceRoute; @@ -526,6 +564,52 @@ mod tests { assert_eq!(reapply_targets(&prev, &next, true), vec![0]); } + #[test] + fn plan_reapply_confirms_a_first_sighting_once() { + use std::collections::HashSet; + // First sighting: applied now, queued for one confirming re-apply. + let (targets, followup) = plan_reapply(&[], &[dev("a", 1, true)], &HashSet::new(), false); + assert_eq!(targets, vec![0]); + assert_eq!(followup, HashSet::from(["a".to_string()])); + // Next refresh: the confirming apply fires, then the queue drains. + let prev = [dev("a", 1, true)]; + let (targets, followup) = plan_reapply(&prev, &prev, &followup, false); + assert_eq!(targets, vec![0]); + assert!(followup.is_empty()); + // Steady state after that: nothing. + let (targets, _) = plan_reapply(&prev, &prev, &followup, false); + assert!(targets.is_empty()); + } + + #[test] + fn plan_reapply_transitions_are_not_queued_for_confirmation() { + use std::collections::HashSet; + // A wake from device sleep re-applies once — the device was already + // booted, so no confirming write is queued. + let (targets, followup) = plan_reapply( + &[dev("a", 1, false)], + &[dev("a", 1, true)], + &HashSet::new(), + false, + ); + assert_eq!(targets, vec![0]); + assert!(followup.is_empty()); + } + + #[test] + fn plan_reapply_skips_a_followup_that_went_offline() { + use std::collections::HashSet; + let prev = [dev("a", 1, true)]; + let (targets, followup) = plan_reapply( + &prev, + &[dev("a", 1, false)], + &HashSet::from(["a".to_string()]), + false, + ); + assert!(targets.is_empty()); + assert!(followup.is_empty()); + } + /// An *empty* snapshot still flips the health to `Ready`: the watcher only /// forwards completed enumerations, so "checked and found nothing" must not /// be reported as "still scanning" — that's the whole distinction the From 12566a07842f71d6c52c7df475c5f577c68a8e76 Mon Sep 17 00:00:00 2001 From: David Budnick Date: Thu, 2 Jul 2026 16:00:44 -0500 Subject: [PATCH 4/5] fix(gui): suppress phantom offline device cards --- crates/openlogi-gui/src/state/devices.rs | 142 +++++++++++++++++++++-- 1 file changed, 135 insertions(+), 7 deletions(-) diff --git a/crates/openlogi-gui/src/state/devices.rs b/crates/openlogi-gui/src/state/devices.rs index bad673f9..ca3b8424 100644 --- a/crates/openlogi-gui/src/state/devices.rs +++ b/crates/openlogi-gui/src/state/devices.rs @@ -57,7 +57,9 @@ pub struct DeviceRecord { /// what makes the list independent of whether a probe wins its timing race: a /// known device (with its Pointer/Buttons panels) is always shown, and the live /// probe only *enriches* it (online state, battery, asset photo) rather than -/// *gating* whether it appears at all. See issue #159. +/// *gating* whether it appears at all. See issue #159. Placeholders that are +/// unreachable (their receiver is unplugged) or duplicate a visible same-model +/// card are suppressed — see [`append_offline_known`] (#271/#280). pub(super) fn build_device_list( inventories: &[DeviceInventory], cache: &AssetResolver, @@ -125,27 +127,52 @@ pub(super) fn build_device_list( if std::env::var_os("OPENLOGI_DEMO_KEYBOARD").is_some() { list.push(demo_keyboard()); } - append_offline_known(&mut list, config.known_identities(), cache); + let present_receivers: HashSet = inventories + .iter() + .filter_map(|inv| inv.receiver.unique_id.as_deref()) + .map(str::to_ascii_lowercase) + .collect(); + append_offline_known( + &mut list, + config.known_identities(), + cache, + &present_receivers, + ); sort_device_list(&mut list); list } /// Append an offline placeholder for every known device not already present in -/// `list` (matched by `config_key`). Split out from [`build_device_list`] so -/// the union rule is unit-testable without an [`AssetResolver`]. +/// `list`, skipping unreachable devices and duplicates of a visible one. +/// +/// Three gates keep phantom cards out (#271/#280): +/// - an exact key/model match against a live record — the device is already +/// in the list; +/// - a `receiver:` key whose receiver is not plugged in — its paired devices +/// are unreachable until that receiver returns (e.g. the work receiver's +/// mouse while at home); +/// - a wire PID already visible live or as an earlier placeholder — two units +/// of one model render as identical cards, so a second one only confuses. +/// The PID comparison also absorbs the flaky extended-model byte that made +/// `0b034` and `2b034` read as different models in #271. fn append_offline_known<'a>( list: &mut Vec, known: impl Iterator, cache: &AssetResolver, + present_receivers: &HashSet, ) { let mut blocked_keys: HashSet = list .iter() .flat_map(|r| [r.config_key.clone(), r.model_key.clone()]) .collect(); + let mut blocked_pids: HashSet = list.iter().filter_map(record_wire_pid).collect(); let mut known = known.collect::>(); known.sort_by_key(|(key, identity)| (identity.model_info.is_none(), (*key).to_string())); for (key, identity) in known { + if receiver_uid_of(key).is_some_and(|uid| !present_receivers.contains(&uid)) { + continue; + } let model_key = identity .model_info .as_ref() @@ -154,12 +181,35 @@ fn append_offline_known<'a>( continue; } let record = offline_record(key, identity, cache); + if let Some(pid) = record_wire_pid(&record) + && !blocked_pids.insert(pid) + { + continue; + } blocked_keys.insert(record.config_key.clone()); blocked_keys.insert(record.model_key.clone()); list.push(record); } } +/// The receiver UID embedded in a `receiver::slot:` config key. +fn receiver_uid_of(key: &str) -> Option { + key.strip_prefix("receiver:") + .and_then(|rest| rest.split(':').next()) + .map(str::to_ascii_lowercase) +} + +/// The record's wire product id, used to suppress same-model duplicate cards. +fn record_wire_pid(record: &DeviceRecord) -> Option { + match record.model_info.as_ref().map(|m| m.model_ids[0]) { + Some(pid) if pid != 0 => Some(format!("{pid:04x}")), + _ => record + .model_key + .strip_prefix("wpid") + .map(str::to_ascii_lowercase), + } +} + /// Synthesize an offline placeholder from a persisted [`DeviceIdentity`]. /// /// `route: None` keeps every hardware write a no-op until the live inventory @@ -338,9 +388,11 @@ mod tests { use crate::asset::AssetResolver; + use std::collections::HashSet; + use super::{ - Capabilities, DeviceIdentity, DeviceKind, DeviceRecord, append_offline_known, - build_device_list, effective_kind, offline_record, + Capabilities, DeviceIdentity, DeviceKind, DeviceModelInfo, DeviceRecord, DeviceTransports, + append_offline_known, build_device_list, effective_kind, offline_record, }; fn paired_device_no_model_info(slot: u8, wpid: Option) -> PairedDevice { @@ -456,7 +508,12 @@ mod tests { let a = mouse_identity("live A overwritten?"); let b = mouse_identity("asleep B"); let cache = AssetResolver::new(); - append_offline_known(&mut list, [("A", &a), ("B", &b)].into_iter(), &cache); + append_offline_known( + &mut list, + [("A", &a), ("B", &b)].into_iter(), + &cache, + &HashSet::new(), + ); assert_eq!(list.len(), 2); assert!( @@ -469,6 +526,77 @@ mod tests { ); } + fn model_info(ext: u8, pid: u16) -> DeviceModelInfo { + DeviceModelInfo { + entity_count: 0, + serial_number: None, + unit_id: [0; 4], + transports: DeviceTransports::default(), + model_ids: [pid, 0, 0], + extended_model_id: ext, + } + } + + #[test] + fn placeholders_for_absent_receivers_are_hidden() { + // The work receiver's mouse must not haunt the list at home: with its + // receiver unplugged the device is unreachable, so no card is shown. + let id = mouse_identity("MX Master 3S"); + let cache = AssetResolver::new(); + let mut list = Vec::new(); + append_offline_known( + &mut list, + [("receiver:aabb:slot:1", &id)].into_iter(), + &cache, + &HashSet::new(), + ); + assert!(list.is_empty()); + append_offline_known( + &mut list, + [("receiver:aabb:slot:1", &id)].into_iter(), + &cache, + &HashSet::from(["aabb".to_string()]), + ); + assert_eq!(list.len(), 1); + } + + #[test] + fn same_model_placeholder_is_blocked_by_a_live_unit() { + // #271: the live mouse reads ext-model 02 while the stale identity was + // recorded as 00 — the wire PID still identifies them as one model, so + // the phantom card is suppressed. + let mut live = online_record("receiver:aabb:slot:2"); + live.model_key = "2b034".to_string(); + live.model_info = Some(model_info(2, 0xb034)); + let mut list = vec![live]; + let id = mouse_identity("MX Master 3S"); + let cache = AssetResolver::new(); + append_offline_known( + &mut list, + [("0b034", &id)].into_iter(), + &cache, + &HashSet::new(), + ); + assert_eq!(list.len(), 1); + } + + #[test] + fn same_model_placeholders_collapse_to_one_card() { + // Two persisted identities of one model render identically — a second + // offline card carries no information, only confusion. + let id_a = mouse_identity("MX Master 3S"); + let id_b = mouse_identity("MX Master 3S"); + let cache = AssetResolver::new(); + let mut list = Vec::new(); + append_offline_known( + &mut list, + [("0b034", &id_a), ("2b034", &id_b)].into_iter(), + &cache, + &HashSet::new(), + ); + assert_eq!(list.len(), 1); + } + #[test] fn asset_kind_overrides_a_misreporting_hid_kind() { // #127: the registry knows this depot is a mouse, so a HID++ source that From f72c10b6efec01b393673e98aa204d0aaa45c140 Mon Sep 17 00:00:00 2001 From: David Budnick Date: Thu, 2 Jul 2026 16:36:03 -0500 Subject: [PATCH 5/5] fix(agent): run hotplug watch under runtime context --- .../src/watchers/inventory.rs | 25 +++++++++++++------ crates/openlogi-gui/src/state/devices.rs | 3 +++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/openlogi-agent-core/src/watchers/inventory.rs b/crates/openlogi-agent-core/src/watchers/inventory.rs index b4ecfa5c..0416d8f9 100644 --- a/crates/openlogi-agent-core/src/watchers/inventory.rs +++ b/crates/openlogi-agent-core/src/watchers/inventory.rs @@ -131,7 +131,10 @@ pub fn spawn(period: Duration) -> mpsc::UnboundedReceiver { let mut enumerator = openlogi_hid::Enumerator::default(); let mut state = WatchState::default(); let mut last_tick = SystemTime::now(); - let mut hotplug = match openlogi_hid::watch_hotplug() { + // `block_on` installs runtime context so a backend that registers an + // `AsyncFd` (Linux udev) fails as a catchable `Err`, not a panic that + // would take down the whole watcher thread. + let mut hotplug = match rt.block_on(async { openlogi_hid::watch_hotplug() }) { Ok(stream) => Some(stream), Err(e) => { warn!(error = ?e, "hotplug watch unavailable — polling only"); @@ -170,13 +173,19 @@ pub fn spawn(period: Duration) -> mpsc::UnboundedReceiver { Some(event) => { debug!(?event, "hotplug event — enumerating early"); tokio::time::sleep(HOTPLUG_SETTLE).await; - // Drain the burst: one enumerate covers every node that just arrived. - while futures_lite::future::poll_once(stream.next()) - .await - .flatten() - .is_some() - {} - true + // Drain the burst so one enumerate covers every node that just + // arrived; a `None` here is the stream closing, so report it now + // rather than after a spurious extra enumerate next tick. + let mut alive = true; + while let Some(drained) = + futures_lite::future::poll_once(stream.next()).await + { + if drained.is_none() { + alive = false; + break; + } + } + alive } None => false, }, diff --git a/crates/openlogi-gui/src/state/devices.rs b/crates/openlogi-gui/src/state/devices.rs index ca3b8424..6c8045f5 100644 --- a/crates/openlogi-gui/src/state/devices.rs +++ b/crates/openlogi-gui/src/state/devices.rs @@ -203,6 +203,9 @@ fn receiver_uid_of(key: &str) -> Option { fn record_wire_pid(record: &DeviceRecord) -> Option { match record.model_info.as_ref().map(|m| m.model_ids[0]) { Some(pid) if pid != 0 => Some(format!("{pid:04x}")), + // A degenerate `model_ids[0] == 0` falls through to `None` (no PID dedup); + // the record still dedups by key, so two identical zero-id models showing + // as separate offline cards is a rare, accepted gap. _ => record .model_key .strip_prefix("wpid")