Skip to content

feat(hid): dual-handle HID++ channel for Windows short/long reports#73

Open
josiah-nelson wants to merge 2 commits into
AprilNEA:masterfrom
josiah-nelson:feat/windows-hidpp-dual-handle
Open

feat(hid): dual-handle HID++ channel for Windows short/long reports#73
josiah-nelson wants to merge 2 commits into
AprilNEA:masterfrom
josiah-nelson:feat/windows-hidpp-dual-handle

Conversation

@josiah-nelson

Copy link
Copy Markdown

Summary

Adds Windows support for the HID++ transport by handling a Windows-specific quirk: the receiver splits HID++ short (0x10) and long (0x11) reports into two separate top-level collections — and therefore two separate device handles — whereas macOS/Linux expose both report IDs on one handle.

Before this change, every HID++ command on Windows failed with ERROR_INVALID_FUNCTION (0x80070001): openlogi list showed the Bolt receiver but "no paired devices", and diag could not read or write anything.

Root cause

On Windows the Bolt receiver enumerates as:

0xff00 / 0x0001   ->  accepts short (0x10), rejects long  (separate handle)
0xff00 / 0x0002   ->  accepts long  (0x11), rejects short (separate handle)

A handle rejects any report ID that isn't its own with ERROR_INVALID_FUNCTION before the device sees it (confirmed with a per-collection write probe). The transport matched only 0xff00/0x0002 (long), so the short-report receiver-register access used for pairing/device enumeration (HID++ 1.0) was sent to the long-only handle and failed. macOS (IOHIDManager) and Linux (hidraw) accept both IDs on a single handle, which is why the single-handle AsyncHidChannel was sufficient there. async-hid's write path is not at fault — it correctly zero-pads to OutputReportByteLength.

Change

  • On Windows, enumeration pairs each long-report collection with its short-report sibling on the same physical interface, matched by a device-path grouping key that strips the &ColNN hardware-ID token and the trailing instance-ID collection index.
  • A new WinDualChannel (a RawHidChannel) holds both handles, routes each write by its leading report ID (0x10 → short handle, 0x11 → long handle), and merges reads from both handles (whichever yields a report first; the other read stays armed, so nothing is dropped).
  • A node with no short companion (long-only BLE-direct) degrades to single-handle behaviour and reports short-unsupported, so the hidpp channel up-converts shorts to long exactly as before.
  • enumerate_hidpp_devices / open_hidpp_channel now yield an opaque HidppNode that derefs to DeviceInfo (so callers can still pre-filter on vendor/product ID before opening).

Scope & safety

Everything new is #[cfg(windows)]. macOS and Linux code paths are unchanged — the existing single-handle AsyncHidChannel is retained verbatim on non-Windows targets. This cannot regress the currently-supported platform.

Validation

On a Logi Bolt receiver (with Logi Options+ running — no exclusive-access conflict):

  • openlogi list reads the receiver UID, pairing count, and all paired device codenames/kinds:
    Logi Bolt Receiver (E2AC869B3F97989F, vid=046d pid=c548)
      ├─ slot 1 ● MX KEYS S (keyboard, …, battery=60%)
      ├─ slot 2 ○ MX Master 3S (mouse)
      └─ slot 3 ● MX Anywhere 3 (mouse, …, battery=90%)
    
  • DPI round-trip (MX Anywhere 3): 1000 → 1200 → 1000 (read-back OK).
  • SmartShift round-trip: Ratchet → Free → Ratchet (read-back OK).

Tests

Adds Windows grouping-key unit tests built from real Bolt device paths (short/long collections of one interface collapse to the same key; distinct interfaces do not). cargo fmt, cargo clippy -p openlogi-hid (clean), and cargo test -p openlogi-hid (21 passing) all green on x86_64-pc-windows-msvc.

Notes

  • This is the first piece of Windows support; the GUI (openlogi-gui / GPUI), the OS event hook, and packaging are separate follow-ups.
  • Unrelated: clippy::result_large_err fires in openlogi-core/src/config.rs under newer stable toolchains (not touched here) — worth a separate look when the pinned toolchain is bumped.

On Windows the Bolt receiver exposes HID++ short (report 0x10) and long
(report 0x11) reports as two separate top-level collections, and therefore
two separate device handles (0xff00/0x0001 = short, 0xff00/0x0002 = long).
A handle rejects any foreign report id with ERROR_INVALID_FUNCTION before
the device sees it, so the short-report receiver-register access used for
pairing and device enumeration failed against the long-only handle the
transport opened — every HID++ command returned 0x80070001.

macOS (IOHIDManager) and Linux (hidraw) accept both report ids on a single
handle, which is why the single-handle AsyncHidChannel sufficed there.

On Windows, pair the two collections of one physical interface (matched by a
device-path grouping key that strips the &ColNN token and trailing
collection index) into a new WinDualChannel that routes each write by its
leading report id and merges reads from both handles. A node with no short
companion (long-only BLE-direct) degrades to the previous single-handle
behaviour and reports short-unsupported, so the hidpp channel up-converts
shorts to long as before. Non-Windows targets are unchanged (cfg-gated);
enumerate/open now yield an opaque HidppNode that derefs to DeviceInfo.

Validated on a Logi Bolt receiver (Options+ running, no contention):
- `openlogi list` reads the receiver UID, pairing count, and all paired
  device codenames/kinds (MX KEYS S, MX Master 3S, MX Anywhere 3).
- DPI round-trip on MX Anywhere 3: 1000 -> 1200 -> 1000 (read-back OK).
- SmartShift round-trip: Ratchet -> Free -> Ratchet (read-back OK).

Adds Windows grouping-key unit tests built from real Bolt device paths.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — adds Windows dual-handle HID++ transport via WinDualChannel, pairing short and long collections by device path grouping key, with a HidppNode opaque type to abstract the platform difference from callers. All existing call sites (inventory.rs, pairing.rs, route.rs) compile transparently via Deref<Target = DeviceInfo>. Everything new is #[cfg(windows)] — macOS/Linux paths are unchanged.

  • HidppNode opaque type — wraps single-handle (non-Windows) or dual-handle (Windows) device ownership; Derefs to DeviceInfo for pre-filter filtering
  • WinDualChannel — routes writes by leading report ID (0x10 → short, 0x11 → long); merges reads via futures_lite::future::or with documented cancel-safety analysis
  • grouping_key/normalize_collection_path — strips &ColNN and trailing instance index from Windows HID device paths to pair short/long siblings on the same physical interface
  • Windows enumeration — indexes short HID++ collections (0xff00/0x0001) by grouping key, attaches each to its long sibling; degrades gracefully to single-handle long-only when no short companion found

Pullfrog  | View workflow run | Using Big Pickle (free) (credentials for Anthropic not configured) | 𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant