From 8b56139fe36d60ab0841473f4e6d2e11aac7292a Mon Sep 17 00:00:00 2001 From: ydw1904 Date: Thu, 8 Oct 2026 11:24:22 +0800 Subject: [PATCH] feat(glorious): driver for the Model O 2 PRO 4K/8K The Model O 2 PRO 4K/8K (0x258a:0x201b on the cable, 0x2035 through its receiver) speaks the Glorious CORE feature-report protocol: unnumbered 64-byte reports on the 0xffff:0 collection, `00 00 02 len bank register data`, with the profile id first in every per-profile register. The codec (src/glorious-core2/index.ts) re-encodes all 116 requests of a community capture of Glorious CORE on a wired unit (Discord ticket 0160) byte for byte. What each register is comes from CORE's own frame builders: DPI stages and colors, active stage, polling rate (one code for the cable and one for 2.4 GHz), simple and advanced debounce, motion sync, profile select, and the firmware and battery reads. GloriousCore2HidClient reads firmware and battery and writes DPI stages, polling rate (125 to 8000 Hz, 4000 Hz on the receiver), debounce, motion sync and the active profile. The mouse has no read for those settings, so the client keeps its last-written values per profile. Lift-off, auto sleep, lighting and buttons are left out: not captured, or, for lift-off, CORE writes the same value for both of its options. 0x201b and 0x2035 leave GLORIOUS_CLASSIC_PRODUCTS so that exactly one driver claims them. Not tested on hardware. Co-Authored-By: Claude Sonnet 5.5 --- captures/glorious-o2-pro-4k8k-wired/README.md | 74 ++++ .../core-session.hex | 163 +++++++ .../dpi-button-reports.hex | 14 + docs/glorious-o2-pro-4k8k.md | 110 +++++ package.json | 4 + src/drivers/glorious/core2-hid.test.ts | 242 ++++++++++ src/drivers/glorious/core2-hid.ts | 412 ++++++++++++++++++ src/drivers/glorious/core2-protocol.test.ts | 199 +++++++++ src/drivers/registry.ts | 5 +- src/drivers/vendors.ts | 20 +- src/glorious-core2/index.ts | 329 ++++++++++++++ src/index.ts | 1 + 12 files changed, 1569 insertions(+), 4 deletions(-) create mode 100644 captures/glorious-o2-pro-4k8k-wired/README.md create mode 100644 captures/glorious-o2-pro-4k8k-wired/core-session.hex create mode 100644 captures/glorious-o2-pro-4k8k-wired/dpi-button-reports.hex create mode 100644 docs/glorious-o2-pro-4k8k.md create mode 100644 src/drivers/glorious/core2-hid.test.ts create mode 100644 src/drivers/glorious/core2-hid.ts create mode 100644 src/drivers/glorious/core2-protocol.test.ts create mode 100644 src/glorious-core2/index.ts 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 2489f55..ac31e43 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 578f135..35caf3d 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -56,6 +56,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"; @@ -76,7 +77,7 @@ import { RapooHidClient } from "./rapoo/hid.ts"; import { CoolerMasterHidClient } from "./coolermaster/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient; export interface DeviceDriver { brand: string; @@ -165,6 +166,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 17c9faa..22ed77f 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 { @@ -287,7 +288,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 }, @@ -864,6 +877,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 df3399e..67a9c35 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";