openlogi-hid 0.6.22

HID++ device discovery for OpenLogi, wrapping the hidpp crate over async-hid.
Documentation
//! `RawHidChannel` implementation over `async-hid`.
//!
//! `hidpp` derives short/long-report support by reading the HID report
//! descriptor, but `async-hid 0.4` only exposes descriptors on Linux. We avoid
//! that path by pre-filtering to the Logitech HID++ vendor collections at
//! enumeration time (see [`HIDPP_LONG_COLLECTIONS`]) and reporting support
//! straight from [`AsyncHidChannel::supports_short_long_hidpp`]: USB / receiver
//! collections carry both reports; BLE-direct collections are long-only, and the
//! `hidpp` channel up-converts outgoing short messages to long for them.

#[cfg(not(target_os = "windows"))]
use std::error::Error;
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;
#[cfg(not(target_os = "windows"))]
use hidpp::{async_trait, channel::RawHidChannel};
#[cfg(not(target_os = "windows"))]
use tokio::sync::Mutex;
use tracing::debug;

#[cfg(any(target_os = "windows", test))]
mod windows;
#[cfg(target_os = "windows")]
use windows::WindowsHidppChannel;
#[cfg(test)]
use windows::normalize_collection_path;

/// Logitech HID vendor ID.
const LOGITECH_VID: u16 = 0x046d;
/// HID++ long-report vendor collections, as `(usage_page, usage_id, long_only)`.
///
/// Logitech exposes its HID++ long-report (report id `0x11`) under a
/// vendor-defined HID collection, but the page differs by transport:
///
/// - `0xFF00 / 0x0002` — USB, Logi Bolt / Unifying receivers, and
///   Bluetooth-*classic* devices (MX Master over BT).
/// - `0xFF43 / 0x0202` — Bluetooth-*Low-Energy* directly-paired devices
///   (e.g. the Logitech Lift / Signature mice). Same HID++ protocol, just a
///   different vendor page on the BLE HID report descriptor.
/// - `0xFF43 / 0x0602` — wired G-series gaming keyboards (e.g. the G513): a
///   distinct vendor collection on the same `0xFF43` page. Carries both report
///   widths, so it is not long-only.
///
/// `long_only` marks a transport that exposes *only* the long report — no
/// short-report (`0x10`) collection — so short HID++ requests must be
/// up-converted to long (handled by the `hidpp` channel). BLE-direct devices on
/// macOS are long-only; USB / receiver / wired-keyboard devices carry both.
/// Keeping the flag in this table means a new long-only transport is a
/// single-line addition here, with no second site to update.
///
/// Filtering on these pairs gives us one HID node per physical HID++ device on
/// every supported OS, without reading report descriptors (`async-hid 0.4`
/// only exposes those on Linux).
const HIDPP_LONG_COLLECTIONS: [(u16, u16, bool); 3] = [
    (0xff00, 0x0002, false),
    (0xff43, 0x0202, true),
    (0xff43, 0x0602, false),
];

/// Whether `(usage_page, usage_id)` is one of the HID++ long-report collections.
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))
}

/// Whether the matched HID++ collection exposes only the long report, so short
/// requests must be re-framed as long (done in the `hidpp` channel). `false` for
/// pages not in [`HIDPP_LONG_COLLECTIONS`].
// Windows routes short vs long by report id over the composite channel
// (WindowsHidppChannel), so the long-only up-conversion path — and thus this
// helper — is only reached off Windows. Still compiled + unit-tested there.
#[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))
}

/// Process-wide HID backend, created once and reused for every enumeration.
///
/// async-hid's macOS backend wraps an `IOHIDManager`; `HidBackend::default()`
/// builds, schedules, and (on drop) cancels one. The inventory watcher
/// enumerates every ~2 s, so building a fresh backend per call spun up and tore
/// down an `IOHIDManager` on every tick — needless churn that kept the process
/// busy and its heap dirty around the clock (issue #99). Reusing one long-lived
/// backend is the usage async-hid intends, and keeps the device set warm between
/// polls. `HidBackend` is `Arc`-backed, so this is shared, not copied.
///
/// `enumerate` is also reached from `open_route_writer`, so the inventory
/// watcher and a (rare) lighting write can enumerate through this one backend
/// concurrently. That is sound: async-hid declares the backend `Send + Sync`,
/// `enumerate` only reads a snapshot (`IOHIDManagerCopyDevices`), and sharing a
/// single long-lived `IOHIDManager` across threads is the model hidapi uses too.
static HID_BACKEND: LazyLock<HidBackend> = LazyLock::new(HidBackend::default);

