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(" ")})`; +}