Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions docs/diamondback-custom-lighting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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.
Expand Down
15 changes: 9 additions & 6 deletions src/drivers/razer/hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
22 changes: 14 additions & 8 deletions src/drivers/razer/hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> = Promise.resolve();

readonly device: HIDDevice;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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] };
}
Expand Down
9 changes: 5 additions & 4 deletions src/razer/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 15 additions & 6 deletions src/razer/custom-frame.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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"]));
});
Loading