Skip to content
Open
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
5 changes: 5 additions & 0 deletions crates/openlogi-core/src/config/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ pub struct DeviceIdentity {
/// not a physical-device key and never contains a serial or OS node id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry_model_id: Option<String>,
/// Wireless product ID from the receiver pairing table. Persisted so
/// HID++ 1.0 devices (which lack `model_info` and may lack `codename`
/// during early probing) can still be distinguished after a re-pairing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wpid: Option<u16>,
}

impl DeviceIdentity {
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-desktop/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,7 @@ mod tests {
slot: 1,
online: true,
battery: None,
wpid: None,
}
}

Expand Down
45 changes: 45 additions & 0 deletions crates/openlogi-desktop/src/services/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,51 @@ impl AssetResolver {
self.load_standalone_files(depot, entry, registry_model_id)
}

/// Resolve a device that lacks HID++ 2.0 `DeviceModelInfo` using its
/// Unifying/Bolt wireless product id (wpid) and optional codename.
///
/// HID++ 1.0 devices (many Unifying keyboards) never expose feature
/// 0x0003, so the probe produces `model_info = None`. However, the
/// device-arrival event still carries a wpid that — when formatted as a
/// 4-hex suffix — matches the asset registry's `modelId` for these
/// products. The codename (read from the receiver's pairing register) is
/// tried as a display-name fallback when the suffix match misses.
pub fn resolve_by_wpid(
&self,
wpid: Option<u16>,
codename: Option<&str>,
) -> Option<ResolvedAsset> {
let index = self.index.as_ref()?;

// Try wpid as a suffix match (e.g. wpid 0x4076 → "4076" matches
// registry modelId "4076" for the K540/K545).
if let Some(wpid) = wpid {
let suffix = format!("{wpid:04x}");
if let Some((depot, entry)) = index.find_by_model_id_suffix(&suffix) {
debug!(depot, wpid = %suffix, "asset matched via wpid suffix for HID++ 1.0 device");
let model_id = &entry.model_id;
if let Some(asset) = self.load_standalone_files(depot, entry, model_id) {
return Some(asset);
}
}
}

// Fall back to codename ↔ displayName when the wpid lookup misses.
if let Some(name) = codename
&& let Some((depot, entry)) = index.find_by_display_name(name)
{
debug!(
depot,
codename = name,
"asset matched via codename for HID++ 1.0 device"
);
let model_id = &entry.model_id;
return self.load_standalone_files(depot, entry, model_id);
}

None
}

