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