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