From 5522044171338777ea7b2a20afbd62bfc2e1bc2f Mon Sep 17 00:00:00 2001 From: DEERSTUDIO101 <181780870+DEERSTUDIO101@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:35:47 +0200 Subject: [PATCH] feat(razer): add individual LED lighting for Diamondback Chroma --- docs/diamondback-custom-lighting.md | 62 ++++++++++++++ src/drivers/mouse-types.ts | 2 +- src/drivers/razer/hid.test.ts | 123 +++++++++++++++++++++++++++- src/drivers/razer/hid.ts | 65 ++++++++++++++- src/razer/codec.ts | 27 ++++++ src/razer/custom-frame.test.ts | 38 +++++++++ src/razer/devices.ts | 4 +- 7 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 docs/diamondback-custom-lighting.md create mode 100644 src/razer/custom-frame.test.ts diff --git a/docs/diamondback-custom-lighting.md b/docs/diamondback-custom-lighting.md new file mode 100644 index 0000000..66abd06 --- /dev/null +++ b/docs/diamondback-custom-lighting.md @@ -0,0 +1,62 @@ +# Diamondback Chroma individual LED lighting + +Scope: `1532:004c`, wired USB. Existing DPI, polling, whole-mouse effects and +global brightness stay on their existing paths. No OpenMouse Bridge changes +are required: both writes use the driver's existing feature report 0 transport. +Button remapping and macros are outside this change. + +## Protocol evidence + +- [OpenRazer's mouse catalog](https://github.com/openrazer/openrazer/blob/master/daemon/openrazer_daemon/hardware/mouse.py) + describes the Diamondback as a 1 x 21 matrix. +- [OpenRazer's mouse driver](https://github.com/openrazer/openrazer/blob/master/driver/razermouse_driver.c) + dispatches this product's custom frame to + `razer_chroma_misc_one_row_set_custom_frame`, on transaction id `0xff`. +- [OpenRazer's Chroma codecs](https://github.com/openrazer/openrazer/blob/master/driver/razerchromacommon.c) + define the single-row write as class `0x03`, command `0x0c`, fixed data size + `0x32`, inclusive start/end columns followed by RGB bytes. Custom activation + is class `0x03`, command `0x0a`, data size `0x02`, arguments `[0x05, 0x00]`. + +These facts are transcribed from the upstream sources, not inferred from other +Razer models. The new writes have automated coverage, but have not yet been +verified on a physical Diamondback by this project. + +## Behavior + +The status exposes the existing Mouse effects zone followed by LED 0 through +LED 20. LED numbers are protocol columns, not a claimed physical left-to-right +layout. The existing OpenMouse lighting strip renders them without a new UI. + +Choosing Static or Off writes only the selected cell, then activates the +volatile custom frame if it is not already active. This preserves untouched +cells in the mouse's frame buffer; their initial colors cannot be read back. +Off writes black to that LED. Brightness is global and remains on the Mouse +zone. A built-in Mouse effect overrides the visible custom colors; the driver +then clears the LED modes it reports. The next LED write activates custom again. + +Colors and effect state are write-only. On connection the LED modes are unknown. +The cache records a cell only after both upload and activation succeed. This +does not promise persistence after an unplug, or synchronize with other RGB +software editing the same mouse. + +## Hardware validation for the ticket reporter + +Use the current Bridge build already confirmed to connect this mouse, and quit +Synapse and OpenRGB so they do not replace the test colors. + +1. Record the firmware, DPI, polling rate and brightness before testing. +2. In Lighting, choose LED 0, set Static red and apply. Record the physical LED + that changes and any error. The rest of the frame should not be repainted. +3. Repeat with LED 1 in green and LED 20 in blue; confirm earlier cells keep + their colors, and record the physical positions. +4. Set LED 1 to Off; verify only that cell goes dark. +5. On Mouse choose Spectrum; verify the whole-mouse effect returns. Pick LED 0 + and apply Static again; verify custom colors can be resumed. +6. On Mouse choose Static and change global brightness. Confirm its read-back. +7. Reload and unplug/replug separately. Unknown LED modes after a new client + connection are expected; note what the actual hardware retains. +8. Confirm DPI and polling still work. Attach the hardware-test JSON and console + errors, if any, and the observed LED index-to-position map. + +The Mamba Elite is deliberately not enabled: its custom-frame protocol is a +different extended-matrix family, and this change supplies no evidence for it. diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index d27a09d..031d8a3 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -110,7 +110,7 @@ export interface MouseLighting { brightnessLevels?: readonly number[]; /** True when the mouse cannot report the effect back (Razer effect writes). */ writeOnly?: boolean; - /** HID++ per-key/per-LED zone id when this is a directly painted RGB cell. */ + /** Driver-specific per-LED zone id when this is a directly painted RGB cell. */ hardwareZoneId?: number; /** Lets the UI group individually painted cells into one physical surface. */ group?: string; diff --git a/src/drivers/razer/hid.test.ts b/src/drivers/razer/hid.test.ts index 6ee3d6b..2d21e14 100644 --- a/src/drivers/razer/hid.test.ts +++ b/src/drivers/razer/hid.test.ts @@ -922,7 +922,13 @@ test("a Chrome-refused feature-report write surfaces troubleshooting, not the ba * 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 } = {}) { +function fakeChromaMouse(options: { + productId?: number; + brightness?: number; + ignoreWrites?: boolean; + failCustomFrame?: boolean; + failCustomEffect?: boolean; +} = {}) { const sent: Uint8Array[] = []; let level = options.brightness ?? 0xff; let pending = new Uint8Array(RAZER_PACKET_LENGTH); @@ -942,7 +948,15 @@ function fakeChromaMouse(options: { productId?: number; brightness?: number; ign 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 === 0x0c) { + pending = options.failCustomFrame + ? replyPacket(commandClass, commandId, data[5], [], RAZER_STATUS.unsupported) + : answer(data[5], [...data.slice(8, 8 + data[5])]); + } else if (commandClass === 0x03 && commandId === 0x0a) { + pending = options.failCustomEffect && data[8] === 0x05 + ? replyPacket(commandClass, commandId, data[5], [], RAZER_STATUS.unsupported) + : 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]]); @@ -982,6 +996,111 @@ test("a Razer model off the lighting allowlist is never sent a lighting command" assert.equal(sent.some((packet) => packet[6] === 0x03), false); }); +test("the Diamondback exposes 21 unknown, write-only LED cells beside its whole-mouse effects", async () => { + const { client, sent } = fakeChromaMouse(); + const status = await client.readStatus(); + assert.equal(status.lightingZones?.length, 22); + assert.equal(status.lightingZones?.[0], status.lighting); + const cells = status.lightingZones!.slice(1); + assert.deepEqual(cells.map((cell) => cell.hardwareZoneId), Array.from({ length: 21 }, (_, i) => i)); + for (const cell of cells) { + assert.equal(cell.mode, null); + assert.equal(cell.writeOnly, true); + assert.deepEqual(cell.modes, ["Off", "Static"]); + assert.equal(cell.brightnessLevels, undefined); + } + assert.equal(sent.some((packet) => packet[6] === 0x03 && packet[7] === 0x0c), false); +}); + +test("painting LED 20 uploads only that cell before activating the volatile custom frame", async () => { + const { client, sent } = fakeChromaMouse(); + const status = await client.readStatus(); + sent.length = 0; + await client.setLighting({ ...status.lightingZones![21], mode: "Static", color: "#123456" }); + assert.deepEqual(sent.map((p) => [p[1], p[5], p[6], p[7]]), [[0xff, 0x32, 0x03, 0x0c], [0xff, 0x02, 0x03, 0x0a]]); + assert.deepEqual([...sliceArgs(sent[0], 0, 5)], [20, 20, 0x12, 0x34, 0x56]); + assert.deepEqual([...sliceArgs(sent[1], 0, 2)], [0x05, 0x00]); + const after = await client.readStatus(); + assert.equal(after.lighting?.mode, null); + assert.equal(after.lightingZones![21].mode, "Static"); + assert.equal(after.lightingZones![21].color, "#123456"); + assert.equal(after.lightingZones![1].mode, null); +}); + +test("LED zero supports Off without overwriting other cells or global brightness", async () => { + const { client, sent } = fakeChromaMouse(); + const zones = (await client.readStatus()).lightingZones!; + await client.setLighting({ ...zones[2], mode: "Static", color: "#abcdef" }); + sent.length = 0; + await client.setLighting({ ...zones[1], mode: "Off", color: "#ff0000", brightness: 25 }); + assert.equal(sent.length, 1); + assert.deepEqual([...sliceArgs(sent[0], 0, 5)], [0, 0, 0, 0, 0]); + const after = await client.readStatus(); + assert.equal(after.lightingZones![1].mode, "Off"); + assert.equal(after.lightingZones![2].color, "#abcdef"); + assert.equal(after.lighting?.brightness, 100); +}); + +test("a whole-mouse effect clears the active LED modes and the next cell reactivates custom lighting", async () => { + const { client, sent } = fakeChromaMouse(); + const zones = (await client.readStatus()).lightingZones!; + await client.setLighting({ ...zones[1], mode: "Static", color: "#abcdef" }); + await client.setLighting({ ...zones[0], mode: "Spectrum" }); + const after = await client.readStatus(); + assert.equal(after.lighting?.mode, "Spectrum"); + assert.ok(after.lightingZones!.slice(1).every((cell) => cell.mode === null)); + sent.length = 0; + await client.setLighting({ ...after.lightingZones![2], mode: "Static", color: "#654321" }); + assert.deepEqual(sent.map((p) => p[7]), [0x0c, 0x0a]); +}); + +test("failed frame uploads and activations never populate the write-only cache", async () => { + for (const failure of ["failCustomFrame", "failCustomEffect"] as const) { + const options = { [failure]: true }; + const { client, sent } = fakeChromaMouse(options); + const zones = (await client.readStatus()).lightingZones!; + sent.length = 0; + await assert.rejects(client.setLighting({ ...zones[1], mode: "Static", color: "#123456" })); + assert.equal((await client.readStatus()).lightingZones![1].mode, null); + if (failure === "failCustomFrame") assert.equal(sent.some((p) => p[7] === 0x0a), false); + options[failure] = false; + sent.length = 0; + await client.setLighting({ ...zones[1], mode: "Static", color: "#123456" }); + assert.deepEqual(sent.map((p) => p[7]), [0x0c, 0x0a]); + } +}); + +test("LED validation rejects unknown indices, mismatched zones, effects and colours before writing", async () => { + const { client, sent } = fakeChromaMouse(); + const cell = (await client.readStatus()).lightingZones![1]; + sent.length = 0; + for (const patch of [ + { hardwareZoneId: -1 }, { hardwareZoneId: 21 }, { hardwareZoneId: 0.5 }, + { hardwareZoneId: NaN }, { zone: "Logo" }, { mode: "Wave" as const }, + { color: "no-colour" }, { color: null }, + ]) { + await assert.rejects(client.setLighting({ ...cell, mode: "Static", color: "#112233", ...patch })); + } + assert.equal(sent.length, 0); + const other = fakeChromaMouse({ productId: 0x006c }); + await assert.rejects(other.client.setLighting({ ...cell, mode: "Static" }), /individual LED zone/); + assert.equal(other.sent.length, 0); +}); + +test("concurrent LED writes keep the upload and activation together", async () => { + const { client, sent } = fakeChromaMouse(); + const zones = (await client.readStatus()).lightingZones!; + sent.length = 0; + await Promise.all([ + client.setLighting({ ...zones[1], mode: "Static", color: "#112233" }), + client.setLighting({ ...zones[2], mode: "Static", color: "#445566" }), + ]); + assert.deepEqual(sent.map((p) => p[7]), [0x0c, 0x0a, 0x0c]); + const after = await client.readStatus(); + assert.equal(after.lightingZones![1].color, "#112233"); + assert.equal(after.lightingZones![2].color, "#445566"); +}); + 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(); diff --git a/src/drivers/razer/hid.ts b/src/drivers/razer/hid.ts index 0b2b674..de6b3a5 100644 --- a/src/drivers/razer/hid.ts +++ b/src/drivers/razer/hid.ts @@ -58,6 +58,8 @@ import { razerSetSleepTimeoutCommand, razerSetBacklightBrightnessCommand, razerSetStandardEffectCommand, + razerSetStandardCustomEffectCommand, + razerSetOneRowCustomFrameCommand, razerReadExtendedBrightnessCommand, razerSetExtendedBrightnessCommand, razerSetExtendedEffectCommand, @@ -210,6 +212,9 @@ export class RazerHidClient { private lighting: MouseLighting | null = null; /** Same cache, one entry per led, for the extended-matrix family. */ private lightingZones: MouseLighting[] | null = null; + private perLedLighting: MouseLighting[] | null = null; + private customFrameActive = false; + private lightingQueue: Promise = Promise.resolve(); readonly device: HIDDevice; @@ -374,7 +379,11 @@ export class RazerHidClient { // 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; - const lightingZones = this.profile()?.extendedMatrixLighting === true ? await this.readExtendedLighting() : null; + const lightingZones = this.profile()?.extendedMatrixLighting === true + ? await this.readExtendedLighting() + : lighting && this.profile()?.oneRowCustomFrameLedCount + ? [lighting, ...this.perLedLightingFromCache()] + : null; return { brand: "Razer", name: this.displayName(), @@ -544,6 +553,14 @@ export class RazerHidClient { * breathing alone fails on hardware, that is the assumption to revisit. */ async setLighting(lighting: MouseLighting): Promise { + // Keep a frame upload and activation together, including concurrent callers. + const run = this.lightingQueue.then(() => this.writeLighting(lighting)); + this.lightingQueue = run.catch(() => undefined); + return run; + } + + private async writeLighting(lighting: MouseLighting): Promise { + if (lighting.hardwareZoneId !== undefined) return this.setPerLedLighting(lighting); if (this.profile()?.extendedMatrixLighting === true) return this.setExtendedLighting(lighting); if (this.profile()?.standardMatrixLighting !== true) { throw new Error("This mouse does not support changing the lighting yet."); @@ -566,6 +583,10 @@ export class RazerHidClient { // 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 }; + this.customFrameActive = false; + // A whole-mouse effect overrides every individual cell on screen. Its + // custom frame buffer remains on the device, but cannot be read back. + for (const cell of this.perLedLightingFromCache()) cell.mode = null; if (lighting.brightness != null && lighting.brightness !== previous.brightness) { await this.request(razerSetBacklightBrightnessCommand(lighting.brightness)); const confirmed = decodeBacklightBrightness(await this.request(RAZER_BACKLIGHT_BRIGHTNESS_READ)); @@ -577,6 +598,48 @@ export class RazerHidClient { return this.lighting; } + private async setPerLedLighting(lighting: MouseLighting): Promise { + const cells = this.perLedLightingFromCache(); + const index = lighting.hardwareZoneId!; + const previous = cells[index]; + if (!Number.isInteger(index) || index < 0 || !previous || previous.zone !== lighting.zone) { + throw new Error(`This mouse has no "${lighting.zone}" individual LED zone.`); + } + if (lighting.mode !== "Off" && lighting.mode !== "Static") { + throw new Error("Individual LEDs support only Off or Static."); + } + if (lighting.mode === "Static" && lighting.color === null) { + throw new Error("An individual LED needs a colour."); + } + await this.request(razerSetOneRowCustomFrameCommand(index, [lighting.mode === "Off" ? "#000000" : lighting.color!])); + if (!this.customFrameActive) await this.request(razerSetStandardCustomEffectCommand()); + this.customFrameActive = true; + // Custom colours have no getter. Only cache an acknowledged frame, and + // never imply that untouched LEDs or their physical order were read back. + cells[index] = { ...previous, mode: lighting.mode, color: lighting.color }; + this.lighting = { ...this.lightingFromCache(), mode: null }; + return { ...cells[index] }; + } + + private perLedLightingFromCache(): MouseLighting[] { + this.perLedLighting ??= Array.from({ length: this.profile()?.oneRowCustomFrameLedCount ?? 0 }, (_, index) => ({ + zone: `LED ${index}`, + group: "Custom frame", + hardwareZoneId: index, + modes: ["Off", "Static"], + mode: null, + color: "#00ff00", + color2: null, + colorModes: ["Static"], + dualColorModes: [], + reactiveModes: [], + speeds: [], + speed: null, + writeOnly: true, + })); + return this.perLedLighting; + } + /** * The cached effect plus the brightness the mouse reports. A brightness read * that fails hides only the brightness row; the effects are still offered. diff --git a/src/razer/codec.ts b/src/razer/codec.ts index 5a5c738..e9b688a 100644 --- a/src/razer/codec.ts +++ b/src/razer/codec.ts @@ -451,6 +451,33 @@ export function razerSetStandardEffectCommand( } } +/** Activate the volatile frame after writing single-row RGB cells. */ +export function razerSetStandardCustomEffectCommand(): RazerCommand { + // OpenRazer's standard custom effect selects the volatile frame (NOSTORE). + return { commandClass: 0x03, commandId: 0x0a, dataSize: 0x02, args: [0x05, 0x00] }; +} + +/** + * Diamondback Chroma / Mamba TE single-row frame (`0x03`/`0x0c`), matching + * OpenRazer's razer_chroma_misc_one_row_set_custom_frame. Columns are inclusive + * and zero-based. Keep the fixed 0x32 data size; at most 16 RGB cells fit. + * A partial frame leaves the other cells in the device's frame buffer alone. + */ +export function razerSetOneRowCustomFrameCommand(startColumn: number, colors: readonly string[]): RazerCommand { + if (!Number.isInteger(startColumn) || startColumn < 0 || startColumn > 255) { + throw new RazerProtocolError("The start column must be a whole byte from 0 to 255."); + } + if (colors.length < 1 || colors.length > 16 || startColumn + colors.length - 1 > 255) { + throw new RazerProtocolError("A single-row frame needs 1 to 16 colours within columns 0 to 255."); + } + return { + commandClass: 0x03, + commandId: 0x0c, + dataSize: 0x32, + args: [startColumn, startColumn + colors.length - 1, ...colors.flatMap((color) => parseRazerColor(color))], + }; +} + /** Backlight brightness read (`0x03`/`0x83`); the level answers in the third byte. */ export const RAZER_BACKLIGHT_BRIGHTNESS_READ: RazerCommand = { commandClass: 0x03, diff --git a/src/razer/custom-frame.test.ts b/src/razer/custom-frame.test.ts new file mode 100644 index 0000000..cdfdb55 --- /dev/null +++ b/src/razer/custom-frame.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + encodeRazerRequest, + razerChecksum, + razerSetOneRowCustomFrameCommand, + razerSetStandardCustomEffectCommand, +} from "./codec.js"; + +test("a Diamondback partial frame carries inclusive columns, RGB bytes and the fixed 50-byte size", () => { + const packet = encodeRazerRequest(razerSetOneRowCustomFrameCommand(19, ["#ff8000", "#123456"]), 0xff); + assert.equal(packet.length, 90); + assert.deepEqual([...packet.slice(1, 8)], [0xff, 0, 0, 0, 0x32, 0x03, 0x0c]); + assert.deepEqual([...packet.slice(8, 16)], [19, 20, 255, 128, 0, 18, 52, 86]); + assert.ok(packet.slice(16, 88).every((byte) => byte === 0)); + assert.equal(packet[88], razerChecksum(packet)); +}); + +test("the maximum frame fills the declared payload without spilling into padding", () => { + const packet = encodeRazerRequest(razerSetOneRowCustomFrameCommand(0, Array(16).fill("#123456")), 0xff); + assert.deepEqual([...packet.slice(8, 10)], [0, 15]); + assert.deepEqual([...packet.slice(55, 58)], [18, 52, 86]); + assert.ok(packet.slice(58, 88).every((byte) => byte === 0)); +}); + +test("a custom effect activates the volatile frame on class 03 command 0a", () => { + const packet = encodeRazerRequest(razerSetStandardCustomEffectCommand(), 0xff); + assert.deepEqual([...packet.slice(5, 10)], [2, 3, 10, 5, 0]); + assert.equal(packet[88], razerChecksum(packet)); +}); + +test("invalid frame ranges and colours are rejected", () => { + for (const start of [-1, 0.5, 256, NaN]) assert.throws(() => razerSetOneRowCustomFrameCommand(start, ["#123456"])); + assert.throws(() => razerSetOneRowCustomFrameCommand(0, [])); + assert.throws(() => razerSetOneRowCustomFrameCommand(0, Array(17).fill("#123456"))); + assert.throws(() => razerSetOneRowCustomFrameCommand(255, ["#123456", "#abcdef"])); + assert.throws(() => razerSetOneRowCustomFrameCommand(0, ["#12"])); +}); diff --git a/src/razer/devices.ts b/src/razer/devices.ts index bbf262b..1f46af7 100644 --- a/src/razer/devices.ts +++ b/src/razer/devices.ts @@ -101,6 +101,8 @@ export interface RazerProduct { * hardware run can tell. */ standardMatrixLighting?: boolean; + /** Volatile, zero-based RGB cells using the single-row custom-frame command. */ + oneRowCustomFrameLedCount?: number; /** * The extended-matrix lighting commands (class `0x0f`: effect `0x02` and * the brightness pair `0x04`/`0x84`) on transaction id `0x1f`, addressed per @@ -465,7 +467,7 @@ const PRODUCT_DEFINITIONS: ReadonlyArray<[number, Omit