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..b5af928 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 @@ -375,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: @@ -868,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 02f2eeb..dd18fde 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 @@ -269,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 8b7fe3c..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. */ @@ -441,7 +451,13 @@ const PRODUCT_DEFINITIONS: ReadonlyArray<[number, Omit