Skip to main content

openlogi_device/
pairing.rs

1//! Wireless device pairing for Logi Bolt and Unifying receivers.
2//!
3//! The published `hidpp 0.2` can only *read* existing pairings, and its
4//! `BoltReceiver` is closed to extension. So OpenLogi drives the receiver's
5//! HID++ 1.0 registers directly over the public [`HidppChannel`] primitives,
6//! the same way [`crate::write`] and [`crate::session::gesture`] bypass the crate's
7//! higher-level abstractions.
8//!
9//! The register layout and notification framing below are reverse engineered
10//! from Solaar (the authoritative open-source reference) and cross-checked
11//! against `hidpp 0.2`'s own `0x41` device-connection parser. Two families,
12//! two flows:
13//!
14//! - **Bolt** (`046d:c548`): open *discovery* → the receiver streams nearby
15//!   unpaired devices → pick one → pair by its BTLE address → the device
16//!   shows a *passkey* the user types (keyboard) or clicks (pointer) →
17//!   success carries the assigned slot.
18//! - **Unifying** (`046d:c52b`, `046d:c532`): open a pairing *lock*; the next
19//!   powered-on unpaired device in range links on its own. No discovery list,
20//!   no passkey.
21//!
22//! Drive a session with [`run_pairing`]: it streams [`PairingEvent`]s out and
23//! takes [`PairingCommand`]s in (the Bolt device pick / cancel). [`unpair`]
24//! removes a slot; [`list_pairing_receivers`] reports what's connectable.
25
26use std::{collections::HashMap, sync::Arc};
27
28use hidpp::{
29    channel::{HidppChannel, HidppMessage},
30    receiver::{self, Receiver},
31};
32use tokio::sync::mpsc;
33use tracing::{debug, trace};
34
35pub use hidpp::receiver::bolt::DeviceKind as BoltDeviceKind;
36// Click / PasskeyMethod / ReceiverSelector / PairingError are pure data with
37// no HID++/backend I/O, so they live in `openlogi_core::hid::pairing`;
38// re-exported here unchanged so this module's own API surface doesn't churn.
39pub use openlogi_core::hid::pairing::{Click, PairingError, PasskeyMethod, ReceiverSelector};
40
41use crate::backend::HidBackend;
42
43mod notification;
44mod registers;
45
46use notification::{Notification, decode, parse_notification, subscribe};
47use registers::{
48    BOLT_DISCOVERY, BOLT_PAIRING, NOTIFICATION_FLAGS, NOTIFICATIONS, UNIFYING_PAIRING,
49    write_long_register, write_register,
50};
51
52/// HID++ device index addressing the receiver itself (not a paired device).
53const RECEIVER_INDEX: u8 = 0xff;
54
55/// Receiver pairing family. Each uses a different register flow.
56#[derive(Clone, Copy, PartialEq, Eq, Debug)]
57pub enum ReceiverFamily {
58    /// Logi Bolt receiver.
59    Bolt,
60    /// Logitech Unifying receiver.
61    Unifying,
62}
63
64enum SessionState {
65    BoltDiscovery(BoltDiscovery),
66    BoltPairing(BoltPairing),
67    UnifyingPairing,
68}
69
70impl From<ReceiverFamily> for SessionState {
71    fn from(family: ReceiverFamily) -> Self {
72        match family {
73            ReceiverFamily::Bolt => Self::BoltDiscovery(BoltDiscovery::default()),
74            ReceiverFamily::Unifying => Self::UnifyingPairing,
75        }
76    }
77}
78
79impl SessionState {
80    async fn open(&self, channel: &HidppChannel) -> Result<(), PairingError> {
81        match self {
82            Self::BoltDiscovery(_) => {
83                write_register(channel, BOLT_DISCOVERY, [DISCOVERY_TIMEOUT, 0x01, 0x00]).await
84            }
85            Self::UnifyingPairing => {
86                write_register(channel, UNIFYING_PAIRING, [0x01, 0x00, DISCOVERY_TIMEOUT]).await
87            }
88            Self::BoltPairing(_) => unreachable!("a session starts in receiver-open state"),
89        }
90    }
91
92    fn select_bolt_device(&mut self, device: &DiscoveredDevice) -> Result<(), PairingError> {
93        match self {
94            Self::BoltDiscovery(_) | Self::BoltPairing(_) => {
95                *self = Self::BoltPairing(BoltPairing {
96                    authentication: device.authentication,
97                });
98                Ok(())
99            }
100            Self::UnifyingPairing => Err(PairingError::UnsupportedCommand),
101        }
102    }
103}
104
105#[derive(Default)]
106struct BoltDiscovery {
107    partial: HashMap<u16, PartialDevice>,
108}
109
110struct BoltPairing {
111    authentication: u8,
112}
113
114fn family_for(product_id: u16) -> Option<ReceiverFamily> {
115    match crate::find_receiver(crate::LOGITECH_VENDOR_ID, product_id)?.protocol {
116        crate::ReceiverProtocol::Bolt => Some(ReceiverFamily::Bolt),
117        crate::ReceiverProtocol::Unifying => Some(ReceiverFamily::Unifying),
118    }
119}
120
121/// A pairing-capable receiver currently connected to the host.
122#[derive(Clone, Debug)]
123pub struct PairingReceiver {
124    /// Bolt unique ID, when readable. `None` for Unifying (no read path yet).
125    pub uid: Option<String>,
126    /// Receiver protocol family.
127    pub family: ReceiverFamily,
128    /// USB product ID of the receiver.
129    pub product_id: u16,
130}
131
132/// A nearby unpaired device surfaced by Bolt discovery.
133#[derive(Clone, Debug)]
134pub struct DiscoveredDevice {
135    /// 6-byte BTLE address used to pair.
136    pub address: [u8; 6],
137    /// Authentication-method bitfield (bit 0 = passkey typed on keyboard).
138    pub authentication: u8,
139    /// Device class reported by the receiver discovery notification.
140    pub kind: BoltDeviceKind,
141    /// Human-readable name advertised by the discovered device.
142    pub name: String,
143}
144
145impl DiscoveredDevice {
146    /// Whether authentication is by typing a passkey on a keyboard (vs. a
147    /// pointer click sequence).
148    #[must_use]
149    pub fn passkey_on_keyboard(&self) -> bool {
150        self.authentication & 0x01 != 0
151    }
152
153    /// Pairing entropy: keyboards use 20 bits, everything else 10.
154    fn entropy(&self) -> u8 {
155        if self.kind == BoltDeviceKind::Keyboard {
156            20
157        } else {
158            10
159        }
160    }
161}
162
163/// Renders a Bolt passkey value as a 10-bit MSB-first left/right click sequence.
164fn passkey_to_clicks(value: u32) -> Vec<Click> {
165    (0..10)
166        .rev()
167        .map(|bit| {
168            if value & (1 << bit) != 0 {
169                Click::Right
170            } else {
171                Click::Left
172            }
173        })
174        .collect()
175}
176
177/// Events streamed out of a pairing session.
178#[derive(Clone, Debug)]
179pub enum PairingEvent {
180    /// Discovery (Bolt) or the pairing lock (Unifying) is now open.
181    Searching,
182    /// Bolt only: a nearby unpaired device was discovered.
183    DeviceFound(DiscoveredDevice),
184    /// Bolt only: the device asks the user to enter a passkey to authenticate.
185    Passkey(PasskeyMethod),
186    /// A device was paired and assigned a receiver slot.
187    Paired {
188        /// Assigned pairing slot.
189        slot: u8,
190    },
191    /// The flow ended without pairing a device.
192    Failed(PairingError),
193}
194
195/// Commands fed into a pairing session.
196#[derive(Clone, Debug)]
197pub enum PairingCommand {
198    /// Bolt: pair with a previously discovered device.
199    Pair(DiscoveredDevice),
200    /// Abort the in-progress flow.
201    Cancel,
202}
203
204/// Lists supported pairing-capable receivers connected to the host.
205pub async fn list_pairing_receivers(
206    backend: &dyn HidBackend,
207) -> Result<Vec<PairingReceiver>, PairingError> {
208    let mut out = Vec::new();
209    for node in backend.enumerate_hidpp().await? {
210        let Some(channel) = backend.open_hidpp(&node).await? else {
211            continue;
212        };
213        let Some(family) = family_for(channel.product_id) else {
214            continue;
215        };
216        let uid = match family {
217            ReceiverFamily::Bolt => read_bolt_uid(&channel).await,
218            ReceiverFamily::Unifying => None,
219        };
220        out.push(PairingReceiver {
221            uid,
222            family,
223            product_id: channel.product_id,
224        });
225    }
226    Ok(out)
227}
228
229/// Reads a Bolt receiver's unique ID via the crate's `BoltReceiver`.
230async fn read_bolt_uid(channel: &Arc<HidppChannel>) -> Option<String> {
231    let Some(Receiver::Bolt(bolt)) = receiver::detect(Arc::clone(channel)) else {
232        return None;
233    };
234    bolt.get_unique_id().await.ok()
235}
236
237/// Opens the channel for the receiver named by `target`.
238async fn open_receiver(
239    backend: &dyn HidBackend,
240    target: &ReceiverSelector,
241) -> Result<(Arc<HidppChannel>, ReceiverFamily), PairingError> {
242    for node in backend.enumerate_hidpp().await? {
243        let Some(channel) = backend.open_hidpp(&node).await? else {
244            continue;
245        };
246        let Some(family) = family_for(channel.product_id) else {
247            continue;
248        };
249        match target {
250            ReceiverSelector::First => return Ok((channel, family)),
251            ReceiverSelector::BoltUid(want) => {
252                if family == ReceiverFamily::Bolt
253                    && read_bolt_uid(&channel)
254                        .await
255                        .is_some_and(|uid| uid.eq_ignore_ascii_case(want))
256                {
257                    return Ok((channel, family));
258                }
259            }
260        }
261    }
262    Err(PairingError::ReceiverNotFound)
263}
264
265/// Overall guard so a wedged receiver can't hang the session forever.
266const SESSION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90);
267/// Discovery / lock window opened on the receiver, in seconds.
268const DISCOVERY_TIMEOUT: u8 = 30;
269
270/// Runs a pairing session against `target`, streaming [`PairingEvent`]s to
271/// `events` and consuming [`PairingCommand`]s from `commands`. Returns when the
272/// flow finishes (paired, failed, cancelled, or timed out).
273///
274/// The caller owns the orchestration: spawn this on a runtime, hold the command
275/// sender to forward the user's device pick / cancel, and read events to drive
276/// the UI.
277pub async fn run_pairing(
278    backend: &dyn HidBackend,
279    target: ReceiverSelector,
280    mut commands: mpsc::UnboundedReceiver<PairingCommand>,
281    events: mpsc::UnboundedSender<PairingEvent>,
282) -> Result<(), PairingError> {
283    let (channel, family) = match open_receiver(backend, &target).await {
284        Ok(receiver) => receiver,
285        Err(e) => {
286            let _ = events.send(PairingEvent::Failed(e.clone()));
287            return Err(e);
288        }
289    };
290    let (listener, mut notifications) = subscribe(&channel);
291
292    let result = run_session(&channel, family, &mut commands, &mut notifications, &events).await;
293
294    drop(listener);
295    // Best-effort restore: clear notification flags we set.
296    let _ = channel
297        .write_register(RECEIVER_INDEX, NOTIFICATIONS, [0, 0, 0])
298        .await;
299
300    if let Err(ref e) = result {
301        let _ = events.send(PairingEvent::Failed(e.clone()));
302    }
303    result
304}
305
306/// Runs the core flow and phase-correct cancellation on every unsuccessful exit.
307async fn run_session(
308    channel: &HidppChannel,
309    family: ReceiverFamily,
310    commands: &mut mpsc::UnboundedReceiver<PairingCommand>,
311    notifications: &mut mpsc::UnboundedReceiver<HidppMessage>,
312    events: &mpsc::UnboundedSender<PairingEvent>,
313) -> Result<(), PairingError> {
314    let mut state = SessionState::from(family);
315    let result = drive(channel, &mut state, commands, notifications, events).await;
316    if result.is_err() {
317        cancel(channel, &state).await;
318    }
319    result
320}
321
322/// Core session loop.
323async fn drive(
324    channel: &HidppChannel,
325    state: &mut SessionState,
326    commands: &mut mpsc::UnboundedReceiver<PairingCommand>,
327    notifications: &mut mpsc::UnboundedReceiver<HidppMessage>,
328    events: &mpsc::UnboundedSender<PairingEvent>,
329) -> Result<(), PairingError> {
330    write_register(channel, NOTIFICATIONS, NOTIFICATION_FLAGS).await?;
331    state.open(channel).await?;
332    let _ = events.send(PairingEvent::Searching);
333
334    let deadline = tokio::time::sleep(SESSION_TIMEOUT);
335    tokio::pin!(deadline);
336
337    loop {
338        tokio::select! {
339            () = &mut deadline => return Err(PairingError::Timeout),
340
341            cmd = commands.recv() => match cmd {
342                Some(PairingCommand::Pair(device)) => {
343                    state.select_bolt_device(&device)?;
344                    pair_bolt_device(channel, &device).await?;
345                }
346                Some(PairingCommand::Cancel) | None => {
347                    return Err(PairingError::Cancelled);
348                }
349            },
350
351            msg = notifications.recv() => {
352                let Some(msg) = msg else {
353                    return Err(PairingError::Hid("receiver channel closed".into()));
354                };
355                let (device_index, sub_id, payload) = decode(&msg);
356                // Reverse-engineered wire format — log every notification so a
357                // mis-parse can be diagnosed against real hardware.
358                trace!(sub_id = format_args!("{sub_id:#04x}"), ?payload, "pairing notification");
359                let Some(note) = parse_notification(sub_id, device_index, payload) else {
360                    continue;
361                };
362                // Discovery is phase-bound: a late DeviceFound would move the
363                // event owner back from Pairing to Found after selection.
364                match note {
365                    Notification::DiscoveryInfo { counter, kind, address, authentication } => {
366                        let SessionState::BoltDiscovery(discovery) = state else {
367                            continue;
368                        };
369                        let entry = discovery.partial.entry(counter).or_default();
370                        entry.kind = Some(kind);
371                        entry.address = Some(address);
372                        entry.authentication = Some(authentication);
373                        if let Some(device) = entry.build() {
374                            let _ = events.send(PairingEvent::DeviceFound(device));
375                        }
376                    }
377                    Notification::DiscoveryName { counter, name } => {
378                        let SessionState::BoltDiscovery(discovery) = state else {
379                            continue;
380                        };
381                        let entry = discovery.partial.entry(counter).or_default();
382                        entry.name = Some(name);
383                        if let Some(device) = entry.build() {
384                            let _ = events.send(PairingEvent::DeviceFound(device));
385                        }
386                    }
387                    Notification::Passkey { digits, value } => {
388                        let method = match state {
389                            SessionState::BoltPairing(pairing)
390                                if pairing.authentication & 0x01 != 0 =>
391                            {
392                                PasskeyMethod::Keyboard(digits)
393                            }
394                            _ => PasskeyMethod::Pointer {
395                                clicks: passkey_to_clicks(value),
396                                passkey: digits,
397                            },
398                        };
399                        let _ = events.send(PairingEvent::Passkey(method));
400                    }
401                    Notification::MalformedPasskey => {
402                        return Err(PairingError::MalformedNotification("passkey digits"));
403                    }
404                    Notification::PairingSucceeded { slot } => {
405                        let _ = events.send(PairingEvent::Paired { slot });
406                        return Ok(());
407                    }
408                    Notification::PairingError(code) => return Err(PairingError::Device(code)),
409                    Notification::Connected { slot, established }
410                        if matches!(state, SessionState::UnifyingPairing) => {
411                        if established {
412                            let _ = events.send(PairingEvent::Paired { slot });
413                            return Ok(());
414                        }
415                    }
416                    Notification::Connected { .. } => {}
417                    Notification::UnifyingLock { open, error } => {
418                        if error != 0 {
419                            return Err(PairingError::Device(error));
420                        }
421                        if !open {
422                            // Lock closed without a connection notification: nothing paired.
423                            return Err(PairingError::Timeout);
424                        }
425                    }
426                }
427            }
428        }
429    }
430}
431
432/// Accumulates the two Bolt discovery frames for one device.
433#[derive(Default)]
434struct PartialDevice {
435    kind: Option<u8>,
436    address: Option<[u8; 6]>,
437    authentication: Option<u8>,
438    name: Option<String>,
439    emitted: bool,
440}
441
442impl PartialDevice {
443    /// Builds a [`DiscoveredDevice`] once both frames have arrived, exactly once.
444    fn build(&mut self) -> Option<DiscoveredDevice> {
445        if self.emitted {
446            return None;
447        }
448        let (kind, address, authentication, name) = (
449            self.kind?,
450            self.address?,
451            self.authentication?,
452            self.name.clone()?,
453        );
454        self.emitted = true;
455        Some(DiscoveredDevice {
456            address,
457            authentication,
458            kind: BoltDeviceKind::from(kind & 0x0f),
459            name,
460        })
461    }
462}
463
464/// Sends the Bolt pair command (action `0x01`, auto slot) for `device`.
465async fn pair_bolt_device(
466    channel: &HidppChannel,
467    device: &DiscoveredDevice,
468) -> Result<(), PairingError> {
469    let mut payload = [0u8; 16];
470    payload[0] = 0x01; // action: pair
471    payload[1] = 0x00; // slot: auto-assign
472    payload[2..8].copy_from_slice(&device.address);
473    payload[8] = device.authentication;
474    payload[9] = device.entropy();
475    write_long_register(channel, BOLT_PAIRING, payload).await
476}
477
478/// Best-effort cancel of an in-progress flow.
479async fn cancel(channel: &HidppChannel, state: &SessionState) {
480    let res = match state {
481        SessionState::BoltDiscovery(_) => {
482            write_register(channel, BOLT_DISCOVERY, [DISCOVERY_TIMEOUT, 0x02, 0x00]).await
483        }
484        SessionState::BoltPairing(_) => {
485            let mut payload = [0u8; 16];
486            payload[0] = 0x02;
487            write_long_register(channel, BOLT_PAIRING, payload).await
488        }
489        SessionState::UnifyingPairing => {
490            write_register(channel, UNIFYING_PAIRING, [0x02, 0x00, 0x00]).await
491        }
492    };
493    if let Err(e) = res {
494        let phase = match state {
495            SessionState::BoltDiscovery(_) => "Bolt discovery",
496            SessionState::BoltPairing(_) => "Bolt pairing",
497            SessionState::UnifyingPairing => "Unifying pairing",
498        };
499        debug!(phase, ?e, "cancel write failed");
500    }
501}
502
503/// Removes the device on `slot` from the receiver named by `target`.
504pub async fn unpair(
505    backend: &dyn HidBackend,
506    target: ReceiverSelector,
507    slot: u8,
508) -> Result<(), PairingError> {
509    let (channel, family) = open_receiver(backend, &target).await?;
510    match family {
511        ReceiverFamily::Bolt => {
512            let mut payload = [0u8; 16];
513            payload[0] = 0x03; // action: unpair
514            payload[1] = slot;
515            write_long_register(&channel, BOLT_PAIRING, payload).await
516        }
517        ReceiverFamily::Unifying => {
518            write_register(&channel, UNIFYING_PAIRING, [0x03, slot, 0x00]).await
519        }
520    }
521}
522
523#[cfg(test)]
524mod tests;