diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index 80f80a0c..48963e18 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -9,7 +9,7 @@ const BUDGET_BYTES: Record = { // guarded flash editor, verification exporter, upstream Finalmouse driver, // the dedicated Viper Mini protocol driver, and Viper V3 sleep/low-power plus // asymmetric lift-off protocol and controls. Preview fixtures remain dev-only. - ".js": 325_000, + ".js": 335_000, }; const ASSETS = join("dist", "assets"); diff --git a/src/devices/razer/TESTING.md b/src/devices/razer/TESTING.md index 96e0353b..a0e11883 100644 --- a/src/devices/razer/TESTING.md +++ b/src/devices/razer/TESTING.md @@ -3,16 +3,26 @@ Test in Chrome or Edge over HTTPS. Quit Razer Synapse first — it holds the control interface open and reads then time out. -Supported identifiers: +Identifiers verified on hardware: - `1532:00a5` — Viper V2 Pro, wired - `1532:00a6` — Viper V2 Pro, Stock receiver - `1532:00c0` — Viper V3 Pro, wired - `1532:00c1` — Viper V3 Pro, HyperSpeed receiver -- `1532:008a` — Viper Mini, wired +- `1532:008a` — Viper Mini, wired (separate driver) +- `1532:00b8` — Viper V3 HyperSpeed, stock HyperSpeed receiver + +Claimed but never connected: + - `1532:006e` — DeathAdder Essential, wired - `1532:0071` — DeathAdder Essential White Edition, wired - `1532:0098` — DeathAdder Essential (2021), wired +- 99 further products from the OpenRazer reference + +These three shipped with the driver long before the registry existed and were +listed here as supported, but the section below has always described them as not +hardware-tested. See [Untested models](#untested-models) before testing any of +them. Razer does not declare its control channel in the HID descriptor, so no interface advertises a feature report. The exchange still works because WebHID @@ -64,6 +74,295 @@ other control is withheld because no command for it has been confirmed. 11. Record the device identifier, firmware version, and any failing setting in the issue or pull request. +## Viper V3 HyperSpeed (`1532:00b8`) — verified on the stock receiver + +Battery, DPI read/write, lift-off distance and sleep timeout all behaved. One +entry in the registry was wrong and is now corrected: + +**The stock HyperSpeed receiver rejects the extended polling command** +(`0x00`/`0x40`, a divisor of 8000) as unsupported, and answers only the legacy +divisor-of-1000 one. 125, 500 and 1000 Hz were each written and read back +successfully after `highRatePolling` was set to `false`. + +This is the first product to show that `highRatePolling` is genuinely per-PID. +It cannot be inferred from either of the obvious rules: + +| Rule you might infer | Counter-example | +| --- | --- | +| "wireless ⇒ extended command" | `0x00b8` is wireless and refuses it | +| "1000 Hz ceiling ⇒ legacy command" | `0x00a6` tops out at 1000 Hz and uses the extended one | + +Both are wireless receivers advertising the same three rates, and they disagree, +so this field has to be settled per product and must not be tidied onto a group +default. `devices.test.ts` pins the pair against exactly that. + +Still untested on this model: the asymmetric lift-off pair. `asymmetricLiftOff` +stays `false`, so the mode probe — which is a *write* — is never sent and the +mouse keeps the plain three-stop tracking control. The reported lift-off +behaviour is that control, not the pair. + +## Basilisk X HyperSpeed (`1532:0083`) — a command that answers without existing + +Reported: every lift-off level failed, two different ways. + +| Level | Sent | Reply | Message | +| --- | --- | --- | --- | +| Medium | `00 04 01 01` | `0x02` OK, args echoed | "kept Low tracking distance instead of Medium" | +| High | `00 04 01 02` | `0x05` unsupported | "Class 0x0b command 0x0b is not supported" | + +The mouse has no class `0x0b` lift-off. What made it look as though it did is +the **read**: `0x0b`/`0x85` answers status `0x02` with an all-zero payload, on +every call, and `decodeLiftOff` maps `args[2] = 0` to a perfectly legitimate +**"Low"**. The driver offered the control because the read succeeded. + +There is no reply that separates "no lift-off control" from "Low at the bottom +of the range" — `0` is a valid level — so this cannot be probed and is now a +per-product `liftOff` flag, off unless a hardware report turned it on. It also +saves `0x0b` a round trip on every background refresh for the 102 products that +do not have it. + +**This is the third capability that could not be probed**, after +`highRatePolling` and `asymmetricLiftOff`. The pattern is worth stating plainly: +a Razer mouse answering a command is not evidence it implements it. Firmware +acknowledges commands it ignores, and returns zeroed payloads that decode as +valid values. Any future capability must default off and be turned on by a +hardware report. + +The same capture also showed the battery level (`0x07`/`0x80` → `0x35`, 21%) +being discarded because the charging query (`0x07`/`0x84`) is unsupported on +this model and took the whole read down with it. The two are now read +independently; an unreadable charging state reports `Unknown` rather than +costing the level. + +## DeathAdder V3 Pro (`1532:00b6`) — open: a write that confirms but may not apply + +Reported: polling rate, low power mode and auto sleep "don't flash into the +mouse", yet changing them in Razer's own software does show up here. + +The capture does not support a bug in the write path. Across 80 events and 252 +reports there were **zero failures**, and every write was confirmed by a +separate read of the paired getter: + +| Set | Wrote | Read back | Still reading, 20 min later | +| --- | --- | --- | --- | +| Polling 125 Hz | `00`/`05` `[08]` | `00`/`85` → `08` | `08` | +| Low power 5% | `07`/`01` `[0d 00]` | `07`/`81` → `0d 00` | `0d 00` | +| Auto sleep 300 s | `07`/`03` `[01 2c]` | `07`/`83` → `01 2c` | `01 2c` | + +Transaction id `0x1f` is right, the bytes match OpenRazer's `set_polling_rate`, +`set_idle_time` and `set_low_battery_threshold`, and `decodeRazerResponse` +checked class and id on every reply. So if the reporter is right, **the register +that answers is not the register that governs the hardware** — the Basilisk X +lesson one level deeper: reading back what you wrote is not proof it applied. + +### Polling: resolved on the receiver (`1532:00b7`) + +A second capture, on the receiver rather than the cable, wrote 500, 125 and +1000 Hz on the extended command (`0x00`/`0x40`) and read every one back from +`0x00`/`0xc0` correctly — while the **measured** report rate stayed at 1000 Hz +throughout. + +`0x00b7` is the stock HyperSpeed receiver, whose ceiling is 1000 Hz. The 8000 Hz +HyperPolling Wireless Dongle is a different device (`0x00b3`) that this driver +does not claim, so a mouse reaching us on this product id is never on one. The +extended encoding expresses the rate as a divisor of 8000, so it was addressing +a range this hardware does not have; the firmware stored the value and kept +running at 1000. `highRatePolling` is now `false` here, which sends the legacy +command and its divisor of 1000 — enough for every rate the stock receiver can +actually reach. + +Two things this does **not** settle: + +- `0x00c3` is the same model on a second product id and inherits + `MODERN_RECEIVER` unchanged. Likely the same, unmeasured. +- Six other products still pair `highRatePolling: true` with a 1000 Hz ceiling + (`0x007b`, `0x007d`, `0x00a8`, `0x00ab`, `0x00b0`, `0x00c3`), and so does + `0x00a6`, which is marked verified. The 499 Hz `pointerrawupdate` measurement + below belongs to the Viper V3 Pro (`0x00c1`) on a genuinely 8K-capable + dongle — no measurement covers `0x00a6`, so it should not be read as proof + that the extended command works on a 1000 Hz receiver. + +**Read-back is not measurement.** That is the fourth capability to confirm +itself and do nothing, after `highRatePolling` on `0x00b8`, `asymmetricLiftOff`, +and `liftOff` on `0x0083`. + +### Sleep and low power: still open + +Both were written and read back cleanly on both transports, and neither can be +observed while the mouse is on a cable. Two mechanisms still fit: + +1. **No commit step.** DPI writes carry a storage selector (`RAZER_STORAGE`); + these two carry none, so the value may sit in volatile state the getter + faithfully echoes. +2. **Contention.** Two replies arrived carrying an earlier command's id and a + transaction id the host never sent (`0x10`, `0x14`), and latency went from + ~100 ms to ~1000 ms mid-session. Something else was on the wire. + +To tell them apart: set auto sleep, power-cycle the mouse, and re-read with the +vendor software closed — a value that reverts means 1. Then repeat with the +vendor software and its background service fully quit — a value that now holds +means 2. Do not guess between them; picking wrong ships another silent no-op. + +Note that auto sleep and low power do nothing while the mouse is on a cable, so +the vendor software displaying a stale value for those two is not evidence +either way; it keeps its own copy and pushes it down. + +Fixed from the same capture: a reply belonging to the previous command used to +fail that read outright. Re-reading cannot recover it — nothing new arrives +until the host asks again — so getters now re-issue the request, and setters +still refuse to repeat themselves. + +## Transaction ids, audited against OpenRazer + +A hardware report on the Viper Ultimate (`1532:007b`) found `0x1f` silent where +`0x3f` read firmware, DPI, polling and battery correctly. Auditing the whole +registry against the id OpenRazer's `razer_attr_read_firmware_version()` selects +found **26 of 107 products wrong**, because the id had been inherited from the +transport preset. + +The id does not follow the transport group, the connection, the model's age or +its marketing family. Within a single group all three values occur: + +| Product | Group | Id | +| --- | --- | --- | +| Basilisk `0x0064` | standard | `0x3f` | +| Basilisk V2 `0x0085` | standard | `0x1f` | +| Basilisk X HyperSpeed `0x0083` | new-receiver | `0xff` | +| Lancehead Wireless `0x006f` | new-receiver | `0x3f` | +| Pro Click `0x0077` | new-receiver | `0x1f` | + +It is now a flat per-product list in `devices.ts`, checked against a full +transcription of the reference in `devices.test.ts`. A wrong id produces silence +rather than an error, so there is no failure mode to catch an inherited guess — +this is why it is not a preset field. + +Be clear about how strong that check is. Both lists were transcribed from one +reading of the driver, so a misreading is in both and the test cannot see it. +The test catches drift and forces divergences to be declared; it is not +independent confirmation. Only connecting a mouse gives that, which is why +these products stay `verified: false` regardless of how carefully the id was +transcribed. + +Two divergences from OpenRazer are deliberate and listed in +`EXPECTED_DIVERGENCE`: + +- **Viper Ultimate `0x007a`/`0x007b`** — OpenRazer sends `0xff` on every command + for both ids; the hardware report has `0x3f` working. Observed behaviour wins. + The mouse may accept both, so **re-testing against `0xff` would settle it**. +- **DeathAdder Essential `0x006e`/`0x0071`/`0x0098`** — the driver has always + sent `0x3f` on the stated grounds that OpenRazer does, which the driver source + does not bear out: it lists all three under `0xff`. Untested either way, so it + was left alone rather than changed blind. **Next thing to check on this + family.** + +### Known limitation: OpenRazer varies the id per command + +`RazerProduct.transactionId` is one value per product, and for some models that +is not enough. OpenRazer selects the id per command, and these disagree with +themselves: + +| Product | Divergence | +| --- | --- | +| Lancehead Wireless `0x006f`/`0x0070` | firmware/serial/polling/DPI `0x3f`, battery `0x1f` | +| Basilisk Ultimate `0x0086`/`0x0088` | firmware/DPI/polling-write `0x1f`, polling-read `0xff` | +| Mamba Elite `0x006c` | firmware/DPI `0x1f`, serial/polling `0xff` | + +The registry uses the firmware-read id, since that read gates everything else. +The consequence is that the commands listed above may fail on those three +models. It degrades rather than breaking: battery and serial are already +optional reads, and the polling read falls back to the other encoding. Supporting +this properly needs a per-command override, which is not implemented. + +## Untested models + +`devices.ts` claims 102 further products taken from OpenRazer's supported-device +table. They reuse the commands verified above; what the table records per model +is which of those commands are valid, which transaction id the mouse answers on, +and what its sensor and radio can do. **None has been connected**, so each is a +prediction until someone reports otherwise. The panel says so: the connection +card reads `… · untested model`. + +The Viper V3 HyperSpeed result above is worth reading before testing one: the +first model connected had a wrong `highRatePolling`, so expect that field to be +the most likely thing to need correcting. It presents as the polling rate +refusing to change while everything else works. + +Testing one is worth doing and is low-risk, because every failure mode here is +loud rather than silent: + +| If this is wrong | What happens | +| --- | --- | +| Transaction id | The mouse never replies. The status read fails on firmware and the panel reports a connection failure. Nothing is written. | +| Interface choice | Same — the wrong interface never answers. Add the device again and pick another entry. | +| A capability flag | The command is not sent at all. The control is missing, not broken. | +| DPI or rate ceiling | The write is refused, or fails its read-back and reports what the mouse kept. | + +What is deliberately **not** attempted on an untested model: + +- The asymmetric lift-off mode probe, which is a *write*. It stays off unless + `asymmetricLiftOff` is set, which only the four Viper V2/V3 Pro ids have. An + untested mouse that answers class `0x0b` still gets the plain three-stop + tracking control, which costs reads only. +- Lighting, button mapping and macros, none of which this driver implements for + any model. + +To promote a model to verified: + +1. Work through the numbered checklist above for it. +2. Confirm the model name, connection type and firmware read at all — that alone + proves the transaction id and the interface. +3. Check DPI and polling **against Synapse before writing anything**, then + change each, reload, and confirm it persisted. +4. Correct the model's row in `devices.ts`, set `verified: true`, add its id to + the verified list at the top of this file and to `VERIFIED` in + `devices.test.ts`, and record the firmware version in the pull request. + +Three groups from the OpenRazer list are excluded on purpose, and adding them +needs new transport work rather than a table row: + +- **`legacy/old`** — Orochi 2011 `0x0013`, DeathAdder 3.5G `0x0016` and `0x0029`. + These predate the 90-byte report and use direct USB control writes, so this + driver could only ever time out on them. +- **Orochi V2 Bluetooth `0x0095`** — a Bluetooth HID path is not the USB control + channel and must not be assumed to take the same reports. +- **HyperPolling Wireless Dongle `0x00b3`** — a receiver rather than a mouse. + Reaching the mouse paired to it needs dongle-specific commands. + +The `index3` models (Naga X `0x0096`, Basilisk V3 `0x0099`, Basilisk V3 35K +`0x00cb`) are the least certain of those that *are* claimed: OpenRazer reaches +them through USB control-transfer index 3, and WebHID cannot select a `wIndex`. +The picker offers every interface instead, so the right one has to be found by +trying them. If none answers, that is worth recording — it would mean these need +a native helper rather than a driver fix. + +## Models Chrome may not be able to reach at all + +Reported on the Viper Ultimate dongle: **every** collection came back +`feat[none]`, including the Generic Desktop Mouse interface, whose reports +Chrome stripped as protected. `sendFeatureReport` then fails whatever the +transaction id is, and the mouse could only be driven through the native OS HID +API. + +This is worth separating from the ordinary Razer situation, which looks similar +and is not the same thing. Razer never declares the control report in its +descriptor, so `feat[none]` is normal and expected — the Viper V3 Pro reads and +writes fine in that state, because WebHID does not validate report IDs against +the descriptor. What is different here is Chrome *removing* the reports from a +protected collection, which no transaction id or interface choice can work +around. + +If that holds up, the affected models need the native/HAL transport rather than +a driver fix, and their registry entries are unreachable in the browser however +correct they are. Two things would establish the boundary: + +1. Whether it is specific to this device, or to Chrome's handling of a device + whose only candidate interface is a protected mouse collection. +2. Whether any product currently claimed by the registry shares that shape. + +Until then the entries stay: they are correct data, they cost nothing but a +picker row, and a model that cannot be opened fails at `open()` with a clear +browser error rather than doing anything harmful. + ## DeathAdder Essential — not yet hardware-tested This model shares the 90-byte protocol above, so it reuses the same commands. diff --git a/src/devices/razer/devices.test.ts b/src/devices/razer/devices.test.ts new file mode 100644 index 00000000..bc980dc0 --- /dev/null +++ b/src/devices/razer/devices.test.ts @@ -0,0 +1,342 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { RAZER_PRODUCTS, RAZER_PRODUCT_IDS, RATES_1K, RATES_8K } from "./devices.ts"; +import { + RAZER_TRANSACTION_ID, + RAZER_TRANSACTION_ID_FF, + RAZER_TRANSACTION_ID_3F, + razerSetExtendedPollingCommand, + razerSetLegacyPollingCommand, +} from "./protocol.ts"; +import { VIPER_MINI_PRODUCT_ID } from "./viper-mini-hid.ts"; +import { VIPER_V4_PRO_PRODUCTS } from "./viper-v4-pro-hid.ts"; + +interface VerifiedProfile { + model: string; + wireless: boolean; + maxDpi: number; + transactionId: number; + rates: readonly number[]; + highRate: boolean; +} + +/** + * The models the driver already treated as verified before the OpenRazer + * registry was added. `highRatePolling` was read off `wireless` back then, so + * these seven also pin that the refactor changed no behaviour — see the + * separate test below, which is a claim about *these* products rather than + * about verified products in general. + */ +const REFACTOR_BASELINE: ReadonlyArray<[number, VerifiedProfile]> = [ + [0x00a5, { model: "Viper V2 Pro", wireless: false, maxDpi: 30000, transactionId: RAZER_TRANSACTION_ID, rates: RATES_1K, highRate: false }], + [0x00a6, { model: "Viper V2 Pro", wireless: true, maxDpi: 30000, transactionId: RAZER_TRANSACTION_ID, rates: RATES_1K, highRate: true }], + [0x00c0, { model: "Viper V3 Pro", wireless: false, maxDpi: 35000, transactionId: RAZER_TRANSACTION_ID, rates: RATES_1K, highRate: false }], + [0x00c1, { model: "Viper V3 Pro", wireless: true, maxDpi: 35000, transactionId: RAZER_TRANSACTION_ID, rates: RATES_8K, highRate: true }], + [0x006e, { model: "DeathAdder Essential", wireless: false, maxDpi: 6400, transactionId: RAZER_TRANSACTION_ID_3F, rates: RATES_1K, highRate: false }], + [0x0071, { model: "DeathAdder Essential White Edition", wireless: false, maxDpi: 6400, transactionId: RAZER_TRANSACTION_ID_3F, rates: RATES_1K, highRate: false }], + [0x0098, { model: "DeathAdder Essential (2021)", wireless: false, maxDpi: 6400, transactionId: RAZER_TRANSACTION_ID_3F, rates: RATES_1K, highRate: false }], +]; + +/** Models promoted by a hardware report since. */ +const VERIFIED_SINCE: ReadonlyArray<[number, VerifiedProfile]> = [ + // Reported against the stock HyperSpeed receiver: the extended polling + // command is refused as unsupported, and 125/500/1000 Hz each round-tripped + // on the legacy one. + [0x00b8, { model: "Viper V3 HyperSpeed", wireless: true, maxDpi: 30000, transactionId: RAZER_TRANSACTION_ID, rates: RATES_1K, highRate: false }], +]; + +const VERIFIED = [...REFACTOR_BASELINE, ...VERIFIED_SINCE]; + +/** + * Products a hardware report has actually covered. + * + * Deliberately smaller than REFACTOR_BASELINE: the DeathAdder Essential family + * shipped with the driver but TESTING.md has always listed it as "not yet + * hardware-tested", so it is pinned like the others without claiming to be + * verified. + */ +const HARDWARE_VERIFIED: readonly number[] = [0x00a5, 0x00a6, 0x00c0, 0x00c1, 0x00b8]; + +test("every pinned product keeps exactly the profile it was given", () => { + // A silent change to any of these would only show up on hardware, which is + // the one place this project cannot re-run on demand. + for (const [productId, expected] of VERIFIED) { + const product = RAZER_PRODUCTS.get(productId); + assert.ok(product, `0x${productId.toString(16)} is missing from the registry`); + assert.equal(product.model, expected.model); + assert.equal(product.wireless, expected.wireless); + assert.equal(product.maxDpi, expected.maxDpi); + assert.equal(product.transactionId, expected.transactionId); + assert.deepEqual([...product.pollingRates], [...expected.rates]); + assert.equal(product.highRatePolling, expected.highRate); + } +}); + +test("splitting the polling command off `wireless` changed nothing for the models that predate it", () => { + // The driver used to choose the polling command with `isWireless()`. For the + // seven products that existed then the two must still agree, or the refactor + // silently moved one of them onto the other encoding. + for (const [productId, expected] of REFACTOR_BASELINE) { + const product = RAZER_PRODUCTS.get(productId); + assert.equal(product?.highRatePolling, product?.wireless, `0x${productId.toString(16)}`); + assert.equal(product?.highRatePolling, expected.highRate); + } +}); + +test("a wireless model can still answer only the legacy polling command", () => { + // The reason the two are separate fields. 0x00b8 is wireless and refuses the + // extended command; 0x00a6 is wireless, tops out at 1000 Hz too, and accepts + // it. Neither the transport group nor the rate ceiling predicts which, so a + // rule inferred from either would be wrong about one of these two. + const hyperSpeed = RAZER_PRODUCTS.get(0x00b8); + const viperV2 = RAZER_PRODUCTS.get(0x00a6); + assert.equal(hyperSpeed?.wireless, true); + assert.equal(hyperSpeed?.highRatePolling, false); + assert.equal(viperV2?.wireless, true); + assert.equal(viperV2?.highRatePolling, true); + // Same ceiling, opposite encoding: the rate list cannot be what decides. + assert.deepEqual([...hyperSpeed?.pollingRates ?? []], [...viperV2?.pollingRates ?? []]); +}); + +test("the DeathAdder V3 Pro receiver writes polling on the legacy command", () => { + // Reported on hardware: the extended command was accepted and read back at + // every rate, while the measured report rate never left 1000 Hz. The stock + // receiver this model ships with has a 1000 Hz ceiling, so an encoding built + // around 8000 was addressing a range the hardware does not have. + const receiver = RAZER_PRODUCTS.get(0x00b7); + assert.equal(receiver?.wireless, true); + assert.equal(receiver?.highRatePolling, false); + // The legacy encoding has to reach every rate still offered, or the fix + // trades a silent no-op for a visible failure. + for (const rate of receiver?.pollingRates ?? []) { + assert.doesNotThrow(() => razerSetLegacyPollingCommand(rate)); + } +}); + +test("no product asks for a rate its polling command cannot encode", () => { + // `highRatePolling` picks the encoding and `pollingRates` picks the values + // offered; they are set independently, so nothing stops a product asking for + // 8000 Hz through an encoding whose ceiling is 1000. + for (const [productId, product] of RAZER_PRODUCTS) { + const encode = product.highRatePolling ? razerSetExtendedPollingCommand : razerSetLegacyPollingCommand; + for (const rate of product.pollingRates) { + assert.doesNotThrow( + () => encode(rate), + `0x${productId.toString(16).padStart(4, "0")} ${product.model} cannot encode ${rate} Hz`, + ); + } + } +}); + +test("only models connected by this project claim to be verified", () => { + // `verified` drives the "untested model" label and whether a failed battery + // read is fatal, so it must mean "someone plugged one in", not "shipped for + // a while". + const verified = RAZER_PRODUCT_IDS.filter((id) => RAZER_PRODUCTS.get(id)?.verified === true); + assert.deepEqual([...verified].sort(), [...HARDWARE_VERIFIED].sort()); +}); + +test("the DeathAdder Essential family does not claim to be tested", () => { + // TESTING.md carries it under "not yet hardware-tested", and an earlier + // revision of the registry contradicted that. + for (const productId of [0x006e, 0x0071, 0x0098]) { + assert.equal(RAZER_PRODUCTS.get(productId)?.verified, false, `0x${productId.toString(16)}`); + } +}); + +test("lift-off is only offered where the mouse is known to have it", () => { + // A model without lift-off can still answer 0x0b/0x85: the Basilisk X + // HyperSpeed replies status 0x02 with zeros, which decode as "Low". Offering + // the control on a successful read gave a picker where Medium was silently + // ignored and High was refused, on a mouse with no lift-off control at all. + const offered = RAZER_PRODUCT_IDS.filter((id) => RAZER_PRODUCTS.get(id)?.liftOff === true); + assert.deepEqual(offered.sort(), [0x00a5, 0x00a6, 0x00b8, 0x00c0, 0x00c1]); + for (const id of offered) { + assert.equal(RAZER_PRODUCTS.get(id)?.verified, true, `0x${id.toString(16)} offers lift-off without being verified`); + } +}); + +test("the asymmetric pair is never armed without the tracking control", () => { + // The pair write is a superset: `setLiftOff` reads the same 0x0b/0x85 reply + // back, so a product claiming the pair but not the level would probe a + // command whose reply it does not trust. + for (const [productId, product] of RAZER_PRODUCTS) { + if (!product.asymmetricLiftOff) continue; + assert.equal(product.liftOff, true, `0x${productId.toString(16)} claims the pair but not the level`); + } +}); + +test("the asymmetric lift-off write probe is only armed where it was confirmed", () => { + // Establishing the mode is a write, so an unverified model must not be sent + // one during an ordinary status read. + const armed = RAZER_PRODUCT_IDS.filter((id) => RAZER_PRODUCTS.get(id)?.asymmetricLiftOff === true); + assert.deepEqual(armed.sort(), [0x00a5, 0x00a6, 0x00c0, 0x00c1]); + for (const id of armed) assert.equal(RAZER_PRODUCTS.get(id)?.verified, true); +}); + +test("no product is claimed by both this registry and a dedicated Razer driver", () => { + // `driverFor` returns the first match in DEVICE_DRIVERS, so an overlap would + // silently kill whichever driver is registered later. + assert.equal(RAZER_PRODUCTS.has(VIPER_MINI_PRODUCT_ID), false); + for (const productId of VIPER_V4_PRO_PRODUCTS.keys()) { + assert.equal(RAZER_PRODUCTS.has(productId), false, `0x${productId.toString(16)} also has a Viper V4 Pro driver`); + } +}); + +test("families that cannot work over this transport are left out", () => { + // Orochi 2011 and the two DeathAdder 3.5G ids predate the 90-byte report and + // need direct USB control writes; 0x0095 is a Bluetooth path, and 0x00b3 is a + // dongle rather than a mouse. Listing any of them would produce a device that + // connects and then times out. + for (const productId of [0x0013, 0x0016, 0x0029, 0x0095, 0x00b3]) { + assert.equal(RAZER_PRODUCTS.has(productId), false, `0x${productId.toString(16)} cannot be driven by this transport`); + } +}); + +test("every product answers on a transaction id the protocol defines", () => { + // A wrong id is silent — the mouse simply never replies — so an id outside + // the three known ones would be a guess with no failure mode to catch it. + const known = new Set([RAZER_TRANSACTION_ID, RAZER_TRANSACTION_ID_3F, RAZER_TRANSACTION_ID_FF]); + for (const [productId, product] of RAZER_PRODUCTS) { + assert.equal(known.has(product.transactionId), true, `0x${productId.toString(16)} uses an unknown transaction id`); + } +}); + +/** + * The transaction id every product is expected to answer on, transcribed from + * the id OpenRazer's `razer_attr_read_firmware_version()` selects — the gating + * first read, so a product that disagrees there cannot be reached at all. + * + * What this does and does not buy, stated plainly because the difference + * matters: this list and the one in `devices.ts` were transcribed from the same + * reading of the driver, in one sitting, by the same person. They are two + * copies of one transcription, not two independent readings. A misreading of + * the driver is therefore present in both and this test will not catch it. + * + * What it does catch is drift — an id edited in `devices.ts` without a + * corresponding decision here — and it forces every deliberate divergence to be + * declared in EXPECTED_DIVERGENCE with its evidence rather than sitting in the + * table looking like an audited value. Independent confirmation only comes from + * connecting the mouse. + */ +const OPENRAZER_TRANSACTION_IDS: ReadonlyMap = new Map([ + ...[ + 0x0050, 0x0059, 0x005a, 0x005c, 0x0060, 0x0064, 0x0065, 0x006f, 0x0070, + 0x0072, 0x0073, 0x007c, 0x007d, 0x0084, 0x008c, + ].map((id) => [id, 0x3f] as const), + ...[ + 0x0062, 0x006c, 0x0077, 0x0080, 0x0085, 0x0086, 0x0088, 0x008d, 0x008f, + 0x0090, 0x0094, 0x0096, 0x0099, 0x009a, 0x009c, 0x009e, 0x009f, 0x00a1, + 0x00a5, 0x00a6, 0x00a7, 0x00a8, 0x00aa, 0x00ab, 0x00af, 0x00b0, 0x00b2, + 0x00b4, 0x00b6, 0x00b7, 0x00b8, 0x00b9, 0x00be, 0x00bf, 0x00c0, 0x00c1, + 0x00c2, 0x00c3, 0x00c4, 0x00c5, 0x00c7, 0x00c8, 0x00cb, 0x00cc, 0x00cd, + 0x00d0, 0x00d1, 0x00d3, 0x00d4, 0x00d6, 0x00d7, + ].map((id) => [id, 0x1f] as const), +]); + +/** Products where this driver knowingly sends something else, and why. */ +const EXPECTED_DIVERGENCE: ReadonlyMap = new Map([ + [0x007a, { ours: 0x3f, openRazer: 0xff, why: "hardware report: 0x1f silent, 0x3f reads correctly" }], + [0x007b, { ours: 0x3f, openRazer: 0xff, why: "hardware report: 0x1f silent, 0x3f reads correctly" }], + [0x006e, { ours: 0x3f, openRazer: 0xff, why: "predates the audit; untested either way" }], + [0x0071, { ours: 0x3f, openRazer: 0xff, why: "predates the audit; untested either way" }], + [0x0098, { ours: 0x3f, openRazer: 0xff, why: "predates the audit; untested either way" }], +]); + +test("every transaction id matches OpenRazer, or is a divergence with a reason", () => { + // This field has no failure mode that reaches the user as anything but + // silence, so it is checked product by product against the reference rather + // than inherited from a transport group — which is how 26 of them were wrong. + const wrong: string[] = []; + for (const [productId, product] of RAZER_PRODUCTS) { + const divergence = EXPECTED_DIVERGENCE.get(productId); + const expected = divergence?.ours ?? OPENRAZER_TRANSACTION_IDS.get(productId) ?? 0xff; + if (product.transactionId !== expected) { + wrong.push(`0x${productId.toString(16).padStart(4, "0")} ${product.model}:` + + ` sends 0x${product.transactionId.toString(16)}, expected 0x${expected.toString(16)}`); + } + } + assert.deepEqual(wrong, [], "A transaction id disagrees with the OpenRazer audit and is not a listed divergence."); +}); + +test("each declared divergence really does differ from OpenRazer", () => { + // Otherwise the list accumulates entries that no longer say anything, and the + // next person cannot tell which ones still need a decision. + for (const [productId, divergence] of EXPECTED_DIVERGENCE) { + assert.notEqual(divergence.ours, divergence.openRazer, `0x${productId.toString(16)} is listed but does not diverge`); + assert.equal(OPENRAZER_TRANSACTION_IDS.has(productId), false, + `0x${productId.toString(16)} is in both the audit map and the divergence list`); + assert.ok(divergence.why.length > 0); + } +}); + +test("every advertised polling rate can be encoded by the command that model uses", () => { + // The two encodings are divisors of 1000 and 8000, so a rate that divides + // neither would be offered in the panel and then rejected by the builder. + for (const [productId, product] of RAZER_PRODUCTS) { + for (const rate of product.pollingRates) { + const build = () => (product.highRatePolling + ? razerSetExtendedPollingCommand(rate) + : razerSetLegacyPollingCommand(rate)); + assert.doesNotThrow(build, `0x${productId.toString(16)} cannot encode ${rate} Hz`); + } + } +}); + +test("a model is only offered rates above 1000 Hz when it uses the extended command", () => { + // The legacy command encodes a divisor of 1000, so it cannot express a faster + // rate at all. + for (const [productId, product] of RAZER_PRODUCTS) { + if (product.highRatePolling) continue; + const fastest = Math.max(...product.pollingRates); + assert.ok(fastest <= 1000, `0x${productId.toString(16)} offers ${fastest} Hz on the legacy command`); + } +}); + +test("every product has a usable DPI range", () => { + for (const [productId, product] of RAZER_PRODUCTS) { + assert.ok(Number.isInteger(product.maxDpi), `0x${productId.toString(16)} has a non-integer ceiling`); + // 100 is the floor the driver offers, so a lower ceiling would leave the + // DPI control with nothing to show. + assert.ok(product.maxDpi >= 100, `0x${productId.toString(16)} has a ceiling below the 100 DPI floor`); + assert.ok(product.pollingRates.length > 0, `0x${productId.toString(16)} advertises no polling rate`); + assert.ok(product.model.length > 0); + } +}); + +test("only wireless-capable models are sent battery commands", () => { + // An unsupported reply to the battery read aborts the whole status read, so a + // wired-only model must never be asked. The converse is not true: a wireless + // mouse on its cable still has a cell. + for (const [productId, product] of RAZER_PRODUCTS) { + if (!product.wireless) continue; + assert.equal(product.hasBattery, true, `0x${productId.toString(16)} is wireless but reports no battery`); + } +}); + +test("the picker filter list covers every product in the registry", () => { + // Built from the same map, so this guards the wiring rather than the data: + // an id in the registry with no filter can never reach the driver. + assert.equal(RAZER_PRODUCT_IDS.length, RAZER_PRODUCTS.size); + assert.equal(new Set(RAZER_PRODUCT_IDS).size, RAZER_PRODUCT_IDS.length, "duplicate product id"); +}); + +test("the catch-all filter does not widen a filter that was deliberately narrowed", async () => { + // The Viper V2/V3 collection filters are limited to known ids so they cannot + // surface Razer keyboards or the V4 Pro's boot-mouse interfaces. A whole-device + // filter for the same id would put those interfaces back in the picker. + const { RAZER_REGISTRY_FILTERS, SUPPORTED_HID_FILTERS, VENDOR_ID } = await import("../vendors.ts"); + + const broad = new Set(RAZER_REGISTRY_FILTERS.map((filter) => filter.productId)); + for (const productId of [0x00a5, 0x00a6, 0x00c0, 0x00c1, 0x006e, 0x0071, 0x0098]) { + assert.equal(broad.has(productId), false, `0x${productId.toString(16)} is filtered twice`); + } + // Every registry id still reaches the picker, through one filter or another. + const offered = SUPPORTED_HID_FILTERS + .filter((filter) => filter.vendorId === VENDOR_ID.razer && filter.productId !== undefined) + .map((filter) => filter.productId); + for (const productId of RAZER_PRODUCT_IDS) { + assert.ok(offered.includes(productId), `0x${productId.toString(16)} is not offered in the picker`); + } +}); diff --git a/src/devices/razer/devices.ts b/src/devices/razer/devices.ts new file mode 100644 index 00000000..76085fc5 --- /dev/null +++ b/src/devices/razer/devices.ts @@ -0,0 +1,503 @@ +/** + * Razer per-PID capability registry. + * + * Razer's protocol is not self-describing: nothing on the wire says which + * commands a mouse answers, so the only way to know is a table keyed on the + * exact product id. This is that table, kept apart from the transport in + * `hid.ts` so adding a model is data rather than code. + * + * ## Provenance and what "supported" means here + * + * The product list and the transport grouping come from OpenRazer's public + * supported-device table and mouse driver (see + * `OPENRAZER_ALL_MICE_DEVELOPER_REFERENCE.md`). Only the seven entries marked + * `verified: true` have been exercised against real hardware by this project — + * everything else is transcribed protocol facts, not a tested driver. + * + * Nothing here is a guess about *packets*: every model below is driven by the + * same 90-byte commands already confirmed on the Viper V3 Pro. What varies per + * model, and what this table records, is which of those commands are valid, + * which transaction id the mouse answers on, and what the sensor and radio can + * actually do. + * + * ## Why an unverified entry is safe to ship + * + * - A wrong transaction id means the mouse never replies. `readStatus` throws + * on the firmware read and the panel reports a connection failure. Nothing + * is written. + * - A wrong capability flag suppresses a command rather than inventing one. + * - A wrong ceiling is caught by the read-back every setter already performs: + * the mouse keeps its own value, the driver notices and reports it. + * + * The one thing that would not be safe is sending a command a model does not + * implement, so unverified models are given the conservative flags below and + * the asymmetric lift-off *write probe* stays off for all of them. + * + * ## Deliberately absent + * + * - `legacy/old` (Orochi 2011 `0x0013`, DeathAdder 3.5G `0x0016`/`0x0029`). + * These predate the 90-byte report and need direct USB control writes, so + * this driver could only ever time out on them. + * - Orochi V2 Bluetooth (`0x0095`). A Bluetooth HID path is not the USB + * control channel and must not be assumed to take the same reports. + * - HyperPolling Wireless Dongle (`0x00b3`). It is a receiver, not a mouse; + * addressing the mouse paired to it needs dongle-specific commands that are + * not documented here. + * - Viper Mini (`0x008a`) and Viper V4 Pro (`0x00e5`/`0x00e6`), which have + * their own drivers in this folder. Listing them here would give two drivers + * the same device. + */ + +import { + RAZER_TRANSACTION_ID_1F, + RAZER_TRANSACTION_ID_3F, + RAZER_TRANSACTION_ID_FF, +} from "./protocol.ts"; + +/** + * OpenRazer's transport families. They differ in response timing, control + * interface and which commands exist — not in packet layout. + */ +export type RazerTransport = + | "standard" + | "index3" + | "atheris-receiver" + | "viper-receiver" + | "new-receiver"; + +export interface RazerProduct { + model: string; + wireless: boolean; + pollingRates: readonly number[]; + /** Sensor ceiling, per axis. */ + maxDpi: number; + /** Razer's per-generation transaction id; a mismatch means no reply at all. */ + transactionId: number; + /** Battery commands only exist on models that have a cell. */ + hasBattery: boolean; + /** Also accept a vendor-defined collection as the control interface. */ + vendorControlInterface?: boolean; + transport: RazerTransport; + /** + * Use the extended polling command (a divisor of 8000) rather than the legacy + * one (a divisor of 1000). This is a property of the generation, not of the + * connection: the Viper 8KHz is wired and needs the extended command, while + * the older HyperSpeed receivers are wireless and only answer the legacy one. + */ + highRatePolling: boolean; + /** + * The mouse implements the class `0x0b` tracking distance. + * + * This cannot be probed. The Basilisk X HyperSpeed answers `0x0b`/`0x85` with + * status `0x02` and an all-zero payload, which decodes as a legitimate + * "Low" — so "the command replied" is not evidence the mouse has the feature, + * and offering the control on that basis produced a picker where every level + * failed: `0x01` was acknowledged and stored nothing, `0x02` was refused + * outright. + * + * There is no reply that distinguishes "no lift-off control" from "Low at the + * bottom of the range", so this has to be stated per product. + */ + liftOff: boolean; + /** + * The mouse additionally stores separate lift-off and landing heights, and + * the mode can be established by the pair write's own status. + * + * That probe is a *write*, so it is only enabled where the command has been + * confirmed on hardware. Implies `liftOff`. + */ + asymmetricLiftOff: boolean; + /** Confirmed against real hardware by this project. */ + verified: boolean; +} + +/** Everything a preset supplies. The transaction id is deliberately not here. */ +type ProductDefaults = Omit; + +// The cable tops out at 1000 Hz on the models verified so far, which is also +// the ceiling the legacy polling command can encode. +export const RATES_1K: readonly number[] = [125, 500, 1000]; +export const RATES_8K: readonly number[] = [125, 500, 1000, 2000, 4000, 8000]; + +/** + * Products answering on `0x3f`, audited against OpenRazer's mouse driver. + * + * ## Why this is a flat list and not a field on the transport presets + * + * It was a preset field, and that was wrong for 26 of the 107 products. The + * transaction id does not follow the transport group, the connection, the + * model's age or its marketing family — OpenRazer picks it per product id, and + * the groups interleave all three values: + * + * - Basilisk `0x0064` is `0x3f` while Basilisk V2 `0x0085` is `0x1f` and + * Basilisk X HyperSpeed `0x0083` is `0xff`. + * - Viper `0x0078` is `0xff`, Viper 8KHz `0x0091` is `0xff`, Viper Mini SE + * `0x009e` is `0x1f`. + * - Inside one `new-receiver` group: Lancehead Wireless `0x006f` is `0x3f`, + * Pro Click `0x0077` is `0x1f`, Basilisk X HyperSpeed `0x0083` is `0xff`. + * + * A wrong id is silent — the mouse never replies — so there is no failure mode + * to catch an inherited guess. Keeping the ids in one auditable block, rather + * than spread across presets that imply a pattern, is what stops that + * inheritance happening again. + */ +const TRANSACTION_3F: readonly number[] = [ + 0x0050, 0x0059, 0x005a, 0x005c, 0x0060, 0x0064, 0x0065, 0x006f, 0x0070, + 0x0072, 0x0073, 0x007c, 0x007d, 0x0084, 0x008c, + // --- Diverging from OpenRazer, on purpose --------------------------------- + // Viper Ultimate. OpenRazer sends `0xff` on every command for both ids, but + // a hardware report has `0x1f` silent and `0x3f` reading firmware, DPI, + // polling and battery correctly. Observed behaviour wins over the reference, + // and the mouse may well accept both. Worth re-testing against `0xff`. + 0x007a, 0x007b, + // DeathAdder Essential. Predates this audit: the driver has always sent + // `0x3f` here on the stated grounds that OpenRazer does, which the driver + // source does not bear out — it lists all three ids under `0xff`. Left as it + // was rather than changed blind, because nothing has connected one either + // way. Flagged in TESTING.md as the next thing to check on this family. + 0x006e, 0x0071, 0x0098, +]; + +/** Products answering on `0x1f`. Same provenance as the list above. */ +const TRANSACTION_1F: readonly number[] = [ + 0x0062, 0x006c, 0x0077, 0x0080, 0x0085, 0x0086, 0x0088, 0x008d, 0x008f, + 0x0090, 0x0094, 0x0096, 0x0099, 0x009a, 0x009c, 0x009e, 0x009f, 0x00a1, + 0x00a5, 0x00a6, 0x00a7, 0x00a8, 0x00aa, 0x00ab, 0x00af, 0x00b0, 0x00b2, + 0x00b4, 0x00b6, 0x00b7, 0x00b8, 0x00b9, 0x00be, 0x00bf, 0x00c0, 0x00c1, + 0x00c2, 0x00c3, 0x00c4, 0x00c5, 0x00c7, 0x00c8, 0x00cb, 0x00cc, 0x00cd, + 0x00d0, 0x00d1, 0x00d3, 0x00d4, 0x00d6, 0x00d7, +]; + +/** + * `0xff` is the fallback because it is the id the largest group uses, not + * because it is safe to assume: a product missing from both lists above has + * simply not been audited, and will be silent if `0xff` is wrong for it. + */ +function transactionIdFor(productId: number): number { + if (TRANSACTION_3F.includes(productId)) return RAZER_TRANSACTION_ID_3F; + if (TRANSACTION_1F.includes(productId)) return RAZER_TRANSACTION_ID_1F; + return RAZER_TRANSACTION_ID_FF; +} + +/** + * Sensor ceilings by generation, from published specifications rather than from + * the mouse — Razer exposes no "what is your maximum DPI" command. A ceiling + * that is too high costs a failed read-back on a value the mouse will not hold; + * one that is too low would refuse a value it would have taken, so these err + * generous where a model's exact figure is not encoded in its name. + */ +const DPI_PRE_CHROMA = 8_200; +const DPI_CHROMA = 16_000; +const DPI_FOCUS = 20_000; +const DPI_FOCUS_PRO = 30_000; +const DPI_FOCUS_PRO_35K = 35_000; + +/** + * Chroma-era and older wired mice. OpenRazer's `standard` group answers on the + * original transaction id, and which interface carries the control channel + * varies by revision, so a vendor-defined collection is accepted too. + */ +const STANDARD = { + transport: "standard", + wireless: false, + pollingRates: RATES_1K, + maxDpi: DPI_CHROMA, + hasBattery: false, + vendorControlInterface: true, + highRatePolling: false, + liftOff: false, + asymmetricLiftOff: false, + verified: false, +} as const satisfies ProductDefaults; + +/** Same generation, but a model with a cell and a charging dock or receiver. */ +const STANDARD_WIRELESS = { + ...STANDARD, + wireless: true, + hasBattery: true, +} as const satisfies ProductDefaults; + +/** + * OpenRazer routes Naga X, Basilisk V3 and Basilisk V3 35K through USB control + * transfer index 3. WebHID cannot select a `wIndex`, so the browser has to be + * pointed at the HID collection belonging to that interface instead: the picker + * offers every interface and the wrong one simply never answers. + */ +const INDEX3 = { + ...STANDARD, + transport: "index3", + maxDpi: DPI_FOCUS_PRO, +} as const satisfies ProductDefaults; + +/** Atheris and Orochi V2 receivers, which need a longer response window. */ +const ATHERIS_RECEIVER = { + ...STANDARD, + transport: "atheris-receiver", + wireless: true, + hasBattery: true, + maxDpi: DPI_FOCUS, +} as const satisfies ProductDefaults; + +/** + * The modern HyperSpeed generation, wired half. These are wireless mice on a + * cable, so they keep their battery commands; the cable itself runs at the + * legacy ceiling. + */ +const MODERN_WIRED = { + transport: "new-receiver", + wireless: false, + pollingRates: RATES_1K, + maxDpi: DPI_FOCUS, + hasBattery: true, + highRatePolling: false, + liftOff: false, + asymmetricLiftOff: false, + verified: false, +} as const satisfies ProductDefaults; + +/** The stock 1000 Hz receiver these ship with. */ +const MODERN_RECEIVER = { + ...MODERN_WIRED, + wireless: true, + highRatePolling: true, +} as const satisfies ProductDefaults; + +/** + * Older HyperSpeed receivers, which predate the extended polling command and + * answer only the legacy one. + */ +const LEGACY_RECEIVER = { + ...MODERN_RECEIVER, + highRatePolling: false, +} as const satisfies ProductDefaults; + +/** Receivers that ship as HyperPolling dongles and reach 8000 Hz. */ +const HYPERPOLLING_RECEIVER = { + ...MODERN_RECEIVER, + pollingRates: RATES_8K, + maxDpi: DPI_FOCUS_PRO_35K, +} as const satisfies ProductDefaults; + +/** Viper Ultimate / Viper Mini SE / DeathAdder V2 Pro timing group. */ +const VIPER_RECEIVER_WIRED = { + ...MODERN_WIRED, + transport: "viper-receiver", +} as const satisfies ProductDefaults; + +const VIPER_RECEIVER_WIRELESS = { + ...MODERN_RECEIVER, + transport: "viper-receiver", +} as const satisfies ProductDefaults; + +/** + * DeathAdder Essential family. 6400 DPI is the officially published maximum + * and the ceiling the vendor software offers. + * + * Not verified: it shipped before this registry existed and TESTING.md has + * always carried it under "not yet hardware-tested". An earlier revision of + * this file marked it verified by mistake, which was wrong in both directions — + * it suppressed the "untested model" label and armed the strict battery read. + */ +const DEATHADDER_ESSENTIAL = { + transport: "standard", + wireless: false, + pollingRates: RATES_1K, + maxDpi: 6400, + hasBattery: false, + vendorControlInterface: true, + highRatePolling: false, + liftOff: false, + asymmetricLiftOff: false, + verified: false, +} as const satisfies ProductDefaults; + +const VIPER_V2_PRO = { + transport: "new-receiver", + maxDpi: DPI_FOCUS_PRO, + hasBattery: true, + // Stock receiver, not an 8K HyperPolling dongle. + pollingRates: RATES_1K, + liftOff: true, + asymmetricLiftOff: true, + verified: true, +} as const; + +const VIPER_V3_PRO = { + transport: "viper-receiver", + maxDpi: DPI_FOCUS_PRO_35K, + hasBattery: true, + liftOff: true, + asymmetricLiftOff: true, + verified: true, +} as const; + +/** + * Every product this driver claims, before the audited transaction id is + * attached. Build `RAZER_PRODUCTS` from this rather than reading it directly. + * + * The `verified: true` entries are the ones a hardware report has covered; + * the rest come from the OpenRazer reference and have never been connected. + */ +const PRODUCT_DEFINITIONS: ReadonlyArray<[number, Omit]> = [ + // ---- Verified on hardware ------------------------------------------------- + [0x00a5, { model: "Viper V2 Pro", wireless: false, highRatePolling: false, ...VIPER_V2_PRO }], + [0x00a6, { model: "Viper V2 Pro", wireless: true, highRatePolling: true, ...VIPER_V2_PRO }], + [0x00c0, { model: "Viper V3 Pro", wireless: false, pollingRates: RATES_1K, highRatePolling: false, ...VIPER_V3_PRO }], + [0x00c1, { model: "Viper V3 Pro", wireless: true, pollingRates: RATES_8K, highRatePolling: true, ...VIPER_V3_PRO }], + [0x006e, { model: "DeathAdder Essential", ...DEATHADDER_ESSENTIAL }], + [0x0071, { model: "DeathAdder Essential White Edition", ...DEATHADDER_ESSENTIAL }], + [0x0098, { model: "DeathAdder Essential (2021)", ...DEATHADDER_ESSENTIAL }], + + // ---- standard: wired, original transaction id ----------------------------- + // Ceilings encoded in a model's own name are used as given; the rest take the + // generation default. + [0x0015, { model: "Naga", ...STANDARD, maxDpi: DPI_PRE_CHROMA }], + [0x001f, { model: "Naga Epic", ...STANDARD, maxDpi: DPI_PRE_CHROMA }], + [0x0020, { model: "Abyssus 1800", ...STANDARD, maxDpi: 1800 }], + [0x0024, { model: "Mamba 2012 (Wired)", ...STANDARD, maxDpi: DPI_PRE_CHROMA }], + [0x0025, { model: "Mamba 2012", ...STANDARD_WIRELESS, maxDpi: DPI_PRE_CHROMA }], + [0x002e, { model: "Naga 2012", ...STANDARD, maxDpi: DPI_PRE_CHROMA }], + [0x002f, { model: "Imperator 2012", ...STANDARD, maxDpi: DPI_PRE_CHROMA }], + [0x0032, { model: "Ouroboros 2012", ...STANDARD, maxDpi: DPI_PRE_CHROMA }], + [0x0034, { model: "Taipan", ...STANDARD, maxDpi: DPI_PRE_CHROMA }], + [0x0036, { model: "Naga Hex (Red)", ...STANDARD, maxDpi: DPI_PRE_CHROMA }], + [0x0037, { model: "DeathAdder 2013", ...STANDARD, maxDpi: 6400 }], + [0x0038, { model: "DeathAdder 1800", ...STANDARD, maxDpi: 1800 }], + [0x0039, { model: "Orochi 2013", ...STANDARD_WIRELESS, maxDpi: DPI_PRE_CHROMA }], + [0x003e, { model: "Naga Epic Chroma (Wired)", ...STANDARD }], + [0x003f, { model: "Naga Epic Chroma", ...STANDARD_WIRELESS }], + [0x0040, { model: "Naga 2014", ...STANDARD, maxDpi: DPI_PRE_CHROMA }], + [0x0041, { model: "Naga Hex", ...STANDARD }], + [0x0042, { model: "Abyssus 2014", ...STANDARD, maxDpi: 1800 }], + [0x0043, { model: "DeathAdder Chroma", ...STANDARD }], + [0x0044, { model: "Mamba (Wired)", ...STANDARD }], + [0x0045, { model: "Mamba", ...STANDARD_WIRELESS }], + [0x0046, { model: "Mamba Tournament Edition", ...STANDARD }], + [0x0048, { model: "Orochi (Wired)", ...STANDARD, maxDpi: DPI_PRE_CHROMA }], + [0x004c, { model: "Diamondback Chroma", ...STANDARD }], + [0x004f, { model: "DeathAdder 2000", ...STANDARD, maxDpi: 2000 }], + [0x0050, { model: "Naga Hex V2", ...STANDARD }], + [0x0053, { model: "Naga Chroma", ...STANDARD }], + [0x0054, { model: "DeathAdder 3500", ...STANDARD, maxDpi: 3500 }], + [0x0059, { model: "Lancehead (Wired)", ...STANDARD }], + [0x005a, { model: "Lancehead", ...STANDARD_WIRELESS }], + [0x005b, { model: "Abyssus V2", ...STANDARD, maxDpi: 5000 }], + [0x005c, { model: "DeathAdder Elite", ...STANDARD }], + [0x005e, { model: "Abyssus 2000", ...STANDARD, maxDpi: 2000 }], + [0x0060, { model: "Lancehead Tournament Edition", ...STANDARD }], + [0x0064, { model: "Basilisk", ...STANDARD }], + [0x0065, { model: "Basilisk Essential", ...STANDARD, maxDpi: 6400 }], + [0x0067, { model: "Naga Trinity", ...STANDARD }], + [0x006a, { model: "Abyssus Elite (D.Va Edition)", ...STANDARD, maxDpi: 7200 }], + [0x006b, { model: "Abyssus Essential", ...STANDARD, maxDpi: 7200 }], + [0x006c, { model: "Mamba Elite", ...STANDARD }], + [0x0078, { model: "Viper", ...STANDARD, maxDpi: DPI_FOCUS }], + [0x0084, { model: "DeathAdder V2", ...STANDARD, maxDpi: DPI_FOCUS }], + [0x0085, { model: "Basilisk V2", ...STANDARD, maxDpi: DPI_FOCUS }], + [0x008c, { model: "DeathAdder V2 Mini", ...STANDARD, maxDpi: 8500 }], + [0x008d, { model: "Naga Left-Handed Edition 2020", ...STANDARD, maxDpi: DPI_FOCUS }], + // Wired, but the whole point of the model is 8000 Hz, which the legacy + // polling command cannot encode. + [0x0091, { model: "Viper 8KHz", ...STANDARD, maxDpi: DPI_FOCUS, pollingRates: RATES_8K, highRatePolling: true }], + [0x00a1, { model: "DeathAdder V2 Lite", ...STANDARD, maxDpi: 8500 }], + [0x00a3, { model: "Cobra", ...STANDARD, maxDpi: 8500 }], + [0x00b2, { model: "DeathAdder V3", ...STANDARD, maxDpi: DPI_FOCUS_PRO }], + + // ---- index3: wired, control channel on USB interface 3 -------------------- + [0x0096, { model: "Naga X", ...INDEX3, maxDpi: 18_000 }], + [0x0099, { model: "Basilisk V3", ...INDEX3, maxDpi: 26_000 }], + [0x00cb, { model: "Basilisk V3 35K", ...INDEX3, maxDpi: DPI_FOCUS_PRO_35K }], + + // ---- atheris-receiver: longer receiver wait ------------------------------- + [0x0062, { model: "Atheris", ...ATHERIS_RECEIVER, maxDpi: 7200 }], + [0x0094, { model: "Orochi V2", ...ATHERIS_RECEIVER, maxDpi: 18_000 }], + + // ---- viper-receiver ------------------------------------------------------- + [0x007a, { model: "Viper Ultimate (Wired)", ...VIPER_RECEIVER_WIRED }], + [0x007b, { model: "Viper Ultimate", ...VIPER_RECEIVER_WIRELESS }], + [0x007c, { model: "DeathAdder V2 Pro (Wired)", ...VIPER_RECEIVER_WIRED }], + [0x007d, { model: "DeathAdder V2 Pro", ...VIPER_RECEIVER_WIRELESS }], + [0x009e, { model: "Viper Mini Signature Edition (Wired)", ...VIPER_RECEIVER_WIRED, maxDpi: DPI_FOCUS_PRO }], + [0x009f, { model: "Viper Mini Signature Edition", ...VIPER_RECEIVER_WIRELESS, maxDpi: DPI_FOCUS_PRO, pollingRates: RATES_8K }], + // Verified on hardware with the stock HyperSpeed receiver, and the first + // model to prove `highRatePolling` is genuinely per-PID rather than a + // property of the transport group or of being wireless: this receiver + // answers only the legacy divisor-of-1000 command and rejects the extended + // one (`0x00`/`0x40`) as unsupported. 125/500/1000 Hz were each written and + // read back. + // + // Do not "tidy" this back onto the group default. 0x00a6 is the standing + // counter-example in the other direction — a 1000 Hz receiver that does use + // the extended command — so neither the group nor the rate ceiling predicts + // this, and it can only be settled per product. + // Lift-off reported working here, so the tracking control stays on — but not + // the asymmetric pair, which was never exercised. If this model turns out to + // answer `0x0b`/`0x85` with zeros the way the Basilisk X HyperSpeed does, it + // will show a permanent "Low" and refuse every level; that is the thing to + // check before trusting it. + [0x00b8, { model: "Viper V3 HyperSpeed", ...VIPER_RECEIVER_WIRELESS, highRatePolling: false, liftOff: true, maxDpi: DPI_FOCUS_PRO, verified: true }], + + // ---- new-receiver --------------------------------------------------------- + [0x006f, { model: "Lancehead Wireless", ...LEGACY_RECEIVER, maxDpi: DPI_CHROMA }], + [0x0070, { model: "Lancehead Wireless (Wired)", ...MODERN_WIRED, maxDpi: DPI_CHROMA }], + [0x0072, { model: "Mamba Wireless", ...LEGACY_RECEIVER, maxDpi: DPI_CHROMA }], + [0x0073, { model: "Mamba Wireless (Wired)", ...MODERN_WIRED, maxDpi: DPI_CHROMA }], + [0x0077, { model: "Pro Click", ...LEGACY_RECEIVER, maxDpi: DPI_CHROMA }], + [0x0080, { model: "Pro Click (Wired)", ...MODERN_WIRED, maxDpi: DPI_CHROMA }], + [0x0083, { model: "Basilisk X HyperSpeed", ...LEGACY_RECEIVER, maxDpi: DPI_CHROMA }], + [0x0086, { model: "Basilisk Ultimate (Wired)", ...MODERN_WIRED }], + [0x0088, { model: "Basilisk Ultimate", ...LEGACY_RECEIVER }], + [0x008f, { model: "Naga Pro (Wired)", ...MODERN_WIRED }], + [0x0090, { model: "Naga Pro", ...LEGACY_RECEIVER }], + [0x009a, { model: "Pro Click Mini", ...LEGACY_RECEIVER, maxDpi: 12_000 }], + [0x009c, { model: "DeathAdder V2 X HyperSpeed", ...LEGACY_RECEIVER, maxDpi: 14_000 }], + [0x00a7, { model: "Naga V2 Pro (Wired)", ...MODERN_WIRED, maxDpi: DPI_FOCUS_PRO }], + [0x00a8, { model: "Naga V2 Pro", ...MODERN_RECEIVER, maxDpi: DPI_FOCUS_PRO }], + [0x00aa, { model: "Basilisk V3 Pro (Wired)", ...MODERN_WIRED, maxDpi: DPI_FOCUS_PRO }], + [0x00ab, { model: "Basilisk V3 Pro", ...MODERN_RECEIVER, maxDpi: DPI_FOCUS_PRO }], + [0x00af, { model: "Cobra Pro (Wired)", ...MODERN_WIRED, maxDpi: DPI_FOCUS_PRO }], + [0x00b0, { model: "Cobra Pro", ...MODERN_RECEIVER, maxDpi: DPI_FOCUS_PRO }], + [0x00b4, { model: "Naga V2 HyperSpeed", ...LEGACY_RECEIVER, maxDpi: DPI_FOCUS_PRO }], + [0x00b6, { model: "DeathAdder V3 Pro (Wired)", ...MODERN_WIRED, maxDpi: DPI_FOCUS_PRO }], + // Hardware report: the extended command is accepted and reads back, but the + // measured report rate stays at 1000 Hz whatever is written. This model ships + // with the stock 1000 Hz HyperSpeed receiver, not the 8000 Hz HyperPolling + // dongle (`0x00b3`, which this driver does not claim), so the extended + // encoding was addressing a ceiling the hardware does not have. `0x00c3` is + // the same model on a second product id and is likely the same, but nobody + // has measured it — see TESTING.md. + [0x00b7, { model: "DeathAdder V3 Pro", ...MODERN_RECEIVER, highRatePolling: false, maxDpi: DPI_FOCUS_PRO }], + [0x00b9, { model: "Basilisk V3 X HyperSpeed", ...LEGACY_RECEIVER, maxDpi: 18_000 }], + [0x00be, { model: "DeathAdder V4 Pro (Wired)", ...MODERN_WIRED, maxDpi: DPI_FOCUS_PRO_35K }], + [0x00bf, { model: "DeathAdder V4 Pro", ...HYPERPOLLING_RECEIVER }], + [0x00c2, { model: "DeathAdder V3 Pro (Wired)", ...MODERN_WIRED, maxDpi: DPI_FOCUS_PRO }], + [0x00c3, { model: "DeathAdder V3 Pro", ...MODERN_RECEIVER, maxDpi: DPI_FOCUS_PRO }], + [0x00c4, { model: "DeathAdder V3 HyperSpeed (Wired)", ...MODERN_WIRED, maxDpi: DPI_FOCUS_PRO }], + [0x00c5, { model: "DeathAdder V3 HyperSpeed", ...LEGACY_RECEIVER, maxDpi: DPI_FOCUS_PRO }], + [0x00c7, { model: "Pro Click V2 Vertical Edition (Wired)", ...MODERN_WIRED }], + [0x00c8, { model: "Pro Click V2 Vertical Edition", ...LEGACY_RECEIVER }], + [0x00cc, { model: "Basilisk V3 Pro 35K (Wired)", ...MODERN_WIRED, maxDpi: DPI_FOCUS_PRO_35K }], + [0x00cd, { model: "Basilisk V3 Pro 35K", ...HYPERPOLLING_RECEIVER }], + [0x00d0, { model: "Pro Click V2 (Wired)", ...MODERN_WIRED }], + [0x00d1, { model: "Pro Click V2", ...LEGACY_RECEIVER }], + [0x00d3, { model: "Basilisk Mobile (Wired)", ...MODERN_WIRED, maxDpi: 18_000 }], + [0x00d4, { model: "Basilisk Mobile", ...LEGACY_RECEIVER, maxDpi: 18_000 }], + [0x00d6, { model: "Basilisk V3 Pro 35K Phantom Green (Wired)", ...MODERN_WIRED, maxDpi: DPI_FOCUS_PRO_35K }], + [0x00d7, { model: "Basilisk V3 Pro 35K Phantom Green", ...HYPERPOLLING_RECEIVER }], +]; + +/** + * Every product, with its audited transaction id attached. + * + * Joined here rather than written into each row so there is exactly one place + * the id can come from, and no preset can supply one by inheritance. + */ +export const RAZER_PRODUCTS: ReadonlyMap = new Map( + PRODUCT_DEFINITIONS.map(([productId, product]) => [ + productId, + { ...product, transactionId: transactionIdFor(productId) }, + ]), +); + +/** Product ids for the WebHID picker filters in `../vendors.ts`. */ +export const RAZER_PRODUCT_IDS: readonly number[] = [...RAZER_PRODUCTS.keys()]; diff --git a/src/devices/razer/hid.test.ts b/src/devices/razer/hid.test.ts index cd53eba7..a72551ab 100644 --- a/src/devices/razer/hid.test.ts +++ b/src/devices/razer/hid.test.ts @@ -92,6 +92,207 @@ function fakeMouse(state: FakeLiftOff, options: FakeOptions = {}) { return { client: new RazerHidClient(device), sent }; } +/** + * A mouse that answers firmware, DPI and legacy polling, and reports every + * power-management command (class 0x07) as unsupported — the shape an untested + * model takes when its `hasBattery` prediction is wrong. + */ +function fakeMouseWithoutBattery(productId: number) { + let pending = new Uint8Array(RAZER_PACKET_LENGTH); + const device = { + vendorId: 0x1532, + productId, + productName: "Razer test device", + opened: true, + collections: [{ usagePage: 0x01, usage: 0x02, children: [], featureReports: [], inputReports: [], outputReports: [] }], + open: async () => {}, + close: async () => {}, + sendFeatureReport: async (_reportId: number, data: Uint8Array) => { + const [commandClass, commandId] = [data[6], data[7]]; + const answer = (dataSize: number, args: number[]) => + replyPacket(commandClass, commandId, dataSize, args, RAZER_STATUS.ok); + if (commandClass === 0x00 && commandId === 0x81) pending = answer(0x02, [1, 12]); + else if (commandClass === 0x04 && commandId === 0x85) pending = answer(0x07, [0x01, 0x03, 0x20, 0x03, 0x20]); + else if (commandClass === 0x00 && commandId === 0x85) pending = answer(0x01, [1]); + else pending = replyPacket(commandClass, commandId, data[5], [], RAZER_STATUS.unsupported); + }, + receiveFeatureReport: async () => new DataView(pending.buffer.slice(0)), + } as unknown as HIDDevice; + return new RazerHidClient(device); +} + +test("an untested model that refuses the battery read still reports the rest", async () => { + // `hasBattery` is a prediction on a model nobody has connected. Unlike sleep + // and low power, this read is not optional, so an unsupported reply would + // abort the whole status read and take DPI and polling down with it. + // Arrange: 0x0083 is Basilisk X HyperSpeed — wireless, unverified. + const client = fakeMouseWithoutBattery(0x0083); + + // Act + const status = await client.readStatus(); + + // Assert + assert.equal(status.name, "Razer Basilisk X HyperSpeed"); + assert.equal(status.batteryPercent, null); + assert.equal(status.dpi, 800); + assert.equal(status.pollingRateHz, 1000); + // The panel should not present a transcribed model as a tested one. + assert.match(status.connectionDetail ?? "", /untested model/); +}); + +test("a reply left over from the previous command is re-read, not reported", async () => { + // A DeathAdder V3 Pro capture had two reads out of 252 answered by the + // command before them — `0x07`/`0x83` receiving `0x07`/`0x80`'s battery reply + // with a transaction id the host never sent. The buffer had simply not caught + // up, and the following exchange resynced both times, so the recovery is the + // same one `busy` gets. Arrange: the sleep read is stale exactly once. + let staleLeft = 1; + let pending = new Uint8Array(RAZER_PACKET_LENGTH); + const device = { + vendorId: 0x1532, + productId: 0x00b6, + productName: "Razer DeathAdder V3 Pro", + opened: true, + collections: [{ usagePage: 0x01, usage: 0x02, children: [], featureReports: [], inputReports: [], outputReports: [] }], + open: async () => {}, + close: async () => {}, + sendFeatureReport: async (_reportId: number, data: Uint8Array) => { + const [commandClass, commandId] = [data[6], data[7]]; + const answer = (dataSize: number, args: number[]) => + replyPacket(commandClass, commandId, dataSize, args, RAZER_STATUS.ok); + if (commandClass === 0x07 && commandId === 0x83 && staleLeft > 0) { + staleLeft -= 1; + // The battery answer, verbatim, in place of the sleep timeout's. + pending = replyPacket(0x07, 0x80, 0x02, [0x00, 0x3b], RAZER_STATUS.ok); + return; + } + if (commandClass === 0x00 && commandId === 0x81) pending = answer(0x02, [1, 5]); + else if (commandClass === 0x07 && commandId === 0x80) pending = answer(0x02, [0x00, 0x3b]); + else if (commandClass === 0x07 && commandId === 0x83) pending = answer(0x02, [0x01, 0x2c]); + else if (commandClass === 0x04 && commandId === 0x85) pending = answer(0x07, [0x01, 0x06, 0x40, 0x06, 0x40]); + else if (commandClass === 0x00 && commandId === 0x85) pending = answer(0x01, [1]); + else pending = replyPacket(commandClass, commandId, data[5], [], RAZER_STATUS.unsupported); + }, + receiveFeatureReport: async () => new DataView(pending.buffer.slice(0)), + } as unknown as HIDDevice; + + // Act + const status = await new RazerHidClient(device).readStatus(); + + // Assert: the retry found the real answer rather than dropping the field. + assert.equal(status.sleepTimeout, 300); + assert.equal(staleLeft, 0); +}); + +test("a stale reply to a write is reported rather than sending the write again", async () => { + // The same recovery must not extend to setters. A write is the one command + // the reference says never to repeat blindly, and a stale reply is no + // evidence the first one missed — it may already have landed. + const sent: Uint8Array[] = []; + let pending = new Uint8Array(RAZER_PACKET_LENGTH); + const device = { + vendorId: 0x1532, + productId: 0x00b6, + productName: "Razer DeathAdder V3 Pro", + opened: true, + collections: [{ usagePage: 0x01, usage: 0x02, children: [], featureReports: [], inputReports: [], outputReports: [] }], + open: async () => {}, + close: async () => {}, + sendFeatureReport: async (_reportId: number, data: Uint8Array) => { + sent.push(data.slice()); + // Always one command behind: the sleep write gets the battery's answer. + pending = replyPacket(0x07, 0x80, 0x02, [0x00, 0x3b], RAZER_STATUS.ok); + }, + receiveFeatureReport: async () => new DataView(pending.buffer.slice(0)), + } as unknown as HIDDevice; + + // Act / Assert + await assert.rejects( + () => new RazerHidClient(device).setSleepTimeout(300), + /answered by a different command/, + ); + assert.equal(sent.filter((packet) => packet[6] === 0x07 && packet[7] === 0x03).length, 1); +}); + +/** + * The Basilisk X HyperSpeed as captured in a user's diagnostics: it answers + * `0x0b`/`0x85` with status 0x02 and an all-zero payload, refuses the charging + * query, and reports a good battery level. + */ +function fakeBasiliskXHyperSpeed() { + const sent: Uint8Array[] = []; + let pending = new Uint8Array(RAZER_PACKET_LENGTH); + const device = { + vendorId: 0x1532, + productId: 0x0083, + productName: "Razer Basilisk X HyperSpeed", + opened: true, + collections: [{ usagePage: 0x01, usage: 0x02, children: [], featureReports: [], inputReports: [], outputReports: [] }], + open: async () => {}, + close: async () => {}, + sendFeatureReport: async (_reportId: number, data: Uint8Array) => { + sent.push(data); + const [commandClass, commandId] = [data[6], data[7]]; + const ok = (dataSize: number, args: number[]) => + replyPacket(commandClass, commandId, dataSize, args, RAZER_STATUS.ok); + if (commandClass === 0x00 && commandId === 0x81) pending = ok(0x02, [1, 2]); + else if (commandClass === 0x07 && commandId === 0x80) pending = ok(0x02, [0x00, 0x35]); + // Captured: the charging query is refused even though the level works. + else if (commandClass === 0x07 && commandId === 0x84) pending = replyPacket(commandClass, commandId, 0x02, [], RAZER_STATUS.unsupported); + else if (commandClass === 0x04 && commandId === 0x85) pending = ok(0x07, [0x01, 0x1f, 0x40, 0x1f, 0x40]); + else if (commandClass === 0x00 && commandId === 0x85) pending = ok(0x01, [1]); + // Captured: answers the lift-off read with zeros, which decode as "Low". + else if (commandClass === 0x0b && commandId === 0x85) pending = ok(0x05, [0, 0, 0, 0, 0]); + else pending = replyPacket(commandClass, commandId, data[5], [], RAZER_STATUS.unsupported); + }, + receiveFeatureReport: async () => new DataView(pending.buffer.slice(0)), + } as unknown as HIDDevice; + return { client: new RazerHidClient(device), sent }; +} + +test("a mouse that answers the lift-off read with zeros is not offered lift-off", async () => { + // The reply decodes as a legitimate "Low", so a successful read cannot be + // what enables the control: every level then fails, one silently acknowledged + // and the other refused outright. + // Arrange + const { client, sent } = fakeBasiliskXHyperSpeed(); + + // Act + const status = await client.readStatus(); + + // Assert + assert.deepEqual(status.supportedLiftOffDistances, []); + assert.equal(status.liftOffDistance, null); + assert.equal(status.asymmetricLiftOff, null); + // Not merely hidden — the command is never sent, so it costs no round trip + // on the refresh loop either. + assert.equal(sent.some((packet) => packet[6] === 0x0b), false, "class 0x0b was still sent"); +}); + +test("a battery level survives a mouse that refuses the charging query", async () => { + // Captured: 0x07/0x80 answers 0x35 while 0x07/0x84 is unsupported. Losing the + // level over that is losing the reading the panel exists to show. + // Arrange + const { client } = fakeBasiliskXHyperSpeed(); + + // Act + const status = await client.readStatus(); + + // Assert + assert.equal(status.batteryPercent, 21); + assert.equal(status.batteryState, "Unknown"); +}); + +test("a verified model still fails loudly when its battery read stops answering", async () => { + // There the command is known to exist, so a refusal is news rather than an + // absent capability, and hiding it would hide a real fault. + // Arrange: 0x00c1 is the Viper V3 Pro receiver. + const client = fakeMouseWithoutBattery(0x00c1); + + // Act / Assert + await assert.rejects(() => client.readStatus(), /not supported by this mouse/); +}); + test("lift-off reads the tracking level and the asymmetric pair together", async () => { // Arrange const { client } = fakeMouse({ tracking: 1, liftOff: 16, landing: 11, asymmetric: true }); diff --git a/src/devices/razer/hid.ts b/src/devices/razer/hid.ts index d31f3911..2a65b69c 100644 --- a/src/devices/razer/hid.ts +++ b/src/devices/razer/hid.ts @@ -1,5 +1,6 @@ import type { MouseStatus } from "../mouse-types.ts"; import { VENDOR_ID } from "../vendors.ts"; +import { RATES_1K, RAZER_PRODUCTS, type RazerProduct } from "./devices.ts"; import { RAZER_LANDING_MAX, RAZER_LANDING_MIN, @@ -10,7 +11,6 @@ import { RAZER_STATUS, RAZER_TRACKING_DISTANCES, RAZER_TRANSACTION_ID, - RAZER_TRANSACTION_ID_LEGACY, RazerProtocolError, decodeBatteryPercent, decodeCharging, @@ -24,6 +24,7 @@ import { decodeSerial, decodeSleepTimeout, encodeRazerRequest, + isRazerGetter, razerSetDpiCommand, razerSetExtendedPollingCommand, razerSetLegacyPollingCommand, @@ -38,53 +39,6 @@ import { type RazerTrackingDistance, } from "./protocol.ts"; -interface RazerProduct { - model: string; - wireless: boolean; - pollingRates: readonly number[]; - /** Sensor ceiling, per axis. */ - maxDpi: number; - /** Razer's per-generation transaction id; a mismatch means no reply at all. */ - transactionId: number; - /** Battery commands only exist on models that have one. */ - hasBattery: boolean; - /** Also accept a vendor-defined collection as the control interface. */ - vendorControlInterface?: boolean; -} - -// The cable tops out at 1000 Hz on this model, which is also the ceiling the -// legacy polling command can encode. HyperPolling rates need the receiver. -const RATES_WIRED: readonly number[] = [125, 500, 1000]; -const RATES_RECEIVER: readonly number[] = [125, 500, 1000, 2000, 4000, 8000]; - -// The DeathAdder Essential's officially published maximum, and the ceiling the -// vendor software offers. -const DEATHADDER_ESSENTIAL = { - wireless: false, - pollingRates: RATES_WIRED, - maxDpi: 6400, - transactionId: RAZER_TRANSACTION_ID_LEGACY, - hasBattery: false, - vendorControlInterface: true, -} as const; - -const VIPER_V3_PRO = { - maxDpi: 35000, - transactionId: RAZER_TRANSACTION_ID, - hasBattery: true, -} as const; - -const PRODUCTS: ReadonlyMap = new Map([ - // Stock receiver, not 8K HyperPolling. - [0x00a5, { model: "Viper V2 Pro", wireless: false, pollingRates: RATES_WIRED, maxDpi: 30000, transactionId: RAZER_TRANSACTION_ID, hasBattery: true }], - [0x00a6, { model: "Viper V2 Pro", wireless: true, pollingRates: RATES_WIRED, maxDpi: 30000, transactionId: RAZER_TRANSACTION_ID, hasBattery: true }], - [0x00c0, { model: "Viper V3 Pro", wireless: false, pollingRates: RATES_WIRED, ...VIPER_V3_PRO }], - [0x00c1, { model: "Viper V3 Pro", wireless: true, pollingRates: RATES_RECEIVER, ...VIPER_V3_PRO }], - [0x006e, { model: "DeathAdder Essential", ...DEATHADDER_ESSENTIAL }], - [0x0071, { model: "DeathAdder Essential White Edition", ...DEATHADDER_ESSENTIAL }], - [0x0098, { model: "DeathAdder Essential (2021)", ...DEATHADDER_ESSENTIAL }], -]); - // The sensor takes any whole DPI from here up to the model's ceiling, per axis. const DPI_MIN = 100; const RESPONSE_DELAY_MS = 100; @@ -154,14 +108,27 @@ export class RazerHidClient { } static isSupported(device: HIDDevice): boolean { - const product = PRODUCTS.get(device.productId); + const product = RAZER_PRODUCTS.get(device.productId); if (device.vendorId !== VENDOR_ID.razer || !product) return false; return isMouseControlInterface(device) || (product.vendorControlInterface === true && hasVendorCollection(device)); } private profile(): RazerProduct | undefined { - return PRODUCTS.get(this.device.productId); + return RAZER_PRODUCTS.get(this.device.productId); + } + + /** + * Whether polling uses the extended command (a divisor of 8000) rather than + * the legacy one (a divisor of 1000). + * + * Asked of the product rather than of the connection: the Viper 8KHz is wired + * and needs the extended command, while the pre-HyperPolling receivers are + * wireless and answer only the legacy one. For every model verified so far + * this is exactly `isWireless()`, which is what it used to read. + */ + private usesHighRatePolling(): boolean { + return this.profile()?.highRatePolling ?? this.isWireless(); } async open(): Promise { @@ -183,12 +150,24 @@ export class RazerHidClient { return this.profile()?.wireless ?? false; } + /** + * Says so when the model has never been connected by this project. + * + * The commands are the ones already confirmed elsewhere, but which of them a + * given model answers is transcribed rather than measured, so the panel + * should not present it as the same thing as a tested mouse. + */ + private connectionDetail(wireless: boolean): string { + const link = wireless ? "HyperSpeed receiver" : "Wired USB"; + return this.profile()?.verified === false ? `${link} · untested model` : link; + } + maxDpi(): number { return this.profile()?.maxDpi ?? 35000; } getSupportedPollingRates(): number[] { - return [...(this.profile()?.pollingRates ?? RATES_WIRED)]; + return [...(this.profile()?.pollingRates ?? RATES_1K)]; } /** Seconds, matching what the panel labels and what `setSleepTimeout` takes. */ @@ -222,7 +201,17 @@ export class RazerHidClient { // A wired mouse with no cell answers battery and power-management commands // as unsupported, which would otherwise abort the whole status read. const hasBattery = this.profile()?.hasBattery !== false; - const battery = hasBattery ? await this.readBattery() : null; + // On a model nobody has connected, `hasBattery` is a prediction. An + // unsupported reply throws, and this read is not optional the way sleep and + // low power are, so it would abort the whole status read and take DPI and + // polling down with it rather than dropping one card. Verified models still + // fail loudly: there, a battery command that stopped answering is news. + const battery = hasBattery + ? await this.readBattery().catch((error: unknown) => { + if (this.profile()?.verified === true) throw error; + return null; + }) + : null; // Both transports answer this, unlike polling. A transport that ever stops // reports no timeout and hides the control rather than failing the whole // read, which would take DPI and battery down with it. @@ -230,7 +219,13 @@ export class RazerHidClient { const lowPower = hasBattery ? await this.request(RAZER_READ.lowPowerThreshold).catch(() => null) : null; const dpi = decodeDpi(await this.request(RAZER_READ.dpi)); const pollingRateHz = await this.readPollingRateHz(); - const liftOff = await this.readLiftOff(); + // Asked of the product, not of the mouse. A model without lift-off can + // still answer this read — the Basilisk X HyperSpeed returns status 0x02 + // with an all-zero payload, which decodes as a perfectly ordinary "Low" — + // so a successful reply proves nothing and the control cannot be offered on + // the strength of one. Skipping it also spares 0x0b a round trip on every + // background refresh for every model that does not have it. + const liftOff = this.profile()?.liftOff === true ? await this.readLiftOff() : null; return { brand: "Razer", name: this.displayName(), @@ -263,7 +258,7 @@ export class RazerHidClient { supportedPollingRates: this.getSupportedPollingRates(), activeProfile: null, connectionType: wireless ? "Wireless" : "Wired", - connectionDetail: wireless ? "HyperSpeed receiver" : "Wired USB", + connectionDetail: this.connectionDetail(wireless), sleepTimeout: sleep ? decodeSleepTimeout(sleep) : null, lowBatteryWarning: lowPower ? decodeLowPowerThreshold(lowPower) : null, unitId: serial ? decodeSerial(serial) : null, @@ -271,13 +266,15 @@ export class RazerHidClient { // An empty list hides the control, which is what should happen on a // transport that does not answer class 0x0b at all. supportedLiftOffDistances: liftOff ? [...RAZER_TRACKING_DISTANCES] : [], - asymmetricLiftOff: liftOff && { - enabled: await this.asymmetricMode(liftOff), - liftOff: liftOff.liftOff, - landing: liftOff.landing, - liftOffRange: { min: RAZER_LIFT_OFF_MIN, max: RAZER_LIFT_OFF_MAX }, - landingRange: { min: RAZER_LANDING_MIN, max: RAZER_LANDING_MAX }, - }, + asymmetricLiftOff: liftOff && this.hasAsymmetricLiftOff() + ? { + enabled: await this.asymmetricMode(liftOff), + liftOff: liftOff.liftOff, + landing: liftOff.landing, + liftOffRange: { min: RAZER_LIFT_OFF_MIN, max: RAZER_LIFT_OFF_MAX }, + landingRange: { min: RAZER_LANDING_MIN, max: RAZER_LANDING_MAX }, + } + : null, firmware: [`Mouse ${decodeFirmwareVersion(firmware)}`], }; } @@ -338,7 +335,7 @@ export class RazerHidClient { if (!this.getSupportedPollingRates().includes(pollingRateHz)) { throw new Error(`This mouse does not support ${pollingRateHz.toLocaleString()} Hz on this connection.`); } - await this.request(this.isWireless() + await this.request(this.usesHighRatePolling() ? razerSetExtendedPollingCommand(pollingRateHz) : razerSetLegacyPollingCommand(pollingRateHz)); const confirmed = await this.readPollingRateHz(); @@ -348,10 +345,24 @@ export class RazerHidClient { return confirmed; } + /** + * Battery level, and the charging state when the mouse reports one. + * + * The two are separate commands and a mouse may answer the first without the + * second — the Basilisk X HyperSpeed reports a good level and refuses + * `0x07`/`0x84` as unsupported. Letting that take the level down with it threw + * away the reading the panel actually wanted; an unknown charging state is + * only a missing word in the battery caption. + */ private async readBattery(): Promise<{ percent: number; state: MouseStatus["batteryState"] }> { const level = await this.request(RAZER_READ.battery); - const charging = decodeCharging(await this.request(RAZER_READ.charging)); - return { percent: decodeBatteryPercent(level), state: charging ? "Charging" : "Discharging" }; + const charging = await this.request(RAZER_READ.charging).catch(() => null); + return { + percent: decodeBatteryPercent(level), + state: charging === null + ? "Unknown" + : decodeCharging(charging) ? "Charging" : "Discharging", + }; } /** @@ -397,6 +408,18 @@ export class RazerHidClient { } } + /** + * Whether this model stores a separate lift-off/landing pair at all. + * + * Establishing the mode costs a *write*, so it is only attempted where the + * pair commands have been confirmed on hardware. A model that has not been + * connected keeps the plain three-stop tracking control instead, which costs + * reads only. + */ + private hasAsymmetricLiftOff(): boolean { + return this.profile()?.asymmetricLiftOff === true; + } + /** Probes once, then trusts what the setters leave behind. */ private async asymmetricMode(current: RazerLiftOff): Promise { if (!this.asymmetricKnown) { @@ -469,7 +492,7 @@ export class RazerHidClient { private async readPollingRateHz(): Promise { const extended = [RAZER_READ.pollingRateExtended, decodeExtendedPollingRate] as const; const legacy = [RAZER_READ.pollingRate, decodeLegacyPollingRate] as const; - for (const [command, decode] of this.isWireless() ? [extended, legacy] : [legacy, extended]) { + for (const [command, decode] of this.usesHighRatePolling() ? [extended, legacy] : [legacy, extended]) { const reply = await this.request(command).catch(() => null); if (reply) return decode(reply); } @@ -494,18 +517,36 @@ export class RazerHidClient { private async exchange(command: RazerCommand): Promise { await this.open(); const transactionId = this.profile()?.transactionId ?? RAZER_TRANSACTION_ID; - await this.device.sendFeatureReport(RAZER_REPORT_ID, encodeRazerRequest(command, transactionId)); + const request = encodeRazerRequest(command, transactionId); + await this.device.sendFeatureReport(RAZER_REPORT_ID, request); + let last: unknown = new Error("The mouse stayed busy — it may be asleep or out of range."); for (let attempt = 0; attempt < RESPONSE_ATTEMPTS; attempt += 1) { await this.delay(RESPONSE_DELAY_MS); const reply = this.copyDataView(await this.device.receiveFeatureReport(RAZER_REPORT_ID)); try { return decodeRazerResponse(reply, command); } catch (error) { - if (error instanceof RazerProtocolError && error.status === RAZER_STATUS.busy) continue; + if (!(error instanceof RazerProtocolError)) throw error; + // Busy means "ask again", and re-reading is enough: the mouse is + // already working on this command. + if (error.status === RAZER_STATUS.busy) { + last = error; + continue; + } + // A reply carrying an earlier command's id means the buffer is behind. + // Re-reading alone cannot fix that — nothing new arrives until the host + // asks again — so the request has to go out a second time. Only for a + // getter: repeating a write is the one retry the reference warns + // against, and every setter here confirms itself by read-back anyway. + if (error.stale && isRazerGetter(command)) { + last = error; + await this.device.sendFeatureReport(RAZER_REPORT_ID, request); + continue; + } throw error; } } - throw new Error("The mouse stayed busy — it may be asleep or out of range."); + throw last; } private copyDataView(view: DataView): Uint8Array { diff --git a/src/devices/razer/protocol.test.ts b/src/devices/razer/protocol.test.ts index ee9e3c57..4ce474b8 100644 --- a/src/devices/razer/protocol.test.ts +++ b/src/devices/razer/protocol.test.ts @@ -10,7 +10,7 @@ import { RAZER_READ, RAZER_STATUS, RAZER_TRANSACTION_ID, - RAZER_TRANSACTION_ID_LEGACY, + RAZER_TRANSACTION_ID_3F, RAZER_WRITE, RazerProtocolError, decodeBatteryPercent, @@ -69,7 +69,7 @@ test("requests carry the transaction id, command and checksum", () => { }); test("a request can carry the older transaction id the Essential family uses", () => { - const packet = encodeRazerRequest(RAZER_READ.firmware, RAZER_TRANSACTION_ID_LEGACY); + const packet = encodeRazerRequest(RAZER_READ.firmware, RAZER_TRANSACTION_ID_3F); assert.equal(packet[1], 0x3f); assert.equal(packet[5], 0x02); @@ -81,7 +81,7 @@ test("the transaction id sits outside the checksummed range", () => { // OpenRazer checksums bytes 2..87, so the same command checksums identically // on either transaction id. A mismatch here would mean the range is wrong. const modern = encodeRazerRequest(RAZER_READ.dpi, RAZER_TRANSACTION_ID); - const legacy = encodeRazerRequest(RAZER_READ.dpi, RAZER_TRANSACTION_ID_LEGACY); + const legacy = encodeRazerRequest(RAZER_READ.dpi, RAZER_TRANSACTION_ID_3F); assert.notEqual(modern[1], legacy[1]); assert.equal(modern[88], legacy[88]); diff --git a/src/devices/razer/protocol.ts b/src/devices/razer/protocol.ts index 147f588c..cbd7749b 100644 --- a/src/devices/razer/protocol.ts +++ b/src/devices/razer/protocol.ts @@ -10,15 +10,29 @@ export const RAZER_REPORT_ID = 0; export const RAZER_PACKET_LENGTH = 90; -/** Verified against Viper V3 Pro firmware 1.12 on both transports. */ -export const RAZER_TRANSACTION_ID = 0x1f; +/** + * Razer's three transaction ids. + * + * Named by value on purpose. They carry no meaning beyond "which firmware + * generation answers to this", they do not order neatly by device age, and + * every name that tried to describe them has been wrong: `_LEGACY` for `0x3f` + * read as "the oldest one" when `0xff` is older, and `_DEFAULT` for `0xff` + * read as "use this when unsure" when there is no safe default at all. A + * mismatch is silent — the mouse simply never replies — so every product must + * state its own id and none may be inferred from a family name. + * + * The same three values are what OpenRazer's driver selects between, and what + * the reference notes type as `"ff" | "3f" | "1f"`. + */ +export const RAZER_TRANSACTION_ID_FF = 0xff; +export const RAZER_TRANSACTION_ID_3F = 0x3f; +export const RAZER_TRANSACTION_ID_1F = 0x1f; /** - * Razer's older configuration interfaces answer on a different transaction id, - * and a mismatch is silent: the mouse simply never replies. OpenRazer uses this - * one for the DeathAdder Essential family. + * Default for `encodeRazerRequest`. Verified against Viper V3 Pro firmware 1.12 + * on both transports; the per-product table overrides it for everything else. */ -export const RAZER_TRANSACTION_ID_LEGACY = 0x3f; +export const RAZER_TRANSACTION_ID = RAZER_TRANSACTION_ID_1F; const ARGS_OFFSET = 8; const CHECKSUM_INDEX = 88; @@ -123,14 +137,30 @@ export function razerSetExtendedPollingCommand(pollingRateHz: number): RazerComm export class RazerProtocolError extends Error { readonly status: number | null; + /** + * The reply belonged to an earlier exchange, so re-reading may still find the + * right one. Distinct from a failure status, which the mouse meant for us. + */ + readonly stale: boolean; - constructor(message: string, status: number | null = null) { + constructor(message: string, status: number | null = null, stale = false) { super(message); this.name = "RazerProtocolError"; this.status = status; + this.stale = stale; } } +/** + * Razer pairs each read with a write that clears the high bit of the command + * id, so the bit is what separates a question from an instruction. Repeating a + * question costs nothing; repeating an instruction is what the reference means + * by "do not automatically retry writes". + */ +export function isRazerGetter(command: RazerCommand): boolean { + return (command.commandId & 0x80) !== 0; +} + export function razerChecksum(packet: Uint8Array): number { let checksum = 0; for (let index = CHECKSUM_FIRST; index < CHECKSUM_LAST; index += 1) checksum ^= packet[index]; @@ -172,7 +202,7 @@ export function decodeRazerResponse(packet: Uint8Array, command: RazerCommand): throw new RazerProtocolError(describe(command, `returned status ${`0x${status.toString(16).padStart(2, "0")}`}`), status); } if (packet[6] !== command.commandClass || packet[7] !== command.commandId) { - throw new RazerProtocolError(describe(command, "was answered by a different command"), status); + throw new RazerProtocolError(describe(command, "was answered by a different command"), status, true); } const length = Math.min(packet[5], RAZER_PACKET_LENGTH - ARGS_OFFSET); return packet.slice(ARGS_OFFSET, ARGS_OFFSET + length); diff --git a/src/devices/vendors.ts b/src/devices/vendors.ts index 3001636a..9271ae55 100644 --- a/src/devices/vendors.ts +++ b/src/devices/vendors.ts @@ -1,5 +1,6 @@ import { EGG_WE_HID_FILTERS } from "./endgame/egg-we-control.ts"; import { LOGITECH_DIRECT_PRODUCT_IDS } from "./logitech/protocol.ts"; +import { RAZER_PRODUCT_IDS } from "./razer/devices.ts"; export const VENDOR_ID = { pulsar: 0x3710, @@ -51,6 +52,26 @@ export const RAZER_DEATHADDER_ESSENTIAL_FILTERS: HIDDeviceFilter[] = [0x006e, 0x (productId) => ({ vendorId: VENDOR_ID.razer, productId }), ); +/** + * Razer product ids whose control interface is known, and which therefore get a + * narrower filter of their own above. Excluded from the catch-all below so a + * broad filter cannot quietly widen one that was deliberately narrowed. + */ +const RAZER_NARROWED_PRODUCT_IDS: ReadonlySet = new Set([ + 0x00a5, 0x00a6, 0x00c0, 0x00c1, 0x006e, 0x0071, 0x0098, +]); + +/** + * Every remaining product in the Razer registry. Which interface carries the + * control channel has not been established for these, and it varies by + * revision, so the whole device is requested and the picker offers each + * interface: the driver rejects the ones that cannot answer, and a model whose + * first entry never replies is added again on another entry. + */ +export const RAZER_REGISTRY_FILTERS: HIDDeviceFilter[] = RAZER_PRODUCT_IDS + .filter((productId) => !RAZER_NARROWED_PRODUCT_IDS.has(productId)) + .map((productId) => ({ vendorId: VENDOR_ID.razer, productId })); + export const TEEVOLUTION_PRODUCT_IDS = [0xf520, 0xf523, 0xf5bb, 0xf522] as const; // Logitech HID++ control interfaces addressed through a receiver slot (HID++ @@ -127,6 +148,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ { vendorId: VENDOR_ID.atk, usagePage: 0xff02, usage: 2 }, ...RAZER_VIPER_V4_CONTROL_FILTERS, ...RAZER_DEATHADDER_ESSENTIAL_FILTERS, + ...RAZER_REGISTRY_FILTERS, ...EGG_WE_HID_FILTERS, ...LOGITECH_RECEIVER_FILTERS, ];