fn load_files(
&self,
depot: &str,
Expand Down
173 changes: 169 additions & 4 deletions crates/openlogi-desktop/src/state/devices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ use openlogi_camera::Camera;
use openlogi_core::config::{Config, DeviceIdentity};
use openlogi_core::device::{
BatteryInfo, Capabilities, DeviceInventory, DeviceKind, DeviceModelInfo, DeviceTransports,
LightCapabilities, StandaloneDevice,
LightCapabilities, PairedDevice, StandaloneDevice,
};
use openlogi_core::device_order::{DeviceStableId, PhysicalDeviceKey};
use openlogi_core::hid::DeviceRoute;
use tracing::debug;

use super::device_key::DeviceKey;
use super::inventory::is_fallback_display_name;
use crate::services::assets::{AssetResolver, ResolvedAsset};

/// One paired device with everything the UI needs to switch to it in O(1):
Expand Down Expand Up @@ -62,6 +63,11 @@ pub struct DeviceRecord {
pub slot: u8,
pub online: bool,
pub battery: Option<BatteryInfo>,
/// Wireless product ID from the receiver pairing table. Available for
/// HID++ 1.0 and 2.0 receiver-paired devices; `None` for direct/USB or
/// offline devices that never reported a WPID. Used as a model-level
/// discriminator for identity persistence.
pub wpid: Option<u16>,
}

impl DeviceRecord {
Expand Down Expand Up @@ -125,7 +131,14 @@ pub(super) fn build_device_list(
let route = DeviceRoute::device_route_for(inv, paired.slot);
let (model_key, asset, model_info, codename, serial_number, unit_id) =
if let Some(model) = paired.model_info.as_ref() {
let asset = cache.resolve(model, paired.codename.as_deref());
let asset = cache
.resolve(model, paired.codename.as_deref())
.or_else(|| {
// Normal resolution failed (e.g. model_ids are all
// zero on HID++ 1.0 devices). Fall back to WPID-based
// suffix matching against the asset registry.
cache.resolve_by_wpid(paired.wpid, paired.codename.as_deref())
});
(
model.config_key(),
asset,
Expand All @@ -139,11 +152,17 @@ pub(super) fn build_device_list(
// timed out. Surface the device anyway using the wpid (or slot
// as a last-resort model key) so it appears in the carousel
// with a stable display fallback.
//
// Try resolving assets via wpid suffix or codename: many
// Unifying keyboards (K540/K545, K375s, …) are HID++ 1.0 and
// lack feature 0x0003, but their wpid still matches the asset
// registry's modelId.
let key = paired.wpid.map_or_else(
|| format!("slot{}", paired.slot),
|w| format!("wpid{w:04x}"),
);
(key, None, None, paired.codename.clone(), None, [0u8; 4])
let asset = cache.resolve_by_wpid(paired.wpid, paired.codename.as_deref());
(key, asset, None, paired.codename.clone(), None, [0u8; 4])
};
let stable_id = DeviceStableId::from_parts(
route.as_ref(),
Expand All @@ -160,6 +179,22 @@ pub(super) fn build_device_list(
.as_ref()
.map(|a| a.display_name.clone())
.or_else(|| paired.codename.as_deref().map(prettify_codename))
.or_else(|| {
// Transient resolver failure protection: when the asset
// resolver cannot find a match this cycle (e.g. the index
// is being rewritten by the sync task), fall back to the
// persisted identity's display name if it carries a known
// product name and the device model hasn't changed (a kind
// change or model mismatch signals a re-pairing — the stale
// name must not be inherited by the replacement device).
config
.device_identity(&config_key)
.filter(|id| id.kind == paired.kind)
.filter(|id| persisted_model_matches_paired(id, paired))
.map(|id| &id.display_name)
.filter(|name| !is_fallback_display_name(name))
.cloned()
})
.unwrap_or_else(|| format!("Slot {}", paired.slot));
let kind = effective_kind(paired.kind, asset.as_ref().map(|a| a.kind));
list.push(DeviceRecord {
Expand All @@ -182,6 +217,7 @@ pub(super) fn build_device_list(
slot: paired.slot,
online: paired.online,
battery: paired.battery.clone(),
wpid: paired.wpid,
});
}
}
Expand Down Expand Up @@ -248,6 +284,7 @@ fn camera_record(camera: &Camera, cache: &AssetResolver) -> DeviceRecord {
slot: 0,
online: true,
battery: None,
wpid: None,
}
}

Expand Down Expand Up @@ -321,6 +358,7 @@ fn append_standalone(
slot: openlogi_core::hid::DIRECT_DEVICE_INDEX,
online: device.online,
battery: None,
wpid: None,
});
}
}
Expand Down Expand Up @@ -473,6 +511,7 @@ fn offline_record(
slot: 0,
online: false,
battery: None,
wpid: identity.wpid,
}
}

Expand Down Expand Up @@ -531,9 +570,9 @@ pub(super) fn adopt_transient_record(known: &DeviceRecord, live: DeviceRecord) -
slot: live.slot,
online: live.online,
battery: live.battery.or_else(|| known.battery.clone()),
wpid: live.wpid.or(known.wpid),
}
}

/// Order the carousel by physical route. HID enumeration order can change as
/// different mice wake, sleep, or are selected; sorting by the stable route
/// (not whichever HID node was reported first) keeps the header stable.
Expand Down Expand Up @@ -585,6 +624,7 @@ fn demo_keyboard() -> DeviceRecord {
slot: 0,
online: true,
battery: None,
wpid: None,
}
}

Expand Down Expand Up @@ -633,6 +673,46 @@ pub(super) fn pick_initial_device(list: &[DeviceRecord], saved: Option<&str>) ->

/// Tidy a raw HID++ codename for display when no curated asset name exists.
/// Logitech reports gaming codenames in ALL CAPS (e.g. `"G513 RGB MECHANICAL
/// Checks whether a persisted [`DeviceIdentity`] plausibly refers to the same
/// product model as the live [`PairedDevice`]. Used to guard against inheriting
/// a stale display name when a different device is re-paired into the same
/// receiver slot.
///
/// Returns `true` when no available model-level identifier contradicts the
/// persisted identity. When neither side carries a codename or model_info, the
/// WPID is used as a final discriminator before falling back to the
/// kind-only match.
fn persisted_model_matches_paired(id: &DeviceIdentity, paired: &PairedDevice) -> bool {
// Codename is the lightest model discriminator — available even for
// HID++ 1.0 devices that lack feature 0x0003.
if let (Some(persisted_cn), Some(live_cn)) =
(id.codename.as_deref(), paired.codename.as_deref())
{
return persisted_cn == live_cn;
}

// model_info config_key: extended_model_id + model_ids[0].
if let (Some(persisted_mi), Some(live_mi)) =
(id.model_info.as_ref(), paired.model_info.as_ref())
{
return persisted_mi.config_key() == live_mi.config_key();
}

// When the live side lacks both codename and model_info but has a WPID,
// compare it against the persisted identity's WPID. A mismatch means a
// different product now occupies this slot.
if let (Some(persisted_wpid), Some(live_wpid)) = (id.wpid, paired.wpid)
&& persisted_wpid != 0
&& live_wpid != 0
{
return persisted_wpid == live_wpid;
}

// No discriminator available beyond kind — conservatively allow the
// fallback (matches pre-existing behaviour).
true
}

