openlogi_hid/route.rs
1//! How to reach a controllable HID++ device, and the logic to (re-)open its
2//! channel.
3//!
4//! Two addressing modes:
5//!
6//! - [`DeviceRoute::Bolt`] — a device paired to a Logi Bolt receiver, reached
7//! through the receiver channel at a pairing slot.
8//! - [`DeviceRoute::Direct`] — a device attached straight to the host over a
9//! USB cable or Bluetooth, reached on its own channel at the HID++
10//! self-index [`DIRECT_DEVICE_INDEX`].
11//!
12//! Both the write path ([`crate::write`]) and the capture session
13//! ([`crate::gesture`]) resolve a route to an open channel through
14//! [`open_route_channel`], so the Bolt-vs-direct branch lives in exactly one
15//! place.
16
17use std::fmt;
18use std::sync::Arc;
19
20use hidpp::{
21 channel::HidppChannel,
22 receiver::{self, Receiver},
23};
24use openlogi_core::device::DeviceInventory;
25use serde::{Deserialize, Serialize};
26
27use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel};
28
29/// HID++ device index that addresses a directly-attached device's own
30/// features (USB-cable or Bluetooth, no receiver indirection).
31pub const DIRECT_DEVICE_INDEX: u8 = 0xff;
32
33/// How to reach a controllable HID++ device.
34///
35/// Crosses the agent↔GUI IPC (every per-device RPC takes one), so variant and
36/// field order are wire format — changes require a `PROTOCOL_VERSION` bump
37/// (guarded by `openlogi-agent-core/tests/wire_format.rs`).
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub enum DeviceRoute {
40 /// Paired to a Logi Bolt receiver. `receiver_uid` disambiguates multiple
41 /// plugged-in receivers; `slot` is the device's pairing slot (1..=6).
42 Bolt {
43 /// Receiver unique ID used to select the physical Bolt receiver.
44 receiver_uid: String,
45 /// Pairing slot of the target device on that receiver.
46 slot: u8,
47 },
48 /// Paired to a Logi Unifying receiver. Same addressing structure as Bolt
49 /// (receiver channel + pairing slot) but the receiver speaks HID++ 1.0.
50 Unifying {
51 /// Receiver unique ID used to select the physical Unifying receiver.
52 receiver_uid: String,
53 /// Pairing slot of the target device on that receiver.
54 slot: u8,
55 },
56 /// Attached straight to the host over USB cable or Bluetooth, addressed at
57 /// the HID++ self-index. Re-found by matching the HID node's vendor/product
58 /// id — two identical mice on one host are indistinguishable here, so the
59 /// first match wins (acceptable for v0).
60 Direct {
61 /// USB/HID vendor ID of the direct device.
62 vendor_id: u16,
63 /// USB/HID product ID of the direct device.
64 product_id: u16,
65 },
66}
67
68/// USB product IDs that identify Logi Bolt receivers.
69pub const BOLT_PIDS: &[u16] = &[0xc548];
70
71/// USB product IDs that identify Logi Unifying receivers. Used by callers that
72/// need to construct the correct [`DeviceRoute`] variant from a raw inventory.
73pub const UNIFYING_PIDS: &[u16] = &[0xc52b, 0xc532];
74
75impl DeviceRoute {
76 /// The HID++ device index features are addressed at for this route: the
77 /// pairing slot for a Bolt device, the self-index for a direct one.
78 #[must_use]
79 pub fn device_index(&self) -> u8 {
80 match self {
81 Self::Bolt { slot, .. } | Self::Unifying { slot, .. } => *slot,
82 Self::Direct { .. } => DIRECT_DEVICE_INDEX,
83 }
84 }
85
86 /// Build the route that reaches a paired device from a receiver inventory.
87 ///
88 /// Picks [`DeviceRoute::Unifying`] or [`DeviceRoute::Bolt`] based on the
89 /// receiver's product ID using the canonical `UNIFYING_PIDS` / `BOLT_PIDS`
90 /// lists. Any receiver PID not in `UNIFYING_PIDS` — including future Bolt
91 /// variants whose PID isn't yet in `BOLT_PIDS` — defaults to
92 /// [`DeviceRoute::Bolt`] so writes keep working rather than silently
93 /// dropping. [`DeviceRoute::Direct`] is used for directly-attached devices
94 /// (slot == [`DIRECT_DEVICE_INDEX`] with no receiver UID). Returns `None`
95 /// when the receiver UID is unknown (writes are skipped, not mis-routed).
96 #[must_use]
97 pub fn device_route_for(inv: &DeviceInventory, slot: u8) -> Option<Self> {
98 match &inv.receiver.unique_id {
99 Some(uid) if UNIFYING_PIDS.contains(&inv.receiver.product_id) => Some(Self::Unifying {
100 receiver_uid: uid.clone(),
101 slot,
102 }),
103 Some(uid) => {
104 // Default to Bolt for any receiver whose PID is not in
105 // UNIFYING_PIDS. This covers both known Bolt PIDs (BOLT_PIDS)
106 // and any future Bolt-compatible receiver with a new PID —
107 // returning None would silently drop writes for such receivers.
108 if !BOLT_PIDS.contains(&inv.receiver.product_id) {
109 tracing::debug!(
110 pid = format_args!("{:04x}", inv.receiver.product_id),
111 "unknown receiver PID — routing as Bolt"
112 );
113 }
114 Some(Self::Bolt {
115 receiver_uid: uid.clone(),
116 slot,
117 })
118 }
119 None if slot == DIRECT_DEVICE_INDEX => Some(Self::Direct {
120 vendor_id: inv.receiver.vendor_id,
121 product_id: inv.receiver.product_id,
122 }),
123 None => None,
124 }
125 }
126}
127
128impl fmt::Display for DeviceRoute {
129 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130 match self {
131 Self::Bolt { receiver_uid, slot } | Self::Unifying { receiver_uid, slot } => {
132 write!(f, "slot {slot} on receiver {receiver_uid}")
133 }
134 Self::Direct {
135 vendor_id,
136 product_id,
137 } => write!(f, "direct {vendor_id:04x}:{product_id:04x}"),
138 }
139 }
140}
141
142/// Enumerate HID++ candidates and open the channel that reaches `route`.
143///
144/// For a Bolt route this is the receiver channel (the caller addresses the
145/// device through its slot via [`DeviceRoute::device_index`]); for a direct
146/// route it is the device's own channel. Returns `None` when nothing matching
147/// is currently connected.
148pub(crate) async fn open_route_channel(
149 route: &DeviceRoute,
150) -> Result<Option<Arc<HidppChannel>>, async_hid::HidError> {
151 let candidates = enumerate_hidpp_devices().await?;
152 for dev in candidates {
153 // A direct route's vendor/product id is on the unopened `DeviceInfo`
154 // (`async_hid::Device` derefs to it), so skip non-matching nodes before
155 // paying the ~100ms channel-open cost — otherwise every direct write on
156 // a host that also has a Bolt receiver opens the receiver's channel
157 // first. The Bolt branch still needs an open channel for `detect`.
158 if let DeviceRoute::Direct {
159 vendor_id,
160 product_id,
161 } = route
162 && (dev.vendor_id != *vendor_id || dev.product_id != *product_id)
163 {
164 continue;
165 }
166 let Some((_, channel)) = open_hidpp_channel(dev).await? else {
167 continue;
168 };
169 match route {
170 DeviceRoute::Bolt { receiver_uid, .. } => {
171 let Some(Receiver::Bolt(bolt)) = receiver::detect(Arc::clone(&channel)) else {
172 continue;
173 };
174 if let Ok(uid) = bolt.get_unique_id().await
175 && uid.eq_ignore_ascii_case(receiver_uid)
176 {
177 return Ok(Some(channel));
178 }
179 }
180 DeviceRoute::Unifying { receiver_uid, .. } => {
181 let Some(Receiver::Unifying(unifying)) = receiver::detect(Arc::clone(&channel))
182 else {
183 continue;
184 };
185 if let Ok(uid) = unifying.get_unique_id().await
186 && uid.eq_ignore_ascii_case(receiver_uid)
187 {
188 return Ok(Some(channel));
189 }
190 }
191 DeviceRoute::Direct { .. } => return Ok(Some(channel)),
192 }
193 }
194 Ok(None)
195}
196
197#[cfg(test)]
198mod tests {
199 use std::assert_matches;
200
201 use openlogi_core::device::{DeviceInventory, ReceiverInfo};
202
203 use super::{DIRECT_DEVICE_INDEX, DeviceRoute, UNIFYING_PIDS};
204
205 fn inv(product_id: u16, unique_id: Option<&str>) -> DeviceInventory {
206 DeviceInventory {
207 receiver: ReceiverInfo {
208 name: "test".into(),
209 vendor_id: 0x046d,
210 product_id,
211 unique_id: unique_id.map(str::to_string),
212 },
213 paired: vec![],
214 }
215 }
216
217 #[test]
218 fn device_route_for_unifying_pids_create_unifying_route() {
219 for &pid in UNIFYING_PIDS {
220 let route = DeviceRoute::device_route_for(&inv(pid, Some("A1B2")), 2);
221 assert!(
222 matches!(route, Some(DeviceRoute::Unifying { ref receiver_uid, slot: 2 }) if receiver_uid == "A1B2"),
223 "pid {pid:#06x} should produce Unifying route"
224 );
225 }
226 }
227
228 #[test]
229 fn device_route_for_bolt_pid_creates_bolt_route() {
230 // 0xC548 is Bolt; anything not in UNIFYING_PIDS defaults to Bolt so
231 // future Bolt variants with unknown PIDs still work.
232 let route = DeviceRoute::device_route_for(&inv(0xc548, Some("UID")), 1);
233 assert_matches!(
234 route,
235 Some(DeviceRoute::Bolt { ref receiver_uid, slot: 1 }) if receiver_uid == "UID"
236 );
237 }
238
239 #[test]
240 fn device_route_for_direct_when_no_uid_and_direct_slot() {
241 let route = DeviceRoute::device_route_for(&inv(0xb025, None), DIRECT_DEVICE_INDEX);
242 assert_matches!(
243 route,
244 Some(DeviceRoute::Direct {
245 vendor_id: 0x046d,
246 product_id: 0xb025
247 })
248 );
249 }
250
251 #[test]
252 fn device_route_for_none_when_no_uid_and_non_direct_slot() {
253 let route = DeviceRoute::device_route_for(&inv(0xc52b, None), 1);
254 assert!(route.is_none());
255 }
256
257 #[test]
258 fn unifying_device_index_is_the_slot() {
259 let route = DeviceRoute::Unifying {
260 receiver_uid: "X".into(),
261 slot: 4,
262 };
263 assert_eq!(route.device_index(), 4);
264 }
265
266 #[test]
267 fn unifying_display_matches_bolt_format() {
268 let r = DeviceRoute::Unifying {
269 receiver_uid: "AABBCC".into(),
270 slot: 3,
271 };
272 assert_eq!(r.to_string(), "slot 3 on receiver AABBCC");
273 }
274}