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
2 changes: 1 addition & 1 deletion crates/openlogi-hid/src/inventory/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ async fn probe_unifying_receiver(
NodeProbe {
inventory: Some(DeviceInventory {
receiver: ReceiverInfo {
name: "Unifying Receiver".to_string(),
name: crate::route::receiver_display_name(info.product_id).to_string(),
vendor_id: info.vendor_id,
product_id: info.product_id,
unique_id,
Expand Down
5 changes: 4 additions & 1 deletion crates/openlogi-hid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ pub use pairing::{
Click, DiscoveredDevice, PairingCommand, PairingError, PairingEvent, PairingReceiver,
PasskeyMethod, ReceiverFamily, ReceiverSelector, list_pairing_receivers, run_pairing, unpair,
};
pub use route::{BOLT_PIDS, DIRECT_DEVICE_INDEX, DeviceRoute, UNIFYING_PIDS};
pub use route::{
BOLT_PIDS, DIRECT_DEVICE_INDEX, DeviceRoute, LIGHTSPEED_PIDS, UNIFYING_PIDS,
receiver_display_name, speaks_unifying_protocol,
};
pub use smartshift::{AUTO_DISENGAGE_PERMANENT, SmartShiftMode, SmartShiftStatus};
pub use write::{
DpiCapabilities, DpiInfo, FeatureEntry, HidppFeatureErrorKind, HidppOperation, LightingMethod,
Expand Down
3 changes: 2 additions & 1 deletion crates/openlogi-hid/src/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ pub enum ReceiverFamily {
fn family_for(product_id: u16) -> Option<ReceiverFamily> {
if crate::BOLT_PIDS.contains(&product_id) {
Some(ReceiverFamily::Bolt)
} else if crate::UNIFYING_PIDS.contains(&product_id) {
} else if crate::speaks_unifying_protocol(product_id) {

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 Pairing Flow Uses Enumeration Match

family_for now treats every Lightspeed PID as a Unifying pairing receiver, so run_pairing and unpair can write the Unifying pairing commands to 0xc53f. The compatibility noted for enumeration registers does not show that the pairing lock and unpair commands use the same contract, so a Lightspeed receiver that lists devices correctly but rejects those commands can fail pairing or apply the wrong receiver operation.

Fix in Codex Fix in Claude Code

// Unifying proper plus protocol-compatible Lightspeed receivers.
Some(ReceiverFamily::Unifying)
} else {
None
Expand Down
82 changes: 68 additions & 14 deletions crates/openlogi-hid/src/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,34 @@ pub const BOLT_PIDS: &[u16] = &[0xc548];
/// need to construct the correct [`DeviceRoute`] variant from a raw inventory.
pub const UNIFYING_PIDS: &[u16] = &[0xc52b, 0xc532];

/// USB product IDs that identify Logitech Lightspeed nano receivers — the
/// receivers bundled with G-series wireless mice such as the G305. They speak
/// the same HID++ 1.0 receiver register protocol as Unifying, so they are
/// enumerated, routed, and paired through the Unifying code path; only the
/// user-facing receiver name (see [`receiver_display_name`]) differs.
pub const LIGHTSPEED_PIDS: &[u16] = &[0xc53f];

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 Lightspeed Children Stay Unfiltered

Adding 0xc53f makes Lightspeed receivers appear in the receiver inventory, but the Linux receiver-child sysfs filter still checks only Bolt and Unifying PIDs. On Linux, HID child nodes behind this receiver can still be treated as standalone devices, so the same physical mouse can show up with duplicate or wrong direct-device routing.

Fix in Codex Fix in Claude Code


/// Whether `product_id` is a receiver that speaks the Unifying HID++ 1.0
/// register protocol — a Unifying receiver proper, or a protocol-compatible
/// Lightspeed receiver. Such receivers are addressed with
/// [`DeviceRoute::Unifying`].
#[must_use]
pub fn speaks_unifying_protocol(product_id: u16) -> bool {
UNIFYING_PIDS.contains(&product_id) || LIGHTSPEED_PIDS.contains(&product_id)
}

/// Human-readable name for a receiver identified by `product_id`, used to label
/// it in the inventory. Lightspeed receivers share the Unifying protocol path
/// but are surfaced under their own name.
#[must_use]
pub fn receiver_display_name(product_id: u16) -> &'static str {
if LIGHTSPEED_PIDS.contains(&product_id) {
"Lightspeed Receiver"
} else {
"Unifying Receiver"
}
}

impl DeviceRoute {
/// The HID++ device index features are addressed at for this route: the
/// pairing slot for a Bolt device, the self-index for a direct one.
Expand All @@ -86,25 +114,29 @@ impl DeviceRoute {
/// Build the route that reaches a paired device from a receiver inventory.
///
/// Picks [`DeviceRoute::Unifying`] or [`DeviceRoute::Bolt`] based on the
/// receiver's product ID using the canonical `UNIFYING_PIDS` / `BOLT_PIDS`
/// lists. Any receiver PID not in `UNIFYING_PIDS` — including future Bolt
/// variants whose PID isn't yet in `BOLT_PIDS` — defaults to
/// [`DeviceRoute::Bolt`] so writes keep working rather than silently
/// dropping. [`DeviceRoute::Direct`] is used for directly-attached devices
/// receiver's product ID via [`speaks_unifying_protocol`] (Unifying proper
/// plus protocol-compatible Lightspeed receivers). Any receiver that does
/// not speak the Unifying protocol — including future Bolt variants whose
/// PID isn't yet in `BOLT_PIDS` — defaults to [`DeviceRoute::Bolt`] so
/// writes keep working rather than silently dropping.
/// [`DeviceRoute::Direct`] is used for directly-attached devices
/// (slot == [`DIRECT_DEVICE_INDEX`] with no receiver UID). Returns `None`
/// when the receiver UID is unknown (writes are skipped, not mis-routed).
#[must_use]
pub fn device_route_for(inv: &DeviceInventory, slot: u8) -> Option<Self> {
match &inv.receiver.unique_id {
Some(uid) if UNIFYING_PIDS.contains(&inv.receiver.product_id) => Some(Self::Unifying {
receiver_uid: uid.clone(),
slot,
}),
Some(uid) if speaks_unifying_protocol(inv.receiver.product_id) => {
Some(Self::Unifying {
receiver_uid: uid.clone(),
slot,
})
}
Some(uid) => {
// Default to Bolt for any receiver whose PID is not in
// UNIFYING_PIDS. This covers both known Bolt PIDs (BOLT_PIDS)
// and any future Bolt-compatible receiver with a new PID —
// returning None would silently drop writes for such receivers.
// Default to Bolt for any receiver that does not speak the
// Unifying protocol. This covers both known Bolt PIDs
// (BOLT_PIDS) and any future Bolt-compatible receiver with a new
// PID — returning None would silently drop writes for such
// receivers.
if !BOLT_PIDS.contains(&inv.receiver.product_id) {
tracing::debug!(
pid = format_args!("{:04x}", inv.receiver.product_id),
Expand Down Expand Up @@ -198,7 +230,9 @@ pub(crate) async fn open_route_channel(
mod tests {
use openlogi_core::device::{DeviceInventory, ReceiverInfo};

use super::{DIRECT_DEVICE_INDEX, DeviceRoute, UNIFYING_PIDS};
use super::{
DIRECT_DEVICE_INDEX, DeviceRoute, LIGHTSPEED_PIDS, UNIFYING_PIDS, receiver_display_name,
};

fn inv(product_id: u16, unique_id: Option<&str>) -> DeviceInventory {
DeviceInventory {
Expand All @@ -223,6 +257,26 @@ mod tests {
}
}

#[test]
fn device_route_for_lightspeed_pids_create_unifying_route() {
// Lightspeed nano receivers (e.g. the G305's) speak the Unifying
// protocol, so writes must be routed through DeviceRoute::Unifying —
// not defaulted to Bolt, which would address the pairing slot wrong.
for &pid in LIGHTSPEED_PIDS {
let route = DeviceRoute::device_route_for(&inv(pid, Some("A1B2")), 2);
assert!(
matches!(route, Some(DeviceRoute::Unifying { ref receiver_uid, slot: 2 }) if receiver_uid == "A1B2"),
"lightspeed pid {pid:#06x} should produce a Unifying route"
);
}
}

#[test]
fn lightspeed_receiver_has_its_own_display_name() {
assert_eq!(receiver_display_name(0xc53f), "Lightspeed Receiver");
assert_eq!(receiver_display_name(0xc52b), "Unifying Receiver");
}

#[test]
fn device_route_for_bolt_pid_creates_bolt_route() {
// 0xC548 is Bolt; anything not in UNIFYING_PIDS defaults to Bolt so
Expand Down
9 changes: 8 additions & 1 deletion crates/openlogi-hidpp/src/receiver/unifying.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ use crate::{

/// All USB vendor & product ID pairs that are known to identify Unifying
/// receivers.
pub const VPID_PAIRS: &[(u16, u16)] = &[(0x046d, 0xc52b), (0x046d, 0xc532)];
///
/// `0xc53f` is a Lightspeed nano receiver (bundled with G-series wireless mice
/// such as the G305). It is not a Unifying receiver, but it exposes the same
/// HID++ 1.0 receiver registers (`0x02` connections, `0xB5/0x5N` pairing info),
/// so device enumeration goes through this implementation unchanged. Callers
/// that surface a user-facing receiver name label it separately (see
/// `openlogi-hid`). Verified against a G305 (paired device wpid `0x4074`).
pub const VPID_PAIRS: &[(u16, u16)] = &[(0x046d, 0xc52b), (0x046d, 0xc532), (0x046d, 0xc53f)];

/// All known registers of the Unifying receiver.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)]
Expand Down