From e144c131427fe69a185c9c1e9c85b0027ae3131b Mon Sep 17 00:00:00 2001 From: Carlos Requena Date: Mon, 13 Jul 2026 12:18:40 +0200 Subject: [PATCH] feat(hid): recognise Lightspeed nano receivers (G-series, e.g. G305) Logitech Lightspeed nano receivers (e.g. the one bundled with the G305) were dropped by receiver::detect(), which only matched Bolt and Unifying receivers, so a mouse paired to one never surfaced in the inventory. A Lightspeed receiver exposes the same HID++ 1.0 receiver registers as Unifying, so route it through the existing Unifying code path and only differentiate the user-facing receiver name ("Lightspeed Receiver"). - add 046d:c53f to unifying::VPID_PAIRS so detect() builds the receiver - add LIGHTSPEED_PIDS + speaks_unifying_protocol()/receiver_display_name() - route Lightspeed as DeviceRoute::Unifying (was defaulting to Bolt) and classify it as ReceiverFamily::Unifying for pairing - label the enumerated receiver via receiver_display_name() - tests for Lightspeed routing and display name Verified on macOS with a real G305 (paired device wpid 0x4074). --- crates/openlogi-hid/src/inventory/probe.rs | 2 +- crates/openlogi-hid/src/lib.rs | 5 +- crates/openlogi-hid/src/pairing.rs | 3 +- crates/openlogi-hid/src/route.rs | 82 +++++++++++++++---- .../openlogi-hidpp/src/receiver/unifying.rs | 9 +- 5 files changed, 83 insertions(+), 18 deletions(-) diff --git a/crates/openlogi-hid/src/inventory/probe.rs b/crates/openlogi-hid/src/inventory/probe.rs index e1d970ad..3031fe96 100644 --- a/crates/openlogi-hid/src/inventory/probe.rs +++ b/crates/openlogi-hid/src/inventory/probe.rs @@ -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, diff --git a/crates/openlogi-hid/src/lib.rs b/crates/openlogi-hid/src/lib.rs index 6d0418d8..2d91886f 100644 --- a/crates/openlogi-hid/src/lib.rs +++ b/crates/openlogi-hid/src/lib.rs @@ -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, diff --git a/crates/openlogi-hid/src/pairing.rs b/crates/openlogi-hid/src/pairing.rs index 93f2968b..156d2cc1 100644 --- a/crates/openlogi-hid/src/pairing.rs +++ b/crates/openlogi-hid/src/pairing.rs @@ -62,7 +62,8 @@ pub enum ReceiverFamily { fn family_for(product_id: u16) -> Option { 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) { + // Unifying proper plus protocol-compatible Lightspeed receivers. Some(ReceiverFamily::Unifying) } else { None diff --git a/crates/openlogi-hid/src/route.rs b/crates/openlogi-hid/src/route.rs index 108fa2db..1a90e6e9 100644 --- a/crates/openlogi-hid/src/route.rs +++ b/crates/openlogi-hid/src/route.rs @@ -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]; + +/// 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. @@ -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 { 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), @@ -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 { @@ -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 diff --git a/crates/openlogi-hidpp/src/receiver/unifying.rs b/crates/openlogi-hidpp/src/receiver/unifying.rs index 42611a7e..961b4355 100755 --- a/crates/openlogi-hidpp/src/receiver/unifying.rs +++ b/crates/openlogi-hidpp/src/receiver/unifying.rs @@ -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)]