From ac1b6d8fff5049b0c3916d0d05d0fc8b0393ca24 Mon Sep 17 00:00:00 2001 From: ydw1904 Date: Wed, 7 Oct 2026 20:52:49 +0800 Subject: [PATCH] feat(corsair): read-only Bragi driver for the IRONCLAW RGB WIRELESS Corsair's newer mice speak Bragi (ckb-next's name, OpenRGB's Corsair Peripheral V2): 64-byte output reports on the 0xFF42 interface, byte 0 is 0x08 | slot, where slot 0 is the device on the cable and slots 1-7 are the devices paired to a SLIPSTREAM receiver. The codec (src/corsair/bragi.ts, re-exported from the corsair entry point) re-encodes all 202 frames of a community iCUE capture byte for byte: DPI X and Y writes to slot 1 through receiver 0x1bdc, Discord ticket 0159. GET replies were not captured; their layout and the property ids follow ckb-next, OpenRGB and OpenLinkHub, which agree with each other. CorsairBragiHidClient reads identity, firmware, mode, polling rate, battery and live DPI over the cable (0x1b4c) or a receiver (0x1b66, 0x1bdc) and reports settingsReady false. It never writes and never changes the hardware/software mode: the DPI write is encoded and tested, but stays out of the driver until a capture shows it sticks with iCUE closed. Co-Authored-By: Claude Opus 5.5 --- .../corsair-ironclaw-rgb-wireless/README.md | 34 ++ .../dpi-slider.hex | 408 ++++++++++++++++++ .../usb-descriptors.txt | 21 + src/corsair/bragi.test.ts | 97 +++++ src/corsair/bragi.ts | 174 ++++++++ src/corsair/index.ts | 4 + src/drivers/corsair/bragi-hid.test.ts | 143 ++++++ src/drivers/corsair/bragi-hid.ts | 219 ++++++++++ src/drivers/registry.test.ts | 2 +- src/drivers/registry.ts | 4 +- src/drivers/vendors.ts | 15 + 11 files changed, 1119 insertions(+), 2 deletions(-) create mode 100644 captures/corsair-ironclaw-rgb-wireless/README.md create mode 100644 captures/corsair-ironclaw-rgb-wireless/dpi-slider.hex create mode 100644 captures/corsair-ironclaw-rgb-wireless/usb-descriptors.txt create mode 100644 src/corsair/bragi.test.ts create mode 100644 src/corsair/bragi.ts create mode 100644 src/drivers/corsair/bragi-hid.test.ts create mode 100644 src/drivers/corsair/bragi-hid.ts diff --git a/captures/corsair-ironclaw-rgb-wireless/README.md b/captures/corsair-ironclaw-rgb-wireless/README.md new file mode 100644 index 0000000..34afe59 --- /dev/null +++ b/captures/corsair-ironclaw-rgb-wireless/README.md @@ -0,0 +1,34 @@ +# Corsair IRONCLAW RGB WIRELESS fixtures + +Reference material for `src/corsair/bragi.ts` and +`src/drivers/corsair/bragi-hid.ts`. From a community USBPcap capture +(ticket-0159, Windows, 2026-10-02): the mouse paired to a SLIPSTREAM WIRELESS +USB Receiver (VID `0x1b1c`, PID `0x1bdc`) while the owner dragged iCUE's DPI +slider. No serial numbers or personal data are included; the pointer traffic +and the other USB devices in the capture were left out. + +| File | Trust | What it is | +|-|-|-| +| `dpi-slider.hex` | Vendor capture | Every frame on the receiver's interface 1: 101 pairs of `SET 0x21` (DPI X) and `SET 0x22` (DPI Y) addressed to slot 1 (`09 01 …`), 1391 to 11,288 DPI, each answered `01 01 00` (slot 1, SET, ok). `src/corsair/bragi.test.ts` re-encodes all 202 requests byte for byte. | +| `usb-descriptors.txt` | Vendor capture | The receiver's device and configuration descriptors: six HID interfaces, Bragi on interface 1 (EP `0x04` OUT, `0x84` IN), notifications on interface 2. | + +Why this is the IRONCLAW and not the M75 Wireless (OpenRGB names `0x1bdc` +after the M75 receiver; OpenLinkHub lists it as a generic SLIPSTREAM +receiver): the slider values only fit iCUE's 100 to 18,000 scale in 208 steps +of 86.06 DPI, and the M75 Wireless goes to 26,000. + +## Not captured yet + +- **Any GET reply.** iCUE had already set the mouse up before the capture + started, so there is no handshake and no status read. The driver's GET + layout (value little-endian from byte 3) and every property id other than + `0x21`/`0x22` come from ckb-next, OpenRGB and OpenLinkHub, not from this mouse. +- **Hardware mode.** iCUE was running, so the mouse was presumably in software + mode. Whether `0x21`/`0x22` read back and stick with iCUE closed is unknown, + which is why the driver is read-only. +- **The HID report descriptors**, which name each 0xFF42 collection's usage. + Capturing a replug of the receiver would include them. +- **Wired mode** (PID `0x1b4c`, slot 0) and the IRONCLAW's own receiver + (`0x1b66`, from ckb-next): no traffic seen. +- **Other settings**: DPI stages, polling rate, lift-off, angle snapping, + sleep, lighting. diff --git a/captures/corsair-ironclaw-rgb-wireless/dpi-slider.hex b/captures/corsair-ironclaw-rgb-wireless/dpi-slider.hex new file mode 100644 index 0000000..95cf2e1 --- /dev/null +++ b/captures/corsair-ironclaw-rgb-wireless/dpi-slider.hex @@ -0,0 +1,408 @@ +# Corsair IRONCLAW RGB WIRELESS through a SLIPSTREAM receiver (USB 0x1b1c:0x1bdc), ticket-0159. +# iCUE on Windows, owner dragging the DPI slider; USBPcap, 2026-10-02. Vendor interface 1 only +# (EP 0x04 OUT, EP 0x84 IN). Format: , where > is +# host to device and < is device to host, full 64-byte frames with trailing zeros dropped. +15.385 > 09 01 21 00 6f 05 +15.391 < 01 01 +15.392 > 09 01 22 00 6f 05 +15.397 < 01 01 +15.595 > 09 01 21 00 c5 05 +15.604 < 01 01 +15.605 > 09 01 22 00 c5 05 +15.612 < 01 01 +15.613 > 09 01 21 00 1b 06 +15.618 < 01 01 +15.619 > 09 01 22 00 1b 06 +15.626 < 01 01 +15.634 > 09 01 21 00 71 06 +15.641 < 01 01 +15.642 > 09 01 22 00 71 06 +15.647 < 01 01 +15.651 > 09 01 21 00 1d 07 +15.658 < 01 01 +15.659 > 09 01 22 00 1d 07 +15.665 < 01 01 +15.668 > 09 01 21 00 73 07 +15.674 < 01 01 +15.675 > 09 01 22 00 73 07 +15.681 < 01 01 +15.685 > 09 01 21 00 1f 08 +15.692 < 01 01 +15.693 > 09 01 22 00 1f 08 +15.698 < 01 01 +15.701 > 09 01 21 00 cb 08 +15.706 < 01 01 +15.707 > 09 01 22 00 cb 08 +15.713 < 01 01 +15.718 > 09 01 21 00 22 09 +15.724 < 01 01 +15.725 > 09 01 22 00 22 09 +15.731 < 01 01 +15.734 > 09 01 21 00 ce 09 +15.742 < 01 01 +15.743 > 09 01 22 00 ce 09 +15.748 < 01 01 +15.751 > 09 01 21 00 24 0a +15.760 < 01 01 +15.760 > 09 01 22 00 24 0a +15.767 < 01 01 +15.768 > 09 01 21 00 7a 0a +15.776 < 01 01 +15.777 > 09 01 22 00 7a 0a +15.782 < 01 01 +15.783 > 09 01 21 00 d0 0a +15.788 < 01 01 +15.789 > 09 01 22 00 d0 0a +15.796 < 01 01 +15.797 > 09 01 21 00 7c 0b +15.804 < 01 01 +15.805 > 09 01 22 00 7c 0b +15.811 < 01 01 +15.812 > 09 01 21 00 28 0c +15.818 < 01 01 +15.819 > 09 01 22 00 28 0c +15.825 < 01 01 +15.826 > 09 01 21 00 2a 0d +15.829 < 01 01 +15.830 > 09 01 22 00 2a 0d +15.836 < 01 01 +15.837 > 09 01 21 00 80 0d +15.844 < 01 01 +15.845 > 09 01 22 00 80 0d +15.853 < 01 01 +15.854 > 09 01 21 00 82 0e +15.860 < 01 01 +15.861 > 09 01 22 00 82 0e +15.868 < 01 01 +15.869 > 09 01 21 00 db 0f +15.875 < 01 01 +15.876 > 09 01 22 00 db 0f +15.883 < 01 01 +15.885 > 09 01 21 00 33 11 +15.893 < 01 01 +15.894 > 09 01 22 00 33 11 +15.903 < 01 01 +15.904 > 09 01 21 00 e1 12 +15.912 < 01 01 +15.913 > 09 01 22 00 e1 12 +15.924 < 01 01 +15.925 > 09 01 21 00 3c 15 +15.932 < 01 01 +15.933 > 09 01 22 00 3c 15 +15.948 < 01 01 +15.949 > 09 01 21 00 96 17 +15.960 < 01 01 +15.961 > 09 01 22 00 96 17 +15.969 < 01 01 +15.969 > 09 01 21 00 a1 1c +15.975 < 01 01 +15.976 > 09 01 22 00 a1 1c +15.987 < 01 01 +15.987 > 09 01 21 00 a7 1f +15.993 < 01 01 +15.994 > 09 01 22 00 a7 1f +16.003 < 01 01 +16.004 > 09 01 21 00 56 21 +16.014 < 01 01 +16.015 > 09 01 22 00 56 21 +16.021 < 01 01 +16.022 > 09 01 21 00 b0 23 +16.031 < 01 01 +16.032 > 09 01 22 00 b0 23 +16.039 < 01 01 +16.040 > 09 01 21 00 08 25 +16.047 < 01 01 +16.048 > 09 01 22 00 08 25 +16.054 < 01 01 +16.055 > 09 01 21 00 61 26 +16.062 < 01 01 +16.063 > 09 01 22 00 61 26 +16.070 < 01 01 +16.071 > 09 01 21 00 0d 27 +16.077 < 01 01 +16.078 > 09 01 22 00 0d 27 +16.084 < 01 01 +16.085 > 09 01 21 00 0f 28 +16.090 < 01 01 +16.091 > 09 01 22 00 0f 28 +16.097 < 01 01 +16.118 > 09 01 21 00 65 28 +16.125 < 01 01 +16.126 > 09 01 22 00 65 28 +16.131 < 01 01 +16.134 > 09 01 21 00 bd 29 +16.141 < 01 01 +16.142 > 09 01 22 00 bd 29 +16.148 < 01 01 +16.152 > 09 01 21 00 69 2a +16.159 < 01 01 +16.160 > 09 01 22 00 69 2a +16.165 < 01 01 +24.010 > 09 01 21 00 18 2c +24.017 < 01 01 +24.017 > 09 01 22 00 18 2c +24.024 < 01 01 +24.126 > 09 01 21 00 c1 2b +24.132 < 01 01 +24.132 > 09 01 22 00 c1 2b +24.138 < 01 01 +24.145 > 09 01 21 00 6b 2b +24.156 < 01 01 +24.156 > 09 01 22 00 6b 2b +24.166 < 01 01 +24.166 > 09 01 21 00 bd 29 +24.179 < 01 01 +24.179 > 09 01 22 00 bd 29 +24.187 < 01 01 +24.187 > 09 01 21 00 5e 25 +24.196 < 01 01 +24.196 > 09 01 22 00 5e 25 +24.203 < 01 01 +24.203 > 09 01 21 00 ac 21 +24.213 < 01 01 +24.213 > 09 01 22 00 ac 21 +24.222 < 01 01 +24.222 > 09 01 21 00 51 1f +24.228 < 01 01 +24.228 > 09 01 22 00 51 1f +24.236 < 01 01 +24.236 > 09 01 21 00 a5 1e +24.244 < 01 01 +24.244 > 09 01 22 00 a5 1e +24.250 < 01 01 +24.251 > 09 01 21 00 4f 1e +24.258 < 01 01 +24.258 > 09 01 22 00 4f 1e +24.267 < 01 01 +24.267 > 09 01 21 00 f9 1d +24.276 < 01 01 +24.276 > 09 01 22 00 f9 1d +24.283 < 01 01 +24.283 > 09 01 21 00 a1 1c +24.292 < 01 01 +24.292 > 09 01 22 00 a1 1c +24.299 < 01 01 +24.299 > 09 01 21 00 49 1b +24.307 < 01 01 +24.307 > 09 01 22 00 49 1b +24.314 < 01 01 +24.314 > 09 01 21 00 f3 1a +24.321 < 01 01 +24.321 > 09 01 22 00 f3 1a +24.327 < 01 01 +24.327 > 09 01 21 00 9d 1a +24.334 < 01 01 +24.334 > 09 01 22 00 9d 1a +24.341 < 01 01 +24.341 > 09 01 21 00 f0 19 +24.348 < 01 01 +24.348 > 09 01 22 00 f0 19 +24.358 < 01 01 +24.358 > 09 01 21 00 42 18 +24.367 < 01 01 +24.367 > 09 01 22 00 42 18 +24.376 < 01 01 +24.376 > 09 01 21 00 ea 16 +24.385 < 01 01 +24.385 > 09 01 22 00 ea 16 +24.393 < 01 01 +24.393 > 09 01 21 00 e8 15 +24.400 < 01 01 +24.400 > 09 01 22 00 e8 15 +24.406 < 01 01 +24.406 > 09 01 21 00 3c 15 +24.412 < 01 01 +24.412 > 09 01 22 00 3c 15 +24.418 < 01 01 +24.468 > 09 01 21 00 e6 14 +24.475 < 01 01 +24.475 > 09 01 22 00 e6 14 +24.482 < 01 01 +24.482 > 09 01 21 00 e3 13 +24.489 < 01 01 +24.489 > 09 01 22 00 e3 13 +24.496 < 01 01 +24.496 > 09 01 21 00 8d 13 +24.503 < 01 01 +24.503 > 09 01 22 00 8d 13 +24.509 < 01 01 +24.509 > 09 01 21 00 37 13 +24.515 < 01 01 +24.515 > 09 01 22 00 37 13 +24.521 < 01 01 +24.521 > 09 01 21 00 8b 12 +24.528 < 01 01 +24.528 > 09 01 22 00 8b 12 +24.536 < 01 01 +24.595 > 09 01 21 00 35 12 +24.604 < 01 01 +24.604 > 09 01 22 00 35 12 +24.611 < 01 01 +24.611 > 09 01 21 00 df 11 +24.618 < 01 01 +24.618 > 09 01 22 00 df 11 +24.625 < 01 01 +24.648 > 09 01 21 00 89 11 +24.654 < 01 01 +24.654 > 09 01 22 00 89 11 +24.660 < 01 01 +24.684 > 09 01 21 00 33 11 +24.691 < 01 01 +24.691 > 09 01 22 00 33 11 +24.700 < 01 01 +24.708 > 09 01 21 00 dd 10 +24.715 < 01 01 +24.715 > 09 01 22 00 dd 10 +24.723 < 01 01 +24.735 > 09 01 21 00 db 0f +24.743 < 01 01 +24.743 > 09 01 22 00 db 0f +24.751 < 01 01 +24.751 > 09 01 21 00 85 0f +24.758 < 01 01 +24.758 > 09 01 22 00 85 0f +24.765 < 01 01 +24.765 > 09 01 21 00 2f 0f +24.772 < 01 01 +24.772 > 09 01 22 00 2f 0f +24.780 < 01 01 +24.780 > 09 01 21 00 82 0e +24.785 < 01 01 +24.785 > 09 01 22 00 82 0e +24.791 < 01 01 +24.802 > 09 01 21 00 2c 0e +24.809 < 01 01 +24.809 > 09 01 22 00 2c 0e +24.815 < 01 01 +24.817 > 09 01 21 00 d6 0d +24.826 < 01 01 +24.826 > 09 01 22 00 d6 0d +24.833 < 01 01 +24.834 > 09 01 21 00 2a 0d +24.840 < 01 01 +24.840 > 09 01 22 00 2a 0d +24.846 < 01 01 +25.129 > 09 01 21 00 d4 0c +25.135 < 01 01 +25.135 > 09 01 22 00 d4 0c +25.142 < 01 01 +25.445 > 09 01 21 00 7e 0c +25.451 < 01 01 +25.451 > 09 01 22 00 7e 0c +25.458 < 01 01 +25.475 > 09 01 21 00 28 0c +25.483 < 01 01 +25.483 > 09 01 22 00 28 0c +25.489 < 01 01 +25.489 > 09 01 21 00 d2 0b +25.496 < 01 01 +25.496 > 09 01 22 00 d2 0b +25.502 < 01 01 +25.562 > 09 01 21 00 7c 0b +25.569 < 01 01 +25.569 > 09 01 22 00 7c 0b +25.575 < 01 01 +25.586 > 09 01 21 00 26 0b +25.595 < 01 01 +25.595 > 09 01 22 00 26 0b +25.601 < 01 01 +25.657 > 09 01 21 00 d0 0a +25.664 < 01 01 +25.664 > 09 01 22 00 d0 0a +25.670 < 01 01 +26.252 > 09 01 21 00 26 0b +26.260 < 01 01 +26.260 > 09 01 22 00 26 0b +26.266 < 01 01 +26.270 > 09 01 21 00 7c 0b +26.276 < 01 01 +26.276 > 09 01 22 00 7c 0b +26.283 < 01 01 +26.292 > 09 01 21 00 d2 0b +26.299 < 01 01 +26.299 > 09 01 22 00 d2 0b +26.306 < 01 01 +26.690 > 09 01 21 00 7c 0b +26.696 < 01 01 +26.696 > 09 01 22 00 7c 0b +26.703 < 01 01 +26.708 > 09 01 21 00 26 0b +26.714 < 01 01 +26.714 > 09 01 22 00 26 0b +26.720 < 01 01 +26.728 > 09 01 21 00 d0 0a +26.735 < 01 01 +26.735 > 09 01 22 00 d0 0a +26.742 < 01 01 +26.751 > 09 01 21 00 7a 0a +26.758 < 01 01 +26.758 > 09 01 22 00 7a 0a +26.766 < 01 01 +26.808 > 09 01 21 00 24 0a +26.815 < 01 01 +26.815 > 09 01 22 00 24 0a +26.822 < 01 01 +26.823 > 09 01 21 00 ce 09 +26.831 < 01 01 +26.831 > 09 01 22 00 ce 09 +26.839 < 01 01 +26.839 > 09 01 21 00 78 09 +26.845 < 01 01 +26.845 > 09 01 22 00 78 09 +26.852 < 01 01 +26.853 > 09 01 21 00 22 09 +26.858 < 01 01 +26.858 > 09 01 22 00 22 09 +26.865 < 01 01 +27.113 > 09 01 21 00 cb 08 +27.119 < 01 01 +27.119 > 09 01 22 00 cb 08 +27.125 < 01 01 +27.388 > 09 01 21 00 75 08 +27.394 < 01 01 +27.394 > 09 01 22 00 75 08 +27.400 < 01 01 +27.508 > 09 01 21 00 1f 08 +27.513 < 01 01 +27.513 > 09 01 22 00 1f 08 +27.519 < 01 01 +27.739 > 09 01 21 00 c9 07 +27.746 < 01 01 +27.746 > 09 01 22 00 c9 07 +27.751 < 01 01 +27.755 > 09 01 21 00 73 07 +27.761 < 01 01 +27.761 > 09 01 22 00 73 07 +27.769 < 01 01 +27.772 > 09 01 21 00 1d 07 +27.778 < 01 01 +27.778 > 09 01 22 00 1d 07 +27.785 < 01 01 +27.929 > 09 01 21 00 c7 06 +27.934 < 01 01 +27.934 > 09 01 22 00 c7 06 +27.940 < 01 01 +28.087 > 09 01 21 00 71 06 +28.092 < 01 01 +28.092 > 09 01 22 00 71 06 +28.098 < 01 01 +28.106 > 09 01 21 00 1b 06 +28.113 < 01 01 +28.113 > 09 01 22 00 1b 06 +28.119 < 01 01 +28.234 > 09 01 21 00 c5 05 +28.240 < 01 01 +28.240 > 09 01 22 00 c5 05 +28.246 < 01 01 +28.342 > 09 01 21 00 6f 05 +28.347 < 01 01 +28.347 > 09 01 22 00 6f 05 +28.353 < 01 01 +29.435 > 09 01 21 00 c5 05 +29.441 < 01 01 +29.441 > 09 01 22 00 c5 05 +29.448 < 01 01 +29.464 > 09 01 21 00 1b 06 +29.471 < 01 01 +29.471 > 09 01 22 00 1b 06 +29.477 < 01 01 diff --git a/captures/corsair-ironclaw-rgb-wireless/usb-descriptors.txt b/captures/corsair-ironclaw-rgb-wireless/usb-descriptors.txt new file mode 100644 index 0000000..5ef3c1a --- /dev/null +++ b/captures/corsair-ironclaw-rgb-wireless/usb-descriptors.txt @@ -0,0 +1,21 @@ +# SLIPSTREAM WIRELESS USB Receiver, as USBPcap injected it at capture start (ticket-0159). +# Only the device and configuration descriptors: the capture began after enumeration, so the +# HID report descriptors (and with them each collection's usage) are not in it. + +device 12 01 00 02 00 00 00 40 1c 1b dc 1b 04 05 01 02 03 01 + USB 2.00, VID 0x1b1c, PID 0x1bdc, bcdDevice 5.04, one configuration + +config 09 02 a6 00 06 01 00 a0 32 09 04 00 00 01 03 01 02 00 09 21 11 01 00 01 22 55 00 07 05 82 03 + 40 00 01 09 04 01 00 02 03 00 00 00 09 21 11 01 00 01 22 1b 00 07 05 84 03 40 00 01 07 05 04 + 03 40 00 01 09 04 02 00 01 03 00 00 00 09 21 11 01 00 01 22 15 00 07 05 83 03 40 00 01 09 04 + 05 00 01 03 01 01 00 09 21 11 01 00 01 22 41 00 07 05 86 03 40 00 01 09 04 04 00 01 03 00 02 + 00 09 21 11 01 00 01 22 1e 00 07 05 85 03 40 00 01 09 04 03 00 01 03 01 01 00 09 21 11 01 00 + 01 22 5a 00 07 05 81 03 40 00 01 + +interface class/sub/proto report descriptor endpoints (interrupt, 64 bytes, 1 ms) traffic in the capture +0 HID / boot / mouse 85 bytes 0x82 IN pointer reports (15,585) +1 HID / 0 / 0 27 bytes 0x84 IN, 0x04 OUT Bragi requests and replies (202 + 202) +2 HID / 0 / 0 21 bytes 0x83 IN 18 frames: 01 02 01 / 01 02 00, left button down/up from slot 1 +3 HID / boot / kbd 90 bytes 0x81 IN none +4 HID / 0 / mouse 30 bytes 0x85 IN none +5 HID / boot / kbd 65 bytes 0x86 IN none diff --git a/src/corsair/bragi.test.ts b/src/corsair/bragi.test.ts new file mode 100644 index 0000000..e610100 --- /dev/null +++ b/src/corsair/bragi.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + CORSAIR_BRAGI_COMMAND, + CORSAIR_BRAGI_PROPERTY, + CORSAIR_BRAGI_STATUS, + corsairBragiDecode as decode, + corsairBragiEncode as encode, + corsairBragiIsReplyTo, + corsairBragiPacket, +} from "./index.ts"; + +/** ` ` lines from the iCUE capture, zero-padded back to 64 bytes. */ +function capture(): Array<{ dir: string; bytes: Uint8Array }> { + const text = readFileSync(new URL("../../captures/corsair-ironclaw-rgb-wireless/dpi-slider.hex", import.meta.url), "utf8"); + return text.split("\n").filter((line) => line && !line.startsWith("#")).map((line) => { + const [, dir, ...hex] = line.trim().split(/\s+/); + const bytes = new Uint8Array(64); + bytes.set(hex.map((byte) => Number.parseInt(byte, 16))); + return { dir: dir!, bytes }; + }); +} + +function frame(...bytes: number[]): Uint8Array { + const out = new Uint8Array(64); + out.set(bytes); + return out; +} + +test("re-encodes every frame iCUE sent while the DPI slider moved", () => { + const sent = capture().filter((entry) => entry.dir === ">"); + assert.equal(sent.length, 202); + for (const { bytes } of sent) { + const axis = bytes[2] === CORSAIR_BRAGI_PROPERTY.dpiX ? "x" : "y"; + const dpi = bytes[4]! | (bytes[5]! << 8); + assert.deepEqual(encode.setDpi(bytes[0]! & 0x07, axis, dpi), bytes); + } +}); + +test("iCUE writes X then Y with the same value", () => { + const sent = capture().filter((entry) => entry.dir === ">").map(({ bytes }) => ({ property: bytes[2], dpi: bytes[4]! | (bytes[5]! << 8) })); + for (let index = 0; index < sent.length; index += 2) { + assert.equal(sent[index]!.property, CORSAIR_BRAGI_PROPERTY.dpiX); + assert.equal(sent[index + 1]!.property, CORSAIR_BRAGI_PROPERTY.dpiY); + assert.equal(sent[index]!.dpi, sent[index + 1]!.dpi); + } + const values = sent.map((entry) => entry.dpi); + assert.deepEqual([Math.min(...values), Math.max(...values)], [1391, 11288]); +}); + +test("every captured ack answers the request before it: slot 1, SET echoed, status ok", () => { + const frames = capture(); + for (let index = 0; index < frames.length; index += 2) { + const request = frames[index]!; + const reply = frames[index + 1]!; + assert.equal(request.dir, ">"); + assert.equal(reply.dir, "<"); + assert.ok(corsairBragiIsReplyTo(request.bytes, reply.bytes)); + assert.deepEqual(decode.reply(reply.bytes), { slot: 1, command: CORSAIR_BRAGI_COMMAND.set, status: CORSAIR_BRAGI_STATUS.ok, value: 0 }); + } +}); + +test("a reply from another slot or for another command is not the answer", () => { + const request = encode.get(1, CORSAIR_BRAGI_PROPERTY.dpiX); + assert.ok(corsairBragiIsReplyTo(request, frame(0x01, 0x02, 0x00, 0x6f, 0x05))); + assert.ok(!corsairBragiIsReplyTo(request, frame(0x00, 0x02, 0x00, 0x6f, 0x05))); + assert.ok(!corsairBragiIsReplyTo(request, frame(0x01, 0x01, 0x00))); +}); + +// GET replies below follow ckb-next / OpenRGB / OpenLinkHub; none was captured yet. +test("GET requests carry the slot in byte 0 and the property in byte 2", () => { + assert.deepEqual([...encode.get(0, CORSAIR_BRAGI_PROPERTY.slots).subarray(0, 4)], [0x08, 0x02, 0x36, 0x00]); + assert.deepEqual([...encode.get(1, CORSAIR_BRAGI_PROPERTY.productId).subarray(0, 4)], [0x09, 0x02, 0x12, 0x00]); + assert.throws(() => corsairBragiPacket(8, 0x02, 0x12), /slot/); + assert.throws(() => encode.setDpi(1, "x", 0), /DPI/); + assert.throws(() => encode.setDpi(1, "x", 70_000), /DPI/); +}); + +test("decodes values little-endian from byte 3", () => { + assert.deepEqual(decode.reply(frame(0x01, 0x02, 0x00, 0x4c, 0x1b)), { slot: 1, command: 0x02, status: 0, value: 0x1b4c }); + assert.equal(decode.reply(frame(0x01, 0x02, 0x05)).status, CORSAIR_BRAGI_STATUS.unsupported); + assert.equal(decode.firmware(frame(0x01, 0x02, 0x00, 0x03, 0x0b, 0x2a, 0x00)), "3.11.42"); + assert.throws(() => decode.reply(new Uint8Array(3)), /shorter/); +}); + +test("maps polling, battery and slot values", () => { + assert.deepEqual([1, 2, 3, 4].map(decode.pollingRateHz), [125, 250, 500, 1000]); + assert.equal(decode.pollingRateHz(0), null); + assert.equal(decode.batteryPercent(875), 88); + assert.equal(decode.batteryPercent(1001), null); + assert.deepEqual([1, 2, 3, 0].map(decode.batteryState), ["Charging", "Discharging", "Full", "Unknown"]); + assert.deepEqual(decode.slots(0b0000_0010), [1]); + assert.deepEqual(decode.slots(0b1000_0101), [2, 7]); + assert.deepEqual(decode.slots(0), []); +}); diff --git a/src/corsair/bragi.ts b/src/corsair/bragi.ts new file mode 100644 index 0000000..b47d411 --- /dev/null +++ b/src/corsair/bragi.ts @@ -0,0 +1,174 @@ +/** + * Corsair's "Bragi" protocol (ckb-next's name; OpenRGB calls it Corsair + * Peripheral V2), spoken by the IRONCLAW RGB WIRELESS and Corsair's other + * post-2019 peripherals, over the cable and through SLIPSTREAM receivers. + * + * The request layout and the SET ack are confirmed against iCUE in + * `captures/corsair-ironclaw-rgb-wireless/`. GET replies were not captured: + * their layout and the property ids come from ckb-next (`bragi_proto.h`, + * `bragi_common.c`), OpenRGB (`CorsairPeripheralV2Controller.cpp`) and + * OpenLinkHub (`ironclawW.go`), which agree with each other. + * + * Transport: 64-byte output reports without a report id on the vendor + * interface (usage page 0xFF42, interface 1, interrupt endpoints 0x04/0x84). + * Every request is answered by a 64-byte input report on the same interface. + * + * request [0x08 | slot] [command] [property] [0x00] [value, u16 LE] + * reply [slot] [command] [status] [value, LE] + * + * Slot 0 is the device on the cable: the mouse when wired, the receiver when + * wireless. A receiver relays slot n (1–7) to the device paired in that slot. + * Nothing here transports bytes; the WebHID client lives in + * `src/drivers/corsair/bragi-hid.ts`. + */ +export const CORSAIR_BRAGI_USAGE_PAGE = 0xff42; +export const CORSAIR_BRAGI_PACKET_SIZE = 64; +/** Byte 0 of every request, OR'd with the slot. */ +export const CORSAIR_BRAGI_MAGIC = 0x08; +export const CORSAIR_BRAGI_MAX_SLOT = 7; + +export const CORSAIR_BRAGI_COMMAND = { + set: 0x01, + get: 0x02, +} as const; + +export const CORSAIR_BRAGI_PROPERTY = { + /** 1–7 = 8 ms … 0.125 ms, see `CORSAIR_BRAGI_POLLING_RATES`. */ + pollingRate: 0x01, + /** 1 = hardware (onboard settings), 2 = software (iCUE drives the mouse). */ + mode: 0x03, + /** Tenths of a percent, 0–1000. */ + batteryLevel: 0x0f, + batteryStatus: 0x10, + vendorId: 0x11, + productId: 0x12, + firmware: 0x13, + /** Live DPI per axis, plain u16 (no scaling). */ + dpiX: 0x21, + dpiY: 0x22, + /** Receivers only: bit n is set while the device paired in slot n is connected. */ + slots: 0x36, +} as const; + +/** Reply byte 2. Names from OpenRGB's `GetErrorString`; ckb-next agrees on 5. */ +export const CORSAIR_BRAGI_STATUS = { + ok: 0x00, + invalidValue: 0x01, + failed: 0x03, + unsupported: 0x05, +} as const; + +export const CORSAIR_BRAGI_MODE = { + hardware: 0x01, + software: 0x02, +} as const; + +/** Polling property value → Hz. ckb-next stores `value - 1` into its 8 ms … 0.125 ms enum. */ +export const CORSAIR_BRAGI_POLLING_RATES: ReadonlyMap = new Map([ + [1, 125], [2, 250], [3, 500], [4, 1000], [5, 2000], [6, 4000], [7, 8000], +]); + +export interface CorsairBragiMouse { + name: string; + dpiMin: number; + dpiMax: number; +} + +/** + * Mice by the product id they report over the cable, which is also what a + * receiver returns for property 0x12 on their slot (ckb-next names receiver + * children this way). DPI range from iCUE's slider: the captured values only + * fit 100–18,000. + */ +export const CORSAIR_BRAGI_MICE: ReadonlyMap = new Map([ + [0x1b4c, { name: "IRONCLAW RGB WIRELESS", dpiMin: 100, dpiMax: 18_000 }], +]); + +/** + * Receivers that relay to paired slots. 0x1bdc carried the ticket-0159 + * capture; 0x1b66 is the IRONCLAW's stock receiver in ckb-next (`usb.h`). + */ +export const CORSAIR_BRAGI_RECEIVERS: ReadonlyMap = new Map([ + [0x1b66, "IRONCLAW RGB WIRELESS receiver"], + [0x1bdc, "SLIPSTREAM WIRELESS USB Receiver"], +]); + +export const CORSAIR_BRAGI_PRODUCT_IDS: readonly number[] = [...CORSAIR_BRAGI_MICE.keys(), ...CORSAIR_BRAGI_RECEIVERS.keys()]; + +export interface CorsairBragiReply { + slot: number; + command: number; + status: number; + /** Bytes 3–6 little-endian; a property uses as many of them as it needs. */ + value: number; +} + +/** A zero-padded 64-byte request. */ +export function corsairBragiPacket(slot: number, command: number, property: number, ...value: number[]): Uint8Array { + if (!Number.isInteger(slot) || slot < 0 || slot > CORSAIR_BRAGI_MAX_SLOT) { + throw new Error(`Corsair Bragi slot must be 0–${CORSAIR_BRAGI_MAX_SLOT}.`); + } + const packet = new Uint8Array(CORSAIR_BRAGI_PACKET_SIZE); + packet.set([CORSAIR_BRAGI_MAGIC | slot, command, property, 0x00, ...value]); + return packet; +} + +export const corsairBragiEncode = { + get: (slot: number, property: number): Uint8Array => + corsairBragiPacket(slot, CORSAIR_BRAGI_COMMAND.get, property), + /** + * One DPI axis. iCUE sends X then Y on every change and gets a bare ack + * (`01 01 00`) for each. Encoded here so the layout is tested against the + * capture; the read-only driver never sends it. + */ + setDpi: (slot: number, axis: "x" | "y", dpi: number): Uint8Array => { + if (!Number.isInteger(dpi) || dpi < 1 || dpi > 0xffff) throw new Error("Corsair Bragi DPI must be an integer from 1 to 65535."); + const property = axis === "x" ? CORSAIR_BRAGI_PROPERTY.dpiX : CORSAIR_BRAGI_PROPERTY.dpiY; + return corsairBragiPacket(slot, CORSAIR_BRAGI_COMMAND.set, property, dpi & 0xff, dpi >> 8); + }, +} as const; + +/** True when `reply` answers `request`: same slot (low three bits) and the command echoed. */ +export function corsairBragiIsReplyTo(request: Uint8Array, reply: Uint8Array): boolean { + return reply.length >= 3 && (reply[0]! & 0x07) === (request[0]! & 0x07) && reply[1] === request[1]; +} + +export function corsairBragiStatusText(status: number): string { + switch (status) { + case CORSAIR_BRAGI_STATUS.invalidValue: return "invalid value"; + case CORSAIR_BRAGI_STATUS.failed: return "failed"; + case CORSAIR_BRAGI_STATUS.unsupported: return "not supported"; + default: return `error 0x${status.toString(16).padStart(2, "0")}`; + } +} + +export const corsairBragiDecode = { + reply: (reply: Uint8Array): CorsairBragiReply => { + if (reply.length < 7) throw new Error("Corsair Bragi reply is shorter than 7 bytes."); + return { + slot: reply[0]! & 0x07, + command: reply[1]!, + status: reply[2]!, + value: (reply[3]! | (reply[4]! << 8) | (reply[5]! << 16) | (reply[6]! << 24)) >>> 0, + }; + }, + /** Bytes 3–4 major and minor, 5–6 build (u16 LE), as OpenLinkHub prints it. */ + firmware: (reply: Uint8Array): string => { + if (reply.length < 7) throw new Error("Corsair Bragi firmware reply is shorter than 7 bytes."); + return `${reply[3]}.${reply[4]}.${reply[5]! | (reply[6]! << 8)}`; + }, + pollingRateHz: (value: number): number | null => CORSAIR_BRAGI_POLLING_RATES.get(value) ?? null, + /** Tenths of a percent → percent. */ + batteryPercent: (value: number): number | null => (value <= 1000 ? Math.round(value / 10) : null), + /** ckb-next's battery enum: 1 charging, 2 discharging, 3 charged. */ + batteryState: (value: number): "Charging" | "Discharging" | "Full" | "Unknown" => + value === 1 ? "Charging" : value === 2 ? "Discharging" : value === 3 ? "Full" : "Unknown", + /** Connected receiver slots, ascending. Bit 0 is never a paired device. */ + slots: (value: number): number[] => { + const slots: number[] = []; + for (let slot = 1; slot <= CORSAIR_BRAGI_MAX_SLOT; slot += 1) { + if (value & (1 << slot)) slots.push(slot); + } + return slots; + }, +} as const; diff --git a/src/corsair/index.ts b/src/corsair/index.ts index dc4cb4d..afe6243 100644 --- a/src/corsair/index.ts +++ b/src/corsair/index.ts @@ -13,7 +13,11 @@ * replies but must be sent little-endian in SET payloads. Identity fields are * little-endian. Nothing here transports bytes; the WebHID client lives in * `src/drivers/corsair/hid.ts`. + * + * Newer Corsair mice speak Bragi instead; that codec is `./bragi.ts`. */ +export * from "./bragi.js"; + export const CORSAIR_VENDOR_ID = 0x1b1c; export const CORSAIR_USAGE_PAGE = 0xffc2; /** Usage of the config collection. MI_00 also carries an 0xffc2 collection with usage 3 that never answers. */ diff --git a/src/drivers/corsair/bragi-hid.test.ts b/src/drivers/corsair/bragi-hid.test.ts new file mode 100644 index 0000000..dfc4011 --- /dev/null +++ b/src/drivers/corsair/bragi-hid.test.ts @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CorsairBragiHidClient } from "./bragi-hid.ts"; + +const WIRED = 0x1b4c; +const RECEIVER = 0x1bdc; +const NIGHTSWORD = 0x1b5c; + +const hex = (bytes: Uint8Array) => [...bytes.subarray(0, 4)].map((b) => b.toString(16).padStart(2, "0")).join(" "); + +function collection(usagePage: number, output: boolean): HIDCollectionInfo { + const report = [{ reportId: 0, items: [] }]; + return { usagePage, usage: 1, children: [], inputReports: report, outputReports: output ? report : [], featureReports: [] } as unknown as HIDCollectionInfo; +} + +/** + * The mouse's properties as ckb-next / OpenRGB / OpenLinkHub read them: an + * IRONCLAW RGB WIRELESS at 1391 DPI (the capture's first slider value), 1000 + * Hz, hardware mode, 88.0 % and discharging, firmware 3.11.42. + */ +const MOUSE: Record = { + 0x01: 4, + 0x03: 1, + 0x0f: 880, + 0x10: 2, + 0x12: WIRED, + 0x13: 0x002a0b03, + 0x21: 1391, + 0x22: 1391, +}; + +interface FakeOptions { + productId?: number; + /** Receiver property 0x36. */ + slots?: number; + /** Properties the mouse never answers. */ + silent?: number[]; + collections?: HIDCollectionInfo[]; +} + +/** + * Answers each GET with an input report the way the receiver does: byte 0 is + * the slot, byte 1 echoes the command, byte 2 is the status (5 = unsupported + * for anything unknown), bytes 3-6 the value. + */ +function fakeDevice(options: FakeOptions = {}) { + const productId = options.productId ?? WIRED; + const sent: Uint8Array[] = []; + const listeners = new Set<(event: HIDInputReportEvent) => void>(); + const device = { + vendorId: 0x1b1c, + productId, + productName: "CORSAIR", + opened: false, + collections: options.collections ?? [collection(0xff42, true)], + open: async () => { device.opened = true; }, + close: async () => { device.opened = false; }, + addEventListener: (_type: string, listener: (event: HIDInputReportEvent) => void) => listeners.add(listener), + removeEventListener: (_type: string, listener: (event: HIDInputReportEvent) => void) => listeners.delete(listener), + sendReport: async (_reportId: number, data: ArrayBuffer) => { + const request = new Uint8Array(data.slice(0)); + sent.push(request); + const slot = request[0]! & 0x07; + const property = request[2]!; + if (options.silent?.includes(property)) return; + const value = slot === 0 && productId === RECEIVER + ? (property === 0x36 ? options.slots : undefined) + : MOUSE[property]; + const reply = new Uint8Array(64); + reply.set(value === undefined + ? [slot, request[1]!, 0x05] + : [slot, request[1]!, 0x00, value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff, (value >>> 24) & 0xff]); + setTimeout(() => listeners.forEach((listener) => listener({ reportId: 0, data: new DataView(reply.buffer) } as HIDInputReportEvent)), 1); + }, + }; + return { device: device as unknown as HIDDevice, sent }; +} + +test("claims the 0xFF42 command interface of the IRONCLAW and its receivers only", () => { + assert.equal(CorsairBragiHidClient.isSupported(fakeDevice().device), true); + assert.equal(CorsairBragiHidClient.isSupported(fakeDevice({ productId: RECEIVER }).device), true); + // Interface 2 is 0xFF42 too, but input-only. + assert.equal(CorsairBragiHidClient.isSupported(fakeDevice({ collections: [collection(0xff42, false)] }).device), false); + assert.equal(CorsairBragiHidClient.isSupported(fakeDevice({ productId: NIGHTSWORD }).device), false); + assert.equal(CorsairBragiHidClient.isSupported(fakeDevice({ collections: [collection(0xffc2, true)] }).device), false); +}); + +test("reads the wired mouse on slot 0 and never writes", async () => { + const { device, sent } = fakeDevice(); + const status = await new CorsairBragiHidClient(device).readStatus(); + assert.deepEqual(sent.map(hex), ["08 02 12 00", "08 02 13 00", "08 02 03 00", "08 02 01 00", "08 02 0f 00", "08 02 10 00", "08 02 21 00", "08 02 22 00"]); + assert.equal(status.name, "IRONCLAW RGB WIRELESS"); + assert.equal(status.dpi, 1391); + assert.equal(status.dpiY, 1391); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.batteryPercent, 88); + assert.equal(status.batteryState, "Discharging"); + assert.equal(status.deviceMode, "Onboard"); + assert.equal(status.connectionType, "Wired"); + assert.deepEqual(status.firmware, ["Firmware 3.11.42", "Mode: hardware (onboard settings)", "Product id 0x1b4c"]); + assert.equal(status.ui?.settingsReady, false); + assert.equal(status.ui?.valuesVerified, true); + assert.deepEqual(new CorsairBragiHidClient(device).getDpiOptions(), []); +}); + +test("through the receiver, reads the mouse in slot 1 like iCUE addresses it", async () => { + const { device, sent } = fakeDevice({ productId: RECEIVER, slots: 0b10 }); + const status = await new CorsairBragiHidClient(device).readStatus(); + assert.equal(hex(sent[0]!), "08 02 36 00"); + assert.ok(sent.slice(1).every((request) => request[0] === 0x09 && request[1] === 0x02)); + assert.equal(status.name, "IRONCLAW RGB WIRELESS"); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.connectionDetail, "SLIPSTREAM WIRELESS USB Receiver"); + assert.equal(status.dpi, 1391); + assert.equal(status.firmware.at(-1), "Product id 0x1b4c in receiver slot 1"); +}); + +test("a receiver that refuses the slot read falls back to slot 1", async () => { + const { device, sent } = fakeDevice({ productId: RECEIVER }); + const status = await new CorsairBragiHidClient(device).readStatus(); + assert.equal(hex(sent[1]!), "09 02 12 00"); + assert.equal(status.dpi, 1391); +}); + +test("a receiver with nothing connected reports itself and reads no further", async () => { + const { device, sent } = fakeDevice({ productId: RECEIVER, slots: 0 }); + const status = await new CorsairBragiHidClient(device).readStatus(); + assert.deepEqual(sent.map(hex), ["08 02 36 00"]); + assert.equal(status.name, "SLIPSTREAM WIRELESS USB Receiver"); + assert.equal(status.ui?.settingsReady, false); + assert.match(status.ui?.statusNote ?? "", /no mouse is connected/); +}); + +test("a mouse that does not answer the DPI read still identifies", async () => { + const { device, sent } = fakeDevice({ silent: [0x21] }); + const status = await new CorsairBragiHidClient(device).readStatus(); + assert.equal(sent.some((request) => request[2] === 0x22), false); + assert.equal(status.name, "IRONCLAW RGB WIRELESS"); + assert.equal(status.dpi, 0); + assert.equal(status.ui?.valuesVerified, false); + assert.match(status.ui?.statusNote ?? "", /did not answer the DPI read/); +}); diff --git a/src/drivers/corsair/bragi-hid.ts b/src/drivers/corsair/bragi-hid.ts new file mode 100644 index 0000000..ccdd038 --- /dev/null +++ b/src/drivers/corsair/bragi-hid.ts @@ -0,0 +1,219 @@ +import type { MouseStatus } from "../mouse-types.ts"; +import { + CORSAIR_BRAGI_MICE, + CORSAIR_BRAGI_MODE, + CORSAIR_BRAGI_PROPERTY, + CORSAIR_BRAGI_RECEIVERS, + CORSAIR_BRAGI_USAGE_PAGE, + CORSAIR_VENDOR_ID, + corsairBragiDecode, + corsairBragiEncode, + corsairBragiIsReplyTo, + corsairBragiStatusText, +} from "@openmouse/protocol/corsair"; + +/** ckb-next waits 2 s and OpenLinkHub 1 s; iCUE's acks came back within 10 ms through the receiver. */ +const REPLY_TIMEOUT_MS = 1000; +const REPLY_POLL_MS = 5; +const SUPPORTED_POLLING_RATES = [125, 250, 500, 1000]; + +/** + * Corsair Bragi mice (IRONCLAW RGB WIRELESS) over the cable or a SLIPSTREAM + * receiver. Read-only on purpose: iCUE's DPI writes are decoded and encoded + * in the codec, but they were captured with iCUE driving the mouse (software + * mode), and nothing shows yet whether the live-DPI property reads back or + * sticks in hardware mode with iCUE closed. ckb-next flips to software mode + * before reading; this driver never changes the mode, because a mouse left in + * software mode stops applying its onboard settings until something flips it + * back. + * + * A request is one 64-byte output report; its answer is the next input report + * on the same interface with the same slot and command. Replies do not echo + * the property, so all traffic goes through one queue. Windows hands every + * open handle a copy of each input report, so a GET iCUE sends at the same + * moment can answer ours: close iCUE for trustworthy values. + */ +export class CorsairBragiHidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private inbox: Uint8Array[] = []; + private onReport: ((event: HIDInputReportEvent) => void) | null = null; + + constructor(device: HIDDevice) { + this.device = device; + } + + /** + * Corsair VID, a known Bragi mouse or receiver, and the 0xFF42 collection + * that takes output reports. The receiver's interface 2 is also 0xFF42 but + * input-only (button and connection notifications), so it is not claimed. + */ + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== CORSAIR_VENDOR_ID) return false; + if (!CORSAIR_BRAGI_MICE.has(device.productId) && !CORSAIR_BRAGI_RECEIVERS.has(device.productId)) return false; + return hasCommandCollection(device.collections); + } + + get pollIntervalMs(): number { return 30_000; } + + getDpiOptions(): number[] { return []; } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + if (!this.onReport) { + this.onReport = (event) => { + this.inbox.push(new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength)); + }; + this.device.addEventListener("inputreport", this.onReport); + } + } + + async close(): Promise { + if (this.onReport) { + this.device.removeEventListener("inputreport", this.onReport); + this.onReport = null; + } + this.inbox = []; + if (this.device.opened) await this.device.close(); + } + + async readStatus(): Promise { + return await this.run(async () => { + await this.open(); + return await this.readStatusDirect(); + }); + } + + private async readStatusDirect(): Promise { + const receiver = CORSAIR_BRAGI_RECEIVERS.get(this.device.productId); + let slot = 0; + if (receiver) { + const connected = await this.value(0, CORSAIR_BRAGI_PROPERTY.slots).then((bits) => bits === null ? null : corsairBragiDecode.slots(bits)); + const picked = pickSlot(connected); + if (picked === null) { + return identityOnly(receiver, "The receiver answered, but no mouse is connected to it. Wake the mouse (move it or click), then add it again."); + } + slot = picked; + } + + const productId = await this.value(slot, CORSAIR_BRAGI_PROPERTY.productId); + const mouse = CORSAIR_BRAGI_MICE.get(productId ?? this.device.productId); + const name = mouse?.name ?? (productId === null ? receiver ?? "Corsair mouse" : `mouse 0x${hex(productId, 4)}`); + const firmware = await this.get(slot, CORSAIR_BRAGI_PROPERTY.firmware).then(corsairBragiDecode.firmware, () => null); + const mode = await this.value(slot, CORSAIR_BRAGI_PROPERTY.mode); + const polling = await this.value(slot, CORSAIR_BRAGI_PROPERTY.pollingRate); + const level = await this.value(slot, CORSAIR_BRAGI_PROPERTY.batteryLevel); + const charge = await this.value(slot, CORSAIR_BRAGI_PROPERTY.batteryStatus); + const dpiX = await this.value(slot, CORSAIR_BRAGI_PROPERTY.dpiX); + // A mouse that ignores X will not answer Y either; skip the second timeout. + const dpiY = dpiX === null ? null : await this.value(slot, CORSAIR_BRAGI_PROPERTY.dpiY); + + const software = mode === CORSAIR_BRAGI_MODE.software; + const lines = [ + firmware ? `Firmware ${firmware}` : null, + mode === null ? null : `Mode: ${software ? "software (iCUE is driving the mouse)" : mode === CORSAIR_BRAGI_MODE.hardware ? "hardware (onboard settings)" : `unknown (${mode})`}`, + productId === null ? null : `Product id 0x${hex(productId, 4)}${receiver ? ` in receiver slot ${slot}` : ""}`, + ].filter((line): line is string => line !== null); + + return { + brand: "Corsair", + name, + batteryPercent: level === null ? null : corsairBragiDecode.batteryPercent(level), + batteryState: charge === null ? "Unknown" : corsairBragiDecode.batteryState(charge), + dpi: dpiX ?? 0, + dpiY: dpiY ?? dpiX ?? 0, + supportsSeparateDpiAxes: true, + pollingRateHz: polling === null ? 0 : corsairBragiDecode.pollingRateHz(polling) ?? 0, + supportedPollingRates: [...SUPPORTED_POLLING_RATES], + activeProfile: null, + deviceMode: mode === CORSAIR_BRAGI_MODE.hardware ? "Onboard" : software ? "Host" : "Unknown", + connectionType: receiver ? "Wireless" : "Wired", + connectionDetail: receiver ?? "USB", + liftOffDistance: null, + firmware: lines, + ui: { + family: "corsair-bragi", + settingsReady: false, + valuesVerified: dpiX !== null, + defaultDisplayName: `Corsair ${name}`, + statusNote: dpiX === null + ? "Identified the mouse, but it did not answer the DPI read. Close iCUE, reconnect the mouse, and add it again." + : software + ? "Read-only for now. iCUE has the mouse in software mode, so these are iCUE's values." + : "Read-only for now: DPI, polling rate and battery are read from the mouse but cannot be changed here yet.", + }, + }; + } + + /** A property's value, or null when the mouse refuses it or stays silent. */ + private async value(slot: number, property: number): Promise { + return await this.get(slot, property).then((reply) => corsairBragiDecode.reply(reply).value, () => null); + } + + /** One GET: send, then wait for the matching reply. Throws on a non-zero status or a timeout. */ + private async get(slot: number, property: number): Promise { + const request = corsairBragiEncode.get(slot, property); + this.inbox = []; + await this.device.sendReport(0, request.buffer as ArrayBuffer); + const deadline = Date.now() + REPLY_TIMEOUT_MS; + while (Date.now() < deadline) { + const reply = this.inbox.find((candidate) => corsairBragiIsReplyTo(request, candidate)); + if (reply) { + const { status } = corsairBragiDecode.reply(reply); + if (status !== 0) throw new Error(`Corsair property 0x${hex(property, 2)}: ${corsairBragiStatusText(status)}.`); + return reply; + } + await delay(REPLY_POLL_MS); + } + throw new Error(`The Corsair mouse did not answer property 0x${hex(property, 2)}.`); + } + + private async run(operation: () => Promise): Promise { + const result = this.queue.then(operation, operation); + this.queue = result.then(() => undefined, () => undefined); + return await result; + } +} + +/** + * Which receiver slot to talk to. `connected` lists the slots the receiver + * reports as connected, ascending, or is null when that read failed; a failed + * read falls back to slot 1, the one iCUE used, in case this receiver does not + * answer 0x36 at all. Null means no mouse to talk to. + */ +function pickSlot(connected: number[] | null): number | null { + // ponytail: lowest connected slot; read 0x12 per slot if a keyboard ever shares the receiver. + if (connected === null) return 1; + return connected[0] ?? null; +} + +function identityOnly(name: string, note: string): MouseStatus { + return { + brand: "Corsair", + name, + batteryPercent: null, + batteryState: "Unknown", + dpi: 0, + pollingRateHz: 0, + activeProfile: null, + connectionType: "Wireless", + connectionDetail: name, + liftOffDistance: null, + firmware: [], + ui: { family: "corsair-bragi", settingsReady: false, valuesVerified: false, defaultDisplayName: `Corsair ${name}`, statusNote: note }, + }; +} + +function hasCommandCollection(collections: readonly HIDCollectionInfo[]): boolean { + return collections.some((collection) => + (collection.usagePage === CORSAIR_BRAGI_USAGE_PAGE && collection.outputReports.length > 0) + || hasCommandCollection(collection.children)); +} + +function hex(value: number, digits: number): string { + return value.toString(16).padStart(digits, "0"); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index de950a9..03162fd 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -20,7 +20,7 @@ import { COOLERMASTER_PRODUCT_IDS } from "@openmouse/protocol/coolermaster"; const DEVICES_DIR = dirname(fileURLToPath(import.meta.url)); const REPORT_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0x09, 0x0e, 0x0f, 0x10, 0x11, 0x20, 0x51, 0xa1, 0xb3, 0xb4, 0xb5]; -const USAGE_PAGES = [0x01, 0x0a, 0x0c, 0x8c, 0xFF07, 0xff, 0xff00, 0xff01, 0xff02, 0xff05, 0xff0a, 0xff1c, 0xff43, 0xff55, 0xff60, 0xffa0, 0xffc0, 0xffc1, 0xffc2, 0xffff]; +const USAGE_PAGES = [0x01, 0x0a, 0x0c, 0x8c, 0xFF07, 0xff, 0xff00, 0xff01, 0xff02, 0xff05, 0xff0a, 0xff1c, 0xff42, 0xff43, 0xff55, 0xff60, 0xffa0, 0xffc0, 0xffc1, 0xffc2, 0xffff]; // Usage 4 is the Corsair config collection; 0x61 is VIA raw HID; 0xc7 is // Ryunix telemetry; 0x0e is Rapoo's 0xBA configuration channel. const USAGES = [0, 1, 0x0202, 0x0212, 2, 4, 0x0e, 0x10, 0x61, 0xc7]; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 43657ca..19637f7 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -40,6 +40,7 @@ import { WLMouseBeastX4kHidClient } from "./wlmouse/beast-x-4k-hid.ts"; import { WootingHidClient } from "./wooting/hid.ts"; import { ZaunkoenigHidClient } from "./zaunkoenig/hid.ts"; import { CorsairHidClient } from "./corsair/hid.ts"; +import { CorsairBragiHidClient } from "./corsair/bragi-hid.ts"; import { GWolvesHidClient } from "./gwolves/hid.ts"; import { GWolvesXviHidClient } from "./gwolves/xvi-hid.ts"; import { SteelSeriesRival3HidClient } from "./steelseries/hid.ts"; @@ -77,7 +78,7 @@ import { CoolerMasterHidClient } from "./coolermaster/hid.ts"; import { AjazzHidClient } from "./ajazz/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient; export interface DeviceDriver { brand: string; @@ -101,6 +102,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "Motospeed", supports: (device) => MotospeedHidClient.isSupported(device), create: (device) => new MotospeedHidClient(device), score: () => 7 }, { brand: "Zaunkoenig", supports: (device) => ZaunkoenigHidClient.isSupported(device), create: (device) => new ZaunkoenigHidClient(device), score: () => 10 }, { brand: "Corsair", supports: (device) => CorsairHidClient.isSupported(device), create: (device) => new CorsairHidClient(device), score: () => 8 }, + { brand: "Corsair", supports: (device) => CorsairBragiHidClient.isSupported(device), create: (device) => new CorsairBragiHidClient(device), score: () => 8 }, { brand: "Finalmouse", supports: (device) => FinalmouseHidClient.isSupported(device), create: (device) => new FinalmouseHidClient(device), score: () => 10 }, // Scored above EggWeHidClient.supportScore's ~50 ceiling: a 4K v2 dongle // (PID 0x1970) exposes WE-shaped sibling interfaces next to this one. diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 89d8b44..b16e910 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -54,6 +54,8 @@ import { ZAUNKOENIG_VENDOR_ID, } from "@openmouse/protocol/zaunkoenig"; import { + CORSAIR_BRAGI_PRODUCT_IDS, + CORSAIR_BRAGI_USAGE_PAGE, CORSAIR_CONFIG_USAGE, CORSAIR_PRODUCT_IDS, CORSAIR_USAGE_PAGE, @@ -722,6 +724,18 @@ export const CORSAIR_HID_FILTERS: HIDDeviceFilter[] = CORSAIR_PRODUCT_IDS.map((p usage: CORSAIR_CONFIG_USAGE, })); +/** + * Bragi mice and their receivers. Interface 2 of the receiver is 0xFF42 as + * well (input only), so the picker may list two entries; the driver claims + * only the one that takes output reports. The HID report descriptor, and with + * it the usage that would tell the two apart here, has not been captured yet. + */ +export const CORSAIR_BRAGI_HID_FILTERS: HIDDeviceFilter[] = CORSAIR_BRAGI_PRODUCT_IDS.map((productId) => ({ + vendorId: CORSAIR_VENDOR_ID, + productId, + usagePage: CORSAIR_BRAGI_USAGE_PAGE, +})); + export const MICROSOFT_HID_FILTERS: HIDDeviceFilter[] = [ { vendorId: VENDOR_ID.microsoft, productId: MICROSOFT_PRODUCT_CLASSIC, usagePage: MICROSOFT_CLASSIC_USAGE_PAGE, usage: MICROSOFT_CLASSIC_USAGE }, // Classic { vendorId: VENDOR_ID.microsoft, productId: MICROSOFT_PRODUCT_PRO, usagePage: MICROSOFT_PRO_USAGE_PAGE, usage: MICROSOFT_PRO_USAGE }, // Pro @@ -797,6 +811,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ usagePage: ZAUNKOENIG_USAGE_PAGE, })), ...CORSAIR_HID_FILTERS, + ...CORSAIR_BRAGI_HID_FILTERS, { vendorId: VENDOR_ID.finalmouse, productId: 0x0100, usagePage: 0xff00, usage: 0x0001 }, { vendorId: VENDOR_ID.pulsar }, ...PULSAR_XS1_HID_FILTERS,