Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 133 additions & 10 deletions crates/openlogi-gui/src/asset/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,15 @@ impl AssetResolver {
) -> Option<ResolvedAsset> {
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(
Expand Down Expand Up @@ -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)
Comment on lines +401 to +404

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Codename alias uses substring matchname.contains(needle) means any future device whose OS product string happens to contain "g502 hero" (e.g. a hypothetical "G502 HERO SE" with a different chassis) would borrow the wrong render without any explicit entry. Since the PID path is authoritative and takes priority, the risk is low today, but as the alias table grows, an exact-match or anchored check would be safer and communicate intent more clearly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

}

fn strict_candidates(model: &DeviceModelInfo) -> Vec<String> {
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 41 additions & 2 deletions crates/openlogi-hid/src/inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -965,6 +977,33 @@ fn battery_feature_index(ids: impl IntoIterator<Item = u16>) -> Option<u8> {
.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,
];
Comment on lines +984 to +986

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 PERIPHERAL_FEATURE_IDS is missing 0x8070 (ColorLedEffects), which Capabilities::from_feature_ids recognises as a driving lighting feature alongside 0x8080. A wired keyboard that exposes only 0x8070 and whose feature-table walk fails would have an empty present list after the fallback probe, causing peripheral_caps_via_root to return None and the device to be dropped — the exact failure mode this PR is designed to fix.

Suggested change
const PERIPHERAL_FEATURE_IDS: [u16; 8] = [
0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04, 0x2201, 0x2202, 0x8080,
];
const PERIPHERAL_FEATURE_IDS: [u16; 9] = [
0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04, 0x2201, 0x2202, 0x8070, 0x8080,
];

Fix in Codex Fix in Claude Code


/// 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<HidppChannel>, index: u8) -> Option<Capabilities> {
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-
Expand Down