Skip to main content

openlogi_core/hid/
route.rs

1//! How to reach a controllable HID++ device — addressing data only, no I/O.
2//!
3//! Two addressing modes:
4//!
5//! - [`DeviceRoute::Bolt`] — a device paired to a Logi Bolt receiver, reached
6//!   through the receiver channel at a pairing slot.
7//! - [`DeviceRoute::Direct`] — a device attached straight to the host over a
8//!   USB cable or Bluetooth, reached on its own channel at the HID++
9//!   self-index [`DIRECT_DEVICE_INDEX`].
10//!
11//! Opening the channel a route names is `openlogi_hid::channel::route::open_route_channel`
12//! — the one place both the write path and the capture session resolve a
13//! route to an open channel, so the Bolt-vs-direct branch lives in exactly
14//! one place.
15
16use std::fmt;
17
18use serde::{Deserialize, Serialize};
19
20use crate::device::DeviceInventory;
21
22/// HID++ device index that addresses a directly-attached device's own
23/// features (USB-cable or Bluetooth, no receiver indirection).
24pub const DIRECT_DEVICE_INDEX: u8 = 0xff;
25
26/// Logitech's USB/Bluetooth vendor ID. `u16` because that is the width of the
27/// field itself; readers whose API hands back a wider integer widen at the
28/// comparison.
29pub const LOGITECH_VENDOR_ID: u16 = 0x046d;
30
31/// How to reach a controllable HID++ device.
32///
33/// Crosses the agent↔GUI IPC (every per-device RPC takes one), so variant and
34/// field order are wire format — changes require a `PROTOCOL_VERSION` bump
35/// (guarded by `openlogi-ipc/tests/wire_format.rs`).
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub enum DeviceRoute {
38    /// Paired to a Logi Bolt receiver. `receiver_uid` disambiguates multiple
39    /// plugged-in receivers; `slot` is the device's pairing slot (1..=6).
40    Bolt {
41        /// Receiver unique ID used to select the physical Bolt receiver.
42        receiver_uid: String,
43        /// Pairing slot of the target device on that receiver.
44        slot: u8,
45    },
46    /// Paired to a Logi Unifying receiver. Same addressing structure as Bolt
47    /// (receiver channel + pairing slot) but the receiver speaks HID++ 1.0.
48    Unifying {
49        /// Receiver unique ID used to select the physical Unifying receiver.
50        receiver_uid: String,
51        /// Pairing slot of the target device on that receiver.
52        slot: u8,
53    },
54    /// Attached straight to the host over USB cable or Bluetooth, addressed at
55    /// the HID++ self-index. Re-found by matching the HID node's vendor/product
56    /// id — two identical mice on one host are indistinguishable here, so the
57    /// first match wins (acceptable for v0).
58    Direct {
59        /// USB/HID vendor ID of the direct device.
60        vendor_id: u16,
61        /// USB/HID product ID of the direct device.
62        product_id: u16,
63    },
64    /// Standalone raw-HID device, such as a Litra light. The identity is an
65    /// opaque transport-generated value used to disambiguate duplicate HID
66    /// nodes; this route must never be passed to HID++ channel code.
67    RawHid {
68        /// HID vendor ID.
69        vendor_id: u16,
70        /// HID product ID.
71        product_id: u16,
72        /// HID usage page.
73        usage_page: u16,
74        /// HID usage ID.
75        usage_id: u16,
76        /// Stable/opaque device identity selected during enumeration.
77        identity: String,
78    },
79}
80
81/// USB product IDs that identify Logi Bolt receivers.
82pub const BOLT_PIDS: &[u16] = &[0xc548];
83
84/// USB product IDs that identify Logi Unifying receivers. Used by callers that
85/// need to construct the correct [`DeviceRoute`] variant from a raw inventory.
86///
87/// `0xc537` is the Nano receiver bundled with the G602. It answers the same
88/// HID++ 1.0 enumeration and pairing-information registers as Unifying, so it
89/// routes as [`DeviceRoute::Unifying`].
90pub const UNIFYING_PIDS: &[u16] = &[0xc52b, 0xc532, 0xc537];
91
92/// USB product IDs that identify Logitech Lightspeed receivers — the
93/// receivers bundled with G-series wireless devices. `0xc539` ships with the
94/// G502 LIGHTSPEED and the G Pro Wireless — its USB product string is
95/// literally `LIGHTSPEED Receiver`; `0xc53f` is the nano receiver of wireless
96/// mice such as the G305; `0xc547` ships with newer G-series devices such as
97/// the G915 keyboard and the G502 X LIGHTSPEED; `0xc54d` ships with the
98/// PRO X SUPERLIGHT 2 DEX.
99/// They speak the same HID++ 1.0 receiver register protocol as Unifying, so
100/// they are enumerated, routed, and paired through the Unifying code path;
101/// only the user-facing receiver name (see [`receiver_display_name`]) differs.
102pub const LIGHTSPEED_PIDS: &[u16] = &[0xc539, 0xc53f, 0xc547, 0xc54d];
103
104/// Whether `product_id` is a receiver that speaks the Unifying HID++ 1.0
105/// register protocol — a Unifying receiver proper, or a protocol-compatible
106/// Lightspeed receiver. Such receivers are addressed with
107/// [`DeviceRoute::Unifying`].
108#[must_use]
109pub fn speaks_unifying_protocol(product_id: u16) -> bool {
110    UNIFYING_PIDS.contains(&product_id) || LIGHTSPEED_PIDS.contains(&product_id)
111}
112
113/// Whether `product_id` is a known Logitech receiver dongle of any family
114/// (Bolt, Unifying, or Lightspeed).
115#[must_use]
116pub fn is_receiver_pid(product_id: u16) -> bool {
117    BOLT_PIDS.contains(&product_id) || speaks_unifying_protocol(product_id)
118}
119
120/// Human-readable name for a receiver identified by `product_id`, used to label
121/// it in the inventory. Lightspeed receivers share the Unifying protocol path
122/// but are surfaced under their own name.
123#[must_use]
124pub fn receiver_display_name(product_id: u16) -> &'static str {
125    if LIGHTSPEED_PIDS.contains(&product_id) {
126        "Lightspeed Receiver"
127    } else {
128        "Unifying Receiver"
129    }
130}
131
132impl DeviceRoute {
133    /// Whether two receiver routes use the same physical HID transport.
134    /// Direct routes cannot prove identity because they carry only VID/PID.
135    #[must_use]
136    pub fn shares_transport(&self, other: &Self) -> bool {
137        match (self, other) {
138            (
139                Self::Bolt {
140                    receiver_uid: left, ..
141                },
142                Self::Bolt {
143                    receiver_uid: right,
144                    ..
145                },
146            )
147            | (
148                Self::Unifying {
149                    receiver_uid: left, ..
150                },
151                Self::Unifying {
152                    receiver_uid: right,
153                    ..
154                },
155            ) => left.eq_ignore_ascii_case(right),
156            _ => false,
157        }
158    }
159
160    /// The HID++ device index features are addressed at for this route: the
161    /// pairing slot for a Bolt device, the self-index for a direct one.
162    #[must_use]
163    pub fn device_index(&self) -> u8 {
164        match self {
165            Self::Bolt { slot, .. } | Self::Unifying { slot, .. } => *slot,
166            Self::Direct { .. } | Self::RawHid { .. } => DIRECT_DEVICE_INDEX,
167        }
168    }
169
170    /// Build the route that reaches a paired device from a receiver inventory.
171    ///
172    /// Picks [`DeviceRoute::Unifying`] or [`DeviceRoute::Bolt`] based on the
173    /// receiver's product ID via [`speaks_unifying_protocol`] (Unifying proper
174    /// plus protocol-compatible Lightspeed receivers). Any receiver that does
175    /// not speak the Unifying protocol — including future Bolt variants whose
176    /// PID isn't yet in `BOLT_PIDS` — defaults to [`DeviceRoute::Bolt`] so
177    /// writes keep working rather than silently dropping.
178    /// [`DeviceRoute::Direct`] is used for directly-attached devices
179    /// (slot == [`DIRECT_DEVICE_INDEX`] with no receiver UID). Returns `None`
180    /// when the receiver UID is unknown (writes are skipped, not mis-routed).
181    #[must_use]
182    pub fn device_route_for(inv: &DeviceInventory, slot: u8) -> Option<Self> {
183        match &inv.receiver.unique_id {
184            Some(uid) if speaks_unifying_protocol(inv.receiver.product_id) => {
185                Some(Self::Unifying {
186                    receiver_uid: uid.clone(),
187                    slot,
188                })
189            }
190            Some(uid) => {
191                // Default to Bolt for any receiver that does not speak the
192                // Unifying protocol. This covers both known Bolt PIDs
193                // (BOLT_PIDS) and any future Bolt-compatible receiver with a new
194                // PID — returning None would silently drop writes for such
195                // receivers.
196                if !BOLT_PIDS.contains(&inv.receiver.product_id) {
197                    tracing::debug!(
198                        pid = format_args!("{:04x}", inv.receiver.product_id),
199                        "unknown receiver PID — routing as Bolt"
200                    );
201                }
202                Some(Self::Bolt {
203                    receiver_uid: uid.clone(),
204                    slot,
205                })
206            }
207            None if slot == DIRECT_DEVICE_INDEX => Some(Self::Direct {
208                vendor_id: inv.receiver.vendor_id,
209                product_id: inv.receiver.product_id,
210            }),
211            None => None,
212        }
213    }
214}
215
216impl fmt::Display for DeviceRoute {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        match self {
219            Self::Bolt { receiver_uid, slot } | Self::Unifying { receiver_uid, slot } => {
220                write!(f, "slot {slot} on receiver {receiver_uid}")
221            }
222            Self::Direct {
223                vendor_id,
224                product_id,
225            } => write!(f, "direct {vendor_id:04x}:{product_id:04x}"),
226            Self::RawHid {
227                vendor_id,
228                product_id,
229                usage_page,
230                usage_id,
231                identity,
232            } => write!(
233                f,
234                "raw {vendor_id:04x}:{product_id:04x} usage {usage_page:04x}:{usage_id:04x} ({identity})"
235            ),
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use std::assert_matches;
243
244    use crate::device::{DeviceInventory, ReceiverInfo};
245
246    use super::{
247        DIRECT_DEVICE_INDEX, DeviceRoute, LIGHTSPEED_PIDS, UNIFYING_PIDS, receiver_display_name,
248    };
249
250    fn inv(product_id: u16, unique_id: Option<&str>) -> DeviceInventory {
251        DeviceInventory {
252            receiver: ReceiverInfo {
253                name: "test".into(),
254                vendor_id: 0x046d,
255                product_id,
256                unique_id: unique_id.map(str::to_string),
257            },
258            paired: vec![],
259        }
260    }
261
262    #[test]
263    fn device_route_for_unifying_pids_create_unifying_route() {
264        for &pid in UNIFYING_PIDS {
265            let route = DeviceRoute::device_route_for(&inv(pid, Some("A1B2")), 2);
266            assert!(
267                matches!(route, Some(DeviceRoute::Unifying { ref receiver_uid, slot: 2 }) if receiver_uid == "A1B2"),
268                "pid {pid:#06x} should produce Unifying route"
269            );
270        }
271    }
272
273    #[test]
274    fn device_route_for_lightspeed_pids_create_unifying_route() {
275        // Lightspeed nano receivers (e.g. the G305's) speak the Unifying
276        // protocol, so writes must be routed through DeviceRoute::Unifying —
277        // not defaulted to Bolt, which would address the pairing slot wrong.
278        for &pid in LIGHTSPEED_PIDS {
279            let route = DeviceRoute::device_route_for(&inv(pid, Some("A1B2")), 2);
280            assert!(
281                matches!(route, Some(DeviceRoute::Unifying { ref receiver_uid, slot: 2 }) if receiver_uid == "A1B2"),
282                "lightspeed pid {pid:#06x} should produce a Unifying route"
283            );
284        }
285    }
286
287    #[test]
288    fn lightspeed_receiver_has_its_own_display_name() {
289        // 0xc539 is the receiver bundled with the G502 LIGHTSPEED and the
290        // G Pro Wireless. It routes through the Unifying code path, but it is
291        // Lightspeed hardware and says so in its own USB product string, so it
292        // must not be surfaced as a Unifying receiver.
293        assert_eq!(receiver_display_name(0xc539), "Lightspeed Receiver");
294        assert_eq!(receiver_display_name(0xc53f), "Lightspeed Receiver");
295        assert_eq!(receiver_display_name(0xc547), "Lightspeed Receiver");
296        assert_eq!(receiver_display_name(0xc54d), "Lightspeed Receiver");
297        assert_eq!(receiver_display_name(0xc52b), "Unifying Receiver");
298        assert_eq!(receiver_display_name(0xc532), "Unifying Receiver");
299    }
300
301    #[test]
302    fn device_route_for_bolt_pid_creates_bolt_route() {
303        // 0xC548 is Bolt; anything not in UNIFYING_PIDS defaults to Bolt so
304        // future Bolt variants with unknown PIDs still work.
305        let route = DeviceRoute::device_route_for(&inv(0xc548, Some("UID")), 1);
306        assert_matches!(
307            route,
308            Some(DeviceRoute::Bolt { ref receiver_uid, slot: 1 }) if receiver_uid == "UID"
309        );
310    }
311
312    #[test]
313    fn device_route_for_direct_when_no_uid_and_direct_slot() {
314        let route = DeviceRoute::device_route_for(&inv(0xb025, None), DIRECT_DEVICE_INDEX);
315        assert_matches!(
316            route,
317            Some(DeviceRoute::Direct {
318                vendor_id: 0x046d,
319                product_id: 0xb025
320            })
321        );
322    }
323
324    #[test]
325    fn device_route_for_none_when_no_uid_and_non_direct_slot() {
326        let route = DeviceRoute::device_route_for(&inv(0xc52b, None), 1);
327        assert!(route.is_none());
328    }
329
330    #[test]
331    fn unifying_device_index_is_the_slot() {
332        let route = DeviceRoute::Unifying {
333            receiver_uid: "X".into(),
334            slot: 4,
335        };
336        assert_eq!(route.device_index(), 4);
337    }
338
339    #[test]
340    fn unifying_display_matches_bolt_format() {
341        let r = DeviceRoute::Unifying {
342            receiver_uid: "AABBCC".into(),
343            slot: 3,
344        };
345        assert_eq!(r.to_string(), "slot 3 on receiver AABBCC");
346    }
347}