From 67014aa1ca3d9b142ac2eb24accfd2f613594b01 Mon Sep 17 00:00:00 2001 From: Oricul Date: Thu, 15 Oct 2026 13:25:04 -0500 Subject: [PATCH] feat(corsair): add SCIMITAR RGB ELITE to the Bragi driver The wired SCIMITAR RGB ELITE (USB 0x1b1c:0x1be3) speaks the Bragi protocol the IRONCLAW RGB WIRELESS already uses, so it joins that codec and driver instead of getting a new one. The driver stays read-only. - Table entry with the mouse's 128-byte reports (the IRONCLAW's are 64), DPI range 100-18,000 and command usage 0x01. CorsairBragiMouse gains an optional packetSize that defaults to 64, so existing entries are unchanged. corsairBragiPacket keeps its signature and corsairBragiPacketOfSize is new. - New properties: 0x02 brightness, 0x07 angle snapping, 0x4f active DPI stage. Encoders for polling rate, brightness and angle snapping are tested against the capture but not sent by the driver. - The driver reads firmware, mode, DPI X/Y, polling rate, angle snapping, brightness and the active stage. The extra reads are opt-in per mouse, so the IRONCLAW's request sequence is unchanged. - The picker filter for this PID pins usage 1, which excludes the input-only 0xFF42:2 interface. Evidence: captures/corsair-scimitar-rgb-elite/ (iCUE property traffic, USB descriptors, README). Checked on one unit, firmware 1.2.21: direct HID reads and writes with iCUE closed, and the driver in OpenMouse on Chrome/Linux. Live DPI writes are volatile; polling rate, brightness and angle snapping are stored. See the README for what is not verified. Co-Authored-By: Claude Sonnet 5.5 --- captures/corsair-scimitar-rgb-elite/README.md | 65 +++ .../corsair-scimitar-rgb-elite/properties.hex | 446 ++++++++++++++++++ .../usb-descriptors.txt | 22 + src/corsair/bragi-scimitar.test.ts | 147 ++++++ src/corsair/bragi.ts | 119 ++++- src/drivers/corsair/bragi-hid.test.ts | 99 +++- src/drivers/corsair/bragi-hid.ts | 50 +- src/drivers/vendors.ts | 21 +- 8 files changed, 929 insertions(+), 40 deletions(-) create mode 100644 captures/corsair-scimitar-rgb-elite/README.md create mode 100644 captures/corsair-scimitar-rgb-elite/properties.hex create mode 100644 captures/corsair-scimitar-rgb-elite/usb-descriptors.txt create mode 100644 src/corsair/bragi-scimitar.test.ts diff --git a/captures/corsair-scimitar-rgb-elite/README.md b/captures/corsair-scimitar-rgb-elite/README.md new file mode 100644 index 00000000..0d517aeb --- /dev/null +++ b/captures/corsair-scimitar-rgb-elite/README.md @@ -0,0 +1,65 @@ +# Corsair SCIMITAR RGB ELITE fixtures + +Reference material for `src/corsair/bragi.ts` and `src/drivers/corsair/bragi-hid.ts`. +From a wired SCIMITAR RGB ELITE (USB `0x1b1c:0x1be3`, firmware 1.2.21 as iCUE +reports it), captured 2026-10-15: iCUE on Windows 11 in a virtual machine with the +mouse passed through to the guest, USB traffic recorded with usbmon on the Linux +host. The iCUE build is the one Corsair served on that date; its version was not +recorded. No serial numbers or personal data are included; the other devices on the +bus, the pointer and keyboard interfaces, and the lighting stream were left out. + +| File | Trust | What it is | +|-|-|-| +| `properties.hex` | Vendor capture | 219 request/reply pairs on the vendor interface (interface 1, EP `0x04` OUT / `0x84` IN): the connect-time GETs iCUE sends (`01 02 03 07 11 12 13 14 4f 50 51`), DPI and polling-rate, brightness and angle-snapping SETs, with their replies. `src/corsair/bragi-scimitar.test.ts` re-encodes every request byte for byte. | +| `usb-descriptors.txt` | Vendor capture | The four HID interfaces, their endpoints, and the report descriptors in hex. Interface 1 is vendor page `0xFF42`, usage `0x01`, 128-byte input and output reports with no report id. Interface 2 is `0xFF42`, usage `0x02`, input only. | + +## What differs from the IRONCLAW + +- Reports are **128 bytes**, not 64 (the descriptor says so). The codec takes a per-mouse + `packetSize`; the IRONCLAW entry keeps 64. +- Replies are the same shape: `[slot] [command] [status] [value, LE]`, here slot 0 on + the cable. Status `05` means unsupported (e.g. battery on this wired mouse). +- Extra properties that answered a GET: `02` brightness, `07` angle snapping, `4f` + active DPI stage. + +## Checked on the real mouse + +With iCUE closed, the mouse in hardware mode (`GET 03` = 1) and direct HID access on +Linux, one unit: + +- Every GET in `properties.hex` answers with status `00`; `GET 21`/`22` return the live + DPI, which iCUE itself never reads. +- A live DPI SET (`21`/`22`) is acked and reads back, **and is lost on an unplug and + replug**. The mouse falls back to the stored value of the active stage. +- DPI range, probed with SET on X: 100 and 18,000 are acked ok; 50, 99, 18,001, 20,000, + 26,000 and 65,535 are acked with status `01` (invalid value) and change nothing. +- Polling rate SET `1`/`2`/`3`/`4` = 125/250/500/1000 Hz. The mouse **re-enumerates on + USB** right after the ack, so a read-back needs the new device. The value is stored: + it survived that reset and an unplug. +- Brightness (`0`–`1000`, tenths of a percent; iCUE's steps are 0/33/66/100 %) and angle + snapping (`0`/`1`) are stored across an unplug too. +- `GET 4f` follows the mouse's own DPI button (stage 1 → 2 read 0 → 1 with DPI 800 → 1500) + and kept its value through a replug and through the polling-rate reset. + +## What the driver does with it + +It adds the mouse to the Bragi table and reads name, firmware, mode, DPI X/Y, polling +rate, angle snapping, brightness and the active stage. It stays **read-only**: the +SET encoders for DPI, polling rate, brightness and angle snapping are tested against +the capture but the driver never sends them, and it never changes the mode. + +## Not captured, or not understood + +- **Property `03`** (mode) is read as `1` = hardware, `2` = software like the IRONCLAW + table says. iCUE writes `2`, `1`, `2` at connect; why is unknown. +- **`14`** replies `00 01 04` and **`50`/`51`** reply `0`; their meaning is unknown and the + codec does not name them. +- **Device Memory Mode / onboard profiles.** iCUE's profile slots are a separate + mechanism and are not in these fixtures. `08 01 6e` is what iCUE sends when a slot is + selected, but the mouse acks it with status `05` and `GET 6e` is also `05`, so it is not + shown to switch slots. +- **Lighting.** Frames are `08 06 00 0f` followed by five red values, five green and five + blue; they are not in the fixtures and the codec does not implement them. +- **Other variants.** Only the wired `0x1be3` was exercised. The wireless SCIMITAR models + and receivers are not covered. +- Whether the same properties behave identically at other firmware versions. diff --git a/captures/corsair-scimitar-rgb-elite/properties.hex b/captures/corsair-scimitar-rgb-elite/properties.hex new file mode 100644 index 00000000..52b5cc29 --- /dev/null +++ b/captures/corsair-scimitar-rgb-elite/properties.hex @@ -0,0 +1,446 @@ +# Corsair SCIMITAR RGB ELITE, wired (USB 0x1b1c:0x1be3), Bragi property traffic only. +# iCUE (the build downloaded on 2026-10-15) on Windows 11 in a VM with the mouse passed through; +# captured with usbmon on the Linux host. Vendor interface 1 only (EP 0x04 OUT, EP 0x84 IN). +# Format: , where > is host to device and < is device +# to host, full 128-byte frames with trailing zeros dropped. Only GET (08 02) and SET (08 01) +# frames for properties 01 02 03 07 11 12 13 14 21 22 4f 50 51 (GETs) and 01 02 03 07 21 22 (SETs) +# are kept; the lighting stream, profile transfer and key-assignment traffic are left out. +# Every request is followed by its reply. +47.148 > 08 02 03 +47.150 < 00 02 00 01 +47.150 > 08 01 03 00 02 +47.152 < 00 01 +47.157 > 08 01 03 00 01 +47.167 < 00 01 +47.188 > 08 02 11 +47.190 < 00 02 00 1c 1b +47.190 > 08 02 12 +47.192 < 00 02 00 e3 1b +47.193 > 08 02 03 +47.195 < 00 02 00 01 +47.195 > 08 02 13 +47.197 < 00 02 00 01 02 15 +47.197 > 08 02 14 +47.199 < 00 02 00 00 01 04 +47.199 > 08 01 03 00 02 +47.201 < 00 01 +47.201 > 08 02 11 +47.203 < 00 02 00 1c 1b +47.203 > 08 02 12 +47.205 < 00 02 00 e3 1b +47.207 > 08 02 03 +47.209 < 00 02 00 02 +47.209 > 08 02 01 +47.212 < 00 02 00 04 +47.212 > 08 02 03 +47.214 < 00 02 00 02 +47.216 > 08 02 13 +47.218 < 00 02 00 01 02 15 +47.218 > 08 02 14 +47.220 < 00 02 00 00 01 04 +47.446 > 08 02 02 +47.448 < 00 02 00 e8 03 +48.137 > 08 01 21 00 dc 05 +48.139 < 00 01 +48.139 > 08 01 22 00 dc 05 +48.141 < 00 01 +48.143 > 08 02 07 +48.145 < 00 02 +48.149 > 08 02 02 +48.151 < 00 02 00 e8 03 +48.247 > 08 01 21 00 dc 05 +48.249 < 00 01 +48.249 > 08 01 22 00 dc 05 +48.251 < 00 01 +48.253 > 08 02 4f +48.255 < 00 02 +48.255 > 08 02 50 +48.257 < 00 02 +48.258 > 08 02 51 +48.260 < 00 02 +113.013 > 08 01 21 00 b8 0b +113.015 < 00 01 +113.024 > 08 01 22 00 b8 0b +113.026 < 00 01 +286.009 > 08 01 21 00 dc 05 +286.011 < 00 01 +286.017 > 08 01 22 00 dc 05 +286.019 < 00 01 +346.421 > 08 01 21 00 dc 05 +346.423 < 00 01 +346.423 > 08 01 22 00 dc 05 +346.425 < 00 01 +359.316 > 08 01 21 00 b0 04 +359.318 < 00 01 +359.318 > 08 01 22 00 dc 05 +359.320 < 00 01 +364.562 > 08 01 21 00 b0 04 +364.564 < 00 01 +364.564 > 08 01 22 00 60 09 +364.566 < 00 01 +441.777 > 08 01 01 00 03 +441.786 < 00 01 +565.826 > 08 02 03 +565.829 < 00 02 00 01 +565.830 > 08 01 03 00 02 +565.833 < 00 01 +565.846 > 08 01 03 00 01 +565.857 < 00 01 +565.874 > 08 02 11 +565.877 < 00 02 00 1c 1b +565.878 > 08 02 12 +565.881 < 00 02 00 e3 1b +565.884 > 08 02 03 +565.887 < 00 02 00 01 +565.888 > 08 02 13 +565.891 < 00 02 00 01 02 15 +565.892 > 08 02 14 +565.895 < 00 02 00 00 01 04 +565.896 > 08 01 03 00 02 +565.899 < 00 01 +565.900 > 08 02 11 +565.903 < 00 02 00 1c 1b +565.904 > 08 02 12 +565.907 < 00 02 00 e3 1b +565.912 > 08 02 03 +565.915 < 00 02 00 02 +565.916 > 08 02 01 +565.919 < 00 02 00 03 +565.920 > 08 02 03 +565.923 < 00 02 00 02 +565.924 > 08 02 13 +565.927 < 00 02 00 01 02 15 +565.928 > 08 02 14 +565.931 < 00 02 00 00 01 04 +565.958 > 08 02 02 +565.961 < 00 02 00 e8 03 +565.990 > 08 02 07 +565.993 < 00 02 +566.980 > 08 02 4f +566.983 < 00 02 +566.984 > 08 02 50 +566.987 < 00 02 +566.988 > 08 02 51 +566.991 < 00 02 +566.992 > 08 01 21 00 b0 04 +566.995 < 00 01 +566.996 > 08 01 22 00 60 09 +566.999 < 00 01 +567.000 > 08 02 02 +567.003 < 00 02 00 e8 03 +587.102 > 08 01 01 00 04 +587.112 < 00 01 +643.740 > 08 02 03 +643.742 < 00 02 00 01 +643.743 > 08 01 03 00 02 +643.745 < 00 01 +643.754 > 08 01 03 00 01 +643.764 < 00 01 +643.780 > 08 02 11 +643.782 < 00 02 00 1c 1b +643.782 > 08 02 12 +643.784 < 00 02 00 e3 1b +643.785 > 08 02 03 +643.787 < 00 02 00 01 +643.787 > 08 02 13 +643.789 < 00 02 00 01 02 15 +643.789 > 08 02 14 +643.791 < 00 02 00 00 01 04 +643.791 > 08 01 03 00 02 +643.793 < 00 01 +643.793 > 08 02 11 +643.795 < 00 02 00 1c 1b +643.795 > 08 02 12 +643.797 < 00 02 00 e3 1b +643.800 > 08 02 03 +643.802 < 00 02 00 02 +643.802 > 08 02 01 +643.804 < 00 02 00 04 +643.804 > 08 02 03 +643.806 < 00 02 00 02 +643.806 > 08 02 13 +643.808 < 00 02 00 01 02 15 +643.808 > 08 02 14 +643.810 < 00 02 00 00 01 04 +643.871 > 08 02 02 +643.873 < 00 02 00 e8 03 +643.891 > 08 02 07 +643.893 < 00 02 +644.418 > 08 02 4f +644.420 < 00 02 +644.420 > 08 02 50 +644.422 < 00 02 +644.422 > 08 02 51 +644.424 < 00 02 +644.426 > 08 01 21 00 b0 04 +644.428 < 00 01 +644.428 > 08 01 22 00 60 09 +644.430 < 00 01 +644.430 > 08 02 02 +644.432 < 00 02 00 e8 03 +1616.160 > 08 01 21 00 dc 05 +1616.162 < 00 01 +1616.162 > 08 01 22 00 dc 05 +1616.164 < 00 01 +1616.164 > 08 01 21 00 20 03 +1616.166 < 00 01 +1616.166 > 08 01 22 00 20 03 +1616.168 < 00 01 +1827.836 > 08 01 21 00 dc 05 +1827.838 < 00 01 +1827.839 > 08 01 22 00 dc 05 +1827.842 < 00 01 +1879.980 > 08 01 21 00 b8 0b +1879.982 < 00 01 +1879.985 > 08 01 22 00 b8 0b +1879.987 < 00 01 +1889.603 > 08 01 21 00 70 17 +1889.605 < 00 01 +1889.621 > 08 01 22 00 70 17 +1889.623 < 00 01 +1899.416 > 08 01 21 00 20 03 +1899.418 < 00 01 +1899.420 > 08 01 22 00 20 03 +1899.422 < 00 01 +1988.611 > 08 01 21 00 20 03 +1988.613 < 00 01 +1988.624 > 08 01 22 00 20 03 +1988.626 < 00 01 +2170.852 > 08 01 21 00 90 01 +2170.854 < 00 01 +2170.854 > 08 01 22 00 90 01 +2170.856 < 00 01 +2182.580 > 08 01 21 00 20 03 +2182.582 < 00 01 +2182.586 > 08 01 22 00 20 03 +2182.588 < 00 01 +2194.128 > 08 01 21 00 20 03 +2194.130 < 00 01 +2194.130 > 08 01 22 00 20 03 +2194.132 < 00 01 +2195.500 > 08 01 21 00 20 03 +2195.502 < 00 01 +2195.503 > 08 01 22 00 20 03 +2195.505 < 00 01 +2196.264 > 08 01 21 00 20 03 +2196.266 < 00 01 +2196.266 > 08 01 22 00 20 03 +2196.268 < 00 01 +2197.675 > 08 01 21 00 20 03 +2197.677 < 00 01 +2197.678 > 08 01 22 00 20 03 +2197.680 < 00 01 +2199.271 > 08 01 21 00 20 03 +2199.273 < 00 01 +2199.314 > 08 01 22 00 20 03 +2199.316 < 00 01 +2312.531 > 08 01 21 00 20 03 +2312.533 < 00 01 +2312.533 > 08 01 22 00 20 03 +2312.536 < 00 01 +2314.195 > 08 01 21 00 20 03 +2314.197 < 00 01 +2314.197 > 08 01 22 00 20 03 +2314.199 < 00 01 +2316.033 > 08 01 21 00 20 03 +2316.035 < 00 01 +2316.061 > 08 01 22 00 20 03 +2316.063 < 00 01 +2318.122 > 08 01 21 00 20 03 +2318.124 < 00 01 +2318.157 > 08 01 22 00 20 03 +2318.159 < 00 01 +2405.423 > 08 01 21 00 20 03 +2405.425 < 00 01 +2405.425 > 08 01 22 00 20 03 +2405.427 < 00 01 +2405.427 > 08 01 21 00 b0 04 +2405.429 < 00 01 +2405.429 > 08 01 22 00 60 09 +2405.431 < 00 01 +2510.299 > 08 01 21 00 dc 05 +2510.301 < 00 01 +2510.302 > 08 01 22 00 dc 05 +2510.304 < 00 01 +2510.304 > 08 01 21 00 20 03 +2510.306 < 00 01 +2510.306 > 08 01 22 00 20 03 +2510.309 < 00 01 +2525.221 > 08 01 02 00 94 02 +2525.231 < 00 01 +2525.235 > 08 02 02 +2525.237 < 00 02 00 94 02 +2525.314 > 08 02 02 +2525.316 < 00 02 00 94 02 +2529.802 > 08 01 02 00 4a 01 +2529.812 < 00 01 +2529.814 > 08 02 02 +2529.816 < 00 02 00 4a 01 +2529.858 > 08 02 02 +2529.860 < 00 02 00 4a 01 +2562.280 > 08 01 02 00 94 02 +2562.290 < 00 01 +2562.290 > 08 02 02 +2562.292 < 00 02 00 94 02 +2562.349 > 08 02 02 +2562.351 < 00 02 00 94 02 +2563.869 > 08 01 02 00 e8 03 +2563.879 < 00 01 +2563.893 > 08 02 02 +2563.895 < 00 02 00 e8 03 +2563.928 > 08 02 02 +2563.930 < 00 02 00 e8 03 +2567.503 > 08 01 02 00 94 02 +2567.513 < 00 01 +2567.514 > 08 02 02 +2567.516 < 00 02 00 94 02 +2567.579 > 08 02 02 +2567.581 < 00 02 00 94 02 +2590.299 > 08 01 02 00 e8 03 +2590.309 < 00 01 +2590.312 > 08 02 02 +2590.314 < 00 02 00 e8 03 +2590.393 > 08 02 02 +2590.395 < 00 02 00 e8 03 +2604.170 > 08 01 07 00 01 +2604.180 < 00 01 +2615.473 > 08 01 07 +2615.483 < 00 01 +2730.785 > 08 01 01 00 02 +2730.794 < 00 01 +2767.606 > 08 02 03 +2767.611 < 00 02 00 01 +2767.614 > 08 01 03 00 02 +2767.619 < 00 01 +2767.638 > 08 01 03 00 01 +2767.651 < 00 01 +2767.670 > 08 02 11 +2767.675 < 00 02 00 1c 1b +2767.678 > 08 02 12 +2767.683 < 00 02 00 e3 1b +2767.686 > 08 02 03 +2767.691 < 00 02 00 01 +2767.694 > 08 02 13 +2767.699 < 00 02 00 01 02 15 +2767.702 > 08 02 14 +2767.707 < 00 02 00 00 01 04 +2767.710 > 08 01 03 00 02 +2767.715 < 00 01 +2767.718 > 08 02 11 +2767.723 < 00 02 00 1c 1b +2767.726 > 08 02 12 +2767.731 < 00 02 00 e3 1b +2767.742 > 08 02 03 +2767.747 < 00 02 00 02 +2767.750 > 08 02 01 +2767.755 < 00 02 00 02 +2767.758 > 08 02 03 +2767.763 < 00 02 00 02 +2767.766 > 08 02 13 +2767.771 < 00 02 00 01 02 15 +2767.774 > 08 02 14 +2767.779 < 00 02 00 00 01 04 +2767.806 > 08 02 02 +2767.811 < 00 02 00 e8 03 +2767.870 > 08 02 07 +2767.875 < 00 02 +2770.274 > 08 02 4f +2770.279 < 00 02 +2770.282 > 08 02 50 +2770.287 < 00 02 +2770.290 > 08 02 51 +2770.295 < 00 02 +2770.298 > 08 02 02 +2770.303 < 00 02 00 e8 03 +2788.698 > 08 01 01 00 01 +2788.708 < 00 01 +2831.690 > 08 02 03 +2831.706 < 00 02 00 01 +2831.710 > 08 01 03 00 02 +2831.722 < 00 01 +2831.758 > 08 01 03 00 01 +2831.778 < 00 01 +2831.818 > 08 02 11 +2831.834 < 00 02 00 1c 1b +2831.838 > 08 02 12 +2831.850 < 00 02 00 e3 1b +2831.854 > 08 02 03 +2831.866 < 00 02 00 01 +2831.870 > 08 02 13 +2831.882 < 00 02 00 01 02 15 +2831.886 > 08 02 14 +2831.898 < 00 02 00 00 01 04 +2831.902 > 08 01 03 00 02 +2831.914 < 00 01 +2831.918 > 08 02 11 +2831.930 < 00 02 00 1c 1b +2831.934 > 08 02 12 +2831.946 < 00 02 00 e3 1b +2831.966 > 08 02 03 +2831.978 < 00 02 00 02 +2831.982 > 08 02 01 +2831.994 < 00 02 00 01 +2831.998 > 08 02 03 +2832.010 < 00 02 00 02 +2832.014 > 08 02 13 +2832.026 < 00 02 00 01 02 15 +2832.030 > 08 02 14 +2832.042 < 00 02 00 00 01 04 +2832.070 > 08 02 02 +2832.082 < 00 02 00 e8 03 +2832.198 > 08 02 07 +2832.210 < 00 02 +2836.982 > 08 02 4f +2836.994 < 00 02 +2836.998 > 08 02 50 +2837.010 < 00 02 +2837.014 > 08 02 51 +2837.026 < 00 02 +2837.030 > 08 02 02 +2837.042 < 00 02 00 e8 03 +2850.830 > 08 01 01 00 04 +2850.843 < 00 01 +2925.811 > 08 02 03 +2925.813 < 00 02 00 01 +2925.814 > 08 01 03 00 02 +2925.816 < 00 01 +2925.823 > 08 01 03 00 01 +2925.833 < 00 01 +2925.844 > 08 02 11 +2925.846 < 00 02 00 1c 1b +2925.847 > 08 02 12 +2925.849 < 00 02 00 e3 1b +2925.851 > 08 02 03 +2925.853 < 00 02 00 01 +2925.854 > 08 02 13 +2925.856 < 00 02 00 01 02 15 +2925.857 > 08 02 14 +2925.859 < 00 02 00 00 01 04 +2925.860 > 08 01 03 00 02 +2925.862 < 00 01 +2925.863 > 08 02 11 +2925.865 < 00 02 00 1c 1b +2925.866 > 08 02 12 +2925.868 < 00 02 00 e3 1b +2925.872 > 08 02 03 +2925.874 < 00 02 00 02 +2925.875 > 08 02 01 +2925.877 < 00 02 00 04 +2925.878 > 08 02 03 +2925.880 < 00 02 00 02 +2925.881 > 08 02 13 +2925.883 < 00 02 00 01 02 15 +2925.884 > 08 02 14 +2925.886 < 00 02 00 00 01 04 +2925.905 > 08 02 02 +2925.907 < 00 02 00 e8 03 +2925.929 > 08 02 07 +2925.931 < 00 02 +2926.850 > 08 02 4f +2926.852 < 00 02 +2926.853 > 08 02 50 +2926.856 < 00 02 +2926.856 > 08 02 51 +2926.858 < 00 02 +2926.859 > 08 02 02 +2926.861 < 00 02 00 e8 03 diff --git a/captures/corsair-scimitar-rgb-elite/usb-descriptors.txt b/captures/corsair-scimitar-rgb-elite/usb-descriptors.txt new file mode 100644 index 00000000..6abf5643 --- /dev/null +++ b/captures/corsair-scimitar-rgb-elite/usb-descriptors.txt @@ -0,0 +1,22 @@ +Corsair SCIMITAR RGB ELITE (wired), VID 0x1b1c PID 0x1be3 +bcdUSB 2.00, bcdDevice 1.02, iProduct "CORSAIR SCIMITAR RGB ELITE Gaming Mouse" +(No serial number is recorded here.) + +Interface 0 HID, boot protocol Mouse EP 0x82 IN 64 bytes, bInterval 4 report descriptor 66 bytes +Interface 1 HID, no boot protocol EP 0x84 IN / EP 0x04 OUT 128 bytes, bInterval 4 report descriptor 27 bytes <- Bragi +Interface 2 HID, no boot protocol EP 0x83 IN 64 bytes, bInterval 4 report descriptor 21 bytes +Interface 3 HID, boot protocol Keyboard EP 0x81 IN 64 bytes, bInterval 4 report descriptor 62 bytes + +HID report descriptors (hex, from /sys/class/hidraw/*/device/report_descriptor): + +interface 0 (Generic Desktop / Mouse, 5 buttons, 16-bit X/Y, wheel; no report id) +05010902a1010901a100050919012905150025019505750181029503750181037510950205010930093116018026ff7f810675089501050109381581257f8106c0c0 + +interface 1 (vendor page 0xFF42, usage 0x01: 128-byte input and 128-byte output, no report id) +0642ff0901a101150026ff007508958009018102958009019102c0 + +interface 2 (vendor page 0xFF42, usage 0x02: 64-byte input only) +0642ff0902a101150026ff007508954009028102c0 + +interface 3 (Keyboard with report id 1, plus Consumer Control with report id 2) +05010906a10185010507750119e029e79508810219002967956881021987298e95088102c0050c0901a101850295017510150026ff0319002aff038100c0 diff --git a/src/corsair/bragi-scimitar.test.ts b/src/corsair/bragi-scimitar.test.ts new file mode 100644 index 00000000..14c79bd8 --- /dev/null +++ b/src/corsair/bragi-scimitar.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + CORSAIR_BRAGI_COMMAND, + CORSAIR_BRAGI_MICE, + CORSAIR_BRAGI_PROPERTY, + CORSAIR_BRAGI_STATUS, + CORSAIR_VENDOR_ID, + corsairBragiDecode as decode, + corsairBragiEncode as encode, + corsairBragiIsReplyTo, + corsairBragiPacketOfSize, +} from "./index.ts"; + +const SIZE = 128; +const PRODUCT_ID = 0x1be3; + +/** ` ` lines from the iCUE capture, zero-padded back to 128 bytes. */ +function capture(): Array<{ dir: string; bytes: Uint8Array }> { + const text = readFileSync(new URL("../../captures/corsair-scimitar-rgb-elite/properties.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(SIZE); + bytes.set(hex.map((byte) => Number.parseInt(byte, 16))); + return { dir: dir!, bytes }; + }); +} + +/** Request/reply pairs; the capture alternates > and <. */ +function pairs(): Array<{ request: Uint8Array; reply: Uint8Array }> { + const frames = capture(); + const out: Array<{ request: Uint8Array; reply: Uint8Array }> = []; + for (let index = 0; index < frames.length; index += 2) { + assert.equal(frames[index]!.dir, ">"); + assert.equal(frames[index + 1]!.dir, "<"); + out.push({ request: frames[index]!.bytes, reply: frames[index + 1]!.bytes }); + } + return out; +} + +const value = (bytes: Uint8Array) => bytes[4]! | (bytes[5]! << 8); + +test("the SCIMITAR RGB ELITE is in the Bragi table with its 128-byte report size and usage", () => { + assert.deepEqual(CORSAIR_BRAGI_MICE.get(PRODUCT_ID), { + name: "SCIMITAR RGB ELITE", + dpiMin: 100, + dpiMax: 18_000, + packetSize: 128, + commandUsage: 0x01, + extraProperties: ["brightness", "angleSnapping", "dpiStage"], + }); +}); + +test("every captured GET request is the encoder's output at 128 bytes", () => { + const gets = pairs().filter(({ request }) => request[1] === CORSAIR_BRAGI_COMMAND.get); + assert.ok(gets.length > 100); + for (const { request } of gets) assert.deepEqual(encode.get(0, request[2]!, SIZE), request); +}); + +test("every captured GET reply is slot 0, GET echoed, status ok", () => { + for (const { request, reply } of pairs().filter(({ request }) => request[1] === CORSAIR_BRAGI_COMMAND.get)) { + assert.ok(corsairBragiIsReplyTo(request, reply)); + const { slot, command, status } = decode.reply(reply); + assert.deepEqual([slot, command, status], [0, CORSAIR_BRAGI_COMMAND.get, CORSAIR_BRAGI_STATUS.ok]); + } +}); + +test("every captured SET request is re-encoded byte for byte and acked ok", () => { + const sets = pairs().filter(({ request }) => request[1] === CORSAIR_BRAGI_COMMAND.set); + assert.ok(sets.length > 50); + const seen = new Set(); + for (const { request, reply } of sets) { + const property = request[2]!; + const v = value(request); + seen.add(property); + switch (property) { + case CORSAIR_BRAGI_PROPERTY.dpiX: assert.deepEqual(encode.setDpi(0, "x", v, SIZE), request); break; + case CORSAIR_BRAGI_PROPERTY.dpiY: assert.deepEqual(encode.setDpi(0, "y", v, SIZE), request); break; + case CORSAIR_BRAGI_PROPERTY.pollingRate: assert.deepEqual(encode.setPollingRate(0, decode.pollingRateHz(v)!, SIZE), request); break; + case CORSAIR_BRAGI_PROPERTY.brightness: assert.deepEqual(encode.setBrightness(0, decode.brightnessPercent(v)!, SIZE), request); break; + case CORSAIR_BRAGI_PROPERTY.angleSnapping: assert.deepEqual(encode.setAngleSnapping(0, v === 1, SIZE), request); break; + // iCUE flips the mode at connect; the driver never does, so there is no encoder. + case CORSAIR_BRAGI_PROPERTY.mode: assert.deepEqual(corsairBragiPacketOfSize(SIZE, 0, CORSAIR_BRAGI_COMMAND.set, property, v & 0xff, v >> 8), request); break; + default: assert.fail(`unexpected SET property 0x${property.toString(16)}`); + } + assert.deepEqual(decode.reply(reply), { slot: 0, command: CORSAIR_BRAGI_COMMAND.set, status: CORSAIR_BRAGI_STATUS.ok, value: 0 }); + } + // The capture exercised every settable property the codec encodes. + assert.deepEqual([...seen].sort((a, b) => a - b), [0x01, 0x02, 0x03, 0x07, 0x21, 0x22]); +}); + +test("the handshake reads identify the mouse and its firmware", () => { + const reads = new Map(); + for (const { request, reply } of pairs()) { + if (request[1] === CORSAIR_BRAGI_COMMAND.get && !reads.has(request[2]!)) reads.set(request[2]!, reply); + } + assert.equal(decode.reply(reads.get(CORSAIR_BRAGI_PROPERTY.vendorId)!).value, CORSAIR_VENDOR_ID); + assert.equal(decode.reply(reads.get(CORSAIR_BRAGI_PROPERTY.productId)!).value, PRODUCT_ID); + // iCUE showed "v. 1.2.21" for this mouse. + assert.equal(decode.firmware(reads.get(CORSAIR_BRAGI_PROPERTY.firmware)!), "1.2.21"); + assert.equal(decode.pollingRateHz(decode.reply(reads.get(CORSAIR_BRAGI_PROPERTY.pollingRate)!).value), 1000); +}); + +test("polling rates seen in the capture are the 125, 250, 500 and 1000 Hz values", () => { + const rates = new Set(pairs() + .filter(({ request }) => request[1] === CORSAIR_BRAGI_COMMAND.set && request[2] === CORSAIR_BRAGI_PROPERTY.pollingRate) + .map(({ request }) => decode.pollingRateHz(value(request)))); + assert.deepEqual([...rates].sort((a, b) => a! - b!), [125, 250, 500, 1000]); +}); + +test("brightness is tenths of a percent: iCUE's 33, 66 and 100 % steps", () => { + const sent = pairs() + .filter(({ request }) => request[1] === CORSAIR_BRAGI_COMMAND.set && request[2] === CORSAIR_BRAGI_PROPERTY.brightness) + .map(({ request }) => value(request)); + assert.ok(sent.length >= 3); + assert.deepEqual([...new Set(sent)].sort((a, b) => a - b), [330, 660, 1000]); + assert.deepEqual([330, 660, 1000].map(decode.brightnessPercent), [33, 66, 100]); + assert.equal(decode.brightnessPercent(1001), null); + assert.deepEqual([...encode.setBrightness(0, 66, SIZE).subarray(0, 6)], [0x08, 0x01, 0x02, 0x00, 0x94, 0x02]); +}); + +test("angle snapping is 0 or 1", () => { + assert.equal(decode.angleSnapping(0), false); + assert.equal(decode.angleSnapping(1), true); + assert.equal(decode.angleSnapping(2), null); + assert.deepEqual([...encode.setAngleSnapping(0, true, SIZE).subarray(0, 5)], [0x08, 0x01, 0x07, 0x00, 0x01]); +}); + +test("a truncated reply is rejected and an unsupported status is reported, not decoded as a value", () => { + const request = encode.get(0, CORSAIR_BRAGI_PROPERTY.brightness, SIZE); + assert.throws(() => decode.reply(new Uint8Array([0x00, 0x02, 0x00, 0xe8, 0x03])), /shorter/); + const unsupported = new Uint8Array(SIZE); + unsupported.set([0x00, 0x02, CORSAIR_BRAGI_STATUS.unsupported]); + assert.ok(corsairBragiIsReplyTo(request, unsupported)); + assert.equal(decode.reply(unsupported).status, CORSAIR_BRAGI_STATUS.unsupported); +}); + +test("encoders validate their input and size the packet", () => { + assert.equal(encode.get(0, 0x21).length, 64); + assert.equal(encode.get(0, 0x21, 128).length, 128); + assert.throws(() => encode.setPollingRate(0, 3000, SIZE), /polling/); + assert.throws(() => encode.setBrightness(0, 101, SIZE), /brightness/); + assert.throws(() => encode.setBrightness(0, -1, SIZE), /brightness/); + assert.throws(() => corsairBragiPacketOfSize(3, 0, 0x02, 0x21), /size/); +}); diff --git a/src/corsair/bragi.ts b/src/corsair/bragi.ts index b47d4110..fdba3f5f 100644 --- a/src/corsair/bragi.ts +++ b/src/corsair/bragi.ts @@ -1,17 +1,22 @@ /** * 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. + * Peripheral V2), spoken by the IRONCLAW RGB WIRELESS, the SCIMITAR RGB ELITE + * 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`, + * `captures/corsair-ironclaw-rgb-wireless/`. GET replies, the handshake and + * the polling, brightness and angle-snapping properties were captured from a + * wired SCIMITAR RGB ELITE in `captures/corsair-scimitar-rgb-elite/`. Property + * ids not seen in a capture 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. + * Transport: output reports without a report id on the vendor interface + * (usage page 0xFF42, interface 1, interrupt endpoints 0x04/0x84). Every + * request is answered by an input report of the same size on the same + * interface. The size is 64 bytes on the IRONCLAW and 128 bytes on the + * SCIMITAR RGB ELITE (its report descriptor declares 128; see the mouse table). * * request [0x08 | slot] [command] [property] [0x00] [value, u16 LE] * reply [slot] [command] [status] [value, LE] @@ -22,6 +27,7 @@ * `src/drivers/corsair/bragi-hid.ts`. */ export const CORSAIR_BRAGI_USAGE_PAGE = 0xff42; +/** The default report size; a mouse whose descriptor says otherwise sets `packetSize` in its table entry. */ export const CORSAIR_BRAGI_PACKET_SIZE = 64; /** Byte 0 of every request, OR'd with the slot. */ export const CORSAIR_BRAGI_MAGIC = 0x08; @@ -35,8 +41,12 @@ export const CORSAIR_BRAGI_COMMAND = { export const CORSAIR_BRAGI_PROPERTY = { /** 1–7 = 8 ms … 0.125 ms, see `CORSAIR_BRAGI_POLLING_RATES`. */ pollingRate: 0x01, + /** Lighting brightness in tenths of a percent, 0–1000 (SCIMITAR RGB ELITE: iCUE's 0/33/66/100 % steps send 0/330/660/1000). */ + brightness: 0x02, /** 1 = hardware (onboard settings), 2 = software (iCUE drives the mouse). */ mode: 0x03, + /** Angle snapping, 0 = off, 1 = on (SCIMITAR RGB ELITE). */ + angleSnapping: 0x07, /** Tenths of a percent, 0–1000. */ batteryLevel: 0x0f, batteryStatus: 0x10, @@ -46,6 +56,12 @@ export const CORSAIR_BRAGI_PROPERTY = { /** Live DPI per axis, plain u16 (no scaling). */ dpiX: 0x21, dpiY: 0x22, + /** + * Active onboard DPI stage, 0-based (SCIMITAR RGB ELITE: follows the mouse's own + * DPI button and survives a replug; iCUE writes it along with the stage's DPI + * when a stage is selected in Device Memory Mode). Read-only here. + */ + dpiStage: 0x4f, /** Receivers only: bit n is set while the device paired in slot n is connected. */ slots: 0x36, } as const; @@ -72,16 +88,44 @@ export interface CorsairBragiMouse { name: string; dpiMin: number; dpiMax: number; + /** + * Output/input report size in bytes, from the vendor interface's HID report + * descriptor. Optional so existing entries stay valid: omitted means + * `CORSAIR_BRAGI_PACKET_SIZE` (64). + */ + packetSize?: number; + /** + * HID usage of the command collection (the input-only 0xFF42 collection on the + * same device has a different usage). Left out when the descriptor was not + * captured, which leaves the picker filter on the usage page alone. + */ + commandUsage?: number; + /** + * Properties beyond the base set that this mouse has been seen to answer a GET + * for on hardware. Mice without the entry are never asked, so a mouse that + * refuses them costs no timeouts and the IRONCLAW's request sequence is unchanged. + */ + extraProperties?: readonly ("brightness" | "angleSnapping" | "dpiStage")[]; } /** * 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. + * children this way). The IRONCLAW's DPI range is from iCUE's slider: the + * captured values only fit 100–18,000. The SCIMITAR RGB ELITE's range was + * probed on the mouse: SET 100 and 18,000 are acked ok, 99 and 18,001 get + * status 1 (invalid value) and leave the old value in place. */ export const CORSAIR_BRAGI_MICE: ReadonlyMap = new Map([ [0x1b4c, { name: "IRONCLAW RGB WIRELESS", dpiMin: 100, dpiMax: 18_000 }], + [0x1be3, { + name: "SCIMITAR RGB ELITE", + dpiMin: 100, + dpiMax: 18_000, + packetSize: 128, + commandUsage: 0x01, + extraProperties: ["brightness", "angleSnapping", "dpiStage"], + }], ]); /** @@ -103,29 +147,66 @@ export interface CorsairBragiReply { value: number; } -/** A zero-padded 64-byte request. */ -export function corsairBragiPacket(slot: number, command: number, property: number, ...value: number[]): Uint8Array { +/** A zero-padded request of `size` bytes (a mouse's `packetSize`). */ +export function corsairBragiPacketOfSize(size: number, 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); + if (!Number.isInteger(size) || size < 4 + value.length) throw new Error("Corsair Bragi packet size is too small for the request."); + const packet = new Uint8Array(size); packet.set([CORSAIR_BRAGI_MAGIC | slot, command, property, 0x00, ...value]); return packet; } +/** A zero-padded 64-byte request. */ +export function corsairBragiPacket(slot: number, command: number, property: number, ...value: number[]): Uint8Array { + return corsairBragiPacketOfSize(CORSAIR_BRAGI_PACKET_SIZE, slot, command, property, ...value); +} + +/** The Bragi polling property value for a rate in Hz, or null when the table has none. */ +function pollingValue(hz: number): number | null { + for (const [value, rate] of CORSAIR_BRAGI_POLLING_RATES) if (rate === hz) return value; + return null; +} + +/** + * Request encoders. `size` is the mouse's `packetSize` (default 64); pass 128 for the + * SCIMITAR RGB ELITE. The driver is read-only for now, so nothing here is sent by it: + * the SET layouts are tested against captures. + */ export const corsairBragiEncode = { - get: (slot: number, property: number): Uint8Array => - corsairBragiPacket(slot, CORSAIR_BRAGI_COMMAND.get, property), + get: (slot: number, property: number, size = CORSAIR_BRAGI_PACKET_SIZE): Uint8Array => + corsairBragiPacketOfSize(size, 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. + * capture; the read-only driver never sends it. The mouse itself rejects values + * outside its range with status 1 (SCIMITAR RGB ELITE: below 100 or above 18,000). + * The write is live and volatile: the SCIMITAR RGB ELITE forgot it on a replug. */ - setDpi: (slot: number, axis: "x" | "y", dpi: number): Uint8Array => { + setDpi: (slot: number, axis: "x" | "y", dpi: number, size = CORSAIR_BRAGI_PACKET_SIZE): 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); + return corsairBragiPacketOfSize(size, slot, CORSAIR_BRAGI_COMMAND.set, property, dpi & 0xff, dpi >> 8); + }, + /** + * Polling rate in Hz. The SCIMITAR RGB ELITE stores it and re-enumerates on USB + * right after the ack, so the read-back must wait for the device to return. + */ + setPollingRate: (slot: number, hz: number, size = CORSAIR_BRAGI_PACKET_SIZE): Uint8Array => { + const value = pollingValue(hz); + if (value === null) throw new Error(`Corsair Bragi has no polling value for ${hz} Hz.`); + return corsairBragiPacketOfSize(size, slot, CORSAIR_BRAGI_COMMAND.set, CORSAIR_BRAGI_PROPERTY.pollingRate, value, 0x00); + }, + /** Lighting brightness as a percentage, sent in tenths of a percent. Stored by the SCIMITAR RGB ELITE. */ + setBrightness: (slot: number, percent: number, size = CORSAIR_BRAGI_PACKET_SIZE): Uint8Array => { + if (!Number.isFinite(percent) || percent < 0 || percent > 100) throw new Error("Corsair Bragi brightness must be 0–100 %."); + const value = Math.round(percent * 10); + return corsairBragiPacketOfSize(size, slot, CORSAIR_BRAGI_COMMAND.set, CORSAIR_BRAGI_PROPERTY.brightness, value & 0xff, value >> 8); }, + /** Angle snapping on or off. Stored by the SCIMITAR RGB ELITE. */ + setAngleSnapping: (slot: number, enabled: boolean, size = CORSAIR_BRAGI_PACKET_SIZE): Uint8Array => + corsairBragiPacketOfSize(size, slot, CORSAIR_BRAGI_COMMAND.set, CORSAIR_BRAGI_PROPERTY.angleSnapping, enabled ? 1 : 0, 0x00), } as const; /** True when `reply` answers `request`: same slot (low three bits) and the command echoed. */ @@ -160,6 +241,10 @@ export const corsairBragiDecode = { 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), + /** Tenths of a percent → percent (SCIMITAR RGB ELITE brightness: 330 → 33). */ + brightnessPercent: (value: number): number | null => (value <= 1000 ? Math.round(value / 10) : null), + /** 0 off, 1 on; anything else is not a value this mouse has been seen to send. */ + angleSnapping: (value: number): boolean | null => (value === 1 ? true : value === 0 ? false : 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", diff --git a/src/drivers/corsair/bragi-hid.test.ts b/src/drivers/corsair/bragi-hid.test.ts index dfc40116..e8edeb11 100644 --- a/src/drivers/corsair/bragi-hid.test.ts +++ b/src/drivers/corsair/bragi-hid.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { CORSAIR_BRAGI_HID_FILTERS } from "../vendors.ts"; import { CorsairBragiHidClient } from "./bragi-hid.ts"; const WIRED = 0x1b4c; @@ -37,6 +38,10 @@ interface FakeOptions { /** Properties the mouse never answers. */ silent?: number[]; collections?: HIDCollectionInfo[]; + /** The mouse's property values; defaults to the IRONCLAW's. */ + properties?: Record; + /** Input report size in bytes; defaults to the IRONCLAW's 64. */ + reportSize?: number; } /** @@ -66,8 +71,8 @@ function fakeDevice(options: FakeOptions = {}) { if (options.silent?.includes(property)) return; const value = slot === 0 && productId === RECEIVER ? (property === 0x36 ? options.slots : undefined) - : MOUSE[property]; - const reply = new Uint8Array(64); + : (options.properties ?? MOUSE)[property]; + const reply = new Uint8Array(options.reportSize ?? 64); reply.set(value === undefined ? [slot, request[1]!, 0x05] : [slot, request[1]!, 0x00, value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff, (value >>> 24) & 0xff]); @@ -141,3 +146,93 @@ test("a mouse that does not answer the DPI read still identifies", async () => { assert.equal(status.ui?.valuesVerified, false); assert.match(status.ui?.statusNote ?? "", /did not answer the DPI read/); }); + +const SCIMITAR = 0x1be3; + +/** What the real SCIMITAR RGB ELITE answered over hidraw with iCUE closed: hardware mode, 1000 Hz, 800 DPI, stage 0. */ +const SCIMITAR_PROPERTIES: Record = { + 0x01: 4, + 0x02: 1000, + 0x03: 1, + 0x07: 0, + 0x12: SCIMITAR, + 0x13: 0x00150201, + 0x21: 800, + 0x22: 800, + 0x4f: 0, +}; + +const scimitar = (options: FakeOptions = {}) => fakeDevice({ productId: SCIMITAR, properties: SCIMITAR_PROPERTIES, reportSize: 128, ...options }); + +test("claims the SCIMITAR RGB ELITE's command interface", () => { + assert.equal(CorsairBragiHidClient.isSupported(scimitar().device), true); + assert.equal(CorsairBragiHidClient.isSupported(scimitar({ collections: [collection(0xff42, false)] }).device), false); +}); + +test("reads the wired SCIMITAR RGB ELITE with 128-byte GETs and never writes", async () => { + const { device, sent } = scimitar(); + const status = await new CorsairBragiHidClient(device).readStatus(); + assert.ok(sent.every((request) => request.length === 128), "every request is a 128-byte report"); + assert.ok(sent.every((request) => request[1] === 0x02), "only GETs are sent"); + 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", + "08 02 02 00", "08 02 07 00", "08 02 4f 00", + ]); + assert.equal(status.name, "SCIMITAR RGB ELITE"); + assert.equal(status.dpi, 800); + assert.equal(status.dpiY, 800); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.angleSnapping, false); + assert.equal(status.batteryPercent, null); + assert.equal(status.deviceMode, "Onboard"); + assert.equal(status.connectionType, "Wired"); + assert.deepEqual(status.firmware, [ + "Firmware 1.2.21", + "Mode: hardware (onboard settings)", + "Product id 0x1be3", + "Lighting brightness 100 %", + "Active DPI stage 1", + ]); + assert.equal(status.ui?.settingsReady, false); + assert.equal(status.ui?.valuesVerified, true); + assert.match(status.ui?.statusNote ?? "", /brightness and angle snapping/); +}); + +test("the SCIMITAR reports angle snapping on and a later DPI stage", async () => { + const { device } = scimitar({ properties: { ...SCIMITAR_PROPERTIES, 0x07: 1, 0x4f: 3, 0x02: 660, 0x21: 6500, 0x22: 6500 } }); + const status = await new CorsairBragiHidClient(device).readStatus(); + assert.equal(status.angleSnapping, true); + assert.equal(status.dpi, 6500); + assert.ok(status.firmware.includes("Active DPI stage 4")); + assert.ok(status.firmware.includes("Lighting brightness 66 %")); +}); + +test("a SCIMITAR that does not answer the extra reads still identifies and reports DPI", async () => { + const { device } = scimitar({ silent: [0x02, 0x07, 0x4f] }); + const status = await new CorsairBragiHidClient(device).readStatus(); + assert.equal(status.name, "SCIMITAR RGB ELITE"); + assert.equal(status.dpi, 800); + assert.equal("angleSnapping" in status, false); + assert.deepEqual(status.firmware, ["Firmware 1.2.21", "Mode: hardware (onboard settings)", "Product id 0x1be3"]); +}); + +test("a SCIMITAR that answers outside the values it has been seen to send does not report them", async () => { + const { device } = scimitar({ properties: { ...SCIMITAR_PROPERTIES, 0x02: 1001, 0x07: 2 } }); + const status = await new CorsairBragiHidClient(device).readStatus(); + assert.equal("angleSnapping" in status, false); + assert.equal(status.firmware.some((line) => line.startsWith("Lighting brightness")), false); + assert.equal(status.dpi, 800); +}); + +test("the IRONCLAW is never asked for the SCIMITAR-only properties", async () => { + const { device, sent } = fakeDevice(); + await new CorsairBragiHidClient(device).readStatus(); + assert.ok(sent.every((request) => request.length === 64)); + assert.equal(sent.some((request) => [0x02, 0x07, 0x4f].includes(request[2]!)), false); +}); + +test("the picker filter pins the SCIMITAR's command usage and leaves the IRONCLAW's alone", () => { + const filters = CORSAIR_BRAGI_HID_FILTERS; + assert.deepEqual(filters.find((filter) => filter.productId === SCIMITAR), { vendorId: 0x1b1c, productId: SCIMITAR, usagePage: 0xff42, usage: 0x01 }); + assert.deepEqual(filters.find((filter) => filter.productId === WIRED), { vendorId: 0x1b1c, productId: WIRED, usagePage: 0xff42 }); +}); diff --git a/src/drivers/corsair/bragi-hid.ts b/src/drivers/corsair/bragi-hid.ts index ccdd038c..1240162e 100644 --- a/src/drivers/corsair/bragi-hid.ts +++ b/src/drivers/corsair/bragi-hid.ts @@ -2,6 +2,7 @@ import type { MouseStatus } from "../mouse-types.ts"; import { CORSAIR_BRAGI_MICE, CORSAIR_BRAGI_MODE, + CORSAIR_BRAGI_PACKET_SIZE, CORSAIR_BRAGI_PROPERTY, CORSAIR_BRAGI_RECEIVERS, CORSAIR_BRAGI_USAGE_PAGE, @@ -18,20 +19,23 @@ 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 + * Corsair Bragi mice (IRONCLAW RGB WIRELESS, SCIMITAR RGB ELITE) over the cable + * or a SLIPSTREAM receiver. Read-only on purpose: iCUE's writes are decoded and + * encoded in the codec, but this driver never sends one. On the SCIMITAR RGB + * ELITE, checked on hardware with iCUE closed and the mouse in hardware mode, + * the live DPI properties read back what was written but a replug discards it, + * while polling rate, brightness and angle snapping are stored. Whether the + * IRONCLAW behaves the same has not been checked. 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. + * A request is one output report (the mouse's `packetSize`, 64 or 128 bytes); + * 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; @@ -58,6 +62,11 @@ export class CorsairBragiHidClient { getDpiOptions(): number[] { return []; } + /** Report size for this device's mice; receivers and unlisted ids use the 64-byte default. */ + private get packetSize(): number { + return CORSAIR_BRAGI_MICE.get(this.device.productId)?.packetSize ?? CORSAIR_BRAGI_PACKET_SIZE; + } + async open(): Promise { if (!this.device.opened) await this.device.open(); if (!this.onReport) { @@ -107,12 +116,22 @@ export class CorsairBragiHidClient { 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); + // Only mice whose table entry lists these were seen to answer them. + const extras = mouse?.extraProperties ?? []; + const brightness = extras.includes("brightness") ? await this.value(slot, CORSAIR_BRAGI_PROPERTY.brightness) : null; + const angle = extras.includes("angleSnapping") ? await this.value(slot, CORSAIR_BRAGI_PROPERTY.angleSnapping) : null; + const stage = extras.includes("dpiStage") ? await this.value(slot, CORSAIR_BRAGI_PROPERTY.dpiStage) : null; + const brightnessPercent = brightness === null ? null : corsairBragiDecode.brightnessPercent(brightness); + const angleSnapping = angle === null ? null : corsairBragiDecode.angleSnapping(angle); 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}` : ""}`, + brightnessPercent === null ? null : `Lighting brightness ${brightnessPercent} %`, + // iCUE numbers the stages from 1; the property is 0-based. + stage === null ? null : `Active DPI stage ${stage + 1}`, ].filter((line): line is string => line !== null); return { @@ -130,6 +149,7 @@ export class CorsairBragiHidClient { connectionType: receiver ? "Wireless" : "Wired", connectionDetail: receiver ?? "USB", liftOffDistance: null, + ...(angleSnapping === null ? {} : { angleSnapping }), firmware: lines, ui: { family: "corsair-bragi", @@ -140,7 +160,9 @@ export class CorsairBragiHidClient { ? "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.", + : extras.length > 0 + ? "Read-only for now: DPI, polling rate, brightness and angle snapping are read from the mouse but cannot be changed here yet." + : "Read-only for now: DPI, polling rate and battery are read from the mouse but cannot be changed here yet.", }, }; } @@ -152,7 +174,7 @@ export class CorsairBragiHidClient { /** 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); + const request = corsairBragiEncode.get(slot, property, this.packetSize); this.inbox = []; await this.device.sendReport(0, request.buffer as ArrayBuffer); const deadline = Date.now() + REPLY_TIMEOUT_MS; diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 3387e201..a65b914c 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -58,6 +58,7 @@ import { ZAUNKOENIG_VENDOR_ID, } from "@openmouse/protocol/zaunkoenig"; import { + CORSAIR_BRAGI_MICE, CORSAIR_BRAGI_PRODUCT_IDS, CORSAIR_BRAGI_USAGE_PAGE, CORSAIR_CONFIG_USAGE, @@ -798,14 +799,20 @@ export const CORSAIR_HID_FILTERS: HIDDeviceFilter[] = CORSAIR_PRODUCT_IDS.map((p /** * 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. + * only the one that takes output reports. Where the descriptor was captured + * (the SCIMITAR RGB ELITE: command collection usage 0x01, input-only usage 0x02) + * the filter also pins the usage; elsewhere the usage that would tell the two + * apart 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 CORSAIR_BRAGI_HID_FILTERS: HIDDeviceFilter[] = CORSAIR_BRAGI_PRODUCT_IDS.map((productId) => { + const usage = CORSAIR_BRAGI_MICE.get(productId)?.commandUsage; + return { + vendorId: CORSAIR_VENDOR_ID, + productId, + usagePage: CORSAIR_BRAGI_USAGE_PAGE, + ...(usage === undefined ? {} : { usage }), + }; +}); export const MICROSOFT_HID_FILTERS: HIDDeviceFilter[] = [ { vendorId: VENDOR_ID.microsoft, productId: MICROSOFT_PRODUCT_CLASSIC, usagePage: MICROSOFT_CLASSIC_USAGE_PAGE, usage: MICROSOFT_CLASSIC_USAGE }, // Classic