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 8ad915e..5838179 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -153,7 +153,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" | "Noir Gear" | "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" | "Noir Gear" | "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..35c609f --- /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.buffer as ArrayBuffer); + } 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.buffer as ArrayBuffer); + } 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 ed3c4da..e6969b5 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -20,8 +20,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 74febad..ada92e7 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -68,9 +68,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 | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient; +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 | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient; export interface DeviceDriver { brand: string; @@ -162,6 +163,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/**/*"],