Skip to main content

hidpp/receiver/bolt/
event.rs

1//! The notifications a Bolt receiver broadcasts, and their decoding.
2//!
3//! The receiver reports device arrivals, discovery results, and pairing
4//! progress as unsolicited HID++1.0 messages. Their layout is not publicly
5//! documented — it comes from reading other implementations (primarily Solaar)
6//! and from fuzzing registers — so every offset and mask here is pinned by a
7//! test rather than by a specification.
8
9use num_enum::{FromPrimitive, IntoPrimitive, TryFromPrimitive};
10
11use crate::{protocol::v10, receiver::RECEIVER_DEVICE_INDEX};
12
13/// The notification sub-ids this module decodes.
14///
15/// Modelling the sub-id as an enum rather than matching bare bytes makes the
16/// dispatch below exhaustive: a new notification cannot be added here without
17/// the compiler demanding a decode for it.
18#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)]
19#[repr(u8)]
20enum Notification {
21    /// A device connected to, or disconnected from, the receiver.
22    DeviceConnection = 0x41,
23
24    /// The receiver asks for a passkey to authenticate a device being paired.
25    PairingPasskeyRequest = 0x4d,
26
27    /// The user pressed a key while entering a pairing passkey.
28    PairingPasskeyPressed = 0x4e,
29
30    /// Details or the name of a device found while discovering.
31    DeviceDiscovery = 0x4f,
32
33    /// Device discovery was enabled or disabled.
34    DeviceDiscoveryStatus = 0x53,
35}
36
37/// The two payload kinds a [`Notification::DeviceDiscovery`] report carries,
38/// selected by `payload[2]`.
39#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)]
40#[repr(u8)]
41enum DiscoveryPart {
42    /// Address, kind, and product id of the discovered device.
43    Details = 0,
44
45    /// The discovered device's advertised name.
46    Name = 1,
47}
48
49/// Decodes an unsolicited receiver message into the event it carries.
50///
51/// Returns `None` for a report this crate does not model, one addressed
52/// elsewhere, or one whose payload does not parse — all of which the listener
53/// drops.
54///
55/// Kept separate from the message listener in [`super::Receiver::new`] so the
56/// wire layout is reachable from consumers and tests without a HID channel
57/// behind it.
58#[must_use]
59pub fn decode(msg: &v10::Message) -> Option<Event> {
60    let header = msg.header();
61    let payload = msg.extend_payload();
62
63    let notification = Notification::try_from(header.sub_id).ok()?;
64
65    // Every notification but a device connection is addressed to the receiver
66    // itself. A connection notification instead carries the device's slot in
67    // the header — the only place that index is reported.
68    if notification != Notification::DeviceConnection
69        && header.device_index != RECEIVER_DEVICE_INDEX
70    {
71        return None;
72    }
73
74    match notification {
75        Notification::DeviceConnection => Some(Event::DeviceConnection(DeviceConnection {
76            index: header.device_index,
77            // Kind is identity-only; an unrecognised nibble folds to `Unknown`
78            // instead of dropping the event, which would hide the device
79            // entirely.
80            kind: DeviceKind::from(payload[1] & 0x0f),
81            encrypted: payload[1] & (1 << 5) != 0,
82            online: payload[1] & (1 << 6) == 0,
83            wpid: u16::from_le_bytes([payload[2], payload[3]]),
84        })),
85
86        Notification::DeviceDiscovery => match DiscoveryPart::try_from(payload[2]).ok()? {
87            DiscoveryPart::Details => Some(Event::DeviceDiscoveryDeviceDetails {
88                counter: discovery_counter(&payload),
89                kind: DeviceKind::from(payload[4] & 0x0f),
90                wpid: u16::from_le_bytes([payload[5], payload[6]]),
91                address: address6(&payload, 7),
92                authentication: payload[15],
93            }),
94            DiscoveryPart::Name => {
95                let name = discovery_name(&payload)?;
96                Some(Event::DeviceDiscoveryDeviceName {
97                    counter: discovery_counter(&payload),
98                    name: name.to_string(),
99                })
100            }
101        },
102
103        Notification::DeviceDiscoveryStatus => Some(Event::DeviceDiscoveryStatus {
104            discovery_enabled: payload[0] == 0x00,
105        }),
106
107        Notification::PairingPasskeyRequest => Some(Event::PairingPasskeyRequest {
108            device_address: address6(&payload, 7),
109            passkey: passkey(&payload)?.to_string(),
110        }),
111
112        Notification::PairingPasskeyPressed => Some(Event::PairingPasskeyPressed {
113            device_address: address6(&payload, 1),
114            press_type: PairingPasskeyPressType::from(payload[0]),
115        }),
116    }
117}
118
119/// The little-endian counter that pairs a discovery details report with the
120/// name report describing the same device.
121fn discovery_counter(payload: &[u8; 17]) -> u16 {
122    u16::from_le_bytes([payload[0], payload[1]])
123}
124
125/// Extracts 6 contiguous bytes starting at `start` from a receiver-event
126/// payload into a BTLE device-address array.
127///
128/// Every call site above passes a compile-time-fixed `start` (1, 2, or 7)
129/// comfortably within the fixed 17-byte payload, so this never panics in
130/// practice.
131fn address6(payload: &[u8; 17], start: usize) -> [u8; 6] {
132    [
133        payload[start],
134        payload[start + 1],
135        payload[start + 2],
136        payload[start + 3],
137        payload[start + 4],
138        payload[start + 5],
139    ]
140}
141
142/// Reads the name out of a device-discovery name notification.
143///
144/// `payload[3]` is the device-reported name length. The byte comes straight
145/// off the radio, so it must never index past the report: a length that does
146/// not fit the packet (or non-UTF-8 bytes) drops the event instead of
147/// panicking the listener.
148fn discovery_name(payload: &[u8; 17]) -> Option<&str> {
149    let end = 4usize.checked_add(usize::from(payload[3]))?;
150    str::from_utf8(payload.get(4..end)?).ok()
151}
152
153/// Reads the passkey out of a passkey-request notification.
154///
155/// The passkey occupies 6 bytes and is NUL-padded when it is shorter.
156fn passkey(payload: &[u8; 17]) -> Option<&str> {
157    let digits = &payload[1..=6];
158    let len = digits.iter().position(|&b| b == 0).unwrap_or(digits.len());
159    str::from_utf8(&digits[..len]).ok()
160}
161
162/// Represents an event emitted by the receiver.
163///
164/// You can listen to these events using [`super::Receiver::listen`]. Only
165/// enabled notifications as indicated by
166/// [`super::Receiver::get_notification_state`] are emitted.
167#[derive(Clone, PartialEq, Eq, Hash, Debug)]
168#[cfg_attr(feature = "serde", derive(serde::Serialize))]
169#[non_exhaustive]
170pub enum Event {
171    /// Is emitted whenever a device connects to or disconnects from the
172    /// receiver, but only if
173    /// [`NotificationState::wireless_notifications`](super::NotificationState::wireless_notifications)
174    /// is enabled.
175    ///
176    /// Can be triggered for all paired devices using
177    /// [`Receiver::trigger_device_arrival`](super::Receiver::trigger_device_arrival)
178    /// to allow easy device enumeration.
179    ///
180    /// [`Receiver::collect_paired_devices`](super::Receiver::collect_paired_devices)
181    /// implements a simple mechanism to collect all paired devices.
182    DeviceConnection(DeviceConnection),
183
184    /// Is emitted whenever the device discovery status changes.
185    DeviceDiscoveryStatus {
186        /// Whether discovery mode is enabled.
187        discovery_enabled: bool,
188    },
189
190    /// Is emitted many times for every device discovered using
191    /// [`Receiver::discover_devices`](super::Receiver::discover_devices).
192    ///
193    /// This event contains device details, including its address required to
194    /// start pairing. The [`Event::DeviceDiscoveryDeviceName`] event will also
195    /// be emitted and contains the device name.
196    DeviceDiscoveryDeviceDetails {
197        /// The incrementing event counter. This can be used to map
198        /// [`Event::DeviceDiscoveryDeviceDetails`] and
199        /// [`Event::DeviceDiscoveryDeviceName`] events.
200        counter: u16,
201
202        /// Device kind reported by discovery.
203        kind: DeviceKind,
204
205        /// Wireless product ID of the discovered device.
206        wpid: u16,
207
208        /// The address of the device required to pair it using
209        /// [`Receiver::pair_device`](super::Receiver::pair_device).
210        ///
211        /// This can also be used as the unique device identifier when
212        /// collecting discovered devices.
213        address: [u8; 6],
214
215        /// The authentication type(s) the device supports. Unfortunately, there
216        /// is not much information about this value and whether it is a
217        /// single value or a bitfield.
218        authentication: u8,
219    },
220
221    /// Is emitted many times for every device discovered using
222    /// [`Receiver::discover_devices`](super::Receiver::discover_devices).
223    ///
224    /// This event only contains the device name. Device details will be
225    /// provided using the [`Event::DeviceDiscoveryDeviceDetails`] event.
226    DeviceDiscoveryDeviceName {
227        /// The incrementing event counter. This can be used to map
228        /// [`Event::DeviceDiscoveryDeviceDetails`] and
229        /// [`Event::DeviceDiscoveryDeviceName`] events.
230        counter: u16,
231
232        /// Discovered device name.
233        name: String,
234    },
235
236    /// Is emitted once the receiver requests a passkey to be entered on a
237    /// device that should be paired to it.
238    PairingPasskeyRequest {
239        /// BTLE address of the device being paired.
240        device_address: [u8; 6],
241
242        /// The passkey the user has to enter in order to pair the device.
243        ///
244        /// Depending on the device and authentication type, this value has
245        /// different implications.
246        ///
247        /// For mice, this value will be a valid 6-digit number. After parsing
248        /// this into an integer, the (least significant) bits represent
249        /// the sequence of mouse presses (`0` = left, `1` = right) the
250        /// user has to perform, with an additional press of both mouse
251        /// buttons simultaneously.
252        ///
253        /// The amount of bits significant to this equals to the `entropy`
254        /// passed to [`Receiver::pair_device`](super::Receiver::pair_device).
255        passkey: String,
256    },
257
258    /// Is emitted for every keypress a user performs while entering a pairing
259    /// passkey.
260    PairingPasskeyPressed {
261        /// BTLE address of the device being paired.
262        device_address: [u8; 6],
263
264        /// The type of the keypress the user performed.
265        ///
266        /// Every passkey sequence starts with an event where this value is set
267        /// to [`PairingPasskeyPressType::Initialization`]. Each time the user
268        /// presses a key, an event with a press type of
269        /// [`PairingPasskeyPressType::Keypress`] is emitted. Once the user
270        /// submits their passkey, this value will be
271        /// [`PairingPasskeyPressType::Submit`].
272        press_type: PairingPasskeyPressType,
273    },
274}
275
276/// Represents a device connected to a Bolt receiver.
277///
278/// This information is emitted by the [`Event::DeviceConnection`] event and can
279/// be conveniently collected using
280/// [`Receiver::collect_paired_devices`](super::Receiver::collect_paired_devices).
281#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
282#[cfg_attr(feature = "serde", derive(serde::Serialize))]
283#[non_exhaustive]
284pub struct DeviceConnection {
285    /// Slot index (1-based) of the device.
286    pub index: u8,
287
288    /// Device kind reported by the receiver.
289    pub kind: DeviceKind,
290
291    /// Whether the link is encrypted.
292    pub encrypted: bool,
293
294    /// Whether the device is currently online.
295    pub online: bool,
296
297    /// Wireless product ID of the device.
298    pub wpid: u16,
299}
300
301/// Represents the kind of a device paired to a Bolt receiver.
302#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, FromPrimitive)]
303#[cfg_attr(feature = "serde", derive(serde::Serialize))]
304#[non_exhaustive]
305#[repr(u8)]
306pub enum DeviceKind {
307    /// Unknown device kind — also the fold target for values this crate
308    /// does not model (kind is identity-only and must never drop an event).
309    #[num_enum(default)]
310    Unknown = 0x00,
311    /// Keyboard device.
312    Keyboard = 0x01,
313    /// Mouse device.
314    Mouse = 0x02,
315    /// Numeric keypad device.
316    Numpad = 0x03,
317    /// Presenter device.
318    Presenter = 0x04,
319    /// Remote-control device.
320    Remote = 0x07,
321    /// Trackball device.
322    Trackball = 0x08,
323    /// Touchpad device.
324    Touchpad = 0x09,
325    /// Tablet device.
326    Tablet = 0x0a,
327    /// Gamepad device.
328    Gamepad = 0x0b,
329    /// Joystick device.
330    Joystick = 0x0c,
331    /// Headset device.
332    Headset = 0x0d,
333}
334
335/// Represents the type of a single passkey press.
336///
337/// This is reported by the [`Event::PairingPasskeyPressed`] event, which also
338/// includes some further information about the context of these values.
339#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, FromPrimitive, IntoPrimitive)]
340#[cfg_attr(feature = "serde", derive(serde::Serialize))]
341#[non_exhaustive]
342#[repr(u8)]
343pub enum PairingPasskeyPressType {
344    /// Passkey entry has started.
345    Initialization = 0x00,
346    /// A passkey keypress was entered.
347    Keypress = 0x01,
348    /// Passkey entry was submitted.
349    Submit = 0x04,
350    /// A press type this crate does not model; carries the raw byte.
351    #[num_enum(catch_all)]
352    Other(u8),
353}
354
355#[cfg(test)]
356mod tests {
357    use super::{
358        DeviceConnection, DeviceKind, Event, PairingPasskeyPressType, decode, discovery_name,
359    };
360    use crate::{
361        protocol::v10::{Message, MessageHeader},
362        receiver::RECEIVER_DEVICE_INDEX,
363    };
364
365    /// Builds the long notification the receiver broadcasts, with `payload`
366    /// laid out exactly as the 17 bytes following the header.
367    fn notification(device_index: u8, sub_id: u8, payload: [u8; 17]) -> Message {
368        Message::Long(
369            MessageHeader {
370                device_index,
371                sub_id,
372            },
373            payload,
374        )
375    }
376
377    /// A receiver-addressed notification.
378    fn from_receiver(sub_id: u8, payload: [u8; 17]) -> Message {
379        notification(RECEIVER_DEVICE_INDEX, sub_id, payload)
380    }
381
382    #[test]
383    fn device_connection_reads_slot_from_the_header_not_the_payload() {
384        // A connection notification is the only one addressed to the device's
385        // own slot rather than to the receiver, and that header byte is the
386        // only place the slot is reported.
387        let mut payload = [0u8; 17];
388        payload[1] = 0x02; // mouse, not encrypted, online
389        payload[2] = 0x0b;
390        payload[3] = 0x40;
391
392        let event = decode(&notification(3, 0x41, payload)).unwrap();
393
394        assert_eq!(
395            event,
396            Event::DeviceConnection(DeviceConnection {
397                index: 3,
398                kind: DeviceKind::Mouse,
399                encrypted: false,
400                online: true,
401                wpid: 0x400b,
402            })
403        );
404    }
405
406    #[test]
407    fn device_connection_decodes_its_status_bits() {
408        // Bit 5 is the link encryption flag and bit 6 is *inverted*: it is set
409        // when the device is offline. Unifying uses the same layout — bit 4 is
410        // the software-present flag on both receivers.
411        let connection = |status: u8| {
412            let mut payload = [0u8; 17];
413            payload[1] = status;
414            match decode(&notification(1, 0x41, payload)) {
415                Some(Event::DeviceConnection(connection)) => connection,
416                other => panic!("expected a device connection, got {other:?}"),
417            }
418        };
419
420        let encrypted_online = connection(1 << 5);
421        assert!(encrypted_online.encrypted);
422        assert!(encrypted_online.online);
423
424        let plain_offline = connection(1 << 6);
425        assert!(!plain_offline.encrypted);
426        assert!(!plain_offline.online);
427    }
428
429    #[test]
430    fn unmodelled_device_kind_folds_to_unknown_instead_of_dropping_the_event() {
431        // Losing the event would hide the device from enumeration entirely,
432        // which is far worse than reporting an unknown kind.
433        let mut payload = [0u8; 17];
434        payload[1] = 0x0e;
435
436        let Some(Event::DeviceConnection(connection)) = decode(&notification(1, 0x41, payload))
437        else {
438            panic!("an unknown kind must still produce an event");
439        };
440        assert_eq!(connection.kind, DeviceKind::Unknown);
441    }
442
443    #[test]
444    fn discovery_details_and_name_share_a_counter() {
445        // The counter is what lets a caller join the two halves of one
446        // discovered device, so both must read it from the same little-endian
447        // pair.
448        let mut details = [0u8; 17];
449        details[0] = 0x34;
450        details[1] = 0x12;
451        details[2] = 0; // details part
452        details[4] = 0x01; // keyboard
453        details[5] = 0xcd;
454        details[6] = 0xab;
455        details[7..13].copy_from_slice(&[1, 2, 3, 4, 5, 6]);
456        details[15] = 0x20;
457
458        assert_eq!(
459            decode(&from_receiver(0x4f, details)).unwrap(),
460            Event::DeviceDiscoveryDeviceDetails {
461                counter: 0x1234,
462                kind: DeviceKind::Keyboard,
463                wpid: 0xabcd,
464                address: [1, 2, 3, 4, 5, 6],
465                authentication: 0x20,
466            }
467        );
468
469        let mut name = [0u8; 17];
470        name[0] = 0x34;
471        name[1] = 0x12;
472        name[2] = 1; // name part
473        name[3] = 4;
474        name[4..8].copy_from_slice(b"Casa");
475
476        assert_eq!(
477            decode(&from_receiver(0x4f, name)).unwrap(),
478            Event::DeviceDiscoveryDeviceName {
479                counter: 0x1234,
480                name: "Casa".to_string(),
481            }
482        );
483    }
484
485    #[test]
486    fn unmodelled_discovery_part_is_dropped() {
487        let mut payload = [0u8; 17];
488        payload[2] = 9;
489
490        assert_eq!(decode(&from_receiver(0x4f, payload)), None);
491    }
492
493    #[test]
494    fn discovery_status_is_inverted_on_the_wire() {
495        let enabled = |byte: u8| {
496            let mut payload = [0u8; 17];
497            payload[0] = byte;
498            decode(&from_receiver(0x53, payload)).unwrap()
499        };
500
501        assert_eq!(
502            enabled(0x00),
503            Event::DeviceDiscoveryStatus {
504                discovery_enabled: true
505            }
506        );
507        assert_eq!(
508            enabled(0x01),
509            Event::DeviceDiscoveryStatus {
510                discovery_enabled: false
511            }
512        );
513    }
514
515    #[test]
516    fn passkey_request_stops_at_the_nul_padding() {
517        let mut payload = [0u8; 17];
518        payload[1..5].copy_from_slice(b"1234");
519        payload[7..13].copy_from_slice(&[0xaa; 6]);
520
521        assert_eq!(
522            decode(&from_receiver(0x4d, payload)).unwrap(),
523            Event::PairingPasskeyRequest {
524                device_address: [0xaa; 6],
525                passkey: "1234".to_string(),
526            }
527        );
528    }
529
530    #[test]
531    fn passkey_request_uses_all_six_digits_when_unpadded() {
532        let mut payload = [0u8; 17];
533        payload[1..7].copy_from_slice(b"951753");
534
535        let Some(Event::PairingPasskeyRequest { passkey, .. }) =
536            decode(&from_receiver(0x4d, payload))
537        else {
538            panic!("expected a passkey request");
539        };
540        assert_eq!(passkey, "951753");
541    }
542
543    #[test]
544    fn passkey_request_with_invalid_utf8_is_dropped() {
545        let mut payload = [0u8; 17];
546        payload[1] = 0xff;
547        payload[2] = 0xfe;
548
549        assert_eq!(decode(&from_receiver(0x4d, payload)), None);
550    }
551
552    #[test]
553    fn passkey_press_carries_an_unmodelled_press_type() {
554        let mut payload = [0u8; 17];
555        payload[0] = 0x33;
556        payload[1..7].copy_from_slice(&[1, 2, 3, 4, 5, 6]);
557
558        assert_eq!(
559            decode(&from_receiver(0x4e, payload)).unwrap(),
560            Event::PairingPasskeyPressed {
561                device_address: [1, 2, 3, 4, 5, 6],
562                press_type: PairingPasskeyPressType::Other(0x33),
563            }
564        );
565    }
566
567    #[test]
568    fn notifications_addressed_elsewhere_are_dropped_except_device_connections() {
569        // A device-addressed report is only ever a connection notification;
570        // anything else at a device index belongs to that device, not to us.
571        assert_eq!(decode(&notification(2, 0x53, [0u8; 17])), None);
572        assert!(decode(&notification(2, 0x41, [0u8; 17])).is_some());
573    }
574
575    #[test]
576    fn unmodelled_sub_id_is_dropped() {
577        assert_eq!(decode(&from_receiver(0x42, [0u8; 17])), None);
578    }
579
580    #[test]
581    fn short_notifications_decode_from_the_zero_padded_payload() {
582        // The receiver may answer in either report width; a short report is
583        // widened with zeroes, which must not change the decoded event.
584        let short = Message::Short(
585            MessageHeader {
586                device_index: 4,
587                sub_id: 0x41,
588            },
589            [0x00, 0x02, 0x0b, 0x40],
590        );
591
592        assert_eq!(
593            decode(&short).unwrap(),
594            Event::DeviceConnection(DeviceConnection {
595                index: 4,
596                kind: DeviceKind::Mouse,
597                encrypted: false,
598                online: true,
599                wpid: 0x400b,
600            })
601        );
602    }
603
604    #[test]
605    fn discovery_name_with_oversized_length_is_dropped() {
606        let mut payload = [0u8; 17];
607        payload[3] = 200;
608
609        assert_eq!(discovery_name(&payload), None);
610    }
611
612    #[test]
613    fn discovery_name_within_bounds_parses() {
614        let mut payload = [0u8; 17];
615        payload[3] = 4;
616        payload[4..8].copy_from_slice(b"Casa");
617
618        assert_eq!(discovery_name(&payload), Some("Casa"));
619    }
620
621    #[test]
622    fn discovery_name_rejects_invalid_utf8() {
623        let mut payload = [0u8; 17];
624        payload[3] = 2;
625        payload[4] = 0xff;
626        payload[5] = 0xfe;
627
628        assert_eq!(discovery_name(&payload), None);
629    }
630}