From 1db47cf223b1481f4ff7ffcf29fa5cbe5b952745 Mon Sep 17 00:00:00 2001 From: ydw1904 Date: Thu, 1 Oct 2026 11:37:11 +0800 Subject: [PATCH 1/2] razer: mark the Diamondback Chroma (0x004c) verified on hardware Firmware "Mouse 1.0" over the cable, through OpenMouse Bridge on Windows, with RazerHidClient unmodified: - Identity, firmware, DPI (1800) and polling (500 Hz) read back on the existing 0xff transaction id. - 800 DPI and 1000 Hz each wrote, read back and were restored. - Polling was read back but not measured (the sampler saw dropouts), and the DPI stage read gave nothing usable, so no stage editor is offered. The app's hardware test export is in captures/razer-diamondback-chroma/. Co-Authored-By: Claude Opus 5.5 --- captures/razer-diamondback-chroma/README.md | 17 +++ .../hardware-test-2026-09-30.json | 132 ++++++++++++++++++ docs/razer-testing.md | 1 + src/drivers/razer/devices.test.ts | 5 +- src/razer/devices.ts | 7 +- 5 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 captures/razer-diamondback-chroma/README.md create mode 100644 captures/razer-diamondback-chroma/hardware-test-2026-09-30.json diff --git a/captures/razer-diamondback-chroma/README.md b/captures/razer-diamondback-chroma/README.md new file mode 100644 index 0000000..1ff181f --- /dev/null +++ b/captures/razer-diamondback-chroma/README.md @@ -0,0 +1,17 @@ +# Razer Diamondback Chroma (`1532:004c`) hardware report, 2026-09-30 + +Reporter's own mouse, firmware "Mouse 1.0", wired, Windows. Connected through +OpenMouse Bridge with an unmodified `RazerHidClient`; the Bridge listed seven +HID paths for the device and opened all of them. + +- `hardware-test-2026-09-30.json`: the app's hardware test export. Identity, + firmware, DPI (1800) and polling (500 Hz) read back; 800 DPI and 1000 Hz each + wrote, read back and were restored. Verdict: pass. + +What it does not settle: + +- Polling was read back but not measured: the sampler averaged 305 Hz against + the reported 500 Hz and flagged dropouts, so it skipped rather than failed. +- The DPI stage read (`0x04`/`0x86`) gave nothing usable, so no stage editor + is offered on this model. +- No raw feature reports were captured in this session. diff --git a/captures/razer-diamondback-chroma/hardware-test-2026-09-30.json b/captures/razer-diamondback-chroma/hardware-test-2026-09-30.json new file mode 100644 index 0000000..e010e9d --- /dev/null +++ b/captures/razer-diamondback-chroma/hardware-test-2026-09-30.json @@ -0,0 +1,132 @@ +{ + "device": { + "present": true, + "brand": "Razer", + "name": "Razer Diamondback Chroma", + "vendorId": 5426, + "productId": 76, + "productName": "Razer Diamondback Chroma", + "transport": "bridge", + "connectionType": "Wired", + "pollingRateHz": 500, + "supportedPollingRates": [ + 125, + 500, + 1000 + ], + "dpi": 1800, + "dpiStages": null, + "activeDpiStage": null, + "batteryPercent": null, + "batteryState": "Unknown", + "firmware": [ + "Mouse 1.0" + ], + "liftOffDistance": null, + "driverFamily": "razer", + "deviceMode": null, + "collectionsSummary": "interfaces 0x0001:0x0002, 0x000C:0x0001, 0x0001:0x0006, 0x0001:0x0080, 0x0001:0x0000", + "signalStrength": null, + "receiverOnline": null, + "receiverRfId": null, + "pairingInProgress": false + }, + "results": [ + { + "key": "connection", + "label": "Device connected", + "status": "pass", + "detail": "Connected through OpenMouse Bridge" + }, + { + "key": "interface", + "label": "Control interface", + "status": "pass", + "detail": "interfaces 0x0001:0x0002, 0x000C:0x0001, 0x0001:0x0006, 0x0001:0x0080, 0x0001:0x0000" + }, + { + "key": "identity", + "label": "Device identity", + "status": "pass", + "detail": "Razer Razer Diamondback Chroma" + }, + { + "key": "driver", + "label": "Driver identification", + "status": "pass", + "detail": "Razer razer" + }, + { + "key": "dpi", + "label": "DPI read-back", + "status": "pass", + "detail": "1,800 DPI" + }, + { + "key": "pollingRead", + "label": "Polling rate read-back", + "status": "pass", + "detail": "500 Hz" + }, + { + "key": "battery", + "label": "Battery read-back", + "status": "skip", + "detail": "Not reported by this device." + }, + { + "key": "firmware", + "label": "Firmware read-back", + "status": "pass", + "detail": "Mouse 1.0" + }, + { + "key": "liftOff", + "label": "Lift-off read-back", + "status": "skip", + "detail": "Not reported by this device." + }, + { + "key": "link", + "label": "Wireless link (receiver)", + "status": "skip", + "detail": null + }, + { + "key": "flashRead", + "label": "Flash / EEPROM read-back", + "status": "pass", + "detail": "decoded fields are in range (dpi, polling)" + }, + { + "key": "brandWirelessBattery", + "label": "Razer wireless battery", + "status": "skip", + "detail": null + }, + { + "key": "sampling", + "label": "Polling rate sampling", + "status": "skip", + "detail": "avg 305 Hz vs reported 500 Hz — dropout or rate mismatch detected, so the sample is not counted as a failure." + }, + { + "key": "flashWrite", + "label": "Flash write round-trip", + "status": "pass", + "detail": "wrote 800 DPI → read back → restored 1,800 DPI · wrote 1,000 Hz → read back → restored 500 Hz · Lift-off skipped" + } + ], + "verdict": "pass", + "durationMs": 17802.09999999404, + "runAt": "2026-09-30T21:58:33.335Z", + "build": "BETA · v2.0.58d", + "supportedPage": { + "listed": true, + "status": "likely", + "label": "Test Needed", + "matchedBy": "pid", + "pageModel": "Diamondback Chroma", + "detail": "listed as Test Needed on the supported-devices page — this passing verification supports moving it to Supported." + } +} \ No newline at end of file diff --git a/docs/razer-testing.md b/docs/razer-testing.md index d65c23d..3b32c56 100644 --- a/docs/razer-testing.md +++ b/docs/razer-testing.md @@ -25,6 +25,7 @@ Identifiers verified on hardware: - `1532:00a3` — Cobra, wired (separate driver) - `1532:00b6` — DeathAdder V3 Pro, wired (firmware 2.1) - `1532:00b7` — DeathAdder V3 Pro, stock HyperSpeed receiver (firmware 2.1) +- `1532:004c`: Diamondback Chroma, wired (firmware 1.0, through OpenMouse Bridge) Mouse Dock Pro uses the same 90-byte protocol as the paired mouse. It has no fixed polling list: if the paired mouse answers the extended polling command it diff --git a/src/drivers/razer/devices.test.ts b/src/drivers/razer/devices.test.ts index 02f2eeb..746d3e2 100644 --- a/src/drivers/razer/devices.test.ts +++ b/src/drivers/razer/devices.test.ts @@ -65,6 +65,9 @@ const VERIFIED_SINCE: ReadonlyArray<[number, VerifiedProfile]> = [ // Same mouse over the cable, same session: 13/13 writes persisted, 126 Hz // peak measured at 125, battery reports "Charging". [0x00b6, { model: "DeathAdder V3 Pro (Wired)", wireless: false, maxDpi: 30000, transactionId: RAZER_TRANSACTION_ID, rates: RATES_1K, highRate: false }], + // Diamondback Chroma (firmware 1.0) over the cable through OpenMouse Bridge: + // answers on `0xff`, and DPI and legacy polling each round-tripped. + [0x004c, { model: "Diamondback Chroma", wireless: false, maxDpi: 16000, transactionId: RAZER_TRANSACTION_ID_FF, rates: RATES_1K, highRate: false }], ]; const VERIFIED = [...REFACTOR_BASELINE, ...VERIFIED_SINCE]; @@ -77,7 +80,7 @@ const VERIFIED = [...REFACTOR_BASELINE, ...VERIFIED_SINCE]; * hardware-tested", so it is pinned like the others without claiming to be * verified. */ -const HARDWARE_VERIFIED: readonly number[] = [0x00a4, 0x00a5, 0x00a6, 0x00a7, 0x00a8, 0x00c0, 0x00c1, 0x00b8, 0x00b7, 0x00b6]; +const HARDWARE_VERIFIED: readonly number[] = [0x00a4, 0x00a5, 0x00a6, 0x00a7, 0x00a8, 0x00c0, 0x00c1, 0x00b8, 0x00b7, 0x00b6, 0x004c]; 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 diff --git a/src/razer/devices.ts b/src/razer/devices.ts index 8b7fe3c..56b620a 100644 --- a/src/razer/devices.ts +++ b/src/razer/devices.ts @@ -441,7 +441,12 @@ const PRODUCT_DEFINITIONS: ReadonlyArray<[number, Omit Date: Thu, 1 Oct 2026 11:52:16 +0800 Subject: [PATCH 2/2] feat(razer): standard-matrix lighting for the Diamondback Chroma OpenRazer drives the Diamondback Chroma's LEDs through the older Chroma command family (class 0x03), not the extended matrix the Cobra and Viper Mini use. Adds that family to the generic RazerHidClient behind a new per-product standardMatrixLighting flag, set only on 0x004c: - codec: razerSetStandardEffectCommand (off, spectrum, wave, static, reactive, breathing random/single/dual on 0x03/0x0a) and the backlight brightness pair (0x03/0x03, 0x03/0x83, backlight led 0x05, 0-255 level as whole percent), byte for byte from razerchromacommon.c - driver: readStatus reports one "Mouse" zone with the brightness read from the mouse; setLighting writes the effect, then the brightness only when it changed, confirmed by read-back. The effect has no read, so it is cached and marked write-only, as on the Cobra and Viper Mini. Every command uses the product's 0xff transaction id, breathing included, although OpenRazer lists this model's breathing on 0x3f: that entry sits in the block where the Cobra's breathing turned out to answer on its usual id. Untested on hardware; the checklist is in docs/razer-testing.md. Co-Authored-By: Claude Opus 5.5 --- docs/razer-testing.md | 51 +++++++++++- src/drivers/razer/devices.test.ts | 8 ++ src/drivers/razer/hid.test.ts | 107 +++++++++++++++++++++++++ src/drivers/razer/hid.ts | 116 ++++++++++++++++++++++++++- src/drivers/razer/protocol.test.ts | 60 ++++++++++++++ src/razer/codec.ts | 122 +++++++++++++++++++++++++++++ src/razer/devices.ts | 15 +++- 7 files changed, 473 insertions(+), 6 deletions(-) diff --git a/docs/razer-testing.md b/docs/razer-testing.md index 3b32c56..b5af928 100644 --- a/docs/razer-testing.md +++ b/docs/razer-testing.md @@ -376,9 +376,11 @@ What is deliberately **not** attempted on an untested model: `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. The generic driver implements none of - them for any model — the only lighting controls anywhere in this project are - the dedicated Cobra and Viper Mini drivers. +- Lighting, button mapping and macros. Macros are not implemented for any + Razer model. Lighting and button mapping are per-product allowlists + (`standardMatrixLighting`, `buttonMapping`) that only list models someone + has connected; beyond them, the only lighting controls are the dedicated + Cobra and Viper Mini drivers. To promote a model to verified: @@ -869,3 +871,46 @@ other effects also use `0x3f`, but every other Cobra effect answers on `0x1f` and breathing does too, so the single `0x1f` choice holds. Brightness is not implemented: this driver covers effects and colour only. + +## Diamondback Chroma (`1532:004c`) + +Driven by the generic `RazerHidClient` on transaction id `0xff`. Verified on +firmware 1.0 over the cable, through OpenMouse Bridge on Windows: identity, +DPI and legacy polling read back, and an 800 DPI and a 1000 Hz write each +round-tripped and were restored. The report is in +`captures/razer-diamondback-chroma/`. + +It is the one model this driver lights, through openrazer's older +standard-matrix family, and **the lighting has not been tried on hardware +yet**: + +| Write | Class / ID | Notes | +| --- | --- | --- | +| Off / Spectrum / Wave / Static / Reactive / Breathing | `0x03` / `0x0a` | effect id first, no storage byte or led; one effect drives every LED | +| Backlight brightness | `0x03` / `0x03` | storage byte, backlight led (`0x05`), level on a 0-255 scale | + +| Read | Class / ID | Notes | +| --- | --- | --- | +| Backlight brightness | `0x03` / `0x83` | level in the third byte | + +The effect write has no read-back, so the panel shows the last effect it wrote +and marks the card write-only. Brightness is read on every refresh and +confirmed after each write. Wave always runs in direction `0x01`. + +openrazer sends all of these on `0xff` except breathing, which it lists on +`0x3f` in the same block where it lists the Cobra. The Cobra's breathing +answered on its usual id, so breathing goes out on `0xff` here too. **If +breathing alone fails, that is the first thing to change.** + +To test the lighting, with Synapse quit: + +1. Open the Lighting tab and note the brightness it reads. +2. Pick each effect and watch the mouse: Off, Spectrum, Wave, Static, Reactive + (click to trigger it), and the three breathing modes. +3. Change the Static colour, the Reactive speed and both Breathing dual + colours. +4. Change the brightness, reload, and confirm the new level reads back. +5. Unplug the mouse and plug it back in, and note whether the effect and the + brightness survived. Nothing reads the effect back, so this is the only way + to learn whether it is stored. +6. Report any effect that errors or leaves the LEDs unchanged. diff --git a/src/drivers/razer/devices.test.ts b/src/drivers/razer/devices.test.ts index 746d3e2..dd18fde 100644 --- a/src/drivers/razer/devices.test.ts +++ b/src/drivers/razer/devices.test.ts @@ -272,6 +272,14 @@ test("button mapping is only offered on connections where class 0x02 answered", } }); +test("standard-matrix lighting is only offered on a model someone is testing it on", () => { + // Effect writes have no read-back, so a mouse that ignores them looks the + // same as one that obeys. Every entry needs its owner to watch the LEDs. + const offered = RAZER_PRODUCT_IDS.filter((id) => RAZER_PRODUCTS.get(id)?.standardMatrixLighting === true); + assert.deepEqual(offered, [0x004c]); + for (const id of offered) 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. diff --git a/src/drivers/razer/hid.test.ts b/src/drivers/razer/hid.test.ts index cbc2629..7adcb83 100644 --- a/src/drivers/razer/hid.test.ts +++ b/src/drivers/razer/hid.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import type { MouseLighting } from "../mouse-types.ts"; // `hid.ts` schedules its inter-exchange delay through `window`, which node does // not provide. The global carries the same `setTimeout`. @@ -914,3 +915,109 @@ test("a Chrome-refused feature-report write surfaces troubleshooting, not the ba return true; }); }); + +/** + * A Chroma-era wired mouse: firmware, DPI and legacy polling, plus the + * standard-matrix effect write and the backlight brightness pair (`0x03`). + * Brightness is held on the mouse's 0-255 scale; `ignoreWrites` acknowledges + * a brightness write and keeps the old level. + */ +function fakeChromaMouse(options: { productId?: number; brightness?: number; ignoreWrites?: boolean } = {}) { + const sent: Uint8Array[] = []; + let level = options.brightness ?? 0xff; + let pending = new Uint8Array(RAZER_PACKET_LENGTH); + const device = { + vendorId: 0x1532, + productId: options.productId ?? 0x004c, + productName: "Razer Diamondback Chroma", + 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 answer = (dataSize: number, args: number[]) => + replyPacket(commandClass, commandId, dataSize, args, RAZER_STATUS.ok); + if (commandClass === 0x00 && commandId === 0x81) pending = answer(0x02, [1, 0]); + else if (commandClass === 0x04 && commandId === 0x85) pending = answer(0x07, [0x01, 0x07, 0x08, 0x07, 0x08]); + else if (commandClass === 0x00 && commandId === 0x85) pending = answer(0x01, [2]); + else if (commandClass === 0x03 && commandId === 0x0a) pending = answer(data[5], [...data.slice(8, 8 + data[5])]); + else if (commandClass === 0x03 && commandId === 0x03) { + if (!options.ignoreWrites) level = data[10]; + pending = answer(0x03, [data[8], data[9], data[10]]); + } else if (commandClass === 0x03 && commandId === 0x83) pending = answer(0x03, [data[8], data[9], level]); + 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("the Diamondback Chroma offers its lighting with the brightness it reports", async () => { + const { client } = fakeChromaMouse({ brightness: 0x80 }); + + const { lighting } = await client.readStatus(); + + assert.equal(lighting?.zone, "Mouse"); + assert.ok(lighting?.modes.includes("Wave")); + assert.equal(lighting?.brightness, 50); + // The effect has no read, so nothing claims to know the running one. + assert.equal(lighting?.mode, null); + assert.equal(lighting?.writeOnly, true); +}); + +test("a Razer model off the lighting allowlist is never sent a lighting command", async () => { + // Arrange: 0x0043 is the DeathAdder Chroma, same generation, not listed. + const { client, sent } = fakeChromaMouse({ productId: 0x0043 }); + + const status = await client.readStatus(); + + assert.equal(status.lighting, undefined); + assert.equal(sent.some((packet) => packet[6] === 0x03), false); + await assert.rejects( + client.setLighting({ zone: "Mouse", mode: "Static", color: "#ffffff" } as MouseLighting), + /does not support changing the lighting/, + ); + assert.equal(sent.some((packet) => packet[6] === 0x03), false); +}); + +test("an effect change sends one standard-matrix write and leaves an unchanged brightness alone", async () => { + const { client, sent } = fakeChromaMouse(); + const { lighting } = await client.readStatus(); + sent.length = 0; + + await client.setLighting({ ...lighting!, mode: "Static", color: "#ff8000" }); + + // On the product's own 0xff id, not the driver default. + assert.deepEqual(sent.map((packet) => [packet[1], packet[6], packet[7]]), [[0xff, 0x03, 0x0a]]); + assert.deepEqual([...sliceArgs(sent[0], 0, 4)], [0x06, 0xff, 0x80, 0x00]); + // Write-only: the next status read reports the cached effect. + assert.equal((await client.readStatus()).lighting?.mode, "Static"); +}); + +test("a brightness change is written to the backlight and confirmed by reading it back", async () => { + const { client, sent } = fakeChromaMouse(); + const { lighting } = await client.readStatus(); + sent.length = 0; + + const result = await client.setLighting({ ...lighting!, mode: "Spectrum", brightness: 50 }); + + assert.deepEqual(sent.map((packet) => [packet[6], packet[7]]), [[0x03, 0x0a], [0x03, 0x03], [0x03, 0x83]]); + assert.deepEqual([...sliceArgs(sent[1], 0, 3)], [0x01, 0x05, 0x80]); + assert.equal(result.brightness, 50); +}); + +test("a brightness the mouse does not keep is reported, and the effect it did take stays cached", async () => { + const { client } = fakeChromaMouse({ ignoreWrites: true }); + const { lighting } = await client.readStatus(); + + await assert.rejects( + client.setLighting({ ...lighting!, mode: "Spectrum", brightness: 50 }), + /kept 100% brightness instead of 50%/, + ); + + const after = (await client.readStatus()).lighting; + assert.equal(after?.mode, "Spectrum"); + assert.equal(after?.brightness, 100); +}); diff --git a/src/drivers/razer/hid.ts b/src/drivers/razer/hid.ts index b8552dd..e60dd49 100644 --- a/src/drivers/razer/hid.ts +++ b/src/drivers/razer/hid.ts @@ -1,8 +1,9 @@ -import type { MouseStatus } from "../mouse-types.ts"; +import type { MouseLighting, MouseLightingMode, MouseStatus } from "../mouse-types.ts"; 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_BACKLIGHT_BRIGHTNESS_READ, RAZER_BUTTON_CONTROLS, RAZER_BUTTON_CONTROL_LABEL, RAZER_LANDING_MAX, @@ -18,6 +19,7 @@ import { RAZER_TRACKING_DISTANCES, RAZER_TRANSACTION_ID, RazerProtocolError, + decodeBacklightBrightness, decodeBatteryPercent, decodeCharging, decodeDpi, @@ -52,11 +54,15 @@ import { razerEnableSensorCalibrationCommand, razerSetLowPowerThresholdCommand, razerSetSleepTimeoutCommand, + razerSetBacklightBrightnessCommand, + razerSetStandardEffectCommand, type RazerButtonControl, type RazerButtonMapping, type RazerCommand, type RazerDpi, type RazerLiftOff, + type RazerReactiveSpeed, + type RazerStandardEffect, type RazerToggleControl, type RazerTrackingDistance, } from "@openmouse/protocol/razer"; @@ -102,6 +108,26 @@ const LOW_POWER_OPTIONS: readonly number[] = Array.from( // rather than the command. const LOW_POWER_MAX_POLLING_HZ = 1000; +// The standard-matrix effect drives every LED on the mouse at once, so the +// panel gets one zone, named the way the other whole-mouse drivers name it. +const STANDARD_LIGHTING_ZONE = "Mouse"; +const STANDARD_LIGHTING_EFFECTS = { + Off: "off", + Spectrum: "spectrum", + Wave: "wave", + Static: "static", + Reactive: "reactive", + "Breathing random": "breathing-random", + "Breathing single": "breathing-single", + "Breathing dual": "breathing-dual", +} as const satisfies Partial>; +const STANDARD_LIGHTING_MODES = Object.keys(STANDARD_LIGHTING_EFFECTS) as (keyof typeof STANDARD_LIGHTING_EFFECTS)[]; +const STANDARD_COLOR_MODES: readonly MouseLightingMode[] = ["Static", "Reactive", "Breathing single", "Breathing dual"]; +const STANDARD_DUAL_COLOR_MODES: readonly MouseLightingMode[] = ["Breathing dual"]; +const STANDARD_REACTIVE_MODES: readonly MouseLightingMode[] = ["Reactive"]; +const STANDARD_REACTIVE_SPEEDS: readonly RazerReactiveSpeed[] = [1, 2, 3, 4]; +const STANDARD_BRIGHTNESS_LEVELS: readonly number[] = [25, 50, 75, 100]; + /** * Razer exposes its control channel on the interface that declares a Generic * Desktop Mouse collection. WebHID groups each top-level collection into its @@ -149,6 +175,12 @@ export class RazerHidClient { private discoveredPollingRates: readonly number[] | null = null; /** Which polling command the paired mouse answers; null until probed. */ private discoveredHighRatePolling: boolean | null = null; + /** + * The standard-matrix effect has no read, so this holds the last one + * written, the way the Cobra and Viper Mini drivers do. Brightness does + * read, and every status read refreshes it. + */ + private lighting: MouseLighting | null = null; readonly device: HIDDevice; @@ -312,6 +344,7 @@ export class RazerHidClient { // 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; + const lighting = this.profile()?.standardMatrixLighting === true ? await this.readLighting() : null; return { brand: "Razer", name: this.displayName(), @@ -378,6 +411,7 @@ export class RazerHidClient { } : null, razerButtonMappings: buttonMappings ?? undefined, + lighting: lighting ?? undefined, // Writable through `setDpiStageValue`/`setActiveDpiStage`, which rewrite // the whole table and confirm by reading it back. ...(stages ? { dpiStages: stages.stages, activeDpiStage: stages.active } : {}), @@ -467,6 +501,86 @@ export class RazerHidClient { return confirmed; } + /** + * Writes the standard-matrix effect, then the backlight brightness when it + * changed. The effect cannot be read back, so what was written is cached and + * returned; brightness can, and is confirmed like every other setter. + * + * Every command goes out on the product's own transaction id (`0xff` on the + * Diamondback Chroma), breathing included. OpenRazer sends this model's + * breathing on `0x3f`, but from a block that also lists the Cobra, whose + * breathing turned out to answer on the same id as its other effects. If + * breathing alone fails on hardware, that is the assumption to revisit. + */ + async setLighting(lighting: MouseLighting): Promise { + if (this.profile()?.standardMatrixLighting !== true) { + throw new Error("This mouse does not support changing the lighting yet."); + } + if (lighting.zone !== STANDARD_LIGHTING_ZONE) { + throw new Error(`This mouse has no "${lighting.zone}" lighting zone.`); + } + const effect = lighting.mode ? STANDARD_LIGHTING_EFFECTS[lighting.mode as keyof typeof STANDARD_LIGHTING_EFFECTS] : undefined; + if (!effect) throw new Error("Pick an effect first."); + const speed = STANDARD_REACTIVE_SPEEDS.find((candidate) => candidate === lighting.speed); + if (lighting.speed !== null && speed === undefined) { + throw new Error(`Unknown reactive speed ${lighting.speed}.`); + } + const previous = this.lightingFromCache(); + await this.request(razerSetStandardEffectCommand(effect, { + color: lighting.color ?? undefined, + color2: lighting.color2 ?? undefined, + speed, + })); + // Cached before the brightness step so a refused brightness cannot leave + // the panel showing an effect the mouse is no longer running. + this.lighting = { ...lighting, brightness: previous.brightness }; + if (lighting.brightness != null && lighting.brightness !== previous.brightness) { + await this.request(razerSetBacklightBrightnessCommand(lighting.brightness)); + const confirmed = decodeBacklightBrightness(await this.request(RAZER_BACKLIGHT_BRIGHTNESS_READ)); + if (confirmed !== lighting.brightness) { + throw new Error(`The mouse kept ${confirmed}% brightness instead of ${lighting.brightness}%.`); + } + this.lighting = { ...this.lighting, brightness: confirmed }; + } + return this.lighting; + } + + /** + * The cached effect plus the brightness the mouse reports. A brightness read + * that fails hides only the brightness row; the effects are still offered. + */ + private async readLighting(): Promise { + const reply = await this.request(RAZER_BACKLIGHT_BRIGHTNESS_READ).catch(() => null); + const brightness = reply ? decodeBacklightBrightness(reply) : null; + this.lighting = { + ...this.lightingFromCache(), + brightness, + brightnessLevels: brightness === null ? [] : STANDARD_BRIGHTNESS_LEVELS, + }; + return this.lighting; + } + + private lightingFromCache(): MouseLighting { + this.lighting ??= { + zone: STANDARD_LIGHTING_ZONE, + modes: STANDARD_LIGHTING_MODES, + mode: null, + // The colours are the lighting card's own picker defaults and the speed + // starts mid-scale, so the first effect picked writes what the card + // shows instead of failing for want of a value. + color: "#00ff00", + color2: "#ff0000", + colorModes: STANDARD_COLOR_MODES, + dualColorModes: STANDARD_DUAL_COLOR_MODES, + reactiveModes: STANDARD_REACTIVE_MODES, + speeds: STANDARD_REACTIVE_SPEEDS, + speed: STANDARD_REACTIVE_SPEEDS[1], + brightness: null, + writeOnly: true, + }; + return this.lighting; + } + async setDpi(dpi: number, dpiY: number = dpi): Promise { const ceiling = this.maxDpi(); for (const value of [dpi, dpiY]) { diff --git a/src/drivers/razer/protocol.test.ts b/src/drivers/razer/protocol.test.ts index 0ca9bb7..c82e83c 100644 --- a/src/drivers/razer/protocol.test.ts +++ b/src/drivers/razer/protocol.test.ts @@ -54,6 +54,11 @@ import { razerReadToggleControlCommand, razerSetButtonMappingCommand, razerSetToggleControlCommand, + RAZER_BACKLIGHT_BRIGHTNESS_READ, + decodeBacklightBrightness, + razerSetBacklightBrightnessCommand, + razerSetStandardEffectCommand, + type RazerStandardEffect, } from "@openmouse/protocol/razer"; /** @@ -849,3 +854,58 @@ test("button controls and toggle controls share no control names or option label const toggleEnabledLabels = RAZER_TOGGLE_CONTROLS.map((control) => RAZER_TOGGLE_CONTROL_INFO[control].enabledLabel); assert.equal(buttonOnlyMappings.some((mapping) => toggleEnabledLabels.includes(mapping)), false); }); + +test("standard-matrix effects match openrazer's razer_chroma_standard_matrix_effect_* payloads", () => { + // No capture of this family exists yet, so these pin the reference + // (razerchromacommon.c). The declared size is each effect's own rather than + // its argument count: breathing declares eight bytes whatever it carries. + const cases: Array<{ + effect: RazerStandardEffect; + options?: Parameters[1]; + dataSize: number; + args: number[]; + }> = [ + { effect: "off", dataSize: 0x01, args: [0x00] }, + { effect: "spectrum", dataSize: 0x01, args: [0x04] }, + { effect: "wave", dataSize: 0x02, args: [0x01, 0x01] }, + { effect: "static", options: { color: "#ff8000" }, dataSize: 0x04, args: [0x06, 0xff, 0x80, 0x00] }, + { effect: "reactive", options: { color: "#00ff00", speed: 3 }, dataSize: 0x05, args: [0x02, 0x03, 0x00, 0xff, 0x00] }, + { effect: "breathing-random", dataSize: 0x08, args: [0x03, 0x03] }, + { effect: "breathing-single", options: { color: "#0000ff" }, dataSize: 0x08, args: [0x03, 0x01, 0x00, 0x00, 0xff] }, + { + effect: "breathing-dual", + options: { color: "#ff0000", color2: "#00ff00" }, + dataSize: 0x08, + args: [0x03, 0x02, 0xff, 0x00, 0x00, 0x00, 0xff, 0x00], + }, + ]; + for (const { effect, options, dataSize, args } of cases) { + const command = razerSetStandardEffectCommand(effect, options); + assert.deepEqual([command.commandClass, command.commandId, command.dataSize], [0x03, 0x0a, dataSize], effect); + assert.deepEqual(command.args, args, effect); + } +}); + +test("a standard-matrix effect refuses to guess a missing colour or speed", () => { + assert.throws(() => razerSetStandardEffectCommand("static"), RazerProtocolError); + assert.throws(() => razerSetStandardEffectCommand("reactive", { color: "#ffffff" }), RazerProtocolError); + assert.throws(() => razerSetStandardEffectCommand("breathing-dual", { color: "#ffffff" }), RazerProtocolError); +}); + +test("backlight brightness addresses the backlight led through the storage byte", () => { + assert.deepEqual(RAZER_BACKLIGHT_BRIGHTNESS_READ.args, [0x01, 0x05]); + const write = razerSetBacklightBrightnessCommand(50); + assert.equal(write.commandId, RAZER_BACKLIGHT_BRIGHTNESS_READ.commandId & 0x7f); + assert.deepEqual(write.args, [0x01, 0x05, 0x80]); + assert.equal(decodeBacklightBrightness(Uint8Array.of(0x01, 0x05, 0xff)), 100); + assert.throws(() => razerSetBacklightBrightnessCommand(101), RazerProtocolError); +}); + +test("every whole brightness percentage survives the 0-255 round trip", () => { + // setLighting confirms brightness by reading it back, so a percentage that + // rounded differently on the way back would be reported as refused. + for (let percent = 0; percent <= 100; percent += 1) { + const level = razerSetBacklightBrightnessCommand(percent).args?.[2] ?? -1; + assert.equal(decodeBacklightBrightness(Uint8Array.of(0x01, 0x05, level)), percent); + } +}); diff --git a/src/razer/codec.ts b/src/razer/codec.ts index dc963b8..ac4fd54 100644 --- a/src/razer/codec.ts +++ b/src/razer/codec.ts @@ -333,6 +333,128 @@ export function razerSetExtendedEffectCommand( }; } +/** + * The older Chroma lighting family (class `0x03`), from openrazer's + * `razer_chroma_standard_*` functions, which it sends to the Diamondback + * Chroma on transaction id `0xff`. Not yet confirmed on hardware by this + * project. + * + * The effect write names no led and carries no storage byte: one effect + * drives every LED on the mouse. Only the brightness pair addresses a led, the + * backlight, through the storage byte. + */ +export type RazerStandardEffect = RazerExtendedEffect | "wave"; + +/** Effect ids from openrazer's `MATRIX_EFFECT_*` (razercommon.h). */ +export const RAZER_STANDARD_EFFECT = { + off: 0x00, + wave: 0x01, + reactive: 0x02, + breathing: 0x03, + spectrum: 0x04, + static: 0x06, +} as const; + +/** Breathing's first argument: how many colours follow, or random. */ +const BREATHING_SINGLE = 0x01; +const BREATHING_DUAL = 0x02; +const BREATHING_RANDOM = 0x03; + +// ponytail: one fixed wave direction (openrazer takes 1 or 2); add a direction +// option when the lighting card grows a control for it. +const WAVE_DIRECTION = 0x01; + +/** The backlight's led id in openrazer's standard family (`BACKLIGHT_LED`). */ +const RAZER_LED_BACKLIGHT = 0x05; + +/** Brightness travels on a 0-255 scale; the panel works in whole percent. */ +const BRIGHTNESS_SCALE = 255; + +/** + * Effect write (`0x03`/`0x0a`) matching openrazer's + * `razer_chroma_standard_matrix_effect_*` byte for byte, declared data sizes + * included: the effect id, then that effect's own fields. Breathing always + * declares eight bytes, whatever its colour count, and reactive puts its speed + * before the colour. + */ +export function razerSetStandardEffectCommand( + effect: RazerStandardEffect, + options: { + color?: string; + color2?: string; + speed?: RazerReactiveSpeed; + } = {}, +): RazerCommand { + const command = (dataSize: number, args: number[]): RazerCommand => ({ + commandClass: 0x03, + commandId: 0x0a, + dataSize, + args, + }); + const color = (value: string | undefined, message: string) => { + if (!value) throw new RazerProtocolError(message); + return parseRazerColor(value); + }; + switch (effect) { + case "off": + return command(0x01, [RAZER_STANDARD_EFFECT.off]); + case "spectrum": + return command(0x01, [RAZER_STANDARD_EFFECT.spectrum]); + case "wave": + return command(0x02, [RAZER_STANDARD_EFFECT.wave, WAVE_DIRECTION]); + case "static": + return command(0x04, [RAZER_STANDARD_EFFECT.static, ...color(options.color, "static needs a colour.")]); + case "reactive": + if (!options.speed) throw new RazerProtocolError("Reactive needs a speed."); + return command(0x05, [ + RAZER_STANDARD_EFFECT.reactive, + RAZER_EFFECT_SPEED[options.speed], + ...color(options.color, "reactive needs a colour."), + ]); + case "breathing-random": + return command(0x08, [RAZER_STANDARD_EFFECT.breathing, BREATHING_RANDOM]); + case "breathing-single": + return command(0x08, [ + RAZER_STANDARD_EFFECT.breathing, + BREATHING_SINGLE, + ...color(options.color, "Breathing single needs a colour."), + ]); + case "breathing-dual": + return command(0x08, [ + RAZER_STANDARD_EFFECT.breathing, + BREATHING_DUAL, + ...color(options.color, "Breathing dual needs two colours."), + ...color(options.color2, "Breathing dual needs two colours."), + ]); + } +} + +/** Backlight brightness read (`0x03`/`0x83`); the level answers in the third byte. */ +export const RAZER_BACKLIGHT_BRIGHTNESS_READ: RazerCommand = { + commandClass: 0x03, + commandId: 0x83, + dataSize: 0x03, + args: [RAZER_STORAGE, RAZER_LED_BACKLIGHT], +}; + +/** Backlight brightness write (`0x03`/`0x03`), in the same layout the read answers. */ +export function razerSetBacklightBrightnessCommand(percent: number): RazerCommand { + if (!Number.isInteger(percent) || percent < 0 || percent > 100) { + throw new RazerProtocolError("Brightness must be a whole percentage from 0 to 100."); + } + return { + commandClass: 0x03, + commandId: 0x03, + dataSize: 0x03, + args: [RAZER_STORAGE, RAZER_LED_BACKLIGHT, Math.round((percent * BRIGHTNESS_SCALE) / 100)], + }; +} + +/** Whole percent; every whole percent survives the 0-255 round trip unchanged. */ +export function decodeBacklightBrightness(args: Uint8Array): number { + return Math.round((args[2] * 100) / BRIGHTNESS_SCALE); +} + export class RazerProtocolError extends Error { readonly status: number | null; /** diff --git a/src/razer/devices.ts b/src/razer/devices.ts index 56b620a..1e7560d 100644 --- a/src/razer/devices.ts +++ b/src/razer/devices.ts @@ -91,6 +91,16 @@ export interface RazerProduct { * comes back populated. */ buttonMapping?: boolean; + /** + * The older Chroma lighting commands (class `0x03`: the `0x0a` effect write + * and the backlight brightness pair). OpenRazer drives some Chroma-era mice + * this way and others through the extended matrix or plain led commands, and + * nothing on the wire says which, so this is an allowlist like + * `buttonMapping`. The effect write has no read-back, so a mouse that + * accepts it and ignores it would look exactly like one that obeys; only a + * hardware run can tell. + */ + standardMatrixLighting?: boolean; /** Also accept a vendor-defined collection as the control interface. */ vendorControlInterface?: boolean; /** DPI storage selector; some generations use the no-store command form. */ @@ -445,8 +455,9 @@ const PRODUCT_DEFINITIONS: ReadonlyArray<[number, Omit