#[cfg(not(target_os = "windows"))]
use std::error::Error;
#[cfg(not(target_os = "windows"))]
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicU16, Ordering};
use std::sync::{Arc, LazyLock};
#[cfg(not(target_os = "windows"))]
use async_hid::{AsyncHidRead, AsyncHidWrite, DeviceReader};
use async_hid::{DeviceInfo, DeviceWriter, HidBackend};
use futures_lite::StreamExt as _;
use hidpp::channel::HidppChannel;
use hidpp::nibble::U4;
#[cfg(not(target_os = "windows"))]
use hidpp::{async_trait, channel::RawHidChannel};
#[cfg(not(target_os = "windows"))]
use tokio::sync::Mutex;
use tracing::debug;
use crate::write::{WriteError, matches_litra};
static SW_ID_LEASES: AtomicU16 = AtomicU16::new(0);
#[cfg(any(target_os = "windows", test))]
mod windows;
#[cfg(target_os = "windows")]
use windows::WindowsHidppChannel;
#[cfg(test)]
use windows::normalize_collection_path;
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) fn hid_backend() -> &'static HidBackend {
&HID_BACKEND
}
pub(crate) async fn enumerate_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)
}
pub(crate) fn device_identity(info: &DeviceInfo) -> String {
info.serial_number
.as_deref()
.filter(|serial| !serial.is_empty())
.map_or_else(
|| format!("id:{:?}", info.id),
|serial| format!("serial:{}", serial.to_ascii_lowercase()),
)
}
pub(crate) async fn enumerate_hidpp_devices() -> Result<Vec<async_hid::Device>, async_hid::HidError>
{
Ok(enumerate_devices()
.await?
.into_iter()
.filter(|d| {
is_hidpp_candidate(
d.vendor_id,
d.product_id,
d.usage_page,
d.usage_id,
is_receiver_child_node(&d.id),
)
})
.collect())
}
fn is_hidpp_candidate(
vendor_id: u16,
product_id: u16,
usage_page: u16,
usage_id: u16,
receiver_child: bool,
) -> bool {
vendor_id == LOGITECH_VID
&& is_hidpp_long_collection(usage_page, usage_id)
&& !matches_litra(vendor_id, product_id, usage_page, usage_id)
&& !receiver_child
}
#[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>, WriteError> {
let candidates = match route {
crate::route::DeviceRoute::Direct { .. } => {
enumerate_hidpp_devices().await.map_err(WriteError::from)?
}
crate::route::DeviceRoute::RawHid { .. } => {
enumerate_devices().await.map_err(WriteError::from)?
}
_ => return Ok(None),
};
let mut matched = None;
for dev in candidates {
let is_match = match route {
crate::route::DeviceRoute::Direct {
vendor_id,
product_id,
} => dev.vendor_id == *vendor_id && dev.product_id == *product_id,
crate::route::DeviceRoute::RawHid {
vendor_id,
product_id,
usage_page,
usage_id,
identity,
} => {
dev.vendor_id == *vendor_id
&& dev.product_id == *product_id
&& dev.usage_page == *usage_page
&& dev.usage_id == *usage_id
&& device_identity(&dev) == *identity
}
_ => false,
};
if is_match {
if matches!(route, crate::route::DeviceRoute::Direct { .. }) {
let (_reader, writer) = dev.open().await.map_err(WriteError::from)?;
return Ok(Some(writer));
}
if matched.is_some() {
tracing::warn!("multiple raw HID nodes matched one route");
return Err(WriteError::AmbiguousRawDevice);
}
matched = Some(dev);
}
}
match matched {
Some(dev) => {
let (_reader, writer) = dev.open().await.map_err(WriteError::from)?;
Ok(Some(writer))
}
None => Ok(None),
}
}
fn try_lease_sw_id() -> Option<u8> {
loop {
let bits = SW_ID_LEASES.load(Ordering::Acquire);
let free = (1u8..=15).find(|&id| bits & (1u16 << id) == 0)?;
let next = bits | (1u16 << free);
if SW_ID_LEASES
.compare_exchange(bits, next, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
return Some(free);
}
}
}
fn free_sw_id(id: u8) {
if (1..=15).contains(&id) {
SW_ID_LEASES.fetch_and(!(1u16 << id), Ordering::Release);
}
}
fn configure_channel_sw_ids(channel: &mut HidppChannel) {
let Some(id) = try_lease_sw_id() else {
debug!("all HID++ software ids are leased; channel keeps default id 1");
return;
};
channel.set_sw_id(U4::from_lo(id));
channel.set_rotating_sw_id(false);
channel.set_sw_id_lease(id, free_sw_id);
}
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(mut c) => {
configure_channel_sw_ids(&mut 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(mut c) => {
configure_channel_sw_ids(&mut 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(test)]
mod sw_id_lease_tests {
use super::{free_sw_id, try_lease_sw_id};
#[test]
fn leases_are_unique_until_freed() {
let Some(a) = try_lease_sw_id() else {
return;
};
let Some(b) = try_lease_sw_id() else {
free_sw_id(a);
return;
};
assert_ne!(a, b);
free_sw_id(a);
let Some(c) = try_lease_sw_id() else {
free_sw_id(b);
return;
};
assert_eq!(c, a);
free_sw_id(b);
free_sw_id(c);
}
}
#[cfg(not(target_os = "windows"))]
pub(crate) struct AsyncHidChannel {
reader: Mutex<DeviceReader>,
writer: Mutex<DeviceWriter>,
info: DeviceInfo,
connected: AtomicBool,
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,
connected: AtomicBool::new(true),
long_only,
}
}
fn mark_disconnected(&self) {
if self.connected.swap(false, Ordering::AcqRel) {
debug!(name = %self.info.name, "HID channel disconnected");
}
}
}
#[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;
match w.write_output_report(src).await {
Ok(()) => Ok(src.len()),
Err(e) => {
if matches!(e, async_hid::HidError::Disconnected) {
self.mark_disconnected();
}
Err(e.into())
}
}
}
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) => {
self.mark_disconnected();
std::future::pending().await
}
Err(e) => Err(e.into()),
}
}
fn is_connected(&self) -> bool {
self.connected.load(Ordering::Acquire)
}
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;