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