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