diff --git a/README.md b/README.md index 8ff925b..f22f2c9 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ checklist. | Pulsar | `@openmouse/protocol/pulsar` | | Razer legacy/current | `@openmouse/protocol/razer` | | Razer V4 | `@openmouse/protocol/razer-v4` | +| SteelSeries Rival 3 (Gen 1) | `@openmouse/protocol/steelseries` | | Teevolution | `@openmouse/protocol/teevolution` | | VGN | `@openmouse/protocol/vgn` | | WLMouse | `@openmouse/protocol/wlmouse` | @@ -64,3 +65,9 @@ The Ninjutso catalog and packet layouts are derived from the JavaScript shipped by the official NinjaForce WebHID panel. They have automated transport and codec coverage, but are not marked as hardware-verified until tested on the corresponding Sora V2/V3 and TEN-family devices. + +The SteelSeries Rival 3 Gen 1 codec and driver are derived from the public +rivalcfg project, corroborated against libratbag and OpenRGB. The device is +write-only — only the firmware version can be read back — and no entry is +marked hardware-verified yet. See +[docs/steelseries-testing.md](docs/steelseries-testing.md). diff --git a/docs/steelseries-testing.md b/docs/steelseries-testing.md new file mode 100644 index 0000000..4127d21 --- /dev/null +++ b/docs/steelseries-testing.md @@ -0,0 +1,63 @@ +# SteelSeries hardware test checklist + +Test in Chrome or Edge over HTTPS. **Fully quit SteelSeries GG and the +SteelSeriesEngine background service first** — they hold the configuration +interface open and the firmware probe will time out. + +Supported identifiers (none hardware-verified yet): + +- `1038:1824` — Rival 3, pre-0.37 firmware enumeration +- `1038:184c` — Rival 3, post-v0.37.0.0 firmware enumeration + +The protocol is transcribed from the public rivalcfg project and corroborated +against libratbag's SteelSeries driver and OpenRGB's Rival 3 controller. The +config channel is hidapi interface 3; its WebHID collection shape has not been +captured, so the picker offers every interface and the driver's firmware probe +(`10 00`) is what proves the right one was chosen. A wrong interface fails +loudly — add the device again and choose another entry. + +**This device is write-only.** Nothing except the firmware version can be read +back, so every verification below is physical (pointer speed, an external rate +meter), never a read. The driver reports last-written values flagged as +unverified; that is by design. + +The Rival 3 Wireless (`1038:1830`, `1038:1872`) and Rival 3 Gen 2 +(`1038:1870`) use different, incompatible command sets and are deliberately +not claimed by this driver. + +1. Record the OS, browser, exact VID:PID, and which picker entry connected. + The first time a unit connects, paste the `device.collections` dump into + the issue or pull request — it is the missing evidence that lets the broad + per-PID filter be narrowed to a usage-page filter. +2. Confirm the firmware version the driver reads matches what SteelSeries GG + displays (briefly reopen GG to compare, then quit it again). On a + `1038:184c` unit the version is known to be in the 0.37 family, which also + settles the two-byte order that public implementations disagree on. +3. Because nothing is readable, **record the starting configuration from GG + before changing anything**: every DPI preset, the active preset, the + polling rate, and lighting. This replaces the usual "verify every readable + value" step and is what step 8 restores. +4. Change exactly one setting at a time. +5. Write a DPI value and confirm the pointer speed physically changes. Note + that the write replaces the on-device preset table with that single preset + — the DPI button will no longer cycle the old presets. That is expected. +6. Write each polling rate (125 / 250 / 500 / 1000 Hz) and verify with an + external rate meter (for example a `pointerrawupdate` tester), not by any + read. +7. Reload OpenMouse, reconnect, and confirm the firmware still reads. Then + power-cycle/replug the mouse and confirm the written DPI and polling rate + persisted physically — that is the save command (`09 00`) doing its job. +8. Restore the original presets and settings through SteelSeries GG, and + confirm GG still controls the mouse normally after OpenMouse ran. +9. Record failures, timeouts, and any unknown behavior verbatim in the issue + or pull request. Do not attach captures containing serial numbers. +10. Only after all of the above on a given product id: set that entry's + `verified` flag to `true` in `src/steelseries/devices.ts`, add the id to + the verified list at the top of this file, and record the firmware + version in the pull request. The other product id stays unverified until + it is exercised too. + +Do not test firmware flashing, factory reset, lighting, or button remapping. +The driver implements none of them, and the lighting/button commands are +documented in `src/steelseries/rival3.ts` as known-but-withheld until there is +hardware evidence and a reason to ship them. diff --git a/package.json b/package.json index bf45cd5..c256e97 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,10 @@ "types": "./dist/razer/v4.d.ts", "import": "./dist/razer/v4.js" }, + "./steelseries": { + "types": "./dist/steelseries/index.d.ts", + "import": "./dist/steelseries/index.js" + }, "./teevolution": { "types": "./dist/teevolution/index.d.ts", "import": "./dist/teevolution/index.js" diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index be69708..c237e4e 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -113,7 +113,7 @@ export type MouseLightingMode = | "Breathing dual"; export interface MouseStatus { - brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK"; + brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries"; name: string; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index c345651..23532fd 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -26,9 +26,10 @@ import { WLMouseHidClient } from "./wlmouse/hid.ts"; import { WootingHidClient } from "./wooting/hid.ts"; import { ZaunkoenigHidClient } from "./zaunkoenig/hid.ts"; import { GWolvesHidClient } from "./gwolves/hid.ts"; +import { SteelSeriesRival3HidClient } from "./steelseries/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient; +export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | SteelSeriesRival3HidClient; export interface DeviceDriver { brand: string; @@ -66,6 +67,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "WALLHACK", supports: (device) => WallhackMouseHidClient.isSupported(device), create: (device) => new WallhackMouseHidClient(device), score: () => 8 }, { brand: "WALLHACK", supports: (device) => WallhackKeyboardHidClient.isSupported(device), create: (device) => new WallhackKeyboardHidClient(device), score: () => 8 }, { brand: "G-Wolves", supports: (device) => GWolvesHidClient.isSupported(device), create: (device) => new GWolvesHidClient(device), score: () => 7 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesRival3HidClient.isSupported(device), create: (device) => new SteelSeriesRival3HidClient(device), score: () => 6 }, ]; function driverFor(device: HIDDevice): DeviceDriver | undefined { diff --git a/src/drivers/steelseries/hid.test.ts b/src/drivers/steelseries/hid.test.ts new file mode 100644 index 0000000..e5ef985 --- /dev/null +++ b/src/drivers/steelseries/hid.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesRival3HidClient } from "./hid.ts"; + +function fakeDevice(options: { productId?: number; answerFirmware?: boolean; firmware?: number[] } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x1824, + productName: "SteelSeries Rival 3", + opened: true, + collections: [], + open: async () => {}, + close: async () => {}, + sendReport: async (reportId: number, data: BufferSource) => { + const view = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : new Uint8Array(data as ArrayBuffer); + const payload = [...view]; + sent.push({ reportId, payload }); + if (payload[0] === 0x10 && payload[1] === 0x00 && options.answerFirmware !== false) { + const response = new Uint8Array(options.firmware ?? [0x25, 0x00]); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + } + }, + sendFeatureReport: async () => { throw new Error("the Rival 3 protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Rival 3 protocol does not use feature reports"); }, + addEventListener: (_type: string, attached: (event: HIDInputReportEvent) => void) => { listener = attached; }, + removeEventListener: () => { listener = null; }, + }; + return { device: device as unknown as HIDDevice, sent }; +} + +test("claims only the two Rival 3 Gen 1 product ids", () => { + const { device } = fakeDevice(); + assert.equal(SteelSeriesRival3HidClient.isSupported(device), true); + assert.equal(SteelSeriesRival3HidClient.isSupported({ ...device, productId: 0x184c } as HIDDevice), true); + // Documented different-protocol siblings: Rival 3 Wireless, Gen 2, Wireless Gen 2. + assert.equal(SteelSeriesRival3HidClient.isSupported({ ...device, productId: 0x1830 } as HIDDevice), false); + assert.equal(SteelSeriesRival3HidClient.isSupported({ ...device, productId: 0x1870 } as HIDDevice), false); + assert.equal(SteelSeriesRival3HidClient.isSupported({ ...device, productId: 0x1872 } as HIDDevice), false); + assert.equal(SteelSeriesRival3HidClient.isSupported({ ...device, productId: 0xffff } as HIDDevice), false); + assert.equal(SteelSeriesRival3HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus probes firmware and never claims to have read settings", async () => { + const { device, sent } = fakeDevice(); + const status = await new SteelSeriesRival3HidClient(device).readStatus(); + assert.deepEqual(sent, [{ reportId: 0, payload: [0x10, 0x00] }]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Rival 3"); + assert.deepEqual(status.firmware, ["37.0"]); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.batteryPercent, null); + // rivalcfg defaults, flagged as assumptions rather than device readings. + assert.equal(status.dpi, 800); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.ui?.valuesVerified, false); + assert.ok(status.ui?.pollingNote); +}); + +test("a silent interface fails the probe loudly and names SteelSeries GG", async () => { + const { device, sent } = fakeDevice({ answerFirmware: false }); + await assert.rejects(new SteelSeriesRival3HidClient(device).readStatus(), /SteelSeries GG/); + // The probe was the only report sent; nothing else was attempted blind. + assert.deepEqual(sent, [{ reportId: 0, payload: [0x10, 0x00] }]); +}); + +test("setters write the value then the save command, all on report id 0", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival3HidClient(device); + assert.equal(await client.setDpi(1600), 1600); + assert.equal(await client.setPollingRate(500), 500); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x0b, 0x00, 0x01, 0x01, 0x24] }, + { reportId: 0, payload: [0x09, 0x00] }, + { reportId: 0, payload: [0x04, 0x00, 0x02] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + const status = await client.readStatus(); + assert.equal(status.dpi, 1600); + assert.equal(status.pollingRateHz, 500); +}); + +test("invalid values are rejected before any report reaches the mouse", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival3HidClient(device); + await assert.rejects(client.setDpi(850), /100 DPI steps/); + await assert.rejects(client.setPollingRate(2000), /125, 250, 500, or 1000 Hz/); + assert.deepEqual(sent, []); +}); + +test("concurrent setters never interleave their write/save pairs", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival3HidClient(device); + await Promise.all([client.setDpi(400), client.setPollingRate(125)]); + assert.deepEqual(sent.map(({ payload }) => payload), [ + [0x0b, 0x00, 0x01, 0x01, 0x08], + [0x09, 0x00], + [0x04, 0x00, 0x04], + [0x09, 0x00], + ]); +}); diff --git a/src/drivers/steelseries/hid.ts b/src/drivers/steelseries/hid.ts new file mode 100644 index 0000000..151896a --- /dev/null +++ b/src/drivers/steelseries/hid.ts @@ -0,0 +1,185 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + RIVAL3_POLLING_RATES, + STEELSERIES_PRODUCTS, + STEELSERIES_REPORT_ID, + STEELSERIES_VENDOR_ID, + steelseriesRival3DecodeFirmware, + steelseriesRival3DpiOptions, + steelseriesRival3EncodeDpiPresets, + steelseriesRival3EncodePollingRate, + steelseriesRival3FirmwareQuery, + steelseriesRival3SaveCommand, + type SteelSeriesRival3Firmware, +} from "@openmouse/protocol/steelseries"; + +/** rivalcfg sleeps 50 ms after every command (`command_approve_delay`) and + * cites a SteelSeries mouse crashing when driven faster. */ +const COMMAND_DELAY_MS = 50; +/** rivalcfg reads the firmware response with a 200 ms timeout; allow slack. */ +const FIRMWARE_TIMEOUT_MS = 500; +/** rivalcfg profile defaults, shown only until this session writes a value. */ +const DEFAULT_DPI = 800; +const DEFAULT_POLLING_HZ = 1000; + +/** + * SteelSeries Rival 3 Gen 1 WebHID control (`1038:1824`, `1038:184C`). + * + * The device is write-only: settings are sent as unnumbered output reports and + * nothing can be read back except the two-byte firmware version, which this + * driver uses as its connectivity probe. `readStatus` therefore reports the + * session's last-written values (or rivalcfg's documented defaults before any + * write) with `valuesVerified: false` — it never pretends to have read them. + * Every setter follows its write with the save command so the change persists + * in the mouse's onboard memory, mirroring rivalcfg's CLI default. + * + * The config channel is hidapi interface 3; its WebHID collection shape has + * not been captured yet, so the picker offers every interface and the wrong + * ones fail the firmware probe loudly. + */ +export class SteelSeriesRival3HidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private listenerAttached = false; + private readonly inputWaiters = new Set<(payload: Uint8Array) => void>(); + private lastWritten: { dpi: number | null; pollingRateHz: number | null } = { + dpi: null, + pollingRateHz: null, + }; + + private readonly onInputReport = (event: HIDInputReportEvent): void => { + const payload = new Uint8Array( + event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength), + ); + for (const finish of [...this.inputWaiters]) finish(payload); + }; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + return device.vendorId === STEELSERIES_VENDOR_ID && STEELSERIES_PRODUCTS.has(device.productId); + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...RIVAL3_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesRival3DpiOptions(); } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + if (!this.listenerAttached) { + this.device.addEventListener("inputreport", this.onInputReport); + this.listenerAttached = true; + } + } + + async close(): Promise { + if (this.listenerAttached) { + this.device.removeEventListener("inputreport", this.onInputReport); + this.listenerAttached = false; + } + if (this.device.opened) await this.device.close(); + } + + async readStatus(): Promise { + return await this.run(async () => { + await this.open(); + const firmware = await this.probeFirmware(); + const product = STEELSERIES_PRODUCTS.get(this.device.productId); + const name = this.device.productName?.trim() || `SteelSeries ${product?.model ?? "Rival 3"}`; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-rival3", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: "The Rival 3 cannot report its current settings; values shown are the last written by this app, or assumed defaults.", + defaultDisplayName: "SteelSeries Rival 3", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: this.lastWritten.dpi ?? DEFAULT_DPI, + pollingRateHz: this.lastWritten.pollingRateHz ?? DEFAULT_POLLING_HZ, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + connectionType: "Wired", + liftOffDistance: null, + firmware: [firmware.display], + }; + }); + } + + /** + * Replaces the mouse's DPI preset table with a single preset. The device is + * write-only, so the existing presets cannot be read and preserved — record + * them in SteelSeries GG before testing, per docs/steelseries-testing.md. + */ + async setDpi(dpi: number): Promise { + const report = steelseriesRival3EncodeDpiPresets([dpi], 0); + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival3SaveCommand()); + this.lastWritten.dpi = dpi; + }); + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + const report = steelseriesRival3EncodePollingRate(pollingRateHz); + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival3SaveCommand()); + this.lastWritten.pollingRateHz = pollingRateHz; + }); + return pollingRateHz; + } + + /** + * The `10 00` firmware query is the device's only readable value, so it + * doubles as the proof that the granted interface is the config channel. + */ + private async probeFirmware(): Promise { + const response = new Promise((resolve) => { + let timer: ReturnType; + const finish = (payload: Uint8Array | null): void => { + clearTimeout(timer); + this.inputWaiters.delete(finish as (payload: Uint8Array) => void); + resolve(payload); + }; + timer = setTimeout(() => finish(null), FIRMWARE_TIMEOUT_MS); + this.inputWaiters.add(finish as (payload: Uint8Array) => void); + }); + await this.write(steelseriesRival3FirmwareQuery()); + const payload = await response; + if (!payload) { + throw new Error( + "The Rival 3 did not answer on this interface. Close SteelSeries GG (and the SteelSeriesEngine service); if it still does not answer, add the device again and choose another entry.", + ); + } + return steelseriesRival3DecodeFirmware(payload); + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(STEELSERIES_REPORT_ID, payload.buffer as ArrayBuffer); + } + + private delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); + } + + private async run(operation: () => Promise): Promise { + const result = this.queue.then(operation, operation); + this.queue = result.then(() => undefined, () => undefined); + return await result; + } +} diff --git a/src/drivers/steelseries/protocol.test.ts b/src/drivers/steelseries/protocol.test.ts new file mode 100644 index 0000000..2b22551 --- /dev/null +++ b/src/drivers/steelseries/protocol.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RIVAL3_DPI_MAX, + RIVAL3_DPI_MIN, + RIVAL3_DPI_STEP, + RIVAL3_MAX_DPI_PRESETS, + SteelSeriesProtocolError, + TRUEMOVE_CORE_DPI_TO_BYTE, + steelseriesRival3DecodeFirmware, + steelseriesRival3DpiOptions, + steelseriesRival3EncodeDpiPresets, + steelseriesRival3EncodePollingRate, + steelseriesRival3FirmwareQuery, + steelseriesRival3SaveCommand, +} from "../../steelseries/index.ts"; + +test("the TrueMove Core table matches every value rivalcfg quotes", () => { + // Anchors quoted verbatim in rivalcfg's devices/dpi/truemove_core.py. + const quoted: Array<[number, number]> = [ + [200, 0x04], [300, 0x06], [400, 0x08], [500, 0x0b], [600, 0x0d], [700, 0x0f], + [800, 0x12], [900, 0x14], [1000, 0x16], [1100, 0x19], [1200, 0x1b], + [1600, 0x24], [8500, 0xc5], + ]; + for (const [dpi, byte] of quoted) { + assert.equal(TRUEMOVE_CORE_DPI_TO_BYTE.get(dpi), byte, `${dpi} DPI`); + } +}); + +test("the table spans 200–8,500 in 100 DPI steps with strictly increasing bytes", () => { + const options = steelseriesRival3DpiOptions(); + assert.equal(options.length, (RIVAL3_DPI_MAX - RIVAL3_DPI_MIN) / RIVAL3_DPI_STEP + 1); + assert.equal(options[0], RIVAL3_DPI_MIN); + assert.equal(options.at(-1), RIVAL3_DPI_MAX); + let previousByte = -1; + for (const [index, dpi] of options.entries()) { + assert.equal(dpi, RIVAL3_DPI_MIN + index * RIVAL3_DPI_STEP); + const byte = TRUEMOVE_CORE_DPI_TO_BYTE.get(dpi)!; + assert.ok(byte > previousByte, `byte for ${dpi} DPI must exceed the previous entry`); + previousByte = byte; + } +}); + +test("encodes rivalcfg's default two-preset configuration byte for byte", () => { + // rivalcfg default `sensitivity: "800, 1600"`, first preset selected. + assert.deepEqual( + [...steelseriesRival3EncodeDpiPresets([800, 1600], 0)], + [0x0b, 0x00, 0x02, 0x01, 0x12, 0x24], + ); + assert.deepEqual([...steelseriesRival3EncodeDpiPresets([800], 0)], [0x0b, 0x00, 0x01, 0x01, 0x12]); + assert.deepEqual( + [...steelseriesRival3EncodeDpiPresets([200, 400, 800, 1600, 8500], 4)], + [0x0b, 0x00, 0x05, 0x05, 0x04, 0x08, 0x12, 0x24, 0xc5], + ); +}); + +test("rejects off-grid DPI, bad preset counts, and bad selected indices", () => { + assert.throws(() => steelseriesRival3EncodeDpiPresets([850], 0), SteelSeriesProtocolError); + assert.throws(() => steelseriesRival3EncodeDpiPresets([250], 0), /100 DPI steps/); + assert.throws(() => steelseriesRival3EncodeDpiPresets([150], 0), SteelSeriesProtocolError); + assert.throws(() => steelseriesRival3EncodeDpiPresets([8600], 0), SteelSeriesProtocolError); + assert.throws(() => steelseriesRival3EncodeDpiPresets([], 0), /1–5 DPI presets/); + assert.throws( + () => steelseriesRival3EncodeDpiPresets([800, 800, 800, 800, 800, 800], 0), + new RegExp(`1–${RIVAL3_MAX_DPI_PRESETS} DPI presets`), + ); + assert.throws(() => steelseriesRival3EncodeDpiPresets([800, 1600], -1), SteelSeriesProtocolError); + assert.throws(() => steelseriesRival3EncodeDpiPresets([800, 1600], 2), SteelSeriesProtocolError); +}); + +test("encodes every polling rate and rejects rates the mouse does not offer", () => { + assert.deepEqual([...steelseriesRival3EncodePollingRate(1000)], [0x04, 0x00, 0x01]); + assert.deepEqual([...steelseriesRival3EncodePollingRate(500)], [0x04, 0x00, 0x02]); + assert.deepEqual([...steelseriesRival3EncodePollingRate(250)], [0x04, 0x00, 0x03]); + assert.deepEqual([...steelseriesRival3EncodePollingRate(125)], [0x04, 0x00, 0x04]); + assert.throws(() => steelseriesRival3EncodePollingRate(2000), SteelSeriesProtocolError); + assert.throws(() => steelseriesRival3EncodePollingRate(0), SteelSeriesProtocolError); +}); + +test("frames the save and firmware commands", () => { + assert.deepEqual([...steelseriesRival3SaveCommand()], [0x09, 0x00]); + assert.deepEqual([...steelseriesRival3FirmwareQuery()], [0x10, 0x00]); +}); + +test("decodes the firmware response and rejects truncated payloads", () => { + const firmware = steelseriesRival3DecodeFirmware(new Uint8Array([0x25, 0x00])); + assert.deepEqual(firmware.bytes, [37, 0]); + assert.equal(firmware.display, "37.0"); + assert.throws(() => steelseriesRival3DecodeFirmware(new Uint8Array([])), SteelSeriesProtocolError); + assert.throws(() => steelseriesRival3DecodeFirmware(new Uint8Array([0x25])), /shorter than two bytes/); +}); + +test("encoders return fresh buffers and never mutate the caller's presets", () => { + const presets = [800, 1600]; + const first = steelseriesRival3EncodeDpiPresets(presets, 0); + const second = steelseriesRival3EncodeDpiPresets(presets, 0); + assert.notEqual(first.buffer, second.buffer); + assert.deepEqual([...first], [...second]); + assert.deepEqual(presets, [800, 1600]); +}); diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index cc1ba3a..438be82 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -24,6 +24,10 @@ import { WOOTING_PRODUCT_IDS, WOOTING_VENDOR_ID, } from "@openmouse/protocol/wooting"; +import { + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, +} from "@openmouse/protocol/steelseries"; import { WALLHACK_KEYBOARD_ALT_VENDOR_ID, WALLHACK_KEYBOARD_PRODUCT_IDS, @@ -60,8 +64,23 @@ export const VENDOR_ID = { wallhack: WALLHACK_VENDOR_ID, wallhackKeyboardAlt: WALLHACK_KEYBOARD_ALT_VENDOR_ID, gwolves: 0x33e4, + steelseries: STEELSERIES_VENDOR_ID, } as const; +/** + * SteelSeries ships keyboards, headsets, and USB audio under 0x1038, so there + * must never be a VID-only SteelSeries filter. The Rival 3 Gen 1's config + * channel is hidapi interface 3, whose WebHID collection shape has not been + * captured yet, so the whole device is requested per product id and the + * picker offers each interface; the driver's firmware probe fails loudly on + * the ones that never answer (add the device again and choose another entry). + * Narrow these to a usage-page filter once the collection dump is recorded in + * docs/steelseries-testing.md. + */ +export const STEELSERIES_RIVAL3_FILTERS: HIDDeviceFilter[] = [...STEELSERIES_PRODUCTS.keys()].map( + (productId) => ({ vendorId: STEELSERIES_VENDOR_ID, productId }), +); + // Keychron VIA raw HID. 0x0440 is Nape Pro wired; 0xd026/0xd029 are shared Link-KM receivers. export const KEYCHRON_PRODUCT_IDS = [0x0440, 0xd026, 0xd029] as const; @@ -300,4 +319,5 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...WALLHACK_HID_FILTERS, { vendorId: VENDOR_ID.gwolves, productId: 0x5618, usagePage: 0xff02 }, { vendorId: VENDOR_ID.gwolves, productId: 0x3854, usagePage: 0xff02 }, + ...STEELSERIES_RIVAL3_FILTERS, ]; diff --git a/src/index.ts b/src/index.ts index d325b8b..9479999 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export * as pulsar from "./pulsar/index.js"; export * as razer from "./razer/index.js"; export * as razerDevices from "./razer/devices.js"; export * as razerV4 from "./razer/v4.js"; +export * as steelseries from "./steelseries/index.js"; export * as teevolution from "./teevolution/index.js"; export * as vgn from "./vgn/index.js"; export * as wlmouse from "./wlmouse/index.js"; diff --git a/src/steelseries/devices.ts b/src/steelseries/devices.ts new file mode 100644 index 0000000..ca7e338 --- /dev/null +++ b/src/steelseries/devices.ts @@ -0,0 +1,59 @@ +/** + * SteelSeries per-PID catalog. + * + * SteelSeries is a multi-family vendor: rivalcfg alone documents four + * incompatible Rival 3 command sets (Gen 1, Gen 2, Wireless, Wireless Gen 2), + * and libratbag distinguishes four more protocol versions for older mice. The + * `family` field selects which codec module drives a product; command bytes, + * framing, and value tables live in that module, never here, and no family's + * codec branches on product id. + * + * Only PIDs whose protocol has been traced to a public implementation belong + * here. The Rival 3 Wireless (`0x1830`, `0x1872`) and Rival 3 Gen 2 (`0x1870`) + * are deliberately absent: their command sets are documented as different, and + * listing them would claim devices this codec would misprogram. + */ + +export const STEELSERIES_VENDOR_ID = 0x1038; + +export type SteelSeriesProtocolFamily = "rival3"; + +export interface SteelSeriesProduct { + model: string; + /** Selects the codec module. Never infer one family's commands from another. */ + family: SteelSeriesProtocolFamily; + wireless: boolean; + /** + * No public implementation has a settings getter for this family — rivalcfg + * keeps a local JSON mirror because the mouse cannot be asked. Typed as the + * literal `false` so a future readable family forces a conscious widening + * rather than silently defaulting reads on. + */ + settingsReadable: false; + /** The `10 00` firmware query; the Gen 2 profile has no firmware command. */ + hasFirmwareQuery: boolean; + /** True only after this exact product id was exercised on real hardware. */ + verified: boolean; +} + +export const STEELSERIES_PRODUCTS: ReadonlyMap = new Map([ + // Pre-0.37 firmware enumeration. + [0x1824, { + model: "Rival 3", + family: "rival3", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + // The same mouse re-enumerated after the v0.37.0.0 firmware update; rivalcfg + // lists both ids against one profile and OpenRGB names 0x1824 "Old Firmware". + [0x184c, { + model: "Rival 3", + family: "rival3", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], +]); diff --git a/src/steelseries/index.ts b/src/steelseries/index.ts new file mode 100644 index 0000000..b2bb019 --- /dev/null +++ b/src/steelseries/index.ts @@ -0,0 +1,2 @@ +export * from "./rival3.js"; +export * from "./devices.js"; diff --git a/src/steelseries/rival3.ts b/src/steelseries/rival3.ts new file mode 100644 index 0000000..a2b8c90 --- /dev/null +++ b/src/steelseries/rival3.ts @@ -0,0 +1,174 @@ +/** + * SteelSeries Rival 3 Gen 1 configuration protocol — pure encode/decode helpers. + * + * Reconstructed from public open-source implementations, not vendor software: + * + * - rivalcfg `rivalcfg/devices/rival3.py`, `rivalcfg/handlers/multidpi_range_choice.py`, + * `rivalcfg/devices/dpi/truemove_core.py`, `rivalcfg/mouse.py`, `rivalcfg/usbhid.py` + * (https://github.com/flozz/rivalcfg) — the primary source for every byte here. + * - libratbag `src/driver-steelseries.c` — corroborates the polling values, the + * save id, the firmware id, and that SteelSeries uses unnumbered reports. + * - OpenRGB `SteelSeriesRival3Controller.cpp` — corroborates interface 3, the + * firmware query, and that short unpadded writes are accepted. + * + * Every command is an HID **output report** with report id 0x00 on the vendor + * configuration interface (hidapi `interface_number == 3`). The functions here + * build the report *payload* — the bytes after the report id, which the WebHID + * driver passes to `sendReport(0x00, …)` separately. rivalcfg sends these + * frames unpadded, and that is what this codec produces. + * + * **This device is write-only.** No public implementation has a getter for + * DPI, polling, lighting, or buttons: rivalcfg mirrors state into a local JSON + * file because the mouse cannot be asked, and libratbag flags its SteelSeries + * profiles `RATBAG_PROFILE_CAP_WRITE_ONLY`. The single readable value is the + * two-byte firmware version behind command `10 00`. + * + * Settings apply immediately; the save command (`09 00`) commits them to + * onboard flash (rivalcfg's `--no-save`: "Do not persist settings in the + * internal device memory"). Which half of that is volatile has not been + * confirmed on hardware yet. + * + * Known but deliberately not implemented — documented so nobody re-derives + * them, withheld until there is a reason and hardware evidence to ship them: + * + * - `05 00 ` — zone color (zone 0 = all) + * - `06 00 <0x00–0x06>` — lighting effect (steady = 0x04) + * - `07 00` + 8 × 2-byte fields — button mapping + * + * None of this has been verified on physical hardware by this project. + * The Rival 3 Gen 2 (`1038:1870`) and Rival 3 Wireless (`1038:1830`) use + * different, incompatible command sets and must not reuse this module. + */ + +export const STEELSERIES_REPORT_ID = 0x00; + +/** Two-byte command prefixes; the payload is the prefix plus its arguments. */ +export const RIVAL3_COMMAND = { + pollingRate: [0x04, 0x00], + save: [0x09, 0x00], + dpiPresets: [0x0b, 0x00], + firmware: [0x10, 0x00], +} as const; + +export const RIVAL3_POLLING_RATES = [125, 250, 500, 1000] as const; + +const POLLING_RATE_TO_BYTE: ReadonlyMap = new Map([ + [1000, 0x01], + [500, 0x02], + [250, 0x03], + [125, 0x04], +]); + +export const RIVAL3_DPI_MIN = 200; +export const RIVAL3_DPI_MAX = 8500; +export const RIVAL3_DPI_STEP = 100; +export const RIVAL3_MAX_DPI_PRESETS = 5; +export const RIVAL3_FIRMWARE_RESPONSE_LENGTH = 2; + +/** + * rivalcfg's TrueMove Core sensor table (`devices/dpi/truemove_core.py`): + * DPI is sent as one byte from this lookup, not as an integer. The source + * ships it as a literal table with no closed-form formula; the increments + * follow a repeating +2/+2/+3 pattern, and the values rivalcfg quotes + * (200→0x04 … 1200→0x1b, 1600→0x24, 8500→0xc5) are pinned in protocol.test.ts. + */ +export const TRUEMOVE_CORE_DPI_TO_BYTE: ReadonlyMap = new Map([ + [200, 0x04], [300, 0x06], [400, 0x08], [500, 0x0b], [600, 0x0d], [700, 0x0f], + [800, 0x12], [900, 0x14], [1000, 0x16], [1100, 0x19], [1200, 0x1b], [1300, 0x1d], + [1400, 0x20], [1500, 0x22], [1600, 0x24], [1700, 0x27], [1800, 0x29], [1900, 0x2b], + [2000, 0x2e], [2100, 0x30], [2200, 0x32], [2300, 0x35], [2400, 0x37], [2500, 0x39], + [2600, 0x3c], [2700, 0x3e], [2800, 0x40], [2900, 0x43], [3000, 0x45], [3100, 0x47], + [3200, 0x4a], [3300, 0x4c], [3400, 0x4e], [3500, 0x51], [3600, 0x53], [3700, 0x55], + [3800, 0x58], [3900, 0x5a], [4000, 0x5c], [4100, 0x5f], [4200, 0x61], [4300, 0x63], + [4400, 0x66], [4500, 0x68], [4600, 0x6a], [4700, 0x6d], [4800, 0x6f], [4900, 0x71], + [5000, 0x74], [5100, 0x76], [5200, 0x78], [5300, 0x7b], [5400, 0x7d], [5500, 0x7f], + [5600, 0x82], [5700, 0x84], [5800, 0x86], [5900, 0x89], [6000, 0x8b], [6100, 0x8d], + [6200, 0x90], [6300, 0x92], [6400, 0x94], [6500, 0x97], [6600, 0x99], [6700, 0x9b], + [6800, 0x9e], [6900, 0xa0], [7000, 0xa2], [7100, 0xa5], [7200, 0xa7], [7300, 0xa9], + [7400, 0xac], [7500, 0xae], [7600, 0xb0], [7700, 0xb3], [7800, 0xb5], [7900, 0xb7], + [8000, 0xba], [8100, 0xbc], [8200, 0xbe], [8300, 0xc1], [8400, 0xc3], [8500, 0xc5], +]); + +export class SteelSeriesProtocolError extends Error {} + +/** The 84 DPI values the sensor table can express, ascending. */ +export function steelseriesRival3DpiOptions(): number[] { + return [...TRUEMOVE_CORE_DPI_TO_BYTE.keys()].sort((a, b) => a - b); +} + +/** + * `0B 00 ` — replaces the mouse's whole preset + * table. `selectedIndex` is 0-based here and encoded 1-based on the wire. + * + * DPI values must be exact table keys. rivalcfg rounds a request to the + * nearest table entry; this codec rejects off-grid values instead, matching + * how the rest of this package validates DPI, so a caller is never silently + * given a different sensitivity than it asked for. + */ +export function steelseriesRival3EncodeDpiPresets( + presets: readonly number[], + selectedIndex: number, +): Uint8Array { + if (presets.length < 1 || presets.length > RIVAL3_MAX_DPI_PRESETS) { + throw new SteelSeriesProtocolError( + `SteelSeries Rival 3 supports 1–${RIVAL3_MAX_DPI_PRESETS} DPI presets.`, + ); + } + if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= presets.length) { + throw new SteelSeriesProtocolError( + `Selected DPI preset must be an index into the ${presets.length} presets being written.`, + ); + } + const encoded = presets.map((dpi) => { + const byte = TRUEMOVE_CORE_DPI_TO_BYTE.get(dpi); + if (byte === undefined) { + throw new SteelSeriesProtocolError( + `SteelSeries Rival 3 DPI must be ${RIVAL3_DPI_MIN}–${RIVAL3_DPI_MAX.toLocaleString()} in ${RIVAL3_DPI_STEP} DPI steps.`, + ); + } + return byte; + }); + return new Uint8Array([...RIVAL3_COMMAND.dpiPresets, presets.length, selectedIndex + 1, ...encoded]); +} + +/** `04 00 ` with 1000→0x01, 500→0x02, 250→0x03, 125→0x04. */ +export function steelseriesRival3EncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new SteelSeriesProtocolError("SteelSeries Rival 3 supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...RIVAL3_COMMAND.pollingRate, byte]); +} + +/** `09 00` — commit the current settings to onboard flash. */ +export function steelseriesRival3SaveCommand(): Uint8Array { + return new Uint8Array(RIVAL3_COMMAND.save); +} + +/** `10 00` — the device answers with a two-byte input report. */ +export function steelseriesRival3FirmwareQuery(): Uint8Array { + return new Uint8Array(RIVAL3_COMMAND.firmware); +} + +export interface SteelSeriesRival3Firmware { + /** The two raw response bytes, in the order the device sent them. */ + bytes: [number, number]; + /** The bytes joined in read order, e.g. "37.0". */ + display: string; +} + +/** + * Decode the two-byte firmware response. The byte order is contested between + * public implementations — libratbag reads [minor, major], current rivalcfg + * joins the bytes in read order, and OpenRGB treats them as a little-endian + * word — so both raw bytes are returned and `display` follows rivalcfg's + * read-order behavior until hardware (a `1038:184C` unit, known to be firmware + * v0.37.0.0) settles which byte is which. + */ +export function steelseriesRival3DecodeFirmware(payload: Uint8Array): SteelSeriesRival3Firmware { + if (payload.length < RIVAL3_FIRMWARE_RESPONSE_LENGTH) { + throw new SteelSeriesProtocolError("SteelSeries Rival 3 firmware response is shorter than two bytes."); + } + const bytes: [number, number] = [payload[0]!, payload[1]!]; + return { bytes, display: `${bytes[0]}.${bytes[1]}` }; +} diff --git a/tsconfig.json b/tsconfig.json index 7dea2e2..739db85 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ "@openmouse/protocol/razer": ["./src/razer/index.ts"], "@openmouse/protocol/razer-devices": ["./src/razer/devices.ts"], "@openmouse/protocol/razer-v4": ["./src/razer/v4.ts"], + "@openmouse/protocol/steelseries": ["./src/steelseries/index.ts"], "@openmouse/protocol/teevolution": ["./src/teevolution/index.ts"], "@openmouse/protocol/vgn": ["./src/vgn/index.ts"], "@openmouse/protocol/wallhack": ["./src/wallhack/index.ts"],