use std::{collections::HashMap, sync::Arc};
use hidpp::{
channel::{HidppChannel, HidppMessage},
receiver::{self, Receiver},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::mpsc;
use tracing::{debug, trace, warn};
pub use hidpp::receiver::bolt::DeviceKind as BoltDeviceKind;
use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel};
const RECEIVER_INDEX: u8 = 0xff;
mod reg {
pub const NOTIFICATIONS: u8 = 0x00;
pub const UNIFYING_PAIRING: u8 = 0xb2;
pub const BOLT_DISCOVERY: u8 = 0xc0;
pub const BOLT_PAIRING: u8 = 0xc1;
}
mod notif {
pub const DEVICE_CONNECTION: u8 = 0x41;
pub const UNIFYING_LOCK: u8 = 0x4a;
pub const PASSKEY_REQUEST: u8 = 0x4d;
pub const DEVICE_DISCOVERY: u8 = 0x4f;
pub const DISCOVERY_STATUS: u8 = 0x53;
pub const PAIRING_STATUS: u8 = 0x54;
}
const NOTIF_FLAGS: [u8; 3] = [0x00, 0x09, 0x00];
const BOLT_PRODUCT_IDS: &[u16] = &[0xc548];
const UNIFYING_PRODUCT_IDS: &[u16] = &[0xc52b, 0xc532];
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ReceiverFamily {
Bolt,
Unifying,
}
fn family_for(product_id: u16) -> Option<ReceiverFamily> {
if BOLT_PRODUCT_IDS.contains(&product_id) {
Some(ReceiverFamily::Bolt)
} else if UNIFYING_PRODUCT_IDS.contains(&product_id) {
Some(ReceiverFamily::Unifying)
} else {
None
}
}
#[derive(Clone, Debug)]
pub struct PairingReceiver {
pub uid: Option<String>,
pub family: ReceiverFamily,
pub product_id: u16,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum ReceiverSelector {
First,
BoltUid(String),
}
#[derive(Clone, Debug)]
pub struct DiscoveredDevice {
pub address: [u8; 6],
pub authentication: u8,
pub kind: BoltDeviceKind,
pub name: String,
}
impl DiscoveredDevice {
#[must_use]
pub fn passkey_on_keyboard(&self) -> bool {
self.authentication & 0x01 != 0
}
fn entropy(&self) -> u8 {
if self.kind == BoltDeviceKind::Keyboard {
20
} else {
10
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum Click {
Left,
Right,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum PasskeyMethod {
Keyboard(String),
Pointer { passkey: String, clicks: Vec<Click> },
}
fn passkey_to_clicks(passkey: &str) -> Vec<Click> {
let value: u32 = passkey.trim().parse().unwrap_or(0);
(0..10)
.rev()
.map(|bit| {
if value & (1 << bit) != 0 {
Click::Right
} else {
Click::Left
}
})
.collect()
}
#[derive(Clone, Debug)]
pub enum PairingEvent {
Searching,
DeviceFound(DiscoveredDevice),
Passkey(PasskeyMethod),
Paired { slot: u8 },
Failed(PairingError),
}
#[derive(Clone, Debug)]
pub enum PairingCommand {
Pair(DiscoveredDevice),
Cancel,
}
#[derive(Clone, Debug, Error)]
pub enum PairingError {
#[error("HID transport error: {0}")]
Hid(String),
#[error("no supported pairing-capable receiver found")]
ReceiverNotFound,
#[error("receiver register access failed: {0}")]
Register(String),
#[error("pairing timed out")]
Timeout,
#[error("receiver reported pairing error {0:#04x}")]
Device(u8),
#[error("pairing was cancelled")]
Cancelled,
}
impl From<async_hid::HidError> for PairingError {
fn from(e: async_hid::HidError) -> Self {
PairingError::Hid(e.to_string())
}
}
pub async fn list_pairing_receivers() -> Result<Vec<PairingReceiver>, PairingError> {
let mut out = Vec::new();
for dev in enumerate_hidpp_devices().await? {
let Some((_, channel)) = open_hidpp_channel(dev).await? else {
continue;
};
let Some(family) = family_for(channel.product_id) else {
continue;
};
let uid = match family {
ReceiverFamily::Bolt => read_bolt_uid(&channel).await,
ReceiverFamily::Unifying => None,
};
out.push(PairingReceiver {
uid,
family,
product_id: channel.product_id,
});
}
Ok(out)
}
async fn read_bolt_uid(channel: &Arc<HidppChannel>) -> Option<String> {
let Some(Receiver::Bolt(bolt)) = receiver::detect(Arc::clone(channel)) else {
return None;
};
bolt.get_unique_id().await.ok()
}
async fn open_receiver(
target: &ReceiverSelector,
) -> Result<(Arc<HidppChannel>, ReceiverFamily), PairingError> {
for dev in enumerate_hidpp_devices().await? {
let Some((_, channel)) = open_hidpp_channel(dev).await? else {
continue;
};
let Some(family) = family_for(channel.product_id) else {
continue;
};
match target {
ReceiverSelector::First => return Ok((channel, family)),
ReceiverSelector::BoltUid(want) => {
if family == ReceiverFamily::Bolt
&& read_bolt_uid(&channel)
.await
.is_some_and(|uid| uid.eq_ignore_ascii_case(want))
{
return Ok((channel, family));
}
}
}
}
Err(PairingError::ReceiverNotFound)
}
fn decode(msg: &HidppMessage) -> (u8, u8, [u8; 17]) {
let mut payload = [0u8; 17];
match msg {
HidppMessage::Short(d) => {
payload[..4].copy_from_slice(&d[2..6]);
(d[0], d[1], payload)
}
HidppMessage::Long(d) => {
payload.copy_from_slice(&d[2..19]);
(d[0], d[1], payload)
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum Notification {
DiscoveryInfo {
counter: u16,
kind: u8,
address: [u8; 6],
authentication: u8,
},
DiscoveryName { counter: u16, name: String },
PairingSucceeded { slot: u8 },
PairingError(u8),
Passkey(String),
Connected { slot: u8, established: bool },
UnifyingLock { open: bool, error: u8 },
}
fn parse_notification(sub_id: u8, device_index: u8, p: [u8; 17]) -> Option<Notification> {
match sub_id {
notif::DEVICE_CONNECTION => Some(Notification::Connected {
slot: device_index,
established: p[1] & (1 << 6) == 0,
}),
notif::DEVICE_DISCOVERY => {
let counter = u16::from(p[0]) + u16::from(p[1]) * 256;
match p[2] {
0 => {
let mut address = [0u8; 6];
address.copy_from_slice(&p[7..13]);
Some(Notification::DiscoveryInfo {
counter,
kind: p[4],
address,
authentication: p[15],
})
}
1 => {
let len = usize::from(p[3]).min(p.len() - 4);
let name = String::from_utf8_lossy(&p[4..4 + len]).into_owned();
Some(Notification::DiscoveryName { counter, name })
}
_ => None,
}
}
notif::DISCOVERY_STATUS => {
let error = p[1];
if error != 0 {
Some(Notification::PairingError(error))
} else {
None
}
}
notif::PAIRING_STATUS => {
let error = p[1];
if error != 0 {
Some(Notification::PairingError(error))
} else if p[0] == 0x02 {
Some(Notification::PairingSucceeded { slot: p[8] })
} else {
None
}
}
notif::PASSKEY_REQUEST => {
let passkey = String::from_utf8_lossy(&p[1..7]).into_owned();
Some(Notification::Passkey(passkey))
}
notif::UNIFYING_LOCK => Some(Notification::UnifyingLock {
open: p[0] & 0x01 != 0,
error: p[1],
}),
_ => None,
}
}
fn subscribe(channel: &HidppChannel) -> (u32, mpsc::UnboundedReceiver<HidppMessage>) {
let (tx, rx) = mpsc::unbounded_channel();
let hdl = channel.add_msg_listener(move |msg, matched| {
if !matched {
let _ = tx.send(msg);
}
});
(hdl, rx)
}
const SESSION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90);
const DISCOVERY_TIMEOUT: u8 = 30;
pub async fn run_pairing(
target: ReceiverSelector,
mut commands: mpsc::UnboundedReceiver<PairingCommand>,
events: mpsc::UnboundedSender<PairingEvent>,
) -> Result<(), PairingError> {
let (channel, family) = open_receiver(&target).await?;
let (listener, mut notifications) = subscribe(&channel);
let result = drive(&channel, family, &mut commands, &mut notifications, &events).await;
channel.remove_msg_listener(listener);
let _ = channel
.write_register(RECEIVER_INDEX, reg::NOTIFICATIONS, [0, 0, 0])
.await;
if let Err(ref e) = result {
let _ = events.send(PairingEvent::Failed(e.clone()));
}
result
}
async fn drive(
channel: &HidppChannel,
family: ReceiverFamily,
commands: &mut mpsc::UnboundedReceiver<PairingCommand>,
notifications: &mut mpsc::UnboundedReceiver<HidppMessage>,
events: &mpsc::UnboundedSender<PairingEvent>,
) -> Result<(), PairingError> {
write_register(channel, reg::NOTIFICATIONS, NOTIF_FLAGS).await?;
match family {
ReceiverFamily::Bolt => {
write_register(
channel,
reg::BOLT_DISCOVERY,
[DISCOVERY_TIMEOUT, 0x01, 0x00],
)
.await?;
}
ReceiverFamily::Unifying => {
write_register(
channel,
reg::UNIFYING_PAIRING,
[0x01, 0x00, DISCOVERY_TIMEOUT],
)
.await?;
}
}
let _ = events.send(PairingEvent::Searching);
let mut partial: HashMap<u16, PartialDevice> = HashMap::new();
let mut pairing_auth: Option<u8> = None;
let deadline = tokio::time::sleep(SESSION_TIMEOUT);
tokio::pin!(deadline);
loop {
tokio::select! {
() = &mut deadline => return Err(PairingError::Timeout),
cmd = commands.recv() => match cmd {
Some(PairingCommand::Pair(device)) => {
pairing_auth = Some(device.authentication);
pair_bolt_device(channel, &device).await?;
}
Some(PairingCommand::Cancel) | None => {
cancel(channel, family).await;
return Err(PairingError::Cancelled);
}
},
msg = notifications.recv() => {
let Some(msg) = msg else {
return Err(PairingError::Hid("receiver channel closed".into()));
};
let (device_index, sub_id, payload) = decode(&msg);
trace!(sub_id = format_args!("{sub_id:#04x}"), ?payload, "pairing notification");
let Some(note) = parse_notification(sub_id, device_index, payload) else {
continue;
};
match note {
Notification::DiscoveryInfo { counter, kind, address, authentication } => {
let entry = partial.entry(counter).or_default();
entry.kind = Some(kind);
entry.address = Some(address);
entry.authentication = Some(authentication);
if let Some(device) = entry.build() {
let _ = events.send(PairingEvent::DeviceFound(device));
}
}
Notification::DiscoveryName { counter, name } => {
let entry = partial.entry(counter).or_default();
entry.name = Some(name);
if let Some(device) = entry.build() {
let _ = events.send(PairingEvent::DeviceFound(device));
}
}
Notification::Passkey(passkey) => {
let method = match pairing_auth {
Some(auth) if auth & 0x01 != 0 => PasskeyMethod::Keyboard(passkey),
_ => PasskeyMethod::Pointer {
clicks: passkey_to_clicks(&passkey),
passkey,
},
};
let _ = events.send(PairingEvent::Passkey(method));
}
Notification::PairingSucceeded { slot } => {
let _ = events.send(PairingEvent::Paired { slot });
return Ok(());
}
Notification::PairingError(code) => return Err(PairingError::Device(code)),
Notification::Connected { slot, established } if family == ReceiverFamily::Unifying => {
if established {
let _ = events.send(PairingEvent::Paired { slot });
return Ok(());
}
}
Notification::Connected { .. } => {}
Notification::UnifyingLock { open, error } => {
if error != 0 {
return Err(PairingError::Device(error));
}
if !open {
return Err(PairingError::Timeout);
}
}
}
}
}
}
}
#[derive(Default)]
struct PartialDevice {
kind: Option<u8>,
address: Option<[u8; 6]>,
authentication: Option<u8>,
name: Option<String>,
emitted: bool,
}
impl PartialDevice {
fn build(&mut self) -> Option<DiscoveredDevice> {
if self.emitted {
return None;
}
let (kind, address, authentication, name) = (
self.kind?,
self.address?,
self.authentication?,
self.name.clone()?,
);
self.emitted = true;
Some(DiscoveredDevice {
address,
authentication,
kind: BoltDeviceKind::try_from(kind & 0x0f).unwrap_or(BoltDeviceKind::Unknown),
name,
})
}
}
async fn pair_bolt_device(
channel: &HidppChannel,
device: &DiscoveredDevice,
) -> Result<(), PairingError> {
let mut payload = [0u8; 16];
payload[0] = 0x01; payload[1] = 0x00; payload[2..8].copy_from_slice(&device.address);
payload[8] = device.authentication;
payload[9] = device.entropy();
write_long_register(channel, reg::BOLT_PAIRING, payload).await
}
async fn cancel(channel: &HidppChannel, family: ReceiverFamily) {
let res = match family {
ReceiverFamily::Bolt => {
write_register(
channel,
reg::BOLT_DISCOVERY,
[DISCOVERY_TIMEOUT, 0x02, 0x00],
)
.await
}
ReceiverFamily::Unifying => {
write_register(channel, reg::UNIFYING_PAIRING, [0x02, 0x00, 0x00]).await
}
};
if let Err(e) = res {
debug!(?e, "cancel write failed");
}
}
pub async fn unpair(target: ReceiverSelector, slot: u8) -> Result<(), PairingError> {
let (channel, family) = open_receiver(&target).await?;
match family {
ReceiverFamily::Bolt => {
let mut payload = [0u8; 16];
payload[0] = 0x03; payload[1] = slot;
write_long_register(&channel, reg::BOLT_PAIRING, payload).await
}
ReceiverFamily::Unifying => {
write_register(&channel, reg::UNIFYING_PAIRING, [0x03, slot, 0x00]).await
}
}
}
async fn write_register(
channel: &HidppChannel,
address: u8,
payload: [u8; 3],
) -> Result<(), PairingError> {
channel
.write_register(RECEIVER_INDEX, address, payload)
.await
.map_err(|e| {
warn!(
register = format_args!("{address:#04x}"),
?e,
"register write failed"
);
PairingError::Register(format!("{e}"))
})
}
async fn write_long_register(
channel: &HidppChannel,
address: u8,
payload: [u8; 16],
) -> Result<(), PairingError> {
channel
.write_long_register(RECEIVER_INDEX, address, payload)
.await
.map_err(|e| {
warn!(
register = format_args!("{address:#04x}"),
?e,
"long register write failed"
);
PairingError::Register(format!("{e}"))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn long(sub_id: u8, device_index: u8, p: [u8; 17]) -> HidppMessage {
let mut d = [0u8; 19];
d[0] = device_index;
d[1] = sub_id;
d[2..19].copy_from_slice(&p);
HidppMessage::Long(d)
}
#[test]
fn decode_maps_long_payload_to_address_first() {
let msg = long(notif::DEVICE_DISCOVERY, 0xff, {
let mut p = [0u8; 17];
p[0] = 0x07; p[1] = 0x00; p
});
let (idx, sub, payload) = decode(&msg);
assert_eq!(idx, 0xff);
assert_eq!(sub, notif::DEVICE_DISCOVERY);
assert_eq!(payload[0], 0x07);
assert_eq!(payload[1], 0x00);
}
#[test]
fn parses_discovery_info_frame() {
let mut p = [0u8; 17];
p[0] = 0x05; p[1] = 0x00; p[2] = 0x00; p[4] = 0x02; p[7..13].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef, 0x01, 0x02]);
p[15] = 0x01; assert_eq!(
parse_notification(notif::DEVICE_DISCOVERY, 0xff, p),
Some(Notification::DiscoveryInfo {
counter: 5,
kind: 0x02,
address: [0xde, 0xad, 0xbe, 0xef, 0x01, 0x02],
authentication: 0x01,
})
);
}
#[test]
fn parses_discovery_name_frame() {
let mut p = [0u8; 17];
p[0] = 0x05;
p[1] = 0x00;
p[2] = 0x01; p[3] = 0x03; p[4..7].copy_from_slice(b"MX3");
assert_eq!(
parse_notification(notif::DEVICE_DISCOVERY, 0xff, p),
Some(Notification::DiscoveryName {
counter: 5,
name: "MX3".to_string(),
})
);
}
#[test]
fn parses_pairing_success_with_slot() {
let mut p = [0u8; 17];
p[0] = 0x02; p[1] = 0x00; p[8] = 0x03; assert_eq!(
parse_notification(notif::PAIRING_STATUS, 0xff, p),
Some(Notification::PairingSucceeded { slot: 3 })
);
}
#[test]
fn parses_pairing_error() {
let mut p = [0u8; 17];
p[0] = 0x00;
p[1] = 0x01; assert_eq!(
parse_notification(notif::PAIRING_STATUS, 0xff, p),
Some(Notification::PairingError(0x01))
);
}
#[test]
fn parses_passkey_digits() {
let mut p = [0u8; 17];
p[1..7].copy_from_slice(b"123456");
assert_eq!(
parse_notification(notif::PASSKEY_REQUEST, 0xff, p),
Some(Notification::Passkey("123456".to_string()))
);
}
#[test]
fn parses_unifying_lock() {
let mut p = [0u8; 17];
p[0] = 0x01; assert_eq!(
parse_notification(notif::UNIFYING_LOCK, 0xff, p),
Some(Notification::UnifyingLock {
open: true,
error: 0
})
);
}
#[test]
fn passkey_clicks_are_msb_first_10_bits() {
assert_eq!(
passkey_to_clicks("5"),
vec![
Click::Left,
Click::Left,
Click::Left,
Click::Left,
Click::Left,
Click::Left,
Click::Left,
Click::Right,
Click::Left,
Click::Right,
]
);
}
}