Skip to main content

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