/// The process-wide HID backend shared by enumeration and hotplug watching.
pub(crate) fn hid_backend() -> &'static HidBackend {
    &HID_BACKEND
}

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;

    // One-time visibility into what the OS actually reports for Logitech nodes,
    // so a transport that uses an unexpected vendor page (e.g. a new BLE mouse)
    // can be diagnosed from `OPENLOGI_LOG=debug` without a rebuild.
    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())
}

/// Returns `true` when a HID++ node is a virtual per-device interface created by
/// the `hid-logitech-dj` kernel driver as a child of a Unifying or Bolt receiver.
///
/// On Linux, each device paired to a Unifying receiver gets its own hidraw node
/// whose sysfs path is a subdirectory of the receiver's HID device path. These
/// nodes expose the same HID++ long-report collection as the receiver, but HID++
/// communication must go through the receiver node, not these child nodes.
/// Probing them directly causes long timeouts and produces no useful inventory.
///
/// Detection: the sysfs path of a child node looks like
/// `.../0003:046D:C52B.0009/0003:046D:4076.000A`
/// while the receiver itself ends at `…/0003:046D:C52B.0009`. We check whether
/// any known receiver PID appears as a *parent directory* component in the path.
#[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())
}

/// Determines whether a resolved sysfs path belongs to a device that is a
/// child of a known receiver. Separated from `is_receiver_child_node` so it
/// can be unit-tested without filesystem access.
#[cfg(any(target_os = "linux", test))]
fn is_receiver_child_sysfs_path(path: &str) -> bool {
    // Build parent-component markers from the canonical PID lists so adding a
    // new receiver PID only needs to be done in one place (route.rs).
    // The kernel HID device name format is "BUS:VID:PID.IFACE" with uppercase hex.
    crate::BOLT_PIDS
        .iter()
        .chain(crate::UNIFYING_PIDS.iter())
        .any(|&pid| {
            let marker = format!(":{LOGITECH_VID:04X}:{pid:04X}.");
            // A parent component contains the marker followed by at least one
            // more "/" — it is not the terminal component of the path.
            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
}

/// Open the raw HID writer for a directly-attached (USB) device, for sending
/// reports the HID++ wrapper can't model — e.g. the 64-byte `0x12` lighting
/// frames G-series keyboards use. Returns `None` for Bolt routes or when no
/// matching node is connected.
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> {
    // `Device: Deref<Target = DeviceInfo>` — clone the deref'd value so we can
    // keep using `dev` (which `to_device_info` would consume).
    let info: DeviceInfo = (*dev).clone();
    // On Windows the short (0x10) and long (0x11) HID++ report collections are
    // exposed as separate device interfaces, so the channel must open both and
    // route by report id (see WindowsHidppChannel). Elsewhere one node carries
    // both reports (or is long-only), handled by AsyncHidChannel.
    #[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?;
        // BLE-direct devices expose only the long HID++ report; flag the channel so
        // it advertises short-unsupported and the `hidpp` channel up-converts shorts.
        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);
            }
        };
        // Logged once per actual open. The inventory watcher reuses channels across
        // ticks, so a steadily-connected device should log this on first sight (and
        // on reconnect) only — not every ~2s tick.
        debug!(name = %info.name, vid = format_args!("{:04x}", info.vendor_id), "opened HID++ channel");
        Ok(Some((info, channel)))
    }
}

#[cfg(not(target_os = "windows"))]
pub(crate) struct AsyncHidChannel {
    reader: Mutex<DeviceReader>,
    writer: Mutex<DeviceWriter>,
    info: DeviceInfo,
    /// Whether the device exposes only the long HID++ report (a BLE-direct
    /// peripheral on macOS). Reported via `supports_short_long_hidpp` so the
    /// `hidpp` channel up-converts outgoing short messages to long.
    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),
            // The device disconnected — there will never be another input
            // report, so this is the permanent-failure case of the
            // `RawHidChannel::read_report` contract: errors are retried by the
            // `hidpp` read loop (surfacing this one would busy-spin a core
            // until the inventory watcher evicts the channel), so park instead.
            // The contract guarantees every caller races this future against
            // the channel's close signal, which tears the read down on drop.
            Err(async_hid::HidError::Disconnected) => std::future::pending().await,
            Err(e) => Err(e.into()),
        }
    }

    fn supports_short_long_hidpp(&self) -> Option<(bool, bool)> {
        // USB / receiver collections carry both reports; BLE-direct collections
        // are long-only (no short report on macOS), where the `hidpp` channel
        // up-converts outgoing short messages to long.
        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;