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    /// 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)]
397#[allow(
398    clippy::unwrap_used,
399    clippy::expect_used,
400    reason = "expect/unwrap are idiomatic in tests"
401)]
402mod tests {
403    use super::{
404        DeviceConnection, DeviceKind, Event, PairingError, PairingPasskeyPressType, decode,
405        discovery_name,
406    };
407    use crate::{
408        protocol::v10::{Message, MessageHeader},
409        receiver::RECEIVER_DEVICE_INDEX,
410    };
411
412    /// Builds the long notification the receiver broadcasts, with `payload`
413    /// laid out exactly as the 17 bytes following the header.
414    fn notification(device_index: u8, sub_id: u8, payload: [u8; 17]) -> Message {
415        Message::Long(
416            MessageHeader {
417                device_index,
418                sub_id,
419            },
420            payload,
421        )
422    }
423
424    /// A receiver-addressed notification.
425    fn from_receiver(sub_id: u8, payload: [u8; 17]) -> Message {
426        notification(RECEIVER_DEVICE_INDEX, sub_id, payload)
427    }
428
429    #[test]
430    fn device_connection_reads_slot_from_the_header_not_the_payload() {
431        // A connection notification is the only one addressed to the device's
432        // own slot rather than to the receiver, and that header byte is the
433        // only place the slot is reported.
434        let mut payload = [0u8; 17];
435        payload[1] = 0x02; // mouse, not encrypted, online
436        payload[2] = 0x0b;
437        payload[3] = 0x40;
438
439        let event = decode(&notification(3, 0x41, payload)).unwrap();
440
441        assert_eq!(
442            event,
443            Event::DeviceConnection(DeviceConnection {
444                index: 3,
445                kind: DeviceKind::Mouse,
446                encrypted: false,
447                online: true,
448                wpid: 0x400b,
449            })
450        );
451    }
452
453    #[test]
454    fn device_connection_decodes_its_status_bits() {
455        // Bit 5 is the link encryption flag and bit 6 is *inverted*: it is set
456        // when the device is offline. Bolt puts encryption on a different bit
457        // than Unifying does (bit 4), so this is not a shared layout.
458        let connection = |status: u8| {
459            let mut payload = [0u8; 17];
460            payload[1] = status;
461            match decode(&notification(1, 0x41, payload)) {
462                Some(Event::DeviceConnection(connection)) => connection,
463                other => panic!("expected a device connection, got {other:?}"),
464            }
465        };
466
467        let encrypted_online = connection(1 << 5);
468        assert!(encrypted_online.encrypted);
469        assert!(encrypted_online.online);
470
471        let plain_offline = connection(1 << 6);
472        assert!(!plain_offline.encrypted);
473        assert!(!plain_offline.online);
474    }
475
476    #[test]
477    fn unmodelled_device_kind_folds_to_unknown_instead_of_dropping_the_event() {
478        // Losing the event would hide the device from enumeration entirely,
479        // which is far worse than reporting an unknown kind.
480        let mut payload = [0u8; 17];
481        payload[1] = 0x0e;
482
483        let Some(Event::DeviceConnection(connection)) = decode(&notification(1, 0x41, payload))
484        else {
485            panic!("an unknown kind must still produce an event");
486        };
487        assert_eq!(connection.kind, DeviceKind::Unknown);
488    }
489
490    #[test]
491    fn discovery_details_and_name_share_a_counter() {
492        // The counter is what lets a caller join the two halves of one
493        // discovered device, so both must read it from the same little-endian
494        // pair.
495        let mut details = [0u8; 17];
496        details[0] = 0x34;
497        details[1] = 0x12;
498        details[2] = 0; // details part
499        details[4] = 0x01; // keyboard
500        details[5] = 0xcd;
501        details[6] = 0xab;
502        details[7..13].copy_from_slice(&[1, 2, 3, 4, 5, 6]);
503        details[15] = 0x20;
504
505        assert_eq!(
506            decode(&from_receiver(0x4f, details)).unwrap(),
507            Event::DeviceDiscoveryDeviceDetails {
508                counter: 0x1234,
509                kind: DeviceKind::Keyboard,
510                wpid: 0xabcd,
511                address: [1, 2, 3, 4, 5, 6],
512                authentication: 0x20,
513            }
514        );
515
516        let mut name = [0u8; 17];
517        name[0] = 0x34;
518        name[1] = 0x12;
519        name[2] = 1; // name part
520        name[3] = 4;
521        name[4..8].copy_from_slice(b"Casa");
522
523        assert_eq!(
524            decode(&from_receiver(0x4f, name)).unwrap(),
525            Event::DeviceDiscoveryDeviceName {
526                counter: 0x1234,
527                name: "Casa".to_string(),
528            }
529        );
530    }
531
532    #[test]
533    fn unmodelled_discovery_part_is_dropped() {
534        let mut payload = [0u8; 17];
535        payload[2] = 9;
536
537        assert_eq!(decode(&from_receiver(0x4f, payload)), None);
538    }
539
540    #[test]
541    fn discovery_status_is_inverted_on_the_wire() {
542        let enabled = |byte: u8| {
543            let mut payload = [0u8; 17];
544            payload[0] = byte;
545            decode(&from_receiver(0x53, payload)).unwrap()
546        };
547
548        assert_eq!(
549            enabled(0x00),
550            Event::DeviceDiscoveryStatus {
551                discovery_enabled: true
552            }
553        );
554        assert_eq!(
555            enabled(0x01),
556            Event::DeviceDiscoveryStatus {
557                discovery_enabled: false
558            }
559        );
560    }
561
562    #[test]
563    fn pairing_status_carries_an_unmodelled_error_code_rather_than_dropping_it() {
564        // Dropping an unrecognised code would turn a reported failure into a
565        // silent session timeout.
566        let mut payload = [0u8; 17];
567        payload[1] = 0x7f;
568        payload[2..8].copy_from_slice(&[9, 8, 7, 6, 5, 4]);
569        payload[8] = 2;
570
571        assert_eq!(
572            decode(&from_receiver(0x54, payload)).unwrap(),
573            Event::PairingStatus {
574                device_address: [9, 8, 7, 6, 5, 4],
575                pairing_error: Some(PairingError::Other(0x7f)),
576                slot: Some(2),
577            }
578        );
579    }
580
581    #[test]
582    fn pairing_status_reports_success_as_no_error_and_slot_zero_as_none() {
583        let payload = [0u8; 17];
584
585        assert_eq!(
586            decode(&from_receiver(0x54, payload)).unwrap(),
587            Event::PairingStatus {
588                device_address: [0; 6],
589                pairing_error: None,
590                slot: None,
591            }
592        );
593    }
594
595    #[test]
596    fn passkey_request_stops_at_the_nul_padding() {
597        let mut payload = [0u8; 17];
598        payload[1..5].copy_from_slice(b"1234");
599        payload[7..13].copy_from_slice(&[0xaa; 6]);
600
601        assert_eq!(
602            decode(&from_receiver(0x4d, payload)).unwrap(),
603            Event::PairingPasskeyRequest {
604                device_address: [0xaa; 6],
605                passkey: "1234".to_string(),
606            }
607        );
608    }
609
610    #[test]
611    fn passkey_request_uses_all_six_digits_when_unpadded() {
612        let mut payload = [0u8; 17];
613        payload[1..7].copy_from_slice(b"951753");
614
615        let Some(Event::PairingPasskeyRequest { passkey, .. }) =
616            decode(&from_receiver(0x4d, payload))
617        else {
618            panic!("expected a passkey request");
619        };
620        assert_eq!(passkey, "951753");
621    }
622
623    #[test]
624    fn passkey_request_with_invalid_utf8_is_dropped() {
625        let mut payload = [0u8; 17];
626        payload[1] = 0xff;
627        payload[2] = 0xfe;
628
629        assert_eq!(decode(&from_receiver(0x4d, payload)), None);
630    }
631
632    #[test]
633    fn passkey_press_carries_an_unmodelled_press_type() {
634        let mut payload = [0u8; 17];
635        payload[0] = 0x33;
636        payload[1..7].copy_from_slice(&[1, 2, 3, 4, 5, 6]);
637
638        assert_eq!(
639            decode(&from_receiver(0x4e, payload)).unwrap(),
640            Event::PairingPasskeyPressed {
641                device_address: [1, 2, 3, 4, 5, 6],
642                press_type: PairingPasskeyPressType::Other(0x33),
643            }
644        );
645    }
646
647    #[test]
648    fn notifications_addressed_elsewhere_are_dropped_except_device_connections() {
649        // A device-addressed report is only ever a connection notification;
650        // anything else at a device index belongs to that device, not to us.
651        assert_eq!(decode(&notification(2, 0x53, [0u8; 17])), None);
652        assert!(decode(&notification(2, 0x41, [0u8; 17])).is_some());
653    }
654
655    #[test]
656    fn unmodelled_sub_id_is_dropped() {
657        assert_eq!(decode(&from_receiver(0x42, [0u8; 17])), None);
658    }
659
660    #[test]
661    fn short_notifications_decode_from_the_zero_padded_payload() {
662        // The receiver may answer in either report width; a short report is
663        // widened with zeroes, which must not change the decoded event.
664        let short = Message::Short(
665            MessageHeader {
666                device_index: 4,
667                sub_id: 0x41,
668            },
669            [0x00, 0x02, 0x0b, 0x40],
670        );
671
672        assert_eq!(
673            decode(&short).unwrap(),
674            Event::DeviceConnection(DeviceConnection {
675                index: 4,
676                kind: DeviceKind::Mouse,
677                encrypted: false,
678                online: true,
679                wpid: 0x400b,
680            })
681        );
682    }
683
684    #[test]
685    fn discovery_name_with_oversized_length_is_dropped() {
686        let mut payload = [0u8; 17];
687        payload[3] = 200;
688
689        assert_eq!(discovery_name(&payload), None);
690    }
691
692    #[test]
693    fn discovery_name_within_bounds_parses() {
694        let mut payload = [0u8; 17];
695        payload[3] = 4;
696        payload[4..8].copy_from_slice(b"Casa");
697
698        assert_eq!(discovery_name(&payload), Some("Casa"));
699    }
700
701    #[test]
702    fn discovery_name_rejects_invalid_utf8() {
703        let mut payload = [0u8; 17];
704        payload[3] = 2;
705        payload[4] = 0xff;
706        payload[5] = 0xfe;
707
708        assert_eq!(discovery_name(&payload), None);
709    }
710}