From fd0d5317b0855c5b714b3d240e86a57e476374e0 Mon Sep 17 00:00:00 2001 From: Pochiiko Date: Sun, 16 Aug 2026 15:54:38 +1000 Subject: [PATCH 1/2] feat(razer): add Viper V3 Pro button mapping Reverse-engineered and hardware-verified against the Viper V3 Pro receiver (firmware 1.12), class 0x02. Read 0x02/0x8c, dataSize 0x0a, args [0x01, controlIndex, layer] Write 0x02/0x0c, dataSize 0x0a, [0x01, controlIndex, layer, actionType, actionLen, actionValue, 0,0,0,0] The write silently no-ops under the shared transaction id 0x1f: status 0x02 (ok), the read-back even changes, but the physical button keeps its old function. Confirmed by writing a plain mouse-button action, pressing the button, and finding it unchanged with 0x1f and correct with any other id (0x02 and 0x10 both round-tripped and worked physically). Hence RAZER_BUTTON_TRANSACTION_ID = 0x10, pinned on the command itself; encodeRazerRequest already accepted a per-call id, so exchange() now prefers command.transactionId over the per-product one. Two distinct control types rather than one: RazerButtonControl (leftClick, rightClick, mouse4, mouse5) is cross- assignable between each other's actions or Disabled -- confirmed cross- control on hardware, not just identity: Mouse Button 4 written with Right Click's index physically right-clicked. Left Click is locked on the Standard layer, matching Synapse's own restriction. RazerToggleControl (scrollUp, scrollDown, sensitivityButton) only switches between its own captured factory action and Disabled. Kept a separate type and out of RAZER_BUTTON_MAPPINGS because cross-assigning these has never been attempted on hardware, and because the sensitivity button's default ("Cycle Up Sensitivity Stages") is actionType 0x06 -- not a plain-button action and not decoded generally. Control indices, each settled by isolated single-variable capture or physical cross-remap test: 0x01 Left Click, 0x02 Right Click, 0x03 Scroll Click, 0x04 Mouse Button 4, 0x05 Mouse Button 5, 0x09 Scroll Up, 0x0a Scroll Down, 0x60 sensitivity button. 0x06-0x08 were probed and never answered; the space is sparse, so neighbours are not guessed. Gated per product by RazerProduct.buttonMapping, set only on the receiver (0x00c1) -- class 0x02 has only ever been exercised over that connection path. The cable (0x00c0) is the same mouse and very likely answers the same way, but a shared protocol family is not evidence for a specific product id, so it is left off until someone checks it. Mappings are read once per connection and refreshed from each write's own confirmed read-back, mirroring asymmetric/asymmetricKnown, rather than polled on the status timer. readButtonMappings returns null rather than an empty record when no control answers, so a transport that does not support the class hides the control instead of reporting an empty set. Not shipped: the Hypershift layer (the read does not honour a requested layer at all, which breaks the read-back verification every write here depends on) and Scroll Click (index known, but this driver's own write to it has only been physically checked against the wrong action). Adds 20 tests built from the hardware captures. --- src/drivers/mouse-types.ts | 11 ++ src/drivers/razer/hid.ts | 121 +++++++++++++- src/drivers/razer/protocol.test.ts | 258 +++++++++++++++++++++++++++++ src/razer/codec.ts | 220 ++++++++++++++++++++++++ src/razer/devices.ts | 16 +- 5 files changed, 621 insertions(+), 5 deletions(-) diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index dd1ffb8..96b2191 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -171,6 +171,17 @@ export interface MouseStatus { eggPollingDivider?: number; eggMulticlickFilters?: number[]; eggButtonMappings?: string[]; + /** + * Every shipped Razer control's current state, keyed by control name — the + * four cross-assignable `RazerButtonControl`s and the three two-state + * `RazerToggleControl`s share one dict, since each family's renderer + * iterates its own fixed control list and the two lists share no control + * names. Undefined on a model that has not been confirmed to support the + * class `0x02` write at all. A control whose reply the driver cannot decode + * (a keyboard shortcut, Hypershift data, an index nothing here knows) is + * omitted rather than guessed at. + */ + razerButtonMappings?: Record; performanceMode?: boolean | null; hyperMode?: boolean | null; sensorMode?: "Eco" | "High" | "Ultra" | null; diff --git a/src/drivers/razer/hid.ts b/src/drivers/razer/hid.ts index 24fff60..e7c3882 100644 --- a/src/drivers/razer/hid.ts +++ b/src/drivers/razer/hid.ts @@ -3,11 +3,15 @@ import { VENDOR_ID } from "../vendors.ts"; import { RATES_1K, RATES_8K, RAZER_PRODUCTS, type RazerProduct } from "@openmouse/protocol/razer-devices"; import { openRazerDevice } from "./hid-open.ts"; import { + RAZER_BUTTON_CONTROLS, + RAZER_BUTTON_CONTROL_LABEL, RAZER_LANDING_MAX, RAZER_LANDING_MIN, RAZER_LIFT_OFF_MAX, RAZER_LIFT_OFF_MIN, RAZER_READ, + RAZER_TOGGLE_CONTROLS, + RAZER_TOGGLE_CONTROL_INFO, RAZER_REPORT_ID, RAZER_STORAGE, RAZER_STATUS, @@ -27,6 +31,12 @@ import { decodeSleepTimeout, encodeRazerRequest, isRazerGetter, + razerDecodeButtonMapping, + razerDecodeToggleControl, + razerReadButtonMappingCommand, + razerReadToggleControlCommand, + razerSetButtonMappingCommand, + razerSetToggleControlCommand, razerReadDpiCommand, razerSetDpiCommand, razerSetExtendedPollingCommand, @@ -37,8 +47,11 @@ import { razerEnableAsymmetricLiftOffCommand, razerSetLowPowerThresholdCommand, razerSetSleepTimeoutCommand, + type RazerButtonControl, + type RazerButtonMapping, type RazerCommand, type RazerLiftOff, + type RazerToggleControl, type RazerTrackingDistance, } from "@openmouse/protocol/razer"; @@ -106,6 +119,15 @@ export class RazerHidClient { // so nothing after the first read needs to ask the mouse again. private asymmetric: boolean | null = null; private asymmetricKnown = false; + + // Same reasoning as asymmetric/asymmetricKnown: read once per connection + // rather than on every background refresh. Button-mapping writes use their + // own transaction id (RAZER_BUTTON_TRANSACTION_ID) and are confirmed by an + // immediate read-back in setButtonMapping — polling this command class on a + // timer would put an unrelated read between a write and its own verify-read + // for no benefit, since nothing changes these but setButtonMapping itself. + private buttonMappings: Record | null = null; + private buttonMappingsKnown = false; /** Filled for dock passthrough after the first polling probe. */ private discoveredPollingRates: readonly number[] | null = null; /** Which polling command the paired mouse answers; null until probed. */ @@ -158,6 +180,7 @@ export class RazerHidClient { async close(): Promise { this.staticReads.clear(); this.asymmetricKnown = false; + this.buttonMappingsKnown = false; this.discoveredPollingRates = null; this.discoveredHighRatePolling = null; if (this.device.opened) await this.device.close(); @@ -243,6 +266,7 @@ export class RazerHidClient { const lowPower = hasBattery ? await this.request(RAZER_READ.lowPowerThreshold).catch(() => null) : null; const dpi = decodeDpi(await this.request(razerReadDpiCommand(this.dpiStorageByte()))); const pollingRateHz = await this.readPollingRateHz(); + const buttonMappings = await this.currentButtonMappings(); // 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" — @@ -267,9 +291,9 @@ export class RazerHidClient { // shares with sleep is opened below, so it would otherwise appear as a // permanent "signal is unavailable" placeholder. hideSignalCard: true, - // Auto sleep is the only card this driver puts in that section, so the - // section opens only when the mouse actually answered the sleep read. - showAdvancedSection: sleep !== null, + // Auto sleep and button mappings are what this driver puts in that + // section, so it opens only when at least one of them answered. + showAdvancedSection: sleep !== null || buttonMappings !== null, forceShowBattery: battery ? true : undefined, defaultDisplayName: this.profile()?.model, }, @@ -299,10 +323,93 @@ export class RazerHidClient { landingRange: { min: RAZER_LANDING_MIN, max: RAZER_LANDING_MAX }, } : null, + razerButtonMappings: buttonMappings ?? undefined, firmware: [`Mouse ${decodeFirmwareVersion(firmware)}`], }; } + /** + * Every shipped control's current state, keyed by control name — the four + * cross-assignable `RazerButtonControl`s and the three two-state + * `RazerToggleControl`s share one dict, since the view layer reads each + * family by iterating its own fixed control list (`RAZER_BUTTON_CONTROLS` / + * `RAZER_TOGGLE_CONTROLS`), and the two lists share no control names. Null + * on a model that has not been confirmed to support this write at all. A + * control whose reply this driver cannot decode is omitted rather than + * guessed at. + */ + async readButtonMappings(): Promise | null> { + if (this.profile()?.buttonMapping !== true) return null; + const mappings: Record = {}; + await Promise.all([ + ...RAZER_BUTTON_CONTROLS.map(async (control) => { + const reply = await this.request(razerReadButtonMappingCommand(control)).catch(() => null); + const mapping = reply ? razerDecodeButtonMapping(reply) : null; + if (mapping) mappings[control] = mapping; + }), + ...RAZER_TOGGLE_CONTROLS.map(async (control) => { + const reply = await this.request(razerReadToggleControlCommand(control)).catch(() => null); + const state = reply ? razerDecodeToggleControl(control, reply) : null; + if (state) mappings[control] = state; + }), + ]); + // Every control failing means the transport does not answer class 0x02 at + // all — the cable (0x00c0) has never been tested against it, only the + // receiver. Collapse that to null so the card is hidden outright rather + // than rendered empty, the same way readLiftOff degrades. + return Object.keys(mappings).length > 0 ? mappings : null; + } + + /** Probes once, then trusts what setButtonMapping leaves behind. */ + private async currentButtonMappings(): Promise | null> { + if (!this.buttonMappingsKnown) { + this.buttonMappings = await this.readButtonMappings(); + this.buttonMappingsKnown = true; + } + return this.buttonMappings; + } + + /** + * Sets one control's mapping and confirms it by reading the same control + * back — not a formality here: this write silently no-ops under the driver's + * default transaction id, so a status-only check would have shipped a + * control that looked correct and did nothing. See + * `RAZER_BUTTON_TRANSACTION_ID`. + */ + async setButtonMapping(control: RazerButtonControl, mapping: RazerButtonMapping): Promise { + await this.request(razerSetButtonMappingCommand(control, mapping)); + const reply = await this.request(razerReadButtonMappingCommand(control)); + const confirmed = razerDecodeButtonMapping(reply); + if (confirmed !== mapping) { + throw new Error( + `The mouse kept ${confirmed ?? "an unrecognised mapping"} for ${RAZER_BUTTON_CONTROL_LABEL[control]} instead of ${mapping}.`, + ); + } + this.buttonMappings = { ...this.buttonMappings, [control]: confirmed }; + this.buttonMappingsKnown = true; + return confirmed; + } + + /** + * Sets a two-state control (Scroll Up/Down, the bottom sensitivity button) + * and confirms it the same way `setButtonMapping` does — read-back after + * write, not status alone. `label` must be the control's own enabled label + * or "Disabled"; `razerSetToggleControlCommand` throws otherwise. + */ + async setToggleControl(control: RazerToggleControl, label: string): Promise { + await this.request(razerSetToggleControlCommand(control, label)); + const reply = await this.request(razerReadToggleControlCommand(control)); + const confirmed = razerDecodeToggleControl(control, reply); + if (confirmed !== label) { + throw new Error( + `The mouse kept ${confirmed ?? "an unrecognised state"} for ${RAZER_TOGGLE_CONTROL_INFO[control].label} instead of ${label}.`, + ); + } + this.buttonMappings = { ...this.buttonMappings, [control]: confirmed }; + this.buttonMappingsKnown = true; + return confirmed; + } + async setDpi(dpi: number, dpiY: number = dpi): Promise { const ceiling = this.maxDpi(); for (const value of [dpi, dpiY]) { @@ -575,7 +682,13 @@ export class RazerHidClient { private async exchange(command: RazerCommand, transactionIdOverride?: number): Promise { await this.open(); - const transactionId = transactionIdOverride ?? this.profile()?.transactionId ?? RAZER_TRANSACTION_ID; + // A command may pin its own transaction id — the class `0x02` button write + // silently no-ops under the shared one. An explicit call-site override + // still wins over both. + const transactionId = transactionIdOverride + ?? command.transactionId + ?? this.profile()?.transactionId + ?? RAZER_TRANSACTION_ID; const request = encodeRazerRequest(command, transactionId); // A busy status means the mouse will answer this same request later, so the // reads keep going without a re-send. A corrupt reply means the exchange diff --git a/src/drivers/razer/protocol.test.ts b/src/drivers/razer/protocol.test.ts index c13c5b7..a45a44e 100644 --- a/src/drivers/razer/protocol.test.ts +++ b/src/drivers/razer/protocol.test.ts @@ -37,6 +37,19 @@ import { razerEnableAsymmetricLiftOffCommand, razerSetLowPowerThresholdCommand, razerSetSleepTimeoutCommand, + RAZER_BUTTON_CONTROLS, + RAZER_BUTTON_CONTROL_LABEL, + RAZER_BUTTON_MAPPINGS, + RAZER_BUTTON_TRANSACTION_ID, + RAZER_LOCKED_BUTTON_CONTROL, + RAZER_TOGGLE_CONTROLS, + RAZER_TOGGLE_CONTROL_INFO, + razerDecodeButtonMapping, + razerDecodeToggleControl, + razerReadButtonMappingCommand, + razerReadToggleControlCommand, + razerSetButtonMappingCommand, + razerSetToggleControlCommand, } from "@openmouse/protocol/razer"; /** @@ -520,3 +533,248 @@ test("serial text stops at the terminator", () => { assert.equal(decodeSerial(args), "PM0000H00000000"); }); + +/* + * Button-mapping fixtures are Viper V3 Pro (firmware 1.12) captures logged in + * BUTTONS-RUNBOOK.md, read with Synapse fully closed. Checksums are re-derived + * since only the byte content was recorded, not the checksummed packet. + */ +test("button mapping decodes a control's own default action", () => { + const args = decodeRazerResponse( + reply("02 1f 00 00 00 0a 02 8c 01 04 00 01 01 04 00 00 00 00"), + RAZER_READ.buttonMapping, + ); + + assert.equal(razerDecodeButtonMapping(args), "Mouse Button 4"); +}); + +test("button mapping decodes Disabled", () => { + const args = decodeRazerResponse( + reply("02 1f 00 00 00 0a 02 8c 01 04 00 00 00 00 00 00 00 00"), + RAZER_READ.buttonMapping, + ); + + assert.equal(razerDecodeButtonMapping(args), "Disabled"); +}); + +test("button mapping decodes a physically-confirmed cross-control remap", () => { + // Mouse Button 4's slot written with Right Click's index, then Left Click's + // index — pressing the physical button right-clicked, then left-clicked. + // Not just a changed read-back; see BUTTONS-RUNBOOK.md. + const asRightClick = decodeRazerResponse( + reply("02 1f 00 00 00 0a 02 8c 01 04 00 01 01 02 00 00 00 00"), + RAZER_READ.buttonMapping, + ); + const asLeftClick = decodeRazerResponse( + reply("02 1f 00 00 00 0a 02 8c 01 04 00 01 01 01 00 00 00 00"), + RAZER_READ.buttonMapping, + ); + + assert.equal(razerDecodeButtonMapping(asRightClick), "Right Click"); + assert.equal(razerDecodeButtonMapping(asLeftClick), "Left Click"); +}); + +test("button mapping decodes Left Click and Right Click's own defaults", () => { + const left = decodeRazerResponse( + reply("02 1f 00 00 00 0a 02 8c 01 01 01 01 01 01 00 00 00 00"), + RAZER_READ.buttonMapping, + ); + const right = decodeRazerResponse( + reply("02 1f 00 00 00 0a 02 8c 01 02 01 01 01 02 00 00 00 00"), + RAZER_READ.buttonMapping, + ); + + assert.equal(razerDecodeButtonMapping(left), "Left Click"); + assert.equal(razerDecodeButtonMapping(right), "Right Click"); +}); + +test("an unrecognised button-mapping type decodes to null, not a guess", () => { + // Type 0x02 has never been captured — this driver has no business claiming + // to know what it means. + const args = decodeRazerResponse( + reply("02 1f 00 00 00 0a 02 8c 01 04 00 02 01 05 00 00 00 00"), + RAZER_READ.buttonMapping, + ); + + assert.equal(razerDecodeButtonMapping(args), null); +}); + +test("a button-mapping write matches the packet this driver's own write was physically confirmed with", () => { + // Transcribed from writeAndVerify() output: Mouse Button 4 set to Right + // Click's index, status 0x02, read-back changed and physically confirmed + // by pressing the button. See BUTTONS-RUNBOOK.md. + const packet = encodeRazerRequest(razerSetButtonMappingCommand("mouse4", "Right Click"), RAZER_BUTTON_TRANSACTION_ID); + + assert.equal(packet[5], 0x0a); + assert.equal(packet[6], 0x02); + assert.equal(packet[7], 0x0c); + assert.deepEqual([...packet.slice(8, 18)], [0x01, 0x04, 0x00, 0x01, 0x01, 0x02, 0x00, 0x00, 0x00, 0x00]); + assert.equal(packet[88], razerChecksum(packet)); +}); + +test("a button-mapping write clears the high bit of the matching read", () => { + assert.equal(RAZER_WRITE.buttonMapping.commandClass, RAZER_READ.buttonMapping.commandClass); + assert.equal(RAZER_WRITE.buttonMapping.commandId, RAZER_READ.buttonMapping.commandId & 0x7f); +}); + +test("the button write carries its own transaction id, not the shared default", () => { + // Confirmed on hardware: RAZER_TRANSACTION_ID (0x1f) silently no-ops this + // write — status 0x02 (ok), read-back even changes — while any other id + // (0x10 and 0x02 both tried) persists it and was physically confirmed at + // the button. Every other write in this file uses the shared default + // without issue, so this is the one exception. + assert.equal(RAZER_WRITE.buttonMapping.transactionId, RAZER_BUTTON_TRANSACTION_ID); + assert.notEqual(RAZER_BUTTON_TRANSACTION_ID, RAZER_TRANSACTION_ID); +}); + +test("a button-mapping read selects the control by index", () => { + const mouse4 = encodeRazerRequest(razerReadButtonMappingCommand("mouse4")); + const mouse5 = encodeRazerRequest(razerReadButtonMappingCommand("mouse5")); + + assert.deepEqual([...mouse4.slice(8, 11)], [0x01, 0x04, 0x00]); + assert.deepEqual([...mouse5.slice(8, 11)], [0x01, 0x05, 0x00]); +}); + +test("Left Click is fixed on the Standard layer, matching Synapse's own restriction", () => { + assert.throws(() => razerSetButtonMappingCommand("leftClick", "Right Click"), RazerProtocolError); + assert.throws(() => razerSetButtonMappingCommand("leftClick", "Disabled"), RazerProtocolError); + assert.doesNotThrow(() => razerSetButtonMappingCommand("leftClick", "Left Click")); + // No such restriction on the other three — each can take any mapping, + // including disabling itself. + assert.doesNotThrow(() => razerSetButtonMappingCommand("rightClick", "Disabled")); + assert.doesNotThrow(() => razerSetButtonMappingCommand("mouse4", "Left Click")); +}); + +test("button-mapping writes round-trip through the decoder", () => { + for (const [control, mapping] of [ + ["leftClick", "Left Click"], + ["rightClick", "Right Click"], + ["mouse4", "Mouse Button 4"], + ["mouse4", "Right Click"], + ["mouse4", "Left Click"], + ["mouse5", "Disabled"], + ] as const) { + const request = encodeRazerRequest(razerSetButtonMappingCommand(control, mapping)); + assert.equal(razerDecodeButtonMapping(request.slice(8)), mapping); + } +}); + +/* + * Toggle-control fixtures (Scroll Up, Scroll Down, the bottom sensitivity + * button) are Viper V3 Pro captures logged in BUTTONS-RUNBOOK.md, same + * conventions as the button-mapping fixtures above. Scroll Click is + * deliberately not covered here yet — see the comment on RazerToggleControl. + */ +test("toggle control decodes its own default action", () => { + const scrollUp = decodeRazerResponse( + reply("02 1f 00 00 00 0a 02 8c 01 09 00 01 01 09 00 00 00 00"), + RAZER_READ.buttonMapping, + ); + const scrollDown = decodeRazerResponse( + reply("02 1f 00 00 00 0a 02 8c 01 0a 00 01 01 0a 00 00 00 00"), + RAZER_READ.buttonMapping, + ); + const sensitivityButton = decodeRazerResponse( + reply("02 1f 00 00 00 0a 02 8c 01 60 00 06 01 06 00 00 00 00"), + RAZER_READ.buttonMapping, + ); + + assert.equal(razerDecodeToggleControl("scrollUp", scrollUp), "Scroll Up"); + assert.equal(razerDecodeToggleControl("scrollDown", scrollDown), "Scroll Down"); + // The bottom button's default is actionType 0x06 — not the plain-button + // shape the other three use — captured verbatim rather than decoded from a + // shared formula, per the comment on RAZER_TOGGLE_CONTROL_INFO. + assert.equal(razerDecodeToggleControl("sensitivityButton", sensitivityButton), "Cycle Up Sensitivity Stages"); +}); + +test("toggle control decodes Disabled", () => { + for (const [control, index] of [["scrollUp", "09"], ["scrollDown", "0a"], ["sensitivityButton", "60"]] as const) { + const args = decodeRazerResponse( + reply(`02 1f 00 00 00 0a 02 8c 01 ${index} 00 00 00 00 00 00 00 00`), + RAZER_READ.buttonMapping, + ); + assert.equal(razerDecodeToggleControl(control, args), "Disabled"); + } +}); + +test("an unrecognised toggle-control encoding decodes to null, not a guess", () => { + // Type 0x02 has never been captured for any control in this file. + const args = decodeRazerResponse( + reply("02 1f 00 00 00 0a 02 8c 01 09 00 02 01 09 00 00 00 00"), + RAZER_READ.buttonMapping, + ); + + assert.equal(razerDecodeToggleControl("scrollUp", args), null); +}); + +test("a toggle control does not decode another control's default as its own", () => { + // Scroll Down's own default (type=1, len=1, value=0x0a) is structurally + // identical to a plain-button record — decoding it as Scroll Up must not + // succeed just because the shape matches; the value has to match too. + const scrollDownDefault = decodeRazerResponse( + reply("02 1f 00 00 00 0a 02 8c 01 0a 00 01 01 0a 00 00 00 00"), + RAZER_READ.buttonMapping, + ); + + assert.equal(razerDecodeToggleControl("scrollUp", scrollDownDefault), null); +}); + +test("a toggle-control write matches the packet physically confirmed with it", () => { + // Scroll Up: transcribed from writeAndVerify() output restoring it to its + // own default — before (disabled) -> after (default) was a real, clean + // transition driven by this exact write, and scrolling up physically + // worked afterward. See BUTTONS-RUNBOOK.md. + const scrollUp = encodeRazerRequest(razerSetToggleControlCommand("scrollUp", "Scroll Up"), RAZER_BUTTON_TRANSACTION_ID); + assert.deepEqual([...scrollUp.slice(8, 18)], [0x01, 0x09, 0x00, 0x01, 0x01, 0x09, 0x00, 0x00, 0x00, 0x00]); + assert.equal(scrollUp[88], razerChecksum(scrollUp)); + + // Sensitivity button: both directions of its disable/restore cycle were + // clean transitions driven by this driver's own write and both physically + // confirmed — the most completely verified of the three. + const disable = encodeRazerRequest(razerSetToggleControlCommand("sensitivityButton", "Disabled"), RAZER_BUTTON_TRANSACTION_ID); + assert.deepEqual([...disable.slice(8, 18)], [0x01, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + const restore = encodeRazerRequest(razerSetToggleControlCommand("sensitivityButton", "Cycle Up Sensitivity Stages"), RAZER_BUTTON_TRANSACTION_ID); + assert.deepEqual([...restore.slice(8, 18)], [0x01, 0x60, 0x00, 0x06, 0x01, 0x06, 0x00, 0x00, 0x00, 0x00]); +}); + +test("a toggle-control read selects the control by index", () => { + const scrollUp = encodeRazerRequest(razerReadToggleControlCommand("scrollUp")); + const scrollDown = encodeRazerRequest(razerReadToggleControlCommand("scrollDown")); + const sensitivityButton = encodeRazerRequest(razerReadToggleControlCommand("sensitivityButton")); + + assert.deepEqual([...scrollUp.slice(8, 11)], [0x01, 0x09, 0x00]); + assert.deepEqual([...scrollDown.slice(8, 11)], [0x01, 0x0a, 0x00]); + assert.deepEqual([...sensitivityButton.slice(8, 11)], [0x01, 0x60, 0x00]); +}); + +test("an invalid label for a toggle control throws instead of sending garbage", () => { + assert.throws(() => razerSetToggleControlCommand("scrollUp", "Right Click"), RazerProtocolError); + assert.throws(() => razerSetToggleControlCommand("scrollUp", "Scroll Down"), RazerProtocolError); + assert.doesNotThrow(() => razerSetToggleControlCommand("scrollUp", "Scroll Up")); + assert.doesNotThrow(() => razerSetToggleControlCommand("scrollUp", "Disabled")); +}); + +test("toggle-control writes round-trip through the decoder", () => { + for (const control of RAZER_TOGGLE_CONTROLS) { + const info = RAZER_TOGGLE_CONTROL_INFO[control]; + for (const label of [info.enabledLabel, "Disabled"]) { + const request = encodeRazerRequest(razerSetToggleControlCommand(control, label)); + assert.equal(razerDecodeToggleControl(control, request.slice(8)), label); + } + } +}); + +test("button controls and toggle controls share no control names or option labels", () => { + // The two families write into the same status dict, keyed by control name + // (see hid.ts's readButtonMappings) — a shared key would let one family's + // renderer read the other's state. A shared option label ("Disabled" + // aside, which means the same thing everywhere) would risk the same + // confusion one level up, in the rendered