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..f360dc8 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,86 @@ 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. + +## Basilisk V3 (`1532:0099`) and Basilisk V3 35K (`1532:00cb`) + +Driven by the generic `RazerHidClient`. Not verified by this project; a user +reported the mouse connecting with no lighting card, which is what added the +lighting. **The lighting has not been tried on hardware yet.** It follows +openrazer's extended-matrix family for these two ids, every command on +transaction id `0x1f`, addressed to one led at a time: + +| Zone | Led | Notes | +| --- | --- | --- | +| Mouse | `0x00` (`ZERO_LED`) | every LED at once; the only way to reach the underglow strip | +| Scroll wheel | `0x01` | | +| Logo | `0x04` | | + +| Write | Class / ID | Notes | +| --- | --- | --- | +| Off / Spectrum / Wave / Static | `0x0f` / `0x02` | `[storage, led, effect, ...]`; wave direction `0x01`, speed `0x28` | +| Brightness | `0x0f` / `0x04` | `[storage, led, level]`, level on a 0-255 scale | + +| Read | Class / ID | Notes | +| --- | --- | --- | +| Brightness | `0x0f` / `0x84` | level in the third byte, read per led on every refresh | + +openrazer creates wave, spectrum, static and brightness for these models and +no "none"; Off is the same family's effect `0x00`, so it is offered and is +the first thing to drop if it fails. Reactive and breathing exist in the family +but openrazer does not expose them here, so they are not offered. + +To test, with Synapse quit: + +1. Open the Lighting tab. Three zones should show, each with the brightness + it read; note the three levels. +2. On the Mouse zone pick Spectrum, Wave, Static (change the colour) and Off, + and watch the whole mouse including the underglow strip. +3. Repeat on Scroll wheel and Logo, and confirm only that zone changes. +4. Change one zone's brightness, reload, and confirm the new level reads back. +5. Unplug and replug the mouse and note whether the effects and brightness + survived. +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..175b4eb 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,23 @@ test("button mapping is only offered on connections where class 0x02 answered", } }); +test("extended-matrix lighting is offered on the Basilisk V3 pair OpenRazer drives that way", () => { + // Untested on hardware: a reporter with the mouse connected and asked for + // the lighting, which is the only way an effect write without a read-back + // ever gets checked. Each id here needs its owner to watch the LEDs. + const offered = RAZER_PRODUCT_IDS.filter((id) => RAZER_PRODUCTS.get(id)?.extendedMatrixLighting === true); + assert.deepEqual(offered, [0x0099, 0x00cb]); + for (const id of offered) assert.notEqual(RAZER_PRODUCTS.get(id)?.standardMatrixLighting, true); +}); + +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..6ee3d6b 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,218 @@ 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); +}); + +/** + * A Basilisk V3: firmware, DPI and legacy polling, plus the extended-matrix + * effect write and per-led brightness pair (`0x0f`), all of which it answers + * only on transaction id `0x1f`. Brightness is held per led on the 0-255 + * scale; `ignoreWrites` acknowledges a brightness write and keeps the level. + */ +function fakeBasiliskV3(options: { productId?: number; ignoreWrites?: boolean } = {}) { + const sent: Uint8Array[] = []; + const levels = new Map([[0x00, 0xff], [0x01, 0x80], [0x04, 0x40]]); + let pending = new Uint8Array(RAZER_PACKET_LENGTH); + const device = { + vendorId: 0x1532, + productId: options.productId ?? 0x0099, + productName: "Razer Basilisk V3", + 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 [transactionId, commandClass, commandId] = [data[1], 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 === 0x0f && transactionId !== 0x1f) { + // The real mouse stays silent on a wrong id; a stale reply models that. + pending = replyPacket(commandClass, commandId, data[5], [], RAZER_STATUS.unsupported); + } else if (commandClass === 0x0f && commandId === 0x02) pending = answer(data[5], [...data.slice(8, 8 + data[5])]); + else if (commandClass === 0x0f && commandId === 0x04) { + if (!options.ignoreWrites) levels.set(data[9], data[10]); + pending = answer(0x03, [data[8], data[9], data[10]]); + } else if (commandClass === 0x0f && commandId === 0x84) pending = answer(0x03, [data[8], data[9], levels.get(data[9]) ?? 0]); + 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 Basilisk V3 offers three extended-matrix zones with each led's brightness", async () => { + const { client, sent } = fakeBasiliskV3(); + + const status = await client.readStatus(); + + assert.deepEqual(status.lightingZones?.map((zone) => zone.zone), ["Mouse", "Scroll wheel", "Logo"]); + assert.deepEqual(status.lightingZones?.map((zone) => zone.brightness), [100, 50, 25]); + assert.deepEqual(status.lightingZones?.[0].modes, ["Off", "Spectrum", "Wave", "Static"]); + assert.equal(status.lightingZones?.[0].writeOnly, true); + // `lighting` stays the first zone for callers that predate zones. + assert.equal(status.lighting?.zone, "Mouse"); + // Every lighting read went out on 0x1f, to the all, scroll and logo leds. + const reads = sent.filter((packet) => packet[6] === 0x0f); + assert.deepEqual(reads.map((packet) => [packet[1], packet[7], packet[9]]), [[0x1f, 0x84, 0x00], [0x1f, 0x84, 0x01], [0x1f, 0x84, 0x04]]); +}); + +test("a Basilisk V3 zone write goes to that zone's led on 0x1f", async () => { + const { client, sent } = fakeBasiliskV3(); + const zones = (await client.readStatus()).lightingZones!; + sent.length = 0; + + await client.setLighting({ ...zones[1], mode: "Static", color: "#ff8000" }); + await client.setLighting({ ...zones[2], mode: "Wave" }); + + assert.deepEqual(sent.map((packet) => [packet[1], packet[6], packet[7]]), [[0x1f, 0x0f, 0x02], [0x1f, 0x0f, 0x02]]); + assert.deepEqual([...sliceArgs(sent[0], 0, 9)], [0x01, 0x01, 0x01, 0x00, 0x00, 0x01, 0xff, 0x80, 0x00]); + assert.deepEqual([...sliceArgs(sent[1], 0, 6)], [0x01, 0x04, 0x04, 0x01, 0x28, 0x00]); + // Write-only: the next status read reports each zone's cached effect. + const after = (await client.readStatus()).lightingZones!; + assert.deepEqual(after.map((zone) => zone.mode), [null, "Static", "Wave"]); +}); + +test("a Basilisk V3 brightness change is written to that led and confirmed by reading it back", async () => { + const { client, sent } = fakeBasiliskV3(); + const zones = (await client.readStatus()).lightingZones!; + sent.length = 0; + + const result = await client.setLighting({ ...zones[0], mode: "Spectrum", brightness: 50 }); + + assert.deepEqual(sent.map((packet) => [packet[7], packet[9]]), [[0x02, 0x00], [0x04, 0x00], [0x84, 0x00]]); + assert.deepEqual([...sliceArgs(sent[1], 0, 3)], [0x01, 0x00, 0x80]); + assert.equal(result.brightness, 50); + assert.equal((await client.readStatus()).lightingZones?.[0].brightness, 50); +}); + +test("a Basilisk V3 brightness the mouse does not keep is reported and the effect stays cached", async () => { + const { client } = fakeBasiliskV3({ ignoreWrites: true }); + const zones = (await client.readStatus()).lightingZones!; + + await assert.rejects( + client.setLighting({ ...zones[2], mode: "Spectrum", brightness: 50 }), + /kept 25% brightness instead of 50%/, + ); + + const after = (await client.readStatus()).lightingZones![2]; + assert.equal(after.mode, "Spectrum"); + assert.equal(after.brightness, 25); +}); + +test("a Basilisk V3 refuses a zone it does not have", async () => { + const { client, sent } = fakeBasiliskV3(); + const zones = (await client.readStatus()).lightingZones!; + sent.length = 0; + + await assert.rejects(client.setLighting({ ...zones[0], zone: "Underglow", mode: "Static" }), /no "Underglow" lighting zone/); + assert.equal(sent.length, 0); +}); diff --git a/src/drivers/razer/hid.ts b/src/drivers/razer/hid.ts index b8552dd..0b2b674 100644 --- a/src/drivers/razer/hid.ts +++ b/src/drivers/razer/hid.ts @@ -1,9 +1,11 @@ -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_LED, RAZER_BUTTON_CONTROL_LABEL, RAZER_LANDING_MAX, RAZER_LANDING_MIN, @@ -17,7 +19,9 @@ import { RAZER_STATUS, RAZER_TRACKING_DISTANCES, RAZER_TRANSACTION_ID, + RAZER_TRANSACTION_ID_1F, RazerProtocolError, + decodeBacklightBrightness, decodeBatteryPercent, decodeCharging, decodeDpi, @@ -52,11 +56,19 @@ import { razerEnableSensorCalibrationCommand, razerSetLowPowerThresholdCommand, razerSetSleepTimeoutCommand, + razerSetBacklightBrightnessCommand, + razerSetStandardEffectCommand, + razerReadExtendedBrightnessCommand, + razerSetExtendedBrightnessCommand, + razerSetExtendedEffectCommand, type RazerButtonControl, type RazerButtonMapping, type RazerCommand, type RazerDpi, + type RazerExtendedEffect, type RazerLiftOff, + type RazerReactiveSpeed, + type RazerStandardEffect, type RazerToggleControl, type RazerTrackingDistance, } from "@openmouse/protocol/razer"; @@ -102,6 +114,47 @@ 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]; + +// The extended-matrix family addresses leds, so the panel gets one zone per +// led OpenRazer exposes on the Basilisk V3: the whole mouse (ZERO_LED, which +// is also the only way to reach the underglow strip), the scroll wheel and +// the logo. Effects are the ones OpenRazer creates for these models; the +// "none" effect is the same command family, so Off is offered too. +const EXTENDED_LIGHTING_ZONES: readonly { zone: string; led: number }[] = [ + { zone: "Mouse", led: RAZER_LED.all }, + { zone: "Scroll wheel", led: RAZER_LED.scrollWheel }, + { zone: "Logo", led: RAZER_LED.logo }, +]; +const EXTENDED_LIGHTING_EFFECTS = { + Off: "off", + Spectrum: "spectrum", + Wave: "wave", + Static: "static", +} as const satisfies Partial>; +const EXTENDED_LIGHTING_MODES = Object.keys(EXTENDED_LIGHTING_EFFECTS) as (keyof typeof EXTENDED_LIGHTING_EFFECTS)[]; +const EXTENDED_COLOR_MODES: readonly MouseLightingMode[] = ["Static"]; +/** OpenRazer sends every extended-matrix command for these models on `0x1f`. */ +const EXTENDED_LIGHTING_TRANSACTION_ID = RAZER_TRANSACTION_ID_1F; + /** * 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 +202,14 @@ 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; + /** Same cache, one entry per led, for the extended-matrix family. */ + private lightingZones: MouseLighting[] | null = null; readonly device: HIDDevice; @@ -312,6 +373,8 @@ 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; + const lightingZones = this.profile()?.extendedMatrixLighting === true ? await this.readExtendedLighting() : null; return { brand: "Razer", name: this.displayName(), @@ -378,6 +441,8 @@ export class RazerHidClient { } : null, razerButtonMappings: buttonMappings ?? undefined, + lighting: lighting ?? lightingZones?.[0], + lightingZones: lightingZones ?? 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 +532,152 @@ 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()?.extendedMatrixLighting === true) return this.setExtendedLighting(lighting); + 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; + } + + /** + * Extended-matrix counterpart of `setLighting`: the effect goes to the zone's + * led on `0x1f`, then the brightness when it changed, confirmed by reading + * it back. Effects have no read, so the zone's cache is what gets reported. + */ + private async setExtendedLighting(lighting: MouseLighting): Promise { + const index = EXTENDED_LIGHTING_ZONES.findIndex(({ zone }) => zone === lighting.zone); + if (index < 0) throw new Error(`This mouse has no "${lighting.zone}" lighting zone.`); + const { led } = EXTENDED_LIGHTING_ZONES[index]; + const effect = lighting.mode ? EXTENDED_LIGHTING_EFFECTS[lighting.mode as keyof typeof EXTENDED_LIGHTING_EFFECTS] : undefined; + if (!effect) throw new Error("Pick an effect first."); + const zones = this.extendedLightingFromCache(); + const previous = zones[index]; + await this.request( + razerSetExtendedEffectCommand(effect, { color: lighting.color ?? undefined, led }), + EXTENDED_LIGHTING_TRANSACTION_ID, + ); + // Cached before the brightness step, as in the standard family. + zones[index] = { ...lighting, brightness: previous.brightness }; + if (lighting.brightness != null && lighting.brightness !== previous.brightness) { + await this.request(razerSetExtendedBrightnessCommand(led, lighting.brightness), EXTENDED_LIGHTING_TRANSACTION_ID); + const confirmed = decodeBacklightBrightness( + await this.request(razerReadExtendedBrightnessCommand(led), EXTENDED_LIGHTING_TRANSACTION_ID), + ); + if (confirmed !== lighting.brightness) { + throw new Error(`The mouse kept ${confirmed}% brightness instead of ${lighting.brightness}%.`); + } + zones[index] = { ...zones[index], brightness: confirmed }; + } + return zones[index]; + } + + /** Every zone's cached effect plus the brightness its led reports. */ + private async readExtendedLighting(): Promise { + const zones = this.extendedLightingFromCache(); + for (const [index, { led }] of EXTENDED_LIGHTING_ZONES.entries()) { + const reply = await this.request(razerReadExtendedBrightnessCommand(led), EXTENDED_LIGHTING_TRANSACTION_ID).catch(() => null); + const brightness = reply ? decodeBacklightBrightness(reply) : null; + zones[index] = { + ...zones[index], + brightness, + brightnessLevels: brightness === null ? [] : STANDARD_BRIGHTNESS_LEVELS, + }; + } + return zones; + } + + private extendedLightingFromCache(): MouseLighting[] { + this.lightingZones ??= EXTENDED_LIGHTING_ZONES.map(({ zone }) => ({ + zone, + modes: EXTENDED_LIGHTING_MODES, + mode: null, + color: "#00ff00", + color2: null, + colorModes: EXTENDED_COLOR_MODES, + dualColorModes: [], + reactiveModes: [], + speeds: [], + speed: null, + brightness: null, + writeOnly: true, + })); + return this.lightingZones; + } + + 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..d0a9de7 100644 --- a/src/drivers/razer/protocol.test.ts +++ b/src/drivers/razer/protocol.test.ts @@ -54,6 +54,15 @@ import { razerReadToggleControlCommand, razerSetButtonMappingCommand, razerSetToggleControlCommand, + RAZER_BACKLIGHT_BRIGHTNESS_READ, + RAZER_LED, + decodeBacklightBrightness, + razerReadExtendedBrightnessCommand, + razerSetExtendedBrightnessCommand, + razerSetExtendedEffectCommand, + razerSetBacklightBrightnessCommand, + razerSetStandardEffectCommand, + type RazerStandardEffect, } from "@openmouse/protocol/razer"; /** @@ -849,3 +858,83 @@ 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("extended-matrix effects address the led they are given, defaulting to the logo", () => { + // No capture of the Basilisk V3 exists yet, so these pin openrazer's + // razer_chroma_extended_matrix_effect_* payloads on the leds its driver + // uses for that model: ZERO_LED, SCROLL_WHEEL_LED and LOGO_LED. + const logo = razerSetExtendedEffectCommand("static", { color: "#ff8000" }); + assert.deepEqual(logo.args, [0x01, 0x04, 0x01, 0x00, 0x00, 0x01, 0xff, 0x80, 0x00]); + const all = razerSetExtendedEffectCommand("spectrum", { led: RAZER_LED.all }); + assert.deepEqual([all.commandClass, all.commandId, all.dataSize], [0x0f, 0x02, 0x06]); + assert.deepEqual(all.args, [0x01, 0x00, 0x03, 0x00, 0x00, 0x00]); + // Wave: effect 0x04, direction, openrazer's fixed 0x28 speed, declared size 6. + const wave = razerSetExtendedEffectCommand("wave", { led: RAZER_LED.scrollWheel }); + assert.equal(wave.dataSize, 0x06); + assert.deepEqual(wave.args, [0x01, 0x01, 0x04, 0x01, 0x28, 0x00]); + assert.deepEqual(razerSetExtendedEffectCommand("off", { led: RAZER_LED.logo }).args, [0x01, 0x04, 0x00, 0x00, 0x00, 0x00]); +}); + +test("extended-matrix brightness addresses one led through the storage byte", () => { + const read = razerReadExtendedBrightnessCommand(RAZER_LED.scrollWheel); + assert.deepEqual([read.commandClass, read.commandId, read.dataSize, read.args], [0x0f, 0x84, 0x03, [0x01, 0x01]]); + const write = razerSetExtendedBrightnessCommand(RAZER_LED.all, 50); + assert.deepEqual([write.commandClass, write.commandId, write.dataSize, write.args], [0x0f, 0x04, 0x03, [0x01, 0x00, 0x80]]); + assert.equal(decodeBacklightBrightness(Uint8Array.of(0x01, 0x00, 0x80)), 50); + assert.throws(() => razerSetExtendedBrightnessCommand(RAZER_LED.all, 101), RazerProtocolError); +}); + +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..5a5c738 100644 --- a/src/razer/codec.ts +++ b/src/razer/codec.ts @@ -80,8 +80,18 @@ export interface RazerCommand { */ export const RAZER_STORAGE = 0x01; -/** The underglow's led id in openrazer's extended-matrix family. */ -const RAZER_LED_LOGO = 0x04; +/** + * Led ids in openrazer's extended-matrix family (`razercommon.h`). `all` is + * `ZERO_LED`: one write drives every zone the mouse has. + */ +export const RAZER_LED = { + all: 0x00, + scrollWheel: 0x01, + logo: 0x04, +} as const; + +/** The underglow's led id on the single-zone Cobra and Viper Mini. */ +const RAZER_LED_LOGO = RAZER_LED.logo; /** Read-only commands confirmed against Viper V3 Pro firmware 1.12. */ export const RAZER_READ = { @@ -254,6 +264,7 @@ export type RazerExtendedEffect = | "off" | "static" | "spectrum" + | "wave" | "reactive" | "breathing-random" | "breathing-single" @@ -264,6 +275,7 @@ export const RAZER_EFFECT = { off: 0x00, static: 0x01, spectrum: 0x03, + wave: 0x04, reactive: 0x05, "breathing-random": 0x02, "breathing-single": 0x02, @@ -283,14 +295,16 @@ export function parseRazerColor(hex: string): [number, number, number] { } /** - * Extended effect command for the Viper Mini's extended-matrix family (`0x0f` / - * `0x02`), matching openrazer's `razer_chroma_extended_matrix_effect_*` - * functions, which this mouse dispatches through with `VARSTORE` and the logo - * led. The mouse's one 1x1 matrix means a single underglow zone. + * Extended effect command for the extended-matrix family (`0x0f` / `0x02`), + * matching openrazer's `razer_chroma_extended_matrix_effect_*` functions, + * which it dispatches through with `VARSTORE` and a led id. Single-zone mice + * (Viper Mini, Cobra) take the default logo led; multi-zone ones (Basilisk V3) + * name the zone through `options.led`. * - * All effects share the `[VARSTORE, logo, effect]` header. Breathing variants - * differ only in the colour count byte and payload length, and reactive adds a - * speed level between the header and its single colour. + * All effects share the `[VARSTORE, led, effect]` header. Breathing variants + * differ only in the colour count byte and payload length, reactive adds a + * speed level between the header and its single colour, and wave carries a + * direction and openrazer's fixed speed byte. */ export function razerSetExtendedEffectCommand( effect: RazerExtendedEffect, @@ -298,15 +312,21 @@ export function razerSetExtendedEffectCommand( color?: string; color2?: string; speed?: RazerReactiveSpeed; + led?: number; } = {}, ): RazerCommand { - const args: number[] = [RAZER_STORAGE, RAZER_LED_LOGO, RAZER_EFFECT[effect]]; + const args: number[] = [RAZER_STORAGE, options.led ?? RAZER_LED_LOGO, RAZER_EFFECT[effect]]; switch (effect) { case "off": case "spectrum": case "breathing-random": args.push(0x00, 0x00, 0x00); break; + case "wave": + // openrazer: arguments[3] = direction, [4] = 0x28 "speed, lower is + // faster", declared size 6. + args.push(WAVE_DIRECTION, WAVE_SPEED, 0x00); + break; case "static": if (!options.color) throw new RazerProtocolError(`${effect} needs a colour.`); args.push(0x00, 0x00, 0x01, ...parseRazerColor(options.color)); @@ -333,6 +353,155 @@ 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 extended wave's speed byte; openrazer always sends this value. */ +const WAVE_SPEED = 0x28; + +/** 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. + * Both brightness families answer the level in the third byte. + */ +export function decodeBacklightBrightness(args: Uint8Array): number { + return Math.round((args[2] * 100) / BRIGHTNESS_SCALE); +} + +/** + * Extended-matrix brightness read (`0x0f`/`0x84`) for one led, from openrazer's + * `razer_chroma_extended_matrix_get_brightness`; the level answers in the + * third byte like the backlight pair above. + */ +export function razerReadExtendedBrightnessCommand(led: number): RazerCommand { + return { commandClass: 0x0f, commandId: 0x84, dataSize: 0x03, args: [RAZER_STORAGE, led] }; +} + +/** Extended-matrix brightness write (`0x0f`/`0x04`), same layout as the read. */ +export function razerSetExtendedBrightnessCommand(led: number, 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: 0x0f, + commandId: 0x04, + dataSize: 0x03, + args: [RAZER_STORAGE, led, Math.round((percent * BRIGHTNESS_SCALE) / 100)], + }; +} + export class RazerProtocolError extends Error { readonly status: number | null; /** diff --git a/src/razer/devices.ts b/src/razer/devices.ts index 8b7fe3c..bbf262b 100644 --- a/src/razer/devices.ts +++ b/src/razer/devices.ts @@ -91,6 +91,24 @@ 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; + /** + * The extended-matrix lighting commands (class `0x0f`: effect `0x02` and + * the brightness pair `0x04`/`0x84`) on transaction id `0x1f`, addressed per + * led: all, scroll wheel and logo. OpenRazer drives the Basilisk V3 family + * this way. Same caveat as `standardMatrixLighting`: the effect write has no + * read-back, so only a hardware run proves the mouse obeys it. + */ + extendedMatrixLighting?: 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 +459,13 @@ const PRODUCT_DEFINITIONS: ReadonlyArray<[number, Omit