Skip to content
Merged
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
62 changes: 62 additions & 0 deletions docs/diamondback-custom-lighting.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion src/drivers/mouse-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
123 changes: 121 additions & 2 deletions src/drivers/razer/hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]]);
Expand Down Expand Up @@ -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();
Expand Down
65 changes: 64 additions & 1 deletion src/drivers/razer/hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import {
razerSetSleepTimeoutCommand,
razerSetBacklightBrightnessCommand,
razerSetStandardEffectCommand,
razerSetStandardCustomEffectCommand,
razerSetOneRowCustomFrameCommand,
razerReadExtendedBrightnessCommand,
razerSetExtendedBrightnessCommand,
razerSetExtendedEffectCommand,
Expand Down Expand Up @@ -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<unknown> = Promise.resolve();

readonly device: HIDDevice;

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -544,6 +553,14 @@ export class RazerHidClient {
* breathing alone fails on hardware, that is the assumption to revisit.
*/
async setLighting(lighting: MouseLighting): Promise<MouseLighting> {
// 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<MouseLighting> {
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.");
Expand All @@ -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));
Expand All @@ -577,6 +598,48 @@ export class RazerHidClient {
return this.lighting;
}

private async setPerLedLighting(lighting: MouseLighting): Promise<MouseLighting> {
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.
Expand Down
27 changes: 27 additions & 0 deletions src/razer/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading