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