diff --git a/docs/diamondback-custom-lighting.md b/docs/diamondback-custom-lighting.md index 66abd061..2a5330e1 100644 --- a/docs/diamondback-custom-lighting.md +++ b/docs/diamondback-custom-lighting.md @@ -16,6 +16,12 @@ Button remapping and macros are outside this change. 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]`. +- [OpenRGB's Diamondback catalog](https://github.com/CalcProgrammer1/OpenRGB/blob/master/Controllers/RazerController/RazerDevices.cpp) + declares a linear 21-cell row (19 strip LEDs, logo and scroll wheel). +- [OpenRGB's controller](https://github.com/CalcProgrammer1/OpenRGB/blob/master/Controllers/RazerController/RazerController/RazerController.cpp) + sends that complete row from column 0 through 20 and activates custom mode + after every upload. Its linear report retains data size `0x32` while copying + all 63 RGB bytes into the report's 80-byte argument area. 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 @@ -27,15 +33,22 @@ 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. +Ticket T 0124 reports that the original single-cell implementation only changed +the first LED, while whole-mouse effects worked. The correction follows +OpenRGB's complete-row upload and activation sequence. This is a source-backed +fix candidate; physical confirmation from the reporter is still required. + +Choosing Static or Off uploads all 21 cells, then activates the volatile custom +frame on every edit. Previously acknowledged Static colors are preserved; +unknown cells start black because their initial colors cannot be read back. +Off writes black to that LED. After a successful upload and activation, unknown +cells are reported as Off. Brightness is global and remains on the Mouse zone. +A built-in Mouse effect overrides the visible custom colors and clears the LED +modes reported by the driver. Starting custom mode again initializes unknown +cells to black. 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 +The cache records the complete row 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. @@ -46,7 +59,7 @@ 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. + that changes and any error. Initially unknown cells are set to black. 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. diff --git a/src/drivers/razer/hid.test.ts b/src/drivers/razer/hid.test.ts index 1f802b42..9d98ffcc 100644 --- a/src/drivers/razer/hid.test.ts +++ b/src/drivers/razer/hid.test.ts @@ -1046,19 +1046,21 @@ test("the Diamondback exposes 21 unknown, write-only LED cells beside its whole- 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 () => { +test("painting LED 20 uploads all 21 cells 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[0], 0, 2)], [0, 20]); + assert.ok(sliceArgs(sent[0], 2, 60).every((byte) => byte === 0)); + assert.deepEqual([...sliceArgs(sent[0], 62, 3)], [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); + assert.equal(after.lightingZones![1].mode, "Off"); }); test("LED zero supports Off without overwriting other cells or global brightness", async () => { @@ -1067,8 +1069,8 @@ test("LED zero supports Off without overwriting other cells or global brightness 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]); + assert.deepEqual(sent.map((p) => p[7]), [0x0c, 0x0a]); + assert.deepEqual([...sliceArgs(sent[0], 0, 8)], [0, 20, 0, 0, 0, 0xab, 0xcd, 0xef]); const after = await client.readStatus(); assert.equal(after.lightingZones![1].mode, "Off"); assert.equal(after.lightingZones![2].color, "#abcdef"); @@ -1129,7 +1131,8 @@ test("concurrent LED writes keep the upload and activation together", async () = 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]); + assert.deepEqual(sent.map((p) => p[7]), [0x0c, 0x0a, 0x0c, 0x0a]); + assert.deepEqual([...sliceArgs(sent[2], 0, 8)], [0, 20, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66]); const after = await client.readStatus(); assert.equal(after.lightingZones![1].color, "#112233"); assert.equal(after.lightingZones![2].color, "#445566"); diff --git a/src/drivers/razer/hid.ts b/src/drivers/razer/hid.ts index de6b3a5a..043f0b4c 100644 --- a/src/drivers/razer/hid.ts +++ b/src/drivers/razer/hid.ts @@ -213,7 +213,6 @@ export class RazerHidClient { /** 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; @@ -583,7 +582,6 @@ 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; @@ -611,12 +609,20 @@ export class RazerHidClient { 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 }; + // Match OpenRGB's Diamondback path: upload the entire row from column 0, + // then latch custom mode on every edit. Single-cell uploads were accepted + // but the ticket reporter only saw LED 0 change. Unknown cells start black + // because this model cannot read its current custom-frame colours. + const colors = cells.map((cell) => cell.mode === "Static" ? cell.color ?? "#000000" : "#000000"); + colors[index] = lighting.mode === "Off" ? "#000000" : lighting.color!; + await this.request(razerSetOneRowCustomFrameCommand(0, colors)); + await this.request(razerSetStandardCustomEffectCommand()); + // Commit the entire write-only cache only after upload and activation. + for (let i = 0; i < cells.length; i++) { + cells[i] = i === index + ? { ...previous, mode: lighting.mode, color: lighting.color } + : { ...cells[i], mode: cells[i].mode ?? "Off" }; + } this.lighting = { ...this.lightingFromCache(), mode: null }; return { ...cells[index] }; } diff --git a/src/razer/codec.ts b/src/razer/codec.ts index e9b688af..d39c90f6 100644 --- a/src/razer/codec.ts +++ b/src/razer/codec.ts @@ -460,15 +460,16 @@ export function razerSetStandardCustomEffectCommand(): RazerCommand { /** * 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. + * and zero-based. The fixed 0x32 header is a firmware convention, not the + * argument capacity: the 80-byte argument area holds 26 RGB cells after the + * two column bytes. OpenRGB sends all 21 Diamondback cells in one report. */ 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."); + if (colors.length < 1 || colors.length > 26 || startColumn + colors.length - 1 > 255) { + throw new RazerProtocolError("A single-row frame needs 1 to 26 colours within columns 0 to 255."); } return { commandClass: 0x03, diff --git a/src/razer/custom-frame.test.ts b/src/razer/custom-frame.test.ts index cdfdb556..75dad98e 100644 --- a/src/razer/custom-frame.test.ts +++ b/src/razer/custom-frame.test.ts @@ -16,11 +16,20 @@ test("a Diamondback partial frame carries inclusive columns, RGB bytes and the f 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("the full 21-LED Diamondback row matches OpenRGB despite the fixed 0x32 header", () => { + const packet = encodeRazerRequest(razerSetOneRowCustomFrameCommand(0, Array(21).fill("#123456")), 0xff); + assert.equal(packet[5], 0x32); + assert.deepEqual([...packet.slice(8, 10)], [0, 20]); + assert.deepEqual([...packet.slice(70, 73)], [18, 52, 86]); + assert.ok(packet.slice(73, 88).every((byte) => byte === 0)); + assert.equal(packet[88], razerChecksum(packet)); +}); + +test("the maximum row fills the argument area without overwriting the checksum", () => { + const packet = encodeRazerRequest(razerSetOneRowCustomFrameCommand(0, Array(26).fill("#123456")), 0xff); + assert.deepEqual([...packet.slice(85, 88)], [18, 52, 86]); + assert.equal(packet[88], razerChecksum(packet)); + assert.equal(packet[89], 0); }); test("a custom effect activates the volatile frame on class 03 command 0a", () => { @@ -32,7 +41,7 @@ test("a custom effect activates the volatile frame on class 03 command 0a", () = 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(0, Array(27).fill("#123456"))); assert.throws(() => razerSetOneRowCustomFrameCommand(255, ["#123456", "#abcdef"])); assert.throws(() => razerSetOneRowCustomFrameCommand(0, ["#12"])); });