diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..7de5924f 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -47,6 +47,7 @@ import { GWolvesHidClient } from "./gwolves/hid.ts"; import { GWolvesXviHidClient } from "./gwolves/xvi-hid.ts"; import { GWolvesXviNewHidClient } from "./gwolves/xvi-new-hid.ts"; import { SteelSeriesRival3HidClient } from "./steelseries/hid.ts"; +import { SteelSeriesRival300HidClient } from "./steelseries/rival-300-hid.ts"; import { SteelSeriesAerox3HidClient } from "./steelseries/aerox3-hid.ts"; import { SteelSeriesAerox3WirelessHidClient } from "./steelseries/aerox3-wireless-hid.ts"; import { SteelSeriesRival3WirelessHidClient } from "./steelseries/rival3-wireless-hid.ts"; @@ -83,8 +84,7 @@ import { AjazzHidClient } from "./ajazz/hid.ts"; import { AjazzAk820HidClient } from "./ajazz/ak820-hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient; - +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesRival300HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient; export interface DeviceDriver { brand: string; supports(device: HIDDevice): boolean; @@ -165,6 +165,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "G-Wolves", supports: (device) => GWolvesXviHidClient.isSupported(device), create: (device) => new GWolvesXviHidClient(device), score: () => 7 }, { brand: "G-Wolves", supports: (device) => GWolvesXviNewHidClient.isSupported(device), create: (device) => new GWolvesXviNewHidClient(device), score: () => 7 }, { brand: "SteelSeries", supports: (device) => SteelSeriesRival3HidClient.isSupported(device), create: (device) => new SteelSeriesRival3HidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesRival300HidClient.isSupported(device), create: (device) => new SteelSeriesRival300HidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesAerox3HidClient.isSupported(device), create: (device) => new SteelSeriesAerox3HidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesAerox3WirelessHidClient.isSupported(device), create: (device) => new SteelSeriesAerox3WirelessHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesRival3WirelessHidClient.isSupported(device), create: (device) => new SteelSeriesRival3WirelessHidClient(device), score: () => 6 }, diff --git a/src/drivers/steelseries/rival-300-hid.test.ts b/src/drivers/steelseries/rival-300-hid.test.ts new file mode 100644 index 00000000..06772c8e --- /dev/null +++ b/src/drivers/steelseries/rival-300-hid.test.ts @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesRival300HidClient } from "./rival-300-hid.ts"; + +function fakeDevice(options: { productId?: number; firmware?: number[]; activePreset?: number; answer?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const reply = (bytes: number[]) => { + const response = new Uint8Array(bytes); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + }; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x1710, + productName: "SteelSeries Rival 300", + 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 (options.answer === false) return; + if (payload[0] === 0x10 && payload[1] === 0x00) reply(options.firmware ?? [0x03, 0x01]); + if (payload[0] === 0x16 && payload[1] === 0x00) reply([options.activePreset ?? 0x01]); + }, + sendFeatureReport: async () => { throw new Error("the Rival 300 protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Rival 300 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 the Rival 300 family product ids and nothing else", () => { + const { device } = fakeDevice(); + for (const productId of [0x1710, 0x1392, 0x1394, 0x1712, 0x1714, 0x1716, 0x1718, 0x171a, 0x171c]) { + assert.equal( + SteelSeriesRival300HidClient.isSupported({ ...device, productId } as HIDDevice), + true, + `expected 0x${productId.toString(16)} to be claimed`, + ); + } + for (const productId of [0x1729, 0x1702, 0x1814, 0x1824, 0x1836]) { + assert.equal( + SteelSeriesRival300HidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesRival300HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus probes firmware and the active preset, reporting that preset's default", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x02 }); + const status = await new SteelSeriesRival300HidClient(device).readStatus(); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x10, 0x00] }, + { reportId: 0, payload: [0x16, 0x00] }, + ]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Rival 300"); + assert.deepEqual(status.firmware, ["1.3"]); + assert.equal(status.connectionType, "Wired"); + // Active preset is 2, which this session has not written, so its default is shown. + assert.equal(status.dpi, 1600); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.ui?.valuesVerified, false); + assert.ok(status.ui?.pollingNote?.includes("active preset (2)")); +}); + +test("a silent interface fails the firmware probe loudly", async () => { + const { device } = fakeDevice({ answer: false }); + const client = new SteelSeriesRival300HidClient(device); + await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); +}); + +test("setDpi writes the active preset (1 by default) plus save", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x01 }); + const client = new SteelSeriesRival300HidClient(device); + await client.readStatus(); + sent.length = 0; + const dpi = await client.setDpi(800); + assert.equal(dpi, 800); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x16, 0x00] }, + { reportId: 0, payload: [0x03, 0x01, 0x10] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); + +test("setDpi writes preset 2 when the CPI button has selected it", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x02 }); + const client = new SteelSeriesRival300HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setDpi(1600); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x16, 0x00] }, + { reportId: 0, payload: [0x03, 0x02, 0x20] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + // readStatus now reports the value written to the active preset. + sent.length = 0; + const status = await client.readStatus(); + assert.equal(status.dpi, 1600); +}); + +test("setDpiPreset2 writes and records preset 2", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x01 }); + const client = new SteelSeriesRival300HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setDpiPreset2(1600); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x03, 0x02, 0x20] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); + +test("setPollingRate, LED color/effect, and buttons mapping write plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival300HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setPollingRate(500); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x04, 0x00, 0x02] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setLedColor("wheel", 0, 0, 0xff); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x08, 0x02, 0x00, 0x00, 0xff] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setLedEffect("logo", "breath"); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x07, 0x01, 0x03] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setButtonsMapping({ button6: { type: "dpiSwitch" } }); + assert.equal(sent.length, 2); + assert.equal(sent[0]?.payload[0], 0x31); + assert.equal(sent[0]?.payload.length, 2 + 6 * 5); + assert.equal(sent[0]?.payload[2 + 5 * 5], 0x30); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x09, 0x00] }); +}); diff --git a/src/drivers/steelseries/rival-300-hid.ts b/src/drivers/steelseries/rival-300-hid.ts new file mode 100644 index 00000000..38b3227f --- /dev/null +++ b/src/drivers/steelseries/rival-300-hid.ts @@ -0,0 +1,251 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + RIVAL300_DEFAULT_DPI_PRESET_1, + RIVAL300_DEFAULT_DPI_PRESET_2, + RIVAL300_POLLING_RATES, + RIVAL300_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesRival300ActiveCpiQuery, + steelseriesRival300DecodeActivePreset, + steelseriesRival300DecodeFirmware, + steelseriesRival300DpiOptions, + steelseriesRival300EncodeButtonsMapping, + steelseriesRival300EncodeLedColor, + steelseriesRival300EncodeLedEffect, + steelseriesRival300EncodePollingRate, + steelseriesRival300EncodeSensitivity1, + steelseriesRival300EncodeSensitivity2, + steelseriesRival300FirmwareQuery, + steelseriesRival300SaveCommand, + type Rival300ButtonAction, + type Rival300ButtonName, + type Rival300Led, + type Rival300LightEffect, + type SteelSeriesRival300Firmware, +} from "@openmouse/protocol/steelseries"; + +/** rivalcfg's `command_approve_delay`, shared across every SteelSeries family. */ +const COMMAND_DELAY_MS = 50; +/** rivalcfg reads input-report replies with a 200 ms timeout; allow slack. */ +const RESPONSE_TIMEOUT_MS = 500; +const DEFAULT_POLLING_HZ = 1000; + +/** + * SteelSeries Rival 300 WebHID control (Rival 300 `1038:1710` and its + * editions Fallout 4 `1038:1712`, Acer Predator `1038:1714`, CS:GO Fade + * `1038:1394` / stm32 `1038:1716`, HP OMEN `1038:1718`, CS:GO Hyper Beast + * `1038:171A`, Evil Geniuses `1038:171C`, plus the original Rival Dota 2 + * `1038:1392` — one shared `rival_base` vendor profile; see + * `../../steelseries/rival-300.ts`). + * + * DPI (two presets, `03 01`/`03 02`), polling, both LED zones, and the six + * buttons are write-only; the two readable values are the firmware version + * (`10 00`) and the active preset index (`16 00`). This client uses the + * firmware query as its connectivity probe, matching rivalcfg's + * `rival300.py`. + * + * Because the active preset index is readable, `setDpi` writes the + * **active** preset (so a change always takes effect, even after the CPI + * button has selected preset 2) rather than always preset 1, and + * `readStatus` reports that active preset's DPI. `setDpiPreset2` writes + * preset 2 explicitly. Both presets are tracked independently; a preset + * whose value this session has not written falls back to rivalcfg's + * documented default for that preset, with `valuesVerified: false`. + * + * The config channel is hidapi interface 0; 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 SteelSeriesRival300HidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private listenerAttached = false; + private readonly inputWaiters = new Set<(payload: Uint8Array) => void>(); + /** Last DPI this session wrote to each preset, if any. */ + private readonly presetDpi: Record<1 | 2, number | null> = { 1: null, 2: null }; + private pollingRateHz: number | null = 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 { + if (device.vendorId !== STEELSERIES_VENDOR_ID) return false; + return STEELSERIES_PRODUCTS.get(device.productId)?.family === "rival-300"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...RIVAL300_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesRival300DpiOptions(); } + + 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 activePreset = await this.readActivePreset(); + const product = STEELSERIES_PRODUCTS.get(this.device.productId); + const name = this.device.productName?.trim() || `SteelSeries ${product?.model ?? "Rival 300"}`; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-rival-300", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: `The Rival 300 cannot report the DPI of a preset this app has not written; the value shown is the last written by this app for the active preset (${activePreset}), or that preset's assumed default.`, + defaultDisplayName: "SteelSeries Rival 300", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: this.dpiForPreset(activePreset), + pollingRateHz: this.pollingRateHz ?? DEFAULT_POLLING_HZ, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + connectionType: "Wired", + liftOffDistance: null, + firmware: [firmware.display], + }; + }); + } + + /** + * Writes `dpi` to the **active** preset (`03 01` or `03 02`), so the change + * takes effect even after the CPI button has switched to preset 2. The + * active index is read first; if that read fails, this falls back to + * preset 1 (the pre-existing behavior) rather than aborting the write. + */ + async setDpi(dpi: number): Promise { + await this.run(async () => { + await this.open(); + const preset = await this.readActivePreset(); + await this.write(preset === 2 ? steelseriesRival300EncodeSensitivity2(dpi) : steelseriesRival300EncodeSensitivity1(dpi)); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival300SaveCommand()); + this.presetDpi[preset] = dpi; + }); + return dpi; + } + + /** `03 02 ` — writes sensitivity preset 2 explicitly. */ + async setDpiPreset2(dpi: number): Promise { + await this.commit(steelseriesRival300EncodeSensitivity2(dpi)); + this.presetDpi[2] = dpi; + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesRival300EncodePollingRate(pollingRateHz)); + this.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + async setLedColor(led: Rival300Led, r: number, g: number, b: number): Promise { + await this.commit(steelseriesRival300EncodeLedColor(led, r, g, b)); + } + + async setLedEffect(led: Rival300Led, effect: Rival300LightEffect): Promise { + await this.commit(steelseriesRival300EncodeLedEffect(led, effect)); + } + + async setButtonsMapping(mapping: Partial>): Promise { + await this.commit(steelseriesRival300EncodeButtonsMapping(mapping)); + } + + private async commit(report: Uint8Array): Promise { + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival300SaveCommand()); + }); + } + + /** The DPI to display for a preset: last written by this session, else that preset's rivalcfg default. */ + private dpiForPreset(preset: 1 | 2): number { + return this.presetDpi[preset] ?? (preset === 2 ? RIVAL300_DEFAULT_DPI_PRESET_2 : RIVAL300_DEFAULT_DPI_PRESET_1); + } + + /** + * `10 00` — two-byte firmware version. Doubles as the proof that the + * granted interface is the config channel. + */ + private async probeFirmware(): Promise { + const payload = await this.awaitResponse(steelseriesRival300FirmwareQuery()); + if (!payload) { + throw new Error("The Rival 300 did not answer the firmware query on this interface. Add the device again and choose another entry."); + } + return steelseriesRival300DecodeFirmware(payload); + } + + /** + * `16 00` — the active preset index (1 or 2). A failed or malformed reply + * falls back to preset 1 so a DPI write is never blocked by the probe. + */ + private async readActivePreset(): Promise<1 | 2> { + const payload = await this.awaitResponse(steelseriesRival300ActiveCpiQuery()); + if (!payload) return 1; + try { + return steelseriesRival300DecodeActivePreset(payload); + } catch { + return 1; + } + } + + /** Send a query and resolve its first input report, or null on timeout. */ + private async awaitResponse(query: Uint8Array): 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), RESPONSE_TIMEOUT_MS); + this.inputWaiters.add(finish as (payload: Uint8Array) => void); + }); + await this.write(query); + return await response; + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(RIVAL300_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/steelseries/devices.ts b/src/steelseries/devices.ts index 8efdb71c..076c223f 100644 --- a/src/steelseries/devices.ts +++ b/src/steelseries/devices.ts @@ -67,6 +67,7 @@ export const STEELSERIES_VENDOR_ID = 0x1038; export type SteelSeriesProtocolFamily = | "rival3" + | "rival-300" | "rival310" | "aerox3" | "aerox3-wireless" @@ -100,6 +101,83 @@ export interface SteelSeriesProduct { } export const STEELSERIES_PRODUCTS: ReadonlyMap = new Map([ + // rival_base.lisp (vendor GG specification): the original Rival / Rival 300 + // command set — linear DPI 0x03, polling 0x04, two-zone LED 0x08/0x07, six + // remappable buttons 0x31, save 0x09, firmware 0x10. Shared by the Rival + // 300 and every edition, plus the original Rival Dota 2 — see + // ./rival-300.ts. Distinct from the Rival 100/110 command sets. + [0x1710, { + model: "Rival 300", + family: "rival-300", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1392, { + model: "Rival Dota 2 Edition", + family: "rival-300", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1394, { + model: "Rival 300 CS:GO Fade Edition", + family: "rival-300", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1712, { + model: "Rival 300 Fallout 4 Edition", + family: "rival-300", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1714, { + model: "Rival 300 Acer Predator Edition", + family: "rival-300", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1716, { + model: "Rival 300 CS:GO Fade Edition (stm32)", + family: "rival-300", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1718, { + model: "Rival 300 HP OMEN Edition", + family: "rival-300", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x171a, { + model: "Rival 300 CS:GO Hyper Beast Edition", + family: "rival-300", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x171c, { + model: "Rival 300 Evil Geniuses Edition", + family: "rival-300", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], // Pre-0.37 firmware enumeration. [0x1824, { model: "Rival 3", diff --git a/src/steelseries/index.ts b/src/steelseries/index.ts index dacc7846..8a68277f 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -1,4 +1,5 @@ export * from "./rival3.js"; +export * from "./rival-300.js"; export * from "./rival310.js"; export * from "./aerox3.js"; export * from "./aerox3-wireless.js"; diff --git a/src/steelseries/rival-300.test.ts b/src/steelseries/rival-300.test.ts new file mode 100644 index 00000000..b434b003 --- /dev/null +++ b/src/steelseries/rival-300.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RIVAL300_DEFAULT_DPI_PRESET_1, + RIVAL300_DEFAULT_DPI_PRESET_2, + RIVAL300_DEFAULT_POLLING_RATE, + RIVAL300_DPI_MAX, + RIVAL300_DPI_MIN, + RIVAL300_POLLING_RATES, + Rival300ProtocolError, + steelseriesRival300ActiveCpiQuery, + steelseriesRival300DecodeActivePreset, + steelseriesRival300DecodeFirmware, + steelseriesRival300DpiOptions, + steelseriesRival300EncodeButtonsMapping, + steelseriesRival300EncodeLedColor, + steelseriesRival300EncodeLedEffect, + steelseriesRival300EncodePollingRate, + steelseriesRival300EncodeSensitivity1, + steelseriesRival300EncodeSensitivity2, + steelseriesRival300FirmwareQuery, + steelseriesRival300SaveCommand, +} from "./rival-300.ts"; + +test("DPI options are the linear 50..6500 range in 50 DPI steps", () => { + const options = steelseriesRival300DpiOptions(); + assert.equal(options[0], RIVAL300_DPI_MIN); + assert.equal(options.at(-1), RIVAL300_DPI_MAX); + assert.equal(options.length, (RIVAL300_DPI_MAX - RIVAL300_DPI_MIN) / 50 + 1); + assert.equal(RIVAL300_DEFAULT_DPI_PRESET_1, 800); + assert.equal(RIVAL300_DEFAULT_DPI_PRESET_2, 1600); + assert.equal(RIVAL300_DEFAULT_POLLING_RATE, 1000); +}); + +test("encodes sensitivity presets with the linear+1 byte", () => { + // byte = (dpi - 50) / 50 + 1 + assert.deepEqual([...steelseriesRival300EncodeSensitivity1(50)], [0x03, 0x01, 0x01]); + assert.deepEqual([...steelseriesRival300EncodeSensitivity1(800)], [0x03, 0x01, 0x10]); + assert.deepEqual([...steelseriesRival300EncodeSensitivity2(1600)], [0x03, 0x02, 0x20]); + assert.deepEqual([...steelseriesRival300EncodeSensitivity1(6500)], [0x03, 0x01, 0x82]); + assert.throws(() => steelseriesRival300EncodeSensitivity1(75), Rival300ProtocolError); + assert.throws(() => steelseriesRival300EncodeSensitivity2(6550), Rival300ProtocolError); +}); + +test("polling, save, firmware, and active-CPI queries", () => { + assert.deepEqual([...steelseriesRival300EncodePollingRate(1000)], [0x04, 0x00, 0x01]); + assert.deepEqual([...steelseriesRival300EncodePollingRate(125)], [0x04, 0x00, 0x04]); + assert.deepEqual([...RIVAL300_POLLING_RATES], [125, 250, 500, 1000]); + assert.throws(() => steelseriesRival300EncodePollingRate(2000), Rival300ProtocolError); + assert.deepEqual([...steelseriesRival300SaveCommand()], [0x09, 0x00]); + assert.deepEqual([...steelseriesRival300FirmwareQuery()], [0x10, 0x00]); + assert.deepEqual([...steelseriesRival300ActiveCpiQuery()], [0x16, 0x00]); + assert.deepEqual(steelseriesRival300DecodeFirmware(new Uint8Array([0x03, 0x01])), { + bytes: [0x03, 0x01], + display: "1.3", + }); + assert.throws(() => steelseriesRival300DecodeFirmware(new Uint8Array([0x03])), Rival300ProtocolError); + // Active-preset reply: report-id stripped, so the first byte is the level. + assert.equal(steelseriesRival300DecodeActivePreset(new Uint8Array([0x01, 0x00])), 1); + assert.equal(steelseriesRival300DecodeActivePreset(new Uint8Array([0x02, 0x00])), 2); + assert.throws(() => steelseriesRival300DecodeActivePreset(new Uint8Array([0x03])), Rival300ProtocolError); + assert.throws(() => steelseriesRival300DecodeActivePreset(new Uint8Array([])), Rival300ProtocolError); +}); + +test("LED color and effect address logo (0x01) and wheel (0x02) zones", () => { + assert.deepEqual([...steelseriesRival300EncodeLedColor("logo", 0xff, 0x18, 0x00)], [0x08, 0x01, 0xff, 0x18, 0x00]); + assert.deepEqual([...steelseriesRival300EncodeLedColor("wheel", 0x00, 0x00, 0xff)], [0x08, 0x02, 0x00, 0x00, 0xff]); + assert.deepEqual([...steelseriesRival300EncodeLedEffect("logo", "steady")], [0x07, 0x01, 0x01]); + assert.deepEqual([...steelseriesRival300EncodeLedEffect("wheel", "breath")], [0x07, 0x02, 0x03]); + assert.throws(() => steelseriesRival300EncodeLedColor("strip" as never, 0, 0, 0), Rival300ProtocolError); + assert.throws(() => steelseriesRival300EncodeLedEffect("logo", "pulse" as never), Rival300ProtocolError); +}); + +test("button mapping covers six slots", () => { + const report = steelseriesRival300EncodeButtonsMapping({ + button1: { type: "button", target: "button1" }, + button6: { type: "dpiSwitch" }, + }); + assert.equal(report[0], 0x31); + assert.equal(report[1], 0x00); + assert.equal(report.length, 2 + 6 * 5); + assert.equal(report[2], 0x01); + assert.equal(report[2 + 5 * 5], 0x30); +}); + +test("button mapping rejects unknown slots and bad codes", () => { + assert.throws( + () => steelseriesRival300EncodeButtonsMapping({ ["button7" as never]: { type: "disabled" } }), + Rival300ProtocolError, + ); + assert.throws( + () => steelseriesRival300EncodeButtonsMapping({ button1: { type: "multimedia", code: 300 } }), + Rival300ProtocolError, + ); +}); diff --git a/src/steelseries/rival-300.ts b/src/steelseries/rival-300.ts new file mode 100644 index 00000000..94cb3b3b --- /dev/null +++ b/src/steelseries/rival-300.ts @@ -0,0 +1,331 @@ +/** + * SteelSeries Rival 300 configuration protocol — pure encode/decode helpers. + * + * Reconstructed from the vendor's own software, not from a third-party + * reimplementation: SteelSeries GG 121.0.0 ships `rival_base.lisp` (the + * shared profile) plus `rival_300.lisp`, `dota2-rival-mouse.lisp`, + * `predator-mouse.lisp`, `csgo-rival-300.lisp`, `csgo-rival-300-stm32.lisp`, + * `fallout_4_rival_300.lisp`, `hp_omen_rival_300.lisp`, + * `csgo-hyperbeast-rival-300.lisp` and `eg-rival-300.lisp`, all of which + * `include "rival_base"`. This module transcribes that command set. Those + * files are OpenPGP-armored ("DESCRIPTOR" armor, SKESK/AES-128). rivalcfg's + * `rival300.py` (which lists the same product ids) independently + * corroborates every command byte. + * + * This is the original Rival / Rival 300 command set, distinct from Rival + * 100 (`./rival-100.ts`) and Rival 110 (`./rival-110.ts`): the DPI byte is a + * linear range (not a choice table), the two LED zones have independent + * color and effect commands, and the six buttons are remappable. + * + * Every command is an HID **output report** with report id 0x00 (33-byte + * reports — `HIDIO 33`). + * + * DPI uses two per-level commands (`03 01`/`03 02`) with a linear byte: + * rivalcfg's `input_range [50, 6500, 50]` against `output_range [0x01, + * 0x82, 1]`, so `byte = (dpi - 50) / 50 + 1`, range 0x01–0x82 + * (50–6500 DPI in 50 DPI steps). The vendor struct carries a trailing + * `dcpi` byte (`0x00` or `0xAA`) that rivalcfg leaves at 0; this codec + * matches rivalcfg and does not emit it. Off-grid values are rejected. + * + * Polling (`04 00`) is write-only (1000→0x01, 500→0x02, 250→0x03, + * 125→0x04). Lighting is two zones — logo (`08 01`) and wheel (`08 02`) — + * each with its own color and effect (`07 01`/`07 02`, steady 0x01 / breath + * 0x03). Buttons are one 30-byte packet (6 slots × 5-byte fields), + * command `31 00`, as an output report. + * + * The two readable values are the firmware version (`10 00`, two bytes, + * `fw_minor` then `fw_major`) and the active preset index (`16 00`). + * Settings apply immediately; the save command (`09 00`) persists them to + * onboard flash. None of this has been verified on physical hardware by this + * project. + */ + +export const RIVAL300_REPORT_ID = 0x00; + +/** Command prefixes; the payload is the prefix plus its arguments. */ +export const RIVAL300_COMMAND = { + sensitivity1: [0x03, 0x01], + sensitivity2: [0x03, 0x02], + pollingRate: [0x04, 0x00], + logoColor: [0x08, 0x01], + wheelColor: [0x08, 0x02], + logoEffect: [0x07, 0x01], + wheelEffect: [0x07, 0x02], + buttonsMapping: [0x31, 0x00], + save: [0x09, 0x00], + firmware: [0x10, 0x00], + activeCpiIndex: [0x16, 0x00], +} as const; + +/** `09 00` — commit the current settings to onboard flash. */ +export const RIVAL300_SAVE_COMMAND = [0x09, 0x00] as const; + +export const RIVAL300_FIRMWARE_RESPONSE_LENGTH = 2; +export const RIVAL300_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 RIVAL300_DPI_MIN = 50; +export const RIVAL300_DPI_MAX = 6500; +export const RIVAL300_DPI_STEP = 50; + +/** rivalcfg `rival300.py` defaults for the two presets. */ +export const RIVAL300_DEFAULT_DPI_PRESET_1 = 800; +export const RIVAL300_DEFAULT_DPI_PRESET_2 = 1600; +export const RIVAL300_DEFAULT_POLLING_RATE = 1000; + +export class Rival300ProtocolError extends Error {} + +/** The 130 DPI values the linear encoding can express, ascending. */ +export function steelseriesRival300DpiOptions(): number[] { + const options: number[] = []; + for (let dpi = RIVAL300_DPI_MIN; dpi <= RIVAL300_DPI_MAX; dpi += RIVAL300_DPI_STEP) { + options.push(dpi); + } + return options; +} + +/** + * `input_range [50, 6500, 50]` -> `output_range [0x01, 0x82, 1]`: + * `byte = (dpi - 50) / 50 + 1`. Off-grid values are rejected. + */ +function encodeDpiByte(dpi: number): number { + if ( + !Number.isInteger(dpi) || + dpi < RIVAL300_DPI_MIN || + dpi > RIVAL300_DPI_MAX || + (dpi - RIVAL300_DPI_MIN) % RIVAL300_DPI_STEP !== 0 + ) { + throw new Rival300ProtocolError( + `SteelSeries Rival 300 DPI must be ${RIVAL300_DPI_MIN}–${RIVAL300_DPI_MAX.toLocaleString("en-US")} in ${RIVAL300_DPI_STEP} DPI steps.`, + ); + } + return (dpi - RIVAL300_DPI_MIN) / RIVAL300_DPI_STEP + 1; +} + +/** `03 01 ` — sensitivity preset 1. */ +export function steelseriesRival300EncodeSensitivity1(dpi: number): Uint8Array { + return new Uint8Array([...RIVAL300_COMMAND.sensitivity1, encodeDpiByte(dpi)]); +} + +/** `03 02 ` — sensitivity preset 2. */ +export function steelseriesRival300EncodeSensitivity2(dpi: number): Uint8Array { + return new Uint8Array([...RIVAL300_COMMAND.sensitivity2, encodeDpiByte(dpi)]); +} + +/** `04 00 ` with 1000→0x01, 500→0x02, 250→0x03, 125→0x04. */ +export function steelseriesRival300EncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new Rival300ProtocolError("SteelSeries Rival 300 supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...RIVAL300_COMMAND.pollingRate, byte]); +} + +/** `09 00` — commit the current settings to onboard flash. */ +export function steelseriesRival300SaveCommand(): Uint8Array { + return new Uint8Array(RIVAL300_SAVE_COMMAND); +} + +/** `10 00` — the device answers with minor then major firmware bytes. */ +export function steelseriesRival300FirmwareQuery(): Uint8Array { + return new Uint8Array(RIVAL300_COMMAND.firmware); +} + +/** `16 00` — read the active preset index. */ +export function steelseriesRival300ActiveCpiQuery(): Uint8Array { + return new Uint8Array(RIVAL300_COMMAND.activeCpiIndex); +} + +/** + * Decode the active-preset reply. The specification's `resolution_level` + * incoming struct is `report_id` then `level`, and the reply's report id is + * not surfaced by WebHID, so the level is the first byte. The device reports + * it 1-based (values 0x01/0x02); anything else is rejected rather than + * guessed at. + */ +export function steelseriesRival300DecodeActivePreset(payload: Uint8Array): 1 | 2 { + const level = payload[0]; + if (level !== 0x01 && level !== 0x02) { + throw new Rival300ProtocolError( + `SteelSeries Rival 300 active preset reply was not 1 or 2 (got ${level === undefined ? "nothing" : `0x${level.toString(16)}`}).`, + ); + } + return level; +} + +export interface SteelSeriesRival300Firmware { + /** The two raw response bytes, in the order the device sent them (minor, major). */ + bytes: [number, number]; + /** The version the two bytes describe, major first, e.g. "1.3". */ + display: string; +} + +/** Decode the two-byte firmware response (`fw_minor`, `fw_major`). */ +export function steelseriesRival300DecodeFirmware(payload: Uint8Array): SteelSeriesRival300Firmware { + if (payload.length < RIVAL300_FIRMWARE_RESPONSE_LENGTH) { + throw new Rival300ProtocolError("SteelSeries Rival 300 firmware response is shorter than two bytes."); + } + const bytes: [number, number] = [payload[0]!, payload[1]!]; + return { bytes, display: `${bytes[1]}.${bytes[0]}` }; +} + +// -- LED ------------------------------------------------------------------- + +/** The two independently controlled LED zones. */ +export type Rival300Led = "logo" | "wheel"; + +const LED_COLOR_COMMAND: Record = { + logo: RIVAL300_COMMAND.logoColor, + wheel: RIVAL300_COMMAND.wheelColor, +}; + +const LED_EFFECT_COMMAND: Record = { + logo: RIVAL300_COMMAND.logoEffect, + wheel: RIVAL300_COMMAND.wheelEffect, +}; + +function encodeRgb(r: number, g: number, b: number): [number, number, number] { + for (const channel of [r, g, b]) { + if (!Number.isInteger(channel) || channel < 0 || channel > 255) { + throw new Rival300ProtocolError("RGB channels must be integers 0–255."); + } + } + return [r, g, b]; +} + +/** `08 ` — one LED zone's color (`logo` = 0x01, `wheel` = 0x02). */ +export function steelseriesRival300EncodeLedColor(led: Rival300Led, r: number, g: number, b: number): Uint8Array { + const command = LED_COLOR_COMMAND[led]; + if (!command) { + throw new Rival300ProtocolError('SteelSeries Rival 300 LEDs are "logo" and "wheel" only.'); + } + return new Uint8Array([...command, ...encodeRgb(r, g, b)]); +} + +export const RIVAL300_LIGHT_EFFECT_TO_BYTE = { + steady: 0x01, + breath: 0x03, +} as const; + +export type Rival300LightEffect = keyof typeof RIVAL300_LIGHT_EFFECT_TO_BYTE; + +/** `07 ` — one LED zone's effect (steady/breath). */ +export function steelseriesRival300EncodeLedEffect(led: Rival300Led, effect: Rival300LightEffect): Uint8Array { + const command = LED_EFFECT_COMMAND[led]; + if (!command) { + throw new Rival300ProtocolError('SteelSeries Rival 300 LEDs are "logo" and "wheel" only.'); + } + const byte = RIVAL300_LIGHT_EFFECT_TO_BYTE[effect]; + if (byte === undefined) { + throw new Rival300ProtocolError( + `SteelSeries Rival 300 light effect must be one of: ${Object.keys(RIVAL300_LIGHT_EFFECT_TO_BYTE).join(", ")}.`, + ); + } + return new Uint8Array([...command, byte]); +} + +// -- Button mapping -------------------------------------------------------- + +/** 6 button slots, 5-byte fields. */ +export const RIVAL300_BUTTONS = { + button1: { id: 0x01, offset: 0x00 }, + button2: { id: 0x02, offset: 0x05 }, + button3: { id: 0x03, offset: 0x0a }, + button4: { id: 0x04, offset: 0x0f }, + button5: { id: 0x05, offset: 0x14 }, + button6: { id: 0x06, offset: 0x19 }, +} as const satisfies Record; + +export type Rival300ButtonName = keyof typeof RIVAL300_BUTTONS; + +const RIVAL300_BUTTON_FIELD_LENGTH = 5; +const RIVAL300_BUTTON_DISABLE = 0x00; +const RIVAL300_BUTTON_DPI_SWITCH = 0x30; +const RIVAL300_BUTTON_SCROLL_UP = 0x31; +const RIVAL300_BUTTON_SCROLL_DOWN = 0x32; +const RIVAL300_BUTTON_KEYBOARD = 0x51; +const RIVAL300_BUTTON_MULTIMEDIA = 0x61; + +export type Rival300ButtonAction = + | { type: "button"; target: Rival300ButtonName } + | { type: "disabled" } + | { type: "dpiSwitch" } + | { type: "scrollUp" } + | { type: "scrollDown" } + | { type: "keyboard"; code: number } + | { type: "multimedia"; code: number }; + +function encodeKeyCode(code: number, kind: string): number { + if (!Number.isInteger(code) || code < 0 || code > 255) { + throw new Rival300ProtocolError(`${kind} codes must be integers 0–255.`); + } + return code; +} + +/** + * `31 00` + one 30-byte packet (6 slots × 5-byte fields), zero-filled + * except where a mapping is given. Pass every button explicitly — unmapped + * buttons stay disabled, matching this package's no-silent-defaults policy. + */ +export function steelseriesRival300EncodeButtonsMapping( + mapping: Partial>, +): Uint8Array { + for (const name of Object.keys(mapping)) { + if (!Object.hasOwn(RIVAL300_BUTTONS, name)) { + throw new Rival300ProtocolError(`Unknown SteelSeries Rival 300 button "${name}".`); + } + } + + const names = Object.keys(RIVAL300_BUTTONS) as Rival300ButtonName[]; + const packet = new Array(names.length * RIVAL300_BUTTON_FIELD_LENGTH).fill(0x00); + + for (const [name, action] of Object.entries(mapping) as Array<[Rival300ButtonName, Rival300ButtonAction]>) { + const { offset } = RIVAL300_BUTTONS[name]; + switch (action.type) { + case "button": { + if (!Object.hasOwn(RIVAL300_BUTTONS, action.target)) { + throw new Rival300ProtocolError(`Unknown SteelSeries Rival 300 button "${action.target}".`); + } + packet[offset] = RIVAL300_BUTTONS[action.target].id; + break; + } + case "disabled": { + packet[offset] = RIVAL300_BUTTON_DISABLE; + break; + } + case "dpiSwitch": { + packet[offset] = RIVAL300_BUTTON_DPI_SWITCH; + break; + } + case "scrollUp": { + packet[offset] = RIVAL300_BUTTON_SCROLL_UP; + break; + } + case "scrollDown": { + packet[offset] = RIVAL300_BUTTON_SCROLL_DOWN; + break; + } + case "keyboard": { + packet[offset] = RIVAL300_BUTTON_KEYBOARD; + packet[offset + 1] = encodeKeyCode(action.code, "Keyboard scan"); + break; + } + case "multimedia": { + packet[offset] = RIVAL300_BUTTON_MULTIMEDIA; + packet[offset + 1] = encodeKeyCode(action.code, "Multimedia key"); + break; + } + default: { + throw new Rival300ProtocolError("Unsupported SteelSeries Rival 300 button action."); + } + } + } + + return new Uint8Array([...RIVAL300_COMMAND.buttonsMapping, ...packet]); +}