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
64#[derive(Clone, Copy, PartialEq, Eq, Debug)]
65enum PairingPhase {
66    BoltDiscovery,
67    BoltPairing,
68    UnifyingPairing,
69}
70
71impl From<ReceiverFamily> for PairingPhase {
72    fn from(family: ReceiverFamily) -> Self {
73        match family {
74            ReceiverFamily::Bolt => Self::BoltDiscovery,
75            ReceiverFamily::Unifying => Self::UnifyingPairing,
76        }
77    }
78}
79
80fn family_for(product_id: u16) -> Option<ReceiverFamily> {
81    match crate::find_receiver(crate::LOGITECH_VENDOR_ID, product_id)?.protocol {
82        crate::ReceiverProtocol::Bolt => Some(ReceiverFamily::Bolt),
83        crate::ReceiverProtocol::Unifying => Some(ReceiverFamily::Unifying),
84    }
85}
86
87/// A pairing-capable receiver currently connected to the host.
88#[derive(Clone, Debug)]
89pub struct PairingReceiver {
90    /// Bolt unique ID, when readable. `None` for Unifying (no read path yet).
91    pub uid: Option<String>,
92    /// Receiver protocol family.
93    pub family: ReceiverFamily,
94    /// USB product ID of the receiver.
95    pub product_id: u16,
96}
97
98/// A nearby unpaired device surfaced by Bolt discovery.
99#[derive(Clone, Debug)]
100pub struct DiscoveredDevice {
101    /// 6-byte BTLE address used to pair.
102    pub address: [u8; 6],
103    /// Authentication-method bitfield (bit 0 = passkey typed on keyboard).
104    pub authentication: u8,
105    /// Device class reported by the receiver discovery notification.
106    pub kind: BoltDeviceKind,
107    /// Human-readable name advertised by the discovered device.
108    pub name: String,
109}
110
111impl DiscoveredDevice {
112    /// Whether authentication is by typing a passkey on a keyboard (vs. a
113    /// pointer click sequence).
114    #[must_use]
115    pub fn passkey_on_keyboard(&self) -> bool {
116        self.authentication & 0x01 != 0
117    }
118
119    /// Pairing entropy: keyboards use 20 bits, everything else 10.
120    fn entropy(&self) -> u8 {
121        if self.kind == BoltDeviceKind::Keyboard {
122            20
123        } else {
124            10
125        }
126    }
127}
128
129/// Renders a Bolt passkey value as a 10-bit MSB-first left/right click sequence.
130fn passkey_to_clicks(value: u32) -> Vec<Click> {
131    (0..10)
132        .rev()
133        .map(|bit| {
134            if value & (1 << bit) != 0 {
135                Click::Right
136            } else {
137                Click::Left
138            }
139        })
140        .collect()
141}
142
143/// Events streamed out of a pairing session.
144#[derive(Clone, Debug)]
145pub enum PairingEvent {
146    /// Discovery (Bolt) or the pairing lock (Unifying) is now open.
147    Searching,
148    /// Bolt only: a nearby unpaired device was discovered.
149    DeviceFound(DiscoveredDevice),
150    /// Bolt only: the device asks the user to enter a passkey to authenticate.
151    Passkey(PasskeyMethod),
152    /// A device was paired and assigned a receiver slot.
153    Paired {
154        /// Assigned pairing slot.
155        slot: u8,
156    },
157    /// The flow ended without pairing a device.
158    Failed(PairingError),
159}
160
161/// Commands fed into a pairing session.
162#[derive(Clone, Debug)]
163pub enum PairingCommand {
164    /// Bolt: pair with a previously discovered device.
165    Pair(DiscoveredDevice),
166    /// Abort the in-progress flow.
167    Cancel,
168}
169
170/// Lists supported pairing-capable receivers connected to the host.
171pub async fn list_pairing_receivers(
172    backend: &dyn HidBackend,
173) -> Result<Vec<PairingReceiver>, PairingError> {
174    let mut out = Vec::new();
175    for node in backend.enumerate_hidpp().await? {
176        let Some(channel) = backend.open_hidpp(&node).await? else {
177            continue;
178        };
179        let Some(family) = family_for(channel.product_id) else {
180            continue;
181        };
182        let uid = match family {
183            ReceiverFamily::Bolt => read_bolt_uid(&channel).await,
184            ReceiverFamily::Unifying => None,
185        };
186        out.push(PairingReceiver {
187            uid,
188            family,
189            product_id: channel.product_id,
190        });
191    }
192    Ok(out)
193}
194
195/// Reads a Bolt receiver's unique ID via the crate's `BoltReceiver`.
196async fn read_bolt_uid(channel: &Arc<HidppChannel>) -> Option<String> {
197    let Some(Receiver::Bolt(bolt)) = receiver::detect(Arc::clone(channel)) else {
198        return None;
199    };
200    bolt.get_unique_id().await.ok()
201}
202
203/// Opens the channel for the receiver named by `target`.
204async fn open_receiver(
205    backend: &dyn HidBackend,
206    target: &ReceiverSelector,
207) -> Result<(Arc<HidppChannel>, ReceiverFamily), PairingError> {
208    for node in backend.enumerate_hidpp().await? {
209        let Some(channel) = backend.open_hidpp(&node).await? else {
210            continue;
211        };
212        let Some(family) = family_for(channel.product_id) else {
213            continue;
214        };
215        match target {
216            ReceiverSelector::First => return Ok((channel, family)),
217            ReceiverSelector::BoltUid(want) => {
218                if family == ReceiverFamily::Bolt
219                    && read_bolt_uid(&channel)
220                        .await
221                        .is_some_and(|uid| uid.eq_ignore_ascii_case(want))
222                {
223                    return Ok((channel, family));
224                }
225            }
226        }
227    }
228    Err(PairingError::ReceiverNotFound)
229}
230
231/// Overall guard so a wedged receiver can't hang the session forever.
232const SESSION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90);
233/// Discovery / lock window opened on the receiver, in seconds.
234const DISCOVERY_TIMEOUT: u8 = 30;
235
236/// Runs a pairing session against `target`, streaming [`PairingEvent`]s to
237/// `events` and consuming [`PairingCommand`]s from `commands`. Returns when the
238/// flow finishes (paired, failed, cancelled, or timed out).
239///
240/// The caller owns the orchestration: spawn this on a runtime, hold the command
241/// sender to forward the user's device pick / cancel, and read events to drive
242/// the UI.
243pub async fn run_pairing(
244    backend: &dyn HidBackend,
245    target: ReceiverSelector,
246    mut commands: mpsc::UnboundedReceiver<PairingCommand>,
247    events: mpsc::UnboundedSender<PairingEvent>,
248) -> Result<(), PairingError> {
249    let (channel, family) = match open_receiver(backend, &target).await {
250        Ok(receiver) => receiver,
251        Err(e) => {
252            let _ = events.send(PairingEvent::Failed(e.clone()));
253            return Err(e);
254        }
255    };
256    let (listener, mut notifications) = subscribe(&channel);
257
258    let result = run_session(&channel, family, &mut commands, &mut notifications, &events).await;
259
260    drop(listener);
261    // Best-effort restore: clear notification flags we set.
262    let _ = channel
263        .write_register(RECEIVER_INDEX, NOTIFICATIONS, [0, 0, 0])
264        .await;
265
266    if let Err(ref e) = result {
267        let _ = events.send(PairingEvent::Failed(e.clone()));
268    }
269    result
270}
271
272/// Runs the core flow and phase-correct cancellation on every unsuccessful exit.
273async fn run_session(
274    channel: &HidppChannel,
275    family: ReceiverFamily,
276    commands: &mut mpsc::UnboundedReceiver<PairingCommand>,
277    notifications: &mut mpsc::UnboundedReceiver<HidppMessage>,
278    events: &mpsc::UnboundedSender<PairingEvent>,
279) -> Result<(), PairingError> {
280    let mut phase = PairingPhase::from(family);
281    let result = drive(channel, family, &mut phase, commands, notifications, events).await;
282    if result.is_err() {
283        cancel(channel, phase).await;
284    }
285    result
286}
287
288/// Core session loop.
289async fn drive(
290    channel: &HidppChannel,
291    family: ReceiverFamily,
292    phase: &mut PairingPhase,
293    commands: &mut mpsc::UnboundedReceiver<PairingCommand>,
294    notifications: &mut mpsc::UnboundedReceiver<HidppMessage>,
295    events: &mpsc::UnboundedSender<PairingEvent>,
296) -> Result<(), PairingError> {
297    write_register(channel, NOTIFICATIONS, NOTIFICATION_FLAGS).await?;
298
299    match family {
300        ReceiverFamily::Bolt => {
301            write_register(channel, BOLT_DISCOVERY, [DISCOVERY_TIMEOUT, 0x01, 0x00]).await?;
302        }
303        ReceiverFamily::Unifying => {
304            write_register(channel, UNIFYING_PAIRING, [0x01, 0x00, DISCOVERY_TIMEOUT]).await?;
305        }
306    }
307    let _ = events.send(PairingEvent::Searching);
308
309    // Partial Bolt discovery frames, keyed by discovery counter.
310    let mut partial: HashMap<u16, PartialDevice> = HashMap::new();
311    // Auth byte of the device the user chose to pair, for passkey rendering.
312    let mut pairing_auth: Option<u8> = None;
313    let deadline = tokio::time::sleep(SESSION_TIMEOUT);
314    tokio::pin!(deadline);
315
316    loop {
317        tokio::select! {
318            () = &mut deadline => return Err(PairingError::Timeout),
319
320            cmd = commands.recv() => match cmd {
321                Some(PairingCommand::Pair(device)) => {
322                    pairing_auth = Some(device.authentication);
323                    if *phase == PairingPhase::BoltDiscovery {
324                        *phase = PairingPhase::BoltPairing;
325                    }
326                    pair_bolt_device(channel, &device).await?;
327                }
328                Some(PairingCommand::Cancel) | None => {
329                    return Err(PairingError::Cancelled);
330                }
331            },
332
333            msg = notifications.recv() => {
334                let Some(msg) = msg else {
335                    return Err(PairingError::Hid("receiver channel closed".into()));
336                };
337                let (device_index, sub_id, payload) = decode(&msg);
338                // Reverse-engineered wire format — log every notification so a
339                // mis-parse can be diagnosed against real hardware.
340                trace!(sub_id = format_args!("{sub_id:#04x}"), ?payload, "pairing notification");
341                let Some(note) = parse_notification(sub_id, device_index, payload) else {
342                    continue;
343                };
344                match note {
345                    Notification::DiscoveryInfo { counter, kind, address, authentication } => {
346                        let entry = partial.entry(counter).or_default();
347                        entry.kind = Some(kind);
348                        entry.address = Some(address);
349                        entry.authentication = Some(authentication);
350                        if let Some(device) = entry.build() {
351                            let _ = events.send(PairingEvent::DeviceFound(device));
352                        }
353                    }
354                    Notification::DiscoveryName { counter, name } => {
355                        let entry = partial.entry(counter).or_default();
356                        entry.name = Some(name);
357                        if let Some(device) = entry.build() {
358                            let _ = events.send(PairingEvent::DeviceFound(device));
359                        }
360                    }
361                    Notification::Passkey { digits, value } => {
362                        let method = match pairing_auth {
363                            Some(auth) if auth & 0x01 != 0 => PasskeyMethod::Keyboard(digits),
364                            _ => PasskeyMethod::Pointer {
365                                clicks: passkey_to_clicks(value),
366                                passkey: digits,
367                            },
368                        };
369                        let _ = events.send(PairingEvent::Passkey(method));
370                    }
371                    Notification::MalformedPasskey => {
372                        return Err(PairingError::MalformedNotification("passkey digits"));
373                    }
374                    Notification::PairingSucceeded { slot } => {
375                        let _ = events.send(PairingEvent::Paired { slot });
376                        return Ok(());
377                    }
378                    Notification::PairingError(code) => return Err(PairingError::Device(code)),
379                    Notification::Connected { slot, established } if family == ReceiverFamily::Unifying => {
380                        if established {
381                            let _ = events.send(PairingEvent::Paired { slot });
382                            return Ok(());
383                        }
384                    }
385                    Notification::Connected { .. } => {}
386                    Notification::UnifyingLock { open, error } => {
387                        if error != 0 {
388                            return Err(PairingError::Device(error));
389                        }
390                        if !open {
391                            // Lock closed without a connection notification: nothing paired.
392                            return Err(PairingError::Timeout);
393                        }
394                    }
395                }
396            }
397        }
398    }
399}
400
401/// Accumulates the two Bolt discovery frames for one device.
402#[derive(Default)]
403struct PartialDevice {
404    kind: Option<u8>,
405    address: Option<[u8; 6]>,
406    authentication: Option<u8>,
407    name: Option<String>,
408    emitted: bool,
409}
410
411impl PartialDevice {
412    /// Builds a [`DiscoveredDevice`] once both frames have arrived, exactly once.
413    fn build(&mut self) -> Option<DiscoveredDevice> {
414        if self.emitted {
415            return None;
416        }
417        let (kind, address, authentication, name) = (
418            self.kind?,
419            self.address?,
420            self.authentication?,
421            self.name.clone()?,
422        );
423        self.emitted = true;
424        Some(DiscoveredDevice {
425            address,
426            authentication,
427            kind: BoltDeviceKind::from(kind & 0x0f),
428            name,
429        })
430    }
431}
432
433/// Sends the Bolt pair command (action `0x01`, auto slot) for `device`.
434async fn pair_bolt_device(
435    channel: &HidppChannel,
436    device: &DiscoveredDevice,
437) -> Result<(), PairingError> {
438    let mut payload = [0u8; 16];
439    payload[0] = 0x01; // action: pair
440    payload[1] = 0x00; // slot: auto-assign
441    payload[2..8].copy_from_slice(&device.address);
442    payload[8] = device.authentication;
443    payload[9] = device.entropy();
444    write_long_register(channel, BOLT_PAIRING, payload).await
445}
446
447/// Best-effort cancel of an in-progress flow.
448async fn cancel(channel: &HidppChannel, phase: PairingPhase) {
449    let res = match phase {
450        PairingPhase::BoltDiscovery => {
451            write_register(channel, BOLT_DISCOVERY, [DISCOVERY_TIMEOUT, 0x02, 0x00]).await
452        }
453        PairingPhase::BoltPairing => {
454            let mut payload = [0u8; 16];
455            payload[0] = 0x02;
456            write_long_register(channel, BOLT_PAIRING, payload).await
457        }
458        PairingPhase::UnifyingPairing => {
459            write_register(channel, UNIFYING_PAIRING, [0x02, 0x00, 0x00]).await
460        }
461    };
462    if let Err(e) = res {
463        debug!(?phase, ?e, "cancel write failed");
464    }
465}
466
467/// Removes the device on `slot` from the receiver named by `target`.
468pub async fn unpair(
469    backend: &dyn HidBackend,
470    target: ReceiverSelector,
471    slot: u8,
472) -> Result<(), PairingError> {
473    let (channel, family) = open_receiver(backend, &target).await?;
474    match family {
475        ReceiverFamily::Bolt => {
476            let mut payload = [0u8; 16];
477            payload[0] = 0x03; // action: unpair
478            payload[1] = slot;
479            write_long_register(&channel, BOLT_PAIRING, payload).await
480        }
481        ReceiverFamily::Unifying => {
482            write_register(&channel, UNIFYING_PAIRING, [0x03, slot, 0x00]).await
483        }
484    }
485}
486
487#[cfg(test)]
488mod tests;