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: crate::receiver::connection_encrypted(payload[0], payload[1]),
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_flags`] 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    /// [`NotificationFlags::WIRELESS`](crate::receiver::NotificationFlags::WIRELESS)
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 a_bolt_protocol_byte_means_an_encrypted_link() {
431        // Byte 3 of the notification is the link protocol; 0x10 is Bolt, which
432        // is encrypted even with bit 5 clear (Solaar: `address == 0x10`).
433        let connection = |protocol: u8, status: u8| {
434            let mut payload = [0u8; 17];
435            payload[0] = protocol;
436            payload[1] = status;
437            match decode(&notification(1, 0x41, payload)) {
438                Some(Event::DeviceConnection(connection)) => connection,
439                other => panic!("expected a device connection, got {other:?}"),
440            }
441        };
442        assert!(connection(0x10, 0x02).encrypted);
443        assert!(connection(0x10, 0x02).online);
444        let offline = connection(0x10, (1 << 6) | 0x02);
445        assert!(offline.encrypted);
446        assert!(!offline.online);
447        // Another protocol byte leaves bit 5 as the only source.
448        assert!(!connection(0x04, 0x02).encrypted);
449        assert!(connection(0x04, (1 << 5) | 0x02).encrypted);
450    }
451
452    #[test]
453    fn unmodelled_device_kind_folds_to_unknown_instead_of_dropping_the_event() {
454        // Losing the event would hide the device from enumeration entirely,
455        // which is far worse than reporting an unknown kind.
456        let mut payload = [0u8; 17];
457        payload[1] = 0x0e;
458
459        let Some(Event::DeviceConnection(connection)) = decode(&notification(1, 0x41, payload))
460        else {
461            panic!("an unknown kind must still produce an event");
462        };
463        assert_eq!(connection.kind, DeviceKind::Unknown);
464    }
465
466    #[test]
467    fn discovery_details_and_name_share_a_counter() {
468        // The counter is what lets a caller join the two halves of one
469        // discovered device, so both must read it from the same little-endian
470        // pair.
471        let mut details = [0u8; 17];
472        details[0] = 0x34;
473        details[1] = 0x12;
474        details[2] = 0; // details part
475        details[4] = 0x01; // keyboard
476        details[5] = 0xcd;
477        details[6] = 0xab;
478        details[7..13].copy_from_slice(&[1, 2, 3, 4, 5, 6]);
479        details[15] = 0x20;
480
481        assert_eq!(
482            decode(&from_receiver(0x4f, details)).unwrap(),
483            Event::DeviceDiscoveryDeviceDetails {
484                counter: 0x1234,
485                kind: DeviceKind::Keyboard,
486                wpid: 0xabcd,
487                address: [1, 2, 3, 4, 5, 6],
488                authentication: 0x20,
489            }
490        );
491
492        let mut name = [0u8; 17];
493        name[0] = 0x34;
494        name[1] = 0x12;
495        name[2] = 1; // name part
496        name[3] = 4;
497        name[4..8].copy_from_slice(b"Casa");
498
499        assert_eq!(
500            decode(&from_receiver(0x4f, name)).unwrap(),
501            Event::DeviceDiscoveryDeviceName {
502                counter: 0x1234,
503                name: "Casa".to_string(),
504            }
505        );
506    }
507
508    #[test]
509    fn unmodelled_discovery_part_is_dropped() {
510        let mut payload = [0u8; 17];
511        payload[2] = 9;
512
513        assert_eq!(decode(&from_receiver(0x4f, payload)), None);
514    }
515
516    #[test]
517    fn discovery_status_is_inverted_on_the_wire() {
518        let enabled = |byte: u8| {
519            let mut payload = [0u8; 17];
520            payload[0] = byte;
521            decode(&from_receiver(0x53, payload)).unwrap()
522        };
523
524        assert_eq!(
525            enabled(0x00),
526            Event::DeviceDiscoveryStatus {
527                discovery_enabled: true
528            }
529        );
530        assert_eq!(
531            enabled(0x01),
532            Event::DeviceDiscoveryStatus {
533                discovery_enabled: false
534            }
535        );
536    }
537
538    #[test]
539    fn passkey_request_stops_at_the_nul_padding() {
540        let mut payload = [0u8; 17];
541        payload[1..5].copy_from_slice(b"1234");
542        payload[7..13].copy_from_slice(&[0xaa; 6]);
543
544        assert_eq!(
545            decode(&from_receiver(0x4d, payload)).unwrap(),
546            Event::PairingPasskeyRequest {
547                device_address: [0xaa; 6],
548                passkey: "1234".to_string(),
549            }
550        );
551    }
552
553    #[test]
554    fn passkey_request_uses_all_six_digits_when_unpadded() {
555        let mut payload = [0u8; 17];
556        payload[1..7].copy_from_slice(b"951753");
557
558        let Some(Event::PairingPasskeyRequest { passkey, .. }) =
559            decode(&from_receiver(0x4d, payload))
560        else {
561            panic!("expected a passkey request");
562        };
563        assert_eq!(passkey, "951753");
564    }
565
566    #[test]
567    fn passkey_request_with_invalid_utf8_is_dropped() {
568        let mut payload = [0u8; 17];
569        payload[1] = 0xff;
570        payload[2] = 0xfe;
571
572        assert_eq!(decode(&from_receiver(0x4d, payload)), None);
573    }
574
575    #[test]
576    fn passkey_press_carries_an_unmodelled_press_type() {
577        let mut payload = [0u8; 17];
578        payload[0] = 0x33;
579        payload[1..7].copy_from_slice(&[1, 2, 3, 4, 5, 6]);
580
581        assert_eq!(
582            decode(&from_receiver(0x4e, payload)).unwrap(),
583            Event::PairingPasskeyPressed {
584                device_address: [1, 2, 3, 4, 5, 6],
585                press_type: PairingPasskeyPressType::Other(0x33),
586            }
587        );
588    }
589
590    #[test]
591    fn notifications_addressed_elsewhere_are_dropped_except_device_connections() {
592        // A device-addressed report is only ever a connection notification;
593        // anything else at a device index belongs to that device, not to us.
594        assert_eq!(decode(&notification(2, 0x53, [0u8; 17])), None);
595        assert!(decode(&notification(2, 0x41, [0u8; 17])).is_some());
596    }
597
598    #[test]
599    fn unmodelled_sub_id_is_dropped() {
600        assert_eq!(decode(&from_receiver(0x42, [0u8; 17])), None);
601    }
602
603    #[test]
604    fn short_notifications_decode_from_the_zero_padded_payload() {
605        // The receiver may answer in either report width; a short report is
606        // widened with zeroes, which must not change the decoded event.
607        let short = Message::Short(
608            MessageHeader {
609                device_index: 4,
610                sub_id: 0x41,
611            },
612            [0x00, 0x02, 0x0b, 0x40],
613        );
614
615        assert_eq!(
616            decode(&short).unwrap(),
617            Event::DeviceConnection(DeviceConnection {
618                index: 4,
619                kind: DeviceKind::Mouse,
620                encrypted: false,
621                online: true,
622                wpid: 0x400b,
623            })
624        );
625    }
626
627    #[test]
628    fn discovery_name_with_oversized_length_is_dropped() {
629        let mut payload = [0u8; 17];
630        payload[3] = 200;
631
632        assert_eq!(discovery_name(&payload), None);
633    }
634
635    #[test]
636    fn discovery_name_within_bounds_parses() {
637        let mut payload = [0u8; 17];
638        payload[3] = 4;
639        payload[4..8].copy_from_slice(b"Casa");
640
641        assert_eq!(discovery_name(&payload), Some("Casa"));
642    }
643
644    #[test]
645    fn discovery_name_rejects_invalid_utf8() {
646        let mut payload = [0u8; 17];
647        payload[3] = 2;
648        payload[4] = 0xff;
649        payload[5] = 0xfe;
650
651        assert_eq!(discovery_name(&payload), None);
652    }
653}