/// GAMING KEYBOARD"`); title-case each word so it reads like the asset names
/// (`"MX Master 3S"`) instead of shouting, while keeping model numbers (tokens
/// with a digit, e.g. `G513`) and short acronyms (`RGB`, `TKL`, `SE`) as-is.
Expand Down Expand Up @@ -740,6 +820,7 @@ mod tests {
slot: 1,
online: true,
battery: None,
wpid: None,
}
}

Expand All @@ -762,6 +843,7 @@ mod tests {
codename: None,
driver_id: None,
registry_model_id: None,
wpid: None,
}
}

Expand Down Expand Up @@ -858,6 +940,7 @@ mod tests {
codename: None,
driver_id: Some("litra".into()),
registry_model_id: Some("8c900".into()),
wpid: None,
};
let record = offline_record(
"raw:046d:c900:ff43:0202:serial:known-light",
Expand Down Expand Up @@ -1184,3 +1267,85 @@ mod tests {
assert_ne!(list[0].capture_id, list[1].capture_id);
}
}

#[cfg(test)]
mod identity_guard_tests {
use super::*;
use openlogi_core::config::DeviceIdentity;
use openlogi_core::device::{Capabilities, DeviceKind, PairedDevice};

fn hidpp1_identity(codename: Option<&str>, wpid: Option<u16>) -> DeviceIdentity {
DeviceIdentity {
display_name: codename.map_or_else(|| "Slot 1".to_string(), str::to_string),
kind: DeviceKind::Keyboard,
capabilities: Capabilities::default(),
light_capabilities: None,
model_info: None,
codename: codename.map(str::to_string),
driver_id: None,
registry_model_id: None,
wpid,
}
}

fn hidpp1_paired(codename: Option<&str>, wpid: Option<u16>) -> PairedDevice {
PairedDevice {
slot: 1,
codename: codename.map(str::to_string),
wpid,
kind: DeviceKind::Keyboard,
online: true,
battery: None,
model_info: None,
capabilities: None,
}
}

#[test]
fn same_wpid_without_codename_allows_fallback() {
let persisted = hidpp1_identity(Some("K540"), Some(0x4074));
let live = hidpp1_paired(None, Some(0x4074));
// Same WPID — device hasn't changed, guard should allow preservation
assert!(persisted_model_matches_paired(&persisted, &live));
}

#[test]
fn different_wpid_without_codename_blocks_fallback() {
let persisted = hidpp1_identity(Some("K540"), Some(0x4074));
let live = hidpp1_paired(None, Some(0x4071));
// Different WPID — re-paired with a different device
assert!(!persisted_model_matches_paired(&persisted, &live));
}

#[test]
fn matching_codename_allows_fallback_regardless_of_wpid() {
let persisted = hidpp1_identity(Some("K540"), Some(0x4074));
let live = hidpp1_paired(Some("K540"), Some(0x4074));
assert!(persisted_model_matches_paired(&persisted, &live));
}

#[test]
fn different_codename_blocks_fallback() {
let persisted = hidpp1_identity(Some("K540"), Some(0x4074));
let live = hidpp1_paired(Some("K375s"), Some(0x4071));
assert!(!persisted_model_matches_paired(&persisted, &live));
}

#[test]
fn no_identifiers_on_either_side_allows_fallback_conservatively() {
let persisted = hidpp1_identity(None, None);
let live = hidpp1_paired(None, None);
// No evidence of a change — conservatively allow
assert!(persisted_model_matches_paired(&persisted, &live));
}

#[test]
fn different_kind_blocks_fallback() {
let mut persisted = hidpp1_identity(Some("K540"), Some(0x4074));
persisted.kind = DeviceKind::Mouse;
let live = hidpp1_paired(Some("K540"), Some(0x4074));
// Kind mismatch is checked by the caller (filter), not this function,
// but codename match still holds within same-kind scope
assert!(persisted_model_matches_paired(&persisted, &live));
}
}
Loading