From bc6f79b65412b2d3b00a9df0bdefdc4cabc04f42 Mon Sep 17 00:00:00 2001 From: David Budnick Date: Tue, 16 Jun 2026 10:20:11 -0500 Subject: [PATCH 1/2] fix(hid): show wired battery-less mice on walk failure --- crates/openlogi-hid/src/inventory.rs | 43 ++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/openlogi-hid/src/inventory.rs b/crates/openlogi-hid/src/inventory.rs index 3e71349b..d8b53a3c 100644 --- a/crates/openlogi-hid/src/inventory.rs +++ b/crates/openlogi-hid/src/inventory.rs @@ -745,7 +745,19 @@ async fn probe_direct( // would be indistinguishable from a transient glitch, so the node is // settled as a failed probe and its last inventory replayed. let walk_succeeded = probe.capabilities.is_some(); - let caps = probe.capabilities.unwrap_or_default(); + // When the full feature-table walk completed, trust its capabilities. When + // it didn't (a wired mouse whose `FeatureSet` walk timed out — e.g. the + // G502 HERO over USB), fall back to a cheap per-feature root probe: each + // `root.getFeature` is a single round-trip that answers even when the table + // walk fails, so the device proves it is a real peripheral instead of being + // misread as a receiver secondary interface and dropped from the list. + let fallback_caps = if probe.capabilities.is_none() { + peripheral_caps_via_root(&channel, DIRECT_DEVICE_INDEX).await + } else { + None + }; + let effective_caps = probe.capabilities.or(fallback_caps); + let caps = effective_caps.unwrap_or_default(); let is_peripheral = probe.battery.is_some() || caps.buttons || caps.pointer || caps.lighting; if !is_peripheral { debug!( @@ -786,7 +798,7 @@ async fn probe_direct( online: true, battery: probe.battery, model_info: probe.model_info, - capabilities: probe.capabilities, + capabilities: effective_caps, }], }; NodeProbe { @@ -965,6 +977,33 @@ fn battery_feature_index(ids: impl IntoIterator) -> Option { .and_then(|pos| u8::try_from(pos + 1).ok()) } +/// Driving feature IDs that mark a direct device as a real configurable +/// peripheral (reprogrammable controls, adjustable DPI, per-key lighting) +/// rather than a receiver's secondary HID interface. Mirrors the families +/// [`Capabilities::from_feature_ids`] keys on. +const PERIPHERAL_FEATURE_IDS: [u16; 8] = [ + 0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04, 0x2201, 0x2202, 0x8080, +]; + +/// Cheap capability probe via the device root, for when the full `FeatureSet` +/// walk (see [`probe_features`]) doesn't complete. Each `root.getFeature` is a +/// single round-trip that answers even when the table walk fails, so a wired +/// mouse whose walk times out still proves it is a peripheral instead of +/// vanishing. `None` when the device can't be opened or announces none of these +/// features. Only reached on the direct path's walk-failed fallback. +async fn peripheral_caps_via_root(channel: &Arc, index: u8) -> Option { + let device = Device::new(Arc::clone(channel), index).await.ok()?; + let root = device.root(); + let mut present = Vec::new(); + for id in PERIPHERAL_FEATURE_IDS { + if let Ok(Some(_)) = root.get_feature(id).await { + present.push(id); + } + } + let caps = Capabilities::from_feature_ids(&present); + (caps != Capabilities::default()).then_some(caps) +} + /// Open a HID++ session for `slot` and read everything we care about (battery, /// device-information, `0x0005` device type, and the feature table that drives /// [`Capabilities`]) in one shot. Device sessions are expensive (multi-round- From 850ca4c1ec49173a399a6fe9861de22de730072a Mon Sep 17 00:00:00 2001 From: David Budnick Date: Tue, 16 Jun 2026 10:20:11 -0500 Subject: [PATCH 2/2] fix(gui): alias the G502 HERO to the g502_core image --- crates/openlogi-gui/src/asset/mod.rs | 143 +++++++++++++++++++++++++-- 1 file changed, 133 insertions(+), 10 deletions(-) diff --git a/crates/openlogi-gui/src/asset/mod.rs b/crates/openlogi-gui/src/asset/mod.rs index 805a5d6f..578b70d4 100644 --- a/crates/openlogi-gui/src/asset/mod.rs +++ b/crates/openlogi-gui/src/asset/mod.rs @@ -179,7 +179,15 @@ impl AssetResolver { ) -> Option { let index = self.index.as_ref()?; let (depot, entry) = resolve_in_index(index, model, codename)?; - self.load_files(depot, entry, model) + let mut resolved = self.load_files(depot, entry, model)?; + // A curated alias lends a sibling depot's render but not its identity: + // keep the device's own name so a G502 HERO isn't relabelled "G502". + if let Some(name) = codename + && aliased_depot(model, codename) == Some(depot) + { + resolved.display_name = name.to_string(); + } + Some(resolved) } fn load_files( @@ -342,15 +350,58 @@ pub(crate) fn resolve_in_index<'a>( return Some(hit); } - // Last resort: bridge by firmware codename ↔ registry displayName. - let name = codename?; - let hit = index.find_by_display_name(name)?; - debug!( - depot = hit.0, - codename = name, - "asset matched via codename↔displayName fallback" - ); - Some(hit) + // Bridge by firmware codename ↔ registry displayName. + if let Some(name) = codename + && let Some(hit) = index.find_by_display_name(name) + { + debug!( + depot = hit.0, + codename = name, + "asset matched via codename↔displayName fallback" + ); + return Some(hit); + } + + // Last resort: a curated alias for devices absent from the upstream registry + // that reuse a sibling's chassis render — e.g. the G502 HERO ships no depot + // of its own but is the classic G502 body, so it borrows `g502_core`'s image. + if let Some(depot) = aliased_depot(model, codename) + && let Some(hit) = index + .devices + .iter() + .find(|(d, _)| d.as_str() == depot) + .map(|(d, e)| (d.as_str(), e)) + { + debug!(depot = hit.0, "asset matched via curated family alias"); + return Some(hit); + } + None +} + +/// Depot to borrow a render from for a device the upstream registry +/// (`assets.openlogi.org`) doesn't list but which is cosmetically identical to +/// a depot it does. Matched by registry PID first (stable when the feature walk +/// read one), then by a codename substring as a fallback for a walk that left +/// no PID. Empty for everything else — the common path never reaches here. +fn aliased_depot(model: &DeviceModelInfo, codename: Option<&str>) -> Option<&'static str> { + /// `(pid, depot)` — the G502 HERO (USB pid `c08b`) shares the classic G502 + /// body, which the registry keys as `g502_core` (pid `c07d`). + const PID_ALIASES: &[(u16, &str)] = &[(0xc08b, "g502_core")]; + /// `(codename-substring, depot)` — used when the walk yielded no PID; the + /// substring is matched case-insensitively. + const NAME_ALIASES: &[(&str, &str)] = &[("g502 hero", "g502_core")]; + + if let Some(&(_, depot)) = PID_ALIASES + .iter() + .find(|(pid, _)| model.model_ids.contains(pid)) + { + return Some(depot); + } + let name = codename?.to_ascii_lowercase(); + NAME_ALIASES + .iter() + .find(|(needle, _)| name.contains(needle)) + .map(|&(_, depot)| depot) } fn strict_candidates(model: &DeviceModelInfo) -> Vec { @@ -458,6 +509,78 @@ mod tests { assert_eq!(hit.map(|(depot, _)| depot), Some("mx_master_3s")); } + fn g502_entry(model_id: &str, display_name: &str, depot: &str) -> DeviceEntry { + DeviceEntry { + model_id: model_id.to_string(), + model_ids: vec![model_id.to_string()], + display_name: display_name.to_string(), + kind: "mouse".to_string(), + asset_path: format!("assets/{depot}/"), + files: Vec::new(), + } + } + + /// The G502 HERO (wired, USB pid `c08b`) reports a pid the registry doesn't + /// list — only the classic `g502_core` ("G502", pid `c07d`) and + /// `g502_wireless` are present — so it resolves via the curated alias. + fn g502_index() -> Index { + let mut devices = HashMap::new(); + devices.insert( + "g502_core".to_string(), + g502_entry("c07d", "G502", "g502_core"), + ); + devices.insert( + "g502_wireless".to_string(), + g502_entry("407f", "G502 Lightspeed", "g502_wireless"), + ); + Index { + schema_version: 1, + devices, + } + } + + fn g502_hero_model(model_ids: [u16; 3]) -> DeviceModelInfo { + DeviceModelInfo { + entity_count: 0, + serial_number: None, + unit_id: [0; 4], + transports: DeviceTransports { + usb: true, + ..Default::default() + }, + model_ids, + extended_model_id: 0, + } + } + + #[test] + fn g502_hero_aliases_to_g502_core_by_pid() { + let index = g502_index(); + let hit = resolve_in_index(&index, &g502_hero_model([0xc08b, 0, 0]), None); + assert_eq!(hit.map(|(depot, _)| depot), Some("g502_core")); + } + + #[test] + fn g502_hero_aliases_to_g502_core_by_codename_without_pid() { + // A walk that left no registry pid still resolves via the codename. + let index = g502_index(); + let hit = resolve_in_index( + &index, + &g502_hero_model([0, 0, 0]), + Some("G502 HERO Gaming Mouse"), + ); + assert_eq!(hit.map(|(depot, _)| depot), Some("g502_core")); + } + + #[test] + fn unaliased_unknown_device_still_misses() { + // The alias must not become a catch-all: an unrelated unknown device + // with no pid/name match resolves to nothing. + let index = g502_index(); + let hit = resolve_in_index(&index, &g502_hero_model([0xabcd, 0, 0]), Some("Some Mouse")); + assert!(hit.is_none()); + } + fn bare_model() -> DeviceModelInfo { DeviceModelInfo { entity_count: 0,