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