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(¬ification(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(¬ification(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(¬ification(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(¬ification(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(¬ification(2, 0x53, [0u8; 17])), None);
595 assert!(decode(¬ification(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}