From 27c21b7c49d3e44b3ba8781c3ec31443417b0ff0 Mon Sep 17 00:00:00 2001 From: 7eedle <232406712+7eedle@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:26:02 +0300 Subject: [PATCH 1/3] fix: duplicated hid --- src/hid.rs | 61 ++++++++++++++++++++++-------------------------------- 1 file changed, 25 insertions(+), 36 deletions(-) diff --git a/src/hid.rs b/src/hid.rs index d3697d1..4038f0d 100644 --- a/src/hid.rs +++ b/src/hid.rs @@ -600,7 +600,13 @@ impl HidSession { self.device_keys.insert(device_id, key.clone()); key }; - if !self.candidates.contains_key(&key) { + let paths = infos.iter().map(|info| info.path()).collect::>(); + let open = self.open_devices.contains_key(&key); + if self + .candidates + .get(&key) + .is_none_or(|candidate| !open && candidate.paths.len() != paths.len()) + { let candidate = inspect_candidate(&self.api, key.clone(), &infos)?; self.candidates.insert(key.clone(), candidate); } @@ -922,8 +928,8 @@ impl HidSession { .open_devices .get(key) .ok_or_else(|| "the HID interface is not open".to_owned())?; - // An open device has several paths only when `inspect_candidate` verified - // they share one serial. A report still goes to exactly one of them. + // An open device has several paths only when `grouped_device_key` matched + // them by serial or Windows HID interface. A report still goes to exactly one. if open.paths.is_empty() { return Err("the HID interface has no open path".into()); } @@ -1082,13 +1088,19 @@ fn grouped_device_key( serial: Option<&str>, ) -> Vec { // A serial is a verified physical identity, so every collection of that - // device is one candidate. Without one, collections stay apart by path. + // device is one candidate. Without one, Windows collections group by their + // interface's OS-assigned parent instance; anything else stays apart by path. let serial = serial.filter(|serial| !serial.is_empty()); - let identity = serial.map_or(path, str::as_bytes); + let interface = collection_interface(path); + let (kind, identity) = match (serial, &interface) { + (Some(serial), _) => (1, serial.as_bytes()), + (None, Some(interface)) => (2, interface.as_bytes()), + (None, None) => (0, path), + }; let mut key = Vec::with_capacity(13 + identity.len()); key.extend_from_slice(&vendor_id.to_le_bytes()); key.extend_from_slice(&product_id.to_le_bytes()); - key.push(u8::from(serial.is_some())); + key.push(kind); key.extend_from_slice(&(identity.len() as u64).to_le_bytes()); key.extend_from_slice(identity); key @@ -1124,12 +1136,13 @@ fn report_paths(layouts: &[&ReportLayout], report_id: u8, feature: bool) -> Vec< } } -/// True when every interface reports the same non-empty serial. -fn shares_verified_serial<'a>(mut serials: impl Iterator>) -> bool { - match serials.next().flatten().filter(|serial| !serial.is_empty()) { - Some(first) => serials.all(|serial| serial == Some(first)), - None => false, - } +fn collection_interface(path: &[u8]) -> Option { + let path = std::str::from_utf8(path).ok()?.to_ascii_lowercase(); + let (hardware, rest) = path.strip_prefix(r"\\?\hid#")?.split_once('#')?; + let (instance, _) = rest.split_once('#')?; + let (hardware, _) = hardware.rsplit_once("&col")?; + let (parent, _) = instance.rsplit_once('&')?; + Some(format!("{hardware}#{parent}")) } fn inspect_candidate( @@ -1148,14 +1161,6 @@ fn inspect_candidate( } } - // Several paths are the collections of one device only when they share a - // verified serial; that is the one case `grouped_device_key` merges them. - if unique_paths.len() > 1 - && !shares_verified_serial(infos.iter().map(hidapi::DeviceInfo::serial_number)) - { - return Err("HID physical identity is ambiguous; refusing multi-path access".into()); - } - let inspect_descriptors = primary.vendor_id() != RAZER_VENDOR_ID; let mut collections = Vec::new(); let mut paths = Vec::with_capacity(unique_paths.len()); @@ -1687,22 +1692,6 @@ mod tests { ); } - #[test] - fn several_paths_open_only_under_one_verified_serial() { - assert!(shares_verified_serial( - [Some("receiver-123"), Some("receiver-123")].into_iter() - )); - assert!(!shares_verified_serial( - [Some("receiver-123"), Some("receiver-456")].into_iter() - )); - assert!(!shares_verified_serial( - [Some("receiver-123"), None].into_iter() - )); - assert!(!shares_verified_serial([None, None].into_iter())); - assert!(!shares_verified_serial([Some(""), Some("")].into_iter())); - assert!(!shares_verified_serial(std::iter::empty())); - } - #[test] fn a_report_goes_to_the_paths_that_declare_it_first() { let mut vendor = ReportLayout::default(); From 02b3e2c1ac8dea6b893469365f9588153aff7aa1 Mon Sep 17 00:00:00 2001 From: 7eedle <232406712+7eedle@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:02:47 +0300 Subject: [PATCH 2/3] refresh open devices when their paths change --- src/hid.rs | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/hid.rs b/src/hid.rs index 4038f0d..cfc8bab 100644 --- a/src/hid.rs +++ b/src/hid.rs @@ -601,14 +601,19 @@ impl HidSession { key }; let paths = infos.iter().map(|info| info.path()).collect::>(); - let open = self.open_devices.contains_key(&key); - if self - .candidates - .get(&key) - .is_none_or(|candidate| !open && candidate.paths.len() != paths.len()) - { - let candidate = inspect_candidate(&self.api, key.clone(), &infos)?; - self.candidates.insert(key.clone(), candidate); + if self.candidates.get(&key).is_none_or(|candidate| { + candidate.paths.len() != paths.len() + || candidate + .paths + .iter() + .any(|path| !paths.contains(path.path.as_c_str())) + }) { + if !self.open_devices.contains_key(&key) { + let candidate = inspect_candidate(&self.api, key.clone(), &infos)?; + self.candidates.insert(key.clone(), candidate); + } else if let Err(error) = self.recover(&key) { + tracing::warn!(session_id = self.session_id, device_key = %key, %error, "HID reopen after path change failed"); + } } if let Some(candidate) = self.candidates.get(&key) { summaries.push(candidate.summary.clone()); @@ -1178,20 +1183,20 @@ fn inspect_candidate( collections.append(&mut path_collections); path_layout } else { + collections.extend( + infos + .iter() + .filter(|info| info.path() == path.as_c_str()) + .map(|info| CollectionInfo { + usage_page: info.usage_page(), + usage: info.usage(), + ..CollectionInfo::default() + }), + ); ReportLayout::default() }; paths.push(CandidatePath { path, layout }); } - if collections.is_empty() { - collections = infos - .iter() - .map(|info| CollectionInfo { - usage_page: info.usage_page(), - usage: info.usage(), - ..CollectionInfo::default() - }) - .collect(); - } Ok(Candidate { paths, From ce189581a2ccd6f9f3eb5c42a447e447526ffadb Mon Sep 17 00:00:00 2001 From: 7eedle <232406712+7eedle@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:24:51 +0300 Subject: [PATCH 3/3] fail list when reopening after a path change fails --- src/hid.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hid.rs b/src/hid.rs index cfc8bab..83a2f3c 100644 --- a/src/hid.rs +++ b/src/hid.rs @@ -611,8 +611,8 @@ impl HidSession { if !self.open_devices.contains_key(&key) { let candidate = inspect_candidate(&self.api, key.clone(), &infos)?; self.candidates.insert(key.clone(), candidate); - } else if let Err(error) = self.recover(&key) { - tracing::warn!(session_id = self.session_id, device_key = %key, %error, "HID reopen after path change failed"); + } else { + self.recover(&key)?; } } if let Some(candidate) = self.candidates.get(&key) {