diff --git a/captures/glorious-o2-pro-4k8k-wired/README.md b/captures/glorious-o2-pro-4k8k-wired/README.md new file mode 100644 index 0000000..a8d64d2 --- /dev/null +++ b/captures/glorious-o2-pro-4k8k-wired/README.md @@ -0,0 +1,74 @@ +# Glorious Model O 2 PRO 4K/8K, wired + +Reference material for `src/glorious-core2/index.ts` and +`src/drivers/glorious/core2-hid.ts`. From a community USBPcap capture +(ticket-0160, Windows, 2026-10-08): Glorious CORE talking to a Model O 2 PRO +4K/8K on its cable (USB `0x258a:0x201b`) while the owner stepped through its +settings. Only the mouse's configuration interface and its DPI button reports +are included. The other USB devices on that bus (two ASUS lighting controllers) +and all pointer traffic were left out. No serial numbers or personal data. + +| File | Trust | What it is | +|-|-|-| +| `core-session.hex` | Vendor capture | Every frame on interface 2 (usage page `0xffff`, usage 0, unnumbered 64-byte feature reports): 116 requests (22 distinct) and the 41 replies CORE read. `src/drivers/glorious/core2-protocol.test.ts` re-encodes all 116 requests byte for byte. | +| `dpi-button-reports.hex` | Vendor capture | 11 input reports (report 4) on interface 1, sent by the mouse when its DPI button was pressed in the first 41 s, before CORE wrote anything. Layout `01, stage (1-based), DPI X (u16 BE), DPI Y (u16 BE), 00 00`. The test checks every one against the stage table CORE wrote later. | + +## What the owner did + +The capture has no labels, so this is read off the frames. CORE re-sends its +whole performance block after every change (7 requests, 30 ms apart), so only +the field that moved says what the owner touched: + +| Seconds | What changed | +|-|-| +| 5.8 | CORE starts: firmware read. Battery read every 10 s from here, 100 % and not charging. | +| 14 to 41 | DPI button pressed on the mouse, stages 1 to 4 cycle. | +| 57 to 114 | Polling rate stepped 125, 250, 500, 1000, 2000, 4000, 8000 Hz (`08, 04, 02, 01, 20, 40, 80` in both polling bytes). | +| 120 | Polling bytes become `80 40`: 8000 Hz wired, 4000 Hz wireless. | +| 124 to 133 | Motion sync off, then on again (register `0x09` goes 1, 0, 1). | +| 141 to 160 | Debounce stepped 4, 8, 12, 16 ms (register `0x08`, first byte after the profile). | +| 168 | Advanced debounce switched on: `00 00 0a 0a 08` (CORE's defaults 0, 0, 10, 10, 8). | + +Every request carries profile `2`: CORE had its second profile selected. A +capture from a Model D2 Pro 4K in the GloriousAutoPollingRate project carries +profile `1`. + +## What the registers are + +The 7 requests of a burst, with what each one is. The frame layout is in the +header comment of `src/glorious-core2/index.ts`. The names come from the code of +Glorious CORE 2.1.21 itself (`MouseV2ProDeviceHandler`, which also drives the +Model O/D Wireless that korkje/mxw documents); the capture fixes the bytes, the +code fixes the meaning. + +| Order | Bank, register | Data after the profile byte | +|-|-|-| +| 1 | 1, `0x01` | DPI stage count, then X and Y as u16 BE per stage | +| 2 | 2, `0x01` | six RGB triplets, the stage LED colors | +| 3 | 1, `0x0b` | lift-off value; CORE writes 1 for both its 1.0 mm and 2.0 mm options, so the value to distance map is unknown | +| 4 | 1, `0x02` | active DPI stage, 1-based | +| 5 | 1, `0x0a` | polling code for the cable, polling code for 2.4 GHz | +| 6 | 0, `0x08` | debounce: `ms, 0, 0, 0, 0, 0`, or in advanced mode `beforePress, beforeRelease, afterPress, afterRelease, liftOffPress, 0` | +| 7 | 1, `0x09` | motion sync, 1 on, 0 off | + +Reads: bank 0 register `0x81` answers firmware `1.0.15.0` then the wired +product id `0x201b`; register `0x83` answers charging flag and percent. The +reply to a write is the request echoed with status `0xa1`, and CORE only reads +it after the last frame of a burst. + +## Not captured yet + +- **Any read of a setting.** CORE never reads one, and no command for it is + known, so the driver shows the last values it wrote. +- **The HID report descriptors.** The capture started after enumeration. The + driver finds the config channel by shape (a feature report with id 0 on usage + page `0xffff`), which is how CORE's own device table describes it. +- **The receiver (`0x2035`).** No traffic. The frames are the same ones CORE + sends over either link (GloriousAutoPollingRate captured the D2 Pro 4K + receiver doing so); only the firmware read differs, target byte 0 instead + of 2, from CORE's code. +- **The profile select frame** (bank 0, register `0x05`), which CORE and mxw + agree on. CORE had already selected profile 2. +- **Lighting, buttons, macros and auto sleep.** Not exercised. +- **8000 Hz on the 2.4 GHz link.** CORE wrote it once (`80 80`) and then + settled on `80 40`; the product page gives 4000 Hz as the wireless maximum. diff --git a/captures/glorious-o2-pro-4k8k-wired/core-session.hex b/captures/glorious-o2-pro-4k8k-wired/core-session.hex new file mode 100644 index 0000000..23e8df0 --- /dev/null +++ b/captures/glorious-o2-pro-4k8k-wired/core-session.hex @@ -0,0 +1,163 @@ +# Glorious Model O 2 PRO 4K/8K, wired (USB 0x258a:0x201b), ticket-0160. +# Glorious CORE on Windows, USBPcap, 2026-10-08. Config interface only: interface 2, +# usage page 0xffff usage 0, unnumbered 64-byte feature reports (report id 0). Format: +# , where > is SET_REPORT (host to +# device) and < is the GET_REPORT reply, full 64-byte frames with trailing zeros dropped. +# CORE has no label per action; what the owner did is inferred in README.md. +5.811 > 00 00 02 03 00 81 +5.868 < a1 00 02 06 00 81 01 00 0f 00 20 1b +15.822 > 00 00 02 02 00 83 +15.884 < a1 00 02 02 00 83 00 64 +25.829 > 00 00 02 02 00 83 +25.891 < a1 00 02 02 00 83 00 64 +35.839 > 00 00 02 02 00 83 +35.901 < a1 00 02 02 00 83 00 64 +45.838 > 00 00 02 02 00 83 +45.899 < a1 00 02 02 00 83 00 64 +55.846 > 00 00 02 02 00 83 +55.907 < a1 00 02 02 00 83 00 64 +57.331 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +57.378 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +57.424 > 00 00 02 02 01 0b 02 01 +57.471 > 00 00 02 02 01 02 02 01 +57.597 > 00 00 02 03 01 0a 02 08 08 +57.643 > 00 00 02 07 00 08 02 +57.646 > 00 00 02 02 01 09 02 01 +57.705 < a1 00 02 02 01 09 02 01 +57.708 < a1 00 02 02 01 09 02 01 +65.860 > 00 00 02 02 00 83 +65.922 < a1 00 02 02 00 83 00 64 +69.450 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +69.490 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +69.537 > 00 00 02 02 01 0b 02 01 +69.584 > 00 00 02 02 01 02 02 01 +69.707 > 00 00 02 03 01 0a 02 04 04 +69.755 > 00 00 02 07 00 08 02 +69.758 > 00 00 02 02 01 09 02 01 +69.818 < a1 00 02 02 01 09 02 01 +69.821 < a1 00 02 02 01 09 02 01 +78.708 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +78.742 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +78.774 > 00 00 02 02 01 0b 02 01 +78.820 > 00 00 02 02 01 02 02 01 +78.946 > 00 00 02 03 01 0a 02 02 02 +78.992 > 00 00 02 07 00 08 02 +78.995 > 00 00 02 02 01 09 02 01 +79.055 < a1 00 02 02 01 09 02 01 +79.058 < a1 00 02 02 01 09 02 01 +89.420 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +89.454 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +89.486 > 00 00 02 02 01 0b 02 01 +89.532 > 00 00 02 02 01 02 02 01 +89.658 > 00 00 02 03 01 0a 02 01 01 +89.705 > 00 00 02 07 00 08 02 +89.708 > 00 00 02 02 01 09 02 01 +89.769 < a1 00 02 02 01 09 02 01 +89.772 < a1 00 02 02 01 09 02 01 +95.985 > 00 00 02 02 00 83 +96.046 < a1 00 02 02 00 83 00 64 +97.386 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +97.432 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +97.477 > 00 00 02 02 01 0b 02 01 +97.524 > 00 00 02 02 01 02 02 01 +97.647 > 00 00 02 03 01 0a 02 20 20 +97.694 > 00 00 02 07 00 08 02 +97.697 > 00 00 02 02 01 09 02 01 +97.756 < a1 00 02 02 01 09 02 01 +97.759 < a1 00 02 02 01 09 02 01 +104.919 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +104.963 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +105.009 > 00 00 02 02 01 0b 02 01 +105.041 > 00 00 02 02 01 02 02 01 +105.166 > 00 00 02 03 01 0a 02 40 40 +105.213 > 00 00 02 07 00 08 02 +105.216 > 00 00 02 02 01 09 02 01 +105.275 < a1 00 02 02 01 09 02 01 +105.278 < a1 00 02 02 01 09 02 01 +114.102 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +114.144 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +114.191 > 00 00 02 02 01 0b 02 01 +114.237 > 00 00 02 02 01 02 02 01 +114.374 > 00 00 02 03 01 0a 02 80 80 +114.421 > 00 00 02 07 00 08 02 +114.424 > 00 00 02 02 01 09 02 01 +114.484 < a1 00 02 02 01 09 02 01 +114.487 < a1 00 02 02 01 09 02 01 +120.129 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +120.174 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +120.221 > 00 00 02 02 01 0b 02 01 +120.269 > 00 00 02 02 01 02 02 01 +120.397 > 00 00 02 03 01 0a 02 80 40 +120.444 > 00 00 02 07 00 08 02 +120.447 > 00 00 02 02 01 09 02 01 +120.506 < a1 00 02 02 01 09 02 01 +120.509 < a1 00 02 02 01 09 02 01 +123.562 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +123.597 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +123.644 > 00 00 02 02 01 0b 02 01 +123.691 > 00 00 02 02 01 02 02 01 +123.817 > 00 00 02 03 01 0a 02 80 40 +123.864 > 00 00 02 07 00 08 02 +123.867 > 00 00 02 02 01 09 02 +123.925 < a1 00 02 02 01 09 02 +123.928 < a1 00 02 02 01 09 02 +126.125 > 00 00 02 02 00 83 +126.366 < a1 00 02 02 00 83 00 64 +132.737 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +132.772 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +132.818 > 00 00 02 02 01 0b 02 01 +132.865 > 00 00 02 02 01 02 02 01 +132.989 > 00 00 02 03 01 0a 02 80 40 +133.036 > 00 00 02 07 00 08 02 +133.039 > 00 00 02 02 01 09 02 01 +133.098 < a1 00 02 02 01 09 02 01 +133.101 < a1 00 02 02 01 09 02 01 +141.160 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +141.202 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +141.249 > 00 00 02 02 01 0b 02 01 +141.295 > 00 00 02 02 01 02 02 01 +141.421 > 00 00 02 03 01 0a 02 80 40 +141.468 > 00 00 02 07 00 08 02 04 +141.471 > 00 00 02 02 01 09 02 01 +141.531 < a1 00 02 02 01 09 02 01 +141.534 < a1 00 02 02 01 09 02 01 +147.456 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +147.489 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +147.536 > 00 00 02 02 01 0b 02 01 +147.582 > 00 00 02 02 01 02 02 01 +147.705 > 00 00 02 03 01 0a 02 80 40 +147.752 > 00 00 02 07 00 08 02 08 +147.755 > 00 00 02 02 01 09 02 01 +147.814 < a1 00 02 02 01 09 02 01 +147.817 < a1 00 02 02 01 09 02 01 +152.004 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +152.051 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +152.098 > 00 00 02 02 01 0b 02 01 +152.130 > 00 00 02 02 01 02 02 01 +152.256 > 00 00 02 03 01 0a 02 80 40 +152.303 > 00 00 02 07 00 08 02 0c +152.306 > 00 00 02 02 01 09 02 01 +152.366 < a1 00 02 02 01 09 02 01 +152.369 < a1 00 02 02 01 09 02 01 +156.429 > 00 00 02 02 00 83 +156.493 < a1 00 02 02 00 83 00 64 +159.448 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +159.485 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +159.532 > 00 00 02 02 01 0b 02 01 +159.564 > 00 00 02 02 01 02 02 01 +159.691 > 00 00 02 03 01 0a 02 80 40 +159.738 > 00 00 02 07 00 08 02 10 +159.741 > 00 00 02 02 01 09 02 01 +159.801 < a1 00 02 02 01 09 02 01 +159.804 < a1 00 02 02 01 09 02 01 +167.941 > 00 00 02 12 01 01 02 04 01 90 01 90 03 20 03 20 06 40 06 40 0c 80 0c 80 +167.988 > 00 00 02 13 02 01 02 ff a4 0d 26 b4 ff ff 26 26 18 b3 0a +168.036 > 00 00 02 02 01 0b 02 01 +168.083 > 00 00 02 02 01 02 02 01 +168.208 > 00 00 02 03 01 0a 02 80 40 +168.254 > 00 00 02 07 00 08 02 00 00 0a 0a 08 +168.257 > 00 00 02 02 01 09 02 01 +168.317 < a1 00 02 02 01 09 02 01 +168.320 < a1 00 02 02 01 09 02 01 +186.559 > 00 00 02 02 00 83 +186.623 < a1 00 02 02 00 83 00 64 diff --git a/captures/glorious-o2-pro-4k8k-wired/dpi-button-reports.hex b/captures/glorious-o2-pro-4k8k-wired/dpi-button-reports.hex new file mode 100644 index 0000000..531f031 --- /dev/null +++ b/captures/glorious-o2-pro-4k8k-wired/dpi-button-reports.hex @@ -0,0 +1,14 @@ +# Same capture: interface 1 input report 4 (8 bytes after the report id), sent by the mouse when +# the DPI button was pressed during the first 41 s, before CORE wrote anything. Format: +# < . Layout: 01, stage (1-based), DPI X (u16 BE), DPI Y (u16 BE), 00 00. +14.207 < 01 02 03 20 03 20 00 00 +14.315 < 01 03 06 40 06 40 00 00 +20.879 < 01 04 0c 80 0c 80 00 00 +26.487 < 01 01 01 90 01 90 00 00 +26.575 < 01 02 03 20 03 20 00 00 +31.469 < 01 04 0c 80 0c 80 00 00 +31.525 < 01 01 01 90 01 90 00 00 +33.657 < 01 02 03 20 03 20 00 00 +33.733 < 01 03 06 40 06 40 00 00 +40.937 < 01 04 0c 80 0c 80 00 00 +41.050 < 01 01 01 90 01 90 00 00 diff --git a/docs/glorious-o2-pro-4k8k.md b/docs/glorious-o2-pro-4k8k.md new file mode 100644 index 0000000..32d5bbf --- /dev/null +++ b/docs/glorious-o2-pro-4k8k.md @@ -0,0 +1,110 @@ +# Glorious Model O 2 PRO 4K/8K + +Requested in the OpenMouse Discord (ticket 0160). The driver was written from a +USBPcap capture of Glorious CORE on a wired unit +(`captures/glorious-o2-pro-4k8k-wired/`) and from the code of CORE 2.1.21, +which names every register. It has not run on hardware. + +## Which mouse + +| Path | VID:PID | Collection | +|-|-|-| +| USB cable | `258a:201b` | usage page `0xffff`, usage 0, unnumbered 64-byte feature report | +| 2.4 GHz receiver | `258a:2035` | same | + +CORE's device table calls it "MODEL O 2 PRO 4k/8kHz Edition" and gives both ids. +Interface 1 has another `0xffff` collection (usage 1, no feature report). The +picker may list it as a second entry; the driver claims only the one with the +feature report. + +Wired it polls up to 8000 Hz, through the receiver up to 4000 Hz. The product +page gives DPI 100 to 26000, debounce 4 to 16 ms (10 ms default), lift-off +1 to 2 mm (1 mm default). + +The classic Glorious driver used to claim both ids with a reduced feature set: +battery, plus RGB and debounce methods the app never surfaced. The ids moved +out of `GLORIOUS_CLASSIC_PRODUCTS` so that exactly one driver claims them. Its +debounce frame is shorter than the one CORE sends (length 1 against 7), which +was never tried on this mouse. CORE builds this mouse's RGB frames with the +classic layout, so RGB can be added to the new client later; it is not +captured here. + +## Frame + +Unnumbered 64-byte feature reports. A request is `SET_REPORT`; a reply is the +`GET_REPORT` about 60 ms later. No checksum. + +``` +00 00 02 len bank register data... profile id first in every per-profile register +``` + +`len` is the data length, `bank` is 0 system, 1 per-profile settings, 2 +per-profile lighting. A reply echoes the request with status `0xa1` in byte 0. +The full register table is in `captures/glorious-o2-pro-4k8k-wired/README.md`. + +## Profiles + +The mouse keeps three profiles and every setting is written with a profile +number: the position of the profile selected in CORE. The mouse cannot report +which one is active and no read of any setting is known. The driver assumes +profile 1 until the user picks one in the Profile card; picking sends CORE's own +select frame (`00 00 02 01 00 05 `), after which writes land in that profile. +The values the panel shows are the driver's last writes, kept per profile in +localStorage (one set per model, shared by the cable and the receiver), so a fresh +browser shows the factory values (400, 800, 1600, 3200 +DPI, 1000 Hz, 10 ms, motion sync on). + +## What the driver does + +Identity and firmware, battery, DPI stages (value, count up to 6, LED color, +active stage), polling rate (125 to 8000 Hz; the receiver stops at 4000 Hz), +debounce (simple mode, 4 to 16 ms), motion sync, and profile select. + +A single DPI edit rewrites the whole stage table, as CORE does, from the values +shown. Frames are sent 30 ms apart and 120 ms after the active stage, CORE's own +pauses. The driver does not read the acknowledgement of a write. + +Not done, with the reason: + +- Lift-off. CORE writes the same value for its 1.0 mm and 2.0 mm options, so + the value to distance map is unknown. +- Auto sleep (bank 0 register `0x07`, seconds as u16 BE, `0xffff` for never), + lighting, buttons, macros, advanced debounce. Not captured; the codec has the + advanced debounce encoder because the capture contains it. +- Reading the receiver's own firmware: the frame (target byte 0) is CORE's, not + captured. + +## To test + +1. Connect in Chrome through control.openmouse.app, on the cable. The status + should show the model, battery, firmware `1.0.15.0` on the unit that was + captured, and "Profile 1". +2. Polling: set 1000 Hz, then 8000 Hz, and measure with a polling rate checker. + Turn Motion Sync off at 8000 Hz, as Glorious advises. +3. DPI: edit a stage, press the DPI button and check the LED color and the + measured DPI; recolor a stage. +4. Debounce and Motion Sync: set each and check the mouse still clicks. +5. If nothing changes, the mouse is on another profile: pick the profile you + use in CORE in the Profile card and repeat. +6. Repeat 2 to 4 on the receiver; 8000 Hz must not be offered there. +7. Report the console log and which profile CORE shows as active. + +## Sources + +- The capture above; no serial numbers or personal data in it. +- Glorious CORE 2.1.21 (Windows installer from gloriousgaming.com), read for + its device table and `MouseV2ProDeviceHandler` frame builders. Nothing from it + is committed. +- https://github.com/korkje/mxw (profile select, firmware and battery reads) + and https://github.com/AMarcinkiewicz/GloriousAutoPollingRate (polling codes, + measured on a Model D2 Pro 4K, whose receiver `258a:2036` takes the same + frame). +- https://www.gloriousgaming.com/pages/pro-mice-4k8k for the limits. + +## Open + +- What the mouse does with 8000 Hz in the wireless byte. CORE wrote it once and + corrected itself. +- Whether the same frames work unchanged on the Model D2 Pro 4K/8K + (`258a:201c`, `258a:2036`). CORE gives it the same handler, but no capture of + it exists here, so it stays on the reduced classic driver. diff --git a/package.json b/package.json index 30769cf..c1a9f5b 100644 --- a/package.json +++ b/package.json @@ -178,6 +178,10 @@ "types": "./dist/glorious-classic/index.d.ts", "import": "./dist/glorious-classic/index.js" }, + "./glorious-core2": { + "types": "./dist/glorious-core2/index.d.ts", + "import": "./dist/glorious-core2/index.js" + }, "./ksnake": { "types": "./dist/ksnake/index.d.ts", "import": "./dist/ksnake/index.js" diff --git a/src/drivers/glorious/core2-hid.test.ts b/src/drivers/glorious/core2-hid.test.ts new file mode 100644 index 0000000..848db84 --- /dev/null +++ b/src/drivers/glorious/core2-hid.test.ts @@ -0,0 +1,242 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + encodeGloriousCore2ActiveDpiStage, + encodeGloriousCore2BatteryRequest, + encodeGloriousCore2Debounce, + encodeGloriousCore2DpiColors, + encodeGloriousCore2DpiStages, + encodeGloriousCore2FirmwareRequest, + encodeGloriousCore2MotionSync, + encodeGloriousCore2PollingRate, + encodeGloriousCore2Profile, +} from "../../glorious-core2/index.ts"; +import { GloriousCore2HidClient } from "./core2-hid.ts"; + +const WIRED = 0x201b; +const RECEIVER = 0x2035; + +/** The replies CORE read in the capture: firmware first, then battery. */ +function capturedReplies(): { firmware: Uint8Array; battery: Uint8Array } { + const lines = readFileSync(new URL("../../../captures/glorious-o2-pro-4k8k-wired/core-session.hex", import.meta.url), "utf8") + .split("\n").filter((line) => line && !line.startsWith("#")); + const reply = (register: number) => { + const line = lines.find((entry) => entry.split(" ")[1] === "<" && Number.parseInt(entry.split(" ")[7]!, 16) === register)!; + const bytes = new Uint8Array(64); + bytes.set(line.split(" ").slice(2).map((byte) => Number.parseInt(byte, 16))); + return bytes; + }; + return { firmware: reply(0x81), battery: reply(0x83) }; +} + +function fakeCollection(usagePage: number, usage: number, withFeatureReport: boolean) { + return { + usagePage, + usage, + type: 1, + children: [], + inputReports: [], + outputReports: [], + featureReports: withFeatureReport ? [{ reportId: 0, items: [{ reportSize: 8, reportCount: 64 }] }] : [], + }; +} + +function fakeDevice(productId: number, options: { vendorId?: number; collections?: unknown[]; replies?: Uint8Array[] } = {}) { + const sent: Array<{ reportId: number; payload: Uint8Array }> = []; + const replies = [...(options.replies ?? [])]; + const device = { + vendorId: options.vendorId ?? 0x258a, + productId, + productName: "", + opened: true, + collections: options.collections ?? [fakeCollection(0xffff, 0, true)], + open: async () => {}, + close: async () => {}, + sendFeatureReport: async (reportId: number, source: BufferSource) => { + const view = ArrayBuffer.isView(source) ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source); + sent.push({ reportId, payload: new Uint8Array(view) }); + }, + receiveFeatureReport: async () => { + const next = replies.shift(); + if (!next) throw new Error("no reply queued"); + return new DataView(next.buffer, next.byteOffset, next.byteLength); + }, + }; + return { device: device as unknown as HIDDevice, sent }; +} + +/** The driver keeps its last-written values in localStorage, which plain node does not have. */ +function withLocalStorage(run: () => Promise) { + return async () => { + const store = new Map(); + (globalThis as { localStorage?: unknown }).localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + }; + try { + await run(); + } finally { + delete (globalThis as { localStorage?: unknown }).localStorage; + } + }; +} + +test("claims the cable and receiver ids on the 0xffff config collection only", () => { + assert.equal(GloriousCore2HidClient.isSupported(fakeDevice(WIRED).device), true); + assert.equal(GloriousCore2HidClient.isSupported(fakeDevice(RECEIVER).device), true); + // Interface 1 carries a 0xffff collection (usage 1) with no feature report. + assert.equal(GloriousCore2HidClient.isSupported(fakeDevice(WIRED, { collections: [fakeCollection(0xffff, 1, false)] }).device), false); + assert.equal(GloriousCore2HidClient.isSupported(fakeDevice(WIRED, { collections: [fakeCollection(0xff01, 0, true)] }).device), false); + assert.equal(GloriousCore2HidClient.isSupported(fakeDevice(0x2036).device), false); + assert.equal(GloriousCore2HidClient.isSupported(fakeDevice(WIRED, { vendorId: 0x3794 }).device), false); +}); + +test("reads firmware and battery with the frames CORE used, and reports the captured answers", async () => { + const { firmware, battery } = capturedReplies(); + const { device, sent } = fakeDevice(WIRED, { replies: [firmware, battery] }); + const status = await new GloriousCore2HidClient(device).readStatus(); + assert.deepEqual(sent.map(({ payload }) => payload), [encodeGloriousCore2FirmwareRequest("mouse"), encodeGloriousCore2BatteryRequest()]); + assert.equal(status.name, "Model O2 Pro 4K/8K"); + assert.deepEqual(status.firmware, ["1.0.15.0"]); + assert.equal(status.batteryPercent, 100); + assert.equal(status.batteryState, "Full"); + assert.equal(status.connectionType, "Wired"); + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000, 2000, 4000, 8000]); + assert.equal(status.ui?.valuesVerified, false); + assert.equal(status.profileCount, 3); + assert.equal(status.activeProfile, 1); +}); + +test("a failed read still returns the identity, and the receiver reads its own firmware", async () => { + const { device, sent } = fakeDevice(RECEIVER); + const status = await new GloriousCore2HidClient(device).readStatus(); + assert.equal(sent[0]!.payload[2], 0x00); + assert.equal(status.name, "Model O2 Pro 4K/8K Wireless receiver"); + assert.equal(status.batteryPercent, null); + assert.equal(status.batteryState, "Unknown"); + assert.deepEqual(status.firmware, []); + assert.equal(status.connectionType, "Wireless"); + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000, 2000, 4000]); +}); + +test("polling 8000 Hz on the cable sends 8000 wired with 4000 wireless, as CORE does", withLocalStorage(async () => { + const { device, sent } = fakeDevice(WIRED); + const client = new GloriousCore2HidClient(device); + assert.equal(await client.setPollingRate(8000), 8000); + assert.deepEqual(sent.map(({ payload }) => payload), [encodeGloriousCore2PollingRate(8000, 4000, 1)]); + assert.equal(sent[0]!.reportId, 0); + await client.setPollingRate(1000); + assert.deepEqual(sent[1]!.payload, encodeGloriousCore2PollingRate(1000, 1000, 1)); +})); + +test("the receiver takes 4000 Hz on both links and refuses 8000 Hz", async () => { + const { device, sent } = fakeDevice(RECEIVER); + const client = new GloriousCore2HidClient(device); + await assert.rejects(() => client.setPollingRate(8000), /8000/); + await assert.rejects(() => client.setPollingRate(1500), /1500/); + assert.equal(sent.length, 0); + await client.setPollingRate(4000); + assert.deepEqual(sent.map(({ payload }) => payload), [encodeGloriousCore2PollingRate(4000, 4000, 1)]); +}); + +test("a DPI stage edit rewrites the table and re-selects the active stage", withLocalStorage(async () => { + const { device, sent } = fakeDevice(WIRED); + const client = new GloriousCore2HidClient(device); + assert.equal(await client.setDpiStageValue(1, 900), 900); + assert.deepEqual(sent.map(({ payload }) => payload), [ + encodeGloriousCore2DpiStages([400, 900, 1600, 3200], 1), + encodeGloriousCore2ActiveDpiStage(2, 1), + ]); + // The edit is remembered: the next read of the status shows it. + const status = await client.readStatus(); + assert.deepEqual(status.dpiStages, [400, 900, 1600, 3200]); + await assert.rejects(() => client.setDpiStageValue(4, 800), /between 1 and 4/); + await assert.rejects(() => client.setDpiStageValue(0, 99), /DPI/); +})); + +test("changing the stage count sends the colors too, and the active stage follows", withLocalStorage(async () => { + const { device, sent } = fakeDevice(WIRED); + const client = new GloriousCore2HidClient(device); + assert.equal(await client.setDpiStageCount(2), 2); + assert.deepEqual(sent.map(({ payload }) => payload), [ + encodeGloriousCore2DpiStages([400, 800], 1), + encodeGloriousCore2DpiColors(["#ffa40d", "#26b4ff"], 1), + encodeGloriousCore2ActiveDpiStage(1, 1), + ]); + await assert.rejects(() => client.setDpiStageCount(7), /1 to 6/); +})); + +test("raising the stage count doubles the last stage for each new one and gives them the palette's next colors", withLocalStorage(async () => { + const { device, sent } = fakeDevice(WIRED); + assert.equal(await new GloriousCore2HidClient(device).setDpiStageCount(6), 6); + assert.deepEqual(sent[0]!.payload, encodeGloriousCore2DpiStages([400, 800, 1600, 3200, 6400, 12_800], 1)); + assert.deepEqual(sent[1]!.payload, encodeGloriousCore2DpiColors(["#ffa40d", "#26b4ff", "#ff2626", "#18b30a", "#5500ff", "#00ffff"], 1)); +})); + +test("a new stage never doubles past the maximum", withLocalStorage(async () => { + const { device, sent } = fakeDevice(WIRED); + const client = new GloriousCore2HidClient(device); + await client.setDpiStageValue(3, 20_000); + await client.setDpiStageCount(6); + assert.deepEqual(sent[2]!.payload, encodeGloriousCore2DpiStages([400, 800, 1600, 20_000, 26_000, 26_000], 1)); +})); + +test("selecting a stage, recoloring one, debounce and motion sync send one frame each", withLocalStorage(async () => { + const { device, sent } = fakeDevice(WIRED); + const client = new GloriousCore2HidClient(device); + await client.setActiveDpiStage(0); + await client.setDpiStageColor(3, "#112233"); + await client.setDebounceTime(8); + await client.setMotionSync(false); + assert.deepEqual(sent.map(({ payload }) => payload), [ + encodeGloriousCore2ActiveDpiStage(0, 1), + encodeGloriousCore2DpiColors(["#ffa40d", "#26b4ff", "#ff2626", "#112233"], 1), + encodeGloriousCore2Debounce(8, 1), + encodeGloriousCore2MotionSync(false, 1), + ]); + assert.deepEqual(client.getDebounceOptions(), [4, 6, 8, 10, 12, 14, 16]); + await assert.rejects(() => client.setDebounceTime(3), /4 to 16/); + await assert.rejects(() => client.setDebounceTime(18), /4 to 16/); +})); + +test("picking a profile selects it on the mouse, and every later write carries it", withLocalStorage(async () => { + const { device, sent } = fakeDevice(WIRED); + const client = new GloriousCore2HidClient(device); + assert.equal(await client.setProfile(3), 3); + await client.setMotionSync(true); + await client.setPollingRate(2000); + assert.deepEqual(sent.map(({ payload }) => payload), [ + encodeGloriousCore2Profile(3), + encodeGloriousCore2MotionSync(true, 3), + encodeGloriousCore2PollingRate(2000, 2000, 3), + ]); + assert.equal((await client.readStatus()).activeProfile, 3); + // Each profile keeps its own shown values. + await client.setPollingRate(500); + await client.setProfile(1); + assert.equal((await client.readStatus()).pollingRateHz, 1000); + await assert.rejects(() => client.setProfile(4), /1 to 3/); +})); + +test("what was written over the cable shows on the receiver, with 8000 Hz shown as 4000 Hz", withLocalStorage(async () => { + const cable = new GloriousCore2HidClient(fakeDevice(WIRED).device); + const receiver = new GloriousCore2HidClient(fakeDevice(RECEIVER).device); + await cable.setPollingRate(8000); + await cable.setDpiStageValue(0, 500); + const onReceiver = await receiver.readStatus(); + assert.equal(onReceiver.pollingRateHz, 4000); + assert.deepEqual(onReceiver.dpiStages, [500, 800, 1600, 3200]); + assert.equal((await cable.readStatus()).pollingRateHz, 8000); +})); + +test("two status reads at once do not interleave their request and reply", async () => { + const { firmware, battery } = capturedReplies(); + const { device, sent } = fakeDevice(WIRED, { replies: [firmware, battery, firmware, battery] }); + const client = new GloriousCore2HidClient(device); + const [first, second] = await Promise.all([client.readStatus(), client.readStatus()]); + assert.deepEqual(first.firmware, ["1.0.15.0"]); + assert.deepEqual(second.firmware, ["1.0.15.0"]); + assert.deepEqual(sent.map(({ payload }) => payload[5]), [0x81, 0x83, 0x81, 0x83]); +}); diff --git a/src/drivers/glorious/core2-hid.ts b/src/drivers/glorious/core2-hid.ts new file mode 100644 index 0000000..16e575a --- /dev/null +++ b/src/drivers/glorious/core2-hid.ts @@ -0,0 +1,412 @@ +import type { MouseStatus, MouseUiHints } from "../mouse-types.ts"; +import { + GLORIOUS_CORE2_DEBOUNCE_DEFAULT_MS, + GLORIOUS_CORE2_DEBOUNCE_MAX_MS, + GLORIOUS_CORE2_DEBOUNCE_MIN_MS, + GLORIOUS_CORE2_DEBOUNCE_STEP_MS, + GLORIOUS_CORE2_DPI_MAX, + GLORIOUS_CORE2_DPI_MIN, + GLORIOUS_CORE2_DPI_STEP, + GLORIOUS_CORE2_FRAME_LENGTH, + GLORIOUS_CORE2_MAX_DPI_STAGES, + GLORIOUS_CORE2_POLLING_CODES, + GLORIOUS_CORE2_PRODUCTS, + GLORIOUS_CORE2_PROFILE_COUNT, + GLORIOUS_CORE2_PROFILE_DEFAULT, + GLORIOUS_CORE2_REPORT_ID, + GLORIOUS_CORE2_USAGE_PAGE, + GLORIOUS_CORE2_VENDOR_ID, + GLORIOUS_CORE2_WIRELESS_MAX_POLLING_HZ, + decodeGloriousCore2Battery, + decodeGloriousCore2Firmware, + encodeGloriousCore2ActiveDpiStage, + encodeGloriousCore2BatteryRequest, + encodeGloriousCore2Debounce, + encodeGloriousCore2DpiColors, + encodeGloriousCore2DpiStages, + encodeGloriousCore2FirmwareRequest, + encodeGloriousCore2MotionSync, + encodeGloriousCore2PollingRate, + encodeGloriousCore2Profile, + type GloriousCore2Battery, + type GloriousCore2Firmware, +} from "../../glorious-core2/index.ts"; + +/** + * Driver for the Glorious Model O 2 PRO 4K/8K, wired (0x258a:0x201b) and + * through its 2.4 GHz receiver (0x2035). Frames follow Glorious CORE's own + * traffic and code, see ../../glorious-core2/index.ts and + * captures/glorious-o2-pro-4k8k-wired/. + * + * Only the firmware and battery are readable: CORE never reads a setting back, + * and no read command for DPI stages, colors, polling rate, debounce, motion + * sync or the active profile is known. Like the sibling classic driver, this + * client keeps its own last-written values (localStorage, one set per profile) + * for the UI to show and reports `valuesVerified: false`. + * + * Every setting belongs to one of three onboard profiles and is written with + * that profile's number. The mouse cannot say which profile is active, so the + * client assumes profile 1 until the user picks one; picking sends CORE's own + * profile-select frame, after which writes land in the profile in use. + * + * Not offered, for want of evidence: lift-off distance (CORE writes the same + * value for both of its options), auto sleep, lighting, buttons and macros, + * and the advanced debounce times. + */ + +/** The mouse answers a request after about 60 ms in the capture. */ +const REPLY_DELAY_MS = 60; +/** CORE waits this long after each frame, and longer after the active DPI stage. */ +const FRAME_GAP_MS = 30; +const ACTIVE_STAGE_GAP_MS = 120; +const PROFILE_SWITCH_GAP_MS = 50; + +const FACTORY_STAGE_DPIS = [400, 800, 1600, 3200]; +/** CORE's stage colors in the order it hands them out: orange, light blue, red, green, then purple and cyan. */ +const STAGE_COLORS = ["#ffa40d", "#26b4ff", "#ff2626", "#18b30a", "#5500ff", "#00ffff"]; +const FACTORY_STAGE_COLORS = STAGE_COLORS.slice(0, FACTORY_STAGE_DPIS.length); +/** The factory default level is 1600 DPI, the third stage. */ +const FACTORY_ACTIVE_STAGE = 2; +const FACTORY_POLLING_HZ = 1000; + +interface GloriousCore2State { + stageDpis: number[]; + stageColors: string[]; + activeStage: number; + pollingRateHz: number; + debounceMs: number; + motionSync: boolean; +} + +function factoryState(): GloriousCore2State { + return { + stageDpis: [...FACTORY_STAGE_DPIS], + stageColors: [...FACTORY_STAGE_COLORS], + activeStage: FACTORY_ACTIVE_STAGE, + pollingRateHz: FACTORY_POLLING_HZ, + debounceMs: GLORIOUS_CORE2_DEBOUNCE_DEFAULT_MS, + motionSync: true, + }; +} + +const delay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +/** + * The DPI a stage added by raising the stage count starts at, given the stages + * already there (never empty): double the last one, capped at the maximum. + * That continues 400, 800, 1600, 3200 with 6400 and 12800, which are CORE's own + * defaults for stages 5 and 6 on the newer Glorious mice. Doubling a multiple of + * the 50 DPI step stays on the step, and the cap does too. + */ +function newStageDpi(existing: readonly number[]): number { + return Math.min(existing.at(-1)! * 2, GLORIOUS_CORE2_DPI_MAX); +} + +export class GloriousCore2HidClient { + get pollIntervalMs(): number { return 30_000; } + readonly device: HIDDevice; + /** Calls run one at a time: a read is a SET followed by a GET and must not interleave with another. */ + private queue: Promise = Promise.resolve(); + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + return device.vendorId === GLORIOUS_CORE2_VENDOR_ID + && GLORIOUS_CORE2_PRODUCTS.has(device.productId) + && GloriousCore2HidClient.hasConfigReport(device.collections); + } + + /** The config channel is an unnumbered feature report on usage page 0xffff; the 0xffff decoy on interface 1 has none. */ + private static hasConfigReport(collections: readonly HIDCollectionInfo[]): boolean { + return collections.some((collection) => + (collection.usagePage === GLORIOUS_CORE2_USAGE_PAGE + && collection.featureReports.some((report) => report.reportId === GLORIOUS_CORE2_REPORT_ID)) + || GloriousCore2HidClient.hasConfigReport(collection.children)); + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + if (this.device.opened) await this.device.close(); + } + + isWireless(): boolean { + return GLORIOUS_CORE2_PRODUCTS.get(this.device.productId)?.wireless ?? false; + } + + displayName(): string { + return this.device.productName || GLORIOUS_CORE2_PRODUCTS.get(this.device.productId)?.name || "Glorious Model O2 Pro 4K/8K"; + } + + getDpiOptions(): number[] { + const options: number[] = []; + for (let dpi = GLORIOUS_CORE2_DPI_MIN; dpi <= GLORIOUS_CORE2_DPI_MAX; dpi += GLORIOUS_CORE2_DPI_STEP) options.push(dpi); + return options; + } + + getDebounceOptions(): number[] { + const options: number[] = []; + for (let ms = GLORIOUS_CORE2_DEBOUNCE_MIN_MS; ms <= GLORIOUS_CORE2_DEBOUNCE_MAX_MS; ms += GLORIOUS_CORE2_DEBOUNCE_STEP_MS) options.push(ms); + return options; + } + + /** 8000 Hz needs the cable; the receiver stops at 4000 Hz. */ + getSupportedPollingRates(): number[] { + return GLORIOUS_CORE2_POLLING_CODES + .map(([hertz]) => hertz) + .filter((hertz) => !this.isWireless() || hertz <= GLORIOUS_CORE2_WIRELESS_MAX_POLLING_HZ); + } + + async readStatus(): Promise { + await this.open(); + const { firmware, battery } = await this.run(async () => ({ + firmware: await this.readFirmware().catch(() => null), + battery: await this.readBattery().catch(() => null), + })); + const profile = this.loadProfile(); + const state = this.loadState(profile); + const wireless = this.isWireless(); + return { + brand: "Glorious", + name: this.displayName(), + ui: this.getUiHints(), + batteryPercent: battery?.percent ?? null, + batteryState: !battery ? "Unknown" : battery.charging ? "Charging" : battery.percent === 100 ? "Full" : "Discharging", + dpi: state.stageDpis[state.activeStage] ?? state.stageDpis[0]!, + dpiStages: state.stageDpis, + dpiStageColors: state.stageColors, + activeDpiStage: state.activeStage, + // A rate remembered from the cable can be 8000 Hz, which the receiver does not offer. + pollingRateHz: wireless ? Math.min(state.pollingRateHz, GLORIOUS_CORE2_WIRELESS_MAX_POLLING_HZ) : state.pollingRateHz, + supportedPollingRates: this.getSupportedPollingRates(), + debounceMs: state.debounceMs, + motionSync: state.motionSync, + profileCount: GLORIOUS_CORE2_PROFILE_COUNT, + activeProfile: profile, + connectionType: wireless ? "Wireless" : "Wired", + connectionDetail: wireless ? "2.4 GHz receiver, up to 4000 Hz" : "USB cable, up to 8000 Hz", + liftOffDistance: null, + firmware: firmware ? [wireless ? `Receiver ${firmware.version}` : firmware.version] : [], + }; + } + + /** Changes the DPI of the active stage. */ + async setDpi(dpi: number): Promise { + return this.setDpiStageValue(this.loadState(this.loadProfile()).activeStage, dpi); + } + + /** Writes the whole stage table, so every stage comes from the values shown, then re-selects the active stage. */ + async setDpiStageValue(stage: number, dpi: number): Promise { + const profile = this.loadProfile(); + const state = this.loadState(profile); + this.assertStage(stage, state.stageDpis.length); + const stageDpis = state.stageDpis.map((value, index) => (index === stage ? dpi : value)); + await this.writeStages(profile, stageDpis, state.activeStage); + this.saveState(profile, { ...state, stageDpis }); + return dpi; + } + + /** New stages start at newStageDpi() with the next color of CORE's palette; the active stage moves down when its slot disappears. */ + async setDpiStageCount(count: number): Promise { + if (!Number.isInteger(count) || count < 1 || count > GLORIOUS_CORE2_MAX_DPI_STAGES) { + throw new Error(`This mouse has 1 to ${GLORIOUS_CORE2_MAX_DPI_STAGES} DPI stages.`); + } + const profile = this.loadProfile(); + const state = this.loadState(profile); + const stageDpis = state.stageDpis.slice(0, count); + while (stageDpis.length < count) stageDpis.push(newStageDpi(stageDpis)); + const stageColors = Array.from({ length: count }, (_, index) => state.stageColors[index] ?? STAGE_COLORS[index]!); + const activeStage = Math.min(state.activeStage, count - 1); + await this.writeStages(profile, stageDpis, activeStage, stageColors); + this.saveState(profile, { ...state, stageDpis, stageColors, activeStage }); + return count; + } + + async setActiveDpiStage(stage: number): Promise { + const profile = this.loadProfile(); + const state = this.loadState(profile); + this.assertStage(stage, state.stageDpis.length); + await this.write([[encodeGloriousCore2ActiveDpiStage(stage, profile), ACTIVE_STAGE_GAP_MS]]); + this.saveState(profile, { ...state, activeStage: stage }); + return stage; + } + + async setDpiStageColor(stage: number, color: string): Promise { + const profile = this.loadProfile(); + const state = this.loadState(profile); + this.assertStage(stage, state.stageDpis.length); + const stageColors = state.stageColors.map((value, index) => (index === stage ? color.toLowerCase() : value)); + await this.write([[encodeGloriousCore2DpiColors(stageColors, profile), FRAME_GAP_MS]]); + this.saveState(profile, { ...state, stageColors }); + return color; + } + + /** + * One rate for both links, as CORE sends it. The cable's 8000 Hz pairs with + * 4000 Hz for the receiver, the most CORE allows there. + */ + async setPollingRate(pollingRateHz: number): Promise { + if (!this.getSupportedPollingRates().includes(pollingRateHz)) throw new Error(`This connection does not support ${pollingRateHz} Hz.`); + const profile = this.loadProfile(); + const wirelessHz = Math.min(pollingRateHz, GLORIOUS_CORE2_WIRELESS_MAX_POLLING_HZ); + await this.write([[encodeGloriousCore2PollingRate(pollingRateHz, wirelessHz, profile), FRAME_GAP_MS]]); + this.saveState(profile, { ...this.loadState(profile), pollingRateHz }); + return pollingRateHz; + } + + /** Simple debounce: also clears the advanced press and release times, as CORE does when its advanced switch is off. */ + async setDebounceTime(milliseconds: number): Promise { + if (!this.getDebounceOptions().includes(milliseconds)) { + throw new Error(`Debounce must be ${GLORIOUS_CORE2_DEBOUNCE_MIN_MS} to ${GLORIOUS_CORE2_DEBOUNCE_MAX_MS} ms in steps of ${GLORIOUS_CORE2_DEBOUNCE_STEP_MS}.`); + } + const profile = this.loadProfile(); + await this.write([[encodeGloriousCore2Debounce(milliseconds, profile), FRAME_GAP_MS]]); + this.saveState(profile, { ...this.loadState(profile), debounceMs: milliseconds }); + return milliseconds; + } + + async setMotionSync(enabled: boolean): Promise { + const profile = this.loadProfile(); + await this.write([[encodeGloriousCore2MotionSync(enabled, profile), FRAME_GAP_MS]]); + this.saveState(profile, { ...this.loadState(profile), motionSync: enabled }); + return enabled; + } + + /** Selects the profile on the mouse; from then on writes go to it. 1-based, as in the shell. */ + async setProfile(profile: number): Promise { + const frame = encodeGloriousCore2Profile(profile); + await this.write([[frame, PROFILE_SWITCH_GAP_MS]]); + this.saveProfile(profile); + return profile; + } + + private assertStage(stage: number, count: number): void { + if (!Number.isInteger(stage) || stage < 0 || stage >= count) throw new Error(`DPI stage must be between 1 and ${count}.`); + } + + private async writeStages(profile: number, stageDpis: number[], activeStage: number, stageColors?: string[]): Promise { + // Encode everything first so a bad value fails before anything is sent. + const frames: Array, number]> = [[encodeGloriousCore2DpiStages(stageDpis, profile), FRAME_GAP_MS]]; + if (stageColors) frames.push([encodeGloriousCore2DpiColors(stageColors, profile), FRAME_GAP_MS]); + frames.push([encodeGloriousCore2ActiveDpiStage(activeStage, profile), ACTIVE_STAGE_GAP_MS]); + await this.write(frames); + } + + /** Sends frames in order, each followed by its own pause. */ + private write(frames: ReadonlyArray, number]>): Promise { + return this.run(async () => { + await this.open(); + for (const [body, gapMs] of frames) { + await this.send(body); + await delay(gapMs); + } + }); + } + + private run(task: () => Promise): Promise { + const next = this.queue.then(task); + this.queue = next.catch(() => undefined); + return next; + } + + private async send(body: Uint8Array): Promise { + await this.device.sendFeatureReport(GLORIOUS_CORE2_REPORT_ID, body); + } + + /** The reply to the request just sent. WebHID hands back the 64-byte body without the report id. */ + private async receive(): Promise { + await delay(REPLY_DELAY_MS); + const view = await this.device.receiveFeatureReport(GLORIOUS_CORE2_REPORT_ID); + return new Uint8Array(view.buffer, view.byteOffset, Math.min(view.byteLength, GLORIOUS_CORE2_FRAME_LENGTH)); + } + + private async readFirmware(): Promise { + await this.send(encodeGloriousCore2FirmwareRequest(this.isWireless() ? "receiver" : "mouse")); + return decodeGloriousCore2Firmware(await this.receive()); + } + + private async readBattery(): Promise { + await this.send(encodeGloriousCore2BatteryRequest()); + return decodeGloriousCore2Battery(await this.receive()); + } + + private getUiHints(): MouseUiHints { + return { + family: "glorious-core2", + valuesVerified: false, + showAdvancedSection: true, + hideUnsupportedPollingRates: true, + hideAngleSnapping: true, + hideRippleControl: true, + hideSleepCard: true, + hideSignalCard: true, + forceShowBattery: this.isWireless(), + pollingNote: "8000 Hz needs the cable; the receiver stops at 4000 Hz. Glorious advises turning Motion Sync off at 8000 Hz.", + statusNote: "This mouse cannot report its settings or its active profile, so the values shown are the last ones written here. Pick the profile you use: that also selects it on the mouse. Changing one DPI stage rewrites the whole table from the values shown.", + dpiStageEditor: { + maxStages: GLORIOUS_CORE2_MAX_DPI_STAGES, + countEditable: true, + minDpi: GLORIOUS_CORE2_DPI_MIN, + maxDpi: GLORIOUS_CORE2_DPI_MAX, + stepDpi: GLORIOUS_CORE2_DPI_STEP, + }, + }; + } + + /** One key per model, not per product id: the cable and the receiver reach the same mouse and the same profiles. */ + private storageKey(suffix: string): string { + return `openmouse-glorious-core2-v1:o2-pro-4k8k:${suffix}`; + } + + private loadProfile(): number { + try { + const stored = Number(localStorage.getItem(this.storageKey("profile"))); + if (Number.isInteger(stored) && stored >= 1 && stored <= GLORIOUS_CORE2_PROFILE_COUNT) return stored; + } catch { + // Fall through to the default when browser storage is unavailable. + } + return GLORIOUS_CORE2_PROFILE_DEFAULT; + } + + private saveProfile(profile: number): void { + try { + localStorage.setItem(this.storageKey("profile"), String(profile)); + } catch { + // The profile is still selected on the mouse. + } + } + + private loadState(profile: number): GloriousCore2State { + const factory = factoryState(); + try { + const stored = JSON.parse(localStorage.getItem(this.storageKey(`state-p${profile}`)) ?? "null") as Partial | null; + if (!stored || !Array.isArray(stored.stageDpis) || stored.stageDpis.length < 1 || stored.stageDpis.length > GLORIOUS_CORE2_MAX_DPI_STAGES) return factory; + const stageDpis = stored.stageDpis; + const stageColors = Array.isArray(stored.stageColors) && stored.stageColors.length === stageDpis.length + ? stored.stageColors + : stageDpis.map((_, index) => STAGE_COLORS[index]!); + return { + stageDpis, + stageColors, + activeStage: Math.min(Math.max(stored.activeStage ?? factory.activeStage, 0), stageDpis.length - 1), + pollingRateHz: stored.pollingRateHz ?? factory.pollingRateHz, + debounceMs: stored.debounceMs ?? factory.debounceMs, + motionSync: stored.motionSync ?? factory.motionSync, + }; + } catch { + return factory; + } + } + + private saveState(profile: number, state: GloriousCore2State): void { + try { + localStorage.setItem(this.storageKey(`state-p${profile}`), JSON.stringify(state)); + } catch { + // Settings still reach the mouse when browser storage is unavailable. + } + } +} diff --git a/src/drivers/glorious/core2-protocol.test.ts b/src/drivers/glorious/core2-protocol.test.ts new file mode 100644 index 0000000..3452829 --- /dev/null +++ b/src/drivers/glorious/core2-protocol.test.ts @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + GLORIOUS_CORE2_BANK, + GLORIOUS_CORE2_POLLING_CODES, + GLORIOUS_CORE2_PROFILE_CAPTURED, + GLORIOUS_CORE2_REGISTER, + GLORIOUS_CORE2_WIRELESS_MAX_POLLING_HZ, + decodeGloriousCore2Battery, + decodeGloriousCore2Firmware, + decodeGloriousCore2Reply, + encodeGloriousCore2ActiveDpiStage, + encodeGloriousCore2AdvancedDebounce, + encodeGloriousCore2BatteryRequest, + encodeGloriousCore2Debounce, + encodeGloriousCore2DpiColors, + encodeGloriousCore2DpiStages, + encodeGloriousCore2FirmwareRequest, + encodeGloriousCore2MotionSync, + encodeGloriousCore2PollingRate, + encodeGloriousCore2Profile, + gloriousCore2PollingCode, + gloriousCore2Request, +} from "../../glorious-core2/index.ts"; + +const CAPTURE = new URL("../../../captures/glorious-o2-pro-4k8k-wired/", import.meta.url); + +/** ` ` lines, zero-padded back to 64 bytes. */ +function capture(file: string): Array<{ dir: string; bytes: Uint8Array }> { + return readFileSync(new URL(file, CAPTURE), "utf8").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 }; + }); +} + +const key = (bytes: Uint8Array) => Buffer.from(bytes).toString("hex"); + +const FACTORY_STAGES = [400, 800, 1600, 3200]; +const FACTORY_COLORS = ["#ffa40d", "#26b4ff", "#ff2626", "#18b30a"]; +const profile = GLORIOUS_CORE2_PROFILE_CAPTURED; + +/** Every distinct request CORE sent, with the call that must reproduce it. */ +function expectedRequests(): Array<[string, Uint8Array]> { + const cases: Array<[string, Uint8Array]> = [ + ["firmware read", encodeGloriousCore2FirmwareRequest()], + ["battery read", encodeGloriousCore2BatteryRequest()], + ["DPI stages", encodeGloriousCore2DpiStages(FACTORY_STAGES, profile)], + ["DPI colors", encodeGloriousCore2DpiColors(FACTORY_COLORS, profile)], + ["active DPI stage 1", encodeGloriousCore2ActiveDpiStage(0, profile)], + // CORE writes 1 for both of its lift-off options, so there is no encoder, only the raw frame. + ["lift-off", gloriousCore2Request(GLORIOUS_CORE2_BANK.profile, GLORIOUS_CORE2_REGISTER.liftOff, [profile, 1])], + ["motion sync on", encodeGloriousCore2MotionSync(true, profile)], + ["motion sync off", encodeGloriousCore2MotionSync(false, profile)], + ...[0, 4, 8, 12, 16].map((ms): [string, Uint8Array] => [`debounce ${ms} ms`, encodeGloriousCore2Debounce(ms, profile)]), + ["advanced debounce", encodeGloriousCore2AdvancedDebounce({ beforePress: 0, beforeRelease: 0, afterPress: 10, afterRelease: 10, liftOffPress: 8 }, profile)], + ]; + // The seven rates in the order CORE's list steps through them, each in both bytes ... + for (const [hertz] of GLORIOUS_CORE2_POLLING_CODES) cases.push([`polling ${hertz} Hz`, encodeGloriousCore2PollingRate(hertz, hertz, profile)]); + // ... then 8000 Hz on the cable with 4000 Hz on 2.4 GHz. + cases.push(["polling 8000 Hz wired, 4000 Hz wireless", encodeGloriousCore2PollingRate(8000, 4000, profile)]); + return cases; +} + +test("re-encodes every request CORE sent, byte for byte", () => { + const sent = capture("core-session.hex").filter((entry) => entry.dir === ">"); + assert.equal(sent.length, 116); + const known = new Map(expectedRequests().map(([name, bytes]) => [key(bytes), name])); + const unexplained = sent.filter(({ bytes }) => !known.has(key(bytes))); + assert.deepEqual(unexplained.map(({ bytes }) => key(bytes).slice(0, 40)), []); + // The other direction: every case above was really sent, and 22 is all there is. + const seen = new Set(sent.map(({ bytes }) => key(bytes))); + assert.deepEqual([...known].filter(([hex]) => !seen.has(hex)).map(([, name]) => name), []); + assert.equal(seen.size, 22); +}); + +test("CORE sends a burst in a fixed order, ending with debounce and motion sync", () => { + const sent = capture("core-session.hex").filter((entry) => entry.dir === ">"); + const first = sent.findIndex(({ bytes }) => bytes[3] === 0x12); + const register = (offset: number) => sent[first + offset]!.bytes[5]; + assert.deepEqual([0, 1, 2, 3, 4, 5, 6].map((offset) => [sent[first + offset]!.bytes[4], register(offset)]), [ + [GLORIOUS_CORE2_BANK.profile, GLORIOUS_CORE2_REGISTER.dpiStages], + [GLORIOUS_CORE2_BANK.lighting, GLORIOUS_CORE2_REGISTER.dpiColors], + [GLORIOUS_CORE2_BANK.profile, GLORIOUS_CORE2_REGISTER.liftOff], + [GLORIOUS_CORE2_BANK.profile, GLORIOUS_CORE2_REGISTER.activeDpiStage], + [GLORIOUS_CORE2_BANK.profile, GLORIOUS_CORE2_REGISTER.pollingRate], + [GLORIOUS_CORE2_BANK.system, GLORIOUS_CORE2_REGISTER.debounce], + [GLORIOUS_CORE2_BANK.profile, GLORIOUS_CORE2_REGISTER.motionSync], + ]); +}); + +test("the polling frame matches the one GloriousAutoPollingRate sends for 1000 Hz, apart from the profile", () => { + const theirs = Uint8Array.from([0x00, 0x00, 0x02, 0x03, 0x01, 0x0a, 0x01, 0x01, 0x01]); + assert.deepEqual(encodeGloriousCore2PollingRate(1000, 1000, 1).subarray(0, theirs.length), theirs); + assert.deepEqual(encodeGloriousCore2PollingRate(125, 125, 1).subarray(7, 9), Uint8Array.from([0x08, 0x08])); + assert.deepEqual(encodeGloriousCore2PollingRate(4000, 4000, 1).subarray(7, 9), Uint8Array.from([0x40, 0x40])); +}); + +test("a chosen rate goes into both bytes, except 8000 Hz whose wireless byte is 4000 Hz", () => { + for (const [hertz, code] of GLORIOUS_CORE2_POLLING_CODES) { + const wirelessCode = hertz === 8000 ? 0x40 : code; + const frame = encodeGloriousCore2PollingRate(hertz, Math.min(hertz, GLORIOUS_CORE2_WIRELESS_MAX_POLLING_HZ), profile); + assert.deepEqual([...frame.subarray(7, 9)], [code, wirelessCode], `${hertz} Hz`); + } + assert.deepEqual(GLORIOUS_CORE2_POLLING_CODES.map(([, code]) => code), [0x08, 0x04, 0x02, 0x01, 0x20, 0x40, 0x80]); + assert.equal(gloriousCore2PollingCode(1500), null); + assert.throws(() => encodeGloriousCore2PollingRate(1500, 1000, profile), /1500/); +}); + +test("simple debounce clears the advanced times; the advanced block is the five times and a zero", () => { + assert.deepEqual([...encodeGloriousCore2Debounce(12, 1).subarray(2, 13)], [0x02, 7, 0, 0x08, 1, 12, 0, 0, 0, 0, 0]); + assert.deepEqual( + [...encodeGloriousCore2AdvancedDebounce({ beforePress: 1, beforeRelease: 2, afterPress: 3, afterRelease: 4, liftOffPress: 5 }, 1).subarray(6, 13)], + [1, 1, 2, 3, 4, 5, 0], + ); + assert.throws(() => encodeGloriousCore2Debounce(17, 1), /0 to 16/); + assert.throws(() => encodeGloriousCore2AdvancedDebounce({ beforePress: 0, beforeRelease: 0, afterPress: 0, afterRelease: 0, liftOffPress: 18 }, 1), /0 to 16/); +}); + +test("the profile select frame is mxw's and CORE's: bank 0, register 5, the profile", () => { + assert.deepEqual([...encodeGloriousCore2Profile(2).subarray(0, 7)], [0x00, 0x00, 0x02, 0x01, 0x00, 0x05, 0x02]); + assert.throws(() => encodeGloriousCore2Profile(0), /1 to 3/); + assert.throws(() => encodeGloriousCore2Profile(4), /1 to 3/); + assert.throws(() => encodeGloriousCore2DpiStages([800], 4), /1 to 3/); +}); + +test("CORE reads a receiver's own firmware with target 0 and the mouse's with target 2", () => { + assert.deepEqual([...encodeGloriousCore2FirmwareRequest("mouse").subarray(0, 6)], [0x00, 0x00, 0x02, 0x03, 0x00, 0x81]); + assert.deepEqual([...encodeGloriousCore2FirmwareRequest("receiver").subarray(0, 6)], [0x00, 0x00, 0x00, 0x03, 0x00, 0x81]); +}); + +test("decodes the firmware reply: version 1.0.15.0 and the wired product id", () => { + const reply = capture("core-session.hex").find((entry) => entry.dir === "<" && entry.bytes[5] === 0x81)!; + assert.deepEqual(decodeGloriousCore2Firmware(reply.bytes), { version: "1.0.15.0", productId: 0x201b }); + const short = Uint8Array.from(reply.bytes); + short[3] = 4; + assert.deepEqual(decodeGloriousCore2Firmware(short), { version: "1.0.15.0", productId: null }); +}); + +test("decodes every battery reply CORE read: 100 %, not charging", () => { + const replies = capture("core-session.hex").filter((entry) => entry.dir === "<" && entry.bytes[5] === 0x83); + assert.equal(replies.length, 10); + for (const { bytes } of replies) assert.deepEqual(decodeGloriousCore2Battery(bytes), { percent: 100, charging: false }); +}); + +test("an asleep, waking or foreign reply is not a battery or firmware answer", () => { + const reply = capture("core-session.hex").find((entry) => entry.dir === "<" && entry.bytes[5] === 0x83)!.bytes; + for (const status of [0xa0, 0xa2, 0xa4]) { + const other = Uint8Array.from(reply); + other[0] = status; + assert.equal(decodeGloriousCore2Battery(other), null); + } + assert.equal(decodeGloriousCore2Firmware(reply), null); + assert.equal(decodeGloriousCore2Battery(new Uint8Array(3)), null); + assert.equal(decodeGloriousCore2Battery(new Uint8Array(64)), null); + assert.equal(decodeGloriousCore2Reply(new Uint8Array(3)), null); +}); + +test("the last write of every burst is answered with an echo whose status is ok", () => { + const echoes = capture("core-session.hex").filter((entry) => entry.dir === "<" && entry.bytes[5] === GLORIOUS_CORE2_REGISTER.motionSync); + assert.equal(echoes.length, 30); + for (const { bytes } of echoes) assert.equal(decodeGloriousCore2Reply(bytes)?.status, 0xa1); +}); + +test("the DPI button reports agree with the stage table CORE wrote", () => { + const written = capture("core-session.hex").find((entry) => entry.bytes[3] === 0x12 && entry.bytes[5] === 0x01)!.bytes; + const count = written[7]!; + const table = Array.from({ length: count }, (_, stage) => { + const at = 8 + stage * 4; + return { x: (written[at]! << 8) | written[at + 1]!, y: (written[at + 2]! << 8) | written[at + 3]! }; + }); + assert.deepEqual(table.map(({ x }) => x), FACTORY_STAGES); + const reports = capture("dpi-button-reports.hex"); + assert.equal(reports.length, 11); + for (const { bytes } of reports) { + assert.equal(bytes[0], 0x01); + const stage = table[bytes[1]! - 1]!; + assert.deepEqual([(bytes[2]! << 8) | bytes[3]!, (bytes[4]! << 8) | bytes[5]!], [stage.x, stage.y]); + } +}); + +test("rejects DPI, stage and color values the frame cannot carry", () => { + assert.throws(() => encodeGloriousCore2DpiStages([], 1), /1 to 6/); + assert.throws(() => encodeGloriousCore2DpiStages([800, 800, 800, 800, 800, 800, 800], 1), /1 to 6/); + assert.throws(() => encodeGloriousCore2DpiStages([99], 1), /DPI/); + assert.throws(() => encodeGloriousCore2DpiStages([26_001], 1), /DPI/); + assert.throws(() => encodeGloriousCore2DpiStages([800.5], 1), /DPI/); + assert.throws(() => encodeGloriousCore2DpiColors(["#12345"], 1), /#rrggbb/); + assert.throws(() => encodeGloriousCore2DpiColors(new Array(7).fill("#000000"), 1), /6 stage colors/); + assert.throws(() => encodeGloriousCore2ActiveDpiStage(6, 1), /out of range/); + assert.throws(() => gloriousCore2Request(1, 1, new Array(59).fill(0)), /fit/); +}); + +test("26000 DPI is 0x6590 on both axes", () => { + assert.deepEqual([...encodeGloriousCore2DpiStages([26_000], profile).subarray(6, 14)], [profile, 1, 0x65, 0x90, 0x65, 0x90, 0, 0]); +}); diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index d41ddcb..bb0674f 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -58,6 +58,7 @@ import { SteelSeriesPrimeMiniWirelessHidClient } from "./steelseries/prime-mini- import { SteelSeriesSenseiTenHidClient } from "./steelseries/sensei-ten-hid.ts"; import { GloriousHidClient } from "./glorious/hid.ts"; import { GloriousClassicHidClient } from "./glorious/classic-hid.ts"; +import { GloriousCore2HidClient } from "./glorious/core2-hid.ts"; import { MchoseHidClient } from "./mchose/hid.ts"; import { MchoseDockHidClient } from "./mchose/dock-hid.ts"; import { MchoseA5ProMaxHidClient } from "./mchose/a5-gen1-hid.ts"; @@ -79,7 +80,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 | 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 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 | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient; export interface DeviceDriver { brand: string; @@ -170,6 +171,8 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiTenHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiTenHidClient(device), score: () => 6 }, { brand: "Glorious", supports: (device) => GloriousHidClient.isSupported(device), create: (device) => new GloriousHidClient(device), score: () => 5 }, { brand: "Glorious", supports: (device) => GloriousClassicHidClient.isSupported(device), create: (device) => new GloriousClassicHidClient(device), score: () => 5 }, + // Same vendor id as the classic family; disjoint by product id. + { brand: "Glorious", supports: (device) => GloriousCore2HidClient.isSupported(device), create: (device) => new GloriousCore2HidClient(device), score: () => 5 }, { brand: "MCHOSE", supports: (device) => MchoseA5ProMaxHidClient.isSupported(device), create: (device) => new MchoseA5ProMaxHidClient(device), score: () => 8 }, { brand: "MCHOSE", supports: (device) => MchoseHidClient.isSupported(device), create: (device) => new MchoseHidClient(device), score: () => 7 }, { brand: "MCHOSE", supports: (device) => MchoseDockHidClient.isSupported(device), create: (device) => new MchoseDockHidClient(device), score: () => 7 }, diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index b16e910..86e64fb 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -7,6 +7,7 @@ import { import { ATK_COMPX_PRODUCT_IDS } from "./atk/products.ts"; import { MICROSOFT_PRODUCT_CLASSIC, MICROSOFT_PRODUCT_PRO, MICROSOFT_VENDOR_ID, MICROSOFT_CLASSIC_USAGE_PAGE, MICROSOFT_CLASSIC_USAGE, MICROSOFT_PRO_USAGE_PAGE, MICROSOFT_PRO_USAGE } from "../microsoft/index.ts"; import { INCOTT_PRODUCT_IDS, INCOTT_USAGE_PAGE, INCOTT_VENDOR_ID } from "../incott/index.ts"; +import { GLORIOUS_CORE2_PRODUCTS, GLORIOUS_CORE2_USAGE_PAGE, GLORIOUS_CORE2_VENDOR_ID } from "../glorious-core2/index.ts"; import { EGG_WE_HID_FILTERS } from "./endgame/egg-we-control.ts"; import { VAXEE_PRODUCT_IDS, VAXEE_USAGE, VAXEE_USAGE_PAGE, VAXEE_VENDOR_ID } from "@openmouse/protocol/vaxee"; import { @@ -306,7 +307,8 @@ export const GLORIOUS_PRODUCTS: ReadonlyMap ({ + vendorId: GLORIOUS_CORE2_VENDOR_ID, + productId, + usagePage: GLORIOUS_CORE2_USAGE_PAGE, +})); + export const GLORIOUS_CLASSIC_HID_FILTERS: HIDDeviceFilter[] = [...GLORIOUS_CLASSIC_PRODUCTS.keys()].flatMap( (productId) => [ { vendorId: VENDOR_ID.gloriousClassic, productId }, @@ -896,6 +909,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...STEELSERIES_RIVAL3_FILTERS, { vendorId: VENDOR_ID.glorious }, ...GLORIOUS_CLASSIC_HID_FILTERS, + ...GLORIOUS_CORE2_HID_FILTERS, // K-snake X11 exposes its 0x55-framed control channel on 0xFF01:0x10 for // both the wired USB VID and the 2.4 GHz dongle VID. { vendorId: VENDOR_ID.ksnakeUsb, productId: 0x2255, usagePage: 0xff01, usage: 0x10 }, diff --git a/src/glorious-core2/index.ts b/src/glorious-core2/index.ts new file mode 100644 index 0000000..a8c9bf2 --- /dev/null +++ b/src/glorious-core2/index.ts @@ -0,0 +1,329 @@ +/** + * Pure encoding rules of Glorious's "core2" feature-report protocol, spoken by + * the Model O 2 PRO 4K/8K (USB 0x258a:0x201b wired, 0x2035 receiver). It is + * the classic Model O/D envelope (see ../glorious-classic/index.ts) with a + * different register map, per-profile data, a polling-rate bitmask and one + * polling byte per link. + * + * Evidence, in order of weight: + * - A USBPcap capture of Glorious CORE on a wired unit + * (captures/glorious-o2-pro-4k8k-wired/). Every request in it is re-encoded + * byte for byte by core2-protocol.test.ts. + * - The frame builders of Glorious CORE 2.1.21 itself (`MouseV2ProDeviceHandler` + * in its Electron main bundle, which also handles the Model O/D Wireless that + * korkje/mxw documents). They name every register below and fix the data + * layouts the capture only shows by example. Their device table lists this + * mouse as "MODEL O 2 PRO 4k/8kHz Edition", config channel usage page 0xffff, + * usage 0, DPI 100 to 26000, three profiles. + * - https://github.com/korkje/mxw (envelope, profile select) and + * https://github.com/AMarcinkiewicz/GloriousAutoPollingRate (polling codes, + * measured on a Model D2 Pro 4K). + * + * Nothing here transports bytes; the WebHID client lives in + * `src/drivers/glorious/core2-hid.ts`. + * + * Transport: unnumbered 64-byte feature reports (report id 0) on the interface + * whose collection is usage page 0xffff, usage 0. A request is SET_REPORT; a + * reply, when there is one, is the GET_REPORT that follows about 60 ms later. + * There is no checksum. + * + * Request body, offsets without the report id: + * + * [0..1] 00 00 + * [2] 0x02, the mouse; CORE uses 0 only to read a receiver's own firmware + * [3] length of the data that follows the register byte + * [4] bank: 0 system, 1 per-profile settings, 2 per-profile lighting + * [5] register + * [6..] data; every per-profile register starts with the profile id + * + * A reply echoes [1..5] and puts its own data length at [3]. Byte [0] is a + * status: 0xa1 ok, 0xa0 busy or waking up, 0xa2 command failed, 0xa4 receiver + * cannot find the mouse. CORE sends the whole performance block (stages, + * colors, lift-off, active stage, polling, debounce, motion sync) in that + * order after any change, 30 ms apart and 120 ms after the active stage. + */ + +export const GLORIOUS_CORE2_VENDOR_ID = 0x258a; +export const GLORIOUS_CORE2_USAGE_PAGE = 0xffff; +export const GLORIOUS_CORE2_REPORT_ID = 0; +export const GLORIOUS_CORE2_FRAME_LENGTH = 64; + +export interface GloriousCore2Product { + name: string; + /** True for the 2.4 GHz receiver, false for the mouse on its cable. */ + wireless: boolean; +} + +/** Product ids from Glorious CORE's device table. */ +export const GLORIOUS_CORE2_PRODUCTS: ReadonlyMap = new Map([ + [0x201b, { name: "Model O2 Pro 4K/8K", wireless: false }], + [0x2035, { name: "Model O2 Pro 4K/8K Wireless receiver", wireless: true }], +]); + +/** Profiles are numbered from 1 on the wire, the position of the profile in CORE's list. */ +export const GLORIOUS_CORE2_PROFILE_COUNT = 3; +export const GLORIOUS_CORE2_PROFILE_DEFAULT = 1; +/** The profile CORE had selected when the capture was taken. */ +export const GLORIOUS_CORE2_PROFILE_CAPTURED = 2; + +const TARGET_MOUSE = 0x02; +const TARGET_RECEIVER = 0x00; +const HEADER_LENGTH = 6; + +export const GLORIOUS_CORE2_BANK = { system: 0x00, profile: 0x01, lighting: 0x02 } as const; + +/** + * Registers by bank. `liftOff` is named after CORE's UI: it writes the same 1 + * for both of its 1.0 mm and 2.0 mm options, so the value-to-distance map is + * not known and the codec has no encoder for it. + */ +export const GLORIOUS_CORE2_REGISTER = { + profileSelect: 0x05, + debounce: 0x08, + motionSync: 0x09, + pollingRate: 0x0a, + liftOff: 0x0b, + dpiStages: 0x01, + activeDpiStage: 0x02, + dpiColors: 0x01, + firmware: 0x81, + battery: 0x83, +} as const; + +/** Reply status byte (CORE `SetAndCheckStatus`). */ +export const GLORIOUS_CORE2_STATUS = { ok: 0xa1, busy: 0xa0, failed: 0xa2, asleep: 0xa4 } as const; + +/** Builds one request. `length` defaults to the data length, which is what every captured write carries. */ +export function gloriousCore2Request( + bank: number, + register: number, + data: readonly number[] = [], + length = data.length, + target = TARGET_MOUSE, +): Uint8Array { + if (HEADER_LENGTH + data.length > GLORIOUS_CORE2_FRAME_LENGTH) throw new RangeError("Glorious core2 data does not fit in one frame."); + const body = new Uint8Array(GLORIOUS_CORE2_FRAME_LENGTH); + body[2] = target; + body[3] = length; + body[4] = bank; + body[5] = register; + body.set(data, HEADER_LENGTH); + return body; +} + +export interface GloriousCore2Reply { + status: number; + bank: number; + register: number; + data: Uint8Array; +} + +/** Splits a reply into its parts, or null when it is too short to be one. */ +export function decodeGloriousCore2Reply(body: Uint8Array): GloriousCore2Reply | null { + if (body.length < HEADER_LENGTH) return null; + return { + status: body[0]!, + bank: body[4]!, + register: body[5]!, + data: body.subarray(HEADER_LENGTH, Math.min(body.length, HEADER_LENGTH + body[3]!)), + }; +} + +function okReplyFor(body: Uint8Array, register: number): GloriousCore2Reply | null { + const reply = decodeGloriousCore2Reply(body); + return reply && reply.status === GLORIOUS_CORE2_STATUS.ok && reply.register === register ? reply : null; +} + +// Reads: firmware and battery. The only two CORE ever issues. + +/** + * Reply data: firmware version a.b.c.d (4 bytes), then the wired product id + * (u16 BE). CORE addresses the mouse over the cable and the receiver itself + * (target 0) over the 2.4 GHz link; only the cable case was captured. + */ +export function encodeGloriousCore2FirmwareRequest(target: "mouse" | "receiver" = "mouse"): Uint8Array { + return gloriousCore2Request(GLORIOUS_CORE2_BANK.system, GLORIOUS_CORE2_REGISTER.firmware, [], 3, target === "mouse" ? TARGET_MOUSE : TARGET_RECEIVER); +} + +export interface GloriousCore2Firmware { + /** "1.0.15.0" for the unit that was captured. */ + version: string; + /** Absent when the reply is too short to carry it. */ + productId: number | null; +} + +export function decodeGloriousCore2Firmware(body: Uint8Array): GloriousCore2Firmware | null { + const reply = okReplyFor(body, GLORIOUS_CORE2_REGISTER.firmware); + if (!reply || reply.data.length < 4) return null; + const [a, b, c, d, high, low] = reply.data as unknown as number[]; + return { version: `${a}.${b}.${c}.${d}`, productId: reply.data.length >= 6 ? (high! << 8) | low! : null }; +} + +/** Reply data: charging flag (1 = charging), then percent. */ +export function encodeGloriousCore2BatteryRequest(): Uint8Array { + return gloriousCore2Request(GLORIOUS_CORE2_BANK.system, GLORIOUS_CORE2_REGISTER.battery, [], 2); +} + +export interface GloriousCore2Battery { + percent: number; + charging: boolean; +} + +/** Null while the mouse is asleep or waking up, or when the percent byte is not a percentage. */ +export function decodeGloriousCore2Battery(body: Uint8Array): GloriousCore2Battery | null { + const reply = okReplyFor(body, GLORIOUS_CORE2_REGISTER.battery); + if (!reply || reply.data.length < 2) return null; + // CORE and mxw both show a raw 0 as 1 %. + const percent = reply.data[1] === 0 ? 1 : reply.data[1]!; + return percent <= 100 ? { percent, charging: reply.data[0] === 1 } : null; +} + +// Profile select. Not in the capture (CORE had already selected profile 2); +// the frame is CORE's `PrepareChangeProfileBuffer` and mxw's `profile::set`. + +function assertProfile(profile: number): void { + if (!Number.isInteger(profile) || profile < 1 || profile > GLORIOUS_CORE2_PROFILE_COUNT) { + throw new RangeError(`Glorious core2 profile must be 1 to ${GLORIOUS_CORE2_PROFILE_COUNT}.`); + } +} + +export function encodeGloriousCore2Profile(profile: number): Uint8Array { + assertProfile(profile); + return gloriousCore2Request(GLORIOUS_CORE2_BANK.system, GLORIOUS_CORE2_REGISTER.profileSelect, [profile]); +} + +// DPI stages, their LED colors and the active stage. + +export const GLORIOUS_CORE2_MAX_DPI_STAGES = 6; +export const GLORIOUS_CORE2_DPI_MIN = 100; +export const GLORIOUS_CORE2_DPI_MAX = 26_000; +/** CORE accepts any value in range; 50 is the step the other Glorious drivers here use. */ +export const GLORIOUS_CORE2_DPI_STEP = 50; + +/** + * Stage table: profile, stage count, then per stage DPI X and DPI Y as u16 + * big-endian. CORE always writes X equal to Y. The mouse reports the same + * layout when a stage is selected with the DPI button (input report 4 on + * interface 1: 01, stage, X, Y). + */ +export function encodeGloriousCore2DpiStages(stages: readonly number[], profile: number): Uint8Array { + assertProfile(profile); + if (stages.length < 1 || stages.length > GLORIOUS_CORE2_MAX_DPI_STAGES) throw new RangeError(`Glorious core2 takes 1 to ${GLORIOUS_CORE2_MAX_DPI_STAGES} DPI stages.`); + const data = [profile, stages.length]; + for (const dpi of stages) { + if (!Number.isInteger(dpi) || dpi < GLORIOUS_CORE2_DPI_MIN || dpi > GLORIOUS_CORE2_DPI_MAX) { + throw new RangeError(`Glorious core2 DPI must be ${GLORIOUS_CORE2_DPI_MIN} to ${GLORIOUS_CORE2_DPI_MAX}.`); + } + data.push(dpi >> 8, dpi & 0xff, dpi >> 8, dpi & 0xff); + } + return gloriousCore2Request(GLORIOUS_CORE2_BANK.profile, GLORIOUS_CORE2_REGISTER.dpiStages, data); +} + +/** + * Stage LED colors: profile, then six RGB triplets whatever the stage count, + * unused slots zero. CORE's factory colors are orange, light blue, red, green. + */ +export function encodeGloriousCore2DpiColors(colors: readonly string[], profile: number): Uint8Array { + assertProfile(profile); + if (colors.length > GLORIOUS_CORE2_MAX_DPI_STAGES) throw new RangeError(`Glorious core2 has ${GLORIOUS_CORE2_MAX_DPI_STAGES} stage colors.`); + const data = [profile]; + for (let slot = 0; slot < GLORIOUS_CORE2_MAX_DPI_STAGES; slot += 1) { + const color = colors[slot]; + if (color === undefined) { + data.push(0, 0, 0); + continue; + } + const match = /^#?([0-9a-f]{6})$/i.exec(color.trim()); + if (!match) throw new RangeError(`Glorious core2 color must be #rrggbb, got ${color}.`); + const rgb = Number.parseInt(match[1]!, 16); + data.push(rgb >> 16, (rgb >> 8) & 0xff, rgb & 0xff); + } + return gloriousCore2Request(GLORIOUS_CORE2_BANK.lighting, GLORIOUS_CORE2_REGISTER.dpiColors, data); +} + +/** `stageIndex` is 0-based here; the mouse counts stages from 1. */ +export function encodeGloriousCore2ActiveDpiStage(stageIndex: number, profile: number): Uint8Array { + assertProfile(profile); + if (!Number.isInteger(stageIndex) || stageIndex < 0 || stageIndex >= GLORIOUS_CORE2_MAX_DPI_STAGES) throw new RangeError("Glorious core2 DPI stage index is out of range."); + return gloriousCore2Request(GLORIOUS_CORE2_BANK.profile, GLORIOUS_CORE2_REGISTER.activeDpiStage, [profile, stageIndex + 1]); +} + +// Polling rate: a bitmask code per rate, one for the cable and one for 2.4 GHz. + +/** + * Codes are not a scale (0x10 is unused). They are CORE's own table; 125 to + * 4000 Hz were also confirmed by counting input reports on a Model D2 Pro 4K + * (GloriousAutoPollingRate). + */ +export const GLORIOUS_CORE2_POLLING_CODES: ReadonlyArray = [ + [125, 0x08], + [250, 0x04], + [500, 0x02], + [1000, 0x01], + [2000, 0x20], + [4000, 0x40], + [8000, 0x80], +]; + +/** 8000 Hz needs the cable; CORE writes 4000 Hz for the 2.4 GHz link in that case. */ +export const GLORIOUS_CORE2_WIRELESS_MAX_POLLING_HZ = 4000; + +export function gloriousCore2PollingCode(hertz: number): number | null { + return GLORIOUS_CORE2_POLLING_CODES.find(([rate]) => rate === hertz)?.[1] ?? null; +} + +/** + * Data: profile, cable code, 2.4 GHz code. For one chosen rate CORE sends it + * twice, except 8000 Hz, whose 2.4 GHz byte is 4000 Hz. The capture also has + * one frame with 8000 Hz in both bytes, written while CORE's list held two + * rates; this encoder builds it, the client never does. + */ +export function encodeGloriousCore2PollingRate(wiredHz: number, wirelessHz: number, profile: number): Uint8Array { + assertProfile(profile); + const wired = gloriousCore2PollingCode(wiredHz); + const wireless = gloriousCore2PollingCode(wirelessHz); + if (wired === null || wireless === null) throw new RangeError(`Glorious core2 has no polling code for ${wired === null ? wiredHz : wirelessHz} Hz.`); + return gloriousCore2Request(GLORIOUS_CORE2_BANK.profile, GLORIOUS_CORE2_REGISTER.pollingRate, [profile, wired, wireless]); +} + +// Debounce: one block of six bytes, either a single time or five advanced ones. + +/** The product page gives 4 to 16 ms, 10 ms by default; CORE's slider moves in steps of 2. */ +export const GLORIOUS_CORE2_DEBOUNCE_MIN_MS = 4; +export const GLORIOUS_CORE2_DEBOUNCE_MAX_MS = 16; +export const GLORIOUS_CORE2_DEBOUNCE_STEP_MS = 2; +export const GLORIOUS_CORE2_DEBOUNCE_DEFAULT_MS = 10; + +/** Simple mode, which also clears the advanced times: profile, ms, five zeros. */ +export function encodeGloriousCore2Debounce(milliseconds: number, profile: number): Uint8Array { + assertProfile(profile); + if (!Number.isInteger(milliseconds) || milliseconds < 0 || milliseconds > GLORIOUS_CORE2_DEBOUNCE_MAX_MS) { + throw new RangeError(`Glorious core2 debounce must be 0 to ${GLORIOUS_CORE2_DEBOUNCE_MAX_MS} ms.`); + } + return gloriousCore2Request(GLORIOUS_CORE2_BANK.system, GLORIOUS_CORE2_REGISTER.debounce, [profile, milliseconds, 0, 0, 0, 0, 0]); +} + +export interface GloriousCore2AdvancedDebounce { + beforePress: number; + beforeRelease: number; + afterPress: number; + afterRelease: number; + liftOffPress: number; +} + +/** CORE's defaults are 0, 0, 10, 10, 8. Not offered by the client. */ +export function encodeGloriousCore2AdvancedDebounce(times: GloriousCore2AdvancedDebounce, profile: number): Uint8Array { + assertProfile(profile); + const values = [times.beforePress, times.beforeRelease, times.afterPress, times.afterRelease, times.liftOffPress]; + if (values.some((value) => !Number.isInteger(value) || value < 0 || value > GLORIOUS_CORE2_DEBOUNCE_MAX_MS)) { + throw new RangeError(`Glorious core2 debounce must be 0 to ${GLORIOUS_CORE2_DEBOUNCE_MAX_MS} ms.`); + } + return gloriousCore2Request(GLORIOUS_CORE2_BANK.system, GLORIOUS_CORE2_REGISTER.debounce, [profile, ...values, 0]); +} + +// Motion sync. Glorious advises turning it off at 8000 Hz. + +export function encodeGloriousCore2MotionSync(enabled: boolean, profile: number): Uint8Array { + assertProfile(profile); + return gloriousCore2Request(GLORIOUS_CORE2_BANK.profile, GLORIOUS_CORE2_REGISTER.motionSync, [profile, enabled ? 1 : 0]); +} diff --git a/src/index.ts b/src/index.ts index 49e6056..9975a1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ export * as corsair from "./corsair/index.js"; export * as dareu from "./dareu/index.js"; export * as gwolves from "./gwolves/index.js"; export * as glorious from "./glorious/index.js"; +export * as gloriousCore2 from "./glorious-core2/index.js"; export * as ksnake from "./ksnake/index.js"; export * as hyperx from "./hyperx/index.js"; export * as valkyrie from "./valkyrie/index.js";