OpenLogi HID 层全解析:openlogi-hid 的 HID++ 设备发现、传输与控制能力
【免费下载链接】OpenLogi⚡️A native, local-first alternative to Logitech Options+, written in Rust 🦀 — remap buttons, DPI, and SmartShift over HID++. No account, no telemetry.项目地址: https://gitcode.com/GitHub_Trending/op/OpenLogi
导读
openlogi-hid是 OpenLogi 项目中面向 Logitech HID++ 外设的 HID 传输层 crate,负责在宿主操作系统的 HID 栈之上完成设备枚举、接收器路由、共享通道传输搭建,并对外提供 DPI、SmartShift、滚轮、拇指轮、可重编程按键、键盘 RGB 等类型化操作,供 CLI、Agent 与 GUI 三方复用。阅读本文后,你将理解 OpenLogi 是如何从一堆原始 HID 节点中识别出 HID++ 设备、如何跨 USB / 接收器 / BLE 三种传输建立通道、以及如何通过统一的host入口完成设备控制与配对。本文以 crates/openlogi-hid/README.md 为骨架,并结合 lib.rs、transport.rs 与 host.rs 等源码深入展开。
openlogi-hid 在整个项目中的定位
OpenLogi 是使用 Rust 编写的、本地优先的 Logitech Options+ 替代方案,支持按键重映射、DPI 调整与 SmartShift,全部通过 HID++ 协议实现,无需账号、无遥测。在多 crate 工作区中,HID 能力被拆成了明确的两层:
- 协议层
openlogi-hidpp:实现 HID++ 协议本身的 feature 编解码,例如0x8070ColorLedEffects、0x8080PerKeyLighting、0x2150Thumbwheel、ExtendedDPI、SmartShift 等,是纯粹的协议实现,不关心设备是怎么被发现的。 - 传输层
openlogi-hid(本 crate):在async-hid之上叠加 OpenLogi 自己的设备发现(enumeration)、接收器路由、共享通道传输搭建与错误分类策略,并把openlogi-device的设备层逻辑通过本机后端接线起来。
按照 README 的表述:当 OpenLogi 需要通过宿主 HID 栈与 Logitech HID++ 设备通信时使用本 crate;当要实现与 OpenLogi 发现/传输策略无关的协议级 feature 支持时,直接使用openlogi-hidpp。这一分层从 Cargo.toml 也能印证:本 crate 依赖openlogi-core、hidpp、async-hid、tokio、openlogi-device,同时按平台引入windows-sys(Windows 原生写路径)与objc2-io-kit(macOS IOKit)。
在 lib.rs 的模块注释中可以看到它的接线方式:"openlogi-deviceoverasync-hid: this host's HID stack"。也就是说,openlogi-hid是openlogi-device的backend 实现者——设备层的枚举与打开逻辑通过async-hid落地,Windows 使用复合通道、macOS 依赖 Input Monitoring 权限,并配以磁盘上的探针缓存。
公共入口一览
README 列出了本 crate 的公共入口点,它们在 lib.rs 的pub use host::{...}中被逐一导出。整理如下:
| 类别 | 入口函数 | 说明 |
|---|---|---|
| 设备枚举 | enumerate | 一次性盘点本机 HID++ 接收器与已配对设备 |
| 接收器配对 | list_pairing_receivers/run_pairing/unpair | 列出可配对接收器、执行配对、解除配对 |
| 指针控制 | get_dpi/set_dpi/get_dpi_info | 读取/写入传感器 DPI,以及 DPI 范围与能力 |
| SmartShift | get_smartshift_status/set_smartshift/toggle_smartshift/set_smartshift_sensitivity | 读取/写入完整 SmartShift 状态、在自由滚动与棘轮间切换、调整自动脱离灵敏度 |
| 滚轮 | get_scroll_wheel_mode/set_scroll_resolution/set_scroll_inversion/set_scroll_wheel_mode | 读取滚轮分辨率与反向、设置分辨率/反向或一次性两者 |
| 拇指轮 | thumbwheel 相关 helper | 支持0x2150拇指轮 |
| 可重编程按键 | reprogrammable-control helpers | 支持 ReprogrammableControls 类 feature |
| 键盘 RGB | set_keyboard_color/set_keyboard_color_with | 设置纯色键盘 RGB,优先走类型化的 ColorLedEffects(0x8070)包装,失败时回退到 PerKeyLighting(0x8080)流 |
| 诊断 | dump_features/dump_reprog_controls | 遍历设备的 HID++ feature 表与可重编程按键表 |
除此之外,host.rs 还提供了get_backlight/set_backlight_enabled(键盘背光)、set_fn_lock(Fn 键反转)、play_haptic(触觉波形)、apply_litra(Litra 补光灯)、dump_firmware_entities(固件实体)、read_battery_raw(原始电池报告)、enumerate_standalone(独立设备枚举)与watch_hotplug(热插拔事件订阅)等入口。
入口函数签名示例
从 host.rs 摘取几个典型入口的真实签名,便于读者对照使用:
/// Read the sensor DPI of the device `route` reaches. pub async fn get_dpi(route: &DeviceRoute) -> Result<Dpi, WriteError> /// Write a new sensor DPI to the device `route` reaches. pub async fn set_dpi(route: &DeviceRoute, dpi: Dpi) -> Result<(), WriteError> /// Flip the device `route` reaches between free-spin and ratchet. pub async fn toggle_smartshift(route: &DeviceRoute) -> Result<SmartShiftMode, WriteError> /// Set every key of the keyboard `route` reaches to one colour. pub async fn set_keyboard_color(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<(), WriteError> /// Set every key to one colour over a chosen lighting feature. pub async fn set_keyboard_color_with( route: &DeviceRoute, method: LightingMethod, r: u8, g: u8, b: u8, ) -> Result<(), WriteError> /// Walk the HID++ feature table of the device `route` reaches. pub async fn dump_features(route: &DeviceRoute) -> Result<Vec<FeatureEntry>, WriteError>注意这些入口均以DeviceRoute为寻址参数。其底层实现都遵循同一模式:device::xxx(&*native_backend(), route, ...),即先把本机 backend 提供给设备层实现,再执行具体操作。设备层自身的类型(Dpi、DpiInfo、SmartShiftStatus、ScrollResolution、LightingMethod、FeatureEntry、ReprogControlEntry、FirmwareEntity、HapticWaveform、LitraModel等)经由pub use openlogi_device::*原样再导出,调用方只需要一个统一的路径即可触达协议与平台两侧。
底层原理:如何在宿主 HID 栈中识别 HID++ 设备
HID++ 长报告 vendor collection 白名单
openlogi-hid不依赖读取 HID report descriptor(async-hid 0.4只在 Linux 上暴露描述符),而是在枚举阶段直接用(usage_page, usage_id)预过滤出 Logitech HID++ vendor collection。transport.rs 中定义了一张三元素常量表:
const HIDPP_LONG_COLLECTIONS: [(u16, u16, bool); 3] = [ (0xff00, 0x0002, false), (0xff43, 0x0202, true), (0xff43, 0x0602, false), ];其中long_only标志标记该传输是否只暴露长报告。各条目的含义如下:
| usage_page | usage_id | long_only | 对应传输 |
|---|---|---|---|
0xFF00 | 0x0002 | 否 | USB、Logi Bolt / Unifying 接收器、Bluetooth-classic 设备(如通过 BT 连接的 MX Master) |
0xFF43 | 0x0202 | 是 | Bluetooth-Low-Energy 直连设备(如 Logitech Lift / Signature 鼠标),只有长报告 |
0xFF43 | 0x0602 | 否 | 有线 G 系列游戏键盘(如 G513),同时携带长短两种报告宽度 |
long_only = true意味着该传输上没有短报告(0x10)collection,因此短 HID++ 请求必须升级(up-convert)为长报告发出——这个动作由hidpp通道负责。在AsyncHidChannel::supports_short_long_hidpp(transport.rs)中可以看到这一判定如何传递给协议层:USB / 接收器 collection 返回(true, true),而 BLE-direct collection 返回(false, true)。把标志放在常量表里还有一个工程上的好处:新增一种 long-only 传输只需在此表中加一行,无需改动第二处。
排除非 HID++ 节点:Litra 与接收器子节点
即使命中了上述 collection,也不代表该节点一定走 HID++ 通道。transport.rs 的is_hidpp_candidate完整条件为:
vendor_id == LOGITECH_VENDOR_ID && is_hidpp_long_collection(usage_page, usage_id) && !matches_litra(vendor_id, product_id, usage_page, usage_id) && !receiver_child- Litra 例外:Litra Glow 补光灯刻意复用了与 Logitech HID++ 外设相同的 BLE usage collection,因此必须按完整的产品/usage 元组排除;其余产品该 collection 仍然有效。
- 接收器子节点例外(仅 Linux):Linux 的
hid-logitech-dj内核驱动会为 Unifying/Bolt 接收器的每个配对设备创建虚拟 hidraw 子节点。这些节点暴露与接收器相同的 HID++ 长报告 collection,但 HID++ 通信必须走接收器节点本身,直接探测子节点会导致长时间超时且没有任何有效 inventory。transport.rs 的is_receiver_child_node通过解析 sysfs 路径判断:子节点的路径形如.../0003:046D:C52B.0009/0003:046D:4076.000A,即已知接收器 PID 作为路径中的父目录组件出现,而接收器自身的路径终止于.../0003:046D:C52B.0009。
这一识别逻辑在 transport/tests.rs 中有完整的单元测试覆盖:matches_usb_ble_and_keyboard_hidpp_collections验证三个 collection 均命中且普通桌面鼠标 collection(0x0001/0x0002)不命中;litra_ble_collection_is_not_a_hidpp_candidate验证 Litra 被排除而普通 BLE 鼠标(如0x046d:0xb023)仍被保留;child_of_unifying_receiver_is_detected等测试覆盖 Unifying / Bolt / Lightspeed 接收器子节点与普通设备 sysfs 路径的判别。
每个 HID++ 设备一个节点的保证
由于过滤只基于 vendor collection,且每类 collection 在操作系统上对应唯一的 usage 对,因此"一个物理 HID++ 设备恰好映射一个 HID 节点",这在所有受支持平台上都成立(transport.rs 的注释明确说明了这一点)。
通道建立:从原始 HID 节点到 HID++ 通道
打开流程与平台差异
open_hidpp_channel(transport.rs)在打开节点前先检查进程级设备 I/O 门(device_io.allows_io()),随后按平台分支:
- Windows:短报告(
0x10)与长报告(0x11)collection 被暴露为两个独立的设备接口,因此必须同时打开两者,并按 report id 路由——这正是WindowsHidppChannel的职责。此外,当async-hid的异步写路径失败时,Windows 还有原生 Win32 HID report 写回退(windows_hid.rs),这是 Cargo.toml 中引入windows-sys的原因。 - 非 Windows(Linux / macOS):单个节点同时承载两种报告(或仅长报告),通过
AsyncHidChannel处理。dev.open()失败时会调用open_error做错误分类。
macOS 的 Input Monitoring 权限门禁
macOS 上打开 Logitech HID 节点经由IOHIDManager,它被系统以 Input Monitoring 隐私权限(TCC)门禁:未授权时每次IOHIDDeviceOpen都被静默拒绝,HID++ 设备永远不会出现,且只留一条 debug 日志。permissions.rs 提供了两个入口:
has_access():查询当前进程是否持有 Input Monitoring 权限(macOS 之外恒为true,因为其他平台没有此类隐私门禁);request_access():弹出系统授权对话框。它阻塞调用线程直到用户应答(或状态已确定即立即返回),因此必须放到异步运行时之外执行(如tokio::task::spawn_blocking)。
更重要的是:request_access()必须在Agent 进程中调用,而不是 GUI。TCC 授权是按"提出请求的代码签名身份"作用域的——真正打开 HID 设备的是 Agent,若由 GUI 弹出授权,则授权会落在错误的进程身份上。open_error(transport.rs)也会在打开失败时把权限状态折叠进错误消息:根据permissions::has_access()的结果,提示用户"在 系统设置 → 隐私与安全性 → 输入监视 中为 OpenLogi Agent 授权"或"可能是另一个应用独占设备,或 macOS 提供了过期的权限会话(注销后重新登录)"。
共享的软件 ID 租约:并发通道不串号
HID++ 协议用(device, feature, function, software_id)四元组关联请求与响应。多个并发打开同一物理 HID 节点的通道共享操作系统输入报告流,如果软件 ID 复用,一个响应可能满足错误的打开请求。transport.rs 用一个进程级原子位图SW_ID_LEASES维护1..=15的租约,关键设计是:
- 每个通道在其生命周期内租用一个固定软件 ID(
SwIdPolicy::Leased),不轮转——轮转序列在并发通道之间最终会撞上同一个 ID 并串号; - 通道 drop 时通过
free_sw_id归还; - 15 个 ID 全部被占用时拒绝打开(
configure_channel_sw_ids返回错误),而不是回退到默认 ID 1——默认 ID 1 此时必然已被某个活跃通道持有,回退会静默复现串号问题。拒绝表现为一次失败的探测,inventory ledger 会在下一轮重放并重试。
断开处理与进程级 I/O 门
AsyncHidChannel::read_report在遇到HidError::Disconnected时会标记断开并pending()停驻,而不是向外抛错——hidpp的读循环会对错误进行重试,把断线错误抛出去会让核心忙转,直到 inventory watcher 驱逐该通道;RawHidChannel::read_report契约保证调用方会把该 future 与通道关闭信号竞争,drop 时读任务自然拆除(transport.rs)。
同时,整个本机 HID 栈受一个进程级生命周期权威DeviceIoGate控制(transport.rs):枚举、打开、读、写之前都会检查allows_io(),被挂起时返回统一的"host device I/O is suspended"错误。host::device_io_signal()与host::device_io_gate()分别向宿主生命周期观察者提供控制端与订阅端。
枚举、探针缓存与热插拔
一次性枚举与持久化枚举
host.rs 提供了两种枚举器:
enumerator():内存探针缓存,一次性调用方(如 CLI)使用——没有暖启动数据、也不留下任何落盘数据;persisted_enumerator():探针缓存落盘到应用数据目录下的probe-cache.json,设备完成一次完整探测后,其身份在重启间保持不变;当数据目录无法解析时自动回退为纯内存模式——暖启动是优化,不是硬性要求。
enumerate()(host.rs)返回Vec<DeviceInventory>,是一次性的接收器与配对设备盘点;enumerate_standalone()则盘点本机识别的独立设备(如直接连接的鼠标、Litra 等)。
探针缓存的容错语义
probe_cache.rs 实现了ProbeCacheStore:JSON 快照通过atomic-write-file原子写入,崩溃不会留下撕裂文件,且在 Windows 上也能可靠替换已有文件。其测试明确了两条容错语义:文件缺失或内容损坏都视为冷启动而非错误(load_tolerates_a_missing_or_unreadable_file),保存时会自动创建父目录(save_creates_the_parent_directory)。
热插拔事件
watch_hotplug()(host.rs)订阅本机 HID 热插拔事件,返回HotplugStream。transport.rs 将async-hid的DeviceEvent::Connected/Disconnected折叠为后端无关的HotplugEvent,并刻意丢弃节点身份——所有消费者都以重新枚举作为响应,携带身份只会诱使调用方去信任它。
单例 backend 与句柄缓存
进程全局只维护一个async-hid后端HID_BACKEND(LazyLock,transport.rs)。原因在注释中写得很清楚:macOS 的async-hidbackend 包裹一个IOHIDManager,每次 reconciliation 都新建/销毁会造成无谓抖动(issue #99);复用长期存活的 backend 正是async-hid的预期用法,还能让设备集合在事件/恢复轮次之间保持"温热"。
NativeBackend(transport/native.rs)内部维护一个以HandleKey = (NodeId, usage_page, usage_id)为键的句柄缓存。之所以要带上 usage 对,是因为 macOS 上async-hid对每个 usage 对各发一个Device,而这些 device 共享同一个 IOKit registry id——如果只按NodeId键控,最后一次枚举的通用 collection 会覆盖掉被选中的 HID++ collection。node_handle_keys_preserve_collections_on_the_same_os_node测试精确验证了这一场景。
接收器配对与独立设备
README 提到的list_pairing_receivers、run_pairing与unpair在本 crate 中是对openlogi-device配对能力的本机接线:list_pairing_receivers()(host.rs)返回Vec<PairingReceiver>。配对相关的错误分类(PairingError)与通知逻辑由openlogi-device的 pairing 模块与openlogi-device-registry的 receiver.rs 提供,本 crate 只负责把 backend 接上。
键盘 RGB 的 feature 回退策略
README 特别强调了set_keyboard_color/set_keyboard_color_with的回退策略:优先使用类型化的ColorLedEffects(0x8070)包装,失败时回退到PerKeyLighting(0x8080)流。set_keyboard_color_with允许调用方显式指定LightingMethod,从而把"用哪个 feature 上色"的决定权交给上层(例如用户已在 GUI 中选择)。这一策略是"发现、路由、回退与错误分类策略在openlogi-hid层"这一 README 断言的直接体现——协议编解码在openlogi-hidpp的 color_led_effects 与 per_key_lighting 中完成,而选路与回退由本 crate 决定。
实战示例:拇指轮原始报告追踪
crates/openlogi-hid/examples/thumbwheel_trace.rs 是本 crate 附带的完整可运行示例,演示了如何直接使用本 crate 打开一个0x2150拇指轮通道并解码其原始事件:
cargo run -p openlogi-hid --example thumbwheel_trace -- <receiver-uid> <slot> # 例如 openlogi list 输出的接收器 id 与槽位示例的运行逻辑展示了上文所有概念的串联:
- 用
DeviceRoute::Bolt { receiver_uid, slot }构造路由,通过ChannelPool::with_backend(host::backend())打开通道; - 用
hidpp::device::Device::new建立协议设备,经root().get_feature(thumbwheel::FEATURE_ID)解析0x2150feature 索引; - 通过
chan.add_msg_listener_guarded订阅通道消息流,把 HID++ v2.0 消息解码为thumbwheelEvent,并打印轮子旋转量(i16::from_be_bytes([p[0], p[1]]))、byte4的旋转状态、byte5中的single_tap/touch/proxy位; tw.divert(WheelDirection::Default)使轮子进入报告注入模式,30 秒后undivert()恢复原生上报。
示例开头的注释给出了一条重要的实操约束:先退出 OpenLogi——一个 HID 节点不能同时服务两个通道,Agent 在捕获期间会持有该通道。运行前请用openlogi list确认接收器 id 与槽位。
诊断能力与调试手段
除dump_features/dump_reprog_controls外,本 crate 还内置了两处对排查问题非常有用的观察点:
- HID++ 节点枚举日志:
enumerate_devices(transport.rs)会为每个 Logitech 节点打一条 debug 日志,包含名称、产品 ID、usage page / usage id 以及是否命中 HID++ collection。当新设备使用了意外的 vendor page(例如新型 BLE 鼠标)时,无需重新编译,直接以OPENLOGI_LOG=debug运行即可诊断; - 打开通道日志:每次实际打开 HID++ 通道都会记录设备名与 VID(transport.rs),且只在首次出现/重连时记录——inventory watcher 复用通道,稳定的连接不应每轮 reconciliation 都刷日志。
与设备层、协议层的边界小结
最终,本 crate 的角色可以用一句话概括:它是 OpenLogi 的"本机 HID 事实来源"。它把协议无关的设备操作(openlogi-device)接到平台相关的传输实现(async-hid、Windows 复合通道、macOS IOKit + Input Monitoring、Linux sysfs 判定)之上,再通过 host.rs 向 CLI(openlogi-cli)、Agent(openlogi-agent)与 GUI(openlogi-desktop)暴露统一入口。协议级 feature 支持留在 openlogi-hidpp 中;设备身份与路由模型由 openlogi-core 定义。三者配合,才构成了 OpenLogi 从 HID 节点到"改一个键、调一档 DPI"的完整链路。
【免费下载链接】OpenLogi⚡️A native, local-first alternative to Logitech Options+, written in Rust 🦀 — remap buttons, DPI, and SmartShift over HID++. No account, no telemetry.项目地址: https://gitcode.com/GitHub_Trending/op/OpenLogi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考