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