use std::{
error::Error,
sync::{Arc, LazyLock},
};
use async_hid::{AsyncHidRead, AsyncHidWrite, DeviceInfo, DeviceReader, DeviceWriter, HidBackend};
use futures_lite::StreamExt as _;
use hidpp::{
async_trait,
channel::{HidppChannel, RawHidChannel},
};
use tokio::sync::Mutex;
use tracing::debug;
#[cfg(target_os = "windows")]
use std::io;
#[cfg(target_os = "windows")]
use crate::windows_hid::NativeHidWriter;
#[cfg(target_os = "windows")]
use hidpp::channel::{LONG_REPORT_ID, LONG_REPORT_LENGTH, SHORT_REPORT_ID, SHORT_REPORT_LENGTH};
const LOGITECH_VID: u16 = 0x046d;
const HIDPP_LONG_COLLECTIONS: [(u16, u16, bool); 3] = [
(0xff00, 0x0002, false),
(0xff43, 0x0202, true),
(0xff43, 0x0602, false),
];
fn is_hidpp_long_collection(usage_page: u16, usage_id: u16) -> bool {
HIDPP_LONG_COLLECTIONS
.iter()
.any(|&(page, usage, _)| (page, usage) == (usage_page, usage_id))
}
#[cfg_attr(
target_os = "windows",
allow(
dead_code,
reason = "long-only up-conversion is the non-Windows AsyncHidChannel path"
)
)]
fn is_long_only_collection(usage_page: u16, usage_id: u16) -> bool {
HIDPP_LONG_COLLECTIONS
.iter()
.any(|&(page, usage, long_only)| long_only && (page, usage) == (usage_page, usage_id))
}
static HID_BACKEND: LazyLock<HidBackend> = LazyLock::new(HidBackend::default);
pub(crate) async fn enumerate_hidpp_devices() -> Result<Vec<async_hid::Device>, async_hid::HidError>
{
let all: Vec<async_hid::Device> = HID_BACKEND.enumerate().await?.collect().await;
for d in all.iter().filter(|d| d.vendor_id == LOGITECH_VID) {
debug!(
name = %d.name,
pid = format_args!("{:04x}", d.product_id),
usage_page = format_args!("{:#06x}", d.usage_page),
usage_id = format_args!("{:#06x}", d.usage_id),
matched = is_hidpp_long_collection(d.usage_page, d.usage_id),
"logitech HID node"
);
}
Ok(all
.into_iter()
.filter(|d| {
d.vendor_id == LOGITECH_VID
&& is_hidpp_long_collection(d.usage_page, d.usage_id)
&& !is_receiver_child_node(&d.id)
})
.collect())
}
#[cfg(target_os = "linux")]
fn is_receiver_child_node(id: &async_hid::DeviceId) -> bool {
use async_hid::DeviceId;
let DeviceId::DevPath(dev_path) = id else {
return false;
};
let Some(node_name) = dev_path.file_name().and_then(|n| n.to_str()) else {
return false;
};
let sysfs_link = format!("/sys/class/hidraw/{node_name}/device");
let Ok(real_path) = std::fs::canonicalize(&sysfs_link) else {
return false;
};
is_receiver_child_sysfs_path(&real_path.to_string_lossy())
}
#[cfg(any(target_os = "linux", test))]
fn is_receiver_child_sysfs_path(path: &str) -> bool {
crate::BOLT_PIDS
.iter()
.chain(crate::UNIFYING_PIDS.iter())
.any(|&pid| {
let marker = format!(":{LOGITECH_VID:04X}:{pid:04X}.");
path.find(&marker)
.is_some_and(|idx| path[idx + marker.len()..].contains('/'))
})
}
#[cfg(not(target_os = "linux"))]
fn is_receiver_child_node(_id: &async_hid::DeviceId) -> bool {
false
}
pub(crate) async fn open_route_writer(
route: &crate::route::DeviceRoute,
) -> Result<Option<DeviceWriter>, async_hid::HidError> {
let crate::route::DeviceRoute::Direct {
vendor_id,
product_id,
} = route
else {
return Ok(None);
};
let candidates = enumerate_hidpp_devices().await?;
for dev in candidates {
if dev.vendor_id == *vendor_id && dev.product_id == *product_id {
let (_reader, writer) = dev.open().await?;
return Ok(Some(writer));
}
}
Ok(None)
}
pub(crate) async fn open_hidpp_channel(
dev: async_hid::Device,
) -> Result<Option<(DeviceInfo, Arc<HidppChannel>)>, async_hid::HidError> {
let info: DeviceInfo = (*dev).clone();
#[cfg(target_os = "windows")]
{
let raw = WindowsHidppChannel::open(dev, info.clone()).await?;
let channel = match HidppChannel::from_raw_channel(raw).await {
Ok(c) => Arc::new(c),
Err(e) => {
debug!(name = %info.name, error = ?e, "not a HID++ channel");
return Ok(None);
}
};
Ok(Some((info, channel)))
}
#[cfg(not(target_os = "windows"))]
{
let (reader, writer) = dev.open().await?;
let long_only = is_long_only_collection(info.usage_page, info.usage_id);
let raw = AsyncHidChannel::new(reader, writer, info.clone(), long_only);
let channel = match HidppChannel::from_raw_channel(raw).await {
Ok(c) => Arc::new(c),
Err(e) => {
debug!(name = %info.name, error = ?e, "not a HID++ channel");
return Ok(None);
}
};
debug!(name = %info.name, vid = format_args!("{:04x}", info.vendor_id), "opened HID++ channel");
Ok(Some((info, channel)))
}
}
#[cfg(target_os = "windows")]
struct HidEndpoint {
reader: Mutex<DeviceReader>,
writer: Mutex<DeviceWriter>,
native_writer: Option<NativeHidWriter>,
}
#[cfg(target_os = "windows")]
impl HidEndpoint {
fn new(reader: DeviceReader, writer: DeviceWriter, info: &DeviceInfo) -> Self {
Self {
reader: Mutex::new(reader),
writer: Mutex::new(writer),
native_writer: NativeHidWriter::new(info),
}
}
async fn write_report(&self, src: &[u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
let mut writer = self.writer.lock().await;
if let Err(e) = writer.write_output_report(src).await {
if let Some(native_writer) = &self.native_writer {
debug!(
error = %e,
report_id = format_args!("{:#04x}", src.first().copied().unwrap_or_default()),
len = src.len(),
"async-hid output report write failed; trying native Windows HID fallback"
);
native_writer.write_report(src)?;
return Ok(src.len());
}
return Err(Box::new(e));
}
Ok(src.len())
}
}
#[cfg(target_os = "windows")]
struct WindowsHidppChannel {
info: DeviceInfo,
short: Option<HidEndpoint>,
long: Option<HidEndpoint>,
}
#[cfg(target_os = "windows")]
impl WindowsHidppChannel {
async fn open(
long_dev: async_hid::Device,
long_info: DeviceInfo,
) -> Result<Self, async_hid::HidError> {
let short_dev = find_windows_short_collection(&long_info).await?;
let (long_reader, long_writer) = long_dev.open().await?;
let long = Some(HidEndpoint::new(long_reader, long_writer, &long_info));
let short = match short_dev {
Some(dev) => {
let short_info: DeviceInfo = (*dev).clone();
match dev.open().await {
Ok((reader, writer)) => {
debug!(
name = %short_info.name,
pid = format_args!("{:04x}", short_info.product_id),
"paired Windows HID++ short collection"
);
Some(HidEndpoint::new(reader, writer, &short_info))
}
Err(e) => {
debug!(
name = %short_info.name,
pid = format_args!("{:04x}", short_info.product_id),
error = ?e,
"could not open Windows HID++ short collection"
);
None
}
}
}
None => None,
};
debug!(
name = %long_info.name,
pid = format_args!("{:04x}", long_info.product_id),
supports_short = short.is_some(),
supports_long = long.is_some(),
"opened Windows HID++ composite channel"
);
Ok(Self {
info: long_info,
short,
long,
})
}
}
#[cfg(target_os = "windows")]
async fn find_windows_short_collection(
long_info: &DeviceInfo,
) -> Result<Option<async_hid::Device>, async_hid::HidError> {
let Some(long_key) = grouping_key(long_info) else {
return Ok(None);
};
let all: Vec<async_hid::Device> = HID_BACKEND.enumerate().await?.collect().await;
Ok(all.into_iter().find(|d| {
d.usage_page == 0xff00
&& d.usage_id == 0x0001
&& grouping_key(d).as_deref() == Some(long_key.as_str())
}))
}
#[cfg(target_os = "windows")]
fn grouping_key(info: &DeviceInfo) -> Option<String> {
match &info.id {
async_hid::DeviceId::UncPath(p) => Some(normalize_collection_path(&p.to_string())),
_ => None,
}
}
#[cfg_attr(
not(target_os = "windows"),
allow(
dead_code,
reason = "pure path parser is exercised by host unit tests; its only runtime caller is the Windows-gated grouping_key"
)
)]
fn normalize_collection_path(path: &str) -> String {
let lower = path.to_ascii_lowercase();
let segments: Vec<&str> = lower.split('#').collect();
let (Some(hw), Some(inst)) = (segments.get(1), segments.get(2)) else {
return lower;
};
let hw_key = hw
.split('&')
.filter(|s| !s.starts_with("col"))
.collect::<Vec<_>>()
.join("&");
let inst_key = inst.rsplit_once('&').map_or(*inst, |(head, _)| head);
format!("{hw_key}#{inst_key}")
}
#[cfg(target_os = "windows")]
#[async_trait]
impl RawHidChannel for WindowsHidppChannel {
fn vendor_id(&self) -> u16 {
self.info.vendor_id
}
fn product_id(&self) -> u16 {
self.info.product_id
}
async fn write_report(&self, src: &[u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
let endpoint = match src.first().copied() {
Some(SHORT_REPORT_ID) => self.short.as_ref(),
Some(LONG_REPORT_ID) => self.long.as_ref(),
_ => None,
}
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::Unsupported,
format!(
"unsupported HID++ report id {:#04x}",
src.first().copied().unwrap_or_default()
),
)
})?;
endpoint.write_report(src).await
}
async fn read_report(&self, buf: &mut [u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
match (&self.short, &self.long) {
(Some(short), Some(long)) => {
let mut short_buf = [0u8; SHORT_REPORT_LENGTH];
let mut long_buf = [0u8; LONG_REPORT_LENGTH];
let mut short_reader = short.reader.lock().await;
let mut long_reader = long.reader.lock().await;
tokio::select! {
res = short_reader.read_input_report(&mut short_buf) => {
copy_report(&short_buf, res?, buf)
}
res = long_reader.read_input_report(&mut long_buf) => {
copy_report(&long_buf, res?, buf)
}
}
}
(Some(endpoint), None) | (None, Some(endpoint)) => {
let mut reader = endpoint.reader.lock().await;
Ok(reader.read_input_report(buf).await?)
}
(None, None) => Err(Box::new(io::Error::new(
io::ErrorKind::NotConnected,
"no Windows HID++ endpoints are open",
))),
}
}
fn supports_short_long_hidpp(&self) -> Option<(bool, bool)> {
Some((self.short.is_some(), self.long.is_some()))
}
async fn get_report_descriptor(
&self,
_buf: &mut [u8],
) -> Result<usize, Box<dyn Error + Send + Sync>> {
Err("get_report_descriptor is not implemented; pre-filter to HID++ usage pages".into())
}
}
#[cfg(target_os = "windows")]
fn copy_report(
src: &[u8],
len: usize,
dst: &mut [u8],
) -> Result<usize, Box<dyn Error + Send + Sync>> {
if len > src.len() || len > dst.len() {
return Err(Box::new(io::Error::new(
io::ErrorKind::InvalidData,
format!("HID report length {len} exceeds buffer size"),
)));
}
dst[..len].copy_from_slice(&src[..len]);
Ok(len)
}
#[cfg(not(target_os = "windows"))]
pub(crate) struct AsyncHidChannel {
reader: Mutex<DeviceReader>,
writer: Mutex<DeviceWriter>,
info: DeviceInfo,
long_only: bool,
}
#[cfg(not(target_os = "windows"))]
impl AsyncHidChannel {
pub(crate) fn new(
reader: DeviceReader,
writer: DeviceWriter,
info: DeviceInfo,
long_only: bool,
) -> Self {
Self {
reader: Mutex::new(reader),
writer: Mutex::new(writer),
info,
long_only,
}
}
}
#[cfg(not(target_os = "windows"))]
#[async_trait]
impl RawHidChannel for AsyncHidChannel {
fn vendor_id(&self) -> u16 {
self.info.vendor_id
}
fn product_id(&self) -> u16 {
self.info.product_id
}
async fn write_report(&self, src: &[u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
let mut w = self.writer.lock().await;
w.write_output_report(src).await?;
Ok(src.len())
}
async fn read_report(&self, buf: &mut [u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
let result = {
let mut r = self.reader.lock().await;
r.read_input_report(buf).await
};
match result {
Ok(n) => Ok(n),
Err(async_hid::HidError::Disconnected) => std::future::pending().await,
Err(e) => Err(e.into()),
}
}
fn supports_short_long_hidpp(&self) -> Option<(bool, bool)> {
Some((!self.long_only, true))
}
async fn get_report_descriptor(
&self,
_buf: &mut [u8],
) -> Result<usize, Box<dyn Error + Send + Sync>> {
Err("get_report_descriptor is not implemented; pre-filter to HID++ usage pages".into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_usb_ble_and_keyboard_hidpp_collections() {
assert!(is_hidpp_long_collection(0xff00, 0x0002)); assert!(is_hidpp_long_collection(0xff43, 0x0202)); assert!(is_hidpp_long_collection(0xff43, 0x0602)); assert!(!is_hidpp_long_collection(0x0001, 0x0002)); assert!(!is_hidpp_long_collection(0xff43, 0x0002)); }
#[test]
fn only_ble_collection_is_long_only() {
assert!(is_long_only_collection(0xff43, 0x0202)); assert!(!is_long_only_collection(0xff00, 0x0002)); assert!(!is_long_only_collection(0xff43, 0x0602)); assert!(!is_long_only_collection(0x0001, 0x0002)); }
#[test]
fn short_and_long_collections_of_one_interface_share_a_grouping_key() {
let short = normalize_collection_path(
r"\\?\HID#VID_046D&PID_C548&MI_02&Col01#7&348660ac&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}",
);
let long = normalize_collection_path(
r"\\?\HID#VID_046D&PID_C548&MI_02&Col02#7&348660ac&0&0001#{4d1e55b2-f16f-11cf-88cb-001111000030}",
);
assert_eq!(short, long);
assert_eq!(short, "vid_046d&pid_c548&mi_02#7&348660ac&0");
}
#[test]
fn distinct_interfaces_do_not_share_a_grouping_key() {
let mi01 = normalize_collection_path(
r"\\?\HID#VID_046D&PID_C548&MI_01&Col02#7&1cc2d467&0&0001#{4d1e55b2-f16f-11cf-88cb-001111000030}",
);
let mi02 = normalize_collection_path(
r"\\?\HID#VID_046D&PID_C548&MI_02&Col02#7&348660ac&0&0001#{4d1e55b2-f16f-11cf-88cb-001111000030}",
);
assert_ne!(mi01, mi02);
}
#[test]
fn distinct_physical_receivers_do_not_share_a_grouping_key() {
let recv_a = normalize_collection_path(
r"\\?\HID#VID_046D&PID_C548&MI_02&Col01#7&348660ac&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}",
);
let recv_b = normalize_collection_path(
r"\\?\HID#VID_046D&PID_C548&MI_02&Col01#7&9f1be20c&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}",
);
assert_ne!(recv_a, recv_b);
let bolt = normalize_collection_path(
r"\\?\HID#VID_046D&PID_C548&MI_02&Col02#7&348660ac&0&0001#{4d1e55b2-f16f-11cf-88cb-001111000030}",
);
let unifying = normalize_collection_path(
r"\\?\HID#VID_046D&PID_C52B&MI_02&Col02#7&1a2b3c4d&0&0001#{4d1e55b2-f16f-11cf-88cb-001111000030}",
);
assert_ne!(bolt, unifying);
}
const UNIFYING_CHILD: &str = "/sys/devices/pci0000:00/0000:00:14.0/usb3/3-5/3-5.4/3-5.4.3/\
3-5.4.3:1.2/0003:046D:C52B.0009/0003:046D:4076.000A";
const UNIFYING_RECEIVER: &str = "/sys/devices/pci0000:00/0000:00:14.0/usb3/3-5/3-5.4/3-5.4.3/\
3-5.4.3:1.2/0003:046D:C52B.0009";
const BOLT_CHILD: &str = "/sys/devices/pci0000:00/0000:00:14.0/usb3/3-5/\
0003:046D:C548.0001/0003:046D:B037.0002";
const UNRELATED: &str = "/sys/devices/pci0000:00/0000:00:15.0/i2c-0/0018:06CB:CE67.0001";
#[test]
fn child_of_unifying_receiver_is_detected() {
assert!(is_receiver_child_sysfs_path(UNIFYING_CHILD));
}
#[test]
fn unifying_receiver_itself_is_not_a_child() {
assert!(!is_receiver_child_sysfs_path(UNIFYING_RECEIVER));
}
#[test]
fn child_of_bolt_receiver_is_detected() {
assert!(is_receiver_child_sysfs_path(BOLT_CHILD));
}
#[test]
fn unrelated_device_is_not_a_child() {
assert!(!is_receiver_child_sysfs_path(UNRELATED));
}
}