use num_enum::{FromPrimitive, IntoPrimitive, TryFromPrimitive};
use crate::{protocol::v10, receiver::RECEIVER_DEVICE_INDEX};
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)]
#[repr(u8)]
enum Notification {
DeviceConnection = 0x41,
PairingPasskeyRequest = 0x4d,
PairingPasskeyPressed = 0x4e,
DeviceDiscovery = 0x4f,
DeviceDiscoveryStatus = 0x53,
PairingStatus = 0x54,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)]
#[repr(u8)]
enum DiscoveryPart {
Details = 0,
Name = 1,
}
pub(super) fn decode(msg: &v10::Message) -> Option<Event> {
let header = msg.header();
let payload = msg.extend_payload();
let notification = Notification::try_from(header.sub_id).ok()?;
if notification != Notification::DeviceConnection
&& header.device_index != RECEIVER_DEVICE_INDEX
{
return None;
}
match notification {
Notification::DeviceConnection => Some(Event::DeviceConnection(DeviceConnection {
index: header.device_index,
kind: DeviceKind::from(payload[1] & 0x0f),
encrypted: payload[1] & (1 << 5) != 0,
online: payload[1] & (1 << 6) == 0,
wpid: u16::from_le_bytes([payload[2], payload[3]]),
})),
Notification::DeviceDiscovery => match DiscoveryPart::try_from(payload[2]).ok()? {
DiscoveryPart::Details => Some(Event::DeviceDiscoveryDeviceDetails {
counter: discovery_counter(&payload),
kind: DeviceKind::from(payload[4] & 0x0f),
wpid: u16::from_le_bytes([payload[5], payload[6]]),
address: address6(&payload, 7),
authentication: payload[15],
}),
DiscoveryPart::Name => {
let name = discovery_name(&payload)?;
Some(Event::DeviceDiscoveryDeviceName {
counter: discovery_counter(&payload),
name: name.to_string(),
})
}
},
Notification::DeviceDiscoveryStatus => Some(Event::DeviceDiscoveryStatus {
discovery_enabled: payload[0] == 0x00,
}),
Notification::PairingStatus => Some(Event::PairingStatus {
device_address: address6(&payload, 2),
pairing_error: (payload[1] != 0x00).then(|| PairingError::from(payload[1])),
slot: (payload[8] != 0x00).then_some(payload[8]),
}),
Notification::PairingPasskeyRequest => Some(Event::PairingPasskeyRequest {
device_address: address6(&payload, 7),
passkey: passkey(&payload)?.to_string(),
}),
Notification::PairingPasskeyPressed => Some(Event::PairingPasskeyPressed {
device_address: address6(&payload, 1),
press_type: PairingPasskeyPressType::from(payload[0]),
}),
}
}
fn discovery_counter(payload: &[u8; 17]) -> u16 {
u16::from_le_bytes([payload[0], payload[1]])
}
fn address6(payload: &[u8; 17], start: usize) -> [u8; 6] {
[
payload[start],
payload[start + 1],
payload[start + 2],
payload[start + 3],
payload[start + 4],
payload[start + 5],
]
}
fn discovery_name(payload: &[u8; 17]) -> Option<&str> {
let end = 4usize.checked_add(usize::from(payload[3]))?;
str::from_utf8(payload.get(4..end)?).ok()
}
fn passkey(payload: &[u8; 17]) -> Option<&str> {
let digits = &payload[1..=6];
let len = digits.iter().position(|&b| b == 0).unwrap_or(digits.len());
str::from_utf8(&digits[..len]).ok()
}
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum Event {
DeviceConnection(DeviceConnection),
DeviceDiscoveryStatus {
discovery_enabled: bool,
},
DeviceDiscoveryDeviceDetails {
counter: u16,
kind: DeviceKind,
wpid: u16,
address: [u8; 6],
authentication: u8,
},
DeviceDiscoveryDeviceName {
counter: u16,
name: String,
},
PairingStatus {
device_address: [u8; 6],
pairing_error: Option<PairingError>,
slot: Option<u8>,
},
PairingPasskeyRequest {
device_address: [u8; 6],
passkey: String,
},
PairingPasskeyPressed {
device_address: [u8; 6],
press_type: PairingPasskeyPressType,
},
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct DeviceConnection {
pub index: u8,
pub kind: DeviceKind,
pub encrypted: bool,
pub online: bool,
pub wpid: u16,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, FromPrimitive)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
#[repr(u8)]
pub enum DeviceKind {
#[num_enum(default)]
Unknown = 0x00,
Keyboard = 0x01,
Mouse = 0x02,
Numpad = 0x03,
Presenter = 0x04,
Remote = 0x07,
Trackball = 0x08,
Touchpad = 0x09,
Tablet = 0x0a,
Gamepad = 0x0b,
Joystick = 0x0c,
Headset = 0x0d,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, FromPrimitive, IntoPrimitive)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
#[repr(u8)]
pub enum PairingError {
DeviceTimeout = 0x01,
Failed = 0x02,
#[num_enum(catch_all)]
Other(u8),
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, FromPrimitive, IntoPrimitive)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
#[repr(u8)]
pub enum PairingPasskeyPressType {
Initialization = 0x00,
Keypress = 0x01,
Submit = 0x04,
#[num_enum(catch_all)]
Other(u8),
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "expect/unwrap are idiomatic in tests"
)]
mod tests {
use super::{
DeviceConnection, DeviceKind, Event, PairingError, PairingPasskeyPressType, decode,
discovery_name,
};
use crate::{
protocol::v10::{Message, MessageHeader},
receiver::RECEIVER_DEVICE_INDEX,
};
fn notification(device_index: u8, sub_id: u8, payload: [u8; 17]) -> Message {
Message::Long(
MessageHeader {
device_index,
sub_id,
},
payload,
)
}
fn from_receiver(sub_id: u8, payload: [u8; 17]) -> Message {
notification(RECEIVER_DEVICE_INDEX, sub_id, payload)
}
#[test]
fn device_connection_reads_slot_from_the_header_not_the_payload() {
let mut payload = [0u8; 17];
payload[1] = 0x02; payload[2] = 0x0b;
payload[3] = 0x40;
let event = decode(¬ification(3, 0x41, payload)).unwrap();
assert_eq!(
event,
Event::DeviceConnection(DeviceConnection {
index: 3,
kind: DeviceKind::Mouse,
encrypted: false,
online: true,
wpid: 0x400b,
})
);
}
#[test]
fn device_connection_decodes_its_status_bits() {
let connection = |status: u8| {
let mut payload = [0u8; 17];
payload[1] = status;
match decode(¬ification(1, 0x41, payload)) {
Some(Event::DeviceConnection(connection)) => connection,
other => panic!("expected a device connection, got {other:?}"),
}
};
let encrypted_online = connection(1 << 5);
assert!(encrypted_online.encrypted);
assert!(encrypted_online.online);
let plain_offline = connection(1 << 6);
assert!(!plain_offline.encrypted);
assert!(!plain_offline.online);
}
#[test]
fn unmodelled_device_kind_folds_to_unknown_instead_of_dropping_the_event() {
let mut payload = [0u8; 17];
payload[1] = 0x0e;
let Some(Event::DeviceConnection(connection)) = decode(¬ification(1, 0x41, payload))
else {
panic!("an unknown kind must still produce an event");
};
assert_eq!(connection.kind, DeviceKind::Unknown);
}
#[test]
fn discovery_details_and_name_share_a_counter() {
let mut details = [0u8; 17];
details[0] = 0x34;
details[1] = 0x12;
details[2] = 0; details[4] = 0x01; details[5] = 0xcd;
details[6] = 0xab;
details[7..13].copy_from_slice(&[1, 2, 3, 4, 5, 6]);
details[15] = 0x20;
assert_eq!(
decode(&from_receiver(0x4f, details)).unwrap(),
Event::DeviceDiscoveryDeviceDetails {
counter: 0x1234,
kind: DeviceKind::Keyboard,
wpid: 0xabcd,
address: [1, 2, 3, 4, 5, 6],
authentication: 0x20,
}
);
let mut name = [0u8; 17];
name[0] = 0x34;
name[1] = 0x12;
name[2] = 1; name[3] = 4;
name[4..8].copy_from_slice(b"Casa");
assert_eq!(
decode(&from_receiver(0x4f, name)).unwrap(),
Event::DeviceDiscoveryDeviceName {
counter: 0x1234,
name: "Casa".to_string(),
}
);
}
#[test]
fn unmodelled_discovery_part_is_dropped() {
let mut payload = [0u8; 17];
payload[2] = 9;
assert_eq!(decode(&from_receiver(0x4f, payload)), None);
}
#[test]
fn discovery_status_is_inverted_on_the_wire() {
let enabled = |byte: u8| {
let mut payload = [0u8; 17];
payload[0] = byte;
decode(&from_receiver(0x53, payload)).unwrap()
};
assert_eq!(
enabled(0x00),
Event::DeviceDiscoveryStatus {
discovery_enabled: true
}
);
assert_eq!(
enabled(0x01),
Event::DeviceDiscoveryStatus {
discovery_enabled: false
}
);
}
#[test]
fn pairing_status_carries_an_unmodelled_error_code_rather_than_dropping_it() {
let mut payload = [0u8; 17];
payload[1] = 0x7f;
payload[2..8].copy_from_slice(&[9, 8, 7, 6, 5, 4]);
payload[8] = 2;
assert_eq!(
decode(&from_receiver(0x54, payload)).unwrap(),
Event::PairingStatus {
device_address: [9, 8, 7, 6, 5, 4],
pairing_error: Some(PairingError::Other(0x7f)),
slot: Some(2),
}
);
}
#[test]
fn pairing_status_reports_success_as_no_error_and_slot_zero_as_none() {
let payload = [0u8; 17];
assert_eq!(
decode(&from_receiver(0x54, payload)).unwrap(),
Event::PairingStatus {
device_address: [0; 6],
pairing_error: None,
slot: None,
}
);
}
#[test]
fn passkey_request_stops_at_the_nul_padding() {
let mut payload = [0u8; 17];
payload[1..5].copy_from_slice(b"1234");
payload[7..13].copy_from_slice(&[0xaa; 6]);
assert_eq!(
decode(&from_receiver(0x4d, payload)).unwrap(),
Event::PairingPasskeyRequest {
device_address: [0xaa; 6],
passkey: "1234".to_string(),
}
);
}
#[test]
fn passkey_request_uses_all_six_digits_when_unpadded() {
let mut payload = [0u8; 17];
payload[1..7].copy_from_slice(b"951753");
let Some(Event::PairingPasskeyRequest { passkey, .. }) =
decode(&from_receiver(0x4d, payload))
else {
panic!("expected a passkey request");
};
assert_eq!(passkey, "951753");
}
#[test]
fn passkey_request_with_invalid_utf8_is_dropped() {
let mut payload = [0u8; 17];
payload[1] = 0xff;
payload[2] = 0xfe;
assert_eq!(decode(&from_receiver(0x4d, payload)), None);
}
#[test]
fn passkey_press_carries_an_unmodelled_press_type() {
let mut payload = [0u8; 17];
payload[0] = 0x33;
payload[1..7].copy_from_slice(&[1, 2, 3, 4, 5, 6]);
assert_eq!(
decode(&from_receiver(0x4e, payload)).unwrap(),
Event::PairingPasskeyPressed {
device_address: [1, 2, 3, 4, 5, 6],
press_type: PairingPasskeyPressType::Other(0x33),
}
);
}
#[test]
fn notifications_addressed_elsewhere_are_dropped_except_device_connections() {
assert_eq!(decode(¬ification(2, 0x53, [0u8; 17])), None);
assert!(decode(¬ification(2, 0x41, [0u8; 17])).is_some());
}
#[test]
fn unmodelled_sub_id_is_dropped() {
assert_eq!(decode(&from_receiver(0x42, [0u8; 17])), None);
}
#[test]
fn short_notifications_decode_from_the_zero_padded_payload() {
let short = Message::Short(
MessageHeader {
device_index: 4,
sub_id: 0x41,
},
[0x00, 0x02, 0x0b, 0x40],
);
assert_eq!(
decode(&short).unwrap(),
Event::DeviceConnection(DeviceConnection {
index: 4,
kind: DeviceKind::Mouse,
encrypted: false,
online: true,
wpid: 0x400b,
})
);
}
#[test]
fn discovery_name_with_oversized_length_is_dropped() {
let mut payload = [0u8; 17];
payload[3] = 200;
assert_eq!(discovery_name(&payload), None);
}
#[test]
fn discovery_name_within_bounds_parses() {
let mut payload = [0u8; 17];
payload[3] = 4;
payload[4..8].copy_from_slice(b"Casa");
assert_eq!(discovery_name(&payload), Some("Casa"));
}
#[test]
fn discovery_name_rejects_invalid_utf8() {
let mut payload = [0u8; 17];
payload[3] = 2;
payload[4] = 0xff;
payload[5] = 0xfe;
assert_eq!(discovery_name(&payload), None);
}
}