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