Skip to main content

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}