Skip to main content

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