From acf654df57e68773bfe17db0a8d818e6af322a7f Mon Sep 17 00:00:00 2001 From: robocyo Date: Tue, 29 Sep 2026 17:36:55 -0400 Subject: [PATCH] feat: add Redragon Predator M612 support Adds the Predator M612 (04d9:fc61) to the Redragon driver. It shares the M724 transport but answers F2 reads, so values are read from the mouse and every write is read back. Supports DPI stages, active stage, polling rate, onboard profiles, lighting (static, breathing, spectrum breathing, wave, off; colour, speed, brightness), and button remapping (mouse, scroll, DPI/profile/ polling/lighting actions, shortcuts, keyboard keys). All verified on hardware; evidence in docs/redragon-m612-testing.md. Co-Authored-By: Claude Opus 5.5 --- captures/redragon-m612/README.md | 12 + .../openmouse-hardware-test.json | 137 ++++++ captures/redragon-m612/rdcfg-startup-push.hex | 114 +++++ docs/redragon-m612-testing.md | 193 ++++++++ src/drivers/redragon/hid.ts | 366 ++++++++++++++- src/drivers/redragon/m612.test.ts | 437 ++++++++++++++++++ src/redragon/index.ts | 361 +++++++++++++++ 7 files changed, 1615 insertions(+), 5 deletions(-) create mode 100644 captures/redragon-m612/README.md create mode 100644 captures/redragon-m612/openmouse-hardware-test.json create mode 100644 captures/redragon-m612/rdcfg-startup-push.hex create mode 100644 docs/redragon-m612-testing.md create mode 100644 src/drivers/redragon/m612.test.ts diff --git a/captures/redragon-m612/README.md b/captures/redragon-m612/README.md new file mode 100644 index 0000000..a34f723 --- /dev/null +++ b/captures/redragon-m612/README.md @@ -0,0 +1,12 @@ +# Redragon Predator M612 (`04d9:fc61`) captures + +- `rdcfg-startup-push.hex`: the 114 config frames RDCfg 1.0.58 sent when it + started (usbmon, Wine), one `SET_FEATURE` payload per line, report id + first. It writes the LED block, opens a session (`02 f5 00`), writes + settings, then the five-profile DPI tables and button maps, the commit + block (`02 f1 02 ..`), and closes (`02 f5 01`). The one 64-byte line is + report 3. +- `openmouse-hardware-test.json`: OpenMouse's built-in hardware test on the + same unit, using the local package build. + +Decoding and verification notes: `docs/redragon-m612-testing.md`. diff --git a/captures/redragon-m612/openmouse-hardware-test.json b/captures/redragon-m612/openmouse-hardware-test.json new file mode 100644 index 0000000..c0271b2 --- /dev/null +++ b/captures/redragon-m612/openmouse-hardware-test.json @@ -0,0 +1,137 @@ +{ + "device": { + "present": true, + "brand": "Redragon", + "name": "Redragon Predator M612", + "vendorId": 1241, + "productId": 64609, + "productName": "Redragon Predator M612", + "transport": "webhid", + "connectionType": "Wired", + "pollingRateHz": 500, + "supportedPollingRates": [ + 125, + 250, + 500, + 1000 + ], + "dpi": 2000, + "dpiStages": [ + 800, + 1000, + 2000, + 3000, + 4000 + ], + "activeDpiStage": 2, + "batteryPercent": null, + "batteryState": "Unknown", + "firmware": null, + "liftOffDistance": null, + "driverFamily": "redragon", + "deviceMode": null, + "collectionsSummary": "interfaces 0x000C:0x0001, 0xFFA0: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, 0xFFA0:0x0001" + }, + { + "key": "identity", + "label": "Device identity", + "status": "pass", + "detail": "Redragon Redragon Predator M612" + }, + { + "key": "driver", + "label": "Driver identification", + "status": "pass", + "detail": "Redragon redragon" + }, + { + "key": "dpi", + "label": "DPI read-back", + "status": "pass", + "detail": "2,000 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": "800, 1,000, 2,000, 3,000, 4,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 475 Hz · peak 500 Hz · stability 95% · 2377 samples" + }, + { + "key": "flashWrite", + "label": "Flash write round-trip", + "status": "pass", + "detail": "wrote 800 DPI → read back → restored 2,000 DPI · wrote 1,000 Hz → read back → restored 500 Hz · Lift-off skipped" + } + ], + "verdict": "pass", + "durationMs": 16416.399999999907, + "runAt": "2026-09-29T19:40:01.127Z", + "build": "BETA · v2.0.1", + "supportedPage": { + "listed": true, + "status": "driver", + "label": "Driver Needed", + "matchedBy": "name", + "pageModel": "PREDATOR M612", + "detail": "listed as Driver Needed on the supported-devices page — this passing verification supports moving it to Supported." + } +} \ No newline at end of file diff --git a/captures/redragon-m612/rdcfg-startup-push.hex b/captures/redragon-m612/rdcfg-startup-push.hex new file mode 100644 index 0000000..92f7cdf --- /dev/null +++ b/captures/redragon-m612/rdcfg-startup-push.hex @@ -0,0 +1,114 @@ +02f34604020000000200000000000000 +02f358040800000081ff000002080103 +02f348040800000080ff000005080103 +02f350040800000081ff000002080303 +02f368040800000081ff000001050103 +02f360040800000081ff000004080103 +02f37004080000008000000000000000 +02f10204000000000000000000000000 +02f50000000000000000000000000000 +02f33e00020000000000000000000000 +02f33c00010000001f00000000000000 +02f34604020000000200000000000000 +02f358040800000081ff000002080103 +02f348040800000080ff000005080103 +02f350040800000081ff000002080303 +02f368040800000081ff000001050103 +02f360040800000081ff000004080103 +02f33200060000000200020002000000 +02f33800040000000200020000000000 +03f320000a0000000100010001000100010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +02f34200010000000000000000000000 +02f30201010000000100000000000000 +02f3b201010000000200000000000000 +02f36202010000000300000000000000 +02f31203010000000400000000000000 +02f10202000000000000000000000000 +02f10210000000000000000000000000 +02f3440005000000010e000e00000000 +02f34a0005000000011b001b00000000 +02f35000050000000135003500000000 +02f3560005000000014f004f00000000 +02f35c0005000000016a006a00000000 +02f3040105000000010e000e00000000 +02f30a0105000000011b001b00000000 +02f31001050000000135003500000000 +02f3160105000000014f004f00000000 +02f31c0105000000016a006a00000000 +02f3b40105000000010e000e00000000 +02f3ba0105000000011b001b00000000 +02f3c001050000000135003500000000 +02f3c60105000000014f004f00000000 +02f3cc0105000000016a006a00000000 +02f3640205000000010e000e00000000 +02f36a0205000000011b001b00000000 +02f37002050000000135003500000000 +02f3760205000000014f004f00000000 +02f37c0205000000016a006a00000000 +02f3140305000000010e000e00000000 +02f31a0305000000011b001b00000000 +02f32003050000000135003500000000 +02f3260305000000014f004f00000000 +02f32c0305000000016a006a00000000 +02f32c00020000000000000000000000 +02f38200040000008100000000000000 +02f38600040000008200000000000000 +02f38a00040000008300000000000000 +02f38e00040000009981030000000000 +02f39200040000008500000000000000 +02f39600040000008400000000000000 +02f39a00040000008a00000000000000 +02f39e00040000008900000000000000 +02f3a200040000009b08000000000000 +02f3aa00040000008b00000000000000 +02f3ae00040000008c00000000000000 +02f34201040000008100000000000000 +02f34601040000008200000000000000 +02f34a01040000008300000000000000 +02f34e01040000009981030000000000 +02f35201040000008500000000000000 +02f35601040000008400000000000000 +02f35a01040000008a00000000000000 +02f35e01040000008900000000000000 +02f36201040000009b08000000000000 +02f36a01040000008b00000000000000 +02f36e01040000008c00000000000000 +02f3f201040000008100000000000000 +02f3f601040000008200000000000000 +02f3fa01040000008300000000000000 +02f3fe01040000009981030000000000 +02f30202040000008500000000000000 +02f30602040000008400000000000000 +02f30a02040000008a00000000000000 +02f30e02040000008900000000000000 +02f31202040000009b08000000000000 +02f31a02040000008b00000000000000 +02f31e02040000008c00000000000000 +02f3a202040000008100000000000000 +02f3a602040000008200000000000000 +02f3aa02040000008300000000000000 +02f3ae02040000009981030000000000 +02f3b202040000008500000000000000 +02f3b602040000008400000000000000 +02f3ba02040000008a00000000000000 +02f3be02040000008900000000000000 +02f3c202040000009b08000000000000 +02f3ca02040000008b00000000000000 +02f3ce02040000008c00000000000000 +02f35203040000008100000000000000 +02f35603040000008200000000000000 +02f35a03040000008300000000000000 +02f35e03040000009981030000000000 +02f36203040000008500000000000000 +02f36603040000008400000000000000 +02f36a03040000008a00000000000000 +02f36e03040000008900000000000000 +02f37203040000009b08000000000000 +02f37a03040000008b00000000000000 +02f37e03040000008c00000000000000 +02f10204000000000000000000000000 +02f10201000000000000000000000000 +02f10202000000000000000000000000 +02f10208000000000000000000000000 +02f10210000000000000000000000000 +02f50100000000000000000000000000 diff --git a/docs/redragon-m612-testing.md b/docs/redragon-m612-testing.md new file mode 100644 index 0000000..29766cb --- /dev/null +++ b/docs/redragon-m612-testing.md @@ -0,0 +1,193 @@ +# Redragon Predator M612 testing notes + +Wired only (`04d9:fc61`, Holtek, bcdDevice 1.10). RDCfg calls this model +"2850". Not to be confused with the M612 PRO (`3554:f55e`, Compx), which +uses an unrelated protocol. + +## USB shape (sysfs report descriptors, Linux) + +Same three interfaces as the M724 K1NG 1K; the config channel is interface 2: + +- IF 0: boot mouse, 8-byte input `[buttons16, x16, y16, wheel, pan]` +- IF 1: boot keyboard (macros) +- IF 2: consumer control (report 1) plus vendor collection `0xFFA0:0x01` + with feature reports 2-6 and input reports 7-8. Report 2 declares 15 + bytes; a plain `GET_FEATURE` answers 8 (`02 08 .. .. 00 00 FA FA`). + +`RedragonHidClient.isSupported` therefore matches unchanged once the PID is +in `REDRAGON_PRODUCTS`. + +## Sources + +- RDCfg 1.0.58 (the Windows download on Redragon's M612 page) run under + Wine on an Xvfb display, captured with `dumpcap -i usbmon3`. Its runtime + `Config.ini` lists `PID 64609` (`0xfc61`) as `PRODUCT_NAME=2850`. +- Startup push: RDCfg writes the whole saved profile when it starts. +- GUI edits driven with `xdotool`, one Apply per change, each diffed + against the previous capture. +- hidraw probes (`HIDIOCSFEATURE`/`HIDIOCGFEATURE`) for the read command, + which RDCfg never uses. The command byte comes from dokutan/mouse_m908's + M908 reader. + +## Frame format + +The M724 notes describe writes as `[02 F3 sub profile section ...]`. On the +M612 the same bytes are an address, a length, and data: + +``` +write 02 F3 addrLo addrHi len 00 00 00 data[len]... (len <= 8) +read 02 F2 addrLo addrHi len 00 ... then GET_FEATURE +answer 02 08 addrLo 32+addrHi len 00 FA FA data[len] +``` + +- Reads work with or without the `F5 00` / `F5 01` bracket. The driver + reads without it, so status polling never opens a vendor session. +- After an answer is consumed, the next plain GET is the 8-byte header + again, so the `FA FA` probe keeps working between reads. +- Writes use the M724 envelope: `F5 00`, the write, the `F1 02` + `04 01 02 08 10` commit block, `F5 01`. + +## Settings map + +| Address | Length | Meaning | +| --- | --- | --- | +| `0x002c` | 1 | active profile, 0-4 | +| `0x0032` | 6 | `[1000/Hz, 00, 02, 00, 02, 00]`; only byte 0 changes with the rate | +| `0x0038` | 4 | `02 00 02 00`, constant across every RDCfg change tried | +| `0x003c` | 1 | enabled-stage mask (`0x1f`) | +| base `+0` | 1 | active DPI stage, 0-4 (the DPI button moves it) | +| base `+2+6n` | 5 | stage `n`: `[enabled, x, xRange, y, yRange]` | + +Profile bases: `0x42, 0x102, 0x1b2, 0x262, 0x312`. Profile 1's slots are the +M724's `0x44 0x4A 0x50 0x56 0x5C` subcommands. + +Report 3 (64 bytes) `03 F3 20 00 0A ...` carries scroll speed in its first +data byte (1 -> 3 when the RDCfg spinner moved 1 -> 3). The driver does not +write it. Pointer speed and double-click speed are Windows-only settings +and send nothing. + +## DPI + +RDCfg's slider has 146 positions, 500-8000: + +- positions 0-92: `value = 14 + p`, range 0 (500-4000) +- positions 93-145: `value = p - 39`, range 1 (4100-8000; range 1 doubles) + +Apply captures: positions 0-6 -> `0e..14`, 93 -> `36/1`, 94 -> `37/1`, +120 -> `51/1`, 144 -> `69/1`, 145 (8000) -> `6a/1`. Factory push: 500, 1000, +2000, 3000, 4000 -> `0e 1b 35 4f 6a`. + +The labels are rounded vendor values, not a formula (code 14 is labelled +500, although 4000/106 per count would give 528). The driver therefore +carries the full label table (`REDRAGON_M612_DPI_LABELS`). The table was +read from screenshots of every slider position, then checked by matching +each label's bitmap-font glyphs: one template per digit, 146/146 labels +decoded identically. Requests snap to the nearest label, and the driver +returns and reports that label. + +## Polling rate + +RDCfg's four choices each changed only byte 0 of `0x0032`: 125 -> `08`, +250 -> `04`, 500 -> `02`, 1000 -> `01`. The driver reads the 6-byte block +and rewrites byte 0 only. + +## Profiles + +Five onboard profiles; `0x2c` holds the active one (0-4). RDCfg's MODE menu +writes it immediately, **outside** any `F5` bracket: + +``` +02 F3 2C 00 02 00 00 00 [profile, 00] +02 F1 02 01 +02 F1 02 04 / 01 / 02 / 08 / 10 +``` + +DPI stages, the active stage, and button maps are per profile; polling and +lighting are global. `setProfile` replays that sequence byte for byte. + +## Lighting + +Global (not per profile). `0x446` selects the effect with one bit +(`[bit, 00]`), and each effect keeps its own 8-byte block +`[flag, R, G, B, kind, speed, ?, brightness]`: + +| RDCfg effect | bit | block | colour | speed | OpenMouse mode | +| --- | --- | --- | --- | --- | --- | +| WAVE | `01` | `0x448` | - | yes | Wave | +| 7 COLOR BREATHING | `02` | `0x450` | - | yes | Breathing random | +| BREATHING | `04` | `0x458` | yes | yes | Breathing single | +| FLASH | `08` | `0x460` | yes | yes | (none: reported as no mode) | +| FULL LIGHTED | `10` | `0x468` | yes | - | Static | +| OFF | `20` | - | - | - | Off | + +Speed byte: 8 (RDCfg's slowest slider position) to 1 (fastest); OpenMouse +shows it as 1-8 with 8 fastest. Brightness byte: RDCfg's three slider +positions, 1-3, shown as 33/67/100%. Each was mapped by changing one +control per Apply and diffing the blocks. The commit codes +`04 01 02 08 10` equal the effect bits. + +## Buttons + +Eleven 4-byte slots per profile at bases `0x82, 0x142, 0x1f2, 0x2a2, +0x352`, offsets `+0x00`...`+0x20` for RDCfg buttons 1-9 and `+0x28`/`+0x2c` +for wheel up/down (no slot at `+0x24`). Factory values, same in every +profile: left `81`, right `82`, middle `83`, fire `99 81 03`, forward `85`, +back `84`, DPI up `8a`, DPI down `89`, lighting cycle `9b 08`, scroll up +`8b`, scroll down `8c`. + +Captured from RDCfg (button 9 of profile 2, then batches on buttons 4-9): +DPI cycle `88`, profile cycle/up/down `8d`/`94`/`95`, polling up/down +`97`/`98`, disabled `00 00 00 00`. Keyboard assignments are +`[8F, modifiers, usage]` with HID modifier bits (1 Ctrl, 2 Shift, 4 Alt, +8 Win): Ctrl+V `8f 01 19`, Ctrl+A `8f 01 04`, Ctrl+F `8f 01 09`, Ctrl+N +`8f 01 11`, Alt+Tab `8f 04 2b`, Alt+F4 `8f 04 3d`, Win+E `8f 08 08`, +Win+R `8f 08 15`, Win+D `8f 08 07`, Win+L `8f 08 0f`. + +The driver offers the captured actions, RDCfg's named shortcuts (plus +Undo/Redo), and single keys (`8f 00 usage`). Any other value decodes to +its keys, or to its raw bytes, and stays shown. It refuses to remove a +profile's last left click. + +## Hardware verification status + +All on one unit, Linux, Chrome 154 (Flatpak) WebHID, OpenMouse BETA v2.0.1 +with the local package. "Pressed" results come from the mouse's own +interrupt-IN reports in usbmon captures. + +- [x] `isSupported` shape matches the sysfs descriptor. +- [x] Every `readStatus` field matches RDCfg's state: profile, active stage, + five stages, polling, lighting, and the 11 button slots. +- [x] OpenMouse's built-in hardware test passed: identity, DPI, stage and + polling read-back, flash write round-trips, and polling sampling at + 500 Hz (`captures/redragon-m612/openmouse-hardware-test.json`). +- [x] Polling applies live: interrupt-IN timing went 500 -> 250 -> 1000 Hz + right after the app's writes. +- [x] Active-stage switching from the app UI was stored, read back, and + felt. +- [x] Editing the active stage's value applies live: stage 3 went + 2000 -> 500 -> 1100 from the app. The user felt each change, and + usbmon motion shrank then grew (p99 counts per report + 9.2 -> 3.2 -> 7.1 at a steady 1000 Hz). +- [x] Profiles: 1 -> 2 -> 1 from the app, read back, and felt as the DPI + changed with the profile. +- [x] Lighting from the app, confirmed by eye: Static in three colours, Off, + Breathing single with colour and speed changes, Breathing random with + speed and brightness changes, and Wave. +- [x] Buttons from the app, each confirmed from what the button then sent: + right -> Left click (left-click reports), wheel click -> Right click + (right-click report), Key A (keyboard usage `04`), Copy (Ctrl + `06`), + Scroll down (wheel -1 per press), DPI cycle (the mouse's own report 8 + stepping through stages 4, 5, 1, 2, 3), and Disabled (no report). + All were then restored to factory. +- [x] `REDRAGON_PRODUCTS` marks `0xfc61` as verified. + +## Not decoded or not exposed + +- Macros: OpenMouse has no generic macro editor, so they stay in RDCfg. + Slots holding a macro show as a vendor action. +- RDCfg's FLASH lighting effect: no matching OpenMouse lighting mode. +- RDCfg's media and browser keys, and its Advance dialogs (fire speed and + count, sniper DPI, key combos beyond the named shortcuts): not captured. + Button 4's factory rapid fire is offered exactly as shipped. +- The enabled-stage mask at `0x3c`: the stage count stays fixed at 5. +- Report 3 beyond scroll speed. diff --git a/src/drivers/redragon/hid.ts b/src/drivers/redragon/hid.ts index 6954423..415c23d 100644 --- a/src/drivers/redragon/hid.ts +++ b/src/drivers/redragon/hid.ts @@ -1,8 +1,26 @@ -import type { MouseStatus } from "../mouse-types.js"; +import type { MouseLighting, MouseLightingMode, MouseStatus } from "../mouse-types.js"; import { REDRAGON_COMMIT_CODES, REDRAGON_CONFIG_USAGE, REDRAGON_CONFIG_USAGE_PAGE, + REDRAGON_M612_ACTIVE_PROFILE_ADDRESS, + REDRAGON_M612_BUTTON_LENGTH, + REDRAGON_M612_BUTTON_OFFSETS, + REDRAGON_M612_BUTTON_OPTIONS, + REDRAGON_M612_DPI_LABELS, + REDRAGON_M612_EFFECTS, + REDRAGON_M612_LIGHTING_BLOCK_LENGTH, + REDRAGON_M612_LIGHTING_EFFECT_ADDRESS, + REDRAGON_M612_LIGHTING_SPEEDS, + REDRAGON_M612_POLL_ADDRESS, + REDRAGON_M612_POLL_LENGTH, + REDRAGON_M612_PRODUCT_ID, + REDRAGON_M612_PROFILE_COUNT, + REDRAGON_M612_PROFILE_SELECT_CODE, + REDRAGON_M612_PROFILE_BASES, + REDRAGON_M612_SLOT_LENGTH, + REDRAGON_M612_STAGE_COUNT, + REDRAGON_POLLING_CODES, REDRAGON_POLLING_RATES, REDRAGON_PRODUCTS, REDRAGON_PRODUCT_IDS, @@ -10,10 +28,21 @@ import { REDRAGON_PROFILE0_DPI_SUBCMDS, REDRAGON_REPORT_ID, REDRAGON_VENDOR_ID, + redragonDecodeRead, redragonEncodeCommit, redragonEncodeDpiSlot, redragonEncodePollingRate, + redragonEncodeRead, + redragonEncodeWrite, redragonHello, + redragonM612ButtonAddress, + redragonM612DecodeButtonAction, + redragonM612DecodeDpi, + redragonM612EffectForBit, + redragonM612EncodeButtonAction, + redragonM612EncodeDpi, + redragonM612SlotAddress, + type RedragonM612Effect, redragonSession, } from "@openmouse/protocol/redragon"; @@ -52,6 +81,12 @@ export class RedragonHidClient { constructor(device: HIDDevice) { this.device = device; + // The driver registry builds this class for every Redragon PID. The M612 + // answers reads, so it gets a subclass with read-back and active-stage + // control; the app gates those controls on which methods exist. + if (new.target === RedragonHidClient && device.productId === REDRAGON_M612_PRODUCT_ID) { + return new RedragonM612HidClient(device); + } } static isSupported(device: HIDDevice): boolean { @@ -178,7 +213,7 @@ export class RedragonHidClient { * the last two payload bytes so both WebHID framings (report id stripped * or kept) validate. */ - private async probeConfigChannel(): Promise { + protected async probeConfigChannel(): Promise { const view = await this.device.receiveFeatureReport(REDRAGON_REPORT_ID); const echo = new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); console.debug("[redragon] feature2 echo", [...echo].map((b) => b.toString(16).padStart(2, "0")).join(" ")); @@ -190,7 +225,7 @@ export class RedragonHidClient { } } - private async sendFrame(frame: Uint8Array): Promise { + protected async sendFrame(frame: Uint8Array): Promise { await this.device.sendFeatureReport(REDRAGON_REPORT_ID, frame.slice(1).buffer as ArrayBuffer); await new Promise((resolve) => setTimeout(resolve, WRITE_DELAY_MS)); } @@ -199,7 +234,7 @@ export class RedragonHidClient { await this.sendFrame(begin ? redragonHello() : redragonSession(false)); } - private async writeSession(operation: () => Promise): Promise { + protected async writeSession(operation: () => Promise): Promise { await this.sendSessionFrame(true); try { await operation(); @@ -208,9 +243,330 @@ export class RedragonHidClient { } } - private async run(operation: () => Promise): Promise { + protected async run(operation: () => Promise): Promise { const result = this.queue.then(operation, operation); this.queue = result.then(() => undefined, () => undefined); return await result; } } + +interface M612Settings { + profile: number; + activeStage: number; + stages: number[]; + pollingHz: number; +} + + +/** RDCfg's button numbers, named for what each one is on the mouse. */ +const M612_BUTTON_NAMES = [ + "Left (1)", "Right (2)", "Wheel click (3)", "Fire (4)", "Button 5", "Button 6", + "Button 7", "Button 8", "Button 9", "Wheel up", "Wheel down", +] as const; + +/** RDCfg effects OpenMouse can show; FLASH has no matching lighting mode. */ +const M612_MODES: ReadonlyArray = [ + ["static", "Static"], + ["breathing", "Breathing single"], + ["spectrumBreathing", "Breathing random"], + ["wave", "Wave"], + ["off", "Off"], +]; +/** RDCfg's three brightness positions as percentages. */ +const M612_BRIGHTNESS = [33, 67, 100] as const; +const M612_SPEEDS = Array.from({ length: REDRAGON_M612_LIGHTING_SPEEDS }, (_, index) => index + 1); + +/** + * Redragon Predator M612 (`04d9:fc61`) WebHID control. + * + * Same Holtek transport as the M724, but the M612 answers `F2` reads (see + * `redragonEncodeRead`), so every value shown is read from the mouse and + * every write is read back before it is reported as done. Reads need no + * session bracket; writes use the vendor bracket and commit block. + * + * DPI stages live per onboard profile, so writes target whichever profile + * the mouse reports as active. DPI values snap to the positions RDCfg's + * slider offers and report its label for them. + */ +export class RedragonM612HidClient extends RedragonHidClient { + override getDpiOptions(): number[] { + return [...REDRAGON_M612_DPI_LABELS]; + } + + override async readStatus(): Promise { + return await this.run(async () => { + await this.open(); + await this.probeConfigChannel(); + const settings = await this.readSettings(); + const product = REDRAGON_PRODUCTS.get(REDRAGON_M612_PRODUCT_ID)!; + const labels = REDRAGON_M612_DPI_LABELS; + return { + brand: "Redragon", + name: `Redragon ${product.name}`, + ui: { + family: "redragon", + settingsReady: true, + valuesVerified: true, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + dpiStageEditor: { + maxStages: REDRAGON_M612_STAGE_COUNT, + countEditable: false, + minDpi: labels[0]!, + maxDpi: labels[labels.length - 1]!, + stepDpi: 10, + }, + defaultDisplayName: `Redragon ${product.name}`, + showAdvancedSection: true, + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: settings.stages[settings.activeStage]!, + dpiStages: settings.stages, + activeDpiStage: settings.activeStage, + pollingRateHz: settings.pollingHz, + supportedPollingRates: this.supportedPollingRates, + activeProfile: settings.profile + 1, + profileCount: REDRAGON_M612_PROFILE_COUNT, + buttonMappings: await this.readButtonMappings(settings.profile), + buttonOptions: [...REDRAGON_M612_BUTTON_OPTIONS], + lighting: await this.readLighting(), + connectionType: "Wired", + liftOffDistance: null, + firmware: [], + }; + }); + } + + /** + * Rewrites one stage slot of the active profile, keeping its enabled byte, + * then reads it back. Returns the DPI label the mouse now holds, which + * can differ from the request by the slider's step. + */ + override async setDpiStageValue(stage: number, dpi: number): Promise { + // Validate both before anything is sent. + const code = redragonM612EncodeDpi(dpi); + redragonM612SlotAddress(0, stage); + return await this.run(async () => { + await this.open(); + const address = redragonM612SlotAddress(await this.readActiveProfile(), stage); + const slot = await this.readBytes(address, REDRAGON_M612_SLOT_LENGTH); + await this.writeAndConfirm(address, [slot[0]!, code.value, code.range, code.value, code.range]); + return code.dpi; + }); + } + + /** Selects the active DPI stage of the active profile, as the DPI button does. */ + async setActiveDpiStage(stage: number): Promise { + if (!Number.isInteger(stage) || stage < 0 || stage >= REDRAGON_M612_STAGE_COUNT) { + throw new Error(`Redragon M612 DPI stage ${stage} is outside 0-4.`); + } + return await this.run(async () => { + await this.open(); + const base = REDRAGON_M612_PROFILE_BASES[await this.readActiveProfile()]!; + await this.writeAndConfirm(base, [stage]); + return stage; + }); + } + + /** Rewrites the polling code, keeping the block's other bytes. */ + override async setPollingRate(hz: number): Promise { + const code = REDRAGON_POLLING_CODES[hz]; + if (code === undefined) { + throw new Error(`Redragon polling rate ${hz} Hz is not offered; supported: ${REDRAGON_POLLING_RATES.join(", ")}.`); + } + return await this.run(async () => { + await this.open(); + const block = await this.readBytes(REDRAGON_M612_POLL_ADDRESS, REDRAGON_M612_POLL_LENGTH); + block[0] = code; + await this.writeAndConfirm(REDRAGON_M612_POLL_ADDRESS, [...block]); + return hz; + }); + } + + /** Switches the onboard profile (1-5), exactly as RDCfg's MODE menu does. */ + async setProfile(profile: number): Promise { + if (!Number.isInteger(profile) || profile < 1 || profile > REDRAGON_M612_PROFILE_COUNT) { + throw new Error(`Redragon M612 profile ${profile} is outside 1-${REDRAGON_M612_PROFILE_COUNT}.`); + } + await this.run(async () => { + await this.open(); + const current = await this.readBytes(REDRAGON_M612_ACTIVE_PROFILE_ADDRESS, 2); + const next = [profile - 1, current[1]!]; + await this.sendFrame(redragonEncodeWrite(REDRAGON_M612_ACTIVE_PROFILE_ADDRESS, next)); + await this.sendFrame(redragonEncodeCommit(REDRAGON_M612_PROFILE_SELECT_CODE)); + for (const code of REDRAGON_COMMIT_CODES) await this.sendFrame(redragonEncodeCommit(code)); + await this.confirm(REDRAGON_M612_ACTIVE_PROFILE_ADDRESS, next); + }); + } + + /** + * Assigns an action from `buttonOptions` to one button of the active + * profile. Refuses to take away the profile's last left click, which would + * leave the mouse unable to click. + */ + async setButtonMapping(button: string, action: string): Promise { + const slot = M612_BUTTON_NAMES.indexOf(button as (typeof M612_BUTTON_NAMES)[number]); + if (slot < 0) throw new Error(`The Redragon M612 has no button named "${button}".`); + const value = redragonM612EncodeButtonAction(action); + await this.run(async () => { + await this.open(); + const profile = await this.readActiveProfile(); + if (action !== "Left click") { + const mappings = await this.readButtonMappings(profile); + const others = Object.entries(mappings).filter(([name, assigned]) => name !== button && assigned === "Left click"); + if (mappings[button] === "Left click" && others.length === 0) { + throw new Error("Keep Left click on at least one button, or the mouse cannot click."); + } + } + await this.writeAndConfirm(redragonM612ButtonAddress(profile, slot), value); + }); + } + + /** + * Writes the effect selector and that effect's parameter block. Lighting is + * global, not per profile. A colour, speed, or brightness equal to what + * the current effect shows is treated as unchanged, so switching effects + * keeps the target effect's own stored values. + */ + async setLighting(lighting: MouseLighting): Promise { + const effect = M612_MODES.find(([, mode]) => mode === lighting.mode)?.[0]; + if (!effect) throw new Error(`The Redragon M612 has no "${lighting.mode}" lighting effect.`); + const info = REDRAGON_M612_EFFECTS.find((candidate) => candidate.effect === effect)!; + if (lighting.speed != null && !M612_SPEEDS.includes(lighting.speed)) { + throw new Error(`Redragon M612 lighting speed ${lighting.speed} is outside 1-${REDRAGON_M612_LIGHTING_SPEEDS}.`); + } + if (lighting.brightness != null && !M612_BRIGHTNESS.includes(lighting.brightness as 33)) { + throw new Error(`Redragon M612 brightness ${lighting.brightness}% is not one of ${M612_BRIGHTNESS.join(", ")}.`); + } + const rgb = lighting.color == null ? null : parseColor(lighting.color); + await this.run(async () => { + await this.open(); + const shown = await this.readLighting(); + const selector = await this.readBytes(REDRAGON_M612_LIGHTING_EFFECT_ADDRESS, 2); + const writes: Array<[number, number[]]> = []; + if (info.block !== null) { + const block = [...await this.readBytes(info.block, REDRAGON_M612_LIGHTING_BLOCK_LENGTH)]; + if (info.color && rgb && lighting.color !== shown.color) block.splice(1, 3, ...rgb); + if (info.speed && lighting.speed != null && lighting.speed !== shown.speed) { + block[5] = REDRAGON_M612_LIGHTING_SPEEDS + 1 - lighting.speed; + } + if (lighting.brightness != null && lighting.brightness !== shown.brightness) { + block[7] = M612_BRIGHTNESS.indexOf(lighting.brightness as 33) + 1; + } + writes.push([info.block, block]); + } + writes.push([REDRAGON_M612_LIGHTING_EFFECT_ADDRESS, [info.bit, selector[1]!]]); + await this.writeSession(async () => { + for (const [address, data] of writes) await this.sendFrame(redragonEncodeWrite(address, data)); + for (const code of REDRAGON_COMMIT_CODES) await this.sendFrame(redragonEncodeCommit(code)); + }); + for (const [address, data] of writes) await this.confirm(address, data); + }); + } + + private async readButtonMappings(profile: number): Promise> { + const first = redragonM612ButtonAddress(profile, 0); + const span = REDRAGON_M612_BUTTON_OFFSETS.at(-1)! + REDRAGON_M612_BUTTON_LENGTH; + const bytes = await this.readRange(first, span); + return Object.fromEntries(M612_BUTTON_NAMES.map((name, slot) => { + const offset = REDRAGON_M612_BUTTON_OFFSETS[slot]!; + return [name, redragonM612DecodeButtonAction(bytes.subarray(offset, offset + REDRAGON_M612_BUTTON_LENGTH))]; + })); + } + + private async readLighting(): Promise { + const [bit] = await this.readBytes(REDRAGON_M612_LIGHTING_EFFECT_ADDRESS, 1); + const info = redragonM612EffectForBit(bit ?? -1); + const block = info?.block != null ? await this.readBytes(info.block, REDRAGON_M612_LIGHTING_BLOCK_LENGTH) : null; + const mode = M612_MODES.find(([effect]) => effect === info?.effect)?.[1] ?? null; + const colorModes = M612_MODES.filter(([effect]) => REDRAGON_M612_EFFECTS.find((e) => e.effect === effect)!.color).map(([, m]) => m); + const speedModes = M612_MODES.filter(([effect]) => REDRAGON_M612_EFFECTS.find((e) => e.effect === effect)!.speed).map(([, m]) => m); + const lit = block !== null; + return { + zone: "Mouse", + modes: M612_MODES.map(([, m]) => m), + mode, + color: block ? `#${[...block.subarray(1, 4)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}` : null, + color2: null, + colorModes, + dualColorModes: [], + reactiveModes: speedModes, + speeds: M612_SPEEDS, + speed: block && info?.speed && block[5]! >= 1 && block[5]! <= REDRAGON_M612_LIGHTING_SPEEDS + ? REDRAGON_M612_LIGHTING_SPEEDS + 1 - block[5]! : null, + brightness: block ? (M612_BRIGHTNESS[block[7]! - 1] ?? null) : null, + // Off has no parameter block, so there is no brightness to set. + brightnessLevels: lit ? [...M612_BRIGHTNESS] : undefined, + }; + } + + private async readRange(address: number, length: number): Promise { + const out = new Uint8Array(length); + for (let offset = 0; offset < length; offset += 8) { + out.set(await this.readBytes(address + offset, Math.min(8, length - offset)), offset); + } + return out; + } + + private async readSettings(): Promise { + const profile = await this.readActiveProfile(); + const base = REDRAGON_M612_PROFILE_BASES[profile]!; + const [activeStage] = await this.readBytes(base, 1); + if (activeStage === undefined || activeStage >= REDRAGON_M612_STAGE_COUNT) { + throw new Error(`The Redragon M612 reported DPI stage ${activeStage}; expected 0-4.`); + } + const stages: number[] = []; + for (let stage = 0; stage < REDRAGON_M612_STAGE_COUNT; stage++) { + const slot = await this.readBytes(redragonM612SlotAddress(profile, stage), REDRAGON_M612_SLOT_LENGTH); + stages.push(redragonM612DecodeDpi(slot[1]!, slot[2]!)); + } + const [pollCode] = await this.readBytes(REDRAGON_M612_POLL_ADDRESS, 1); + const pollingHz = REDRAGON_POLLING_RATES.find((hz) => REDRAGON_POLLING_CODES[hz] === pollCode); + if (pollingHz === undefined) { + throw new Error(`The Redragon M612 reported polling code ${pollCode}; expected 1, 2, 4, or 8.`); + } + return { profile, activeStage, stages, pollingHz }; + } + + private async readActiveProfile(): Promise { + const [profile] = await this.readBytes(REDRAGON_M612_ACTIVE_PROFILE_ADDRESS, 1); + if (profile === undefined || profile >= REDRAGON_M612_PROFILE_BASES.length) { + throw new Error(`The Redragon M612 reported profile ${profile}; expected 0-4.`); + } + return profile; + } + + private async readBytes(address: number, length: number): Promise { + await this.sendFrame(redragonEncodeRead(address, length)); + const view = await this.device.receiveFeatureReport(REDRAGON_REPORT_ID); + const answer = new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); + return redragonDecodeRead(answer, address, length); + } + + /** One write inside the vendor bracket + commit block, then a read-back. */ + private async writeAndConfirm(address: number, data: number[]): Promise { + await this.writeSession(async () => { + await this.sendFrame(redragonEncodeWrite(address, data)); + for (const code of REDRAGON_COMMIT_CODES) { + await this.sendFrame(redragonEncodeCommit(code)); + } + }); + await this.confirm(address, data); + } + + /** Reads `data.length` bytes back and throws unless they match. */ + private async confirm(address: number, data: readonly number[]): Promise { + const confirmed = await this.readBytes(address, data.length); + if (confirmed.some((byte, index) => byte !== data[index])) { + const hex = (bytes: ArrayLike) => Array.from(bytes).map((byte) => byte.toString(16).padStart(2, "0")).join(" "); + throw new Error(`The Redragon M612 kept ${hex(confirmed)} at 0x${address.toString(16)} instead of ${hex(data)}.`); + } + } +} + +function parseColor(color: string): [number, number, number] { + const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(color); + if (!match) throw new Error(`Redragon M612 colour "${color}" is not #rrggbb.`); + return [parseInt(match[1]!, 16), parseInt(match[2]!, 16), parseInt(match[3]!, 16)]; +} diff --git a/src/drivers/redragon/m612.test.ts b/src/drivers/redragon/m612.test.ts new file mode 100644 index 0000000..aeb8e50 --- /dev/null +++ b/src/drivers/redragon/m612.test.ts @@ -0,0 +1,437 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + REDRAGON_M612_BUTTON_OPTIONS, + redragonM612DecodeButtonAction, + redragonM612EncodeButtonAction, + REDRAGON_CONFIG_USAGE, + REDRAGON_CONFIG_USAGE_PAGE, + REDRAGON_M612_DPI_LABELS, + REDRAGON_M612_PRODUCT_ID, + REDRAGON_PRODUCTS, + REDRAGON_REPORT_ID, + redragonDecodeRead, + redragonEncodeRead, + redragonEncodeWrite, + redragonM612DecodeDpi, + redragonM612DpiCodeAt, + redragonM612EncodeDpi, + redragonM612SlotAddress, +} from "@openmouse/protocol/redragon"; +import { RedragonHidClient, RedragonM612HidClient } from "./hid.ts"; + +const hex = (text: string): number[] => text.match(/../g)!.map((byte) => parseInt(byte, 16)); + +/** + * The factory settings memory RDCfg 1.0.58 pushes on startup (usbmon + * capture): every profile holds 500/1000/2000/3000/4000, polling 500 Hz. + */ +function factoryMemory(): Uint8Array { + const memory = new Uint8Array(0x500); + memory.set([0x02, 0x00, 0x02, 0x00, 0x02, 0x00], 0x32); + for (const base of [0x42, 0x102, 0x1b2, 0x262, 0x312]) { + [0x0e, 0x1b, 0x35, 0x4f, 0x6a].forEach((value, stage) => { + memory.set([0x01, value, 0x00, value, 0x00], base + 2 + 6 * stage); + }); + } + // Factory button layout (identical in every profile) and lighting. + const buttons = ["81", "82", "83", "998103", "85", "84", "8a", "89", "9b08", "8b", "8c"]; + for (const base of [0x82, 0x142, 0x1f2, 0x2a2, 0x352]) { + [0, 4, 8, 12, 16, 20, 24, 28, 32, 40, 44].forEach((offset, slot) => memory.set(hex(buttons[slot]!), base + offset)); + } + memory.set([0x02, 0x00], 0x446); + memory.set(hex("80ff000005080103"), 0x448); + memory.set(hex("81ff000002080303"), 0x450); + memory.set(hex("81ff000002080103"), 0x458); + memory.set(hex("81ff000004080103"), 0x460); + memory.set(hex("81ff000001050103"), 0x468); + return memory; +} + +/** + * Settings-memory model of the M612 config channel, with the answers the + * hardware gives: `F2` reads answer on the next GET_FEATURE with + * `[08 lo 32+hi len 00 FA FA data]`; a plain GET answers the 7-byte header. + * Writes outside the `F5 00` / `F5 01` bracket are rejected, except the + * profile byte RDCfg's MODE select writes unbracketed. + */ +class FakeM612 { + vendorId = 0x04d9; + productId = REDRAGON_M612_PRODUCT_ID; + productName = "USB Gaming Mouse"; + opened = false; + collections: HIDCollectionInfo[] = [{ + usagePage: REDRAGON_CONFIG_USAGE_PAGE, + usage: REDRAGON_CONFIG_USAGE, + children: [], + featureReports: [{ reportId: REDRAGON_REPORT_ID, items: [] }], + inputReports: [], + outputReports: [], + }]; + memory = factoryMemory(); + readonly sent: number[][] = []; + inSession = false; + ignoreWrites = false; + failOnSend: number | null = null; + private pending: number[] | null = null; + + async open(): Promise { this.opened = true; } + async close(): Promise { this.opened = false; } + + async sendFeatureReport(reportId: number, data: BufferSource): Promise { + assert.equal(reportId, REDRAGON_REPORT_ID); + const bytes = [...new Uint8Array(data as ArrayBuffer)]; + this.sent.push(bytes); + if (this.sent.length === this.failOnSend) throw new Error("simulated transport failure"); + const [command, lo = 0, hi = 0, length = 0] = bytes; + const address = lo | (hi << 8); + if (command === 0xf5) { + this.inSession = bytes[1] === 0x00; + } else if (command === 0xf3) { + // RDCfg's MODE select writes the profile byte without a bracket. + if (!this.inSession && address !== 0x2c) throw new Error("write outside the F5 bracket"); + if (!this.ignoreWrites) this.memory.set(bytes.slice(7, 7 + length), address); + } else if (command === 0xf1) { + // Commits are accepted either way: RDCfg's MODE select sends them unbracketed. + } else if (command === 0xf2) { + this.pending = [REDRAGON_REPORT_ID, 0x08, lo, 0x32 + hi, length, 0x00, 0xfa, 0xfa, ...this.memory.slice(address, address + length)]; + } else { + throw new Error(`unexpected command ${command}`); + } + } + + async receiveFeatureReport(reportId: number): Promise { + assert.equal(reportId, REDRAGON_REPORT_ID); + const answer = new Uint8Array(this.pending ?? [REDRAGON_REPORT_ID, 0x08, 0x40, 0x32, 0x00, 0x00, 0xfa, 0xfa]); + this.pending = null; + return new DataView(answer.buffer); + } + + /** Writes only, as `[cmd, ...]` hex strings without trailing zeros. */ + writes(): string[] { + return this.sent + .filter((bytes) => bytes[0] !== 0xf2) + .map((bytes) => bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("").replace(/(00)+$/, "")); + } +} + +const asDevice = (fake: FakeM612): HIDDevice => fake as unknown as HIDDevice; +const m612 = (fake = new FakeM612()) => new RedragonHidClient(asDevice(fake)) as RedragonM612HidClient; + +test("M612 catalog entry and slider table", () => { + const product = REDRAGON_PRODUCTS.get(0xfc61); + assert.equal(product?.model, "M612"); + assert.equal(product?.maxDpi, 8000); + assert.equal(REDRAGON_M612_DPI_LABELS.length, 146); + for (let index = 1; index < REDRAGON_M612_DPI_LABELS.length; index++) { + assert.ok(REDRAGON_M612_DPI_LABELS[index]! > REDRAGON_M612_DPI_LABELS[index - 1]!, `label ${index} ascends`); + } +}); + +test("slider positions give the codes RDCfg applied (usbmon)", () => { + const captured: Array<[number, number, 0 | 1, number]> = [ + // position, value, range, RDCfg label + [0, 0x0e, 0, 500], [1, 0x0f, 0, 570], [2, 0x10, 0, 600], [3, 0x11, 0, 640], + [4, 0x12, 0, 680], [5, 0x13, 0, 700], [6, 0x14, 0, 760], + [13, 0x1b, 0, 1000], [39, 0x35, 0, 2000], [65, 0x4f, 0, 3000], [92, 0x6a, 0, 4000], + [93, 0x36, 1, 4100], [94, 0x37, 1, 4180], [120, 0x51, 1, 6150], [144, 0x69, 1, 7980], [145, 0x6a, 1, 8000], + ]; + for (const [position, value, range, label] of captured) { + assert.deepEqual(redragonM612DpiCodeAt(position), { value, range }, `position ${position}`); + assert.equal(REDRAGON_M612_DPI_LABELS[position], label, `label ${position}`); + assert.equal(redragonM612DecodeDpi(value, range), label); + assert.deepEqual(redragonM612EncodeDpi(label), { dpi: label, value, range }); + } + assert.throws(() => redragonM612DpiCodeAt(146), /outside/); +}); + +test("DPI requests snap to the nearest slider label", () => { + assert.equal(redragonM612EncodeDpi(550).dpi, 570); + assert.equal(redragonM612EncodeDpi(535).dpi, 500, "ties go to the lower label"); + assert.equal(redragonM612EncodeDpi(1234).dpi, 1250); + assert.equal(redragonM612EncodeDpi(8000).dpi, 8000); + assert.throws(() => redragonM612EncodeDpi(499), /outside/); + assert.throws(() => redragonM612EncodeDpi(8001), /outside/); + assert.throws(() => redragonM612EncodeDpi(1000.5), /outside/); +}); + +test("codes outside the slider decode on the linear scale", () => { + assert.equal(redragonM612DecodeDpi(0x0a, 0), 380); + assert.equal(redragonM612DecodeDpi(0x80, 0), 4830); + assert.equal(redragonM612DecodeDpi(0x20, 1), 2420); +}); + +test("write and read frames match the capture", () => { + assert.deepEqual([...redragonEncodeWrite(0x44, [0x01, 0x0e, 0x00, 0x0e, 0x00])], hex("02f3440005000000010e000e00000000")); + assert.deepEqual([...redragonEncodeWrite(0x0314, [0x01, 0x6a, 0x00, 0x6a, 0x00])], hex("02f3140305000000016a006a00000000")); + assert.deepEqual([...redragonEncodeWrite(0x32, [0x08, 0x00, 0x02, 0x00, 0x02, 0x00])], hex("02f33200060000000800020002000000")); + assert.deepEqual([...redragonEncodeRead(0x0104, 5)], hex("02f20401050000000000000000000000")); + assert.throws(() => redragonEncodeWrite(0x44, []), /length/); + assert.throws(() => redragonEncodeRead(0x44, 9), /length/); + assert.throws(() => redragonEncodeWrite(0x44, [256]), /byte/); +}); + +test("read answers decode with or without the report id", () => { + const answer = new Uint8Array(hex("0208043305" + "00fafa" + "010e000e00")); + assert.deepEqual([...redragonDecodeRead(answer, 0x0104, 5)], [0x01, 0x0e, 0x00, 0x0e, 0x00]); + assert.deepEqual([...redragonDecodeRead(answer.subarray(1), 0x0104, 5)], [0x01, 0x0e, 0x00, 0x0e, 0x00]); + assert.throws(() => redragonDecodeRead(answer, 0x0004, 5), /unexpected answer/, "high address byte"); + assert.throws(() => redragonDecodeRead(answer, 0x0104, 6), /unexpected answer/, "length"); + assert.throws(() => redragonDecodeRead(new Uint8Array(hex("0208043305000000010e000e00")), 0x0104, 5), /unexpected answer/, "marker"); + assert.throws(() => redragonDecodeRead(new Uint8Array(hex("02084032000000fafa")), 0x0104, 5), /unexpected answer/, "plain echo"); +}); + +test("slot addresses follow the captured per-profile bases", () => { + assert.equal(redragonM612SlotAddress(0, 0), 0x44); + assert.equal(redragonM612SlotAddress(0, 4), 0x5c); + assert.equal(redragonM612SlotAddress(1, 0), 0x104); + assert.equal(redragonM612SlotAddress(4, 4), 0x32c); + assert.throws(() => redragonM612SlotAddress(5, 0), /profile/); + assert.throws(() => redragonM612SlotAddress(0, 5), /stage/); +}); + +test("the registry's constructor hands the M612 its own client, and only it", () => { + const client = new RedragonHidClient(asDevice(new FakeM612())); + assert.ok(client instanceof RedragonM612HidClient); + assert.equal(typeof (client as RedragonM612HidClient).setActiveDpiStage, "function"); + const m724 = new FakeM612(); + m724.productId = 0xfc7a; + const k1ng = new RedragonHidClient(asDevice(m724)); + assert.ok(!(k1ng instanceof RedragonM612HidClient)); + assert.equal((k1ng as unknown as Record).setActiveDpiStage, undefined); + assert.equal(RedragonHidClient.isSupported(asDevice(new FakeM612())), true); +}); + +test("readStatus reports the mouse's own values as verified", async () => { + const fake = new FakeM612(); + fake.memory[0x42] = 2; + const status = await m612(fake).readStatus(); + assert.equal(status.name, "Redragon Predator M612"); + assert.deepEqual(status.dpiStages, [500, 1000, 2000, 3000, 4000]); + assert.equal(status.activeDpiStage, 2); + assert.equal(status.dpi, 2000); + assert.equal(status.pollingRateHz, 500); + assert.equal(status.activeProfile, 1); + assert.equal(status.ui?.valuesVerified, true); + assert.deepEqual(status.ui?.dpiStageEditor, { maxStages: 5, countEditable: false, minDpi: 500, maxDpi: 8000, stepDpi: 10 }); + assert.equal(status.ui?.statusNote, undefined); + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000]); + assert.deepEqual(fake.writes(), [], "reading sends no writes or session frames"); +}); + +test("readStatus follows the active profile", async () => { + const fake = new FakeM612(); + fake.memory[0x2c] = 3; + fake.memory[0x262] = 4; + fake.memory.set([0x01, 0x6a, 0x01, 0x6a, 0x01], 0x262 + 2 + 6 * 4); + const status = await m612(fake).readStatus(); + assert.equal(status.activeProfile, 4); + assert.equal(status.activeDpiStage, 4); + assert.equal(status.dpi, 8000); +}); + +test("readStatus rejects values the firmware never holds", async () => { + const badProfile = new FakeM612(); + badProfile.memory[0x2c] = 7; + await assert.rejects(() => m612(badProfile).readStatus(), /profile 7/); + const badPoll = new FakeM612(); + badPoll.memory[0x32] = 3; + await assert.rejects(() => m612(badPoll).readStatus(), /polling code 3/); +}); + +test("setDpiStageValue writes one slot of the active profile and reads it back", async () => { + const fake = new FakeM612(); + fake.memory[0x2c] = 1; + fake.memory[0x102 + 2 + 6] = 0x00; // stage 2 disabled: the enabled byte must survive + const client = m612(fake); + assert.equal(await client.setDpiStageValue(1, 1234), 1250); + assert.deepEqual(fake.writes(), [ + "f5", + "f30a010500000000210021", + "f10204", "f10201", "f10202", "f10208", "f10210", + "f501", + ]); + assert.deepEqual([...fake.memory.slice(0x10a, 0x10f)], [0x00, 0x21, 0x00, 0x21, 0x00]); + assert.deepEqual((await client.readStatus()).dpiStages, [500, 1250, 2000, 3000, 4000]); +}); + +test("setDpiStageValue validates before touching the mouse", async () => { + const fake = new FakeM612(); + const client = m612(fake); + await assert.rejects(() => client.setDpiStageValue(5, 800), /stage/); + await assert.rejects(() => client.setDpiStageValue(0, 9000), /outside/); + assert.equal(fake.sent.length, 0); +}); + +test("setActiveDpiStage moves the active profile's stage pointer", async () => { + const fake = new FakeM612(); + const client = m612(fake); + assert.equal(await client.setActiveDpiStage(4), 4); + assert.deepEqual(fake.writes(), ["f5", "f342000100000004", "f10204", "f10201", "f10202", "f10208", "f10210", "f501"]); + const status = await client.readStatus(); + assert.equal(status.activeDpiStage, 4); + assert.equal(status.dpi, 4000); + await assert.rejects(() => client.setActiveDpiStage(5), /stage/); +}); + +test("setPollingRate rewrites only the rate byte of the block", async () => { + const fake = new FakeM612(); + fake.memory.set([0x02, 0x00, 0x03, 0x00, 0x05, 0x00], 0x32); + const client = m612(fake); + assert.equal(await client.setPollingRate(125), 125); + assert.deepEqual([...fake.memory.slice(0x32, 0x38)], [0x08, 0x00, 0x03, 0x00, 0x05, 0x00]); + assert.equal((await client.readStatus()).pollingRateHz, 125); + await assert.rejects(() => client.setPollingRate(2000), /not offered/); +}); + +test("a write the mouse does not keep is reported, not assumed", async () => { + const fake = new FakeM612(); + fake.ignoreWrites = true; + await assert.rejects(() => m612(fake).setPollingRate(1000), /kept 02 00 02 00 02 00/); + await assert.rejects(() => m612(fake).setDpiStageValue(0, 800), /kept/); +}); + +test("a failed write still closes the vendor session", async () => { + const fake = new FakeM612(); + const client = m612(fake); + // Reads: profile + slot (2 frames); then F5 00, the slot write fails. + fake.failOnSend = 4; + await assert.rejects(() => client.setDpiStageValue(0, 800), /simulated transport failure/); + assert.deepEqual(fake.sent.at(-1), [0xf5, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + assert.equal(fake.inSession, false); +}); + +test("button actions encode as captured and every option round-trips", () => { + const captured: Array<[string, string]> = [ + ["Left click", "81000000"], ["Back", "84000000"], ["Forward", "85000000"], ["Rapid fire", "99810300"], + ["DPI cycle", "88000000"], ["DPI up", "8a000000"], ["DPI down", "89000000"], + ["Profile cycle", "8d000000"], ["Profile up", "94000000"], ["Profile down", "95000000"], + ["Polling rate up", "97000000"], ["Polling rate down", "98000000"], + ["Lighting effect cycle", "9b080000"], ["Disabled", "00000000"], + ["Paste (Ctrl+V)", "8f011900"], ["Select all (Ctrl+A)", "8f010400"], ["Find (Ctrl+F)", "8f010900"], + ["New (Ctrl+N)", "8f011100"], ["Switch window (Alt+Tab)", "8f042b00"], ["Close window (Alt+F4)", "8f043d00"], + ["File explorer (Win+E)", "8f080800"], ["Run (Win+R)", "8f081500"], ["Show desktop (Win+D)", "8f080700"], + ["Lock PC (Win+L)", "8f080f00"], + ]; + for (const [action, bytes] of captured) { + assert.deepEqual(redragonM612EncodeButtonAction(action), hex(bytes), action); + assert.equal(redragonM612DecodeButtonAction(hex(bytes)), action); + } + assert.deepEqual(redragonM612EncodeButtonAction("Key A"), hex("8f000400")); + assert.deepEqual(redragonM612EncodeButtonAction("Copy (Ctrl+C)"), hex("8f010600")); + assert.equal(new Set(REDRAGON_M612_BUTTON_OPTIONS).size, REDRAGON_M612_BUTTON_OPTIONS.length, "options are unique"); + for (const option of REDRAGON_M612_BUTTON_OPTIONS) { + assert.equal(redragonM612DecodeButtonAction(redragonM612EncodeButtonAction(option)), option); + } + assert.equal(redragonM612DecodeButtonAction(hex("8f030400")), "Keys Ctrl+Shift+A"); + assert.equal(redragonM612DecodeButtonAction(hex("a1020000")), "Vendor action (a1 02 00 00)"); + assert.throws(() => redragonM612EncodeButtonAction("Launch rockets"), /not offered/); +}); + +test("readStatus reports profiles, buttons, and lighting from the mouse", async () => { + const status = await m612().readStatus(); + assert.equal(status.profileCount, 5); + assert.equal(status.ui?.showAdvancedSection, true); + assert.deepEqual(status.buttonMappings, { + "Left (1)": "Left click", "Right (2)": "Right click", "Wheel click (3)": "Middle click", "Fire (4)": "Rapid fire", + "Button 5": "Forward", "Button 6": "Back", "Button 7": "DPI up", "Button 8": "DPI down", + "Button 9": "Lighting effect cycle", "Wheel up": "Scroll up", "Wheel down": "Scroll down", + }); + assert.deepEqual(status.buttonOptions, [...REDRAGON_M612_BUTTON_OPTIONS]); + const lighting = status.lighting!; + assert.equal(lighting.mode, "Breathing random"); + assert.deepEqual(lighting.modes, ["Static", "Breathing single", "Breathing random", "Wave", "Off"]); + assert.deepEqual(lighting.colorModes, ["Static", "Breathing single"]); + assert.deepEqual(lighting.reactiveModes, ["Breathing single", "Breathing random", "Wave"]); + assert.equal(lighting.speed, 1, "wire 08 is the slowest"); + assert.equal(lighting.brightness, 100); + assert.deepEqual(lighting.brightnessLevels, [33, 67, 100]); +}); + +test("lighting OFF hides brightness and FLASH is reported as no mode", async () => { + const off = new FakeM612(); + off.memory[0x446] = 0x20; + const offLighting = (await m612(off).readStatus()).lighting!; + assert.equal(offLighting.mode, "Off"); + assert.equal(offLighting.brightnessLevels, undefined); + const flash = new FakeM612(); + flash.memory[0x446] = 0x08; + assert.equal((await m612(flash).readStatus()).lighting!.mode, null); +}); + +test("setProfile replays RDCfg's MODE select and the status follows", async () => { + const fake = new FakeM612(); + fake.memory[0x1b2 + 2 + 6 * 1] = 0x01; + fake.memory.set([0x01, 0x21, 0x00, 0x21, 0x00], 0x1b2 + 2 + 6 * 0); + const client = m612(fake); + await client.setProfile(3); + assert.deepEqual(fake.writes(), ["f32c000200000002", "f10201", "f10204", "f10201", "f10202", "f10208", "f10210"]); + const status = await client.readStatus(); + assert.equal(status.activeProfile, 3); + assert.equal(status.dpiStages![0], 1250); + await assert.rejects(() => client.setProfile(0), /outside/); + await assert.rejects(() => client.setProfile(6), /outside/); +}); + +test("setButtonMapping writes the active profile's slot and reads it back", async () => { + const fake = new FakeM612(); + fake.memory[0x2c] = 1; + const client = m612(fake); + await client.setButtonMapping("Button 5", "Copy (Ctrl+C)"); + assert.deepEqual(fake.writes(), ["f5", "f35201040000008f0106", "f10204", "f10201", "f10202", "f10208", "f10210", "f501"]); + assert.equal((await client.readStatus()).buttonMappings!["Button 5"], "Copy (Ctrl+C)"); + await client.setButtonMapping("Wheel down", "Key Page Down"); + assert.deepEqual([...fake.memory.slice(0x16e, 0x172)], [0x8f, 0x00, 0x4e, 0x00]); + await assert.rejects(() => client.setButtonMapping("Button 12", "Back"), /no button/); + await assert.rejects(() => client.setButtonMapping("Button 5", "Teleport"), /not offered/); +}); + +test("the last left click cannot be remapped away", async () => { + const fake = new FakeM612(); + const client = m612(fake); + await assert.rejects(() => client.setButtonMapping("Left (1)", "Back"), /Keep Left click/); + assert.deepEqual(fake.writes(), []); + await client.setButtonMapping("Button 6", "Left click"); + await client.setButtonMapping("Left (1)", "Back"); + const mappings = (await client.readStatus()).buttonMappings!; + assert.equal(mappings["Left (1)"], "Back"); + assert.equal(mappings["Button 6"], "Left click"); +}); + +test("setLighting writes the effect block and selector in one session", async () => { + const fake = new FakeM612(); + const client = m612(fake); + const shown = (await client.readStatus()).lighting!; + // Effect change only: Static keeps its own stored red and brightness. + await client.setLighting({ ...shown, mode: "Static" }); + assert.deepEqual(fake.writes(), ["f5", "f368040800000081ff000001050103", "f346040200000010", "f10204", "f10201", "f10202", "f10208", "f10210", "f501"]); + let lighting = (await client.readStatus()).lighting!; + assert.equal(lighting.mode, "Static"); + assert.equal(lighting.color, "#ff0000"); + // Colour and brightness on Static. + await client.setLighting({ ...lighting, color: "#00ff80", brightness: 33 }); + assert.deepEqual([...fake.memory.slice(0x468, 0x470)], hex("8100ff8001050101")); + // Speed on an animated effect: UI 8 (fastest) is wire 01. + lighting = (await client.readStatus()).lighting!; + await client.setLighting({ ...lighting, mode: "Wave", speed: 8 }); + assert.equal(fake.memory[0x446], 0x01); + assert.equal(fake.memory[0x448 + 5], 0x01); + // Off writes only the selector. + lighting = (await client.readStatus()).lighting!; + const before = fake.sent.length; + await client.setLighting({ ...lighting, mode: "Off" }); + assert.ok(fake.writes().slice(-8).includes("f346040200000020")); + assert.ok(fake.sent.length > before); + assert.equal((await client.readStatus()).lighting!.mode, "Off"); +}); + +test("setLighting rejects what the mouse cannot show", async () => { + const client = m612(); + const shown = (await client.readStatus()).lighting!; + await assert.rejects(() => client.setLighting({ ...shown, mode: "Spectrum" }), /no "Spectrum"/); + await assert.rejects(() => client.setLighting({ ...shown, mode: null }), /no "null"/); + await assert.rejects(() => client.setLighting({ ...shown, speed: 9 }), /speed/); + await assert.rejects(() => client.setLighting({ ...shown, brightness: 50 }), /brightness/); + await assert.rejects(() => client.setLighting({ ...shown, mode: "Static", color: "red" }), /#rrggbb/); +}); diff --git a/src/redragon/index.ts b/src/redragon/index.ts index 65cd364..f45dba3 100644 --- a/src/redragon/index.ts +++ b/src/redragon/index.ts @@ -41,6 +41,14 @@ export const REDRAGON_PRODUCTS: ReadonlyMap = new Map([ stages: 5, maxDpi: 12400, }], + [0xfc61, { + model: "M612", + name: "Predator M612", + transport: "wired", + verified: true, + stages: 5, + maxDpi: 8000, + }], ]); export const REDRAGON_PRODUCT_IDS: readonly number[] = [...REDRAGON_PRODUCTS.keys()]; @@ -175,3 +183,356 @@ export function redragonEncodePollingRate(hz: number): Uint8Array { frame[12] = 0x01; return frame; } + +/* + * Predator M612 (`04d9:fc61`), captured with usbmon while RDCfg 1.0.58 ran + * under Wine, then probed with hidraw (see docs/redragon-m612-testing.md). + * + * The M612 shares the M724 transport (feature report 2, `F5` bracket, `F1` + * commit block) but its bytes are best read as a flat settings memory: + * `[02 F3 addrLo addrHi len 00 00 00 data...]` writes `len` bytes at + * `addr`. Unlike the M724, it also answers reads: `[02 F2 addrLo addrHi len]` + * followed by a GET_FEATURE returns `[02 08 addrLo 32+addrHi len 00 FA FA + * data...]`, at most 8 data bytes per read. + */ + +export const REDRAGON_M612_PRODUCT_ID = 0xfc61; +/** First payload byte of a settings read (report id excluded). */ +export const REDRAGON_CMD_READ = 0xf2; +/** First payload byte of every GET_FEATURE answer (report id excluded). */ +export const REDRAGON_REPLY_TAG = 0x08; +/** Length of the answer header before the data bytes (report id excluded). */ +export const REDRAGON_REPLY_HEADER = 7; +/** Most data bytes one read or write frame carries. */ +export const REDRAGON_MAX_TRANSFER = 8; + +/** Active onboard profile, 0-4. */ +export const REDRAGON_M612_ACTIVE_PROFILE_ADDRESS = 0x002c; +/** Polling block: `[1000 / Hz, 00, 02, 00, 02, 00]` (last four constant). */ +export const REDRAGON_M612_POLL_ADDRESS = 0x0032; +export const REDRAGON_M612_POLL_LENGTH = 6; +/** + * Per-profile block bases. `base + 0` is the active DPI stage (0-4, moved + * by the DPI button); stage slot `n` is the 5 bytes at `base + 2 + 6n`: + * `[enabled, x, xRange, y, yRange]`. + */ +export const REDRAGON_M612_PROFILE_BASES = [0x0042, 0x0102, 0x01b2, 0x0262, 0x0312] as const; +export const REDRAGON_M612_STAGE_COUNT = 5; +export const REDRAGON_M612_SLOT_LENGTH = 5; + +/** + * DPI labels RDCfg shows for each position of its DPI slider, read from the + * running vendor app and cross-checked by matching its bitmap-font glyphs. + * Position `p` 0-92 writes `value = 14 + p, range 0`; 93-145 write + * `value = p - 39, range 1` (range 1 doubles the step). The rule was + * confirmed by Apply captures at positions 0-6, 93, 94, 120, 144, and 145, + * and by the factory push (13, 39, 65, 92). The labels are the vendor's + * rounded values, so they are the DPI OpenMouse shows for the same bytes. + */ +export const REDRAGON_M612_DPI_LABELS: readonly number[] = [ + 500, 570, 600, 640, 680, 700, 760, 800, + 830, 870, 900, 950, 980, 1000, 1060, 1100, + 1140, 1170, 1200, 1250, 1300, 1330, 1360, 1400, + 1440, 1480, 1500, 1550, 1600, 1630, 1670, 1700, + 1740, 1780, 1800, 1860, 1900, 1930, 1970, 2000, + 2050, 2090, 2100, 2160, 2200, 2240, 2280, 2300, + 2350, 2400, 2430, 2470, 2500, 2540, 2580, 2600, + 2660, 2700, 2730, 2770, 2800, 2850, 2880, 2900, + 2960, 3000, 3040, 3070, 3100, 3150, 3200, 3230, + 3260, 3300, 3340, 3380, 3400, 3450, 3500, 3530, + 3570, 3600, 3640, 3680, 3700, 3760, 3800, 3830, + 3870, 3900, 3950, 3990, 4000, 4100, 4180, 4250, + 4330, 4400, 4480, 4560, 4630, 4700, 4780, 4860, + 4940, 5000, 5100, 5160, 5240, 5300, 5400, 5470, + 5540, 5600, 5700, 5770, 5850, 5900, 6000, 6080, + 6150, 6230, 6300, 6380, 6460, 6530, 6600, 6680, + 6760, 6840, 6900, 7000, 7060, 7140, 7200, 7300, + 7370, 7440, 7500, 7600, 7670, 7750, 7800, 7900, + 7980, 8000, +]; + +/** Slider position where RDCfg switches to range 1. */ +const M612_RANGE1_FIRST_POSITION = 93; + +export interface RedragonDpiCode { + value: number; + range: 0 | 1; +} + +/** Wire code for one RDCfg slider position. */ +export function redragonM612DpiCodeAt(position: number): RedragonDpiCode { + if (!Number.isInteger(position) || position < 0 || position >= REDRAGON_M612_DPI_LABELS.length) { + throw new Error(`Redragon M612 DPI position ${position} is outside 0-${REDRAGON_M612_DPI_LABELS.length - 1}.`); + } + return position < M612_RANGE1_FIRST_POSITION + ? { value: 14 + position, range: 0 } + : { value: position - 39, range: 1 }; +} + +/** + * Snaps a requested DPI to the nearest RDCfg slider label (ties go to the + * lower one) and returns that label with its wire code. + */ +export function redragonM612EncodeDpi(dpi: number): RedragonDpiCode & { dpi: number } { + const labels = REDRAGON_M612_DPI_LABELS; + if (!Number.isInteger(dpi) || dpi < labels[0]! || dpi > labels[labels.length - 1]!) { + throw new Error(`Redragon M612 DPI ${dpi} is outside ${labels[0]}-${labels[labels.length - 1]}.`); + } + let best = 0; + for (let position = 1; position < labels.length; position++) { + if (Math.abs(labels[position]! - dpi) < Math.abs(labels[best]! - dpi)) best = position; + } + return { dpi: labels[best]!, ...redragonM612DpiCodeAt(best) }; +} + +/** + * DPI for a wire code. Codes RDCfg can write map to its label; anything else + * (another tool's write) falls back to the linear sensor scale the labels + * approximate, 4000 DPI per 106 counts, rounded to 10. + */ +export function redragonM612DecodeDpi(value: number, range: number): number { + const position = range === 0 ? value - 14 : range === 1 ? value + 39 : -1; + const inRange = range === 0 ? position < M612_RANGE1_FIRST_POSITION : position >= M612_RANGE1_FIRST_POSITION; + if (inRange && position >= 0 && position < REDRAGON_M612_DPI_LABELS.length) { + return REDRAGON_M612_DPI_LABELS[position]!; + } + return Math.round((value * (range === 1 ? 2 : 1) * 4000) / 106 / 10) * 10; +} + +function checkTransfer(address: number, length: number): void { + if (!Number.isInteger(address) || address < 0 || address > 0xffff) { + throw new Error(`Redragon address ${address} is outside 0x0000-0xffff.`); + } + if (!Number.isInteger(length) || length < 1 || length > REDRAGON_MAX_TRANSFER) { + throw new Error(`Redragon transfer length ${length} is outside 1-${REDRAGON_MAX_TRANSFER}.`); + } +} + +/** `[02 F3 addrLo addrHi len 00 00 00 data...]` (full frame, report id included). */ +export function redragonEncodeWrite(address: number, data: ArrayLike): Uint8Array { + checkTransfer(address, data.length); + const frame = new Uint8Array(REDRAGON_REPORT_SIZE); + frame[0] = REDRAGON_REPORT_ID; + frame[1] = REDRAGON_CMD_WRITE; + frame[2] = address & 0xff; + frame[3] = address >> 8; + frame[4] = data.length; + for (let index = 0; index < data.length; index++) { + const byte = data[index]!; + if (!Number.isInteger(byte) || byte < 0 || byte > 0xff) throw new Error(`Redragon data byte ${byte} is not a byte.`); + frame[8 + index] = byte; + } + return frame; +} + +/** `[02 F2 addrLo addrHi len ...]` (full frame, report id included). */ +export function redragonEncodeRead(address: number, length: number): Uint8Array { + checkTransfer(address, length); + const frame = new Uint8Array(REDRAGON_REPORT_SIZE); + frame[0] = REDRAGON_REPORT_ID; + frame[1] = REDRAGON_CMD_READ; + frame[2] = address & 0xff; + frame[3] = address >> 8; + frame[4] = length; + return frame; +} + +/** + * Validates the GET_FEATURE answer to `redragonEncodeRead(address, length)` + * and returns its data bytes. Accepts the answer with or without the leading + * report id, since WebHID and hidraw differ there. + */ +export function redragonDecodeRead(answer: Uint8Array, address: number, length: number): Uint8Array { + checkTransfer(address, length); + const body = answer[0] === REDRAGON_REPORT_ID && answer[1] === REDRAGON_REPLY_TAG ? answer.subarray(1) : answer; + const hex = [...answer].map((byte) => byte.toString(16).padStart(2, "0")).join(" "); + if ( + body.length < REDRAGON_REPLY_HEADER + length || + body[0] !== REDRAGON_REPLY_TAG || + body[1] !== (address & 0xff) || + body[2] !== 0x32 + (address >> 8) || + body[3] !== length || + body[5] !== 0xfa || + body[6] !== 0xfa + ) { + throw new Error(`Redragon read of ${length} bytes at 0x${address.toString(16).padStart(4, "0")} got an unexpected answer: ${hex}.`); + } + return body.slice(REDRAGON_REPLY_HEADER, REDRAGON_REPLY_HEADER + length); +} + +/** Address of one M612 DPI stage slot. */ +export function redragonM612SlotAddress(profile: number, stage: number): number { + const base = REDRAGON_M612_PROFILE_BASES[profile]; + if (base === undefined) throw new Error(`Redragon M612 profile ${profile} is outside 0-4.`); + if (!Number.isInteger(stage) || stage < 0 || stage >= REDRAGON_M612_STAGE_COUNT) { + throw new Error(`Redragon M612 DPI stage ${stage} is outside 0-4.`); + } + return base + 2 + 6 * stage; +} + +/* + * M612 profiles, lighting, and buttons: decoded from RDCfg 1.0.58 Apply + * captures (see docs/redragon-m612-testing.md). + */ + +export const REDRAGON_M612_PROFILE_COUNT = 5; + +/** + * RDCfg's MODE select, byte-for-byte: write `[profile, 00]` at `0x2c` + * (outside any `F5` bracket), then `F1 02 01`, then the commit block. + */ +export const REDRAGON_M612_PROFILE_SELECT_CODE = 0x01; + +/** Lighting effect selector: one bit per effect, `[bit, 00]`. Global, not per profile. */ +export const REDRAGON_M612_LIGHTING_EFFECT_ADDRESS = 0x0446; + +export type RedragonM612Effect = "wave" | "spectrumBreathing" | "breathing" | "flash" | "static" | "off"; + +export interface RedragonM612EffectInfo { + effect: RedragonM612Effect; + /** Value at `0x446`. */ + bit: number; + /** 8-byte parameter block `[flag, R, G, B, kind, speed, ?, brightness]`, or null for off. */ + block: number | null; + color: boolean; + speed: boolean; +} + +/** RDCfg effects, from Apply captures of each one. */ +export const REDRAGON_M612_EFFECTS: readonly RedragonM612EffectInfo[] = [ + { effect: "wave", bit: 0x01, block: 0x0448, color: false, speed: true }, + { effect: "spectrumBreathing", bit: 0x02, block: 0x0450, color: false, speed: true }, + { effect: "breathing", bit: 0x04, block: 0x0458, color: true, speed: true }, + { effect: "flash", bit: 0x08, block: 0x0460, color: true, speed: true }, + { effect: "static", bit: 0x10, block: 0x0468, color: true, speed: false }, + { effect: "off", bit: 0x20, block: null, color: false, speed: false }, +]; +export const REDRAGON_M612_LIGHTING_BLOCK_LENGTH = 8; +/** Speed byte: 8 is RDCfg's slowest slider position, 1 its fastest. */ +export const REDRAGON_M612_LIGHTING_SPEEDS = 8; +/** Brightness byte: RDCfg's slider has three positions, 1-3. */ +export const REDRAGON_M612_LIGHTING_BRIGHTNESS_LEVELS = 3; + +export function redragonM612EffectForBit(bit: number): RedragonM612EffectInfo | undefined { + return REDRAGON_M612_EFFECTS.find((info) => info.bit === bit); +} + +/** + * Button slots per profile, in RDCfg's numbering: 1 left, 2 right, + * 3 middle, 4 rapid fire, 5-6 side, 7-8 DPI, 9 LED, then wheel up/down. + * Four bytes each; there is no slot at `base + 0x24`. + */ +export const REDRAGON_M612_BUTTON_BASES = [0x0082, 0x0142, 0x01f2, 0x02a2, 0x0352] as const; +export const REDRAGON_M612_BUTTON_OFFSETS = [0x00, 0x04, 0x08, 0x0c, 0x10, 0x14, 0x18, 0x1c, 0x20, 0x28, 0x2c] as const; +export const REDRAGON_M612_BUTTON_LENGTH = 4; + +export function redragonM612ButtonAddress(profile: number, slot: number): number { + const base = REDRAGON_M612_BUTTON_BASES[profile]; + const offset = REDRAGON_M612_BUTTON_OFFSETS[slot]; + if (base === undefined) throw new Error(`Redragon M612 profile ${profile} is outside 0-4.`); + if (offset === undefined) throw new Error(`Redragon M612 button slot ${slot} is outside 0-10.`); + return base + offset; +} + +/** + * Single-byte actions captured from RDCfg (the value is padded to four bytes + * with zeros). "Rapid fire" is button 4's factory value; its speed and count + * bytes are kept exactly as shipped. + */ +export const REDRAGON_M612_BUTTON_ACTIONS: ReadonlyArray = [ + ["Left click", [0x81]], + ["Right click", [0x82]], + ["Middle click", [0x83]], + ["Back", [0x84]], + ["Forward", [0x85]], + ["Scroll up", [0x8b]], + ["Scroll down", [0x8c]], + ["Rapid fire", [0x99, 0x81, 0x03]], + ["DPI cycle", [0x88]], + ["DPI up", [0x8a]], + ["DPI down", [0x89]], + ["Profile cycle", [0x8d]], + ["Profile up", [0x94]], + ["Profile down", [0x95]], + ["Polling rate up", [0x97]], + ["Polling rate down", [0x98]], + ["Lighting effect cycle", [0x9b, 0x08]], + ["Disabled", []], +]; + +/** + * Keyboard assignments are `[8F, modifiers, usage]` with the HID keyboard + * modifier bits (1 Ctrl, 2 Shift, 4 Alt, 8 Win) and usage IDs. Captured from + * RDCfg's shortcut menus: Ctrl+V `8f 01 19`, Ctrl+A `8f 01 04`, Ctrl+F + * `8f 01 09`, Ctrl+N `8f 01 11`, Alt+Tab `8f 04 2b`, Alt+F4 `8f 04 3d`, + * Win+E `8f 08 08`, Win+R `8f 08 15`, Win+D `8f 08 07`, Win+L `8f 08 0f`. + */ +export const REDRAGON_M612_KEY_ACTION = 0x8f; +const MODIFIER_NAMES: 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[] = [ + ...REDRAGON_M612_BUTTON_ACTIONS.map(([name]) => name), + ...SHORTCUTS.map(([name, modifiers, key]) => `${name} (${keyName(modifiers, KEY_USAGES.find(([k]) => k === key)![1])})`), + ...KEY_USAGES.map(([name]) => `Key ${name}`), +]; + +/** Four-byte slot value for an action name from `REDRAGON_M612_BUTTON_OPTIONS`. */ +export function redragonM612EncodeButtonAction(action: string): number[] { + const pad = (bytes: readonly number[]) => [...bytes, 0, 0, 0, 0].slice(0, REDRAGON_M612_BUTTON_LENGTH); + const fixed = REDRAGON_M612_BUTTON_ACTIONS.find(([name]) => name === action); + if (fixed) return pad(fixed[1]); + const shortcut = SHORTCUTS.find(([name, modifiers, key]) => + action === `${name} (${keyName(modifiers, KEY_USAGES.find(([k]) => k === key)![1])})`); + if (shortcut) { + return pad([REDRAGON_M612_KEY_ACTION, shortcut[1], KEY_USAGES.find(([k]) => k === shortcut[2])![1]]); + } + const key = action.startsWith("Key ") ? KEY_USAGES.find(([name]) => name === action.slice(4)) : undefined; + if (key) return pad([REDRAGON_M612_KEY_ACTION, 0x00, key[1]]); + throw new Error(`Redragon M612 button action "${action}" is not offered.`); +} + +/** + * Names a slot value: an offered action where the bytes match one exactly, + * any other keyboard combination by its keys, and anything else (macros, + * RDCfg's advanced actions) as its raw bytes. + */ +export function redragonM612DecodeButtonAction(value: ArrayLike): string { + const bytes = Array.from(value).slice(0, REDRAGON_M612_BUTTON_LENGTH); + const same = (encoded: readonly number[]) => encoded.every((byte, index) => byte === (bytes[index] ?? 0)); + const named = REDRAGON_M612_BUTTON_OPTIONS.find((option) => same(redragonM612EncodeButtonAction(option))); + if (named) return named; + if (bytes[0] === REDRAGON_M612_KEY_ACTION && (bytes[3] ?? 0) === 0) { + const combination = keyName(bytes[1] ?? 0, bytes[2] ?? 0); + if (combination) return `Keys ${combination}`; + } + return `Vendor action (${bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(" ")})`; +}