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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- *(hid)* keep macOS Bluetooth-direct Logitech devices discoverable when HID++ optional reads are slow.
- *(gui)* avoid stale Bluetooth-direct `unit:*` device cards and render direct offline placeholders as slot 255.

## [0.6.18](https://github.com/AprilNEA/OpenLogi/compare/openlogi-core-v0.6.17...openlogi-core-v0.6.18) - 2026-06-29

### Added
Expand Down
33 changes: 33 additions & 0 deletions crates/openlogi-core/src/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,18 @@ impl DeviceModelInfo {
pub fn config_key(&self) -> String {
format!("{:x}{:04x}", self.extended_model_id, self.model_ids[0])
}

/// Whether this `DeviceInformation` payload names a real Logitech model.
///
/// Some direct macOS HID interfaces answer the `0x0003` function with a
/// structurally valid but semantically empty payload: unit id set, every
/// transport bit clear, and all model IDs zero. Unit id alone can
/// disambiguate two devices, but it cannot identify the model, resolve an
/// asset, or produce a trustworthy direct config key.
#[must_use]
pub fn has_model_identity(&self) -> bool {
self.model_ids.iter().any(|id| *id != 0)
}
}

/// Mirror of hidpp's `DeviceTransport` bitfield — one bool per protocol the
Expand Down Expand Up @@ -325,4 +337,25 @@ mod tests {
Capabilities::default()
);
}

#[test]
fn model_info_identity_requires_a_real_model_id() {
use super::{DeviceModelInfo, DeviceTransports};

let mut info = DeviceModelInfo {
entity_count: 15,
serial_number: None,
unit_id: [0x1c, 0x18, 0x18, 0x00],
transports: DeviceTransports::default(),
model_ids: [0, 0, 0],
extended_model_id: 0,
};
assert!(
!info.has_model_identity(),
"a HID interface that reports no model id must not create a direct config key"
);

info.model_ids[0] = 0xb034;
assert!(info.has_model_identity());
}
}
21 changes: 21 additions & 0 deletions crates/openlogi-gui/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1336,6 +1336,14 @@ fn persist_identities(config: &mut Config, list: &[DeviceRecord]) {
if !record.online {
continue;
}
if !has_persistable_device_identity(record) {
debug!(
key = %record.config_key,
display = %record.display_name,
"online device lacks a persistable identity — not recording offline placeholder"
);
continue;
}
let Some(capabilities) = record.capabilities else {
continue;
};
Expand All @@ -1360,6 +1368,19 @@ fn persist_identities(config: &mut Config, list: &[DeviceRecord]) {
}
}

fn has_persistable_device_identity(record: &DeviceRecord) -> bool {
match record.route {
Some(DeviceRoute::Direct { .. }) => {
record
.model_info
.as_ref()
.is_some_and(openlogi_core::device::DeviceModelInfo::has_model_identity)
&& (record.serial_number.is_some() || record.unit_id != [0; 4])
}
Some(DeviceRoute::Bolt { .. } | DeviceRoute::Unifying { .. }) | None => true,
}
}

/// Whether a DPI discovery error is permanent (the device genuinely lacks the
/// feature or reports nothing usable) versus transient (a timeout or busy
/// device worth retrying).
Expand Down
148 changes: 146 additions & 2 deletions crates/openlogi-gui/src/state/devices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ fn append_offline_known<'a>(
.model_info
.as_ref()
.map_or_else(|| key.to_string(), DeviceModelInfo::config_key);
if stale_direct_identity_shadowed_by_live(list, key, identity) {
debug!(
key,
display = %identity.display_name,
"suppressing stale direct unit identity shadowed by a live direct device"
);
continue;
}
if blocked_keys.contains(key) || blocked_keys.contains(&model_key) {
continue;
}
Expand All @@ -160,6 +168,56 @@ fn append_offline_known<'a>(
}
}

fn stale_direct_identity_shadowed_by_live(
list: &[DeviceRecord],
key: &str,
identity: &DeviceIdentity,
) -> bool {
let Some((vendor_id, product_id, key_identity)) = direct_key_parts(key) else {
return false;
};
if key_identity != "unit" {
return false;
}
if identity
.model_info
.as_ref()
.is_some_and(DeviceModelInfo::has_model_identity)
{
return false;
}
list.iter().any(|record| {
record.online
&& record.display_name == identity.display_name
&& record.kind == identity.kind
&& matches!(
Comment on lines +190 to +193

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 Identical Devices Shadow Placeholders

This match suppresses any unit-keyed, non-identifying direct identity when any live direct record has the same display name, kind, VID, and PID. Two identical Logitech devices can share all of those fields, so if one is online and the other is asleep, the sleeping device's only offline placeholder is dropped from the carousel until it probes live again.

Fix in Codex Fix in Claude Code

record.route,
Some(DeviceRoute::Direct {
vendor_id: live_vid,
product_id: live_pid,
}) if live_vid == vendor_id && live_pid == product_id
)
})
}

