diff --git a/src/drivers/razer/devices.test.ts b/src/drivers/razer/devices.test.ts index c27d7f0..ef50045 100644 --- a/src/drivers/razer/devices.test.ts +++ b/src/drivers/razer/devices.test.ts @@ -247,6 +247,20 @@ test("the asymmetric lift-off write probe is only armed where it was confirmed", for (const id of armed) assert.equal(RAZER_PRODUCTS.get(id)?.verified, true); }); +test("button mapping is only offered on connections where class 0x02 answered", () => { + // Gated per product id and per connection, not per model. An all-zero reply + // decodes as "Disabled" on every control, so a transport that does not + // implement class 0x02 does not fail loudly — it produces a full, plausible + // set of controls that all read Disabled and silently do nothing. Hence an + // allowlist, and hence a hardware run per entry even for two connections of + // the same mouse. + const offered = RAZER_PRODUCT_IDS.filter((id) => RAZER_PRODUCTS.get(id)?.buttonMapping === true); + assert.deepEqual(offered.sort(), [0x00c0, 0x00c1]); + for (const id of offered) { + assert.equal(RAZER_PRODUCTS.get(id)?.verified, true, `0x${id.toString(16)} offers button mapping without being verified`); + } +}); + 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. diff --git a/src/razer/devices.ts b/src/razer/devices.ts index 5e23eec..1beea44 100644 --- a/src/razer/devices.ts +++ b/src/razer/devices.ts @@ -77,8 +77,18 @@ export interface RazerProduct { * only ever been exercised on the Viper V3 Pro. Other Razer mice may well use * a different class or a different control-index scheme, so nothing is * offered to them until it has been checked on hardware. Gates both - * `RazerButtonControl` and `RazerToggleControl` — same command, same - * per-model risk, no reason to split them. + * `RazerButtonControl` and `RazerToggleControl` — same command, same risk, + * no reason to split them. + * + * Set per product id, which means per *connection*: the cable and the + * receiver are separate entries for one mouse and were verified separately. + * That is not pedantry about provenance — the failure mode is silent. + * `razerDecodeButtonMapping` reads `type=0x00, len=0x00, value=0x00` as + * "Disabled", so a transport that does not implement the class answers + * all-zero and yields a full, ordinary-looking set of controls that every + * read reports as Disabled and every write appears to accept. + * `readButtonMappings`'s null-collapse cannot catch it, because the dict + * comes back populated. */ buttonMapping?: boolean; /** Also accept a vendor-defined collection as the control interface. */ @@ -371,6 +381,10 @@ const VIPER_V3_PRO = { hasBattery: true, liftOff: true, asymmetricLiftOff: true, + // Class 0x02 is confirmed on both of this model's connections, so it belongs + // on the shared preset rather than on one product id. It sat on `0x00c1` + // alone while only the receiver had been exercised. + buttonMapping: true, verified: true, } as const; @@ -386,12 +400,7 @@ const PRODUCT_DEFINITIONS: ReadonlyArray<[number, Omit