openlogi_core/hid/route.rs
1//! How to reach a controllable device — addressing data only, no I/O.
2//!
3//! Four 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::Unifying`] — the same addressing through a Unifying
8//! receiver, which speaks HID++ 1.0.
9//! - [`DeviceRoute::Direct`] — a device attached straight to the host over a
10//! USB cable or Bluetooth, reached on its own channel at the HID++
11//! self-index [`DIRECT_DEVICE_INDEX`].
12//! - [`DeviceRoute::RawHid`] — a standalone raw-HID device such as a Litra
13//! light, which never reaches HID++ channel code.
14//!
15//! Opening the channel a HID++ route names is `open_route_channel` in
16//! `openlogi-device`'s `channel::route` — the one place both the write path
17//! and the capture session resolve a route to an open channel, so the
18//! receiver-vs-direct branch lives in exactly one place.
19
20use std::fmt;
21
22pub use openlogi_device_registry::LOGITECH_VENDOR_ID;
23pub use openlogi_device_registry::receiver::{
24 RECEIVERS, ReceiverBrand, ReceiverDescriptor, ReceiverProtocol, find_receiver,
25};
26use serde::{Deserialize, Serialize};
27
28use crate::device::{DeviceInventory, RawDeviceAddress};
29
30/// HID++ device index that addresses a directly-attached device's own
31/// features (USB-cable or Bluetooth, no receiver indirection).
32pub const DIRECT_DEVICE_INDEX: u8 = 0xff;
33
34/// How to reach a controllable HID++ device.
35///
36/// Crosses the agent↔GUI IPC (every per-device RPC takes one), so variant and
37/// field order are wire format — changes require a `PROTOCOL_VERSION` bump
38/// (guarded by `openlogi-ipc/tests/wire_format.rs`).
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub enum DeviceRoute {
41 /// Paired to a Logi Bolt receiver. `receiver_uid` disambiguates multiple
42 /// plugged-in receivers; `slot` is the device's pairing slot (1..=6).
43 Bolt {
44 /// Receiver unique ID used to select the physical Bolt receiver.
45 receiver_uid: String,
46 /// Pairing slot of the target device on that receiver.
47 slot: u8,
48 },
49 /// Paired to a Logi Unifying receiver. Same addressing structure as Bolt
50 /// (receiver channel + pairing slot) but the receiver speaks HID++ 1.0.
51 Unifying {
52 /// Receiver unique ID used to select the physical Unifying receiver.
53 receiver_uid: String,
54 /// Pairing slot of the target device on that receiver.
55 slot: u8,
56 },
57 /// Attached straight to the host over USB cable or Bluetooth, addressed at
58 /// the HID++ self-index. Re-found by matching the HID node's vendor/product
59 /// id — two identical mice on one host are indistinguishable here, so the
60 /// first match wins (acceptable for v0).
61 Direct {
62 /// USB/HID vendor ID of the direct device.
63 vendor_id: u16,
64 /// USB/HID product ID of the direct device.
65 product_id: u16,
66 },
67 /// Standalone raw-HID device, such as a Litra light. The identity is an
68 /// opaque transport-generated value used to disambiguate duplicate HID
69 /// nodes; this route must never be passed to HID++ channel code.
70 RawHid {
71 /// HID vendor ID.
72 vendor_id: u16,
73 /// HID product ID.
74 product_id: u16,
75 /// HID usage page.
76 usage_page: u16,
77 /// HID usage ID.
78 usage_id: u16,
79 /// Stable/opaque device identity selected during enumeration.
80 identity: String,
81 },
82}
83
84/// Whether `product_id` is a receiver that speaks the Unifying HID++ 1.0
85/// register protocol — a Unifying receiver proper, or a protocol-compatible
86/// Nano or Lightspeed receiver. Such receivers are addressed with
87/// [`DeviceRoute::Unifying`].
88#[must_use]
89pub fn speaks_unifying_protocol(product_id: u16) -> bool {
90 find_receiver(LOGITECH_VENDOR_ID, product_id)
91 .is_some_and(|receiver| receiver.protocol == ReceiverProtocol::Unifying)
92}
93
94/// Whether `product_id` is a known Logitech receiver dongle of any family
95/// (Bolt, Unifying, or Lightspeed).
96#[must_use]
97pub fn is_receiver_pid(product_id: u16) -> bool {
98 find_receiver(LOGITECH_VENDOR_ID, product_id).is_some()
99}
100
101/// Human-readable name for a receiver identified by `product_id`, used to label
102/// it in the inventory. Lightspeed receivers share the Unifying protocol path
103/// but are surfaced under their own name.
104#[must_use]
105pub fn receiver_display_name(product_id: u16) -> &'static str {
106 if find_receiver(LOGITECH_VENDOR_ID, product_id)
107 .is_some_and(|receiver| receiver.brand == ReceiverBrand::Lightspeed)
108 {
109 "Lightspeed Receiver"
110 } else {
111 "Unifying Receiver"
112 }
113}
114
115/// The route that reaches a standalone raw-HID interface: its address,
116/// field for field. The one place an address becomes a route.
117impl From<&RawDeviceAddress> for DeviceRoute {
118 fn from(address: &RawDeviceAddress) -> Self {
119 Self::RawHid {
120 vendor_id: address.vendor_id,
121 product_id: address.product_id,
122 usage_page: address.usage_page,
123 usage_id: address.usage_id,
124 identity: address.identity.clone(),
125 }
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 identity in [`RECEIVERS`] (Unifying proper plus
171 /// protocol-compatible Nano and Lightspeed receivers). Any receiver that
172 /// does not speak the Unifying protocol — including future Bolt variants
173 /// whose PID is not yet registered — 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 for_slot(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 receivers and
190 // any future Bolt-compatible receiver with a new PID — returning
191 // None would silently drop writes for such receivers.
192 if find_receiver(LOGITECH_VENDOR_ID, inv.receiver.product_id)
193 .is_none_or(|receiver| receiver.protocol != ReceiverProtocol::Bolt)
194 {
195 tracing::debug!(
196 pid = format_args!("{:04x}", inv.receiver.product_id),
197 "unknown receiver PID — routing as Bolt"
198 );
199 }
200 Some(Self::Bolt {
201 receiver_uid: uid.clone(),
202 slot,
203 })
204 }
205 None if slot == DIRECT_DEVICE_INDEX => Some(Self::Direct {
206 vendor_id: inv.receiver.vendor_id,
207 product_id: inv.receiver.product_id,
208 }),
209 None => None,
210 }
211 }
212}
213
214impl fmt::Display for DeviceRoute {
215 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216 match self {
217 Self::Bolt { receiver_uid, slot } | Self::Unifying { receiver_uid, slot } => {
218 write!(f, "slot {slot} on receiver {receiver_uid}")
219 }
220 Self::Direct {
221 vendor_id,
222 product_id,
223 } => write!(f, "direct {vendor_id:04x}:{product_id:04x}"),
224 Self::RawHid {
225 vendor_id,
226 product_id,
227 usage_page,
228 usage_id,
229 identity,
230 } => write!(
231 f,
232 "raw {vendor_id:04x}:{product_id:04x} usage {usage_page:04x}:{usage_id:04x} ({identity})"
233 ),
234 }
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use std::assert_matches;
241
242 use crate::device::{DeviceInventory, ReceiverInfo};
243
244 use super::{
245 DIRECT_DEVICE_INDEX, DeviceRoute, RECEIVERS, ReceiverProtocol, receiver_display_name,
246 };
247
248 fn inv(product_id: u16, unique_id: Option<&str>) -> DeviceInventory {
249 DeviceInventory {
250 receiver: ReceiverInfo {
251 name: "test".into(),
252 vendor_id: 0x046d,
253 product_id,
254 unique_id: unique_id.map(str::to_string),
255 },
256 paired: vec![],
257 }
258 }
259
260 #[test]
261 fn for_slot_follows_a_known_receivers_protocol() {
262 for receiver in RECEIVERS {
263 let route = DeviceRoute::for_slot(&inv(receiver.product_id, Some("A1B2")), 2);
264 match receiver.protocol {
265 ReceiverProtocol::Bolt => assert_matches!(
266 route,
267 Some(DeviceRoute::Bolt { ref receiver_uid, slot: 2 }) if receiver_uid == "A1B2"
268 ),
269 ReceiverProtocol::Unifying => assert_matches!(
270 route,
271 Some(DeviceRoute::Unifying { ref receiver_uid, slot: 2 }) if receiver_uid == "A1B2"
272 ),
273 }
274 }
275 }
276
277 #[test]
278 fn lightspeed_receiver_has_its_own_display_name() {
279 // 0xc539 is the receiver bundled with the G502 LIGHTSPEED and the
280 // G Pro Wireless. It routes through the Unifying code path, but it is
281 // Lightspeed hardware and says so in its own USB product string, so it
282 // must not be surfaced as a Unifying receiver.
283 assert_eq!(receiver_display_name(0xc539), "Lightspeed Receiver");
284 assert_eq!(receiver_display_name(0xc53f), "Lightspeed Receiver");
285 assert_eq!(receiver_display_name(0xc547), "Lightspeed Receiver");
286 assert_eq!(receiver_display_name(0xc54d), "Lightspeed Receiver");
287 assert_eq!(receiver_display_name(0xc52b), "Unifying Receiver");
288 assert_eq!(receiver_display_name(0xc532), "Unifying Receiver");
289 }
290
291 #[test]
292 fn for_slot_defaults_an_unknown_receiver_to_bolt() {
293 let route = DeviceRoute::for_slot(&inv(0xc5ff, Some("UID")), 1);
294 assert_matches!(
295 route,
296 Some(DeviceRoute::Bolt { ref receiver_uid, slot: 1 }) if receiver_uid == "UID"
297 );
298 }
299
300 #[test]
301 fn for_slot_is_direct_without_a_uid_on_the_direct_slot() {
302 let route = DeviceRoute::for_slot(&inv(0xb025, None), DIRECT_DEVICE_INDEX);
303 assert_matches!(
304 route,
305 Some(DeviceRoute::Direct {
306 vendor_id: 0x046d,
307 product_id: 0xb025
308 })
309 );
310 }
311
312 #[test]
313 fn for_slot_is_none_without_a_uid_on_a_paired_slot() {
314 let route = DeviceRoute::for_slot(&inv(0xc52b, None), 1);
315 assert!(route.is_none());
316 }
317
318 #[test]
319 fn unifying_device_index_is_the_slot() {
320 let route = DeviceRoute::Unifying {
321 receiver_uid: "X".into(),
322 slot: 4,
323 };
324 assert_eq!(route.device_index(), 4);
325 }
326
327 #[test]
328 fn unifying_display_matches_bolt_format() {
329 let r = DeviceRoute::Unifying {
330 receiver_uid: "AABBCC".into(),
331 slot: 3,
332 };
333 assert_eq!(r.to_string(), "slot 3 on receiver AABBCC");
334 }
335}