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