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/// `0xc539` is the Lightspeed gaming receiver: a distinct product line, but it
88/// answers the same HID++ 1.0 enumeration and pairing-information registers as
89/// Unifying, so it routes as [`DeviceRoute::Unifying`].
90pub const UNIFYING_PIDS: &[u16] = &[0xc52b, 0xc532, 0xc539];
91
92/// USB product IDs that identify Logitech Lightspeed receivers — the
93/// receivers bundled with G-series wireless devices. `0xc53f` is the nano
94/// receiver of wireless mice such as the G305; `0xc547` ships with newer
95/// G-series devices such as the G915 keyboard and the G502 X LIGHTSPEED.
96/// They speak the same HID++ 1.0 receiver register protocol as Unifying, so
97/// they are enumerated, routed, and paired through the Unifying code path;
98/// only the user-facing receiver name (see [`receiver_display_name`]) differs.
99pub const LIGHTSPEED_PIDS: &[u16] = &[0xc53f, 0xc547];
100
101/// Whether `product_id` is a receiver that speaks the Unifying HID++ 1.0
102/// register protocol — a Unifying receiver proper, or a protocol-compatible
103/// Lightspeed receiver. Such receivers are addressed with
104/// [`DeviceRoute::Unifying`].
105#[must_use]
106pub fn speaks_unifying_protocol(product_id: u16) -> bool {
107 UNIFYING_PIDS.contains(&product_id) || LIGHTSPEED_PIDS.contains(&product_id)
108}
109
110/// Whether `product_id` is a known Logitech receiver dongle of any family
111/// (Bolt, Unifying, or Lightspeed).
112#[must_use]
113pub fn is_receiver_pid(product_id: u16) -> bool {
114 BOLT_PIDS.contains(&product_id) || speaks_unifying_protocol(product_id)
115}
116
117/// Human-readable name for a receiver identified by `product_id`, used to label
118/// it in the inventory. Lightspeed receivers share the Unifying protocol path
119/// but are surfaced under their own name.
120#[must_use]
121pub fn receiver_display_name(product_id: u16) -> &'static str {
122 if LIGHTSPEED_PIDS.contains(&product_id) {
123 "Lightspeed Receiver"
124 } else {
125 "Unifying Receiver"
126 }
127}
128
129impl DeviceRoute {
130 /// Whether two receiver routes use the same physical HID transport.
131 /// Direct routes cannot prove identity because they carry only VID/PID.
132 #[must_use]
133 pub fn shares_transport(&self, other: &Self) -> bool {
134 match (self, other) {
135 (
136 Self::Bolt {
137 receiver_uid: left, ..
138 },
139 Self::Bolt {
140 receiver_uid: right,
141 ..
142 },
143 )
144 | (
145 Self::Unifying {
146 receiver_uid: left, ..
147 },
148 Self::Unifying {
149 receiver_uid: right,
150 ..
151 },
152 ) => left.eq_ignore_ascii_case(right),
153 _ => false,
154 }
155 }
156
157 /// The HID++ device index features are addressed at for this route: the
158 /// pairing slot for a Bolt device, the self-index for a direct one.
159 #[must_use]
160 pub fn device_index(&self) -> u8 {
161 match self {
162 Self::Bolt { slot, .. } | Self::Unifying { slot, .. } => *slot,
163 Self::Direct { .. } | Self::RawHid { .. } => DIRECT_DEVICE_INDEX,
164 }
165 }
166
167 /// Build the route that reaches a paired device from a receiver inventory.
168 ///
169 /// Picks [`DeviceRoute::Unifying`] or [`DeviceRoute::Bolt`] based on the
170 /// receiver's product ID via [`speaks_unifying_protocol`] (Unifying proper
171 /// plus protocol-compatible Lightspeed receivers). Any receiver that does
172 /// not speak the Unifying protocol — including future Bolt variants whose
173 /// PID isn't yet in `BOLT_PIDS` — defaults to [`DeviceRoute::Bolt`] so
174 /// writes keep working rather than silently dropping.
175 /// [`DeviceRoute::Direct`] is used for directly-attached devices
176 /// (slot == [`DIRECT_DEVICE_INDEX`] with no receiver UID). Returns `None`
177 /// when the receiver UID is unknown (writes are skipped, not mis-routed).
178 #[must_use]
179 pub fn device_route_for(inv: &DeviceInventory, slot: u8) -> Option<Self> {
180 match &inv.receiver.unique_id {
181 Some(uid) if speaks_unifying_protocol(inv.receiver.product_id) => {
182 Some(Self::Unifying {
183 receiver_uid: uid.clone(),
184 slot,
185 })
186 }
187 Some(uid) => {
188 // Default to Bolt for any receiver that does not speak the
189 // Unifying protocol. This covers both known Bolt PIDs
190 // (BOLT_PIDS) and any future Bolt-compatible receiver with a new
191 // PID — returning None would silently drop writes for such
192 // receivers.
193 if !BOLT_PIDS.contains(&inv.receiver.product_id) {
194 tracing::debug!(
195 pid = format_args!("{:04x}", inv.receiver.product_id),
196 "unknown receiver PID — routing as Bolt"
197 );
198 }
199 Some(Self::Bolt {
200 receiver_uid: uid.clone(),
201 slot,
202 })
203 }
204 None if slot == DIRECT_DEVICE_INDEX => Some(Self::Direct {
205 vendor_id: inv.receiver.vendor_id,
206 product_id: inv.receiver.product_id,
207 }),
208 None => None,
209 }
210 }
211}
212
213impl fmt::Display for DeviceRoute {
214 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215 match self {
216 Self::Bolt { receiver_uid, slot } | Self::Unifying { receiver_uid, slot } => {
217 write!(f, "slot {slot} on receiver {receiver_uid}")
218 }
219 Self::Direct {
220 vendor_id,
221 product_id,
222 } => write!(f, "direct {vendor_id:04x}:{product_id:04x}"),
223 Self::RawHid {
224 vendor_id,
225 product_id,
226 usage_page,
227 usage_id,
228 identity,
229 } => write!(
230 f,
231 "raw {vendor_id:04x}:{product_id:04x} usage {usage_page:04x}:{usage_id:04x} ({identity})"
232 ),
233 }
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use std::assert_matches;
240
241 use crate::device::{DeviceInventory, ReceiverInfo};
242
243 use super::{
244 DIRECT_DEVICE_INDEX, DeviceRoute, LIGHTSPEED_PIDS, UNIFYING_PIDS, receiver_display_name,
245 };
246
247 fn inv(product_id: u16, unique_id: Option<&str>) -> DeviceInventory {
248 DeviceInventory {
249 receiver: ReceiverInfo {
250 name: "test".into(),
251 vendor_id: 0x046d,
252 product_id,
253 unique_id: unique_id.map(str::to_string),
254 },
255 paired: vec![],
256 }
257 }
258
259 #[test]
260 fn device_route_for_unifying_pids_create_unifying_route() {
261 for &pid in UNIFYING_PIDS {
262 let route = DeviceRoute::device_route_for(&inv(pid, Some("A1B2")), 2);
263 assert!(
264 matches!(route, Some(DeviceRoute::Unifying { ref receiver_uid, slot: 2 }) if receiver_uid == "A1B2"),
265 "pid {pid:#06x} should produce Unifying route"
266 );
267 }
268 }
269
270 #[test]
271 fn device_route_for_lightspeed_pids_create_unifying_route() {
272 // Lightspeed nano receivers (e.g. the G305's) speak the Unifying
273 // protocol, so writes must be routed through DeviceRoute::Unifying —
274 // not defaulted to Bolt, which would address the pairing slot wrong.
275 for &pid in LIGHTSPEED_PIDS {
276 let route = DeviceRoute::device_route_for(&inv(pid, Some("A1B2")), 2);
277 assert!(
278 matches!(route, Some(DeviceRoute::Unifying { ref receiver_uid, slot: 2 }) if receiver_uid == "A1B2"),
279 "lightspeed pid {pid:#06x} should produce a Unifying route"
280 );
281 }
282 }
283
284 #[test]
285 fn lightspeed_receiver_has_its_own_display_name() {
286 assert_eq!(receiver_display_name(0xc53f), "Lightspeed Receiver");
287 assert_eq!(receiver_display_name(0xc547), "Lightspeed Receiver");
288 assert_eq!(receiver_display_name(0xc52b), "Unifying Receiver");
289 }
290
291 #[test]
292 fn device_route_for_bolt_pid_creates_bolt_route() {
293 // 0xC548 is Bolt; anything not in UNIFYING_PIDS defaults to Bolt so
294 // future Bolt variants with unknown PIDs still work.
295 let route = DeviceRoute::device_route_for(&inv(0xc548, Some("UID")), 1);
296 assert_matches!(
297 route,
298 Some(DeviceRoute::Bolt { ref receiver_uid, slot: 1 }) if receiver_uid == "UID"
299 );
300 }
301
302 #[test]
303 fn device_route_for_direct_when_no_uid_and_direct_slot() {
304 let route = DeviceRoute::device_route_for(&inv(0xb025, None), DIRECT_DEVICE_INDEX);
305 assert_matches!(
306 route,
307 Some(DeviceRoute::Direct {
308 vendor_id: 0x046d,
309 product_id: 0xb025
310 })
311 );
312 }
313
314 #[test]
315 fn device_route_for_none_when_no_uid_and_non_direct_slot() {
316 let route = DeviceRoute::device_route_for(&inv(0xc52b, None), 1);
317 assert!(route.is_none());
318 }
319
320 #[test]
321 fn unifying_device_index_is_the_slot() {
322 let route = DeviceRoute::Unifying {
323 receiver_uid: "X".into(),
324 slot: 4,
325 };
326 assert_eq!(route.device_index(), 4);
327 }
328
329 #[test]
330 fn unifying_display_matches_bolt_format() {
331 let r = DeviceRoute::Unifying {
332 receiver_uid: "AABBCC".into(),
333 slot: 3,
334 };
335 assert_eq!(r.to_string(), "slot 3 on receiver AABBCC");
336 }
337}