From 9bf7cb3fba94edba32c8cd817391a50c0975b576 Mon Sep 17 00:00:00 2001 From: Ross Simpson Date: Sat, 3 Oct 2026 17:20:58 +0200 Subject: [PATCH] feat(redragon): add the M690 PRO driver (SinoWealth 258a:002e/002f) --- captures/redragon-m690-pro/README.md | 50 ++ .../app-startup-cable-fw295.hex | 6 + .../app-startup-cable-fw297.hex | 5 + .../app-startup-receiver-factory.hex | 8 + .../app-startup-receiver.hex | 7 + .../bridge-receiver-session.hex | 18 + .../redragon-m690-pro/button-remaps-cable.hex | 13 + .../redragon-m690-pro/cable-charge-state.hex | 6 + .../chrome-report5-stall.hex | 13 + .../redragon-m690-pro/dpi-button-events.hex | 10 + .../redragon-m690-pro/dpi-colours-cable.hex | 21 + .../dpi-stage-edits-cable.hex | 25 + .../keyboard-keys-receiver.hex | 27 + .../lighting-edits-cable.hex | 21 + .../redragon-m690-pro/macro-assign-cable.hex | 6 + .../openmouse-hardware-test-bridge-wired.json | 137 +++++ ...enmouse-hardware-test-bridge-wireless.json | 137 +++++ .../openmouse-hardware-test-wired.json | 137 +++++ .../openmouse-hardware-test-wireless.json | 137 +++++ .../openmouse-receiver-session.hex | 38 ++ .../polling-125-to-500-cable.hex | 5 + .../receiver-offline-apply.hex | 7 + .../receiver-polling-and-standby.hex | 9 + .../redragon-m690-pro/report-descriptors.hex | 7 + docs/redragon-m690-pro-testing.md | 282 +++++++++ src/drivers/redragon/m690-pro-hid.ts | 541 ++++++++++++++++++ src/drivers/redragon/m690-pro.test.ts | 470 +++++++++++++++ src/drivers/registry.ts | 7 +- src/drivers/vendors.ts | 19 + src/redragon/index.ts | 33 +- src/redragon/keys.ts | 45 ++ src/redragon/m690-pro.ts | 517 +++++++++++++++++ 32 files changed, 2734 insertions(+), 30 deletions(-) create mode 100644 captures/redragon-m690-pro/README.md create mode 100644 captures/redragon-m690-pro/app-startup-cable-fw295.hex create mode 100644 captures/redragon-m690-pro/app-startup-cable-fw297.hex create mode 100644 captures/redragon-m690-pro/app-startup-receiver-factory.hex create mode 100644 captures/redragon-m690-pro/app-startup-receiver.hex create mode 100644 captures/redragon-m690-pro/bridge-receiver-session.hex create mode 100644 captures/redragon-m690-pro/button-remaps-cable.hex create mode 100644 captures/redragon-m690-pro/cable-charge-state.hex create mode 100644 captures/redragon-m690-pro/chrome-report5-stall.hex create mode 100644 captures/redragon-m690-pro/dpi-button-events.hex create mode 100644 captures/redragon-m690-pro/dpi-colours-cable.hex create mode 100644 captures/redragon-m690-pro/dpi-stage-edits-cable.hex create mode 100644 captures/redragon-m690-pro/keyboard-keys-receiver.hex create mode 100644 captures/redragon-m690-pro/lighting-edits-cable.hex create mode 100644 captures/redragon-m690-pro/macro-assign-cable.hex create mode 100755 captures/redragon-m690-pro/openmouse-hardware-test-bridge-wired.json create mode 100755 captures/redragon-m690-pro/openmouse-hardware-test-bridge-wireless.json create mode 100755 captures/redragon-m690-pro/openmouse-hardware-test-wired.json create mode 100755 captures/redragon-m690-pro/openmouse-hardware-test-wireless.json create mode 100644 captures/redragon-m690-pro/openmouse-receiver-session.hex create mode 100644 captures/redragon-m690-pro/polling-125-to-500-cable.hex create mode 100644 captures/redragon-m690-pro/receiver-offline-apply.hex create mode 100644 captures/redragon-m690-pro/receiver-polling-and-standby.hex create mode 100644 captures/redragon-m690-pro/report-descriptors.hex create mode 100644 docs/redragon-m690-pro-testing.md create mode 100644 src/drivers/redragon/m690-pro-hid.ts create mode 100644 src/drivers/redragon/m690-pro.test.ts create mode 100644 src/redragon/keys.ts create mode 100644 src/redragon/m690-pro.ts diff --git a/captures/redragon-m690-pro/README.md b/captures/redragon-m690-pro/README.md new file mode 100644 index 0000000..4f900aa --- /dev/null +++ b/captures/redragon-m690-pro/README.md @@ -0,0 +1,50 @@ +# Redragon M690 PRO (`258a:002e` / `258a:002f`) captures + +Vendor-channel traffic between the official Redragon M690-PRO app (v1.0, +`Redragon_M690-PRO_Setup_v1.0_20221125`, OemDrv-based) and three units +(mouse firmware 2.95 and 2.97, receiver firmware 6.05), captured with +USBPcap on Windows and exported to text: HID feature reports 5 and 8, +report-7 events, keyboard reports from bound keys, and report descriptors only. The mouse reports no serial +number, so the files carry no unit identifier. + +| File | What it is | +| --- | --- | +| `report-descriptors.hex` | Interface 0 (mouse) and interface 1 (keyboard, consumer, vendor) report descriptors. | +| `app-startup-cable-fw295.hex` | App startup over the cable, firmware 2.95. | +| `app-startup-cable-fw297.hex` | The same on firmware 2.97. | +| `app-startup-receiver.hex` | App startup through the receiver (bank `0x21`/`0x22`) and its battery polls. | +| `app-startup-receiver-factory.hex` | App startup through the receiver of a factory-fresh unit: factory settings, identify zeros until the mouse links. | +| `polling-125-to-500-cable.hex` | One polling write, the reference for the block-write format. | +| `dpi-stage-edits-cable.hex` | DPI 250 / 3000 / 8000 on each stage. | +| `dpi-colours-cable.hex` | DPI indicator colours for all five stages, then every stage selected. | +| `lighting-edits-cable.hex` | Steady, Breathing and Colorful Streaming edits. | +| `button-remaps-cable.hex` | Button 8 and wheel-click reassignments. | +| `keyboard-keys-receiver.hex` | Keyboard keys bound to a button through the receiver, each followed by the keyboard report the mouse sent on press. | +| `macro-assign-cable.hex` | Macro store and a macro slot (documented only; the driver does not write macros). | +| `receiver-polling-and-standby.hex` | Receiver writes 125 -> 250 -> 125 Hz, app opened with the mouse off. | +| `receiver-offline-apply.hex` | Link check `05 80` with the mouse off (`00`) and on (`01`). | +| `openmouse-receiver-session.hex` | This driver in OpenMouse through the receiver: a write made with the mouse off is undone when it reconnects; DPI events; 500 / 1000 Hz writes. | +| `cable-charge-state.hex` | `05 90` over the cable: `10 01` while charging, `10 02` once charged. | +| `bridge-receiver-session.hex` | This driver in OpenMouse through OpenMouse Bridge: link and battery status readable, writes held back while the mouse is asleep. | +| `openmouse-hardware-test-wired.json` | OpenMouse's built-in hardware test, cable, Chrome: pass. | +| `openmouse-hardware-test-wireless.json` | The same through the receiver, Chrome: pass. | +| `openmouse-hardware-test-bridge-wired.json` | Cable through OpenMouse Bridge: pass, with charging status. | +| `openmouse-hardware-test-bridge-wireless.json` | Receiver through OpenMouse Bridge: pass, with battery (99 %). | +| `chrome-report5-stall.hex` | Chrome's feature reads (wLength 520) STALLed by the mouse: why battery and link status cannot be read in a browser. | +| `dpi-button-events.hex` | Report 7 events from the mouse's own DPI buttons. | + +In the `.hex` files each line is `SET` (host to mouse), `GET` (mouse to host) +or `EVT` (interrupt-IN), then the report bytes with the report id first and +trailing zeros dropped (`chrome-report5-stall.hex` lists setup packets and USB +status instead). Every read is selected by `SET 05 ` and answered +with that command in the reply's second byte, so the `SET` is shown only when +no answer arrives. Lines are in capture order, without timestamps; a `# --` +comment marks where timing matters. Each settings or button block is shown in +full once per file; later copies give the first four bytes, `..`, then only the +bytes that changed since the previous copy as `[offset] old -> new` +(`unchanged` or `identical to the write` when none did; the `a5` ending a +button-block write at `0x58` is not listed). Repeated reads, repeated answers +and key releases are omitted; battery polls appear only in the startup, +charge and Bridge files, and the identify answer only in the startup files. + +Decoding and verification notes: `docs/redragon-m690-pro-testing.md`. diff --git a/captures/redragon-m690-pro/app-startup-cable-fw295.hex b/captures/redragon-m690-pro/app-startup-cable-fw295.hex new file mode 100644 index 0000000..d3478b1 --- /dev/null +++ b/captures/redragon-m690-pro/app-startup-cable-fw295.hex @@ -0,0 +1,6 @@ +# Redragon M690 PRO: Vendor app v1.0 starting over the cable, firmware 2.95: identify (05 01 -> "2945"), settings +# block (05 11), button block (05 12), then the 30 s status poll (05 90). +GET 05 01 32 39 34 35 +GET 08 11 00 00 00 00 00 00 64 13 01 35 00 02 00 04 00 08 00 0c 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 40 00 00 40 00 00 40 00 00 40 ff 46 00 00 ff ff ff ff ff 02 00 01 00 ff 00 00 00 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +GET 08 12 00 00 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 +GET 05 90 10 01 diff --git a/captures/redragon-m690-pro/app-startup-cable-fw297.hex b/captures/redragon-m690-pro/app-startup-cable-fw297.hex new file mode 100644 index 0000000..54cf3fa --- /dev/null +++ b/captures/redragon-m690-pro/app-startup-cable-fw297.hex @@ -0,0 +1,5 @@ +# Redragon M690 PRO: The same startup on firmware 2.97: same id; 250 Hz ([0x0a] = 02), stage 1 active ([0x0b] = 15). +GET 05 01 32 39 34 35 +GET 08 11 00 00 00 00 00 00 64 13 02 15 00 02 00 04 00 08 00 0c 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ff 00 00 00 00 ff 00 ff 00 ff 00 ff ff ff 00 ff 46 00 00 ff ff ff ff ff 03 42 01 00 ff 00 00 00 07 ff 00 00 00 ff 00 00 00 00 ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 34 0f 03 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +GET 08 12 00 00 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 +GET 05 90 10 01 diff --git a/captures/redragon-m690-pro/app-startup-receiver-factory.hex b/captures/redragon-m690-pro/app-startup-receiver-factory.hex new file mode 100644 index 0000000..2780ff0 --- /dev/null +++ b/captures/redragon-m690-pro/app-startup-receiver-factory.hex @@ -0,0 +1,8 @@ +# Redragon M690 PRO: Vendor app through the receiver of a factory-fresh unit (factory settings). Identify answers all +# zeros (05 01 00 ..) until the mouse links, then "2945"; the app retries every ~7 s. +# 05 90 answers 11 63 (level 99) while the app displays "80 %". +GET 05 01 +GET 05 01 32 39 34 35 +GET 08 21 00 00 00 00 00 00 64 13 01 15 00 02 00 04 00 08 00 0c 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ff 00 00 00 00 ff 00 ff 00 ff 00 ff ff ff 00 ff 46 00 00 ff ff ff ff ff 01 42 01 40 ff 00 00 42 07 ff 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 ff 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +GET 08 22 00 00 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 +GET 05 90 11 63 diff --git a/captures/redragon-m690-pro/app-startup-receiver.hex b/captures/redragon-m690-pro/app-startup-receiver.hex new file mode 100644 index 0000000..d532ace --- /dev/null +++ b/captures/redragon-m690-pro/app-startup-receiver.hex @@ -0,0 +1,7 @@ +# Redragon M690 PRO: Vendor app through the receiver: the receiver bank is read with 05 21 / 05 22. +# 05 90 answers 11 64 (level 100), later 11 63 (99). +GET 05 01 32 39 34 35 +GET 08 21 00 00 00 00 00 00 64 13 01 25 00 02 00 04 00 08 00 0c 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 40 00 00 40 00 00 40 00 00 40 ff 46 00 00 ff ff ff ff ff 02 00 01 00 ff 00 00 00 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +GET 08 22 00 00 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 +GET 05 90 11 64 +GET 05 90 11 63 diff --git a/captures/redragon-m690-pro/bridge-receiver-session.hex b/captures/redragon-m690-pro/bridge-receiver-session.hex new file mode 100644 index 0000000..dd50d7e --- /dev/null +++ b/captures/redragon-m690-pro/bridge-receiver-session.hex @@ -0,0 +1,18 @@ +# Redragon M690 PRO: This driver in OpenMouse through OpenMouse Bridge, receiver path. Bridge reads report 5 with +# wLength 8, so the short answers arrive: link 05 80 00 02 while the mouse sleeps (battery then reads +# the receiver's last value, 11 64), 05 80 01 01 once it wakes (battery 11 63 = 99). Two lighting +# writes follow, each read back. +GET 08 21 00 00 00 00 00 00 64 13 01 25 00 02 00 05 00 0e 00 13 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ff 80 80 ff ff 00 00 ff 00 00 00 ff ff 80 c0 ff 46 00 00 ff ff ff ff ff 03 44 01 40 ff 00 00 44 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 e9 5a ee 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +GET 05 80 00 02 +GET 08 22 00 00 00 00 00 00 11 01 00 00 11 02 00 00 11 02 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 +GET 05 90 11 64 +GET 05 80 01 01 +GET 05 90 11 63 +EVT 07 11 01 03 0e +GET 08 21 00 00 .. [0x0b] 25 -> 35 +SET 08 21 00 92 .. [0x45] 03 -> 01 +SET 08 22 00 50 .. unchanged +GET 08 21 .. identical to the write +GET 08 22 .. identical to the write +SET 08 21 00 92 .. [0x45] 01 -> 03 +GET 08 21 .. identical to the write diff --git a/captures/redragon-m690-pro/button-remaps-cable.hex b/captures/redragon-m690-pro/button-remaps-cable.hex new file mode 100644 index 0000000..abea478 --- /dev/null +++ b/captures/redragon-m690-pro/button-remaps-cable.hex @@ -0,0 +1,13 @@ +# Redragon M690 PRO: Button 8 (slot 8, 0x24) and button 3 (slot 3, 0x10) reassigned: 50 02, 31 01 32 03, 11 10, +# 22 00 00 20 (Refresh), 50 01 (Disable), 11 04. +GET 08 11 00 00 00 00 00 00 64 13 03 35 00 18 00 18 00 18 00 18 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 40 00 00 40 00 00 40 00 00 40 ff 46 00 00 ff ff ff ff ff 02 12 01 00 ff 00 00 42 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 00 00 00 00 ff 00 00 00 ff 00 00 ff 4c 22 8a ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +SET 08 11 00 92 .. unchanged +SET 08 12 00 50 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 50 02 00 00 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 a5 +GET 08 11 .. identical to the write +SET 08 12 00 50 .. [0x24] 50 02 00 00 -> 31 01 32 03 +SET 08 12 00 50 .. [0x24] 31 01 32 03 -> 11 10 00 00 +SET 08 12 00 50 .. [0x24] 11 10 00 00 -> 22 00 00 20 +SET 08 12 00 50 .. [0x24] 22 00 00 20 -> 50 02 00 00 +SET 08 12 00 50 .. [0x11] 04 -> 10 +SET 08 12 00 50 .. [0x10] 11 10 -> 50 01 +SET 08 12 00 50 .. [0x10] 50 01 -> 11 04 diff --git a/captures/redragon-m690-pro/cable-charge-state.hex b/captures/redragon-m690-pro/cable-charge-state.hex new file mode 100644 index 0000000..eb0e76d --- /dev/null +++ b/captures/redragon-m690-pro/cable-charge-state.hex @@ -0,0 +1,6 @@ +# Redragon M690 PRO: Vendor app over the cable with a fully charged mouse just plugged in: 05 90 answers 10 01 +# (charging) for about a minute, then 10 02 (charged), when the app shows "100 %". +GET 08 11 00 00 00 00 00 00 64 13 04 35 00 02 00 04 00 08 00 0c 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 40 00 00 40 00 00 40 00 00 40 ff 46 00 00 ff ff ff ff ff 02 44 01 00 ff 00 00 40 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 0b eb d7 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +GET 08 12 00 00 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 +GET 05 90 10 01 +GET 05 90 10 02 diff --git a/captures/redragon-m690-pro/chrome-report5-stall.hex b/captures/redragon-m690-pro/chrome-report5-stall.hex new file mode 100644 index 0000000..d7efd0b --- /dev/null +++ b/captures/redragon-m690-pro/chrome-report5-stall.hex @@ -0,0 +1,13 @@ +# Redragon M690 PRO: Chrome (WebHID, Windows) over the cable: every feature read asks for wLength 0x0208 (520) instead +# of the app's 8, and the mouse STALLs it (status c0000004). Lines are HID class control transfers: +# the 8-byte setup packet, the USBD status, and any data. +setup 21 09 05 03 01 00 08 00 status 00000000 data 05 01 +setup a1 01 05 03 01 00 08 02 status c0000004 data - +setup 21 09 05 03 01 00 08 00 status 00000000 data 05 01 +setup a1 01 08 03 01 00 08 02 status c0000004 data - +setup 21 09 05 03 01 00 08 00 status 00000000 data 05 01 +setup a1 01 05 03 01 00 08 02 status c0000004 data - +setup 21 09 05 03 01 00 08 00 status 00000000 data 05 90 +setup a1 01 08 03 01 00 08 02 status c0000004 data - +setup 21 09 05 03 01 00 08 00 status 00000000 data 05 90 +setup a1 01 05 03 01 00 08 02 status c0000004 data - diff --git a/captures/redragon-m690-pro/dpi-button-events.hex b/captures/redragon-m690-pro/dpi-button-events.hex new file mode 100644 index 0000000..dc710f0 --- /dev/null +++ b/captures/redragon-m690-pro/dpi-button-events.hex @@ -0,0 +1,10 @@ +# Redragon M690 PRO: Unprompted report 7 when the mouse's DPI buttons change stage: 07 10 01 . +EVT 07 10 01 03 08 +EVT 07 10 01 02 04 +EVT 07 10 01 03 08 +EVT 07 10 01 02 04 +EVT 07 10 01 03 08 +EVT 07 10 01 02 04 +EVT 07 10 01 03 08 +EVT 07 10 01 04 0c +EVT 07 10 01 03 08 diff --git a/captures/redragon-m690-pro/dpi-colours-cable.hex b/captures/redragon-m690-pro/dpi-colours-cable.hex new file mode 100644 index 0000000..230379d --- /dev/null +++ b/captures/redragon-m690-pro/dpi-colours-cable.hex @@ -0,0 +1,21 @@ +# Redragon M690 PRO: DPI indicator colours for stages 1-5 (ff8080, ffff00, 00ff00, 0000ff, ffffff at 0x2d + 3n), +# then every stage selected with the DPI buttons: report 7 reads 07 10 04 .. here and 07 10 01 .. +# in other sessions (byte 2 unknown). +GET 08 11 00 00 00 00 00 00 64 13 04 25 00 02 00 04 00 08 00 0c 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 40 00 00 40 00 00 40 00 00 40 ff 46 00 00 ff ff ff ff ff 02 44 01 00 ff 00 00 40 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 0b eb d7 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +SET 08 11 00 92 .. [0x2d] 00 00 40 00 00 40 00 00 40 00 00 40 00 00 40 -> ff 80 80 ff ff 00 00 ff 00 00 00 ff ff ff ff +SET 08 12 00 50 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 a5 +EVT 07 10 04 03 08 +EVT 07 10 04 04 0c +EVT 07 10 04 05 18 +EVT 07 10 04 04 0c +EVT 07 10 04 03 08 +EVT 07 10 04 02 04 +EVT 07 10 04 01 02 +EVT 07 10 04 02 04 +EVT 07 10 04 03 08 +EVT 07 10 04 04 0c +EVT 07 10 04 05 18 +EVT 07 10 04 04 0c +EVT 07 10 04 03 08 +EVT 07 10 04 02 04 +EVT 07 10 04 01 02 diff --git a/captures/redragon-m690-pro/dpi-stage-edits-cable.hex b/captures/redragon-m690-pro/dpi-stage-edits-cable.hex new file mode 100644 index 0000000..a411aee --- /dev/null +++ b/captures/redragon-m690-pro/dpi-stage-edits-cable.hex @@ -0,0 +1,25 @@ +# Redragon M690 PRO: Every stage set to 250 (code 01), then each in turn to 3000 (0c) and 8000 (18). +GET 08 11 00 00 00 00 00 00 64 13 03 35 00 02 00 04 00 08 00 0c 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 40 00 00 40 00 00 40 00 00 40 ff 46 00 00 ff ff ff ff ff 02 12 01 00 ff 00 00 42 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 00 00 00 00 ff 00 00 00 ff 00 00 ff 4c 22 8a ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +SET 08 11 00 92 .. [0x0d] 02 00 04 00 08 00 0c 00 18 -> 01 00 01 00 01 00 01 00 01 +SET 08 12 00 50 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 a5 +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x0d] 01 -> 0c +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x0d] 0c -> 18 +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x0f] 01 -> 0c +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x0f] 0c -> 18 +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x11] 01 -> 0c +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x11] 0c -> 18 +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x13] 01 -> 0c +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x13] 0c -> 18 +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x15] 01 -> 0c +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x15] 0c -> 18 +GET 08 11 .. identical to the write diff --git a/captures/redragon-m690-pro/keyboard-keys-receiver.hex b/captures/redragon-m690-pro/keyboard-keys-receiver.hex new file mode 100644 index 0000000..33bc224 --- /dev/null +++ b/captures/redragon-m690-pro/keyboard-keys-receiver.hex @@ -0,0 +1,27 @@ +# Redragon M690 PRO: Keyboard keys bound to the wheel click through the receiver (slot 3 = 21 00), +# each followed by presses: the mouse sends keyboard report 1 (01 ..). In order: +# Ctrl+Shift+R (already bound), Alt+A, Win+W, A, \, Delete, Up arrow, F12, Numpad 5, Backspace. +EVT 01 03 15 +GET 08 21 00 00 00 00 00 00 64 13 03 35 00 02 00 05 00 0e 00 13 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ff 80 80 ff ff 00 00 ff 00 00 00 ff ff 80 c0 ff 46 00 00 ff ff ff ff ff 03 44 01 40 ff 00 00 44 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 e9 5a ee 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +GET 08 22 00 00 00 00 00 00 11 01 00 00 11 02 00 00 21 03 15 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 +GET 05 80 01 01 +SET 08 21 00 92 .. unchanged +SET 08 22 00 50 .. [0x11] 03 15 -> 04 04 +EVT 01 04 04 +GET 08 21 .. identical to the write +SET 08 22 00 50 .. [0x11] 04 04 -> 08 1a +EVT 01 08 1a +SET 08 22 00 50 .. [0x11] 08 1a -> 00 04 +EVT 01 00 04 +SET 08 22 00 50 .. [0x12] 04 -> 31 +EVT 01 00 31 +SET 08 22 00 50 .. [0x12] 31 -> 4c +EVT 01 00 4c +SET 08 22 00 50 .. [0x12] 4c -> 52 +EVT 01 00 52 +SET 08 22 00 50 .. [0x12] 52 -> 45 +EVT 01 00 45 +SET 08 22 00 50 .. [0x12] 45 -> 5d +EVT 01 00 5d +SET 08 22 00 50 .. [0x12] 5d -> 2a +EVT 01 00 2a diff --git a/captures/redragon-m690-pro/lighting-edits-cable.hex b/captures/redragon-m690-pro/lighting-edits-cable.hex new file mode 100644 index 0000000..7ff8b00 --- /dev/null +++ b/captures/redragon-m690-pro/lighting-edits-cable.hex @@ -0,0 +1,21 @@ +# Redragon M690 PRO: Twelve lighting writes: Steady brightness and slot ([0x48]), Steady slot colours ([0x66..]), +# Breathing ([0x45] = 03, [0x4c]) and Colorful Streaming ([0x45] = 01, [0x46]). +GET 08 11 00 00 00 00 00 00 64 13 03 35 00 02 00 04 00 08 00 0c 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 40 00 00 40 00 00 40 00 00 40 ff 46 00 00 ff ff ff ff ff 02 42 01 00 ff 00 00 00 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +SET 08 11 00 92 .. [0x48] 00 -> 03 +SET 08 12 00 50 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 a5 +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x48] 03 -> 43 +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x6f] ff ff 00 -> 00 00 ff +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x48] 43 -> 45 +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x45] 02 -> 03, [0x4c] 00 -> 42 +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x45] 03 42 -> 01 33 +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x46] 33 -> 12 +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x45] 01 12 01 45 -> 02 12 01 24, [0x72] 00 ff ff -> 4c 22 8a +GET 08 11 .. identical to the write +SET 08 11 00 92 .. [0x48] 24 -> 00 diff --git a/captures/redragon-m690-pro/macro-assign-cable.hex b/captures/redragon-m690-pro/macro-assign-cable.hex new file mode 100644 index 0000000..971699a --- /dev/null +++ b/captures/redragon-m690-pro/macro-assign-cable.hex @@ -0,0 +1,6 @@ +# Redragon M690 PRO: An empty macro assigned to app button 4: macro store (08 30 ..), then slot 5 (0x18) = 70 01 01 01. +# Documented only; the driver does not write macros. +GET 08 11 00 00 00 00 00 00 64 13 02 25 00 02 00 04 00 08 00 0c 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 40 00 00 40 00 00 40 00 00 40 ff 46 00 00 ff ff ff ff ff 02 00 01 00 ff 00 00 00 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +SET 08 11 00 92 .. unchanged +SET 08 30 02 00 00 00 00 00 01 +SET 08 12 00 50 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 70 01 01 01 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 a5 diff --git a/captures/redragon-m690-pro/openmouse-hardware-test-bridge-wired.json b/captures/redragon-m690-pro/openmouse-hardware-test-bridge-wired.json new file mode 100755 index 0000000..6fbc68c --- /dev/null +++ b/captures/redragon-m690-pro/openmouse-hardware-test-bridge-wired.json @@ -0,0 +1,137 @@ +{ + "device": { + "present": true, + "brand": "Redragon", + "name": "Redragon M690 PRO", + "vendorId": 9610, + "productId": 46, + "productName": "Redragon M690 PRO", + "transport": "bridge", + "connectionType": "Wired", + "pollingRateHz": 500, + "supportedPollingRates": [ + 125, + 250, + 500, + 1000 + ], + "dpi": 3500, + "dpiStages": [ + 500, + 1200, + 3500, + 5500, + 8000 + ], + "activeDpiStage": 2, + "batteryPercent": null, + "batteryState": "Charging", + "firmware": null, + "liftOffDistance": null, + "driverFamily": "redragon-m690-pro", + "deviceMode": null, + "collectionsSummary": "interfaces 0x000C:0x0001, 0xFF00:0x0001, 0x0001:0x0006, 0xFF01:0x0001, 0x0001:0x0002", + "signalStrength": null, + "receiverOnline": null, + "receiverRfId": null, + "pairingInProgress": false + }, + "results": [ + { + "key": "connection", + "label": "Device connected", + "status": "pass", + "detail": "Connected through OpenMouse Bridge" + }, + { + "key": "interface", + "label": "Control interface", + "status": "pass", + "detail": "interfaces 0x000C:0x0001, 0xFF00:0x0001, 0x0001:0x0006, 0xFF01:0x0001, 0x0001:0x0002" + }, + { + "key": "identity", + "label": "Device identity", + "status": "pass", + "detail": "Redragon Redragon M690 PRO" + }, + { + "key": "driver", + "label": "Driver identification", + "status": "pass", + "detail": "Redragon redragon-m690-pro" + }, + { + "key": "dpi", + "label": "DPI read-back", + "status": "pass", + "detail": "3,500 DPI" + }, + { + "key": "pollingRead", + "label": "Polling rate read-back", + "status": "pass", + "detail": "500 Hz" + }, + { + "key": "battery", + "label": "Battery read-back", + "status": "skip", + "detail": "Not reported by this device." + }, + { + "key": "firmware", + "label": "Firmware read-back", + "status": "skip", + "detail": "Not reported by this device." + }, + { + "key": "liftOff", + "label": "Lift-off read-back", + "status": "skip", + "detail": "Not reported by this device." + }, + { + "key": "dpiStages", + "label": "DPI stages read-back", + "status": "pass", + "detail": "500, 1,200, 3,500, 5,500, 8,000 DPI" + }, + { + "key": "link", + "label": "Wireless link (receiver)", + "status": "skip", + "detail": null + }, + { + "key": "flashRead", + "label": "Flash / EEPROM read-back", + "status": "pass", + "detail": "decoded fields are in range (dpi, polling, dpi stages)" + }, + { + "key": "sampling", + "label": "Polling rate sampling", + "status": "skip", + "detail": "avg 60 Hz vs reported 500 Hz — dropout or rate mismatch detected, so the sample is not counted as a failure." + }, + { + "key": "flashWrite", + "label": "Flash write round-trip", + "status": "pass", + "detail": "wrote 800 DPI → read back → restored 3,500 DPI · wrote 1,000 Hz → read back → restored 500 Hz · Lift-off skipped" + } + ], + "verdict": "pass", + "durationMs": 14536, + "runAt": "2026-10-03T15:07:09.433Z", + "build": "BETA · v2.0.1052", + "supportedPage": { + "listed": true, + "status": "supported", + "label": "Supported", + "matchedBy": "pid", + "pageModel": "M690 PRO", + "detail": "already listed as Supported on the supported-devices page — no update needed." + } +} \ No newline at end of file diff --git a/captures/redragon-m690-pro/openmouse-hardware-test-bridge-wireless.json b/captures/redragon-m690-pro/openmouse-hardware-test-bridge-wireless.json new file mode 100755 index 0000000..b7d6648 --- /dev/null +++ b/captures/redragon-m690-pro/openmouse-hardware-test-bridge-wireless.json @@ -0,0 +1,137 @@ +{ + "device": { + "present": true, + "brand": "Redragon", + "name": "Redragon M690 PRO", + "vendorId": 9610, + "productId": 47, + "productName": "Redragon M690 PRO", + "transport": "bridge", + "connectionType": "Wireless", + "pollingRateHz": 500, + "supportedPollingRates": [ + 125, + 250, + 500, + 1000 + ], + "dpi": 3500, + "dpiStages": [ + 500, + 1200, + 3500, + 5500, + 8000 + ], + "activeDpiStage": 2, + "batteryPercent": 99, + "batteryState": "Unknown", + "firmware": null, + "liftOffDistance": null, + "driverFamily": "redragon-m690-pro", + "deviceMode": null, + "collectionsSummary": "interfaces 0x0001:0x0002, 0x000C:0x0001, 0xFF00:0x0001, 0xFF01:0x0001, 0x0001:0x0006", + "signalStrength": null, + "receiverOnline": null, + "receiverRfId": null, + "pairingInProgress": false + }, + "results": [ + { + "key": "connection", + "label": "Device connected", + "status": "pass", + "detail": "Connected through OpenMouse Bridge" + }, + { + "key": "interface", + "label": "Control interface", + "status": "pass", + "detail": "interfaces 0x0001:0x0002, 0x000C:0x0001, 0xFF00:0x0001, 0xFF01:0x0001, 0x0001:0x0006" + }, + { + "key": "identity", + "label": "Device identity", + "status": "pass", + "detail": "Redragon Redragon M690 PRO" + }, + { + "key": "driver", + "label": "Driver identification", + "status": "pass", + "detail": "Redragon redragon-m690-pro" + }, + { + "key": "dpi", + "label": "DPI read-back", + "status": "pass", + "detail": "3,500 DPI" + }, + { + "key": "pollingRead", + "label": "Polling rate read-back", + "status": "pass", + "detail": "500 Hz" + }, + { + "key": "battery", + "label": "Battery read-back", + "status": "pass", + "detail": "99%" + }, + { + "key": "firmware", + "label": "Firmware read-back", + "status": "skip", + "detail": "Not reported by this device." + }, + { + "key": "liftOff", + "label": "Lift-off read-back", + "status": "skip", + "detail": "Not reported by this device." + }, + { + "key": "dpiStages", + "label": "DPI stages read-back", + "status": "pass", + "detail": "500, 1,200, 3,500, 5,500, 8,000 DPI" + }, + { + "key": "link", + "label": "Wireless link (receiver)", + "status": "pass", + "detail": "driver read the wireless connection over the RF link" + }, + { + "key": "flashRead", + "label": "Flash / EEPROM read-back", + "status": "pass", + "detail": "decoded fields are in range (dpi, polling, battery, dpi stages)" + }, + { + "key": "sampling", + "label": "Polling rate sampling", + "status": "skip", + "detail": "Not enough movement captured (needed ≥ 40 samples) — inconclusive." + }, + { + "key": "flashWrite", + "label": "Flash write round-trip", + "status": "pass", + "detail": "wrote 800 DPI → read back → restored 3,500 DPI · wrote 1,000 Hz → read back → restored 500 Hz · Lift-off skipped" + } + ], + "verdict": "pass", + "durationMs": 15616, + "runAt": "2026-10-03T14:56:21.785Z", + "build": "BETA · v2.0.1052", + "supportedPage": { + "listed": true, + "status": "supported", + "label": "Supported", + "matchedBy": "pid", + "pageModel": "M690 PRO", + "detail": "already listed as Supported on the supported-devices page — no update needed." + } +} \ No newline at end of file diff --git a/captures/redragon-m690-pro/openmouse-hardware-test-wired.json b/captures/redragon-m690-pro/openmouse-hardware-test-wired.json new file mode 100755 index 0000000..3aa5561 --- /dev/null +++ b/captures/redragon-m690-pro/openmouse-hardware-test-wired.json @@ -0,0 +1,137 @@ +{ + "device": { + "present": true, + "brand": "Redragon", + "name": "Redragon M690 PRO", + "vendorId": 9610, + "productId": 46, + "productName": "Redragon M690 PRO", + "transport": "webhid", + "connectionType": "Wired", + "pollingRateHz": 500, + "supportedPollingRates": [ + 125, + 250, + 500, + 1000 + ], + "dpi": 3500, + "dpiStages": [ + 500, + 1200, + 3500, + 5500, + 8000 + ], + "activeDpiStage": 2, + "batteryPercent": null, + "batteryState": "Unknown", + "firmware": null, + "liftOffDistance": null, + "driverFamily": "redragon-m690-pro", + "deviceMode": null, + "collectionsSummary": "interfaces 0x000C:0x0001, 0xFF00:0x0001, 0xFF01:0x0001", + "signalStrength": null, + "receiverOnline": null, + "receiverRfId": null, + "pairingInProgress": false + }, + "results": [ + { + "key": "connection", + "label": "Device connected", + "status": "pass", + "detail": "Connected through WebHID" + }, + { + "key": "interface", + "label": "Control interface", + "status": "pass", + "detail": "interfaces 0x000C:0x0001, 0xFF00:0x0001, 0xFF01:0x0001" + }, + { + "key": "identity", + "label": "Device identity", + "status": "pass", + "detail": "Redragon Redragon M690 PRO" + }, + { + "key": "driver", + "label": "Driver identification", + "status": "pass", + "detail": "Redragon redragon-m690-pro" + }, + { + "key": "dpi", + "label": "DPI read-back", + "status": "pass", + "detail": "3,500 DPI" + }, + { + "key": "pollingRead", + "label": "Polling rate read-back", + "status": "pass", + "detail": "500 Hz" + }, + { + "key": "battery", + "label": "Battery read-back", + "status": "skip", + "detail": "Not reported by this device." + }, + { + "key": "firmware", + "label": "Firmware read-back", + "status": "skip", + "detail": "Not reported by this device." + }, + { + "key": "liftOff", + "label": "Lift-off read-back", + "status": "skip", + "detail": "Not reported by this device." + }, + { + "key": "dpiStages", + "label": "DPI stages read-back", + "status": "pass", + "detail": "500, 1,200, 3,500, 5,500, 8,000 DPI" + }, + { + "key": "link", + "label": "Wireless link (receiver)", + "status": "skip", + "detail": null + }, + { + "key": "flashRead", + "label": "Flash / EEPROM read-back", + "status": "pass", + "detail": "decoded fields are in range (dpi, polling, dpi stages)" + }, + { + "key": "sampling", + "label": "Polling rate sampling", + "status": "pass", + "detail": "avg 452 Hz · peak 500 Hz · stability 99% · 2325 samples" + }, + { + "key": "flashWrite", + "label": "Flash write round-trip", + "status": "pass", + "detail": "wrote 800 DPI → read back → restored 3,500 DPI · wrote 1,000 Hz → read back → restored 500 Hz · Lift-off skipped" + } + ], + "verdict": "pass", + "durationMs": 14502.39999999851, + "runAt": "2026-10-03T15:06:31.013Z", + "build": "BETA · v2.0.1052", + "supportedPage": { + "listed": true, + "status": "supported", + "label": "Supported", + "matchedBy": "pid", + "pageModel": "M690 PRO", + "detail": "already listed as Supported on the supported-devices page — no update needed." + } +} \ No newline at end of file diff --git a/captures/redragon-m690-pro/openmouse-hardware-test-wireless.json b/captures/redragon-m690-pro/openmouse-hardware-test-wireless.json new file mode 100755 index 0000000..8ab9a19 --- /dev/null +++ b/captures/redragon-m690-pro/openmouse-hardware-test-wireless.json @@ -0,0 +1,137 @@ +{ + "device": { + "present": true, + "brand": "Redragon", + "name": "Redragon M690 PRO", + "vendorId": 9610, + "productId": 47, + "productName": "Redragon M690 PRO", + "transport": "webhid", + "connectionType": "Wireless", + "pollingRateHz": 500, + "supportedPollingRates": [ + 125, + 250, + 500, + 1000 + ], + "dpi": 3500, + "dpiStages": [ + 500, + 1200, + 3500, + 5500, + 8000 + ], + "activeDpiStage": 2, + "batteryPercent": null, + "batteryState": "Unknown", + "firmware": null, + "liftOffDistance": null, + "driverFamily": "redragon-m690-pro", + "deviceMode": null, + "collectionsSummary": "interfaces 0x000C:0x0001, 0xFF00:0x0001, 0xFF01:0x0001", + "signalStrength": null, + "receiverOnline": null, + "receiverRfId": null, + "pairingInProgress": false + }, + "results": [ + { + "key": "connection", + "label": "Device connected", + "status": "pass", + "detail": "Connected through WebHID" + }, + { + "key": "interface", + "label": "Control interface", + "status": "pass", + "detail": "interfaces 0x000C:0x0001, 0xFF00:0x0001, 0xFF01:0x0001" + }, + { + "key": "identity", + "label": "Device identity", + "status": "pass", + "detail": "Redragon Redragon M690 PRO" + }, + { + "key": "driver", + "label": "Driver identification", + "status": "pass", + "detail": "Redragon redragon-m690-pro" + }, + { + "key": "dpi", + "label": "DPI read-back", + "status": "pass", + "detail": "3,500 DPI" + }, + { + "key": "pollingRead", + "label": "Polling rate read-back", + "status": "pass", + "detail": "500 Hz" + }, + { + "key": "battery", + "label": "Battery read-back", + "status": "skip", + "detail": "Not reported by this device." + }, + { + "key": "firmware", + "label": "Firmware read-back", + "status": "skip", + "detail": "Not reported by this device." + }, + { + "key": "liftOff", + "label": "Lift-off read-back", + "status": "skip", + "detail": "Not reported by this device." + }, + { + "key": "dpiStages", + "label": "DPI stages read-back", + "status": "pass", + "detail": "500, 1,200, 3,500, 5,500, 8,000 DPI" + }, + { + "key": "link", + "label": "Wireless link (receiver)", + "status": "pass", + "detail": "driver read the wireless connection over the RF link" + }, + { + "key": "flashRead", + "label": "Flash / EEPROM read-back", + "status": "pass", + "detail": "decoded fields are in range (dpi, polling, dpi stages)" + }, + { + "key": "sampling", + "label": "Polling rate sampling", + "status": "pass", + "detail": "avg 464 Hz · peak 909 Hz · stability 55% · 2413 samples" + }, + { + "key": "flashWrite", + "label": "Flash write round-trip", + "status": "pass", + "detail": "wrote 800 DPI → read back → restored 3,500 DPI · wrote 1,000 Hz → read back → restored 500 Hz · Lift-off skipped" + } + ], + "verdict": "pass", + "durationMs": 15255.599999997765, + "runAt": "2026-10-03T15:05:36.518Z", + "build": "BETA · v2.0.1052", + "supportedPage": { + "listed": true, + "status": "supported", + "label": "Supported", + "matchedBy": "pid", + "pageModel": "M690 PRO", + "detail": "already listed as Supported on the supported-devices page — no update needed." + } +} \ No newline at end of file diff --git a/captures/redragon-m690-pro/openmouse-receiver-session.hex b/captures/redragon-m690-pro/openmouse-receiver-session.hex new file mode 100644 index 0000000..76042d3 --- /dev/null +++ b/captures/redragon-m690-pro/openmouse-receiver-session.hex @@ -0,0 +1,38 @@ +# Redragon M690 PRO: OpenMouse with this driver through the receiver. Polling is written with the mouse off and the +# receiver keeps it (read back 03); once the mouse is switched on the receiver bank holds the +# mouse's own value again (01) with no write in between. DPI button events read +# 07 11 01 (11 through the receiver, 10 over the cable). Then polling +# 500 -> 125 -> 1000 Hz. +# -- mouse off +GET 08 21 00 00 00 00 00 00 64 13 01 25 00 02 00 04 00 08 00 0c 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 40 00 00 40 00 00 40 00 00 40 ff 46 00 00 ff ff ff ff ff 02 44 01 00 ff 00 00 44 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 0b eb d7 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +SET 05 80 +GET - # no data: the read failed (STALL) +GET 08 22 00 00 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 +SET 08 21 00 92 .. [0x0a] 01 -> 03 +SET 08 22 00 50 .. unchanged +GET 08 21 .. identical to the write +GET 08 22 .. identical to the write +# -- mouse switched on: its DPI buttons send events +EVT 07 11 01 03 08 +EVT 07 11 01 04 0c +EVT 07 11 01 05 18 +GET 08 21 00 00 .. [0x0a] 03 25 -> 01 55 +EVT 07 11 01 04 0c +EVT 07 11 01 03 08 +EVT 07 11 01 02 04 +EVT 07 11 01 01 02 +GET 08 21 00 00 .. [0x0b] 55 -> 15 +SET 08 21 00 92 .. [0x0d] 02 -> 18 +GET 08 21 .. identical to the write +SET 08 21 00 92 .. [0x0d] 18 -> 02 +GET 08 21 .. identical to the write +EVT 07 11 01 02 04 +EVT 07 11 01 03 08 +GET 08 21 00 00 .. [0x0b] 15 -> 35 +# -- later, mouse on: polling 500 -> 125 -> 1000 Hz +SET 08 21 00 92 .. [0x0a] 01 -> 03 +GET 08 21 .. identical to the write +SET 08 21 00 92 .. [0x0a] 03 -> 01 +GET 08 21 .. identical to the write +SET 08 21 00 92 .. [0x0a] 01 -> 04 +GET 08 21 .. identical to the write diff --git a/captures/redragon-m690-pro/polling-125-to-500-cable.hex b/captures/redragon-m690-pro/polling-125-to-500-cable.hex new file mode 100644 index 0000000..c80060f --- /dev/null +++ b/captures/redragon-m690-pro/polling-125-to-500-cable.hex @@ -0,0 +1,5 @@ +# Redragon M690 PRO: Polling 125 -> 500 Hz over the cable: re-read 05 11, write the settings block with [3] = 92 +# (154 - 8) and [0x0a] 01 -> 03, then rewrite the button block with [3] = 50 and the a5 trailer. +GET 08 11 00 00 00 00 00 00 64 13 01 35 00 02 00 04 00 08 00 0c 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 40 00 00 40 00 00 40 00 00 40 ff 46 00 00 ff ff ff ff ff 02 00 01 00 ff 00 00 00 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +SET 08 11 00 92 .. [0x0a] 01 -> 03 +SET 08 12 00 50 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 a5 diff --git a/captures/redragon-m690-pro/receiver-offline-apply.hex b/captures/redragon-m690-pro/receiver-offline-apply.hex new file mode 100644 index 0000000..4400ce1 --- /dev/null +++ b/captures/redragon-m690-pro/receiver-offline-apply.hex @@ -0,0 +1,7 @@ +# Redragon M690 PRO: Through the receiver: Apply with the mouse off reads 05 80 -> 80 00 01 and the app writes nothing; +# with the mouse on it reads 80 01 01 and the write proceeds. +GET 05 80 00 01 +GET 05 80 01 01 +GET 08 21 00 00 00 00 00 00 64 13 01 25 00 02 00 04 00 08 00 0c 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 40 00 00 40 00 00 40 00 00 40 ff 46 00 00 ff ff ff ff ff 02 00 01 00 ff 00 00 00 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +SET 08 21 00 92 .. [0x0a] 01 -> 02 +SET 08 22 00 50 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 a5 diff --git a/captures/redragon-m690-pro/receiver-polling-and-standby.hex b/captures/redragon-m690-pro/receiver-polling-and-standby.hex new file mode 100644 index 0000000..8e99ad6 --- /dev/null +++ b/captures/redragon-m690-pro/receiver-polling-and-standby.hex @@ -0,0 +1,9 @@ +# Redragon M690 PRO: Through the receiver: the app opened with the mouse off (reads still answered), then +# 125 -> 250 -> 125 Hz. Each write is preceded by 05 80 -> 80 01 01 (mouse linked). +GET 08 21 00 00 00 00 00 00 64 13 01 25 00 02 00 04 00 08 00 0c 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 40 00 00 40 00 00 40 00 00 40 ff 46 00 00 ff ff ff ff ff 02 00 01 00 ff 00 00 00 07 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff 02 02 00 00 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 80 00 ff 00 ff ff 00 00 ff 00 00 ff 00 00 02 00 ff 00 fa 03 6a 42 42 02 ff 00 00 00 00 01 02 ff 00 00 a5 +GET 08 22 00 00 00 00 00 00 11 01 00 00 11 02 00 00 11 04 00 00 11 08 00 00 11 10 00 00 41 01 00 00 41 02 00 00 31 01 32 03 50 04 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 00 00 50 01 +GET 05 80 01 01 +SET 08 21 00 92 .. [0x0a] 01 -> 02 +SET 08 22 00 50 .. unchanged +GET 08 21 .. identical to the write +SET 08 21 00 92 .. [0x0a] 02 -> 01 diff --git a/captures/redragon-m690-pro/report-descriptors.hex b/captures/redragon-m690-pro/report-descriptors.hex new file mode 100644 index 0000000..0a19c6a --- /dev/null +++ b/captures/redragon-m690-pro/report-descriptors.hex @@ -0,0 +1,7 @@ +# Redragon M690 PRO: HID report descriptors over the cable (258a:002e, firmware 2.95). Interface 1 carries the vendor +# collections: feature 8 (519 B), input 7 (7 B), feature 5 (7 B) and feature 6 (7 B, page 0xff01). +# Firmware 2.97 and the receiver (258a:002f) return the identical interface-1 descriptor. +# interface 0 report descriptor, 73 bytes +05 01 09 02 a1 01 09 01 a1 00 05 09 19 01 29 05 15 00 25 01 75 01 95 05 81 02 95 03 81 01 05 01 09 30 09 31 16 00 80 26 ff 7f 75 10 95 02 81 06 09 38 15 80 25 7f 75 08 95 01 81 06 05 0c 0a 38 02 95 01 75 08 81 06 c0 c0 +# interface 1 report descriptor, 244 bytes +05 01 09 06 a1 01 85 01 05 07 19 e0 29 e7 15 00 25 01 75 01 95 08 81 02 95 06 75 08 15 00 26 ff 00 05 07 19 00 2a ff 00 81 00 c0 06 0c 00 09 01 a1 01 85 02 25 01 15 00 75 01 0a b5 00 0a b6 00 0a b7 00 0a cd 00 0a e2 00 0a a2 00 0a e9 00 0a ea 00 95 08 81 03 0a 83 01 0a 94 01 0a 86 01 0a 88 01 0a 8a 01 0a 92 01 0a a8 02 0a 84 01 95 08 81 03 0a 21 02 0a 23 02 0a 24 02 0a 25 02 0a 26 02 0a 27 02 0a 2a 02 0a b1 02 95 08 81 03 c0 06 00 ff 09 01 a1 01 85 08 15 00 26 ff 00 19 01 29 02 75 08 96 07 02 b1 02 c0 06 00 ff 09 01 a1 01 85 07 15 00 26 ff 00 19 01 29 02 75 08 95 07 81 00 c0 06 00 ff 09 01 a1 01 85 05 15 00 26 ff 00 19 01 29 02 75 08 95 07 b1 02 c0 06 01 ff 09 01 a1 01 85 06 15 00 26 ff 00 19 01 29 02 75 08 95 07 b1 02 c0 diff --git a/docs/redragon-m690-pro-testing.md b/docs/redragon-m690-pro-testing.md new file mode 100644 index 0000000..1f3de4a --- /dev/null +++ b/docs/redragon-m690-pro-testing.md @@ -0,0 +1,282 @@ +# Redragon M690 PRO testing notes + +The M690 PRO ("MIRAGE PRO", 8000 DPI, wired and 2.4 GHz wireless) is a +SinoWealth design, unrelated to the Holtek M612/M724 protocol in the same +`redragon` entry point. It is a different product from the older M690 / +M690-1 "MIRAGE2" (2400 DPI) and the M690 MAX (S205 sensor, Bluetooth); the +MAX's software is a different platform (an "eevision" app naming a +`VS09M58A` chip) and does not detect the PRO. + +| Path | VID:PID | bcdDevice | Product string | +| --- | --- | --- | --- | +| USB cable | `258a:002e` | 2.95, 2.97 | `M690-PRO` | +| 2.4 GHz receiver | `258a:002f` | 6.05 | `M690-PRO` | + +Three units were examined: mouse firmware 2.95 (one unit) and 2.97 (two +units), every receiver 6.05. Each mouse ships with its own receiver, paired +to it. A factory-fresh unit's cable and receiver banks were byte-identical; +its receiver bank is the factory fixture in the tests. + +The three-position switch (OFF / ON / ECO) also disconnects USB: over the +cable the mouse only enumerates while the switch is on ON or ECO. + +`258a:002f` is listed in public USB databases as a generic "SINOWEALTH 2.4G +Wireless Receiver", so other brands may reuse it. The driver therefore also +requires the vendor collection, and checks the settings block is the M690 +PRO's before it writes anything (see "Battery and link status in a browser" +for why it cannot use the identify answer, `"2945"`). + +## USB shape (USBPcap descriptors, WebHID dump) + +- IF 0: boot mouse, 7-byte input `[buttons, x16, y16, wheel, AC pan]`. The + receiver's descriptor differs cosmetically (75 bytes vs 73; it sets the + three padding bits, so every receiver report starts `e0`). +- IF 1: keyboard (report 1), consumer control (report 2, 24 one-bit usages), + and vendor collections `0xFF00:0x01` with feature report 8 (519 bytes), + input report 7 (7 bytes) and feature report 5 (7 bytes), plus `0xFF01:0x01` + feature report 6 (7 bytes, never used by the app). The interface-1 + descriptor is byte-identical on every unit and both paths. + +Chrome lists one HID device per path with these collections; the mouse and +keyboard collections are protected and hidden. + +## Sources + +- The official app, `Redragon_M690-PRO_Setup_v1.0_20221125` (Inno Setup; + `OemDrv.exe` with `Cfg.ini` `VID=0x258A PID=0x002E PID2=0x002F + Sensor=0x3104`, its `DPISET` list, button table and effect names in + `Text/en/text.xml`), run on Windows and captured with USBPcap while + changing one setting per Apply. Text exports: `captures/redragon-m690-pro/`. +- libratbag's `driver-sinowealth.c` (MIT), whose framing this matches with + report 8 in place of its 4/6: command ids `0x01` (id), `0x11`/`0x12` + (settings/buttons), the `size - 8` write marker, the polling map, and the + button type codes. Where the two disagree, the captures win (see Dead + ends). + +## Framing + +``` +command SET 5 05 cmd 00 00 00 00 00 00 +answer GET 5 05 cmd .. (short commands) + GET 8 08 cmd 00 00 .. (blocks; 520 bytes, meaningful prefix only) +write SET 8 08 cmd 00 len-8 .. block .. [trailer] zero padding +``` + +| Command | Answer | Meaning | +| --- | --- | --- | +| `01` | `05 01 32 39 34 35` | ASCII `"2945"` on both firmware revisions; through the receiver all zeros until the mouse links (the app retries every ~7 s) | +| `11` / `12` | 154 / 88-byte block | cable bank: settings / buttons | +| `21` / `22` | 154 / 88-byte block | receiver bank: settings / buttons | +| `80` | `05 80 01 01` linked, `05 80 00 ..` mouse off or asleep | receiver only; sent before every write. The last byte read `01` and `02` while off or asleep; its meaning is unknown | +| `90` | `05 90 11 64` | receiver: `11`, battery level 0-100 (`0x64`, later `0x63`); the app showed "80 %" for `0x63`, so it displays coarser steps | +| | `05 90 10 01` / `10 02` | cable: `01` charging, `02` charged (the app then shows "100 %" and the wheel LED turns green) | +| `30` / `31 n` | macro store / macro read | documented only, see Macros | + +Writes, as the app sends them: re-read the settings block, write it whole +with byte `[3] = 0x92` (154 - 8), then rewrite the button block whole with +`[3] = 0x50` (88 - 8) and `a5` after its last byte, even when only one of +them changed. The settings block also ends in `a5 00` when read. + +## Receiver + +The cable and the receiver keep **separate settings banks**: after +cable-side writes the receiver bank still held its own polling rate, DPI +stages and lighting. + +The receiver's bank is a copy of the mouse's settings, and the receiver +answers `01`, `21`, `22` and `90` from it even while the mouse is off. A +write made then does not stick (see Dead ends), so the app checks `05 80` +before writing; with the mouse off it shows "The mouse is now offline, +please move or power on the mouse." and writes nothing. The copy also +follows the mouse live: the active stage byte changes as the DPI buttons are +pressed. + +A block write completes only once the mouse has taken it, so a write that +completes has reached the mouse. After the mouse has been idle, the first +write of a batch took 3.9-4.4 s for each of its two reports; later writes +took 170-210 ms, as over the cable. + +## Battery and link status in a browser + +In short: Chrome cannot ask this mouse for its battery level, charging status +or whether it is awake; OpenMouse Bridge can. Everything else works in Chrome. + +The firmware answers `GET_REPORT(Feature 5)` only when it asks for exactly 8 +bytes; the app's reads do (`a1 01 05 03 01 00 08 00`). Chrome sends every +feature read with the device's largest feature length, 520 bytes +(`a1 01 05 03 01 00 08 02`, captured over the cable on Windows), and the +mouse STALLs it (`USBD_STATUS_STALL_PID`), which WebHID reports as +"Failed to receive the feature report." Report-8 reads use 520 bytes in both +and work. The receiver and Chrome on macOS behave the same. On macOS, +opening the device needs **Input Monitoring** permission for Chrome (System +Settings -> Privacy & Security -> Input Monitoring, then restart Chrome), +because the vendor collections share interface 1 with the keyboard +collection; without it WebHID reports "Failed to open the device." + +Web pages cannot choose the read length, so in a browser: + +- the identify answer is unreadable; the driver identifies the mouse from its + settings block instead (154 bytes, `a5` end marker, byte 9 = `0x13` in every + block captured: every unit and firmware revision, both banks); +- battery (`0x90`) and the receiver's link check (`0x80`) are tried once per + connection and otherwise reported as unknown (waiting or retrying does not + help); without the link check, the status line warns that writes made while + the mouse is off are undone. + +**Through OpenMouse Bridge** (v1.0.0-beta.9, Windows), which reads each +feature report at its own length, every report-5 read succeeded with the +driver unchanged: through the receiver the link check read `00` while the +mouse slept (battery then gives the receiver's last value, which the driver +hides) and `01` once it woke, with battery 99 %, and writes waited for the +link. + +## Settings block (`11` / `21`) + +| Offset | Meaning | Evidence | +| --- | --- | --- | +| `0x0a` low nibble | polling: 1 = 125, 2 = 250, 3 = 500, 4 = 1000 Hz | 125/250/500 written by the app and confirmed by report interval (8 / 4 / 2 ms over the cable; ~8 / ~4.4 ms through the receiver); 1000 written by OpenMouse and confirmed through the receiver (1 ms) | +| `0x0b` | high nibble active stage (1-based), low nibble stage count (5) | `35` with the 2000 stage highlighted in the app; `15` with the first | +| `0x0c` | disabled-stage mask | always `00` (all five enabled) | +| `0x0d + 2n` | stage n DPI as a 1-based index into the app's list | 250 -> `01`, 500 -> `02`, 1000 -> `04`, 2000 -> `08`, 3000 -> `0c`, 8000 -> `18` | +| `0x0e + 2n` | always `00`; preserved | | +| `0x2d + 3n` | stage n indicator colour, RGB | app squares `00 00 40`; factory red, blue, green, magenta, yellow (`Cfg.ini DC`) after Restore; the app's write of five new colours re-encodes byte for byte | +| `0x45` | effect: 1 Colorful Streaming, 2 Steady, 3 Breathing | 1, 2, 3 written by the app | +| `0x46` | Streaming: brightness << 4 \| speed | `42`, `33`, `12` matching the sliders | +| `0x48` | Steady: brightness << 4 \| colour slot | `00`, `03`, `43`, `45`, `24` | +| `0x4c` | Breathing: brightness << 4 \| speed | `42` | +| `0x4d`, `0x4e` | Breathing colour count (7) and colours | not written by the driver | +| `0x66 + 3n` | Steady colour slots 0-6, the app's swatch row (Restore sets slot 0 to red) | the app recolours the selected slot (`4c 22 8a` into slot 4) | + +DPI choices (the app's `DPISET`): 250, 500, 800, 1000, 1200, 1500, 1750, +2000, 2250, 2400, 2750, 3000, 3200, 3500, 3750, 4000, 4500, 5000, 5500, +6000, 6500, 7000, 7500, 8000. These are vendor labels; the driver snaps a +request to the nearest one and reports that value. Brightness and speed are +the app's five slider positions (`Cfg.ini LightUI`/`SpeedUI` 0-4); +OpenMouse shows brightness 1-4 as 25-100 % (0 is Off) and speed 0-4 as 1-5. + +The Advanced tab's sensitivity, scrolling speed and double-click speed are +Windows settings: their Apply rewrote both blocks unchanged. + +## Button block (`12` / `22`) + +Twenty 4-byte slots from offset 8; the app's buttons 1-8 use slots +1, 2, 3, 5, 4, 6, 7, 8 (`Cfg.ini Kn_1`, last byte; a macro assigned to app +button 4 landed in slot 5). Slot 9 holds `50 04` and slots 10-20 `50 01`; +the driver never changes them. + +| Bytes | Action | Evidence | +| --- | --- | --- | +| `11 01/02/04/08/10` | left / right / middle / back / forward | factory slots; `11 04` written back by the app | +| `41 01` / `41 02` | DPI up / down | factory slots | +| `31 01 32 03` | "Three click" (repeat button 1, delay `0x32`, count 3) | factory button 8, written back by the app | +| `50 02` | lighting on/off | written by the app | +| `50 01` | disabled | written by the app | +| `21 mods usage 00` | keyboard key: HID modifier bits (1 Ctrl, 2 Shift, 4 Alt, 8 Win) and usage ID | written by the app and sent by the mouse on press as keyboard report 1 (`01 mods usage ..`): Ctrl+Shift+R `03 15`, Alt+A `04 04`, Win+W `08 1a`, and A, `\` (`31`), Delete, Up arrow, F12, Numpad 5 (`5d`) and Backspace unmodified | +| `22 b0 b1 b2` | consumer key, one bit in report 2's order | Refresh -> `22 00 00 20` written by the app | +| `70 n mode count` | macro n | written by the app (`70 01 01 01`) | + +## DPI button events (report 7) + +The mouse's DPI buttons send `07 1x yy ` on +interface 1, e.g. `07 10 01 03 08` for stage 3 at 2000 over the cable; the +second byte is `11` through the receiver, as in the `05 90` answer. The +third byte read `01` in most sessions and `04` in one; its meaning is +unknown. The driver reads the active stage from the settings block on each +status refresh instead. + +## Macros + +Assigning a macro writes `08 30 ..` (macro store) and a `70 n mode count` +slot; at startup the app then reads it back with `05 31 n`. An empty macro +assigned this way made the app report a settings error on its next start +until Restore. OpenMouse has no generic macro editor, so the driver shows +macro slots as `Macro n`, never writes macros, and never sends `30`/`31`. + +## Dead ends + +Tried and ruled out, so nobody repeats them: + +- **Collecting short answers from report 8.** After `05 01` or `05 90`, a + report-8 read STALLs as well; report 8 answers only after a block command + (`11`/`12`/`21`/`22`). +- **DPI as `raw * 250`.** It fits 500-3000 but not 8000 (`0x18`, not `0x20`); + the byte is a position in the vendor's DPI list. +- **`e0` as a receiver link marker.** Every mouse report through the receiver + starts `e0` (the descriptor's padding bits are set); it carries no link + state. +- **libratbag's command names.** Its "profile 2" (`21`/`22`) is this mouse's + receiver bank and its "profile 3" (`31`) is the macro read; there are no + onboard profiles to switch. +- **Writing through the receiver while the mouse is off.** The receiver + accepts and reads back the write, but the mouse undoes it on reconnect + (polling written `03` with the mouse off read back `01` once it was + switched on, with no write in between). +- **Keeping a lighting value "unchanged when equal to what is shown"** (the + M612 driver's rule). Here it broke effect switches: Wave at speed 4, then + Breathing with the panel showing speed 4, kept Breathing's stored speed 1. + The driver applies the requested values instead. +- **Running the vendor app alongside OpenMouse.** It shares the vendor + channel even when minimised, and the receiver's copy changes between a + write and its read-back, so writes fail. Close it first. + +## Hardware verification status + +Three units (mouse firmware 2.95 and 2.97, receiver 6.05). Protocol from the +vendor app's traffic; driver behaviour through OpenMouse with the local +package build in Chrome 154 on Windows, with USBPcap recording the wire, and +read-only on Chrome for macOS. + +- [x] Detection and `readStatus` over the cable (`002e`) on both firmware + revisions and through the receiver (`002f`), across reconnects. +- [x] Every write matched the requested change on the wire and read back + identically, over both paths; changes made in OpenMouse appear in the + vendor app afterwards. +- [x] DPI stage values set and felt; the active stage selected from + OpenMouse and felt. +- [x] Polling 125 / 250 / 500 / 1000 Hz: the mouse's report interval followed + each write (8 / 4 / 2 / 1 ms; through the receiver ~8 / ~4.4 / ~2.6 / + ~1.4 ms), so code `04` = 1000 Hz is confirmed. +- [x] DPI indicator colours: shown on the LEDs as each stage was selected and + in OpenMouse's stage editor; the driver's colour write re-encodes the + vendor app's byte for byte. +- [x] Lighting by eye: Static colours and brightness, Wave and Breathing + speeds (5 fastest), Off. +- [x] Button remaps felt (wheel click -> Right click, -> Volume up), then + restored. +- [x] Keyboard keys assigned from OpenMouse and felt: single keys, Print + Screen, and Copy (Ctrl+C). +- [x] Receiver with the mouse off: a write is accepted and later undone by + the mouse on reconnect; the status line warns about this. +- [x] Battery and link reads (report 5) fail in Chrome on Windows (cable and + receiver, USBPcap) and on macOS (receiver); settings reads and all + writes work. +- [x] Through OpenMouse Bridge (receiver): OpenMouse labels the device + "Bridge" and shows the battery (99 %); report-5 reads succeed (USBPcap: + wLength 8), the link check gates writes, and writes read back. +- [x] OpenMouse's built-in hardware test passed on all four paths (cable + and receiver, Chrome and Bridge): identity, DPI, polling, stages and + link read back, and 800 DPI / 1000 Hz written, read back and restored; + battery (receiver) and charging status (cable) through Bridge. Through + Bridge the polling sampler saw dropouts (60 Hz against 500), so there + the rate was read back but not measured; in Chrome it measured 452 Hz + at 99 % stability over the cable (`captures/redragon-m690-pro/ + openmouse-hardware-test-*.json`). +- [x] `REDRAGON_M690_PRO_PRODUCTS` marks `002e` and `002f` as verified. + +## Not decoded or not exposed + +- Macros (see above), and keyboard keys with right-hand modifiers (bits + `10`-`80`), which show as their raw bytes. +- Disabling DPI stages (`0x0c`), the stage count, and Breathing's colours. +- Feature report 6 (`0xFF01`), which the app never used. +- The ECO switch position; nothing in the captured traffic changes with it. + In ECO the body's lighting effects stay off and only the scroll wheel + lights; switch to ON to see them. +- Signal strength: the vendor app shows none, and no answer byte was found to + carry it, so the driver reports none. +- Per-stage DPI indicator colours are read and written + (`setDpiStageColor`), but OpenMouse currently only writes them when + applying a saved game profile: the stage editor's per-stage colour picker + was dropped in openmouse `0fc6ae1`. The driver needs no change if it + returns. These colours also light the scroll wheel, which shows the + active DPI stage's colour under every effect, Off included. diff --git a/src/drivers/redragon/m690-pro-hid.ts b/src/drivers/redragon/m690-pro-hid.ts new file mode 100644 index 0000000..4392a9f --- /dev/null +++ b/src/drivers/redragon/m690-pro-hid.ts @@ -0,0 +1,541 @@ +import type { MouseLighting, MouseLightingMode, MouseStatus } from "../mouse-types.js"; +import { + REDRAGON_M690_PRO_BLOCK_REPORT_ID, + REDRAGON_M690_PRO_BUTTON_OPTIONS, + REDRAGON_M690_PRO_BUTTONS_LENGTH, + REDRAGON_M690_PRO_BUTTONS_TRAILER, + REDRAGON_M690_PRO_CMD_LINK, + REDRAGON_M690_PRO_CMD_STATUS, + REDRAGON_M690_PRO_COMMAND_REPORT_ID, + REDRAGON_M690_PRO_CONFIG_LENGTH, + REDRAGON_M690_PRO_DPI_LABELS, + REDRAGON_M690_PRO_EFFECT_BREATHING, + REDRAGON_M690_PRO_EFFECT_STEADY, + REDRAGON_M690_PRO_EFFECT_STREAMING, + REDRAGON_M690_PRO_POLLING_RATES, + REDRAGON_M690_PRO_PRODUCTS, + REDRAGON_M690_PRO_STAGE_COUNT, + REDRAGON_M690_PRO_USAGE, + REDRAGON_M690_PRO_USAGE_PAGE, + REDRAGON_M690_PRO_VENDOR_ID, + redragonM690ProCheckBlock, + redragonM690ProCheckCommandReply, + redragonM690ProDecodeButtons, + redragonM690ProDecodeConfig, + redragonM690ProDecodeLink, + redragonM690ProDecodeStatus, + redragonM690ProEncodeBlockWrite, + redragonM690ProEncodeCommand, + redragonM690ProIsModelBlock, + redragonM690ProNearestDpi, + redragonM690ProWithActiveStage, + redragonM690ProWithButtonAction, + redragonM690ProWithLighting, + redragonM690ProWithPollingRate, + redragonM690ProWithStageColor, + redragonM690ProWithStageDpi, + type RedragonM690ProConfig, + type RedragonM690ProLightingChange, + type RedragonM690ProProduct, + type RedragonM690ProRgb, +} from "@openmouse/protocol/redragon"; + +/** The vendor app reads an answer 50-60 ms after sending its command; match it. */ +const COMMAND_DELAY_MS = 60; +/** Block reads are re-requested a few times before giving up; writes never are. */ +const READ_ATTEMPTS = 3; +const READ_RETRY_DELAY_MS = 150; +/** The vendor app leaves 150-400 ms between the settings and button block writes. */ +const BLOCK_WRITE_SETTLE_MS = 200; + +const MODE_STATIC: MouseLightingMode = "Static"; +const MODE_WAVE: MouseLightingMode = "Wave"; +const MODE_BREATHING: MouseLightingMode = "Breathing random"; +const MODE_OFF: MouseLightingMode = "Off"; +const LIGHTING_MODES: readonly MouseLightingMode[] = [MODE_STATIC, MODE_WAVE, MODE_BREATHING, MODE_OFF]; +/** The vendor sliders' five positions (Cfg.ini `LightUI`/`SpeedUI` 0-4); brightness 0 is Off. */ +const BRIGHTNESS_PERCENT = [25, 50, 75, 100] as const; +const SPEEDS = [1, 2, 3, 4, 5] as const; +/** + * Shown for effects without a speed (Static, Off) so that switching to Wave or + * Breathing starts from a selected value: the factory speed byte 2, shown as 3. + */ +const DEFAULT_SPEED = 3; +/** + * Shown when the stored brightness is 0 (Off, or an effect saved dark) so + * that switching to an effect starts from a selected value, and written when + * an effect stored at 0 is selected without one. + */ +const DEFAULT_BRIGHTNESS = 50; +const LIGHTING_EFFECT_FOR_MODE: Partial> = { + [MODE_STATIC]: REDRAGON_M690_PRO_EFFECT_STEADY, + [MODE_OFF]: REDRAGON_M690_PRO_EFFECT_STEADY, + [MODE_WAVE]: REDRAGON_M690_PRO_EFFECT_STREAMING, + [MODE_BREATHING]: REDRAGON_M690_PRO_EFFECT_BREATHING, +}; + +function hasVendorReports(collections: readonly HIDCollectionInfo[], found = new Set()): Set { + for (const collection of collections) { + if (collection.usagePage === REDRAGON_M690_PRO_USAGE_PAGE && collection.usage === REDRAGON_M690_PRO_USAGE) { + for (const report of collection.featureReports ?? []) found.add(report.reportId); + } + hasVendorReports(collection.children ?? [], found); + } + return found; +} + +function toHexColor({ r, g, b }: RedragonM690ProRgb): string { + return `#${[r, g, b].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} + +function parseColor(color: string): RedragonM690ProRgb { + const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(color); + if (!match) throw new Error(`Redragon M690 PRO colour "${color}" is not #rrggbb.`); + return { r: parseInt(match[1]!, 16), g: parseInt(match[2]!, 16), b: parseInt(match[3]!, 16) }; +} + +function sameBytes(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((byte, index) => byte === b[index]); +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +class NotM690ProError extends Error { + constructor() { + super("This SinoWealth device's settings block is not the Redragon M690 PRO's; not touching it."); + } +} + +/** + * Redragon M690 PRO (`258a:002e` cable, `258a:002f` 2.4 GHz receiver) WebHID + * control. + * + * SinoWealth framing (see `m690-pro.ts` in `@openmouse/protocol/redragon`): + * a command on feature report 5 selects what feature report 8 answers. + * Settings and buttons are each one block that is read, changed, and written + * back whole, in the vendor app's order (settings, then buttons with its `a5` + * trailer), then read back and compared. The cable and the receiver keep + * separate settings banks; each PID reads and writes its own, as the vendor + * app does. + * + * Through the receiver, the dongle answers reads even while the mouse is off + * (with the last values it holds), so link state comes from command 0x80 and + * writes are refused while the mouse is reported unreachable, as the vendor + * app does. + * + * Short answers (identify `0x01`, link `0x80`, battery `0x90`) come back on + * the 7-byte feature report 5, and the firmware STALLs that read unless it + * asks for exactly 8 bytes. Chrome asks for the device's largest feature + * report (520 bytes) on every read (captured over the cable), so in a browser + * those answers can be unreadable. The mouse is therefore identified from its + * settings block, and link and battery are read when the transport allows it + * and otherwise reported as unknown. + */ +export class RedragonM690ProHidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private identified = false; + /** Whether report-5 answers can be read here: null until the first try. */ + private shortAnswers: boolean | null = null; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== REDRAGON_M690_PRO_VENDOR_ID) return false; + if (!REDRAGON_M690_PRO_PRODUCTS.has(device.productId)) return false; + const reports = hasVendorReports(device.collections); + return reports.has(REDRAGON_M690_PRO_BLOCK_REPORT_ID) && reports.has(REDRAGON_M690_PRO_COMMAND_REPORT_ID); + } + + get supportedPollingRates(): number[] { + return [...REDRAGON_M690_PRO_POLLING_RATES]; + } + + getDpiOptions(): number[] { + return [...REDRAGON_M690_PRO_DPI_LABELS]; + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + if (this.device.opened) await this.device.close(); + } + + /** + * Reads everything the mouse can report. A device that cannot be opened + * still fails, but a settings read that fails (or a settings block that is + * not the M690 PRO's) gives an identity-only status with controls off, so + * the connection stays up and the next refresh can try again. + */ + async readStatus(): Promise { + return await this.run(async () => { + await this.open(); + try { + return await this.readFullStatus(); + } catch (error) { + return this.unavailableStatus(error); + } + }); + } + + private async readFullStatus(): Promise { + const block = await this.identify() ?? await this.readConfig(); + const product = this.product(); + const wireless = product.connection === "Wireless"; + const linked = wireless ? await this.readLink() : true; + const config = redragonM690ProDecodeConfig(block); + const buttons = redragonM690ProDecodeButtons(await this.readButtons()); + const statusReply = await this.shortCommand(REDRAGON_M690_PRO_CMD_STATUS); + const status = statusReply ? redragonM690ProDecodeStatus(statusReply) : null; + const stages = config.stages.map((dpi) => dpi ?? 0); + const name = `Redragon ${product.name}`; + return { + brand: "Redragon", + name, + ui: { + family: "redragon-m690-pro", + settingsReady: true, + valuesVerified: true, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + dpiStageEditor: { + maxStages: REDRAGON_M690_PRO_STAGE_COUNT, + countEditable: false, + minDpi: REDRAGON_M690_PRO_DPI_LABELS[0]!, + maxDpi: REDRAGON_M690_PRO_DPI_LABELS[REDRAGON_M690_PRO_DPI_LABELS.length - 1]!, + stepDpi: 50, + }, + defaultDisplayName: name, + ...(linked === false ? { + statusNote: "The mouse is off or asleep: the receiver shows its last values, and changes need the mouse awake.", + } : wireless && linked === null ? { + // A browser cannot read the battery or link answers (see the class + // comment); OpenMouse Bridge can. Without the link check, a change + // made while the mouse is off is undone when it reconnects + // (captured), because the receiver only holds a copy. + statusNote: "Battery level and the awake check need OpenMouse Bridge. Without it, changes made while the mouse is off or asleep are undone when it reconnects.", + } : !wireless && status === null ? { + statusNote: "Charging status needs OpenMouse Bridge.", + } : {}), + }, + batteryPercent: linked === false ? null : status?.batteryPercent ?? null, + batteryState: linked !== false && status?.charge ? status.charge : "Unknown", + dpi: stages[config.activeStage] ?? stages[0]!, + dpiStages: stages, + dpiStageColors: config.stageColors.map(toHexColor), + activeDpiStage: config.activeStage, + pollingRateHz: config.pollingHz ?? 0, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + buttonMappings: buttons, + buttonOptions: [...REDRAGON_M690_PRO_BUTTON_OPTIONS], + fixedButtons: Object.entries(buttons) + .filter(([, action]) => !REDRAGON_M690_PRO_BUTTON_OPTIONS.includes(action)) + .map(([button]) => button), + lighting: this.lighting(config), + connectionType: product.connection, + connectionDetail: wireless ? (linked === false ? "2.4 GHz receiver, mouse off or asleep" : "2.4 GHz receiver") : "USB cable", + liftOffDistance: null, + firmware: [], + }; + } + + /** Snaps to the nearest vendor DPI value, writes it, and returns that value. */ + async setDpiStageValue(stage: number, dpi: number): Promise { + const label = redragonM690ProNearestDpi(dpi); + await this.writeConfig((block) => redragonM690ProWithStageDpi(block, stage, label)); + return label; + } + + /** Selects the active DPI stage (0-based), as the mouse's DPI buttons do. */ + async setActiveDpiStage(stage: number): Promise { + await this.writeConfig((block) => redragonM690ProWithActiveStage(block, stage)); + return stage; + } + + async setPollingRate(hz: number): Promise { + await this.writeConfig((block) => redragonM690ProWithPollingRate(block, hz)); + return hz; + } + + /** + * Sets one stage's indicator colour (`#rrggbb`, 0-based stage), the + * contract of OpenMouse's `applyDpiStageColor`. OpenMouse currently calls it + * only when applying a saved game profile: the stage editor's per-stage + * colour picker was dropped in openmouse 0fc6ae1 ("unified stage-based DPI + * editor"), and works again unchanged if that picker returns. + */ + async setDpiStageColor(stage: number, color: string): Promise { + const rgb = parseColor(color); + await this.writeConfig((block) => redragonM690ProWithStageColor(block, stage, rgb)); + } + + /** + * Selects an effect and writes the brightness, speed and colour requested + * for it, as the panel shows them before Apply. A value left unset keeps + * what that effect has stored, except a stored brightness of 0, which would + * leave the chosen effect dark. Off is Steady at brightness 0, which keeps + * the colour. + */ + async setLighting(lighting: MouseLighting): Promise { + const effect = lighting.mode ? LIGHTING_EFFECT_FOR_MODE[lighting.mode] : undefined; + if (effect === undefined) throw new Error(`The Redragon M690 PRO has no "${lighting.mode}" lighting effect.`); + if (lighting.speed != null && !SPEEDS.includes(lighting.speed as 1)) { + throw new Error(`Redragon M690 PRO lighting speed ${lighting.speed} is outside 1-${SPEEDS.length}.`); + } + if (lighting.brightness != null && !BRIGHTNESS_PERCENT.includes(lighting.brightness as 25)) { + throw new Error(`Redragon M690 PRO brightness ${lighting.brightness}% is not one of ${BRIGHTNESS_PERCENT.join(", ")}.`); + } + const color = lighting.color == null ? null : parseColor(lighting.color); + await this.writeConfig((block) => { + const config = redragonM690ProDecodeConfig(block); + const change: RedragonM690ProLightingChange = { effect }; + if (lighting.mode === MODE_OFF) { + change.brightness = 0; + } else { + const stored = effect === REDRAGON_M690_PRO_EFFECT_STEADY ? config.lighting.steady.brightness + : effect === REDRAGON_M690_PRO_EFFECT_STREAMING ? config.lighting.streaming.brightness + : config.lighting.breathing.brightness; + if (lighting.brightness != null) { + change.brightness = BRIGHTNESS_PERCENT.indexOf(lighting.brightness as 25) + 1; + } else if (stored === 0) { + change.brightness = BRIGHTNESS_PERCENT.indexOf(DEFAULT_BRIGHTNESS) + 1; + } + if (effect !== REDRAGON_M690_PRO_EFFECT_STEADY && lighting.speed != null) change.speed = lighting.speed - 1; + if (effect === REDRAGON_M690_PRO_EFFECT_STEADY && color) change.color = color; + } + return redragonM690ProWithLighting(block, change); + }); + } + + /** + * Assigns an action from `buttonOptions` to one of the eight buttons. + * Refuses to take away the last left click, which would leave the mouse + * unable to click. + */ + async setButtonMapping(button: string, action: string): Promise { + await this.writeButtons((block) => { + const next = redragonM690ProWithButtonAction(block, button, action); + if (!Object.values(redragonM690ProDecodeButtons(next)).includes("Left click")) { + throw new Error("Keep Left click on at least one button, or the mouse cannot click."); + } + return next; + }); + } + + private lighting(config: RedragonM690ProConfig): MouseLighting { + const { effect, steady, streaming, breathing, steadyColors } = config.lighting; + let mode: MouseLightingMode | null = null; + let brightness = 0; + let speed: number | null = null; + if (effect === REDRAGON_M690_PRO_EFFECT_STEADY && steady.brightness === 0) { + mode = MODE_OFF; + } else if (effect === REDRAGON_M690_PRO_EFFECT_STEADY) { + mode = MODE_STATIC; + brightness = steady.brightness; + } else if (effect === REDRAGON_M690_PRO_EFFECT_STREAMING) { + mode = MODE_WAVE; + brightness = streaming.brightness; + speed = streaming.speed; + } else if (effect === REDRAGON_M690_PRO_EFFECT_BREATHING) { + mode = MODE_BREATHING; + brightness = breathing.brightness; + speed = breathing.speed; + } + const color = steadyColors[steady.slot] ?? null; + return { + zone: "Mouse", + modes: LIGHTING_MODES, + mode, + color: color ? toHexColor(color) : null, + color2: null, + colorModes: [MODE_STATIC], + dualColorModes: [], + reactiveModes: [MODE_WAVE, MODE_BREATHING], + speeds: SPEEDS, + speed: speed !== null && speed < SPEEDS.length ? speed + 1 : DEFAULT_SPEED, + brightness: BRIGHTNESS_PERCENT[brightness - 1] ?? DEFAULT_BRIGHTNESS, + // Always offered: OpenMouse shows brightness from the effect the mouse + // reports, not the one just picked, so varying it would hide it on Static + // after Off and show it on Off after Breathing. Off ignores it. + brightnessLevels: [...BRIGHTNESS_PERCENT], + }; + } + + private product(): RedragonM690ProProduct { + return REDRAGON_M690_PRO_PRODUCTS.get(this.device.productId)!; + } + + /** + * Opens the device and, once per connection, checks that its settings block + * is the M690 PRO's before anything is written (`258a:002f` is a generic + * SinoWealth receiver id). Returns the settings block it read for that, + * or null once the device is known. + */ + private async prepare(): Promise { + await this.open(); + return await this.identify(); + } + + private async identify(): Promise { + if (this.identified) return null; + const block = await this.readConfig(); + if (!redragonM690ProIsModelBlock(block)) throw new NotM690ProError(); + this.identified = true; + return block; + } + + private unavailableStatus(error: unknown): MouseStatus { + const product = this.product(); + const name = `Redragon ${product.name}`; + const statusNote = error instanceof NotM690ProError + ? "This device's settings block is not the Redragon M690 PRO's, so its settings are left alone." + : `The Redragon M690 PRO's settings could not be read (${error instanceof Error ? error.message : String(error)}); OpenMouse keeps trying.`; + return { + brand: "Redragon", + name: error instanceof NotM690ProError ? this.device.productName?.trim() || name : name, + ui: { + family: "redragon-m690-pro", + settingsReady: false, + valuesVerified: false, + hideProcessingCard: true, + defaultDisplayName: name, + statusNote, + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: 0, + pollingRateHz: 0, + activeProfile: null, + connectionType: product.connection, + liftOffDistance: null, + firmware: [], + }; + } + + /** Receiver link state, or null when report-5 answers cannot be read here. */ + private async readLink(): Promise { + const reply = await this.shortCommand(REDRAGON_M690_PRO_CMD_LINK); + return reply ? redragonM690ProDecodeLink(reply) : null; + } + + private async ensureLinked(): Promise { + if (this.product().connection !== "Wireless") return; + if (await this.readLink() === false) { + throw new Error("The Redragon M690 PRO is off or asleep. Move it or switch it on, then try again."); + } + } + + private async readConfig(): Promise { + return await this.readBlock(this.product().configCommand, REDRAGON_M690_PRO_CONFIG_LENGTH); + } + + private async readButtons(): Promise { + return await this.readBlock(this.product().buttonsCommand, REDRAGON_M690_PRO_BUTTONS_LENGTH); + } + + private async writeConfig(change: (block: Uint8Array) => Uint8Array): Promise { + await this.writeBlocks(change, (block) => block); + } + + private async writeButtons(change: (block: Uint8Array) => Uint8Array): Promise { + await this.writeBlocks((block) => block, change); + } + + /** + * The vendor app's write: settings block, then button block with its + * trailer, both rewritten whole even when only one changed. Both are read + * back and must match before the change is reported as done. + */ + private async writeBlocks( + changeConfig: (block: Uint8Array) => Uint8Array, + changeButtons: (block: Uint8Array) => Uint8Array, + ): Promise { + await this.run(async () => { + await this.prepare(); + await this.ensureLinked(); + const config = changeConfig(await this.readConfig()); + const buttons = changeButtons(await this.readButtons()); + await this.sendBlock(redragonM690ProEncodeBlockWrite(config, REDRAGON_M690_PRO_CONFIG_LENGTH)); + await this.sendBlock(redragonM690ProEncodeBlockWrite(buttons, REDRAGON_M690_PRO_BUTTONS_LENGTH, REDRAGON_M690_PRO_BUTTONS_TRAILER)); + const [configBack, buttonsBack] = [await this.readConfig(), await this.readButtons()]; + if (!sameBytes(configBack, config) || !sameBytes(buttonsBack, buttons)) { + throw new Error("The Redragon M690 PRO did not keep the new settings (read-back differs)."); + } + }); + } + + private async sendBlock(frame: Uint8Array): Promise { + await this.device.sendFeatureReport(REDRAGON_M690_PRO_BLOCK_REPORT_ID, frame.slice(1).buffer as ArrayBuffer); + await sleep(BLOCK_WRITE_SETTLE_MS); + } + + /** + * A short command answered on report 5, tried once: null when the answer + * cannot be read. After a first failure on a transport that never answered, + * later calls skip the doomed request (each one costs the mouse a STALL). + */ + private async shortCommand(command: number): Promise { + if (this.shortAnswers === false) return null; + try { + await this.device.sendFeatureReport(REDRAGON_M690_PRO_COMMAND_REPORT_ID, redragonM690ProEncodeCommand(command).slice(1).buffer as ArrayBuffer); + await sleep(COMMAND_DELAY_MS); + const reply = await this.receive(REDRAGON_M690_PRO_COMMAND_REPORT_ID); + redragonM690ProCheckCommandReply(reply, command); + this.shortAnswers = true; + return reply; + } catch { + if (this.shortAnswers === null) this.shortAnswers = false; + return null; + } + } + + private async readBlock(command: number, length: number): Promise { + return await this.request(command, REDRAGON_M690_PRO_BLOCK_REPORT_ID, (block) => { + redragonM690ProCheckBlock(block, command, length); + return block.slice(0, length); + }); + } + + /** + * Sends a block read command on report 5 and collects the block from + * `reportId`, re-sending the command if the block cannot be read yet or is + * not the one asked for. Only read commands go through here. + */ + private async request(command: number, reportId: number, accept: (answer: Uint8Array) => T): Promise { + let failure: unknown = null; + for (let attempt = 0; attempt < READ_ATTEMPTS; attempt++) { + if (attempt > 0) await sleep(READ_RETRY_DELAY_MS); + try { + await this.device.sendFeatureReport(REDRAGON_M690_PRO_COMMAND_REPORT_ID, redragonM690ProEncodeCommand(command).slice(1).buffer as ArrayBuffer); + await sleep(COMMAND_DELAY_MS); + return accept(await this.receive(reportId)); + } catch (error) { + failure = error; + } + } + const reason = failure instanceof Error ? failure.message : String(failure); + throw new Error(`The Redragon M690 PRO did not answer command 0x${command.toString(16).padStart(2, "0")} on report ${reportId} (${reason}).`); + } + + /** WebHID keeps or strips the report id depending on platform; normalise to "id first". */ + private async receive(reportId: number): Promise { + const view = await this.device.receiveFeatureReport(reportId); + const bytes = new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); + if (bytes[0] === reportId) return bytes; + const out = new Uint8Array(bytes.length + 1); + out[0] = reportId; + out.set(bytes, 1); + return out; + } + + private async run(operation: () => Promise): Promise { + const result = this.queue.then(operation, operation); + this.queue = result.then(() => undefined, () => undefined); + return await result; + } +} diff --git a/src/drivers/redragon/m690-pro.test.ts b/src/drivers/redragon/m690-pro.test.ts new file mode 100644 index 0000000..2140026 --- /dev/null +++ b/src/drivers/redragon/m690-pro.test.ts @@ -0,0 +1,470 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + REDRAGON_M690_PRO_BLOCK_REPORT_ID, + REDRAGON_M690_PRO_BUTTONS_LENGTH, + REDRAGON_M690_PRO_BUTTONS_TRAILER, + REDRAGON_M690_PRO_COMMAND_REPORT_ID, + REDRAGON_M690_PRO_CONFIG_LENGTH, + REDRAGON_M690_PRO_DPI_LABELS, + REDRAGON_M690_PRO_EFFECT_STEADY, + REDRAGON_M690_PRO_EFFECT_STREAMING, + REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID, + REDRAGON_M690_PRO_USAGE, + REDRAGON_M690_PRO_USAGE_PAGE, + REDRAGON_M690_PRO_VENDOR_ID, + REDRAGON_M690_PRO_WIRED_PRODUCT_ID, + redragonM690ProDecodeButtonAction, + redragonM690ProDecodeButtons, + redragonM690ProDecodeConfig, + redragonM690ProDecodeIdentity, + redragonM690ProDecodeLink, + redragonM690ProDecodeStatus, + redragonM690ProEncodeBlockWrite, + redragonM690ProEncodeButtonAction, + redragonM690ProNearestDpi, + redragonM690ProWithButtonAction, + redragonM690ProWithLighting, + redragonM690ProWithPollingRate, + redragonM690ProWithStageColor, + redragonM690ProWithStageDpi, +} from "@openmouse/protocol/redragon"; +import type { MouseLighting } from "../mouse-types.ts"; +import { RedragonM690ProHidClient } from "./m690-pro-hid.ts"; + +const bytes = (text: string): Uint8Array => Uint8Array.from(text.replace(/\s+/g, "").match(/../g)!.map((byte) => parseInt(byte, 16))); + +/** + * A cable settings block (firmware 2.95) as the vendor app read it at startup + * (`05 11` -> report 8, 154 bytes): 125 Hz, stage 3 of 5 active, + * 500/1000/2000/3000/8000 DPI, every stage indicator 00 00 40, Steady + * lighting at brightness 0. + */ +const CABLE_CONFIG = bytes(` + 081100000000000064130135000200040008000c001800000000000000000000 + 00000000000000000000000000000040000040000040000040000040ff460000 + ffffffffff02000100ff0000000700000000ff000000ffffff0000ffffff8000 + ff00ff02020000000000ff000000ffffff0000ffffff8000ff00ffff0000ff00 + 00ff00000200ff00fa036a424202ff000000000102ff0000a500`); + +/** The same mouse's button block (`05 12`): factory assignments. */ +const CABLE_BUTTONS = bytes(` + 0812000000000000110100001102000011040000110800001110000041010000 + 4102000031013203500400005001000050010000500100005001000050010000 + 500100005001000050010000500100005001000050010000`); + +/** The first 154 bytes of the app's polling 125 -> 500 Hz write; the rest of the 520 is zero. */ +const POLLING_500_WRITE = bytes(` + 081100920000000064130335000200040008000c001800000000000000000000 + 00000000000000000000000000000040000040000040000040000040ff460000 + ffffffffff02000100ff0000000700000000ff000000ffffff0000ffffff8000 + ff00ff02020000000000ff000000ffffff0000ffffff8000ff00ffff0000ff00 + 00ff00000200ff00fa036a424202ff000000000102ff0000a500`); + +function padded(prefix: Uint8Array): Uint8Array { + const frame = new Uint8Array(520); + frame.set(prefix); + return frame; +} + +test("decodes the captured cable settings block", () => { + const config = redragonM690ProDecodeConfig(CABLE_CONFIG); + assert.equal(config.pollingHz, 125); + assert.equal(config.stageCount, 5); + assert.equal(config.activeStage, 2); + assert.deepEqual(config.stages, [500, 1000, 2000, 3000, 8000]); + assert.deepEqual(config.stageColors[0], { r: 0, g: 0, b: 0x40 }); + assert.equal(config.lighting.effect, REDRAGON_M690_PRO_EFFECT_STEADY); + assert.deepEqual(config.lighting.steady, { brightness: 0, slot: 0 }); + assert.deepEqual(config.lighting.steadyColors[1], { r: 0, g: 0xff, b: 0 }); +}); + +/** + * The receiver bank of a factory-fresh unit, as the vendor app first read + * it: the factory settings (the same values the app's Restore writes). + */ +const FACTORY_RECEIVER_CONFIG = bytes(` + 082100000000000064130115000200040008000c001800000000000000000000 + 00000000000000000000000000ff00000000ff00ff00ff00ffffff00ff460000 + ffffffffff01420140ff00004207ff000000ff000000ffffff0000ffffff8000 + ff00ff020200ff000000ff000000ffffff0000ffffff8000ff00ffff0000ff00 + 00ff00000200ff00fa036a424202ff000000000102ff0000a500`); + +test("decodes a factory-fresh receiver bank", () => { + const config = redragonM690ProDecodeConfig(FACTORY_RECEIVER_CONFIG); + assert.equal(config.pollingHz, 125); + assert.equal(config.activeStage, 0); + assert.deepEqual(config.stages, [500, 1000, 2000, 3000, 8000]); + assert.deepEqual(config.stageColors, [ + { r: 0xff, g: 0, b: 0 }, { r: 0, g: 0, b: 0xff }, { r: 0, g: 0xff, b: 0 }, + { r: 0xff, g: 0, b: 0xff }, { r: 0xff, g: 0xff, b: 0 }, + ]); + assert.equal(config.lighting.effect, REDRAGON_M690_PRO_EFFECT_STREAMING); + assert.deepEqual(config.lighting.streaming, { brightness: 4, speed: 2 }); + assert.deepEqual(config.lighting.steady, { brightness: 4, slot: 0 }); + assert.deepEqual(config.lighting.steadyColors[0], { r: 0xff, g: 0, b: 0 }); + assert.deepEqual(config.lighting.breathing, { brightness: 4, speed: 2 }); +}); + +test("re-encodes the app's polling write byte for byte", () => { + const frame = redragonM690ProEncodeBlockWrite(redragonM690ProWithPollingRate(CABLE_CONFIG, 500), REDRAGON_M690_PRO_CONFIG_LENGTH); + assert.deepEqual(frame, padded(POLLING_500_WRITE)); +}); + +test("re-encodes the app's unchanged button write, trailer included", () => { + const frame = redragonM690ProEncodeBlockWrite(CABLE_BUTTONS, REDRAGON_M690_PRO_BUTTONS_LENGTH, REDRAGON_M690_PRO_BUTTONS_TRAILER); + const expected = padded(CABLE_BUTTONS); + expected[3] = 0x50; + expected[88] = 0xa5; + assert.deepEqual(frame, expected); +}); + +/** + * The vendor app's DPI-indicator write over the cable: the settings block it + * read, then its write with the five stage colours changed. + */ +const COLOURS_READ = bytes(` + 081100000000000064130425000200040008000c001800000000000000000000 + 00000000000000000000000000000040000040000040000040000040ff460000 + ffffffffff02440100ff0000400700000000ff000000ffffff0000ffffff8000 + ff00ff0202000bebd700ff000000ffffff0000ffffff8000ff00ffff0000ff00 + 00ff00000200ff00fa036a424202ff000000000102ff0000a500`); +const COLOURS_WRITE = bytes(` + 081100920000000064130425000200040008000c001800000000000000000000 + 00000000000000000000000000ff8080ffff0000ff000000ffffffffff460000 + ffffffffff02440100ff0000400700000000ff000000ffffff0000ffffff8000 + ff00ff0202000bebd700ff000000ffffff0000ffffff8000ff00ffff0000ff00 + 00ff00000200ff00fa036a424202ff000000000102ff0000a500`); + +test("re-encodes the app's DPI indicator colour write byte for byte", () => { + const colours = [ + { r: 0xff, g: 0x80, b: 0x80 }, { r: 0xff, g: 0xff, b: 0x00 }, { r: 0x00, g: 0xff, b: 0x00 }, + { r: 0x00, g: 0x00, b: 0xff }, { r: 0xff, g: 0xff, b: 0xff }, + ]; + const block = colours.reduce((next, rgb, stage) => redragonM690ProWithStageColor(next, stage, rgb), COLOURS_READ); + assert.deepEqual(redragonM690ProEncodeBlockWrite(block, REDRAGON_M690_PRO_CONFIG_LENGTH), padded(COLOURS_WRITE)); +}); + +test("stores DPI as the 1-based position in the vendor list", () => { + // Captured: 250 -> 01, 3000 -> 0c, 8000 -> 18 on every stage. + for (const [dpi, code] of [[250, 0x01], [3000, 0x0c], [8000, 0x18]] as const) { + for (let stage = 0; stage < 5; stage++) { + const next = redragonM690ProWithStageDpi(CABLE_CONFIG, stage, dpi); + assert.equal(next[0x0d + 2 * stage], code); + assert.equal(next[0x0e + 2 * stage], 0x00); + } + } + assert.equal(REDRAGON_M690_PRO_DPI_LABELS.length, 24); + assert.equal(redragonM690ProNearestDpi(760), 800); + assert.throws(() => redragonM690ProWithStageDpi(CABLE_CONFIG, 0, 750)); + assert.throws(() => redragonM690ProWithStageDpi(CABLE_CONFIG, 5, 800)); +}); + +test("matches the app's lighting writes", () => { + // Steady (brightness 0) -> Colorful Streaming brightness 4 speed 2: [0x45] 02->01, [0x46] 00->42. + const streaming = redragonM690ProWithLighting(CABLE_CONFIG, { effect: REDRAGON_M690_PRO_EFFECT_STREAMING, brightness: 4, speed: 2 }); + const changed = [...streaming].flatMap((byte, index) => (byte === CABLE_CONFIG[index] ? [] : [index])); + assert.deepEqual(changed, [0x45, 0x46]); + assert.equal(streaming[0x45], 0x01); + assert.equal(streaming[0x46], 0x42); + // Steady colour recolours the selected slot, as the app did for slot 4 (4c 22 8a). + const recoloured = redragonM690ProWithLighting( + Uint8Array.from(CABLE_CONFIG).fill(0x04, 0x48, 0x49), + { effect: REDRAGON_M690_PRO_EFFECT_STEADY, brightness: 2, color: { r: 0x4c, g: 0x22, b: 0x8a } }, + ); + assert.equal(recoloured[0x48], 0x24); + assert.deepEqual([...recoloured.subarray(0x72, 0x75)], [0x4c, 0x22, 0x8a]); + assert.throws(() => redragonM690ProWithLighting(CABLE_CONFIG, { effect: REDRAGON_M690_PRO_EFFECT_STEADY, brightness: 5 })); +}); + +test("decodes and encodes button slots", () => { + assert.deepEqual(redragonM690ProDecodeButtons(CABLE_BUTTONS), { + "Left (1)": "Left click", + "Right (2)": "Right click", + "Wheel click (3)": "Middle click", + "Forward (4)": "Forward", + "Back (5)": "Back", + "DPI up (6)": "DPI up", + "DPI down (7)": "DPI down", + "Fire (8)": "Three click", + }); + // Captured: Refresh on button 8, Disable on the wheel click, a macro on button 4. + assert.deepEqual(redragonM690ProEncodeButtonAction("Web refresh"), [0x22, 0x00, 0x00, 0x20]); + assert.deepEqual(redragonM690ProEncodeButtonAction("Disabled"), [0x50, 0x01, 0x00, 0x00]); + assert.equal(redragonM690ProDecodeButtonAction([0x70, 0x01, 0x01, 0x01]), "Macro 1"); + // Keyboard keys, as captured and pressed: 21 modifiers usage 00. + assert.equal(redragonM690ProDecodeButtonAction([0x21, 0x03, 0x15, 0x00]), "Keys Ctrl+Shift+R"); + assert.equal(redragonM690ProDecodeButtonAction([0x21, 0x04, 0x04, 0x00]), "Keys Alt+A"); + assert.equal(redragonM690ProDecodeButtonAction([0x21, 0x08, 0x1a, 0x00]), "Keys Win+W"); + assert.equal(redragonM690ProDecodeButtonAction([0x21, 0x00, 0x04, 0x00]), "Key A"); + for (const [key, usage] of [["\\", 0x31], ["Delete", 0x4c], ["Up arrow", 0x52], ["F12", 0x45], ["Numpad 5", 0x5d], ["Backspace", 0x2a]] as const) { + assert.deepEqual(redragonM690ProEncodeButtonAction(`Key ${key}`), [0x21, 0x00, usage, 0x00]); + } + assert.deepEqual(redragonM690ProEncodeButtonAction("Copy (Ctrl+C)"), [0x21, 0x01, 0x06, 0x00]); + assert.equal(redragonM690ProDecodeButtonAction([0x21, 0x10, 0x04, 0x00]), "Custom (21 10 04 00)"); + // App button 4 lives in slot 5 (offset 0x18). + const next = redragonM690ProWithButtonAction(CABLE_BUTTONS, "Forward (4)", "Web refresh"); + assert.deepEqual([...next.subarray(0x18, 0x1c)], [0x22, 0x00, 0x00, 0x20]); + assert.deepEqual([...next.subarray(0x14, 0x18)], [0x11, 0x08, 0x00, 0x00]); +}); + +test("decodes identity, link, and battery replies", () => { + assert.equal(redragonM690ProDecodeIdentity(bytes("0501323934350000")), "2945"); + assert.equal(redragonM690ProDecodeLink(bytes("0580010100000000")), true); + assert.equal(redragonM690ProDecodeLink(bytes("0580000100000000")), false); + assert.deepEqual(redragonM690ProDecodeStatus(bytes("0590116300000000")), { wireless: true, batteryPercent: 99, charge: null, raw: 0x63 }); + // Over the cable: charging, then charged (the app shows 100 %). + assert.deepEqual(redragonM690ProDecodeStatus(bytes("0590100100000000")), { wireless: false, batteryPercent: null, charge: "Charging", raw: 0x01 }); + assert.deepEqual(redragonM690ProDecodeStatus(bytes("0590100200000000")), { wireless: false, batteryPercent: 100, charge: "Full", raw: 0x02 }); + assert.throws(() => redragonM690ProDecodeLink(bytes("0590116300000000"))); +}); + +/** + * The M690 PRO's vendor channel as captured: a command on report 5 selects + * what report 8 answers, report-8 writes replace a bank's block, and the + * receiver answers reads even while the mouse is off. Answers keep the + * report id first, as Chrome on Windows returns them. + */ +class FakeM690Pro { + vendorId = REDRAGON_M690_PRO_VENDOR_ID; + productName = "M690-PRO"; + opened = false; + // Interface 1 as WebHID lists it: separate top-level collections. + collections = [ + { usagePage: 0x0c, usage: 0x01, featureReports: [], children: [] }, + { usagePage: REDRAGON_M690_PRO_USAGE_PAGE, usage: REDRAGON_M690_PRO_USAGE, featureReports: [{ reportId: 8 }], children: [] }, + { usagePage: REDRAGON_M690_PRO_USAGE_PAGE, usage: REDRAGON_M690_PRO_USAGE, featureReports: [], children: [] }, + { usagePage: REDRAGON_M690_PRO_USAGE_PAGE, usage: REDRAGON_M690_PRO_USAGE, featureReports: [{ reportId: 5 }], children: [] }, + { usagePage: 0xff01, usage: 0x01, featureReports: [{ reportId: 6 }], children: [] }, + ] as unknown as HIDCollectionInfo[]; + linked = true; + banks = new Map(); + readonly blockWrites: Uint8Array[] = []; + /** Fail this many reads with Chrome's error, as a transport that could not complete them. */ + failReceives = 0; + /** + * Chrome reads every feature report with the device's largest length (520), + * which the firmware STALLs on report 5, so report-5 answers never arrive. + */ + chromeReads = false; + readonly shortReads: number[] = []; + private lastCommand = 0; + + constructor(public productId: number) { + const wireless = productId === REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID; + const config = Uint8Array.from(CABLE_CONFIG); + const buttons = Uint8Array.from(CABLE_BUTTONS); + config[1] = wireless ? 0x21 : 0x11; + buttons[1] = wireless ? 0x22 : 0x12; + this.banks.set(config[1]!, config); + this.banks.set(buttons[1]!, buttons); + } + + async open(): Promise { this.opened = true; } + async close(): Promise { this.opened = false; } + + async sendFeatureReport(reportId: number, data: BufferSource): Promise { + const body = new Uint8Array(data as ArrayBuffer); + if (reportId === REDRAGON_M690_PRO_COMMAND_REPORT_ID) { + assert.equal(body.length, 7); + this.lastCommand = body[0]!; + return; + } + assert.equal(reportId, REDRAGON_M690_PRO_BLOCK_REPORT_ID); + assert.equal(body.length, 519); + const frame = Uint8Array.from([REDRAGON_M690_PRO_BLOCK_REPORT_ID, ...body]); + this.blockWrites.push(frame); + const command = frame[1]!; + const length = this.banks.get(command)!.length; + assert.equal(frame[3], length - 8, "write length byte"); + const stored = frame.slice(0, length); + stored[3] = 0x00; + this.banks.set(command, stored); + } + + async receiveFeatureReport(reportId: number): Promise { + if (this.failReceives > 0) { + this.failReceives--; + throw new Error("Failed to receive the feature report."); + } + if (reportId === REDRAGON_M690_PRO_BLOCK_REPORT_ID) { + const block = this.banks.get(this.lastCommand)!; + const out = new Uint8Array(520); + out.set(block); + return new DataView(out.buffer); + } + this.shortReads.push(this.lastCommand); + if (this.chromeReads) throw new Error("Failed to receive the feature report."); + const reply = new Uint8Array(8); + reply.set([REDRAGON_M690_PRO_COMMAND_REPORT_ID, this.lastCommand]); + if (this.lastCommand === 0x80) reply.set([this.linked ? 0x01 : 0x00, 0x01], 2); + if (this.lastCommand === 0x90) { + reply.set(this.productId === REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID ? [0x11, 0x64] : [0x10, 0x01], 2); + } + return new DataView(reply.buffer); + } +} + +const client = (fake: FakeM690Pro) => new RedragonM690ProHidClient(fake as unknown as HIDDevice); + +test("claims the M690 PRO interface but not other 0x258a devices", () => { + assert.equal(RedragonM690ProHidClient.isSupported(new FakeM690Pro(REDRAGON_M690_PRO_WIRED_PRODUCT_ID) as unknown as HIDDevice), true); + assert.equal(RedragonM690ProHidClient.isSupported(new FakeM690Pro(REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID) as unknown as HIDDevice), true); + const glorious = new FakeM690Pro(0x2011); + assert.equal(RedragonM690ProHidClient.isSupported(glorious as unknown as HIDDevice), false); + const keyboardOnly = new FakeM690Pro(REDRAGON_M690_PRO_WIRED_PRODUCT_ID); + keyboardOnly.collections = keyboardOnly.collections.slice(0, 1); + assert.equal(RedragonM690ProHidClient.isSupported(keyboardOnly as unknown as HIDDevice), false); +}); + +test("reads the cable status from the mouse", async () => { + const status = await client(new FakeM690Pro(REDRAGON_M690_PRO_WIRED_PRODUCT_ID)).readStatus(); + assert.equal(status.name, "Redragon M690 PRO"); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.pollingRateHz, 125); + assert.deepEqual(status.dpiStages, [500, 1000, 2000, 3000, 8000]); + assert.equal(status.activeDpiStage, 2); + assert.equal(status.dpi, 2000); + assert.equal(status.dpiStageColors?.[0], "#000040"); + assert.equal(status.batteryPercent, null); + assert.equal(status.batteryState, "Charging"); + assert.equal(status.ui?.statusNote, undefined); // readable here, as through Bridge + assert.equal(status.lighting?.mode, "Off"); + // Effects without a speed report the factory speed, so switching to Wave or + // Breathing in the panel starts with one selected. + assert.equal(status.lighting?.speed, 3); + assert.equal(status.buttonMappings?.["Fire (8)"], "Three click"); + assert.deepEqual(status.fixedButtons, []); +}); + +test("reads battery and link state through the receiver", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID); + assert.equal((await client(fake).readStatus()).batteryPercent, 100); + fake.linked = false; + const asleep = await client(fake).readStatus(); + assert.equal(asleep.batteryPercent, null); + assert.match(asleep.ui?.statusNote ?? "", /off or asleep/); +}); + +test("writes polling exactly as the app does, then reads it back", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_WIRED_PRODUCT_ID); + assert.equal(await client(fake).setPollingRate(500), 500); + assert.equal(fake.blockWrites.length, 2); + assert.deepEqual(fake.blockWrites[0], padded(POLLING_500_WRITE)); + assert.equal(fake.blockWrites[1]![1], 0x12); + assert.equal(fake.blockWrites[1]![88], 0xa5); + assert.equal(redragonM690ProDecodeConfig(fake.banks.get(0x11)!).pollingHz, 500); +}); + +test("writes the receiver's own bank", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID); + assert.equal(await client(fake).setDpiStageValue(0, 790), 800); + assert.deepEqual(fake.blockWrites.map((frame) => frame[1]), [0x21, 0x22]); + assert.equal(fake.banks.get(0x21)![0x0d], 0x03); +}); + +test("refuses writes while the receiver's mouse is off", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID); + fake.linked = false; + await assert.rejects(client(fake).setPollingRate(250), /off or asleep/); + assert.equal(fake.blockWrites.length, 0); +}); + +test("refuses a 0x258a receiver whose settings block is not the M690 PRO's", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID); + fake.banks.get(0x21)![0x09] = 0x10; + const status = await client(fake).readStatus(); + assert.equal(status.ui?.settingsReady, false); + assert.match(status.ui?.statusNote ?? "", /not the Redragon M690 PRO's/); + assert.equal(status.dpiStages, undefined); + await assert.rejects(client(fake).setPollingRate(250), /not the Redragon M690 PRO's/); + assert.equal(fake.blockWrites.length, 0); +}); + +test("works where report-5 answers cannot be read, and stops asking", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_WIRED_PRODUCT_ID); + fake.chromeReads = true; + const mouse = client(fake); + const status = await mouse.readStatus(); + assert.deepEqual(status.dpiStages, [500, 1000, 2000, 3000, 8000]); + assert.equal(status.batteryPercent, null); + assert.equal(status.ui?.statusNote, "Charging status needs OpenMouse Bridge."); + await mouse.readStatus(); + assert.equal(await mouse.setPollingRate(500), 500); + assert.deepEqual(fake.shortReads, [0x90], "one failed report-5 read, then none"); +}); + +test("through the receiver, an unreadable link check does not block writes", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID); + fake.chromeReads = true; + const status = await client(fake).readStatus(); + assert.equal(status.batteryPercent, null); + assert.match(status.ui?.statusNote ?? "", /need OpenMouse Bridge\. Without it, changes made while the mouse is off/); + assert.equal(await client(fake).setPollingRate(250), 250); + assert.equal(redragonM690ProDecodeConfig(fake.banks.get(0x21)!).pollingHz, 250); +}); + +test("sets a stage colour the way OpenMouse's applyDpiStageColor does", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_WIRED_PRODUCT_ID); + const mouse = client(fake); + const before = await mouse.readStatus(); + assert.equal(before.dpiStageColors?.length, before.dpiStages?.length); + assert.ok(before.dpiStageColors?.every((color) => /^#[0-9a-f]{6}$/.test(color))); + await mouse.setDpiStageColor(2, "#00ff00"); + const after = await mouse.readStatus(); + assert.deepEqual(after.dpiStageColors, ["#000040", "#000040", "#00ff00", "#000040", "#000040"]); + assert.deepEqual([...fake.banks.get(0x11)!.subarray(0x33, 0x36)], [0x00, 0xff, 0x00]); + await assert.rejects(mouse.setDpiStageColor(5, "#00ff00")); + await assert.rejects(mouse.setDpiStageColor(0, "green")); +}); + +test("switches lighting modes and keeps the last left click", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_WIRED_PRODUCT_ID); + const mouse = client(fake); + const shown = (await mouse.readStatus()).lighting!; + assert.equal(shown.brightness, 50); // Off (stored 0) shows the default, so switching starts from it + await mouse.setLighting({ ...shown, mode: "Wave" } as MouseLighting); + let lighting = (await mouse.readStatus()).lighting!; + assert.equal(lighting.mode, "Wave"); + assert.equal(lighting.brightness, 50); + await mouse.setLighting({ ...lighting, mode: "Breathing random", brightness: null } as unknown as MouseLighting); + assert.equal((await mouse.readStatus()).lighting?.brightness, 50); // the stored 0 is lifted to the default + await mouse.setLighting({ ...lighting, mode: "Static", color: "#ff0000", brightness: 50 } as MouseLighting); + lighting = (await mouse.readStatus()).lighting!; + assert.equal(lighting.mode, "Static"); + assert.equal(lighting.color, "#ff0000"); + assert.equal(lighting.brightness, 50); + await mouse.setLighting({ ...lighting, mode: "Off" } as MouseLighting); + assert.equal((await mouse.readStatus()).lighting?.mode, "Off"); + // Brightness stays offered under Off, so picking Static next shows it at once. + assert.deepEqual((await mouse.readStatus()).lighting?.brightnessLevels, [25, 50, 75, 100]); + // Captured through OpenMouse: Wave at speed 4, then Breathing with the + // panel still showing speed 4 must store Breathing speed 4, not keep its own. + await mouse.setLighting({ ...lighting, mode: "Wave", speed: 4, brightness: 100 } as MouseLighting); + await mouse.setLighting({ ...lighting, mode: "Breathing random", speed: 4, brightness: 100 } as MouseLighting); + lighting = (await mouse.readStatus()).lighting!; + assert.equal(lighting.mode, "Breathing random"); + assert.equal(lighting.speed, 4); + assert.equal(fake.banks.get(0x11)![0x4c], 0x43); + + await assert.rejects(mouse.setButtonMapping("Left (1)", "Back"), /Left click/); + await mouse.setButtonMapping("Right (2)", "Left click"); + await mouse.setButtonMapping("Left (1)", "Right click"); + const buttons = (await mouse.readStatus()).buttonMappings!; + assert.equal(buttons["Left (1)"], "Right click"); + assert.equal(buttons["Right (2)"], "Left click"); +}); + +test("re-requests a block read that failed", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_WIRED_PRODUCT_ID); + fake.failReceives = 2; + assert.equal((await client(fake).readStatus()).pollingRateHz, 125); + const stuck = new FakeM690Pro(REDRAGON_M690_PRO_WIRED_PRODUCT_ID); + stuck.failReceives = Infinity; + const status = await client(stuck).readStatus(); + assert.equal(status.ui?.settingsReady, false); + assert.match(status.ui?.statusNote ?? "", /did not answer command 0x11 on report 8 \(Failed to receive the feature report\.\)/); + await assert.rejects(client(stuck).setPollingRate(500), /did not answer command 0x11/); + assert.equal(stuck.blockWrites.length, 0); +}); diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 24e4c71..1fc871d 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -64,6 +64,7 @@ import { MicrosoftHidClient } from "./microsoft/hid.ts"; import { MotospeedHidClient } from "./motospeed/hid.ts"; import { DareuHidClient } from "./dareu/hid.ts"; import { RedragonHidClient } from "./redragon/hid.ts"; +import { RedragonM690ProHidClient } from "./redragon/m690-pro-hid.ts"; import { FaterHidClient } from "./fater/hid.ts"; import { IncottHidClient } from "./incott/hid.ts"; import { HyperXHidClient } from "./hyperx/hid.ts"; @@ -74,7 +75,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 | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | 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 | 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 interface DeviceDriver { brand: string; @@ -88,6 +89,10 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ {brand: "ASUS",supports: (device) => AsusHidClient.isSupported(device), create: (device) => new AsusHidClient(device), score: () => 10,}, { brand: "Dareu", supports: (device) => DareuHidClient.isSupported(device), create: (device) => new DareuHidClient(device), score: () => 9 }, { brand: "Redragon", supports: (device) => RedragonHidClient.isSupported(device), create: (device) => new RedragonHidClient(device), score: () => 9 }, + // Shares SinoWealth's 0x258a with the Glorious classic line; disjoint by + // product id, and the client refuses a settings block that is not the + // M690 PRO's. + { brand: "Redragon", supports: (device) => RedragonM690ProHidClient.isSupported(device), create: (device) => new RedragonM690ProHidClient(device), score: () => 9 }, // Shares Holtek's 0x04d9 with Redragon; disjoint by product id and by usage // page (0xff00 here, 0xffa0 there). { brand: "Fater", supports: (device) => FaterHidClient.isSupported(device), create: (device) => new FaterHidClient(device), score: () => 7 }, diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index eb002e9..862f365 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -107,6 +107,10 @@ import { import { REDRAGON_CONFIG_USAGE, REDRAGON_CONFIG_USAGE_PAGE, + REDRAGON_M690_PRO_PRODUCT_IDS, + REDRAGON_M690_PRO_USAGE, + REDRAGON_M690_PRO_USAGE_PAGE, + REDRAGON_M690_PRO_VENDOR_ID, REDRAGON_PRODUCT_IDS, REDRAGON_VENDOR_ID, } from "@openmouse/protocol/redragon"; @@ -173,6 +177,9 @@ export const VENDOR_ID = { microsoft: MICROSOFT_VENDOR_ID, dareu: DAREU_VENDOR_ID, redragon: REDRAGON_VENDOR_ID, + // Shares SinoWealth's 0x258a with the Glorious classic line (see + // `gloriousClassic` above); disjoint by product id. + redragonM690Pro: REDRAGON_M690_PRO_VENDOR_ID, fater: FATER_VENDOR_ID, // Shares 0x093a with Glorious's Pixart-based Model O 2 / I 2 family (see // `glorious` above); GloriousHidClient.isSupported() only claims its own @@ -218,6 +225,17 @@ export const FATER_HID_FILTERS: HIDDeviceFilter[] = [...FATER_PRODUCT_IDS].map(( })); /** Holtek config collection measured on the M724 K1NG 1K (usbmon + usbhid-dump). */ +/** + * Redragon M690 PRO (SinoWealth): cable and 2.4 GHz receiver, both exposing + * the vendor collection with feature reports 5 and 8 on USB interface 1. + */ +export const REDRAGON_M690_PRO_HID_FILTERS: HIDDeviceFilter[] = REDRAGON_M690_PRO_PRODUCT_IDS.map((productId) => ({ + vendorId: REDRAGON_M690_PRO_VENDOR_ID, + productId, + usagePage: REDRAGON_M690_PRO_USAGE_PAGE, + usage: REDRAGON_M690_PRO_USAGE, +})); + export const REDRAGON_HID_FILTERS: HIDDeviceFilter[] = [...REDRAGON_PRODUCT_IDS].map((productId) => ({ vendorId: REDRAGON_VENDOR_ID, productId, @@ -742,6 +760,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...ASUS_HID_FILTERS, ...DAREU_HID_FILTERS, ...REDRAGON_HID_FILTERS, + ...REDRAGON_M690_PRO_HID_FILTERS, ...FATER_HID_FILTERS, ...MOTOSPEED_HID_FILTERS, ...ZAUNKOENIG_PRODUCT_IDS.map((productId) => ({ diff --git a/src/redragon/index.ts b/src/redragon/index.ts index f45dba3..29f2862 100644 --- a/src/redragon/index.ts +++ b/src/redragon/index.ts @@ -1,3 +1,5 @@ +import { KEY_USAGES, SHORTCUTS, keyName } from "./keys.js"; + export interface RedragonProduct { model: string; name: string; @@ -468,35 +470,6 @@ export const REDRAGON_M612_BUTTON_ACTIONS: ReadonlyArray = [[0x01, "Ctrl"], [0x02, "Shift"], [0x04, "Alt"], [0x08, "Win"]]; - -const KEY_USAGES: ReadonlyArray = [ - ...Array.from({ length: 26 }, (_, index) => [String.fromCharCode(65 + index), 0x04 + index] as const), - ...Array.from({ length: 9 }, (_, index) => [String(index + 1), 0x1e + index] as const), - ["0", 0x27], - ["Enter", 0x28], ["Escape", 0x29], ["Backspace", 0x2a], ["Tab", 0x2b], ["Space", 0x2c], - ["-", 0x2d], ["=", 0x2e], ["[", 0x2f], ["]", 0x30], ["\\", 0x31], [";", 0x33], ["'", 0x34], ["`", 0x35], - [",", 0x36], [".", 0x37], ["/", 0x38], ["Caps Lock", 0x39], - ...Array.from({ length: 12 }, (_, index) => [`F${index + 1}`, 0x3a + index] as const), - ["Print Screen", 0x46], ["Scroll Lock", 0x47], ["Pause", 0x48], ["Insert", 0x49], ["Home", 0x4a], - ["Page Up", 0x4b], ["Delete", 0x4c], ["End", 0x4d], ["Page Down", 0x4e], - ["Right arrow", 0x4f], ["Left arrow", 0x50], ["Down arrow", 0x51], ["Up arrow", 0x52], -]; - -/** Named shortcuts: RDCfg's menu entries plus Undo/Redo, same encoding. */ -const SHORTCUTS: ReadonlyArray = [ - ["Copy", 0x01, "C"], ["Cut", 0x01, "X"], ["Paste", 0x01, "V"], ["Select all", 0x01, "A"], - ["Undo", 0x01, "Z"], ["Redo", 0x01, "Y"], ["Find", 0x01, "F"], ["New", 0x01, "N"], - ["Print", 0x01, "P"], ["Save", 0x01, "S"], - ["Switch window", 0x04, "Tab"], ["Close window", 0x04, "F4"], - ["File explorer", 0x08, "E"], ["Run", 0x08, "R"], ["Show desktop", 0x08, "D"], ["Lock PC", 0x08, "L"], -]; - -function keyName(modifiers: number, usage: number): string | null { - const key = KEY_USAGES.find(([, value]) => value === usage)?.[0]; - if (key === undefined || (modifiers & ~0x0f) !== 0) return null; - return [...MODIFIER_NAMES.filter(([bit]) => modifiers & bit).map(([, name]) => name), key].join("+"); -} /** Every action name `redragonM612EncodeButtonAction` accepts, in display order. */ export const REDRAGON_M612_BUTTON_OPTIONS: readonly string[] = [ @@ -536,3 +509,5 @@ export function redragonM612DecodeButtonAction(value: ArrayLike): string } return `Vendor action (${bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(" ")})`; } + +export * from "./m690-pro.js"; diff --git a/src/redragon/keys.ts b/src/redragon/keys.ts new file mode 100644 index 0000000..d68c677 --- /dev/null +++ b/src/redragon/keys.ts @@ -0,0 +1,45 @@ +/** + * Keyboard keys for the Redragon drivers' button bindings: the HID keyboard + * modifier bits and usage IDs (USB HID Usage Tables, keyboard page 0x07). + * Each driver wraps them in its own slot encoding (M612 `8F mods usage`, + * M690 PRO `21 mods usage 00`). Not exported from the package. + */ + +export const MODIFIER_NAMES: ReadonlyArray = [[0x01, "Ctrl"], [0x02, "Shift"], [0x04, "Alt"], [0x08, "Win"]]; + +export const KEY_USAGES: ReadonlyArray = [ + ...Array.from({ length: 26 }, (_, index) => [String.fromCharCode(65 + index), 0x04 + index] as const), + ...Array.from({ length: 9 }, (_, index) => [String(index + 1), 0x1e + index] as const), + ["0", 0x27], + ["Enter", 0x28], ["Escape", 0x29], ["Backspace", 0x2a], ["Tab", 0x2b], ["Space", 0x2c], + ["-", 0x2d], ["=", 0x2e], ["[", 0x2f], ["]", 0x30], ["\\", 0x31], [";", 0x33], ["'", 0x34], ["`", 0x35], + [",", 0x36], [".", 0x37], ["/", 0x38], ["Caps Lock", 0x39], + ...Array.from({ length: 12 }, (_, index) => [`F${index + 1}`, 0x3a + index] as const), + ["Print Screen", 0x46], ["Scroll Lock", 0x47], ["Pause", 0x48], ["Insert", 0x49], ["Home", 0x4a], + ["Page Up", 0x4b], ["Delete", 0x4c], ["End", 0x4d], ["Page Down", 0x4e], + ["Right arrow", 0x4f], ["Left arrow", 0x50], ["Down arrow", 0x51], ["Up arrow", 0x52], +]; + +/** Numeric keypad keys, offered by the M690 PRO's vendor app (Numpad 5 = 0x5d captured). */ +export const NUMPAD_USAGES: ReadonlyArray = [ + ["Num Lock", 0x53], ["Numpad /", 0x54], ["Numpad *", 0x55], ["Numpad -", 0x56], ["Numpad +", 0x57], + ["Numpad Enter", 0x58], + ...Array.from({ length: 9 }, (_, index) => [`Numpad ${index + 1}`, 0x59 + index] as const), + ["Numpad 0", 0x62], ["Numpad .", 0x63], +]; + +/** Named shortcuts: RDCfg's menu entries plus Undo/Redo. */ +export const SHORTCUTS: ReadonlyArray = [ + ["Copy", 0x01, "C"], ["Cut", 0x01, "X"], ["Paste", 0x01, "V"], ["Select all", 0x01, "A"], + ["Undo", 0x01, "Z"], ["Redo", 0x01, "Y"], ["Find", 0x01, "F"], ["New", 0x01, "N"], + ["Print", 0x01, "P"], ["Save", 0x01, "S"], + ["Switch window", 0x04, "Tab"], ["Close window", 0x04, "F4"], + ["File explorer", 0x08, "E"], ["Run", 0x08, "R"], ["Show desktop", 0x08, "D"], ["Lock PC", 0x08, "L"], +]; + +/** "Ctrl+Shift+R" for modifiers 0x03 and usage 0x15, or null for anything outside `keys`. */ +export function keyName(modifiers: number, usage: number, keys: ReadonlyArray = KEY_USAGES): string | null { + const key = keys.find(([, value]) => value === usage)?.[0]; + if (key === undefined || (modifiers & ~0x0f) !== 0) return null; + return [...MODIFIER_NAMES.filter(([bit]) => modifiers & bit).map(([, name]) => name), key].join("+"); +} diff --git a/src/redragon/m690-pro.ts b/src/redragon/m690-pro.ts new file mode 100644 index 0000000..188f585 --- /dev/null +++ b/src/redragon/m690-pro.ts @@ -0,0 +1,517 @@ +import { KEY_USAGES, NUMPAD_USAGES, SHORTCUTS, keyName } from "./keys.js"; + +/** + * Redragon M690 PRO ("MIRAGE PRO") wire format. + * + * Unlike the Holtek M612/M724 (`04d9`), the M690 PRO is a SinoWealth design + * (`258a:002e` over the cable, `258a:002f` through its 2.4 GHz receiver), + * configured by Redragon's OemDrv-based M690-PRO app v1.0. The framing matches + * libratbag's `driver-sinowealth.c` (MIT): a short command on feature report 5 + * selects what feature report 8 answers or accepts, and settings travel as one + * block that is read, modified and written back whole. The constants come + * from USBPcap captures of that app and from its installer's `Cfg.ini` and + * text table (see docs/redragon-m690-pro-testing.md); unknown bytes are + * preserved, never written with guessed values. + */ + +export interface RedragonM690ProProduct { + name: string; + connection: "Wired" | "Wireless"; + /** First command of this path's settings bank: 0x11/0x12 wired, 0x21/0x22 wireless. */ + configCommand: number; + buttonsCommand: number; + /** True only after the exact PID and path were exercised on hardware. */ + verified: boolean; +} + +export const REDRAGON_M690_PRO_VENDOR_ID = 0x258a; // SinoWealth +export const REDRAGON_M690_PRO_WIRED_PRODUCT_ID = 0x002e; +export const REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID = 0x002f; + +/** + * The cable and the receiver each keep their own settings bank; the vendor + * app reads and writes the bank of whichever PID it is connected to. + * + * Verified on mouse firmware 2.95 and 2.97 (receiver 6.05), over the cable + * and through the receiver: in Chrome on Windows (and on macOS through the + * receiver), every setting read, written and read back; through OpenMouse + * Bridge on Windows, also the battery (receiver), charging status (cable) + * and the receiver's link check, which a browser cannot read. OpenMouse's + * hardware test passed on all four paths; its polling sampler saw dropouts + * through Bridge, so there the rate was read back but not measured. + */ +export const REDRAGON_M690_PRO_PRODUCTS: ReadonlyMap = new Map([ + [REDRAGON_M690_PRO_WIRED_PRODUCT_ID, { + name: "M690 PRO", + connection: "Wired", + configCommand: 0x11, + buttonsCommand: 0x12, + verified: true, + }], + [REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID, { + name: "M690 PRO", + connection: "Wireless", + configCommand: 0x21, + buttonsCommand: 0x22, + verified: true, + }], +]); + +export const REDRAGON_M690_PRO_PRODUCT_IDS: readonly number[] = [...REDRAGON_M690_PRO_PRODUCTS.keys()]; + +/** Vendor collection on USB interface 1 carrying reports 5, 7 and 8. */ +export const REDRAGON_M690_PRO_USAGE_PAGE = 0xff00; +export const REDRAGON_M690_PRO_USAGE = 0x01; +/** 7-byte feature report: commands and their short answers. */ +export const REDRAGON_M690_PRO_COMMAND_REPORT_ID = 5; +export const REDRAGON_M690_PRO_COMMAND_LENGTH = 8; +/** 519-byte feature report: settings and button blocks. */ +export const REDRAGON_M690_PRO_BLOCK_REPORT_ID = 8; +export const REDRAGON_M690_PRO_BLOCK_LENGTH = 520; +/** 7-byte input report the mouse sends when its DPI button changes stage. */ +export const REDRAGON_M690_PRO_EVENT_REPORT_ID = 7; + +export const REDRAGON_M690_PRO_CMD_IDENTIFY = 0x01; +/** Receiver only: whether the mouse is linked (`[05 80 01 ..]`) or off/asleep (`[05 80 00 ..]`). */ +export const REDRAGON_M690_PRO_CMD_LINK = 0x80; +/** Polled every 30 s by the vendor app: connection and battery. */ +export const REDRAGON_M690_PRO_CMD_STATUS = 0x90; + +/** ASCII model id the identify command answers on both firmware revisions seen (2.95, 2.97). */ +export const REDRAGON_M690_PRO_DEVICE_ID = "2945"; + +/** + * Byte 9 of every settings block captured (every unit and firmware + * revision, cable and receiver banks); libratbag's layout puts the sensor + * type there. Browsers cannot read the identify answer (see + * `redragonM690ProIsModelBlock`), so this byte stands in for it. + */ +export const REDRAGON_M690_PRO_MODEL_BYTE = 0x13; +const OFFSET_MODEL = 0x09; + +/** Bytes of each block that are meaningful, report id included; the rest of a 520-byte write is zero. */ +export const REDRAGON_M690_PRO_CONFIG_LENGTH = 154; +export const REDRAGON_M690_PRO_BUTTONS_LENGTH = 88; +/** Byte [3] of a block write carries the block length minus 8 (0x92 settings, 0x50 buttons). */ +const WRITE_LENGTH_OFFSET = 3; +/** The vendor app appends 0xa5 after the button block when it writes it. */ +export const REDRAGON_M690_PRO_BUTTONS_TRAILER = 0xa5; + +const OFFSET_POLLING = 0x0a; +const OFFSET_STAGES = 0x0b; +const OFFSET_STAGE_DPI = 0x0d; +const OFFSET_STAGE_COLORS = 0x2d; +const OFFSET_EFFECT = 0x45; +const OFFSET_STREAMING = 0x46; +const OFFSET_STEADY = 0x48; +const OFFSET_BREATHING = 0x4c; +const OFFSET_STEADY_COLORS = 0x66; + +export const REDRAGON_M690_PRO_STAGE_COUNT = 5; +export const REDRAGON_M690_PRO_STEADY_COLOR_SLOTS = 7; + +/** + * The vendor app's DPI choices (its Cfg.ini `DPISET`). Each stage stores the + * 1-based position in this list, not a DPI: 250 -> 0x01, 500 -> 0x02, + * 3000 -> 0x0c, 8000 -> 0x18 were all captured. + */ +export const REDRAGON_M690_PRO_DPI_LABELS: readonly number[] = [ + 250, 500, 800, 1000, 1200, 1500, 1750, 2000, 2250, 2400, 2750, 3000, + 3200, 3500, 3750, 4000, 4500, 5000, 5500, 6000, 6500, 7000, 7500, 8000, +]; + +/** + * Polling code in the low nibble of byte 0x0a, as in libratbag's map for + * this framing. Each rate was confirmed by the mouse's report interval after + * a write (125/250/500 from the vendor app, 1000 from this driver). + */ +export const REDRAGON_M690_PRO_POLLING_CODES: Readonly> = { + 125: 0x01, + 250: 0x02, + 500: 0x03, + 1000: 0x04, +}; +export const REDRAGON_M690_PRO_POLLING_RATES = [125, 250, 500, 1000] as const; + +/** Effect codes at byte 0x45, named as in the vendor app's text table. */ +export const REDRAGON_M690_PRO_EFFECTS: Readonly> = { + 0x01: "Colorful Streaming", + 0x02: "Steady", + 0x03: "Breathing", +}; +export const REDRAGON_M690_PRO_EFFECT_STREAMING = 0x01; +export const REDRAGON_M690_PRO_EFFECT_STEADY = 0x02; +export const REDRAGON_M690_PRO_EFFECT_BREATHING = 0x03; +/** Brightness the vendor app's slider offers (0 is dark). */ +export const REDRAGON_M690_PRO_MAX_BRIGHTNESS = 4; + +export interface RedragonM690ProRgb { r: number; g: number; b: number } + +export interface RedragonM690ProLighting { + effect: number; + streaming: { brightness: number; speed: number }; + /** `slot` indexes `steadyColors`; the vendor app recolours the selected slot. */ + steady: { brightness: number; slot: number }; + breathing: { brightness: number; speed: number }; + steadyColors: RedragonM690ProRgb[]; +} + +export interface RedragonM690ProConfig { + pollingHz: number | null; + stageCount: number; + /** 0-based. */ + activeStage: number; + /** Bit set = stage disabled in the vendor app. */ + disabledStages: number; + /** DPI label per stage, or null for a code outside the vendor list. */ + stages: Array; + stageColors: RedragonM690ProRgb[]; + lighting: RedragonM690ProLighting; +} + +export interface RedragonM690ProStatus { + wireless: boolean; + /** Battery percent: the receiver's byte, or 100 once the cable reports the battery charged. */ + batteryPercent: number | null; + /** Over the cable only: `01` charging, `02` charged. */ + charge: "Charging" | "Full" | null; + raw: number; +} + +/** A 7-byte command frame, report id first: `[05 cmd 00 00 00 00 00 00]`. */ +export function redragonM690ProEncodeCommand(command: number): Uint8Array { + const frame = new Uint8Array(REDRAGON_M690_PRO_COMMAND_LENGTH); + frame[0] = REDRAGON_M690_PRO_COMMAND_REPORT_ID; + frame[1] = command; + return frame; +} + +/** Throws unless `reply` is report 5 answering `command`. */ +export function redragonM690ProCheckCommandReply(reply: Uint8Array, command: number): void { + if (reply.length < 4 || reply[0] !== REDRAGON_M690_PRO_COMMAND_REPORT_ID || reply[1] !== command) { + throw new Error(`The Redragon M690 PRO answered command 0x${command.toString(16)} with ${hex(reply.subarray(0, 8))}.`); + } +} + +/** `[05 01 32 39 34 35 ..]` -> "2945". */ +export function redragonM690ProDecodeIdentity(reply: Uint8Array): string { + redragonM690ProCheckCommandReply(reply, REDRAGON_M690_PRO_CMD_IDENTIFY); + return String.fromCharCode(...reply.subarray(2, 6)); +} + +/** `[05 80 01 01 ..]` linked, `[05 80 00 01 ..]` mouse off or asleep. */ +export function redragonM690ProDecodeLink(reply: Uint8Array): boolean { + redragonM690ProCheckCommandReply(reply, REDRAGON_M690_PRO_CMD_LINK); + return reply[2] === 0x01; +} + +/** + * `[05 90 11 64 ..]` through the receiver: byte 3 is the battery level, + * 0-100 (0x64 -> 0x63 seen as it drained). The vendor app showed "80 %" for + * 0x63 on a fully charged unit, so it displays coarser steps; the raw level + * is reported. Over the cable byte 3 is the charge + * state: `01` while charging, `02` once charged, when the vendor app shows + * "100 %" and the wheel LED turns green (a mouse plugged in at full charge + * answered `01` for about a minute, then `02`). + */ +export function redragonM690ProDecodeStatus(reply: Uint8Array): RedragonM690ProStatus { + redragonM690ProCheckCommandReply(reply, REDRAGON_M690_PRO_CMD_STATUS); + const wireless = (reply[2]! & 0x01) === 0x01; + const raw = reply[3]!; + if (wireless) return { wireless, batteryPercent: raw <= 100 ? raw : null, charge: null, raw }; + const charge = raw === 0x01 ? "Charging" : raw === 0x02 ? "Full" : null; + return { wireless, batteryPercent: charge === "Full" ? 100 : null, charge, raw }; +} + +/** + * Throws unless `block` is a report-8 answer to `command` long enough to hold + * `length` bytes. The settings block also ends in the vendor's `a5 00` marker. + */ +export function redragonM690ProCheckBlock(block: Uint8Array, command: number, length: number): void { + if (block.length < length || block[0] !== REDRAGON_M690_PRO_BLOCK_REPORT_ID || block[1] !== command) { + throw new Error(`The Redragon M690 PRO answered block 0x${command.toString(16)} with ${block.length} bytes starting ${hex(block.subarray(0, 8))}.`); + } + if (length === REDRAGON_M690_PRO_CONFIG_LENGTH && block[length - 2] !== 0xa5) { + throw new Error("The Redragon M690 PRO settings block lacks its a5 end marker; not using it."); + } +} + +/** + * Whether a checked settings block (`redragonM690ProCheckBlock`) is the M690 + * PRO's. The identify command's answer is the better proof, but it arrives on + * the 7-byte feature report 5, and the firmware STALLs a report-5 read unless + * it asks for exactly 8 bytes; Chrome asks for the device's largest feature + * report (520 bytes) on every read, so in a browser only report 8 answers. + */ +export function redragonM690ProIsModelBlock(block: Uint8Array): boolean { + return block.length >= REDRAGON_M690_PRO_CONFIG_LENGTH && block[OFFSET_MODEL] === REDRAGON_M690_PRO_MODEL_BYTE; +} + +function rgbAt(block: Uint8Array, offset: number): RedragonM690ProRgb { + return { r: block[offset]!, g: block[offset + 1]!, b: block[offset + 2]! }; +} + +/** Decodes a settings block (report id first, at least 154 bytes). */ +export function redragonM690ProDecodeConfig(block: Uint8Array): RedragonM690ProConfig { + const pollingCode = block[OFFSET_POLLING]! & 0x0f; + const pollingHz = REDRAGON_M690_PRO_POLLING_RATES.find((hz) => REDRAGON_M690_PRO_POLLING_CODES[hz] === pollingCode) ?? null; + const stageCount = block[OFFSET_STAGES]! & 0x0f; + const activeStage = (block[OFFSET_STAGES]! >> 4) - 1; + const stages = Array.from({ length: REDRAGON_M690_PRO_STAGE_COUNT }, (_, stage) => + REDRAGON_M690_PRO_DPI_LABELS[block[OFFSET_STAGE_DPI + 2 * stage]! - 1] ?? null); + const stageColors = Array.from({ length: REDRAGON_M690_PRO_STAGE_COUNT }, (_, stage) => + rgbAt(block, OFFSET_STAGE_COLORS + 3 * stage)); + const nibbles = (byte: number) => ({ brightness: byte >> 4, low: byte & 0x0f }); + const streaming = nibbles(block[OFFSET_STREAMING]!); + const steady = nibbles(block[OFFSET_STEADY]!); + const breathing = nibbles(block[OFFSET_BREATHING]!); + return { + pollingHz, + stageCount, + activeStage, + disabledStages: block[OFFSET_STAGES + 1]!, + stages, + stageColors, + lighting: { + effect: block[OFFSET_EFFECT]!, + streaming: { brightness: streaming.brightness, speed: streaming.low }, + steady: { brightness: steady.brightness, slot: steady.low }, + breathing: { brightness: breathing.brightness, speed: breathing.low }, + steadyColors: Array.from({ length: REDRAGON_M690_PRO_STEADY_COLOR_SLOTS }, (_, slot) => + rgbAt(block, OFFSET_STEADY_COLORS + 3 * slot)), + }, + }; +} + +function editable(block: Uint8Array, length: number): Uint8Array { + return Uint8Array.from(block.subarray(0, length)); +} + +function checkStage(stage: number): void { + if (!Number.isInteger(stage) || stage < 0 || stage >= REDRAGON_M690_PRO_STAGE_COUNT) { + throw new Error(`Redragon M690 PRO DPI stage ${stage} is outside 0-${REDRAGON_M690_PRO_STAGE_COUNT - 1}.`); + } +} + +function checkNibble(name: string, value: number, max: number): void { + if (!Number.isInteger(value) || value < 0 || value > max) { + throw new Error(`Redragon M690 PRO ${name} ${value} is outside 0-${max}.`); + } +} + +/** The DPI label nearest to `dpi`, as the vendor slider would land. */ +export function redragonM690ProNearestDpi(dpi: number): number { + if (!Number.isFinite(dpi)) throw new Error(`Redragon M690 PRO DPI ${dpi} is not a number.`); + return REDRAGON_M690_PRO_DPI_LABELS.reduce((best, label) => + Math.abs(label - dpi) < Math.abs(best - dpi) ? label : best); +} + +/** Returns a copy of `block` with the polling code replaced (flag nibble kept). */ +export function redragonM690ProWithPollingRate(block: Uint8Array, hz: number): Uint8Array { + const code = REDRAGON_M690_PRO_POLLING_CODES[hz]; + if (code === undefined) { + throw new Error(`Redragon M690 PRO polling rate ${hz} Hz is not one of ${REDRAGON_M690_PRO_POLLING_RATES.join(", ")}.`); + } + const next = editable(block, REDRAGON_M690_PRO_CONFIG_LENGTH); + next[OFFSET_POLLING] = (next[OFFSET_POLLING]! & 0xf0) | code; + return next; +} + +/** Returns a copy of `block` with one stage set to a vendor DPI label; the byte after it is kept. */ +export function redragonM690ProWithStageDpi(block: Uint8Array, stage: number, dpi: number): Uint8Array { + checkStage(stage); + const index = REDRAGON_M690_PRO_DPI_LABELS.indexOf(dpi); + if (index < 0) throw new Error(`Redragon M690 PRO DPI ${dpi} is not one of the vendor app's values.`); + const next = editable(block, REDRAGON_M690_PRO_CONFIG_LENGTH); + next[OFFSET_STAGE_DPI + 2 * stage] = index + 1; + return next; +} + +/** Returns a copy of `block` with the active stage (high nibble of 0x0b) replaced. */ +export function redragonM690ProWithActiveStage(block: Uint8Array, stage: number): Uint8Array { + checkStage(stage); + const next = editable(block, REDRAGON_M690_PRO_CONFIG_LENGTH); + next[OFFSET_STAGES] = ((stage + 1) << 4) | (next[OFFSET_STAGES]! & 0x0f); + return next; +} + +/** Returns a copy of `block` with one stage's indicator colour replaced. */ +export function redragonM690ProWithStageColor(block: Uint8Array, stage: number, rgb: RedragonM690ProRgb): Uint8Array { + checkStage(stage); + const next = editable(block, REDRAGON_M690_PRO_CONFIG_LENGTH); + next.set([rgb.r, rgb.g, rgb.b], OFFSET_STAGE_COLORS + 3 * stage); + return next; +} + +export interface RedragonM690ProLightingChange { + effect: typeof REDRAGON_M690_PRO_EFFECT_STREAMING | typeof REDRAGON_M690_PRO_EFFECT_STEADY | typeof REDRAGON_M690_PRO_EFFECT_BREATHING; + brightness?: number; + speed?: number; + /** Steady only: recolours the selected slot, as the vendor app does. */ + color?: RedragonM690ProRgb; +} + +/** + * Returns a copy of `block` with the effect selected and that effect's own + * parameter byte updated; other effects' bytes are left as stored. + */ +export function redragonM690ProWithLighting(block: Uint8Array, change: RedragonM690ProLightingChange): Uint8Array { + const next = editable(block, REDRAGON_M690_PRO_CONFIG_LENGTH); + const offset = change.effect === REDRAGON_M690_PRO_EFFECT_STREAMING ? OFFSET_STREAMING + : change.effect === REDRAGON_M690_PRO_EFFECT_STEADY ? OFFSET_STEADY + : change.effect === REDRAGON_M690_PRO_EFFECT_BREATHING ? OFFSET_BREATHING + : null; + if (offset === null) throw new Error(`Redragon M690 PRO lighting effect ${change.effect} cannot be written.`); + let brightness = next[offset]! >> 4; + let low = next[offset]! & 0x0f; + if (change.brightness !== undefined) { + checkNibble("brightness", change.brightness, REDRAGON_M690_PRO_MAX_BRIGHTNESS); + brightness = change.brightness; + } + if (change.speed !== undefined) { + if (change.effect === REDRAGON_M690_PRO_EFFECT_STEADY) throw new Error("Redragon M690 PRO Steady lighting has no speed."); + checkNibble("lighting speed", change.speed, 0x0f); + low = change.speed; + } + if (change.color !== undefined) { + if (change.effect !== REDRAGON_M690_PRO_EFFECT_STEADY) throw new Error("Only Steady lighting takes a colour on the Redragon M690 PRO."); + if (low >= REDRAGON_M690_PRO_STEADY_COLOR_SLOTS) low = 0; + next.set([change.color.r, change.color.g, change.color.b], OFFSET_STEADY_COLORS + 3 * low); + } + next[OFFSET_EFFECT] = change.effect; + next[offset] = (brightness << 4) | low; + return next; +} + +/** + * The 520-byte report-8 write the vendor app sends for a block: the block as + * read (report id first), byte [3] set to its length minus 8, an optional + * trailer byte after it, and zero padding. + */ +export function redragonM690ProEncodeBlockWrite(block: Uint8Array, length: number, trailer?: number): Uint8Array { + if (block.length < length) throw new Error(`Redragon M690 PRO block is ${block.length} bytes; expected ${length}.`); + const frame = new Uint8Array(REDRAGON_M690_PRO_BLOCK_LENGTH); + frame.set(block.subarray(0, length)); + frame[WRITE_LENGTH_OFFSET] = length - 8; + if (trailer !== undefined) frame[length] = trailer; + return frame; +} + +// --------------------------------------------------------------------------- +// Buttons: 4-byte slots from offset 8 of the button block. +// --------------------------------------------------------------------------- + +/** + * The vendor app's buttons 1-8 and the slot each one writes (Cfg.ini `Kn_1` + * last byte; button 4 -> slot 5 was captured when a macro was assigned to it). + * Names follow the factory function of each button. + */ +export const REDRAGON_M690_PRO_BUTTONS: ReadonlyArray = [ + ["Left (1)", 1], + ["Right (2)", 2], + ["Wheel click (3)", 3], + ["Forward (4)", 5], + ["Back (5)", 4], + ["DPI up (6)", 6], + ["DPI down (7)", 7], + ["Fire (8)", 8], +]; + +/** Consumer-control bits in the order of the mouse's own report-2 descriptor. */ +const MEDIA_KEYS: ReadonlyArray = [ + ["Next track", 0, 0], ["Previous track", 0, 1], ["Stop", 0, 2], ["Play/Pause", 0, 3], + ["Mute", 0, 4], ["Volume up", 0, 6], ["Volume down", 0, 7], + ["Media player", 1, 0], ["File explorer", 1, 1], ["Email", 1, 4], ["Calculator", 1, 5], + ["Web search", 2, 0], ["Web home", 2, 1], ["Web back", 2, 2], ["Web forward", 2, 3], + ["Web stop", 2, 4], ["Web refresh", 2, 5], ["Web favorites", 2, 6], +]; + +/** Actions whose slot bytes were captured from the vendor app or read from factory slots. */ +const FIXED_ACTIONS: ReadonlyArray = [ + ["Left click", [0x11, 0x01, 0x00, 0x00]], + ["Right click", [0x11, 0x02, 0x00, 0x00]], + ["Middle click", [0x11, 0x04, 0x00, 0x00]], + ["Back", [0x11, 0x08, 0x00, 0x00]], + ["Forward", [0x11, 0x10, 0x00, 0x00]], + ["DPI up", [0x41, 0x01, 0x00, 0x00]], + ["DPI down", [0x41, 0x02, 0x00, 0x00]], + ["Three click", [0x31, 0x01, 0x32, 0x03]], + ["Lighting on/off", [0x50, 0x02, 0x00, 0x00]], + ["Disabled", [0x50, 0x01, 0x00, 0x00]], +]; + +/** + * Keyboard keys are `21 modifiers usage 00` with the HID modifier bits and + * usage IDs (see ./keys.ts). Captured from the vendor app and confirmed by + * the keyboard report the mouse sent on each press: Ctrl+Shift+R `21 03 15`, + * Alt+A `21 04 04`, Win+W `21 08 1a`, and A, \, Delete, Up arrow, F12, + * Numpad 5 and Backspace with no modifier. + */ +const KEY_TYPE = 0x21; +const KEYS = [...KEY_USAGES, ...NUMPAD_USAGES]; +const usageOf = (key: string) => KEYS.find(([name]) => name === key)![1]; +const SHORTCUT_ACTIONS: ReadonlyArray = SHORTCUTS.map(([name, modifiers, key]) => + [`${name} (${keyName(modifiers, usageOf(key))})`, [KEY_TYPE, modifiers, usageOf(key), 0x00]] as const); +const KEY_ACTIONS: ReadonlyArray = KEYS.map(([name, usage]) => + [`Key ${name}`, [KEY_TYPE, 0x00, usage, 0x00]] as const); + +/** Every action `redragonM690ProEncodeButtonAction` accepts, in display order. */ +export const REDRAGON_M690_PRO_BUTTON_OPTIONS: readonly string[] = [ + ...FIXED_ACTIONS.map(([name]) => name), + ...MEDIA_KEYS.map(([name]) => name), + ...SHORTCUT_ACTIONS.map(([name]) => name), + ...KEY_ACTIONS.map(([name]) => name), +]; + +export function redragonM690ProEncodeButtonAction(action: string): number[] { + const fixed = [...FIXED_ACTIONS, ...SHORTCUT_ACTIONS, ...KEY_ACTIONS].find(([name]) => name === action); + if (fixed) return [...fixed[1]]; + const media = MEDIA_KEYS.find(([name]) => name === action); + if (media) { + const bytes = [0x22, 0x00, 0x00, 0x00]; + bytes[1 + media[1]] = 1 << media[2]; + return bytes; + } + throw new Error(`The Redragon M690 PRO has no button action "${action}".`); +} + +/** Names a 4-byte slot; anything not offered (macros, keys) decodes to its raw bytes. */ +export function redragonM690ProDecodeButtonAction(slot: ArrayLike): string { + const bytes = Array.from(slot).slice(0, 4); + const fixed = [...FIXED_ACTIONS, ...SHORTCUT_ACTIONS, ...KEY_ACTIONS].find(([, value]) => value.every((byte, index) => byte === bytes[index])); + if (fixed) return fixed[0]; + if (bytes[0] === KEY_TYPE && bytes[3] === 0x00) { + const combination = keyName(bytes[1]!, bytes[2]!, KEYS); + if (combination) return `Keys ${combination}`; + } + if (bytes[0] === 0x22) { + const media = MEDIA_KEYS.find(([, byte, bit]) => + bytes.slice(1).every((value, index) => value === (index === byte ? 1 << bit : 0))); + if (media) return media[0]; + } + if (bytes[0] === 0x70) return `Macro ${bytes[1]}`; + return `Custom (${hex(bytes)})`; +} + +function slotOffset(slot: number): number { + return 8 + 4 * (slot - 1); +} + +/** Decodes the eight vendor-app buttons from a button block. */ +export function redragonM690ProDecodeButtons(block: Uint8Array): Record { + return Object.fromEntries(REDRAGON_M690_PRO_BUTTONS.map(([name, slot]) => + [name, redragonM690ProDecodeButtonAction(block.subarray(slotOffset(slot), slotOffset(slot) + 4))])); +} + +/** Returns a copy of the button block with one named button reassigned. */ +export function redragonM690ProWithButtonAction(block: Uint8Array, button: string, action: string): Uint8Array { + const entry = REDRAGON_M690_PRO_BUTTONS.find(([name]) => name === button); + if (!entry) throw new Error(`The Redragon M690 PRO has no button named "${button}".`); + const next = editable(block, REDRAGON_M690_PRO_BUTTONS_LENGTH); + next.set(redragonM690ProEncodeButtonAction(action), slotOffset(entry[1])); + return next; +} + +function hex(bytes: ArrayLike): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" "); +}