From 192104b85993d0bcd27d4153643da95641b2f645 Mon Sep 17 00:00:00 2001 From: LinkPass888 <57739158+LinkPass888@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:25:27 +0800 Subject: [PATCH 1/2] feat(rapoo): add the VT9 Pro driver for the 0x12xx/0x44xx generation The VT9 Pro (0x1205 receiver, 0x4405 cable) answers on its own vendor collection FF00:000E: a 0xBA output frame carries the register address, and the answer to it only ever shows up in an input report. WebHID has no GET_REPORT(Input), so this driver reads its registers through the transport when it exposes receiveInputReport (OpenMouse Bridge) and, when it does not, reports the battery the mouse pushes on 0xBB by itself rather than pretending the registers are readable. Every byte in src/rapoo and src/drivers/rapoo was read off one mouse with HidD_GetInputReport - wired and through the receiver, both links answering byte-for-byte the same - and cross-checked against the vendor driver (RapooGameDevDriver 1.6.29) and pedro3z0/rapoo-software-linux. The frame length is 31 bytes of report data behind the report id; Windows counts the id in the 32-byte buffer, and the browser rejects 32 as oversized. Reads only: the driver has no write path, and it never asks for feature report 0x2B, which drops the link until the cable is pulled. Verified on hardware: polling 4000 Hz, DPI 800 of 7 stages (stage 2), motion sync on, debounce 16 ms, sleep 7200 s, battery 100 %, over both USB paths. --- README.md | 1 + captures/rapoo-vt9-pro/README.md | 35 ++ captures/rapoo-vt9-pro/sweep-2026-09-29.txt | 162 ++++++ docs/rapoo-vt9-pro-testing.md | 164 +++++++ package.json | 4 + src/drivers/mouse-types.ts | 2 +- src/drivers/rapoo/hid.test.ts | 413 ++++++++++++++++ src/drivers/rapoo/hid.ts | 518 ++++++++++++++++++++ src/drivers/registry.test.ts | 5 +- src/drivers/registry.ts | 7 +- src/drivers/vendors.ts | 17 + src/index.ts | 1 + src/rapoo/index.test.ts | 260 ++++++++++ src/rapoo/index.ts | 482 ++++++++++++++++++ tsconfig.json | 3 +- 15 files changed, 2069 insertions(+), 5 deletions(-) create mode 100644 captures/rapoo-vt9-pro/README.md create mode 100644 captures/rapoo-vt9-pro/sweep-2026-09-29.txt create mode 100644 docs/rapoo-vt9-pro-testing.md create mode 100644 src/drivers/rapoo/hid.test.ts create mode 100644 src/drivers/rapoo/hid.ts create mode 100644 src/rapoo/index.test.ts create mode 100644 src/rapoo/index.ts diff --git a/README.md b/README.md index 01db6be..0074b69 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ checklist. | RAWM | `@openmouse/protocol/rawm` | | Pulsar / GravaStar | `@openmouse/protocol/pulsar` | | Razer legacy/current | `@openmouse/protocol/razer` | +| Rapoo VT9 Pro | `@openmouse/protocol/rapoo` | | Razer V4 | `@openmouse/protocol/razer-v4` | | Ryunix | `@openmouse/protocol/ryunix` | | SteelSeries Rival 3 (Gen 1) | `@openmouse/protocol/steelseries` | diff --git a/captures/rapoo-vt9-pro/README.md b/captures/rapoo-vt9-pro/README.md new file mode 100644 index 0000000..6e21466 --- /dev/null +++ b/captures/rapoo-vt9-pro/README.md @@ -0,0 +1,35 @@ +# Rapoo VT9 Pro fixtures + +Hardware reference material for `src/rapoo/` and `src/drivers/rapoo/`. + +Captured from one Rapoo VT9 Pro (1st gen) on Windows on 2026-09-29, both on its +2.4 GHz receiver `0x24AE:0x1205` and on its cable `0x24AE:0x4405`. Both paths +enumerate as "Rapoo Gaming Device" and expose the same six collections: + +| Usage page:usage | Reports (byte length as Windows reports it) | +|---|---| +| `0x000C:0x0001` | input 3 | +| `0x0001:0x0080` | input 2 | +| `0xFF00:0x000E` | input 32, output 32 - the `0xBA` configuration channel | +| `0xFF00:0x0002` | input 7, output 7 | +| `0xFF00:0x0002` | input 10, output 10 | +| `0xFF0B:0x0104` | input 61, output 61, feature 61 - the `0x2A` status block | + +Those lengths are what `HidP_GetCaps` returns on Windows, and it counts the +report id byte. The report *data* is one byte shorter - 2 for the mouse +collection, 31 for the `0xBA` channel, 60 for the `0x2A` block - and 31 is the +number that matters to a transport: WebHID refuses 32 (`Failed to write the +report`), which is how the frame length was pinned down. + +No personal data is included: nothing here carries a serial number, and the +logs are the tool's own output. + +| File | What it is | +|---|---| +| `sweep-2026-09-29.txt` | The full transcript: the interface map, the read that first proved the channel works, the register sweep on the receiver and again on the cable, the connection-byte probe (five candidates, eight attempts each), the lossy-channel measurements, and an idempotent write of the bytes just read. | + +The tests inline the verified blocks: `src/rapoo/index.test.ts` for the codec +and `src/drivers/rapoo/hid.test.ts` for the driver. See +`docs/rapoo-vt9-pro-testing.md` for the write-up, including the one rule that +matters most - never read feature report `0x2B`, which drops the link until the +cable is pulled. diff --git a/captures/rapoo-vt9-pro/sweep-2026-09-29.txt b/captures/rapoo-vt9-pro/sweep-2026-09-29.txt new file mode 100644 index 0000000..27dc4ad --- /dev/null +++ b/captures/rapoo-vt9-pro/sweep-2026-09-29.txt @@ -0,0 +1,162 @@ +Rapoo VT9 Pro (1st gen) register sweep - raw console capture +================================================================ +Date 2026-09-29 +Device 0x24AE:0x1205 "Rapoo Gaming Device" (2.4G receiver, mouse awake) +Tool a local PowerShell/C# probe driving HidD_GetInputReport directly +Transport HidD_GetInputReport (USB GET_REPORT(Input) control transfer) +Protocol output report 0xBA, frame [conn, cmd, len, addr u32 le, data...] + +The browser cannot do this: WebHID has no GET_REPORT(Input) call, so every +0xBA write from a page succeeds and never returns data. The same USB request +is HIDIOCGINPUT on Linux hidraw, and OpenMouse Bridge exposes it as +receiveInputReport() (Windows: HidD_GetInputReport). + +Interface map (report lengths INCLUDE the report id byte) +-------------------------------------------------------- + pid=0x1205 usagePage=0x000c usage=0x0001 in=3 out=0 consumer control + pid=0x1205 usagePage=0x0001 usage=0x0080 in=2 out=0 system control + pid=0x1205 usagePage=0xff00 usage=0x000e in=32 out=32 <-- 0xBA config channel + pid=0x1205 usagePage=0xff00 usage=0x0002 in=7 out=7 0xBB heartbeat + pid=0x1205 usagePage=0xff00 usage=0x0002 in=10 out=10 + pid=0x1205 usagePage=0xff0b usage=0x0104 in=61 out=61 feat=61 0x2A family + +Decision +-------- + usagePage=0xff0b usage=0x0104 out=61: SetOutputReport failed (report 0xBA is + not declared on that interface, only 0x2A is) + usagePage=0xff00 usage=0x000e out=32: ANSWER with connection byte 0xA5 + (2.4G dongle). Connection byte 0xFF (wired) got no answer, which is + expected: the mouse is on the receiver right now, not on a cable. + +Answer frame +------------ + sent conn=0xA5 cmd=0xA4 len=4 addr=0x880 + answer 01 00 00 00 82 00 82 ff 00 00 ... (32 bytes) + [0]=0x01 status OK [1]=0x00 not-a-battery-answer [4..] payload + NOTE: HidD_GetInputReport strips the report id, so the status byte is at + index 0 and the payload at index 4 - exactly the layout mousectl documents. + +Register sweep +-------------- + 0x880 PERF (polling) + raw: 82 00 82 ff + 2.4G = 0x82 -> 4000 Hz + [1] = 0x00 + cable = 0x82 -> 4000 Hz + [3] = 0xff + + 0x884 SENSOR (lift-off, motion sync) + raw: 01 01 01 00 + lift-off index = 1 (MV scale -> 1.0 mm) + motion sync = 1 (on) + [2]=0x01 [3]=0x00 + + 0x888 DPI X stages + raw: 90 01 20 03 b0 04 40 06 80 0c 00 19 90 65 02 00 + stages = [400, 800, 1200, 1600, 3200, 6400, 26000] + stage count byte [14] = 2 -> 3 enabled [15] = 0x00 + + 0x898 DPI active stage + raw: 01 00 02 01 + active stage = 1 (0-based) -> the second stage + + 0x8C0 TIMING (debounce, sleep, flags) + raw: 04 04 78 03 + press debounce = 4 -> 16 ms + release debounce = 4 -> 16 ms + sleep timeout = 120 min + flags = 0x03 (bit0 set = angle snap OFF, bit1 set = ripple OFF) + + 0x8C4 ANGLE + raw: 00 00 01 00 + sensor angle = 0 deg + + 0x8C8 DPI Y stages + raw: 90 01 20 03 b0 04 40 06 80 0c 00 19 90 65 02 00 + identical to 0x888 + + battery query (cmd 0xAA at address 0) + 01 01 62 00 00 00 00 00 + [0]=0x01 status OK [1]=0x01 battery-answer marker [2]=0x62 = 98 % + +What this settles +----------------- + * The 1st-gen configuration channel works. It was never a firmware dead end, + only a missing API in the browser. + * The register map is byte-for-byte the same family layout the 2nd-gen + official driver documents: profile 0 base 0x600 plus offsets + 640 / 644-645 / 648-662 / 664 / 704-707 / 708 / 712. + * Polling-rate codes match: 0x08=125 0x04=250 0x02=500 0x01=1000 + 0x84=2000 0x82=4000 0x81=8000 Hz. + * Battery answers are distinguishable from block answers by byte[1]. + +Write path (-writetest, idempotent: writes back exactly the bytes just read) +-------------------------------------------------------------------------- + == WRITE TEST (idempotent: writes back exactly the bytes it just read) + before : 82 00 82 ff + write : busy->OK, command 0xA5 accepted + after : 82 00 82 ff + verdict: read-back matches - the read/write channel is confirmed + + So both directions work: command 0xA4 to read, 0xA5 to write, both answered + through GET_REPORT(Input) with the busy -> OK handshake. The device state is + unchanged because the written bytes were the ones already stored. + +Wired pass (same day, cable plugged in, receiver still attached) +--------------------------------------------------------------- + With the cable in, Windows keeps BOTH devices: 12 HID collections, + 6 for pid 0x1205 and 6 for pid 0x4405, identical layout per device: + + 0x000C:0x0001 in=3 + 0x0001:0x0080 in=2 + 0xFF00:0x000E in=32 out=32 <-- the 0xBA config channel + 0xFF00:0x0002 in=7 out=7 + 0xFF00:0x0002 in=10 out=10 + 0xFF0B:0x0104 in=61 out=61 feat=61 + + Once the cable takes over, the receiver (0x1205) stops answering the 0xBA + channel and the wired device (0x4405) answers it. Register values are + byte-for-byte identical to the wireless pass: + + 0x880 82 00 82 ff -> 4000 Hz on both slots + 0x884 01 01 01 00 -> LOD index 1, motion sync on + 0x888 90 01 20 03 b0 04 40 06 80 0c 00 19 90 65 02 00 + 0x898 01 00 02 01 -> active stage 1 (0-based) + 0x8C0 04 04 78 03 -> 16/16 ms debounce, 120 min sleep, flags 0x03 + 0x8C4 00 00 01 00 -> 0 deg + 0x8C8 same as 0x888 + + A 0x2A status block is readable on the 0xFF0B:0x0104 interface of BOTH + devices with HidD_GetFeature, even while the 0xBA channel is silent: + + pid=0x4405 -> 2a 01 00 00 04 21 50 00 00 62 ... + pid=0x1205 -> 2a 01 00 00 04 11 20 00 00 62 ... + ^^ ack ^^^^^^^^^^ differ ^^ 0x62 = 98 % + +The connection byte is NOT a discriminator (measured, not assumed) +------------------------------------------------------------------ + mousectl uses 0xFF for wired and 0xA5 for the dongle. On this mouse both + answer, as do 0x00 / 0x01 / 0x5A. Measured with the cable plugged in, + 4-byte read at 0x898, 8 attempts per candidate: + + conn 0xFF: 2/8 answered + conn 0xA5: 3/8 answered + conn 0x00: 2/8 answered + conn 0x01: 4/8 answered + conn 0x5A: 3/8 answered + + So the field is not validated on this model. Use 0xA5, and never read a + failure as "wrong connection type". + +The channel is lossy, and it needs an awake mouse +------------------------------------------------- + * Per-attempt success is roughly 40 %: frames are dropped without the device + ever going busy. Reads need retries - four rounds of four sends with a + 120 ms gap read the whole map reliably (three wired runs + one wireless + run all produced the same table). + * Five back-to-back reads of the same register returned 1/5 and 0/5 in two + runs, and the very next sweep then read every address. "No answer" is not + "unsupported register". + * When the mouse sits idle the 0xBA channel stops answering entirely, while + HidD_GetFeature(0x2A) keeps returning the status block on both devices. + Move the mouse and re-run. diff --git a/docs/rapoo-vt9-pro-testing.md b/docs/rapoo-vt9-pro-testing.md new file mode 100644 index 0000000..05f2c43 --- /dev/null +++ b/docs/rapoo-vt9-pro-testing.md @@ -0,0 +1,164 @@ +# Rapoo VT9 Pro (1st gen) protocol + +Two independent sources describe the same channel, and they agree byte for +byte: + +- a capture of a real **VT9 Pro (1st gen)** on 2026-09-29, on its 2.4 GHz + receiver (`0x24AE:0x1205`) and again on the cable (`0x24AE:0x4405`). The raw + text is in `captures/rapoo-vt9-pro/sweep-2026-09-29.txt`; +- mousectl's `rapoo_vt3pro` driver, reverse engineered from Rapoo's own + `RapooGameDevDriver` **1.6.29** - the same installer build the capture was + taken with. It documents the same frame and the same offsets for the VT3 PRO. + `rapoo-software-linux`'s `PROTOCOL.md`, taken from A HUB 1.0.19, describes a + third Rapoo generation whose addresses line up with these after subtracting + the profile-0 base of `0x600`. + +Everything below is **measured** on that one mouse unless it says otherwise. + +## Transport + +The configuration channel is the `0xFF00:0x000E` collection: a 31-byte output +report `0xBA` in both directions (`HidP_GetCaps` calls it 32 because it counts +the report id; the report *data* is what the transport is handed, and that is +31). The answer does **not** arrive as an +interrupt-IN report - it is fetched with `GET_REPORT(Input)`, which is +`HidD_GetInputReport` on Windows, `HIDIOCGINPUT` on Linux hidraw, and +`receiveInputReport` in OpenMouse Bridge. + +That single detail explains the earlier impression that this mouse had no +readable protocol: **WebHID has no `GET_REPORT(Input)`**, so from a browser +every frame is delivered, the answer is dropped by the browser, and the read +looks like a timeout. Nothing about the device is broken. + +``` +request [0] connection byte [1] command [2] payload length + [3..6] address, u32 little endian [7..] data + +answer [0] status, 0x01 when answered [1] 0 for a block, non-zero for battery + [2] battery percentage on a battery answer [4..] the block +``` + +Windows strips the report id from the buffer it returns and Linux hidraw keeps +it, so every consumer has to accept both - mousectl does, and so does this +repository. + +Commands: `0xA4` read, `0xA5` write, `0xAA` battery. The connection byte is +*not* validated: `0xFF` and `0xA5` both answered, as did `0x00`, `0x01` and +`0x5A` (8 attempts each, 2-4 answered). A dropped frame therefore never means +"wrong connection type". + +## Register map + +Profile 0's base of `0x600` plus the offsets the vendor driver reads. The two +links returned identical bytes. + +| Address | Bytes | Meaning | +|---|---|---| +| `0x880` | `82 00 82 ff` | polling rate: `[0]` on 2.4 GHz, `[2]` on the cable, both `0x82` = 4000 Hz | +| `0x884` | `01 01 01 00` | `[0]` lift-off selector = 1, `[1]` motion sync = on | +| `0x888` | `90 01 20 03 b0 04 40 06 80 0c 00 19 90 65 02 00` | seven little-endian DPI stages = 400/800/1200/1600/3200/6400/26000, `[14]` = 2 | +| `0x898` | `01 00 02 01` | active stage index 1 (0 based) | +| `0x8C0` | `04 04 78 03` | press/release debounce index 4 = 16 ms, sleep 120 min, flags `0x03` | +| `0x8C4` | `00 00 01 00` | sensor angle 0 degrees | +| `0x8C8` | same as `0x888` | DPI Y, byte-identical to X in both runs | + +Polling-rate codes, from the vendor driver's own switch table and confirmed +against `0x880`: `0x08` 125, `0x04` 250, `0x02` 500, `0x01` 1000, `0x84` 2000, +`0x82` 4000, `0x81` 8000. The VT3 PRO profile has `support8k = false`, and the +highest rate this mouse was seen offering is 4000 Hz. + +Debounce is stored as an index into `[1, 2, 4, 8, 16, 24, 32]` ms. The flag +byte at `0x8C0[3]` holds *disable* flags: bit 0 set means angle snap is off, +bit 1 set means ripple control is off. + +## Battery + +Two independent readings, both confirmed at 98% on a fully charged mouse: + +- the `0xAA` answer, `01 01 62 ...` - byte 1 is the charge marker (1 + discharging, 2 charging, per the vendor driver's table) and byte 2 the + percentage. It does not follow the busy -> OK handshake, because the value is + already sitting in the device's input buffer; +- the `0x2A` **feature** report on `0xFF0B:0x0104`, `2a 01 00 00 04 21 50 00 00 62` + on the cable and `2a 01 00 00 04 11 20 00 00 62` on the receiver - the two + links differ at byte 5, and byte 9 of the report data is the percentage. This + one is readable from a browser, because `receiveFeatureReport` does exist in + WebHID. + +The mouse also pushes input report `0xBB` unprompted about every 3 seconds: +`b0 51 20 03 01 62`. Byte 4 was `0x01` on the receiver in every observation and +byte 5 tracks the percentage (`62` at 98%, `64` at 100%). The report stopped +entirely while the mouse was idle and resumed when it was moved. + +## What the link is like + +Three properties were measured rather than assumed, and each one had already +misled a reading taken without it: + +- **the channel is lossy.** Roughly 40% of frames are dropped without the + device ever going busy. Five back-to-back reads of one register returned 1/5 + and 0/5 in two runs, and the sweep immediately after read every address. + "No answer" is not "no such register"; +- **an OK only counts after a busy.** The input buffer holds the previous + command's answer until the receiver picks the new frame up, so an OK that was + not preceded by a busy is stale data. The handshake is: status other than + `0x01` (busy) first, then `0x01` with fresh bytes, and a frame that produced + no busy within 120 ms was dropped and is sent again. Four rounds of four + sends, with 120 ms between rounds, read the whole map on four of four runs; +- **an idle mouse stops answering `0xBA` entirely**, while the `0x2A` feature + read keeps returning the status block. Move the mouse and the channel comes + back. + +Reading feature report **`0x2B` disconnects the mouse** until the cable is +pulled and re-inserted. It is never read by this driver, and anything built on +this protocol should keep it that way. + +With the cable in, Windows keeps *both* devices present - twelve HID +collections, six for each product id, with an identical layout - and the cable +takes the channel over: once it is attached, the receiver stops answering +`0xBA`. + +## Not established + +- the scale the lift-off selector indexes. The vendor bundle carries both a + 1.0-2.0 mm and a 0.7-1.7 mm ladder, so this repository reads the code and + makes no Low/Medium/High claim; +- whether any Rapoo in this family offers 8000 Hz. The code decodes `0x81`, but + nothing offers it as a choice; +- the DPI floor and ceiling as the vendor tool enforces them - only the seven + values this mouse stores were read; +- the meaning of `0x880[1]` and `[3]`, `0x884[2..3]`, `0x8C4[1..3]`, + `0x898[1..3]`, the `0x888[15]` byte that follows the stage count, the `0x2A` + block beyond its battery byte, and `0xBB` bytes 2 and 3. All of them are + preserved untouched by the codec rather than interpreted; +- whether a write survives unplugging the mouse. Writes work - an idempotent + write of the bytes just read was accepted with the same busy -> OK handshake + and read back unchanged - but no setting has been changed and re-read across + a power cycle. + +## What this repository implements + +`src/rapoo/` is the transport-independent codec: frame encoding, answer and +block decoding, and the register tables above. `src/drivers/rapoo/hid.ts` is +read-only, and has two shapes on purpose: + +- in a plain browser it reports the battery from the `0x2A` feature block and + says plainly that the rest needs a native transport; +- with `receiveInputReport` available it walks the register map and reports + DPI, polling rate, motion sync, debounce, sleep and the sensor flags as + verified values. + +Nothing writes yet. This is a block read-modify-write protocol with no partial +update, so a setter has to read a block, edit the decoded object, write it back +and read it again - which needs the same `receiveInputReport` the reads need. + +## Re-verifying + +On Windows, `HidD_GetInputReport` on the `0xFF00:0x000E` handle; on Linux, +`HIDIOCGINPUT` on the hidraw node, for example with mousectl's own driver, +which speaks the same protocol to the VT3 PRO. Send +`A5 A4 04 80 08 00 00` + 24 zero bytes as output report `0xBA`, poll the input +report for a status other than `0x01` followed by `0x01`, and expect +`82 00 82 ff` from byte 4 on. The capture in `captures/rapoo-vt9-pro/` is the +full transcript of that, on both links, including the register sweep and the +idempotent write. diff --git a/package.json b/package.json index 1dbbd65..eec295d 100644 --- a/package.json +++ b/package.json @@ -193,6 +193,10 @@ "types": "./dist/ryunix/index.d.ts", "import": "./dist/ryunix/index.js" }, + "./rapoo": { + "types": "./dist/rapoo/index.d.ts", + "import": "./dist/rapoo/index.js" + }, "./delux": { "types": "./dist/delux/index.d.ts", "import": "./dist/delux/index.js" diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index ea78f98..caa7d74 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -148,7 +148,7 @@ export interface AtkReceiverInfo { } export interface MouseStatus { - brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI"; + brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo"; name: string; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; diff --git a/src/drivers/rapoo/hid.test.ts b/src/drivers/rapoo/hid.test.ts new file mode 100644 index 0000000..e98f1dc --- /dev/null +++ b/src/drivers/rapoo/hid.test.ts @@ -0,0 +1,413 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { RapooHidClient } from "./hid.ts"; + +function fromHex(text: string): Uint8Array { + return Uint8Array.from(text.trim().split(/\s+/).map((byte) => Number.parseInt(byte, 16))); +} + +function toHex(bytes: Uint8Array): string { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(" "); +} + +const zeros = (count: number): string => "00 ".repeat(count).trim(); + +/** The address of every 0xA4 read in a send log, in order. */ +function readAddresses(sent: ReadonlyArray<{ data: Uint8Array }>): number[] { + return sent + .filter((report) => report.data[1] === 0xa4) + .map((report) => report.data[3] | (report.data[4] << 8) | (report.data[5] << 16)); +} + +/** + * The register blocks a real VT9 Pro returned on 2026-09-29, keyed by the + * address the driver asks for them at. Wired and wireless were identical. + */ +const BLOCKS: ReadonlyArray = [ + [0x880, "82 00 82 ff"], + [0x884, "01 01 01 00"], + [0x888, "90 01 20 03 b0 04 40 06 80 0c 00 19 90 65 02 00"], + [0x898, "01 00 02 01"], + [0x8c0, "04 04 78 03"], +]; + +const BLOCK_BY_ADDRESS = new Map(BLOCKS); + +/** The 0x2A feature block as Windows returned it, on the cable. */ +const STATUS_BLOCK = fromHex(`2a 01 00 00 04 21 50 00 00 62 ${zeros(51)}`); + +const CONFIG_COLLECTION = { + usagePage: 0xff00, + usage: 0x000e, + type: 1, + children: [], + inputReports: [], + outputReports: [], + featureReports: [], +} as unknown as HIDCollectionInfo; + +/** `status` is what the transport hands back for GET_REPORT(Input). */ +function busy(): Uint8Array { + return fromHex("02 00 00 00 00 00 00 00"); +} + +function answered(block: string, withReportId = false): Uint8Array { + const body = fromHex(`01 00 00 00 ${block}`); + return withReportId ? Uint8Array.from([0xba, ...body]) : body; +} + +/** + * A HIDDevice that answers 0xBA the way the captures say the mouse does: a + * busy report first, then the block. Addresses in `silentSends` drop that many + * frames instead, which is what a lost frame looks like from the host. + */ +class FakeDevice { + vendorId = 0x24ae; + productId = 0x1205; + productName = "Rapoo Gaming Device"; + opened = false; + collections: HIDCollectionInfo[] = [CONFIG_COLLECTION]; + + /** Every report the driver sent, in order. */ + sent: Array<{ reportId: number; data: Uint8Array }> = []; + /** Every feature report the driver asked for. */ + featureReads: number[] = []; + /** When true the 0x2A feature read fails, as it does on a sleeping mouse. */ + featureThrows = false; + /** Address -> how many sends of it to swallow before answering. */ + silentSends = new Map(); + /** Set when the transport keeps the report id in front of the answer. */ + keepReportId = false; + /** Installed when the test wants GET_REPORT(Input) to exist. */ + receiveInputReport?: (reportId: number) => Promise; + + private pending: Uint8Array[] = []; + private listeners = new Set<(event: unknown) => void>(); + + async open(): Promise { + this.opened = true; + } + + async close(): Promise { + this.opened = false; + } + + async sendReport(reportId: number, data: BufferSource): Promise { + const view = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : new Uint8Array(data as ArrayBuffer); + const bytes = Uint8Array.from(view); + this.sent.push({ reportId, data: bytes }); + if (reportId !== 0xba) return; + + if (bytes[1] === 0xaa) { + // A battery answer carries the marker in byte 1 and needs no handshake. + this.pending = [fromHex("01 01 62 00 00 00 00 00")]; + return; + } + + const address = bytes[3] | (bytes[4] << 8) | (bytes[5] << 16) | (bytes[6] << 24); + const swallow = this.silentSends.get(address) ?? 0; + if (swallow > 0) { + this.silentSends.set(address, swallow - 1); + this.pending = []; + return; + } + + const block = BLOCK_BY_ADDRESS.get(address); + this.pending = block + ? [busy(), answered(block, this.keepReportId)] + : []; + } + + async sendFeatureReport(reportId: number, data: BufferSource): Promise { + void data; + this.sent.push({ reportId, data: new Uint8Array(0) }); + } + + async receiveFeatureReport(reportId: number): Promise { + this.featureReads.push(reportId); + if (this.featureThrows) throw new Error("no feature report"); + return new DataView(STATUS_BLOCK.buffer, STATUS_BLOCK.byteOffset, STATUS_BLOCK.byteLength); + } + + addEventListener(type: string, listener: (event: unknown) => void): void { + if (type === "inputreport") this.listeners.add(listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + if (type === "inputreport") this.listeners.delete(listener); + } + + /** Drive the unsolicited 0xBB report the mouse sends every ~3 seconds. */ + emitNotification(payload: string): void { + const bytes = fromHex(payload); + const event = { + device: this, + reportId: 0xbb, + data: new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength), + }; + for (const listener of this.listeners) listener(event); + } + + /** The port of `receiveInputReport` a native transport installs. */ + attachNativeTransport(options: { keepReportId?: boolean } = {}): void { + this.keepReportId = options.keepReportId ?? false; + this.receiveInputReport = async (): Promise => { + const next = this.pending.shift(); + if (!next) throw new Error("no input report buffered"); + return new DataView(next.buffer, next.byteOffset, next.byteLength); + }; + } + + asHidDevice(): HIDDevice { + return this as unknown as HIDDevice; + } +} + +test("the driver claims both of the interfaces the mouse has been seen on", () => { + const receiver = new FakeDevice(); + assert.equal(RapooHidClient.isSupported(receiver.asHidDevice()), true); + + const wired = new FakeDevice(); + wired.productId = 0x4405; + assert.equal(RapooHidClient.isSupported(wired.asHidDevice()), true); + + const wrongVendor = new FakeDevice(); + wrongVendor.vendorId = 0x046d; + assert.equal(RapooHidClient.isSupported(wrongVendor.asHidDevice()), false); + + const wrongProduct = new FakeDevice(); + wrongProduct.productId = 0x1206; + assert.equal(RapooHidClient.isSupported(wrongProduct.asHidDevice()), false); + + // A Rapoo product without the 0xBA config collection is a different animal. + const wrongCollection = new FakeDevice(); + wrongCollection.collections = [{ + usagePage: 0xff0b, + usage: 0x0104, + type: 1, + children: [], + inputReports: [], + outputReports: [], + featureReports: [], + } as unknown as HIDCollectionInfo]; + assert.equal(RapooHidClient.isSupported(wrongCollection.asHidDevice()), false); +}); + +test("the config collection is found inside a child collection too", () => { + const nested = new FakeDevice(); + nested.collections = [{ + usagePage: 0x0001, + usage: 0x0002, + type: 1, + children: [CONFIG_COLLECTION], + inputReports: [], + outputReports: [], + featureReports: [], + } as unknown as HIDCollectionInfo]; + assert.equal(RapooHidClient.isSupported(nested.asHidDevice()), true); +}); + +test("a browser alone still gets the battery, and nothing is sent", async () => { + const fake = new FakeDevice(); + const client = new RapooHidClient(fake.asHidDevice()); + + const status = await client.readStatus(); + + assert.equal(status.brand, "Rapoo"); + assert.equal(status.name, "Rapoo Gaming Device"); + assert.equal(status.batteryPercent, 98); + // Charge state needs the 0xAA answer, which needs GET_REPORT(Input). + assert.equal(status.batteryState, "Unknown"); + assert.equal(status.dpi, 0); + assert.equal(status.pollingRateHz, 0); + assert.equal(status.liftOffDistance, null); + assert.deepEqual(status.firmware, []); + assert.deepEqual(fake.featureReads, [0x2a]); + // Without a transport that can carry the answer back there is nothing to + // send, so the driver does not write to the mouse at all. + assert.deepEqual(fake.sent, []); + assert.equal(status.ui?.settingsReady, false); + assert.equal(status.ui?.valuesVerified, false); + assert.equal(status.ui?.forceShowBattery, true); + assert.match(status.ui?.statusNote ?? "", /Bridge/); +}); + +test("the register map is read through the busy -> OK handshake", async () => { + const fake = new FakeDevice(); + fake.attachNativeTransport(); + const client = new RapooHidClient(fake.asHidDevice()); + + const status = await client.readStatus(); + + // The stage-1 entry of the stored table is the live DPI. + assert.equal(status.dpi, 800); + assert.deepEqual(status.dpiStages, [400, 800, 1200]); + assert.equal(status.activeDpiStage, 1); + assert.equal(status.pollingRateHz, 4000); + assert.equal(status.motionSync, true); + assert.equal(status.debounceMs, 16); + assert.equal(status.sleepTimeout, 120 * 60); + assert.equal(status.angleSnapping, false); + assert.equal(status.rippleControl, false); + assert.equal(status.batteryPercent, 98); + assert.equal(status.batteryState, "Discharging"); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.connectionDetail, "2.4 GHz receiver"); + assert.equal(status.ui?.valuesVerified, true); + assert.equal(status.ui?.settingsReady, false); + assert.match(status.ui?.statusNote ?? "", /Bridge/); + + // The first frame is the performance read. + assert.equal( + toHex(fake.sent[0].data), + `a5 a4 04 80 08 00 00 ${zeros(24)}`, + ); + // The battery is asked for with its own command, and the last frame is the + // live re-read of the active stage. + const batteryQuery = fake.sent.find((report) => report.data[1] === 0xaa); + assert.equal(toHex(batteryQuery!.data), `a5 aa 00 00 00 00 00 ${zeros(24)}`); + assert.equal(toHex(fake.sent[fake.sent.length - 1].data), `a5 a4 04 98 08 00 00 ${zeros(24)}`); + assert.ok(fake.sent.every((report) => report.reportId === 0xba)); +}); + +test("a wired mouse reads its cable polling rate and reports USB", async () => { + const fake = new FakeDevice(); + fake.productId = 0x4405; + fake.attachNativeTransport(); + const client = new RapooHidClient(fake.asHidDevice()); + + const status = await client.readStatus(); + + assert.equal(status.pollingRateHz, 4000); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.connectionDetail, "USB"); +}); + +test("an answer that keeps the report id in front of the status decodes too", async () => { + const fake = new FakeDevice(); + fake.attachNativeTransport({ keepReportId: true }); + const client = new RapooHidClient(fake.asHidDevice()); + + const status = await client.readStatus(); + + assert.equal(status.dpi, 800); + assert.equal(status.pollingRateHz, 4000); +}); + +test("a lost frame is sent again rather than read as a missing register", async () => { + const fake = new FakeDevice(); + fake.attachNativeTransport(); + // Swallow one round (four sends) of the sensor register. + fake.silentSends.set(0x884, 4); + const client = new RapooHidClient(fake.asHidDevice()); + + const status = await client.readStatus(); + + assert.equal(status.motionSync, true); + const sensorSends = fake.sent.filter((report) => report.data[3] === 0x84 && report.data[4] === 0x08); + assert.ok(sensorSends.length > 4, `expected a resend, saw ${sensorSends.length} sends`); + // Nothing else was disturbed by the retry. + assert.equal(status.dpi, 800); + assert.equal(status.debounceMs, 16); +}); + +test("a register the whole first pass lost is read again instead of reported as zero", async () => { + const fake = new FakeDevice(); + fake.attachNativeTransport(); + // A cold mouse loses the first frames of a walk: swallowing all sixteen sends + // of one round makes the first pass give up on the polling register. That is + // what the real mouse did - one walk came back with pollingRateHz 0 and the + // three walks after it read 4000. + fake.silentSends.set(0x880, 16); + const client = new RapooHidClient(fake.asHidDevice()); + + const status = await client.readStatus(); + + assert.equal(status.pollingRateHz, 4000); + const pollingSends = fake.sent.filter((report) => report.data[3] === 0x80 && report.data[4] === 0x08); + assert.ok(pollingSends.length > 16, `expected the walk to come back for it, saw ${pollingSends.length} sends`); + // The second pass re-reads the gap and leaves the rest of the map alone. + assert.equal(status.dpi, 800); + assert.equal(status.debounceMs, 16); +}); + +test("the register map is walked once, not on every status refresh", async () => { + const fake = new FakeDevice(); + fake.attachNativeTransport(); + const client = new RapooHidClient(fake.asHidDevice()); + + await client.readStatus(); + assert.deepEqual(readAddresses(fake.sent), [0x880, 0x884, 0x888, 0x898, 0x8c0, 0x898]); + + // The app refreshes status on a timer; each refresh re-reads the live stage + // and the battery, and leaves the five-register walk alone. + fake.sent.length = 0; + await client.readStatus(); + assert.deepEqual(readAddresses(fake.sent), [0x898]); + assert.ok(fake.sent.some((report) => report.data[1] === 0xaa)); +}); + +test("the mouse is never asked for the feature that disconnects it", async () => { + const fake = new FakeDevice(); + fake.attachNativeTransport(); + const client = new RapooHidClient(fake.asHidDevice()); + await client.readStatus(); + + // Feature 0x2B drops the link until the cable is pulled; nothing here may + // touch it, on either side of the exchange. + assert.ok(!fake.featureReads.includes(0x2b)); + assert.ok(fake.sent.every((report) => report.reportId !== 0x2b)); +}); + +test("the unsolicited 0xBB report supplies the battery when nothing else answers", async () => { + const fake = new FakeDevice(); + fake.featureThrows = true; + const client = new RapooHidClient(fake.asHidDevice()); + await client.open(); + + fake.emitNotification("b0 51 20 03 01 45"); + const status = await client.readStatus(); + + assert.equal(status.batteryPercent, 69); + assert.equal(status.connectionType, "Wireless"); +}); + +test("a sleeping mouse is reported as unanswered, not as an empty map", async () => { + const fake = new FakeDevice(); + fake.attachNativeTransport(); + // Every register frame is dropped, which is what an idle mouse looks like. + for (const [address] of BLOCKS) fake.silentSends.set(address, 1000); + const client = new RapooHidClient(fake.asHidDevice()); + + const status = await client.readStatus(); + + assert.equal(status.dpi, 0); + assert.equal(status.pollingRateHz, 0); + assert.equal(status.motionSync, undefined); + assert.equal(status.ui?.valuesVerified, false); + assert.match(status.ui?.statusNote ?? "", /wake/i); + // The feature block keeps answering while the register channel is silent. + assert.equal(status.batteryPercent, 98); + // And the walk gave up after two silent registers instead of all five. + const polled = new Set(fake.sent.map((report) => report.data[3] | (report.data[4] << 8))); + assert.deepEqual([...polled].sort((a, b) => a - b), [0x880, 0x884]); +}); + +test("closing detaches the listener it installed", async () => { + const fake = new FakeDevice(); + const client = new RapooHidClient(fake.asHidDevice()); + await client.open(); + assert.equal(fake.opened, true); + + await client.close(); + assert.equal(fake.opened, false); + + // A report arriving after close must not be remembered. + fake.featureThrows = true; + fake.emitNotification("b0 51 20 03 01 45"); + const status = await client.readStatus(); + assert.equal(status.batteryPercent, null); +}); diff --git a/src/drivers/rapoo/hid.ts b/src/drivers/rapoo/hid.ts new file mode 100644 index 0000000..e78f004 --- /dev/null +++ b/src/drivers/rapoo/hid.ts @@ -0,0 +1,518 @@ +import type { MouseStatus } from "../mouse-types.ts"; +import { + RAPOO_ADDRESS, + RAPOO_BLOCK_LENGTH, + RAPOO_CONFIG_REPORT_ID, + RAPOO_CONFIG_USAGE, + RAPOO_CONFIG_USAGE_PAGE, + RAPOO_NOTIFY_REPORT_ID, + RAPOO_PRODUCT_IDS, + RAPOO_STATUS_REPORT_ID, + RAPOO_VENDOR_ID, + RAPOO_WIRELESS_PRODUCT_ID, + decodeRapooActiveStage, + decodeRapooAnswer, + decodeRapooBatteryAnswer, + decodeRapooDpiTable, + decodeRapooNotification, + decodeRapooPerformance, + decodeRapooSensor, + decodeRapooTiming, + encodeRapooBatteryQuery, + encodeRapooRead, + rapooAnswerBlock, + rapooLinkConnection, + rapooStatusBatteryPercent, + type RapooAnswer, + type RapooBattery, + type RapooDpiTable, + type RapooNotification, + type RapooPerformance, + type RapooSensor, + type RapooTiming, +} from "@openmouse/protocol/rapoo"; + +/** + * Rapoo's configuration channel (VT9 Pro and its family), read-only for now. + * + * The mouse answers on 0xFF00:0x000E with output report 0xBA - 31 bytes of + * report data, 32 on Windows because `HidP_GetCaps` counts the report id - and + * the answer comes back through **GET_REPORT(Input)** rather than as an interrupt-IN + * report. WebHID has no equivalent call - `receiveFeatureReport` reads a + * feature report, not an input report - so in a plain browser every frame this + * driver sends is delivered and its answer is dropped on the floor. That is a + * property of the browser, not of the mouse: the same request is + * `HidD_GetInputReport` on Windows, `HIDIOCGINPUT` on Linux hidraw, and + * `receiveInputReport` in OpenMouse Bridge, and on all three the register map + * reads back correctly. + * + * So the driver has two shapes: + * + * - **without a native transport** it does what a browser can still do. The + * 0xFF0B:0x0104 interface answers a *feature* report, and that block carries + * the battery, so the battery is live; everything else stays unavailable and + * `ui.settingsReady` is false with a note saying why; + * - **with `receiveInputReport`** it walks the register map through the + * busy -> OK handshake the vendor driver waits for, and reports DPI, polling + * rate, motion sync, debounce, sleep and the sensor flags as verified values. + * + * What is measured, from the capture in `captures/rapoo-vt9-pro/`: + * + * - **about 40% of frames are dropped** without the device ever going busy, so + * a read is sent again in rounds, and an OK that was not preceded by a busy + * is the previous command's answer rather than this one's; + * - **an idle mouse stops answering 0xBA entirely.** Moving it wakes the + * channel up; the 0x2A feature read keeps working either way; + * - **the connection byte is not validated** - 0xFF, 0xA5, 0x00, 0x01 and 0x5A + * all answered - so a dropped frame is never reported as "wrong link". + * + * Nothing here writes. The register map is a read-modify-write protocol: a + * partial write is impossible, and the bytes this driver does not understand + * (the reserved bytes in the performance block, the sensor and angle blocks) + * have to be carried through untouched. That needs the read-back that only a + * native transport provides, and it is a separate change. + */ + +/** + * The GET_REPORT(Input) call OpenMouse Bridge adds to `HIDDevice`, the same + * extension the Microsoft driver uses. Optional: a device without it still + * connects, it just cannot read registers. + */ +interface RapooNativeTransport { + receiveInputReport(reportId: number): Promise; +} + +/** + * How many times one frame is sent before a read is considered lost, and how + * long to wait for the busy -> OK transition. The vendor driver sleeps 50 ms + * between sends, shows busy within about 4 ms, and gives up on a frame 120 ms + * after sending it - all three figures are its own. + */ +const EXCHANGE_ATTEMPTS = 4; +const EXCHANGE_RETRY_MS = 50; +const EXCHANGE_ACCEPT_MS = 120; +const EXCHANGE_POLL_MS = 2; + +/** + * How many times a whole read is retried before a register is called + * unreadable. Four rounds of four sends read every address of the map on every + * capture run, wired and wireless alike. + */ +const BLOCK_ROUNDS = 4; +const BLOCK_GAP_MS = 120; + +/** + * How many registers in a row have to come back empty before the walk gives up. + * + * One register can lose every frame and still be there - it was measured - so a + * single empty result is not proof of anything. A mouse that is asleep or out + * of range loses all of them, and the point of stopping is to keep that from + * turning into a connect that looks hung: at ~2.9 s per silent register, a full + * walk of the map costs about 14 s. + */ +const BLOCK_FAILURES_BEFORE_SLEEPING = 2; + +/** + * A battery query does not follow busy -> OK: the value is already sitting in + * the device's input buffer, so the answer is found by looking for the marker + * byte instead of by watching the handshake. + */ +const BATTERY_ROUNDS = 4; +const BATTERY_POLLS = 60; +const BATTERY_POLL_MS = 4; + +/** + * How long to wait before walking the register map again after a mouse that did + * not answer. The sweep is the expensive part of a status read, and the app + * refreshes status on a timer, so a mouse that was asleep at connect must not + * make every refresh pay for it. It gets retried on the next read after the + * window instead, which is what eventually catches a user who woke the mouse + * up without reconnecting it. + */ +const SWEEP_RETRY_MS = 30_000; + +function delay(ms: number): Promise { + return new Promise((resolve) => { setTimeout(resolve, ms); }); +} + +function asBytes(view: DataView): Uint8Array { + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); +} + +/** Everything one walk of the register map produced. */ +interface RapooRegisters { + /** [0] is the 2.4 GHz rate, [2] the cable rate. */ + performance: RapooPerformance | null; + sensor: RapooSensor | null; + dpi: RapooDpiTable | null; + activeStage: number | null; + timing: RapooTiming | null; +} + +export class RapooHidClient { + readonly device: HIDDevice; + + /** The last unsolicited 0xBB status, which arrives about every three seconds. */ + private notification: RapooNotification | null = null; + + /** + * The last battery answer from 0xAA - the only place the charge state is + * readable. Kept across a status read so a frame lost to the lossy channel + * does not blank the field. + */ + private battery: RapooBattery | null = null; + + /** + * The register map, walked once per session: five registers at four rounds + * each is slow enough that repeating it on every status would hold up the + * connect flow. The two values that change while the window is open - the + * active DPI stage and the battery - are re-read every time instead. + */ + private registers: RapooRegisters | null = null; + + /** When the walk last ran, so a silent mouse is retried and not re-walked. */ + private lastSweepAt = 0; + + /** + * Status reads are serialized the way every driver here does it. The app can + * ask for a status from its refresh timer while another read is in flight, + * and two interleaved exchanges would pair one command's answer with + * another's. + */ + private queue: Promise = Promise.resolve(); + + private listening = false; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + const search = (collection: HIDCollectionInfo): boolean => + (collection.usagePage === RAPOO_CONFIG_USAGE_PAGE + && collection.usage === RAPOO_CONFIG_USAGE) + || collection.children.some(search); + return device.vendorId === RAPOO_VENDOR_ID + && RAPOO_PRODUCT_IDS.has(device.productId) + && device.collections.some(search); + } + + private nativeTransport(): RapooNativeTransport | null { + const candidate = this.device as unknown as Partial; + return typeof candidate.receiveInputReport === "function" + ? candidate as RapooNativeTransport + : null; + } + + private readonly onInputReport = (event: HIDInputReportEvent): void => { + if (event.reportId !== RAPOO_NOTIFY_REPORT_ID) return; + const notification = decodeRapooNotification(asBytes(event.data)); + if (notification) this.notification = notification; + }; + + async open(): Promise { + if (!RapooHidClient.isSupported(this.device)) { + throw new Error("The Rapoo configuration collection is unavailable."); + } + if (!this.device.opened) await this.device.open(); + if (!this.listening) { + this.device.addEventListener("inputreport", this.onInputReport); + this.listening = true; + } + } + + async close(): Promise { + if (this.listening) { + this.device.removeEventListener("inputreport", this.onInputReport); + this.listening = false; + } + if (this.device.opened) await this.device.close(); + } + + /** Nothing is writable yet, so there is no range to offer. */ + getDpiOptions(): number[] { + return []; + } + + getPollingRateOptions(): number[] { + return []; + } + + async readStatus(): Promise { + const next = this.queue.then(() => this.readStatusNow(), () => this.readStatusNow()); + this.queue = next.catch(() => undefined); + return next; + } + + private async readStatusNow(): Promise { + await this.open(); + + const native = this.nativeTransport(); + const wireless = this.device.productId === RAPOO_WIRELESS_PRODUCT_ID; + + // Feature reports work while the mouse is idle, and are the only Rapoo + // read a browser can perform, so this one is always tried. + const featureBattery = await this.readFeatureBattery(); + + if (native) { + if (!this.registers && Date.now() - this.lastSweepAt >= SWEEP_RETRY_MS) { + this.lastSweepAt = Date.now(); + this.registers = await this.readRegisters(native); + } + // The battery query rides the same channel as the register walk, so it is + // only worth asking when that walk found the mouse awake. + if (this.registers) this.battery = await this.readBattery(native) ?? this.battery; + } else { + this.registers = null; + this.battery = null; + } + + const registers = this.registers; + const liveStage = native && registers ? await this.readActiveStage(native) : null; + + const table = registers?.dpi ?? null; + const stageIndex = liveStage ?? registers?.activeStage ?? null; + const dpi = table && stageIndex !== null ? table.stages[stageIndex] ?? 0 : 0; + + const performance = registers?.performance ?? null; + const pollingRateHz = (wireless ? performance?.receiverHz : performance?.wiredHz) ?? 0; + + const percent = this.battery?.percent + ?? featureBattery + ?? this.notification?.batteryPercent + ?? null; + const batteryState: MouseStatus["batteryState"] = this.battery + ? this.battery.charging ? "Charging" : "Discharging" + : "Unknown"; + + // The 0xBB link byte is the live answer; the product id is the fallback, + // because the byte has only ever been seen as 0x01 (receiver). + const linked = this.notification ? rapooLinkConnection(this.notification.linkCode) : null; + const connectionType: "Wired" | "Wireless" = linked ?? (wireless ? "Wireless" : "Wired"); + + return { + brand: "Rapoo", + name: this.device.productName?.trim() || "Rapoo Mouse", + ui: { + family: "rapoo", + settingsReady: false, + valuesVerified: registers !== null, + pollingReadOnly: true, + hideProcessingCard: true, + hideSignalCard: true, + hideSleepCard: true, + forceShowBattery: true, + defaultDisplayName: "Rapoo Mouse", + statusNote: this.statusNote(registers !== null, native !== null), + }, + batteryPercent: percent, + batteryState, + dpi, + ...(table ? { + dpiStages: table.stages.slice(0, table.enabledStages), + } : {}), + ...(table && stageIndex !== null ? { activeDpiStage: stageIndex } : {}), + pollingRateHz, + activeProfile: null, + // The stored lift-off selector is read, but the scale it indexes is not + // established for this mouse, so no Low/Medium/High claim is made. + liftOffDistance: null, + ...(registers?.sensor ? { motionSync: registers.sensor.motionSync } : {}), + ...(registers?.timing ? { + // The mouse stores press and release separately; the shared field is + // the press figure and the codec keeps both. + debounceMs: registers.timing.pressDebounceMs, + sleepTimeout: registers.timing.sleepMinutes * 60, + angleSnapping: !registers.timing.angleSnapOff, + rippleControl: !registers.timing.rippleOff, + } : {}), + connectionType, + connectionDetail: connectionType === "Wireless" ? "2.4 GHz receiver" : "USB", + firmware: [], + }; + } + + private statusNote(readable: boolean, native: boolean): string { + if (readable) { + return "Settings are read through OpenMouse Bridge. This driver does not write to the mouse yet."; + } + if (native) { + return "The mouse did not answer its register channel. Move it to wake it up and reconnect."; + } + return "Read-only in the browser: reading Rapoo's registers needs GET_REPORT(Input), which WebHID does not have, so only the battery is available. OpenMouse Bridge adds it and unlocks DPI, polling rate and the sensor settings."; + } + + /** One more read of the live DPI stage, so a stage change is picked up. */ + private async readActiveStage(native: RapooNativeTransport): Promise { + const block = await this.readBlock(native, RAPOO_ADDRESS.activeStage, 4); + return block ? decodeRapooActiveStage(block) : null; + } + + private async readRegisters(native: RapooNativeTransport): Promise { + let answered = 0; + let silent = 0; + const blocks = new Map(); + const read = async (address: number): Promise => { + const block = await this.readBlock(native, address, RAPOO_BLOCK_LENGTH[address]); + if (block) { + answered += 1; + silent = 0; + } else { + silent += 1; + } + if (block) blocks.set(address, block); + return block; + }; + + const addresses = [ + RAPOO_ADDRESS.performance, + RAPOO_ADDRESS.sensor, + RAPOO_ADDRESS.dpiX, + RAPOO_ADDRESS.activeStage, + RAPOO_ADDRESS.timing, + ]; + + await read(addresses[0]); + for (const address of addresses.slice(1)) { + if (silent < BLOCK_FAILURES_BEFORE_SLEEPING) await read(address); + } + + // A mouse that answered nothing is asleep or out of range; saying so beats + // reporting five null fields as if the registers did not exist. + if (answered === 0) return null; + + // The first frames of a walk are the ones a mouse that was asleep a moment + // ago loses: on the cable, one cold walk came back with the polling rate + // missing and the three walks after it read it. One more pass over whatever + // is still empty costs a healthy mouse nothing - it is only reached while + // the mouse is answering, and every read here is idempotent - and turns + // that gap into a value instead of a zero. + for (const address of addresses) { + if (!blocks.has(address)) await read(address); + } + + const performance = blocks.get(RAPOO_ADDRESS.performance) ?? null; + const sensor = blocks.get(RAPOO_ADDRESS.sensor) ?? null; + const dpi = blocks.get(RAPOO_ADDRESS.dpiX) ?? null; + const activeStage = blocks.get(RAPOO_ADDRESS.activeStage) ?? null; + const timing = blocks.get(RAPOO_ADDRESS.timing) ?? null; + + return { + performance: performance ? decodeRapooPerformance(performance) : null, + sensor: sensor ? decodeRapooSensor(sensor) : null, + dpi: dpi ? decodeRapooDpiTable(dpi) : null, + activeStage: activeStage ? decodeRapooActiveStage(activeStage) : null, + timing: timing ? decodeRapooTiming(timing) : null, + }; + } + + /** + * Read one register block, resending the frame until the device answers. + * + * Reads are idempotent, so a resend costs nothing but time; the alternative - + * treating the first silence as "the register does not exist" - is what makes + * a lossy channel look like a missing feature. + */ + private async readBlock( + native: RapooNativeTransport, + address: number, + length: number, + ): Promise { + const frame = encodeRapooRead(address, length); + for (let round = 0; round < BLOCK_ROUNDS; round += 1) { + if (round > 0) await delay(BLOCK_GAP_MS); + const answer = await this.exchange(native, frame); + const block = answer ? rapooAnswerBlock(answer, length) : null; + if (block) return block; + } + return null; + } + + /** + * Send one frame and wait for the busy -> OK handshake. + * + * The input report holds the previous answer until the receiver picks the + * frame up, so an OK that arrives without a busy in between belongs to the + * command before this one and must not be handed back as data. Nothing + * arrives at all for a dropped frame, so the wait is bounded and the frame is + * sent again. + */ + private async exchange( + native: RapooNativeTransport, + frame: Uint8Array, + ): Promise { + for (let attempt = 0; attempt < EXCHANGE_ATTEMPTS; attempt += 1) { + if (attempt > 0) await delay(EXCHANGE_RETRY_MS); + + try { + await this.device.sendReport(RAPOO_CONFIG_REPORT_ID, frame); + } catch { + continue; + } + + const sent = Date.now(); + let busy = false; + // Busy shows up within a few milliseconds and the fresh answer follows it + // across the radio round trip. A frame that has neither by 120 ms was + // dropped, whatever the transport is doing in the meantime. + while (Date.now() - sent < EXCHANGE_ACCEPT_MS) { + const raw = await this.getInputReport(native); + const answer = raw ? decodeRapooAnswer(raw) : null; + if (answer) { + if (!answer.ok) { + busy = true; + } else if (busy) { + return answer; + } + } + await delay(EXCHANGE_POLL_MS); + } + } + return null; + } + + /** + * The `0xAA` battery query. Its answer is marked by a non-zero byte 1 rather + * than by the handshake, so each send is followed by a short poll for the + * marker instead of the busy -> OK wait. + */ + private async readBattery(native: RapooNativeTransport): Promise { + const frame = encodeRapooBatteryQuery(); + for (let round = 0; round < BATTERY_ROUNDS; round += 1) { + try { + await this.device.sendReport(RAPOO_CONFIG_REPORT_ID, frame); + } catch { + return null; + } + + for (let poll = 0; poll < BATTERY_POLLS; poll += 1) { + const raw = await this.getInputReport(native); + const answer = raw ? decodeRapooAnswer(raw) : null; + const battery = answer ? decodeRapooBatteryAnswer(answer) : null; + if (battery) return battery; + await delay(BATTERY_POLL_MS); + } + } + return null; + } + + private async getInputReport(native: RapooNativeTransport): Promise { + try { + return asBytes(await native.receiveInputReport(RAPOO_CONFIG_REPORT_ID)); + } catch { + // A transport with nothing buffered throws. That is a "not yet", not a + // reason to abandon the exchange. + return null; + } + } + + /** The battery out of the 0x2A feature block, or null when it will not read. */ + private async readFeatureBattery(): Promise { + try { + const view = await this.device.receiveFeatureReport(RAPOO_STATUS_REPORT_ID); + return rapooStatusBatteryPercent(asBytes(view)); + } catch { + return null; + } + } +} diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index c07a94e..af6189d 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -19,8 +19,9 @@ const DEVICES_DIR = dirname(fileURLToPath(import.meta.url)); const REPORT_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0x09, 0x0e, 0x0f, 0x10, 0x11, 0x20, 0xa1, 0xb3, 0xb4, 0xb5]; const USAGE_PAGES = [0x01, 0x0a, 0x0c, 0xFF07, 0xff, 0xff00, 0xff01, 0xff02, 0xff05, 0xff0a, 0xff1c, 0xff43, 0xff55, 0xff60, 0xffa0, 0xffc1, 0xffc2, 0xffff]; -// Usage 4 is the Corsair config collection; 0x61 is VIA raw HID; 0xc7 is Ryunix telemetry. -const USAGES = [0, 1, 0x0212, 2, 4, 0x10, 0x61, 0xc7]; +// Usage 4 is the Corsair config collection; 0x61 is VIA raw HID; 0xc7 is +// Ryunix telemetry; 0x0e is Rapoo's 0xBA configuration channel. +const USAGES = [0, 1, 0x0212, 2, 4, 0x0e, 0x10, 0x61, 0xc7]; function report(reportId: number, byteLength = 16): HIDReportInfo { return { reportId, items: [{ reportSize: 8, reportCount: byteLength }] } as unknown as HIDReportInfo; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index b18abc1..8c1b9d4 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -66,9 +66,10 @@ import { HyperXHidClient } from "./hyperx/hid.ts"; import { MchoseV3HidClient } from "./mchose/v3-hid.ts"; import { KyuProMx1Client } from "./ryunix/kyu-pro-mx1-hid.ts"; import { BytechHidClient } from "./bytech/hid.ts"; +import { RapooHidClient } from "./rapoo/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | KeychronM6HidClient | Keychron4kHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | KeychronM6HidClient | Keychron4kHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient; export interface DeviceDriver { brand: string; @@ -159,6 +160,10 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "Incott", supports: (device) => IncottHidClient.isSupported(device), create: (device) => new IncottHidClient(device), score: () => 8 }, { brand: "HyperX", supports: (device) => HyperXHidClient.isSupported(device), create: (device) => new HyperXHidClient(device), score: () => 5 }, { brand: "Ryunix", supports: (device) => KyuProMx1Client.isSupported(device), create: (device) => new KyuProMx1Client(device), score: () => 7 }, + // Rapoo owns vendor id 0x24AE and the 0xFF00:0x000E configuration + // collection, neither of which another driver claims, so the two product ids + // can be the whole matcher. + { brand: "Rapoo", supports: (device) => RapooHidClient.isSupported(device), create: (device) => new RapooHidClient(device), score: () => 7 }, ]; function driverFor(device: HIDDevice): DeviceDriver | undefined { diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 9f4fbf4..a631804 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -98,6 +98,12 @@ import { RYUNIX_USAGE_PAGE, RYUNIX_VENDOR_ID, } from "@openmouse/protocol/ryunix"; +import { + RAPOO_CONFIG_USAGE, + RAPOO_CONFIG_USAGE_PAGE, + RAPOO_PRODUCT_IDS, + RAPOO_VENDOR_ID, +} from "@openmouse/protocol/rapoo"; import { REDRAGON_CONFIG_USAGE, REDRAGON_CONFIG_USAGE_PAGE, @@ -110,6 +116,7 @@ export const VENDOR_ID = { vaxee: VAXEE_VENDOR_ID, asus: ASUS_VENDOR_ID, ryunix: RYUNIX_VENDOR_ID, + rapoo: RAPOO_VENDOR_ID, motospeed: MOTOSPEED_VENDOR_ID, pulsar: 0x3710, endgameGear: 0x3367, @@ -688,6 +695,15 @@ export const RYUNIX_HID_FILTERS: HIDDeviceFilter[] = [...RYUNIX_PRODUCT_IDS].map usage: RYUNIX_USAGE, })); +// Both the 2.4 GHz receiver and the mouse on its cable answer on the same +// usage page and usage, so one filter per product id covers the family. +export const RAPOO_HID_FILTERS: HIDDeviceFilter[] = [...RAPOO_PRODUCT_IDS].map((productId) => ({ + vendorId: RAPOO_VENDOR_ID, + productId, + usagePage: RAPOO_CONFIG_USAGE_PAGE, + usage: RAPOO_CONFIG_USAGE, +})); + export const MOTOSPEED_HID_FILTERS: HIDDeviceFilter[] = MOTOSPEED_PRODUCTS.map(({ productId }) => ({ vendorId: MOTOSPEED_VENDOR_ID, productId, @@ -802,4 +818,5 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...INCOTT_HID_FILTERS, ...HYPERX_HID_FILTERS, ...RYUNIX_HID_FILTERS, + ...RAPOO_HID_FILTERS, ]; diff --git a/src/index.ts b/src/index.ts index c4dbf8d..aff8ad5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,3 +33,4 @@ export * as redragon from "./redragon/index.js"; export * as delux from "./delux/index.js"; export * as bytech from "./bytech/index.js"; export * as motospeed from "./motospeed/index.js"; +export * as rapoo from "./rapoo/index.js"; diff --git a/src/rapoo/index.test.ts b/src/rapoo/index.test.ts new file mode 100644 index 0000000..1827663 --- /dev/null +++ b/src/rapoo/index.test.ts @@ -0,0 +1,260 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RAPOO_ADDRESS, + RAPOO_CMD_BATTERY, + RAPOO_CMD_READ, + RAPOO_CMD_WRITE, + RAPOO_CONFIG_REPORT_ID, + RAPOO_DPI_STAGES, + RAPOO_PRODUCT_IDS, + RAPOO_STATUS_REPORT_ID, + RAPOO_VENDOR_ID, + decodeRapooActiveStage, + decodeRapooAngle, + decodeRapooAnswer, + decodeRapooBatteryAnswer, + decodeRapooDpiTable, + decodeRapooNotification, + decodeRapooPerformance, + decodeRapooSensor, + decodeRapooTiming, + encodeRapooBatteryQuery, + encodeRapooRead, + encodeRapooWrite, + rapooAnswerBlock, + rapooLinkConnection, + rapooPollingRateHz, + rapooStatusBatteryPercent, +} from "./index.ts"; + +function fromHex(text: string): Uint8Array { + return Uint8Array.from(text.trim().split(/\s+/).map((byte) => Number.parseInt(byte, 16))); +} + +function toHex(bytes: Uint8Array): string { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(" "); +} + +const zeros = (count: number): string => "00 ".repeat(count).trim(); + +/** + * Register blocks exactly as the 2026-09-29 VT9 Pro capture returned them, on + * the 2.4 GHz receiver and again on the cable. Both links agreed byte for byte. + */ +const PERF_BLOCK = fromHex("82 00 82 ff"); +const SENSOR_BLOCK = fromHex("01 01 01 00"); +const DPI_X_BLOCK = fromHex("90 01 20 03 b0 04 40 06 80 0c 00 19 90 65 02 00"); +const ACTIVE_STAGE_BLOCK = fromHex("01 00 02 01"); +const TIMING_BLOCK = fromHex("04 04 78 03"); +const ANGLE_BLOCK = fromHex("00 00 01 00"); + +test("a read frame is the 31-byte 0xBA report the vendor driver sends", () => { + assert.equal( + toHex(encodeRapooRead(RAPOO_ADDRESS.performance, 4)), + `a5 a4 04 80 08 00 00 ${zeros(24)}`, + ); + assert.equal( + toHex(encodeRapooRead(RAPOO_ADDRESS.dpiX, 16)), + `a5 a4 10 88 08 00 00 ${zeros(24)}`, + ); +}); + +test("a write frame carries the block behind the address", () => { + assert.equal( + toHex(encodeRapooWrite(RAPOO_ADDRESS.performance, [...PERF_BLOCK])), + `a5 a5 04 80 08 00 00 82 00 82 ff ${zeros(20)}`, + ); +}); + +test("the battery query has no address and no length", () => { + assert.equal( + toHex(encodeRapooBatteryQuery()), + `a5 aa 00 00 00 00 00 ${zeros(24)}`, + ); +}); + +test("a frame hides the report id the transport may or may not keep", () => { + const payload = "01 00 00 00 82 00 82 ff"; + const stripped = decodeRapooAnswer(fromHex(payload)); + const kept = decodeRapooAnswer(fromHex(`ba ${payload}`)); + + assert.equal(stripped?.status, 0x01); + assert.equal(stripped?.ok, true); + assert.equal(stripped?.busy, false); + assert.equal(stripped?.marker, 0); + assert.equal(toHex(stripped!.payload), "82 00 82 ff"); + assert.deepEqual(kept, stripped); +}); + +test("an answer that is still busy is not mistaken for data", () => { + const answer = decodeRapooAnswer(fromHex("02 00 00 00 82 00 82 ff")); + assert.equal(answer?.ok, false); + assert.equal(answer?.busy, true); +}); + +test("a short buffer is not an answer at all", () => { + assert.equal(decodeRapooAnswer(Uint8Array.of()), null); + assert.equal(decodeRapooAnswer(fromHex("01 00 00")), null); +}); + +test("a block is only taken from an answered frame of the right size", () => { + const answer = decodeRapooAnswer(fromHex("01 00 00 00 82 00 82 ff")); + assert.equal(toHex(rapooAnswerBlock(answer!, 4)!), "82 00 82 ff"); + // The mouse answers a 4-byte read with 4 bytes; asking for more than it sent + // must not silently pad. + assert.equal(rapooAnswerBlock(answer!, 16), null); +}); + +test("the performance block decodes both links and keeps what it cannot explain", () => { + assert.deepEqual(decodeRapooPerformance(PERF_BLOCK), { + receiverHz: 4000, + wiredHz: 4000, + reserved: [0x00, 0xff], + }); + // An unknown rate code stays null instead of becoming a wrong number. + assert.equal(decodeRapooPerformance(fromHex("7f 00 82 ff"))?.receiverHz, null); + assert.equal(decodeRapooPerformance(fromHex("82 00")), null); +}); + +test("the vendor driver's polling table is carried in full", () => { + assert.equal(rapooPollingRateHz(0x08), 125); + assert.equal(rapooPollingRateHz(0x01), 1000); + assert.equal(rapooPollingRateHz(0x84), 2000); + assert.equal(rapooPollingRateHz(0x82), 4000); + assert.equal(rapooPollingRateHz(0x81), 8000); + assert.equal(rapooPollingRateHz(0x00), null); +}); + +test("the sensor block decodes lift-off and motion sync", () => { + assert.deepEqual(decodeRapooSensor(SENSOR_BLOCK), { liftOffIndex: 1, motionSync: true }); + assert.deepEqual(decodeRapooSensor(fromHex("00 00 01 00")), { + liftOffIndex: 0, + motionSync: false, + }); +}); + +test("the DPI table decodes every stage and the count byte", () => { + const table = decodeRapooDpiTable(DPI_X_BLOCK); + assert.deepEqual(table?.stages, [400, 800, 1200, 1600, 3200, 6400, 26000]); + // A stored byte of 2 means three stages are switched on. + assert.equal(table?.countByte, 2); + assert.equal(table?.enabledStages, 3); + assert.equal(decodeRapooDpiTable(DPI_X_BLOCK)?.stages.length, RAPOO_DPI_STAGES); + assert.equal(decodeRapooDpiTable(fromHex("90 01 20 03")), null); +}); + +test("a count byte past the end of the table is clamped, not wrapped", () => { + const table = decodeRapooDpiTable(fromHex("90 01 20 03 b0 04 40 06 80 0c 00 19 90 65 ff 00")); + assert.equal(table?.enabledStages, RAPOO_DPI_STAGES); +}); + +test("the active stage is decoded 0 based and bounded", () => { + assert.equal(decodeRapooActiveStage(ACTIVE_STAGE_BLOCK), 1); + assert.equal(decodeRapooActiveStage(fromHex("00 00 02 01")), 0); + // Nothing beyond the table can be an active stage. + assert.equal(decodeRapooActiveStage(fromHex("09 00 02 01")), null); +}); + +test("the timing block decodes debounce, sleep and the disable flags", () => { + assert.deepEqual(decodeRapooTiming(TIMING_BLOCK), { + pressDebounceMs: 16, + releaseDebounceMs: 16, + sleepMinutes: 120, + angleSnapOff: true, + rippleOff: true, + flags: 0x03, + }); + // Bit 0 clear means angle snap is on, and the same for ripple on bit 1. + assert.deepEqual(decodeRapooTiming(fromHex("00 00 78 00")), { + pressDebounceMs: 1, + releaseDebounceMs: 1, + sleepMinutes: 120, + angleSnapOff: false, + rippleOff: false, + flags: 0x00, + }); +}); + +test("an unlisted debounce index is reported as unknown rather than guessed", () => { + const timing = decodeRapooTiming(fromHex("09 04 78 03")); + assert.equal(timing?.pressDebounceMs, null); + assert.equal(timing?.releaseDebounceMs, 16); +}); + +test("the angle block decodes degrees", () => { + assert.equal(decodeRapooAngle(ANGLE_BLOCK), 0); + assert.equal(decodeRapooAngle(fromHex("05 00 01 00")), 5); + assert.equal(decodeRapooAngle(Uint8Array.of()), null); +}); + +test("the battery answer is told apart from a block answer by its marker", () => { + const answer = decodeRapooAnswer(fromHex("01 01 62 00 00 00 00 00")); + assert.equal(answer?.marker, 1); + assert.deepEqual(decodeRapooBatteryAnswer(answer!), { percent: 98, charging: false }); + + const charging = decodeRapooAnswer(fromHex("01 02 45 00 00 00 00 00")); + assert.deepEqual(decodeRapooBatteryAnswer(charging!), { percent: 69, charging: true }); + + // A block answer has a zero marker and is not a battery reading. + assert.equal(decodeRapooBatteryAnswer(decodeRapooAnswer(fromHex("01 00 00 00 82 00 82 ff"))!), null); + // Nor is an unlisted marker. + assert.equal(decodeRapooBatteryAnswer(decodeRapooAnswer(fromHex("01 07 62 00"))!), null); + // Nor is an impossible percentage. + assert.equal(decodeRapooBatteryAnswer(decodeRapooAnswer(fromHex("01 01 7f 00"))!), null); +}); + +test("the 0xBB notification decodes the link byte and the percentage", () => { + assert.deepEqual(decodeRapooNotification(fromHex("b0 51 20 03 01 62")), { + linkCode: 0x01, + batteryPercent: 98, + }); + assert.deepEqual(decodeRapooNotification(fromHex("b0 51 20 03 01 64")), { + linkCode: 0x01, + batteryPercent: 100, + }); + + // The header is checked, so another report on 0xBB cannot be read as status. + assert.equal(decodeRapooNotification(fromHex("00 51 20 03 01 62")), null); + assert.equal(decodeRapooNotification(fromHex("b0 51 20 03 01")), null); + // A percentage outside 0-100 is dropped rather than shown. + assert.equal(decodeRapooNotification(fromHex("b0 51 20 03 01 7f"))?.batteryPercent, null); +}); + +test("the notification link byte maps to a connection where it is known", () => { + assert.equal(rapooLinkConnection(0x01), "Wireless"); + assert.equal(rapooLinkConnection(0x02), "Wired"); + assert.equal(rapooLinkConnection(0x03), null); +}); + +test("the 0x2A feature block yields the battery a browser can read", () => { + // Windows' GET_REPORT(Feature) keeps the report id in front of the data. + assert.equal( + rapooStatusBatteryPercent(fromHex("2a 01 00 00 04 21 50 00 00 62 00")), + 98, + ); + assert.equal( + rapooStatusBatteryPercent(fromHex("2a 01 00 00 04 11 20 00 00 62 00")), + 98, + ); + // WebHID's receiveFeatureReport may hand back the data with the id stripped. + assert.equal(rapooStatusBatteryPercent(fromHex("01 00 00 04 21 50 00 00 62 00")), 98); + assert.equal(rapooStatusBatteryPercent(fromHex("01 00 00 04 11 20 00 00 62 00")), 98); + // Too short to hold the byte, and a percentage that cannot be one. + assert.equal(rapooStatusBatteryPercent(fromHex("2a 01 00 00 04 21 50 00")), null); + assert.equal(rapooStatusBatteryPercent(fromHex("2a 01 00 00 04 21 50 00 00 7f 00")), null); +}); + +test("the catalog names the two interfaces this mouse has been seen on", () => { + assert.equal(RAPOO_VENDOR_ID, 0x24ae); + assert.deepEqual([...RAPOO_PRODUCT_IDS].sort((a, b) => a - b), [0x1205, 0x4405]); +}); + +test("commands and report ids match the vendor driver's constants", () => { + assert.equal(RAPOO_CMD_READ, 0xa4); + assert.equal(RAPOO_CMD_WRITE, 0xa5); + assert.equal(RAPOO_CMD_BATTERY, 0xaa); + assert.equal(RAPOO_CONFIG_REPORT_ID, 0xba); + assert.equal(RAPOO_STATUS_REPORT_ID, 0x2a); +}); diff --git a/src/rapoo/index.ts b/src/rapoo/index.ts new file mode 100644 index 0000000..476794c --- /dev/null +++ b/src/rapoo/index.ts @@ -0,0 +1,482 @@ +/** + * Rapoo's vendor configuration channel (VT9 Pro and its family). + * + * Two independent sources pin the layout down, and they agree byte for byte: + * + * - a capture from a real VT9 Pro on 2026-09-29 (receiver 0x24AE:0x1205, + * cable 0x24AE:0x4405) reading every register below twice, on both links; + * - mousectl's `rapoo_vt3pro` driver, reverse engineered from Rapoo's own + * `RapooGameDevDriver` 1.6.29, which documents the same frame and the same + * addresses for the VT3 PRO. + * + * The request is 31 bytes of output report 0xBA on the 0xFF00:0x000E + * interface: one connection byte, then 30 payload bytes. + * + * [0] connection byte [1] command + * [2] payload length [3..6] address, u32 little endian + * [7..] data + * + * Those 31 bytes are the *report data*: the report id goes to the transport + * separately, the way WebHID's `sendReport` and OpenMouse Bridge take it. + * Windows reports the same report as 32 bytes because `HidP_GetCaps` counts + * the id, and hidraw wants it prepended - both of those are this 31 plus one + * byte. Sending 32 bytes of data instead is refused before it reaches the + * mouse (measured in the browser: `Failed to write the report`). + * + * The answer travels back through GET_REPORT(Input) rather than as an + * interrupt-IN report, which is why WebHID cannot see it: the browser has no + * equivalent call. Transports that can issue it (Windows `HidD_GetInputReport`, + * Linux `HIDIOCGINPUT`, OpenMouse Bridge's `receiveInputReport`) deliver the + * report id stripped, leaving the status byte first: + * + * [0] status, 0x01 when the frame was answered + * [1] 0 for every block answer, non-zero on a battery answer + * [2] battery percentage on a battery answer + * [4..] payload + * + * Two measured properties of the link shape every caller, so they belong here + * rather than in a driver: + * + * - **the channel is lossy.** About 40% of frames are dropped without the + * device ever going busy (busy is a status byte other than 0x01). An OK that + * was not preceded by a busy is the *previous* command's answer, so a read + * has to be retried, and "no answer" never means "no such register"; + * - **the connection byte is not validated.** 0xFF and 0xA5 both answered, as + * did 0x00, 0x01 and 0x5A. A dropped frame must therefore never be read as + * "wrong connection type". + * + * Everything in this module is transport independent: it encodes frames, + * decodes answers, and turns register blocks into protocol values. Retries, + * sleeps and the read-modify-write of a live device belong to the driver. + */ + +export const RAPOO_VENDOR_ID = 0x24ae; + +/** The 2.4 GHz receiver's configuration interface. */ +export const RAPOO_WIRELESS_PRODUCT_ID = 0x1205; + +/** The same mouse reached over its cable. */ +export const RAPOO_WIRED_PRODUCT_ID = 0x4405; + +export const RAPOO_PRODUCT_IDS: ReadonlySet = new Set([ + RAPOO_WIRELESS_PRODUCT_ID, + RAPOO_WIRED_PRODUCT_ID, +]); + +export const RAPOO_CONFIG_USAGE_PAGE = 0xff00; +export const RAPOO_CONFIG_USAGE = 0x000e; + +/** Report data length of the command channel, in both directions. */ +export const RAPOO_CONFIG_REPORT_ID = 0xba; + +/** The unsolicited status report; see {@link decodeRapooNotification}. */ +export const RAPOO_NOTIFY_REPORT_ID = 0xbb; + +/** + * A separate interface (0xFF0B:0x0104) that answers a GET_REPORT(Feature) with + * a status block. It is the only Rapoo read WebHID can perform, because + * `receiveFeatureReport` exists in the browser and `GET_REPORT(Input)` does + * not. + */ +export const RAPOO_STATUS_USAGE_PAGE = 0xff0b; +export const RAPOO_STATUS_USAGE = 0x0104; +export const RAPOO_STATUS_REPORT_ID = 0x2a; + +export const RAPOO_FRAME_LENGTH = 31; + +export const RAPOO_CMD_READ = 0xa4; +export const RAPOO_CMD_WRITE = 0xa5; +export const RAPOO_CMD_BATTERY = 0xaa; + +/** The status byte of an answered frame. Anything else means "not ready". */ +export const RAPOO_STATUS_OK = 0x01; + +/** + * What mousectl's driver sends, and the values that were measured answering. + * Nothing validates this byte on the VT9 Pro - see the module comment. + */ +export const RAPOO_CONNECTION_WIRED = 0xff; +export const RAPOO_CONNECTION_RECEIVER = 0xa5; + +/** + * EEPROM addresses: profile 0's base of 0x600 plus the offsets the Windows + * driver uses. The VT9 Pro and the VT3 PRO agree on all of them. + */ +export const RAPOO_ADDRESS = { + /** [0] polling rate on 2.4 GHz, [2] the same on the cable. */ + performance: 0x880, + /** [0] lift-off index, [1] motion sync. */ + sensor: 0x884, + /** 7 little-endian u16 DPI values, [14] the stage count byte. */ + dpiX: 0x888, + /** [0] the active stage index, 0 based. */ + activeStage: 0x898, + /** [0] press debounce, [1] release debounce, [2] sleep minutes, [3] flags. */ + timing: 0x8c0, + /** [0] sensor angle in degrees. */ + angle: 0x8c4, + /** Same layout as {@link RAPOO_ADDRESS.dpiX}. */ + dpiY: 0x8c8, +} as const; + +/** + * The block length the Windows driver reads for each address. A read has to + * ask for the same length the vendor tool does: the device answers with the + * block it has, not with what was asked for. + */ +export const RAPOO_BLOCK_LENGTH: Readonly> = { + [RAPOO_ADDRESS.performance]: 4, + [RAPOO_ADDRESS.sensor]: 4, + [RAPOO_ADDRESS.dpiX]: 16, + [RAPOO_ADDRESS.activeStage]: 4, + [RAPOO_ADDRESS.timing]: 4, + [RAPOO_ADDRESS.angle]: 4, + [RAPOO_ADDRESS.dpiY]: 16, +}; + +/** Polling-rate byte to Hz, straight from the vendor driver's switch table. */ +export const RAPOO_POLLING_RATES: Readonly> = { + 0x08: 125, + 0x04: 250, + 0x02: 500, + 0x01: 1000, + 0x84: 2000, + 0x82: 4000, + // Decoded so an 8K model reads correctly, but deliberately not offered as an + // option anywhere: the VT3 PRO profile has support8k = false and the VT9 Pro + // has never been seen on 8000 Hz. + 0x81: 8000, +}; + +export function rapooPollingRateHz(code: number): number | null { + return RAPOO_POLLING_RATES[code] ?? null; +} + +/** Debounce values the vendor driver's table indexes, in milliseconds. */ +export const RAPOO_DEBOUNCE_MS: readonly number[] = [1, 2, 4, 8, 16, 24, 32]; + +/** How many DPI stages the table holds. */ +export const RAPOO_DPI_STAGES = 7; + +/** + * Bit 0 of {@link RAPOO_ADDRESS.timing}'s flag byte means "angle snap is off", + * bit 1 means "ripple control is off" - the vendor driver stores *disable* + * flags here, so a set bit is a feature the user turned off. + */ +export const RAPOO_FLAG_ANGLE_SNAP_OFF = 0x01; +export const RAPOO_FLAG_RIPPLE_OFF = 0x02; + +export interface RapooFrameOptions { + /** Defaults to {@link RAPOO_CONNECTION_RECEIVER}. */ + connection?: number; + data?: readonly number[]; + /** Overrides the length byte; the data still has to be passed separately. */ + length?: number; +} + +/** + * Build one command frame: the whole report data of 0xBA, 31 bytes. + * + * The frame is zero padded, so a write of fewer bytes than the block length + * still sends the whole block - which is what the vendor driver does, and what + * the device expects. + */ +export function encodeRapooFrame( + command: number, + address: number, + options: RapooFrameOptions = {}, +): Uint8Array { + if (!Number.isInteger(address) || address < 0 || address > 0xffffffff) { + throw new RangeError(`Rapoo address out of range: ${address}`); + } + if (!Number.isInteger(command) || command < 0 || command > 0xff) { + throw new RangeError(`Rapoo command out of range: ${command}`); + } + + const data = options.data ?? []; + const capacity = RAPOO_FRAME_LENGTH - 7; + if (data.length > capacity) { + throw new RangeError(`Rapoo frame holds ${capacity} data bytes, got ${data.length}`); + } + + const frame = new Uint8Array(RAPOO_FRAME_LENGTH); + frame[0] = options.connection ?? RAPOO_CONNECTION_RECEIVER; + frame[1] = command; + frame[2] = options.length ?? data.length; + frame[3] = address & 0xff; + frame[4] = (address >>> 8) & 0xff; + frame[5] = (address >>> 16) & 0xff; + frame[6] = (address >>> 24) & 0xff; + for (let index = 0; index < data.length; index += 1) frame[7 + index] = data[index]; + return frame; +} + +export function encodeRapooRead( + address: number, + length: number, + connection: number = RAPOO_CONNECTION_RECEIVER, +): Uint8Array { + return encodeRapooFrame(RAPOO_CMD_READ, address, { connection, length }); +} + +export function encodeRapooWrite( + address: number, + data: readonly number[], + connection: number = RAPOO_CONNECTION_RECEIVER, +): Uint8Array { + return encodeRapooFrame(RAPOO_CMD_WRITE, address, { connection, data }); +} + +/** + * The battery query. Unlike a block read it carries address 0 and length 0, and + * its answer does not follow the busy -> OK handshake: the value is already in + * the device's input buffer, so callers have to look for the marker byte. + */ +export function encodeRapooBatteryQuery( + connection: number = RAPOO_CONNECTION_RECEIVER, +): Uint8Array { + return encodeRapooFrame(RAPOO_CMD_BATTERY, 0, { connection, length: 0 }); +} + +/** + * Index of the status byte in a raw GET_REPORT(Input) buffer. + * + * Windows strips the report id and Linux hidraw may keep it, so both layouts + * occur in the wild; mousectl accepts either and so does this. The status byte + * is 0x01 when answered and something else while busy, so a 0xBA in front of + * the buffer is unambiguous. + */ +export function rapooAnswerOffset(bytes: Uint8Array): 0 | 1 { + return bytes[0] === RAPOO_CONFIG_REPORT_ID ? 1 : 0; +} + +export interface RapooAnswer { + /** Byte 0: {@link RAPOO_STATUS_OK} when this frame was answered. */ + status: number; + ok: boolean; + /** + * True while the device is still working on the frame before this one. It is + * the expected first state of an exchange, not an error. + */ + busy: boolean; + /** Byte 1: 0 on a block answer, non-zero on a battery answer. */ + marker: number; + /** Everything from byte 4 on, i.e. the block a read asked for. */ + payload: Uint8Array; + /** The answer with any leading report id removed. */ + data: Uint8Array; +} + +/** + * Classify one raw answer. Returns null for a buffer too short to carry a + * status byte, so a transport can treat a non-answer as a non-answer rather + * than as a device that said 0x00. + */ +export function decodeRapooAnswer(bytes: Uint8Array): RapooAnswer | null { + const offset = rapooAnswerOffset(bytes); + const data = bytes.subarray(offset); + if (data.length < 4) return null; + + const status = data[0]; + return { + status, + ok: status === RAPOO_STATUS_OK, + busy: status !== RAPOO_STATUS_OK, + marker: data[1], + payload: data.subarray(4), + data, + }; +} + +/** The block of `length` bytes an answered read carries, or null. */ +export function rapooAnswerBlock(answer: RapooAnswer, length: number): Uint8Array | null { + if (!answer.ok || answer.payload.length < length) return null; + return answer.payload.subarray(0, length); +} + +function readUint16(bytes: Uint8Array, offset: number): number { + return bytes[offset] | (bytes[offset + 1] << 8); +} + +export interface RapooPerformance { + /** Polling rate while the mouse talks to its receiver. */ + receiverHz: number | null; + /** Polling rate while it is on the cable. */ + wiredHz: number | null; + /** Bytes 1 and 3, whose meaning is not established. Preserved unread. */ + reserved: readonly [number, number]; +} + +/** {@link RAPOO_ADDRESS.performance}: `82 00 82 ff` on a 4000 Hz VT9 Pro. */ +export function decodeRapooPerformance(block: Uint8Array): RapooPerformance | null { + if (block.length < 4) return null; + return { + receiverHz: rapooPollingRateHz(block[0]), + wiredHz: rapooPollingRateHz(block[2]), + reserved: [block[1], block[3]], + }; +} + +export interface RapooSensor { + /** + * Raw lift-off selector. The scale it indexes is not established for this + * mouse (the vendor bundle carries both a 1.0-2.0 mm and a 0.7-1.7 mm + * ladder), so it is exposed as the raw code rather than as millimetres. + */ + liftOffIndex: number; + motionSync: boolean; +} + +/** {@link RAPOO_ADDRESS.sensor}: `01 01 01 00` = index 1, motion sync on. */ +export function decodeRapooSensor(block: Uint8Array): RapooSensor | null { + if (block.length < 2) return null; + return { liftOffIndex: block[0], motionSync: block[1] === 1 }; +} + +export interface RapooDpiTable { + /** Every stored stage, in device order, as the mouse stores it. */ + stages: number[]; + /** + * How many stages are switched on. The mouse stores that count byte as one + * less than the number of active stages: a byte of 2 means three stages. + */ + enabledStages: number; + /** The stored byte, for callers that would rather not work from the count. */ + countByte: number; +} + +/** {@link RAPOO_ADDRESS.dpiX} and {@link RAPOO_ADDRESS.dpiY}. */ +export function decodeRapooDpiTable(block: Uint8Array): RapooDpiTable | null { + const countOffset = RAPOO_DPI_STAGES * 2; + if (block.length < countOffset + 1) return null; + + const stages: number[] = []; + for (let index = 0; index < RAPOO_DPI_STAGES; index += 1) { + stages.push(readUint16(block, index * 2)); + } + + const countByte = block[countOffset]; + return { + stages, + enabledStages: Math.min(countByte + 1, RAPOO_DPI_STAGES), + countByte, + }; +} + +/** {@link RAPOO_ADDRESS.activeStage}: `01 00 02 01` = the second stage. */ +export function decodeRapooActiveStage(block: Uint8Array): number | null { + if (block.length < 1) return null; + const index = block[0]; + return index < RAPOO_DPI_STAGES ? index : null; +} + +export interface RapooTiming { + /** Debounce in milliseconds, or null when the stored index is unknown. */ + pressDebounceMs: number | null; + releaseDebounceMs: number | null; + sleepMinutes: number; + angleSnapOff: boolean; + rippleOff: boolean; + /** The raw flag byte, so a caller can preserve what it does not understand. */ + flags: number; +} + +/** {@link RAPOO_ADDRESS.timing}: `04 04 78 03` = 16/16 ms, 120 min. */ +export function decodeRapooTiming(block: Uint8Array): RapooTiming | null { + if (block.length < 4) return null; + + const flags = block[3]; + return { + pressDebounceMs: RAPOO_DEBOUNCE_MS[block[0]] ?? null, + releaseDebounceMs: RAPOO_DEBOUNCE_MS[block[1]] ?? null, + sleepMinutes: block[2], + angleSnapOff: (flags & RAPOO_FLAG_ANGLE_SNAP_OFF) !== 0, + rippleOff: (flags & RAPOO_FLAG_RIPPLE_OFF) !== 0, + flags, + }; +} + +/** {@link RAPOO_ADDRESS.angle}: `00 00 01 00` = 0 degrees. */ +export function decodeRapooAngle(block: Uint8Array): number | null { + if (block.length < 1) return null; + return block[0]; +} + +export interface RapooBattery { + percent: number; + charging: boolean; +} + +/** + * The `0xAA` answer, which is the only place the charge state is readable. + * + * Returns null when the byte is 0 (that is a block answer, not a battery one) + * or when the marker is a value neither the vendor driver's table nor this + * capture explains. + */ +export function decodeRapooBatteryAnswer(answer: RapooAnswer): RapooBattery | null { + if (answer.data.length < 3) return null; + const marker = answer.data[1]; + if (marker !== 1 && marker !== 2) return null; + + const percent = answer.data[2]; + if (percent > 100) return null; + return { percent, charging: marker === 2 }; +} + +export interface RapooNotification { + /** The link byte, `0x01` on the receiver in every capture so far. */ + linkCode: number; + batteryPercent: number | null; +} + +/** + * Input report 0xBB, which the mouse sends unprompted roughly every three + * seconds: `b0 51 20 03 01 62` at 98% on the receiver. + * + * The header is checked rather than assumed, so an unrelated report that + * happens to carry id 0xBB is rejected. Bytes 2 and 3 have been 0x20 and 0x03 + * in every observation but are not understood. + */ +export function decodeRapooNotification(bytes: Uint8Array): RapooNotification | null { + if (bytes.length < 6) return null; + if (bytes[0] !== 0xb0 || bytes[1] !== 0x51) return null; + + const percent = bytes[5]; + return { + linkCode: bytes[4], + batteryPercent: percent <= 100 ? percent : null, + }; +} + +/** + * The link byte of {@link decodeRapooNotification}. + * + * `0x01` was measured on the receiver. `0x02` as the cable is a hypothesis - + * it was never seen, because every capture had the receiver attached - so a + * caller that needs the connection for certain should fall back to the product + * id instead. + */ +export function rapooLinkConnection(linkCode: number): "Wireless" | "Wired" | null { + if (linkCode === 0x01) return "Wireless"; + if (linkCode === 0x02) return "Wired"; + return null; +} + +/** + * Battery percentage out of the `0x2A` feature block on 0xFF0B:0x0104, which + * is the one Rapoo read a browser can perform. + * + * Observed with the two links differing at byte 5 of the report data: + * `2a 01 00 00 04 21 50 00 00 62` on the cable and `... 11 20 ...` on the + * receiver. Windows' GET_REPORT keeps the report id in front of the data and + * WebHID's `receiveFeatureReport` may not, so the reading is taken relative to + * the report data rather than at a fixed offset. Only the percentage is + * understood; the rest of the block is not. + */ +export function rapooStatusBatteryPercent(bytes: Uint8Array): number | null { + const offset = bytes[0] === RAPOO_STATUS_REPORT_ID ? 1 : 0; + if (bytes.length < offset + 9) return null; + const percent = bytes[offset + 8]; + return percent <= 100 ? percent : null; +} diff --git a/tsconfig.json b/tsconfig.json index ec00d7d..4404799 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,7 +47,8 @@ "@openmouse/protocol/zaunkoenig": ["./src/zaunkoenig/index.ts"], "@openmouse/protocol/corsair": ["./src/corsair/index.ts"], "@openmouse/protocol/delux": ["./src/delux/index.ts"], - "@openmouse/protocol/bytech": ["./src/bytech/index.ts"] + "@openmouse/protocol/bytech": ["./src/bytech/index.ts"], + "@openmouse/protocol/rapoo": ["./src/rapoo/index.ts"] } }, "include": ["src/**/*"], From 3d9cc93049ef916625697971387f0c88a0affb1a Mon Sep 17 00:00:00 2001 From: snekxs Date: Wed, 30 Sep 2026 15:48:48 -0600 Subject: [PATCH 2/2] fix(rapoo): pass the config frame as an ArrayBuffer to sendReport --- src/drivers/rapoo/hid.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/drivers/rapoo/hid.ts b/src/drivers/rapoo/hid.ts index e78f004..35c609f 100644 --- a/src/drivers/rapoo/hid.ts +++ b/src/drivers/rapoo/hid.ts @@ -445,7 +445,7 @@ export class RapooHidClient { if (attempt > 0) await delay(EXCHANGE_RETRY_MS); try { - await this.device.sendReport(RAPOO_CONFIG_REPORT_ID, frame); + await this.device.sendReport(RAPOO_CONFIG_REPORT_ID, frame.buffer as ArrayBuffer); } catch { continue; } @@ -480,7 +480,7 @@ export class RapooHidClient { const frame = encodeRapooBatteryQuery(); for (let round = 0; round < BATTERY_ROUNDS; round += 1) { try { - await this.device.sendReport(RAPOO_CONFIG_REPORT_ID, frame); + await this.device.sendReport(RAPOO_CONFIG_REPORT_ID, frame.buffer as ArrayBuffer); } catch { return null; }