use std::fmt;
use std::sync::Arc;
use hidpp::{
channel::HidppChannel,
receiver::{self, Receiver},
};
use openlogi_core::device::DeviceInventory;
use serde::{Deserialize, Serialize};
use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel};
pub const DIRECT_DEVICE_INDEX: u8 = 0xff;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeviceRoute {
Bolt {
receiver_uid: String,
slot: u8,
},
Unifying {
receiver_uid: String,
slot: u8,
},
Direct {
vendor_id: u16,
product_id: u16,
},
RawHid {
vendor_id: u16,
product_id: u16,
usage_page: u16,
usage_id: u16,
identity: String,
},
}
pub const BOLT_PIDS: &[u16] = &[0xc548];
pub const UNIFYING_PIDS: &[u16] = &[0xc52b, 0xc532, 0xc539];
pub const LIGHTSPEED_PIDS: &[u16] = &[0xc53f, 0xc547];
#[must_use]
pub fn speaks_unifying_protocol(product_id: u16) -> bool {
UNIFYING_PIDS.contains(&product_id) || LIGHTSPEED_PIDS.contains(&product_id)
}
#[must_use]
pub fn is_receiver_pid(product_id: u16) -> bool {
BOLT_PIDS.contains(&product_id) || speaks_unifying_protocol(product_id)
}
#[must_use]
pub fn receiver_display_name(product_id: u16) -> &'static str {
if LIGHTSPEED_PIDS.contains(&product_id) {
"Lightspeed Receiver"
} else {
"Unifying Receiver"
}
}
impl DeviceRoute {
#[must_use]
pub fn shares_transport(&self, other: &Self) -> bool {
match (self, other) {
(
Self::Bolt {
receiver_uid: left, ..
},
Self::Bolt {
receiver_uid: right,
..
},
)
| (
Self::Unifying {
receiver_uid: left, ..
},
Self::Unifying {
receiver_uid: right,
..
},
) => left.eq_ignore_ascii_case(right),
_ => false,
}
}
#[must_use]
pub fn device_index(&self) -> u8 {
match self {
Self::Bolt { slot, .. } | Self::Unifying { slot, .. } => *slot,
Self::Direct { .. } | Self::RawHid { .. } => DIRECT_DEVICE_INDEX,
}
}
#[must_use]
pub fn device_route_for(inv: &DeviceInventory, slot: u8) -> Option<Self> {
match &inv.receiver.unique_id {
Some(uid) if speaks_unifying_protocol(inv.receiver.product_id) => {
Some(Self::Unifying {
receiver_uid: uid.clone(),
slot,
})
}
Some(uid) => {
if !BOLT_PIDS.contains(&inv.receiver.product_id) {
tracing::debug!(
pid = format_args!("{:04x}", inv.receiver.product_id),
"unknown receiver PID — routing as Bolt"
);
}
Some(Self::Bolt {
receiver_uid: uid.clone(),
slot,
})
}
None if slot == DIRECT_DEVICE_INDEX => Some(Self::Direct {
vendor_id: inv.receiver.vendor_id,
product_id: inv.receiver.product_id,
}),
None => None,
}
}
}
impl fmt::Display for DeviceRoute {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Bolt { receiver_uid, slot } | Self::Unifying { receiver_uid, slot } => {
write!(f, "slot {slot} on receiver {receiver_uid}")
}
Self::Direct {
vendor_id,
product_id,
} => write!(f, "direct {vendor_id:04x}:{product_id:04x}"),
Self::RawHid {
vendor_id,
product_id,
usage_page,
usage_id,
identity,
} => write!(
f,
"raw {vendor_id:04x}:{product_id:04x} usage {usage_page:04x}:{usage_id:04x} ({identity})"
),
}
}
}
pub(crate) async fn open_route_channel(
route: &DeviceRoute,
) -> Result<Option<Arc<HidppChannel>>, async_hid::HidError> {
if matches!(route, DeviceRoute::RawHid { .. }) {
return Ok(None);
}
let candidates = enumerate_hidpp_devices().await?;
for dev in candidates {
if let DeviceRoute::Direct {
vendor_id,
product_id,
} = route
&& (dev.vendor_id != *vendor_id || dev.product_id != *product_id)
{
continue;
}
let Some((_, channel)) = open_hidpp_channel(dev).await? else {
continue;
};
match route {
DeviceRoute::Bolt { receiver_uid, .. } => {
let Some(Receiver::Bolt(bolt)) = receiver::detect(Arc::clone(&channel)) else {
continue;
};
if let Ok(uid) = bolt.get_unique_id().await
&& uid.eq_ignore_ascii_case(receiver_uid)
{
return Ok(Some(channel));
}
}
DeviceRoute::Unifying { receiver_uid, .. } => {
let Some(Receiver::Unifying(unifying)) = receiver::detect(Arc::clone(&channel))
else {
continue;
};
if let Ok(uid) = unifying.get_unique_id().await
&& uid.eq_ignore_ascii_case(receiver_uid)
{
return Ok(Some(channel));
}
}
DeviceRoute::Direct { .. } => return Ok(Some(channel)),
DeviceRoute::RawHid { .. } => unreachable!("raw HID route entered HID++ channel path"),
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use openlogi_core::device::{DeviceInventory, ReceiverInfo};
use super::{
DIRECT_DEVICE_INDEX, DeviceRoute, LIGHTSPEED_PIDS, UNIFYING_PIDS, receiver_display_name,
};
fn inv(product_id: u16, unique_id: Option<&str>) -> DeviceInventory {
DeviceInventory {
receiver: ReceiverInfo {
name: "test".into(),
vendor_id: 0x046d,
product_id,
unique_id: unique_id.map(str::to_string),
},
paired: vec![],
}
}
#[test]
fn device_route_for_unifying_pids_create_unifying_route() {
for &pid in UNIFYING_PIDS {
let route = DeviceRoute::device_route_for(&inv(pid, Some("A1B2")), 2);
assert!(
matches!(route, Some(DeviceRoute::Unifying { ref receiver_uid, slot: 2 }) if receiver_uid == "A1B2"),
"pid {pid:#06x} should produce Unifying route"
);
}
}
#[test]
fn device_route_for_lightspeed_pids_create_unifying_route() {
for &pid in LIGHTSPEED_PIDS {
let route = DeviceRoute::device_route_for(&inv(pid, Some("A1B2")), 2);
assert!(
matches!(route, Some(DeviceRoute::Unifying { ref receiver_uid, slot: 2 }) if receiver_uid == "A1B2"),
"lightspeed pid {pid:#06x} should produce a Unifying route"
);
}
}
#[test]
fn lightspeed_receiver_has_its_own_display_name() {
assert_eq!(receiver_display_name(0xc53f), "Lightspeed Receiver");
assert_eq!(receiver_display_name(0xc547), "Lightspeed Receiver");
assert_eq!(receiver_display_name(0xc52b), "Unifying Receiver");
}
#[test]
fn device_route_for_bolt_pid_creates_bolt_route() {
let route = DeviceRoute::device_route_for(&inv(0xc548, Some("UID")), 1);
assert_matches!(
route,
Some(DeviceRoute::Bolt { ref receiver_uid, slot: 1 }) if receiver_uid == "UID"
);
}
#[test]
fn device_route_for_direct_when_no_uid_and_direct_slot() {
let route = DeviceRoute::device_route_for(&inv(0xb025, None), DIRECT_DEVICE_INDEX);
assert_matches!(
route,
Some(DeviceRoute::Direct {
vendor_id: 0x046d,
product_id: 0xb025
})
);
}
#[test]
fn device_route_for_none_when_no_uid_and_non_direct_slot() {
let route = DeviceRoute::device_route_for(&inv(0xc52b, None), 1);
assert!(route.is_none());
}
#[test]
fn unifying_device_index_is_the_slot() {
let route = DeviceRoute::Unifying {
receiver_uid: "X".into(),
slot: 4,
};
assert_eq!(route.device_index(), 4);
}
#[test]
fn unifying_display_matches_bolt_format() {
let r = DeviceRoute::Unifying {
receiver_uid: "AABBCC".into(),
slot: 3,
};
assert_eq!(r.to_string(), "slot 3 on receiver AABBCC");
}
}