fn direct_key_parts(key: &str) -> Option<(u16, u16, &str)> {
let mut parts = key.split(':');
match (
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
) {
(Some("direct"), Some(vid), Some(pid), Some(identity), Some(_)) => Some((
u16::from_str_radix(vid, 16).ok()?,
u16::from_str_radix(pid, 16).ok()?,
identity,
)),
_ => None,
}
}

/// Synthesize an offline placeholder from a persisted [`DeviceIdentity`].
///
/// `route: None` keeps every hardware write a no-op until the live inventory
Expand Down Expand Up @@ -195,12 +253,20 @@ fn offline_record(
route: None,
kind: identity.kind,
capabilities: Some(identity.capabilities),
slot: 0,
slot: offline_slot_placeholder(config_key),
online: false,
battery: None,
}
}

fn offline_slot_placeholder(config_key: &str) -> u8 {
if config_key.starts_with("direct:") && direct_key_parts(config_key).is_some() {
0xff
} else {
0
}
}

fn model_info_from_legacy_model_key(key: &str) -> Option<DeviceModelInfo> {
if key.len() <= 4 || !key.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
Expand Down Expand Up @@ -334,7 +400,10 @@ fn prettify_codename(raw: &str) -> String {
#[cfg(test)]
mod tests {
use openlogi_core::config::Config;
use openlogi_core::device::{DeviceInventory, PairedDevice, ReceiverInfo};
use openlogi_core::device::{
DeviceInventory, DeviceModelInfo, DeviceTransports, PairedDevice, ReceiverInfo,
};
use openlogi_hid::DeviceRoute;

use crate::asset::AssetResolver;

Expand Down Expand Up @@ -387,6 +456,43 @@ mod tests {
}
}

fn online_direct_record(
key: &str,
display_name: &str,
vendor_id: u16,
product_id: u16,
) -> DeviceRecord {
DeviceRecord {
config_key: key.to_string(),
model_key: "1b034".to_string(),
display_name: display_name.to_string(),
asset: None,
model_info: Some(DeviceModelInfo {
entity_count: 3,
serial_number: Some("2331LZ520ZQ8".to_string()),
unit_id: [0; 4],
transports: DeviceTransports {
btle: true,
..DeviceTransports::default()
},
model_ids: [0xb034, 0, 0],
extended_model_id: 1,
}),
codename: Some(display_name.to_string()),
serial_number: Some("2331LZ520ZQ8".to_string()),
unit_id: [0; 4],
route: Some(DeviceRoute::Direct {
vendor_id,
product_id,
}),
kind: DeviceKind::Mouse,
capabilities: Some(Capabilities::presumed_from_kind(DeviceKind::Mouse)),
slot: 0xff,
online: true,
battery: None,
}
}

fn mouse_identity(name: &str) -> DeviceIdentity {
DeviceIdentity {
display_name: name.to_string(),
Expand Down Expand Up @@ -447,6 +553,15 @@ mod tests {
assert_eq!(rec.capabilities, Some(id.capabilities));
}

#[test]
fn direct_offline_record_uses_direct_slot_placeholder() {
let id = mouse_identity("MX Master 3S");
let cache = AssetResolver::new();
let rec = offline_record("direct:046d:b034:serial:2331lz520zq8", &id, &cache);

assert_eq!(rec.slot, 0xff);
}

#[test]
fn known_devices_are_appended_only_when_absent_from_live() {
// "A" is live; "B" is known-but-asleep. The union keeps the live "A"
Expand All @@ -469,6 +584,35 @@ mod tests {
);
}

#[test]
fn stale_direct_unit_identity_is_suppressed_when_live_direct_serial_exists() {
let mut list = vec![online_direct_record(
"direct:046d:b034:serial:2331lz520zq8",
"MX Master 3S",
0x046d,
0xb034,
)];
let mut stale = mouse_identity("MX Master 3S");
stale.model_info = Some(DeviceModelInfo {
entity_count: 15,
serial_number: None,
unit_id: [0; 4],
transports: DeviceTransports::default(),
model_ids: [0, 0, 0],
extended_model_id: 0,
});
let cache = AssetResolver::new();

append_offline_known(
&mut list,
[("direct:046d:b034:unit:1c181800", &stale)].into_iter(),
&cache,
);

assert_eq!(list.len(), 1);
assert_eq!(list[0].config_key, "direct:046d:b034:serial:2331lz520zq8");
}

#[test]
fn asset_kind_overrides_a_misreporting_hid_kind() {
// #127: the registry knows this depot is a mouse, so a HID++ source that
Expand Down
Loading