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, warn};
36
37pub use hidpp::receiver::bolt::DeviceKind as BoltDeviceKind;
38
39use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel};
40
41/// HID++ device index addressing the receiver itself (not a paired device).
42const RECEIVER_INDEX: u8 = 0xff;
43
44/// Receiver registers (HID++ 1.0 RAP).
45mod reg {
46    /// Notification-flags register (3-byte big-endian value).
47    pub const NOTIFICATIONS: u8 = 0x00;
48    /// Unifying pairing lock + unpair.
49    pub const UNIFYING_PAIRING: u8 = 0xb2;
50    /// Bolt discovery start/stop (short register).
51    pub const BOLT_DISCOVERY: u8 = 0xc0;
52    /// Bolt pair / cancel / unpair (long register).
53    pub const BOLT_PAIRING: u8 = 0xc1;
54}
55
56/// Notification sub-IDs the receiver emits during pairing.
57mod notif {
58    pub const DEVICE_CONNECTION: u8 = 0x41;
59    pub const UNIFYING_LOCK: u8 = 0x4a;
60    pub const PASSKEY_REQUEST: u8 = 0x4d;
61    pub const DEVICE_DISCOVERY: u8 = 0x4f;
62    pub const DISCOVERY_STATUS: u8 = 0x53;
63    pub const PAIRING_STATUS: u8 = 0x54;
64}
65
66/// `WIRELESS` (0x000100) | `SOFTWARE_PRESENT` (0x000800) notification flags,
67/// big-endian. Both must be set for the receiver to stream pairing events.
68const NOTIF_FLAGS: [u8; 3] = [0x00, 0x09, 0x00];
69
70/// Receiver pairing family. Each uses a different register flow.
71#[derive(Clone, Copy, PartialEq, Eq, Debug)]
72pub enum ReceiverFamily {
73    Bolt,
74    Unifying,
75}
76
77fn family_for(product_id: u16) -> Option<ReceiverFamily> {
78    if crate::BOLT_PIDS.contains(&product_id) {
79        Some(ReceiverFamily::Bolt)
80    } else if crate::UNIFYING_PIDS.contains(&product_id) {
81        Some(ReceiverFamily::Unifying)
82    } else {
83        None
84    }
85}
86
87/// A pairing-capable receiver currently connected to the host.
88#[derive(Clone, Debug)]
89pub struct PairingReceiver {
90    /// Bolt unique ID, when readable. `None` for Unifying (no read path yet).
91    pub uid: Option<String>,
92    pub family: ReceiverFamily,
93    pub product_id: u16,
94}
95
96/// Selects which receiver a pairing operation targets.
97///
98/// Crosses the agent↔GUI IPC (`start_pairing`), so variant order is wire
99/// format — changes require a `PROTOCOL_VERSION` bump (guarded by
100/// `openlogi-agent-core/tests/wire_format.rs`).
101#[derive(Clone, Debug, Serialize, Deserialize)]
102pub enum ReceiverSelector {
103    /// The first supported receiver found — fine for the common single-receiver case.
104    First,
105    /// A specific Bolt receiver by its unique ID.
106    BoltUid(String),
107}
108
109/// A nearby unpaired device surfaced by Bolt discovery.
110#[derive(Clone, Debug)]
111pub struct DiscoveredDevice {
112    /// 6-byte BTLE address used to pair.
113    pub address: [u8; 6],
114    /// Authentication-method bitfield (bit 0 = passkey typed on keyboard).
115    pub authentication: u8,
116    pub kind: BoltDeviceKind,
117    pub name: String,
118}
119
120impl DiscoveredDevice {
121    /// Whether authentication is by typing a passkey on a keyboard (vs. a
122    /// pointer click sequence).
123    #[must_use]
124    pub fn passkey_on_keyboard(&self) -> bool {
125        self.authentication & 0x01 != 0
126    }
127
128    /// Pairing entropy: keyboards use 20 bits, everything else 10.
129    fn entropy(&self) -> u8 {
130        if self.kind == BoltDeviceKind::Keyboard {
131            20
132        } else {
133            10
134        }
135    }
136}
137
138/// A single click in a pointer passkey sequence.
139#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
140pub enum Click {
141    Left,
142    Right,
143}
144
145/// How the user authenticates the device during Bolt pairing.
146///
147/// Crosses the agent↔GUI IPC (inside `PairingUpdate::Passkey`, [`Click`]
148/// included), so variant and field order are wire format — changes require a
149/// `PROTOCOL_VERSION` bump (guarded by
150/// `openlogi-agent-core/tests/wire_format.rs`).
151#[derive(Clone, Debug, Serialize, Deserialize)]
152pub enum PasskeyMethod {
153    /// Type these digits on the new keyboard, then press Enter.
154    Keyboard(String),
155    /// On the new pointer, perform this left/right click sequence, then click
156    /// both buttons together.
157    Pointer { passkey: String, clicks: Vec<Click> },
158}
159
160/// Renders a Bolt passkey as a 10-bit MSB-first left/right click sequence.
161fn passkey_to_clicks(passkey: &str) -> Vec<Click> {
162    let value: u32 = passkey.trim().parse().unwrap_or(0);
163    (0..10)
164        .rev()
165        .map(|bit| {
166            if value & (1 << bit) != 0 {
167                Click::Right
168            } else {
169                Click::Left
170            }
171        })
172        .collect()
173}
174
175/// Events streamed out of a pairing session.
176#[derive(Clone, Debug)]
177pub enum PairingEvent {
178    /// Discovery (Bolt) or the pairing lock (Unifying) is now open.
179    Searching,
180    /// Bolt only: a nearby unpaired device was discovered.
181    DeviceFound(DiscoveredDevice),
182    /// Bolt only: the device asks the user to enter a passkey to authenticate.
183    Passkey(PasskeyMethod),
184    /// A device was paired and assigned `slot`.
185    Paired { slot: u8 },
186    /// The flow ended without pairing a device.
187    Failed(PairingError),
188}
189
190/// Commands fed into a pairing session.
191#[derive(Clone, Debug)]
192pub enum PairingCommand {
193    /// Bolt: pair with a previously discovered device.
194    Pair(DiscoveredDevice),
195    /// Abort the in-progress flow.
196    Cancel,
197}
198
199/// Errors raised by pairing operations.
200#[derive(Clone, Debug, Error)]
201pub enum PairingError {
202    #[error("HID transport error: {0}")]
203    Hid(String),
204    #[error("no supported pairing-capable receiver found")]
205    ReceiverNotFound,
206    #[error("receiver register access failed: {0}")]
207    Register(String),
208    #[error("pairing timed out")]
209    Timeout,
210    #[error("receiver reported pairing error {0:#04x}")]
211    Device(u8),
212    #[error("pairing was cancelled")]
213    Cancelled,
214}
215
216impl From<async_hid::HidError> for PairingError {
217    fn from(e: async_hid::HidError) -> Self {
218        PairingError::Hid(e.to_string())
219    }
220}
221
222/// Lists supported pairing-capable receivers connected to the host.
223pub async fn list_pairing_receivers() -> Result<Vec<PairingReceiver>, PairingError> {
224    let mut out = Vec::new();
225    for dev in enumerate_hidpp_devices().await? {
226        let Some((_, channel)) = open_hidpp_channel(dev).await? else {
227            continue;
228        };
229        let Some(family) = family_for(channel.product_id) else {
230            continue;
231        };
232        let uid = match family {
233            ReceiverFamily::Bolt => read_bolt_uid(&channel).await,
234            ReceiverFamily::Unifying => None,
235        };
236        out.push(PairingReceiver {
237            uid,
238            family,
239            product_id: channel.product_id,
240        });
241    }
242    Ok(out)
243}
244
245/// Reads a Bolt receiver's unique ID via the crate's `BoltReceiver`.
246async fn read_bolt_uid(channel: &Arc<HidppChannel>) -> Option<String> {
247    let Some(Receiver::Bolt(bolt)) = receiver::detect(Arc::clone(channel)) else {
248        return None;
249    };
250    bolt.get_unique_id().await.ok()
251}
252
253/// Opens the channel for the receiver named by `target`.
254async fn open_receiver(
255    target: &ReceiverSelector,
256) -> Result<(Arc<HidppChannel>, ReceiverFamily), PairingError> {
257    for dev in enumerate_hidpp_devices().await? {
258        let Some((_, channel)) = open_hidpp_channel(dev).await? else {
259            continue;
260        };
261        let Some(family) = family_for(channel.product_id) else {
262            continue;
263        };
264        match target {
265            ReceiverSelector::First => return Ok((channel, family)),
266            ReceiverSelector::BoltUid(want) => {
267                if family == ReceiverFamily::Bolt
268                    && read_bolt_uid(&channel)
269                        .await
270                        .is_some_and(|uid| uid.eq_ignore_ascii_case(want))
271                {
272                    return Ok((channel, family));
273                }
274            }
275        }
276    }
277    Err(PairingError::ReceiverNotFound)
278}
279
280/// Decodes a raw HID++ message into `(device_index, sub_id, payload)`, where
281/// `payload[0]` is the HID++ 1.0 notification *address* byte and `payload[k]`
282/// for `k >= 1` is Solaar's `data[k - 1]`. Short payloads are zero-padded.
283fn decode(msg: &HidppMessage) -> (u8, u8, [u8; 17]) {
284    let mut payload = [0u8; 17];
285    match msg {
286        HidppMessage::Short(d) => {
287            payload[..4].copy_from_slice(&d[2..6]);
288            (d[0], d[1], payload)
289        }
290        HidppMessage::Long(d) => {
291            payload.copy_from_slice(&d[2..19]);
292            (d[0], d[1], payload)
293        }
294    }
295}
296
297/// A parsed receiver notification relevant to pairing.
298#[derive(Clone, Debug, PartialEq, Eq)]
299enum Notification {
300    /// Bolt discovery address frame: kind, BTLE address, auth method.
301    DiscoveryInfo {
302        counter: u16,
303        kind: u8,
304        address: [u8; 6],
305        authentication: u8,
306    },
307    /// Bolt discovery name frame.
308    DiscoveryName { counter: u16, name: String },
309    /// Bolt pairing completed; `slot` is the assigned device index.
310    PairingSucceeded { slot: u8 },
311    /// Bolt pairing/discovery failed with a receiver error code.
312    PairingError(u8),
313    /// Bolt passkey to present to the user (6 ASCII digits).
314    Passkey(String),
315    /// A device linked to the receiver (`slot` = its device index).
316    Connected { slot: u8, established: bool },
317    /// Unifying pairing lock changed state; `error` is non-zero on failure.
318    UnifyingLock { open: bool, error: u8 },
319}
320
321/// Parses a raw message into a pairing [`Notification`], if it is one.
322fn parse_notification(sub_id: u8, device_index: u8, p: [u8; 17]) -> Option<Notification> {
323    match sub_id {
324        notif::DEVICE_CONNECTION => Some(Notification::Connected {
325            slot: device_index,
326            // bit 6 of the flags byte set => link not established (offline).
327            established: p[1] & (1 << 6) == 0,
328        }),
329        notif::DEVICE_DISCOVERY => {
330            let counter = u16::from(p[0]) + u16::from(p[1]) * 256;
331            match p[2] {
332                0 => {
333                    let mut address = [0u8; 6];
334                    address.copy_from_slice(&p[7..13]);
335                    Some(Notification::DiscoveryInfo {
336                        counter,
337                        kind: p[4],
338                        address,
339                        authentication: p[15],
340                    })
341                }
342                1 => {
343                    let len = usize::from(p[3]).min(p.len() - 4);
344                    let name = String::from_utf8_lossy(&p[4..4 + len]).into_owned();
345                    Some(Notification::DiscoveryName { counter, name })
346                }
347                _ => None,
348            }
349        }
350        notif::DISCOVERY_STATUS => {
351            let error = p[1];
352            if error != 0 {
353                Some(Notification::PairingError(error))
354            } else {
355                None
356            }
357        }
358        notif::PAIRING_STATUS => {
359            let error = p[1];
360            if error != 0 {
361                Some(Notification::PairingError(error))
362            } else if p[0] == 0x02 {
363                // address 0x02 with no error => paired; slot is data[7] = p[8].
364                Some(Notification::PairingSucceeded { slot: p[8] })
365            } else {
366                None
367            }
368        }
369        notif::PASSKEY_REQUEST => {
370            let passkey = String::from_utf8_lossy(&p[1..7]).into_owned();
371            Some(Notification::Passkey(passkey))
372        }
373        notif::UNIFYING_LOCK => Some(Notification::UnifyingLock {
374            open: p[0] & 0x01 != 0,
375            error: p[1],
376        }),
377        _ => None,
378    }
379}
380
381/// Subscribes a listener that forwards unmatched messages to an async channel,
382/// and returns the listener handle plus the receiver end.
383fn subscribe(channel: &HidppChannel) -> (u32, mpsc::UnboundedReceiver<HidppMessage>) {
384    let (tx, rx) = mpsc::unbounded_channel();
385    let hdl = channel.add_msg_listener(move |msg, matched| {
386        // `matched` messages are responses to our own register writes.
387        if !matched {
388            let _ = tx.send(msg);
389        }
390    });
391    (hdl, rx)
392}
393
394/// Overall guard so a wedged receiver can't hang the session forever.
395const SESSION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90);
396/// Discovery / lock window opened on the receiver, in seconds.
397const DISCOVERY_TIMEOUT: u8 = 30;
398
399/// Runs a pairing session against `target`, streaming [`PairingEvent`]s to
400/// `events` and consuming [`PairingCommand`]s from `commands`. Returns when the
401/// flow finishes (paired, failed, cancelled, or timed out).
402///
403/// The caller owns the orchestration: spawn this on a runtime, hold the command
404/// sender to forward the user's device pick / cancel, and read events to drive
405/// the UI.
406pub async fn run_pairing(
407    target: ReceiverSelector,
408    mut commands: mpsc::UnboundedReceiver<PairingCommand>,
409    events: mpsc::UnboundedSender<PairingEvent>,
410) -> Result<(), PairingError> {
411    let (channel, family) = open_receiver(&target).await?;
412    let (listener, mut notifications) = subscribe(&channel);
413
414    let result = drive(&channel, family, &mut commands, &mut notifications, &events).await;
415
416    channel.remove_msg_listener(listener);
417    // Best-effort restore: clear notification flags we set.
418    let _ = channel
419        .write_register(RECEIVER_INDEX, reg::NOTIFICATIONS, [0, 0, 0])
420        .await;
421
422    if let Err(ref e) = result {
423        let _ = events.send(PairingEvent::Failed(e.clone()));
424    }
425    result
426}
427
428/// Core session loop. Split out so [`run_pairing`] can always run teardown.
429async fn drive(
430    channel: &HidppChannel,
431    family: ReceiverFamily,
432    commands: &mut mpsc::UnboundedReceiver<PairingCommand>,
433    notifications: &mut mpsc::UnboundedReceiver<HidppMessage>,
434    events: &mpsc::UnboundedSender<PairingEvent>,
435) -> Result<(), PairingError> {
436    write_register(channel, reg::NOTIFICATIONS, NOTIF_FLAGS).await?;
437
438    match family {
439        ReceiverFamily::Bolt => {
440            write_register(
441                channel,
442                reg::BOLT_DISCOVERY,
443                [DISCOVERY_TIMEOUT, 0x01, 0x00],
444            )
445            .await?;
446        }
447        ReceiverFamily::Unifying => {
448            write_register(
449                channel,
450                reg::UNIFYING_PAIRING,
451                [0x01, 0x00, DISCOVERY_TIMEOUT],
452            )
453            .await?;
454        }
455    }
456    let _ = events.send(PairingEvent::Searching);
457
458    // Partial Bolt discovery frames, keyed by discovery counter.
459    let mut partial: HashMap<u16, PartialDevice> = HashMap::new();
460    // Auth byte of the device the user chose to pair, for passkey rendering.
461    let mut pairing_auth: Option<u8> = None;
462    let deadline = tokio::time::sleep(SESSION_TIMEOUT);
463    tokio::pin!(deadline);
464
465    loop {
466        tokio::select! {
467            () = &mut deadline => return Err(PairingError::Timeout),
468
469            cmd = commands.recv() => match cmd {
470                Some(PairingCommand::Pair(device)) => {
471                    pairing_auth = Some(device.authentication);
472                    pair_bolt_device(channel, &device).await?;
473                }
474                Some(PairingCommand::Cancel) | None => {
475                    cancel(channel, family).await;
476                    return Err(PairingError::Cancelled);
477                }
478            },
479
480            msg = notifications.recv() => {
481                let Some(msg) = msg else {
482                    return Err(PairingError::Hid("receiver channel closed".into()));
483                };
484                let (device_index, sub_id, payload) = decode(&msg);
485                // Reverse-engineered wire format — log every notification so a
486                // mis-parse can be diagnosed against real hardware.
487                trace!(sub_id = format_args!("{sub_id:#04x}"), ?payload, "pairing notification");
488                let Some(note) = parse_notification(sub_id, device_index, payload) else {
489                    continue;
490                };
491                match note {
492                    Notification::DiscoveryInfo { counter, kind, address, authentication } => {
493                        let entry = partial.entry(counter).or_default();
494                        entry.kind = Some(kind);
495                        entry.address = Some(address);
496                        entry.authentication = Some(authentication);
497                        if let Some(device) = entry.build() {
498                            let _ = events.send(PairingEvent::DeviceFound(device));
499                        }
500                    }
501                    Notification::DiscoveryName { counter, name } => {
502                        let entry = partial.entry(counter).or_default();
503                        entry.name = Some(name);
504                        if let Some(device) = entry.build() {
505                            let _ = events.send(PairingEvent::DeviceFound(device));
506                        }
507                    }
508                    Notification::Passkey(passkey) => {
509                        let method = match pairing_auth {
510                            Some(auth) if auth & 0x01 != 0 => PasskeyMethod::Keyboard(passkey),
511                            _ => PasskeyMethod::Pointer {
512                                clicks: passkey_to_clicks(&passkey),
513                                passkey,
514                            },
515                        };
516                        let _ = events.send(PairingEvent::Passkey(method));
517                    }
518                    Notification::PairingSucceeded { slot } => {
519                        let _ = events.send(PairingEvent::Paired { slot });
520                        return Ok(());
521                    }
522                    Notification::PairingError(code) => return Err(PairingError::Device(code)),
523                    Notification::Connected { slot, established } if family == ReceiverFamily::Unifying => {
524                        if established {
525                            let _ = events.send(PairingEvent::Paired { slot });
526                            return Ok(());
527                        }
528                    }
529                    Notification::Connected { .. } => {}
530                    Notification::UnifyingLock { open, error } => {
531                        if error != 0 {
532                            return Err(PairingError::Device(error));
533                        }
534                        if !open {
535                            // Lock closed without a connection notification: nothing paired.
536                            return Err(PairingError::Timeout);
537                        }
538                    }
539                }
540            }
541        }
542    }
543}
544
545/// Accumulates the two Bolt discovery frames for one device.
546#[derive(Default)]
547struct PartialDevice {
548    kind: Option<u8>,
549    address: Option<[u8; 6]>,
550    authentication: Option<u8>,
551    name: Option<String>,
552    emitted: bool,
553}
554
555impl PartialDevice {
556    /// Builds a [`DiscoveredDevice`] once both frames have arrived, exactly once.
557    fn build(&mut self) -> Option<DiscoveredDevice> {
558        if self.emitted {
559            return None;
560        }
561        let (kind, address, authentication, name) = (
562            self.kind?,
563            self.address?,
564            self.authentication?,
565            self.name.clone()?,
566        );
567        self.emitted = true;
568        Some(DiscoveredDevice {
569            address,
570            authentication,
571            kind: BoltDeviceKind::try_from(kind & 0x0f).unwrap_or(BoltDeviceKind::Unknown),
572            name,
573        })
574    }
575}
576
577/// Sends the Bolt pair command (action `0x01`, auto slot) for `device`.
578async fn pair_bolt_device(
579    channel: &HidppChannel,
580    device: &DiscoveredDevice,
581) -> Result<(), PairingError> {
582    let mut payload = [0u8; 16];
583    payload[0] = 0x01; // action: pair
584    payload[1] = 0x00; // slot: auto-assign
585    payload[2..8].copy_from_slice(&device.address);
586    payload[8] = device.authentication;
587    payload[9] = device.entropy();
588    write_long_register(channel, reg::BOLT_PAIRING, payload).await
589}
590
591/// Best-effort cancel of an in-progress flow.
592async fn cancel(channel: &HidppChannel, family: ReceiverFamily) {
593    let res = match family {
594        ReceiverFamily::Bolt => {
595            write_register(
596                channel,
597                reg::BOLT_DISCOVERY,
598                [DISCOVERY_TIMEOUT, 0x02, 0x00],
599            )
600            .await
601        }
602        ReceiverFamily::Unifying => {
603            write_register(channel, reg::UNIFYING_PAIRING, [0x02, 0x00, 0x00]).await
604        }
605    };
606    if let Err(e) = res {
607        debug!(?e, "cancel write failed");
608    }
609}
610
611/// Removes the device on `slot` from the receiver named by `target`.
612pub async fn unpair(target: ReceiverSelector, slot: u8) -> Result<(), PairingError> {
613    let (channel, family) = open_receiver(&target).await?;
614    match family {
615        ReceiverFamily::Bolt => {
616            let mut payload = [0u8; 16];
617            payload[0] = 0x03; // action: unpair
618            payload[1] = slot;
619            write_long_register(&channel, reg::BOLT_PAIRING, payload).await
620        }
621        ReceiverFamily::Unifying => {
622            write_register(&channel, reg::UNIFYING_PAIRING, [0x03, slot, 0x00]).await
623        }
624    }
625}
626
627async fn write_register(
628    channel: &HidppChannel,
629    address: u8,
630    payload: [u8; 3],
631) -> Result<(), PairingError> {
632    channel
633        .write_register(RECEIVER_INDEX, address, payload)
634        .await
635        .map_err(|e| {
636            warn!(
637                register = format_args!("{address:#04x}"),
638                ?e,
639                "register write failed"
640            );
641            PairingError::Register(format!("{e}"))
642        })
643}
644
645async fn write_long_register(
646    channel: &HidppChannel,
647    address: u8,
648    payload: [u8; 16],
649) -> Result<(), PairingError> {
650    channel
651        .write_long_register(RECEIVER_INDEX, address, payload)
652        .await
653        .map_err(|e| {
654            warn!(
655                register = format_args!("{address:#04x}"),
656                ?e,
657                "long register write failed"
658            );
659            PairingError::Register(format!("{e}"))
660        })
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    /// Builds a long HID++ message from a 17-byte payload (`p[0]` = address).
668    fn long(sub_id: u8, device_index: u8, p: [u8; 17]) -> HidppMessage {
669        let mut d = [0u8; 19];
670        d[0] = device_index;
671        d[1] = sub_id;
672        d[2..19].copy_from_slice(&p);
673        HidppMessage::Long(d)
674    }
675
676    #[test]
677    fn decode_maps_long_payload_to_address_first() {
678        let msg = long(notif::DEVICE_DISCOVERY, 0xff, {
679            let mut p = [0u8; 17];
680            p[0] = 0x07; // counter low (= Solaar address)
681            p[1] = 0x00; // counter high (= Solaar data[0])
682            p
683        });
684        let (idx, sub, payload) = decode(&msg);
685        assert_eq!(idx, 0xff);
686        assert_eq!(sub, notif::DEVICE_DISCOVERY);
687        assert_eq!(payload[0], 0x07);
688        assert_eq!(payload[1], 0x00);
689    }
690
691    #[test]
692    fn parses_discovery_info_frame() {
693        let mut p = [0u8; 17];
694        p[0] = 0x05; // counter low
695        p[1] = 0x00; // counter high
696        p[2] = 0x00; // address frame selector
697        p[4] = 0x02; // kind = mouse
698        p[7..13].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef, 0x01, 0x02]);
699        p[15] = 0x01; // auth: keyboard-typed bit
700        assert_eq!(
701            parse_notification(notif::DEVICE_DISCOVERY, 0xff, p),
702            Some(Notification::DiscoveryInfo {
703                counter: 5,
704                kind: 0x02,
705                address: [0xde, 0xad, 0xbe, 0xef, 0x01, 0x02],
706                authentication: 0x01,
707            })
708        );
709    }
710
711    #[test]
712    fn parses_discovery_name_frame() {
713        let mut p = [0u8; 17];
714        p[0] = 0x05;
715        p[1] = 0x00;
716        p[2] = 0x01; // name frame selector
717        p[3] = 0x03; // length
718        p[4..7].copy_from_slice(b"MX3");
719        assert_eq!(
720            parse_notification(notif::DEVICE_DISCOVERY, 0xff, p),
721            Some(Notification::DiscoveryName {
722                counter: 5,
723                name: "MX3".to_string(),
724            })
725        );
726    }
727
728    #[test]
729    fn parses_pairing_success_with_slot() {
730        let mut p = [0u8; 17];
731        p[0] = 0x02; // address 0x02 = complete
732        p[1] = 0x00; // no error
733        p[8] = 0x03; // slot = data[7]
734        assert_eq!(
735            parse_notification(notif::PAIRING_STATUS, 0xff, p),
736            Some(Notification::PairingSucceeded { slot: 3 })
737        );
738    }
739
740    #[test]
741    fn parses_pairing_error() {
742        let mut p = [0u8; 17];
743        p[0] = 0x00;
744        p[1] = 0x01; // BoltPairingError::DEVICE_TIMEOUT
745        assert_eq!(
746            parse_notification(notif::PAIRING_STATUS, 0xff, p),
747            Some(Notification::PairingError(0x01))
748        );
749    }
750
751    #[test]
752    fn parses_passkey_digits() {
753        let mut p = [0u8; 17];
754        p[1..7].copy_from_slice(b"123456");
755        assert_eq!(
756            parse_notification(notif::PASSKEY_REQUEST, 0xff, p),
757            Some(Notification::Passkey("123456".to_string()))
758        );
759    }
760
761    #[test]
762    fn parses_unifying_lock() {
763        let mut p = [0u8; 17];
764        p[0] = 0x01; // lock open
765        assert_eq!(
766            parse_notification(notif::UNIFYING_LOCK, 0xff, p),
767            Some(Notification::UnifyingLock {
768                open: true,
769                error: 0
770            })
771        );
772    }
773
774    #[test]
775    fn passkey_clicks_are_msb_first_10_bits() {
776        // 0b00_0000_0101 = 5 -> eight lefts then right, left, right.
777        assert_eq!(
778            passkey_to_clicks("5"),
779            vec![
780                Click::Left,
781                Click::Left,
782                Click::Left,
783                Click::Left,
784                Click::Left,
785                Click::Left,
786                Click::Left,
787                Click::Right,
788                Click::Left,
789                Click::Right,
790            ]
791        );
792    }
793}