diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..565af122 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 { SteelSeriesRival110HidClient } from "./steelseries/rival-110-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,7 +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 | SteelSeriesRival110HidClient | 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; @@ -165,6 +166,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) => SteelSeriesRival110HidClient.isSupported(device), create: (device) => new SteelSeriesRival110HidClient(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-110-hid.test.ts b/src/drivers/steelseries/rival-110-hid.test.ts new file mode 100644 index 00000000..75f0751d --- /dev/null +++ b/src/drivers/steelseries/rival-110-hid.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesRival110HidClient } from "./rival-110-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 ?? 0x1729, + productName: "SteelSeries Rival 110", + 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 ?? [0x05, 0x02]); + if (payload[0] === 0x16 && payload[1] === 0x00) reply([options.activePreset ?? 0x01]); + }, + sendFeatureReport: async () => { throw new Error("the Rival 110 protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Rival 110 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 110 family product ids and nothing else", () => { + const { device } = fakeDevice(); + for (const productId of [0x1729, 0x1816, 0x1810]) { + assert.equal( + SteelSeriesRival110HidClient.isSupported({ ...device, productId } as HIDDevice), + true, + `expected 0x${productId.toString(16)} to be claimed`, + ); + } + for (const productId of [0x1702, 0x1814, 0x1824, 0x1836]) { + assert.equal( + SteelSeriesRival110HidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesRival110HidClient.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 SteelSeriesRival110HidClient(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 110"); + assert.deepEqual(status.firmware, ["2.5"]); + 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 SteelSeriesRival110HidClient(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 SteelSeriesRival110HidClient(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, 0x12] }, + { 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 SteelSeriesRival110HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setDpi(3200); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x16, 0x00] }, + { reportId: 0, payload: [0x03, 0x02, 0x49] }, + { 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, 3200); +}); + +test("setDpiPreset2 writes and records preset 2", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x01 }); + const client = new SteelSeriesRival110HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setDpiPreset2(1600); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x03, 0x02, 0x24] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); + +test("setPollingRate, setLedColor, setLightEffect, and setButton6Mode write plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival110HidClient(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(0xff, 0x18, 0x00); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x05, 0x00, 0xff, 0x18, 0x00] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setLightEffect("breath"); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x07, 0x00, 0x03] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setButton6Mode("os"); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x0b, 0x01] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); diff --git a/src/drivers/steelseries/rival-110-hid.ts b/src/drivers/steelseries/rival-110-hid.ts new file mode 100644 index 00000000..91bad516 --- /dev/null +++ b/src/drivers/steelseries/rival-110-hid.ts @@ -0,0 +1,249 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + RIVAL110_DEFAULT_DPI_PRESET_1, + RIVAL110_DEFAULT_DPI_PRESET_2, + RIVAL110_POLLING_RATES, + RIVAL110_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesRival110ActiveCpiQuery, + steelseriesRival110DecodeActivePreset, + steelseriesRival110DecodeFirmware, + steelseriesRival110DpiOptions, + steelseriesRival110EncodeButton6Mode, + steelseriesRival110EncodeLedColor, + steelseriesRival110EncodeLightEffect, + steelseriesRival110EncodePollingRate, + steelseriesRival110EncodeSensitivity1, + steelseriesRival110EncodeSensitivity2, + steelseriesRival110FirmwareQuery, + steelseriesRival110SaveCommand, + type Rival110Button6Mode, + type Rival110LightEffect, + type SteelSeriesRival110Firmware, +} 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 110 WebHID control (Rival 110 `1038:1729`, + * Rival 106 `1038:1816`, Rival 300S `1038:1810` — one shared vendor profile; + * see `../../steelseries/rival-110.ts`). + * + * DPI (two presets, `03 01`/`03 02`), polling, and the LED 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 `rival110.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`. + * + * This mouse has **no button remapping at all** — the codec exposes only the + * button-6 (CPI) mode toggle. Do not confuse this family with the Rival 100 / + * Rival 105, which uses a different (eight-step) DPI table. 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 SteelSeriesRival110HidClient { + 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-110"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...RIVAL110_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesRival110DpiOptions(); } + + 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 110"}`; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-rival-110", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: `The Rival 110 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 110", + }, + 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 ? steelseriesRival110EncodeSensitivity2(dpi) : steelseriesRival110EncodeSensitivity1(dpi)); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival110SaveCommand()); + this.presetDpi[preset] = dpi; + }); + return dpi; + } + + /** `03 02 ` — writes sensitivity preset 2 explicitly. */ + async setDpiPreset2(dpi: number): Promise { + await this.commit(steelseriesRival110EncodeSensitivity2(dpi)); + this.presetDpi[2] = dpi; + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesRival110EncodePollingRate(pollingRateHz)); + this.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + async setLedColor(r: number, g: number, b: number): Promise { + await this.commit(steelseriesRival110EncodeLedColor(r, g, b)); + } + + async setLightEffect(effect: Rival110LightEffect): Promise { + await this.commit(steelseriesRival110EncodeLightEffect(effect)); + } + + /** `0B ` — CPI button mode. */ + async setButton6Mode(mode: Rival110Button6Mode): Promise { + await this.commit(steelseriesRival110EncodeButton6Mode(mode)); + } + + 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(steelseriesRival110SaveCommand()); + }); + } + + /** 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 ? RIVAL110_DEFAULT_DPI_PRESET_2 : RIVAL110_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(steelseriesRival110FirmwareQuery()); + if (!payload) { + throw new Error("The Rival 110 did not answer the firmware query on this interface. Add the device again and choose another entry."); + } + return steelseriesRival110DecodeFirmware(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(steelseriesRival110ActiveCpiQuery()); + if (!payload) return 1; + try { + return steelseriesRival110DecodeActivePreset(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(RIVAL110_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..ab24f511 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-110" | "rival310" | "aerox3" | "aerox3-wireless" @@ -100,6 +101,36 @@ export interface SteelSeriesProduct { } export const STEELSERIES_PRODUCTS: ReadonlyMap = new Map([ + // rival-110.lisp / rival-106.lisp / rival-300s.lisp (vendor GG + // specification): all three `include "rival-100"`, so they share its + // command bytes, but they use the PixArt PMW3326 sensor (200-7200 DPI), + // not the Rival 100's eight-step table — hence a separate family/module + // (./rival-110.ts). rivalcfg groups these ids under rival110.py, with + // rival300s.py reusing it. + [0x1729, { + model: "Rival 110", + family: "rival-110", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1816, { + model: "Rival 106", + family: "rival-110", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1810, { + model: "Rival 300S", + family: "rival-110", + 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..b1909902 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -1,4 +1,5 @@ export * from "./rival3.js"; +export * from "./rival-110.js"; export * from "./rival310.js"; export * from "./aerox3.js"; export * from "./aerox3-wireless.js"; diff --git a/src/steelseries/rival-110.test.ts b/src/steelseries/rival-110.test.ts new file mode 100644 index 00000000..6d2eecbc --- /dev/null +++ b/src/steelseries/rival-110.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RIVAL110_BUTTON6_MODE_TO_BYTE, + RIVAL110_DEFAULT_DPI_PRESET_1, + RIVAL110_DEFAULT_DPI_PRESET_2, + RIVAL110_DEFAULT_POLLING_RATE, + RIVAL110_DPI_MAX, + RIVAL110_DPI_MIN, + RIVAL110_DPI_TO_BYTE, + RIVAL110_FIRMWARE_RESPONSE_LENGTH, + RIVAL110_POLLING_RATES, + Rival110ProtocolError, + steelseriesRival110ActiveCpiQuery, + steelseriesRival110DecodeActivePreset, + steelseriesRival110DecodeFirmware, + steelseriesRival110DpiOptions, + steelseriesRival110EncodeButton6Mode, + steelseriesRival110EncodeLedColor, + steelseriesRival110EncodeLightEffect, + steelseriesRival110EncodePollingRate, + steelseriesRival110EncodeSensitivity1, + steelseriesRival110EncodeSensitivity2, + steelseriesRival110FirmwareQuery, + steelseriesRival110SaveCommand, +} from "./rival-110.ts"; + +test("DPI table is the PMW3326 curve, including 0x49 for 3200", () => { + const options = steelseriesRival110DpiOptions(); + assert.equal(options[0], RIVAL110_DPI_MIN); + assert.equal(options.at(-1), RIVAL110_DPI_MAX); + assert.equal(options.length, (RIVAL110_DPI_MAX - RIVAL110_DPI_MIN) / 100 + 1); + assert.equal(RIVAL110_DPI_TO_BYTE.get(400), 0x08); + assert.equal(RIVAL110_DPI_TO_BYTE.get(800), 0x12); + assert.equal(RIVAL110_DPI_TO_BYTE.get(3200), 0x49); + assert.equal(RIVAL110_DPI_TO_BYTE.get(7200), 0xa7); + assert.equal(RIVAL110_DEFAULT_DPI_PRESET_1, 800); + assert.equal(RIVAL110_DEFAULT_DPI_PRESET_2, 1600); + assert.equal(RIVAL110_DEFAULT_POLLING_RATE, 1000); +}); + +test("encodes both sensitivity presets with the PMW3326 byte", () => { + assert.deepEqual([...steelseriesRival110EncodeSensitivity1(200)], [0x03, 0x01, 0x04]); + assert.deepEqual([...steelseriesRival110EncodeSensitivity1(800)], [0x03, 0x01, 0x12]); + assert.deepEqual([...steelseriesRival110EncodeSensitivity2(1600)], [0x03, 0x02, 0x24]); + assert.deepEqual([...steelseriesRival110EncodeSensitivity2(7200)], [0x03, 0x02, 0xa7]); + // Off-table values are rejected (no 250 step, unlike the Rival 100 family). + assert.throws(() => steelseriesRival110EncodeSensitivity1(250), Rival110ProtocolError); + assert.throws(() => steelseriesRival110EncodeSensitivity2(7300), Rival110ProtocolError); +}); + +test("encodes polling rates with 1000->0x01 down to 125->0x04", () => { + assert.deepEqual([...steelseriesRival110EncodePollingRate(1000)], [0x04, 0x00, 0x01]); + assert.deepEqual([...steelseriesRival110EncodePollingRate(500)], [0x04, 0x00, 0x02]); + assert.deepEqual([...steelseriesRival110EncodePollingRate(250)], [0x04, 0x00, 0x03]); + assert.deepEqual([...steelseriesRival110EncodePollingRate(125)], [0x04, 0x00, 0x04]); + assert.deepEqual([...RIVAL110_POLLING_RATES], [125, 250, 500, 1000]); + assert.throws(() => steelseriesRival110EncodePollingRate(2000), Rival110ProtocolError); +}); + +test("save, firmware, and active-CPI queries", () => { + assert.deepEqual([...steelseriesRival110SaveCommand()], [0x09, 0x00]); + assert.deepEqual([...steelseriesRival110FirmwareQuery()], [0x10, 0x00]); + assert.deepEqual([...steelseriesRival110ActiveCpiQuery()], [0x16, 0x00]); + assert.equal(RIVAL110_FIRMWARE_RESPONSE_LENGTH, 2); + assert.deepEqual(steelseriesRival110DecodeFirmware(new Uint8Array([0x05, 0x02])), { + bytes: [0x05, 0x02], + display: "2.5", + }); + assert.throws(() => steelseriesRival110DecodeFirmware(new Uint8Array([0x05])), Rival110ProtocolError); + // Active-preset reply: report-id stripped, so the first byte is the level. + assert.equal(steelseriesRival110DecodeActivePreset(new Uint8Array([0x01, 0x00])), 1); + assert.equal(steelseriesRival110DecodeActivePreset(new Uint8Array([0x02, 0x00])), 2); + assert.throws(() => steelseriesRival110DecodeActivePreset(new Uint8Array([0x03])), Rival110ProtocolError); + assert.throws(() => steelseriesRival110DecodeActivePreset(new Uint8Array([])), Rival110ProtocolError); +}); + +test("LED color, effect, and button-6 mode", () => { + assert.deepEqual([...steelseriesRival110EncodeLedColor(0xff, 0x18, 0x00)], [0x05, 0x00, 0xff, 0x18, 0x00]); + assert.throws(() => steelseriesRival110EncodeLedColor(0, -1, 0), Rival110ProtocolError); + assert.deepEqual([...steelseriesRival110EncodeLightEffect("steady")], [0x07, 0x00, 0x01]); + assert.deepEqual([...steelseriesRival110EncodeLightEffect("breath")], [0x07, 0x00, 0x03]); + assert.throws(() => steelseriesRival110EncodeLightEffect("rainbow" as never), Rival110ProtocolError); + assert.deepEqual([...steelseriesRival110EncodeButton6Mode("dpi")], [0x0b, 0x00]); + assert.deepEqual([...steelseriesRival110EncodeButton6Mode("os")], [0x0b, 0x01]); + assert.throws(() => steelseriesRival110EncodeButton6Mode("x" as never), Rival110ProtocolError); + assert.deepEqual(RIVAL110_BUTTON6_MODE_TO_BYTE, { dpi: 0x00, os: 0x01 }); +}); diff --git a/src/steelseries/rival-110.ts b/src/steelseries/rival-110.ts new file mode 100644 index 00000000..b6b0bcf7 --- /dev/null +++ b/src/steelseries/rival-110.ts @@ -0,0 +1,249 @@ +/** + * SteelSeries Rival 110 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-110.lisp`, + * `rival-106.lisp` and `rival-300s.lisp`, each of which `include "rival-100"` + * (so the command bytes are Rival 100's) and override only the firmware + * struct. This module transcribes that command set. Those vendor files are + * OpenPGP-armored ("DESCRIPTOR" armor, SKESK/AES-128). rivalcfg groups these + * three product ids under `rival110.py` (with `rival300s.py` reusing it) and + * independently corroborates every command byte. + * + * ## Why this is a separate family from `./rival-100.ts` + * + * The Rival 110/106/300S share the Rival 100's command bytes but use the + * **PixArt PMW3326** sensor, not the Rival 100's eight-step sensor. The + * vendor files state the DPI byte but no table, so rivalcfg's + * `dpi/pmw3326.py` supplies it (200–7200 in 100 DPI steps, the "linear-like" + * sensor curve — notably `0x49`→3200, matching the vendor TrueMove Core + * table in `./rival-3-v2.ts`). Off-table values are rejected. The Rival 100 + * module's eight-value choice table does not apply here and vice versa, + * which is exactly why these are two modules. + * + * ## Protocol + * + * Every command is an HID **output report** with report id 0x00. The vendor + * file says 33-byte reports (`HIDIO 33`); rivalcfg sets a 32-byte + * `packet_length` for these models. That padding difference is a transport + * detail (the driver sends the command bytes), noted here rather than + * encoded. + * + * DPI uses two per-level commands (`03 01`/`03 02`). Polling (`04 00`) is + * write-only (1000→0x01, 500→0x02, 250→0x03, 125→0x04). Lighting is a single + * zone: color (`05 00` + RGB) and effect (`07 00` + steady/breath), with the + * color-sequencing template leaving the brightness byte zero. Button 6 (the + * CPI button under the wheel) has a mode toggle (`0B`): hardware DPI switch + * or an OS-bound button. There is **no button remapping** on this family. + * + * 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 RIVAL110_REPORT_ID = 0x00; + +/** Command prefixes; the payload is the prefix plus its arguments. */ +export const RIVAL110_COMMAND = { + sensitivity1: [0x03, 0x01], + sensitivity2: [0x03, 0x02], + pollingRate: [0x04, 0x00], + ledColor: [0x05, 0x00], + ledPulsation: [0x07, 0x00], + button6Mode: [0x0b], + save: [0x09, 0x00], + firmware: [0x10, 0x00], + activeCpiIndex: [0x16, 0x00], +} as const; + +/** `09 00` — commit the current settings to onboard flash. */ +export const RIVAL110_SAVE_COMMAND = [0x09, 0x00] as const; + +export const RIVAL110_FIRMWARE_RESPONSE_LENGTH = 2; + +export const RIVAL110_POLLING_RATES = [125, 250, 500, 1000] as const; + +const POLLING_RATE_TO_BYTE: ReadonlyMap = new Map([ + [1000, 0x01], + [500, 0x02], + [250, 0x03], + [125, 0x04], +]); + +/** + * The PixArt PMW3326 DPI curve, from rivalcfg's `dpi/pmw3326.py` (the vendor + * files give no table). 200–7200 in 100 DPI steps. + */ +export const RIVAL110_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, 0x34], [2400, 0x37], [2500, 0x39], + [2600, 0x3b], [2700, 0x3e], [2800, 0x40], [2900, 0x42], [3000, 0x45], [3100, 0x47], + [3200, 0x49], [3300, 0x4c], [3400, 0x4e], [3500, 0x50], [3600, 0x53], [3700, 0x55], + [3800, 0x57], [3900, 0x5a], [4000, 0x5c], [4100, 0x5e], [4200, 0x61], [4300, 0x63], + [4400, 0x65], [4500, 0x68], [4600, 0x6a], [4700, 0x6c], [4800, 0x6f], [4900, 0x71], + [5000, 0x73], [5100, 0x76], [5200, 0x78], [5300, 0x7a], [5400, 0x7d], [5500, 0x7f], + [5600, 0x81], [5700, 0x84], [5800, 0x86], [5900, 0x88], [6000, 0x8b], [6100, 0x8d], + [6200, 0x8f], [6300, 0x92], [6400, 0x94], [6500, 0x96], [6600, 0x99], [6700, 0x9b], + [6800, 0x9d], [6900, 0xa0], [7000, 0xa2], [7100, 0xa4], [7200, 0xa7], +]); + +/** rivalcfg `rival110.py` defaults for the two presets. */ +export const RIVAL110_DEFAULT_DPI_PRESET_1 = 800; +export const RIVAL110_DEFAULT_DPI_PRESET_2 = 1600; +export const RIVAL110_DEFAULT_POLLING_RATE = 1000; + +export class Rival110ProtocolError extends Error {} + +/** The PMW3326 DPI curve supports 200–7200 in 100 DPI steps. */ +export const RIVAL110_DPI_MIN = 200; +export const RIVAL110_DPI_MAX = 7200; + +/** The 72 supported DPI values, ascending. */ +export function steelseriesRival110DpiOptions(): number[] { + return [...RIVAL110_DPI_TO_BYTE.keys()].sort((a, b) => a - b); +} + +function encodeDpiByte(dpi: number): number { + const byte = RIVAL110_DPI_TO_BYTE.get(dpi); + if (byte === undefined) { + throw new Rival110ProtocolError( + `SteelSeries Rival 110 DPI must be ${RIVAL110_DPI_MIN}–${RIVAL110_DPI_MAX.toLocaleString("en-US")} in 100 DPI steps.`, + ); + } + return byte; +} + +/** `03 01 ` — sensitivity preset 1. */ +export function steelseriesRival110EncodeSensitivity1(dpi: number): Uint8Array { + return new Uint8Array([...RIVAL110_COMMAND.sensitivity1, encodeDpiByte(dpi)]); +} + +/** `03 02 ` — sensitivity preset 2. */ +export function steelseriesRival110EncodeSensitivity2(dpi: number): Uint8Array { + return new Uint8Array([...RIVAL110_COMMAND.sensitivity2, encodeDpiByte(dpi)]); +} + +/** `04 00 ` with 1000→0x01, 500→0x02, 250→0x03, 125→0x04. */ +export function steelseriesRival110EncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new Rival110ProtocolError("SteelSeries Rival 110 supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...RIVAL110_COMMAND.pollingRate, byte]); +} + +/** `09 00` — commit the current settings to onboard flash. */ +export function steelseriesRival110SaveCommand(): Uint8Array { + return new Uint8Array(RIVAL110_SAVE_COMMAND); +} + +/** `10 00` — the device answers with minor then major firmware bytes. */ +export function steelseriesRival110FirmwareQuery(): Uint8Array { + return new Uint8Array(RIVAL110_COMMAND.firmware); +} + +/** `16 00` — read the active preset index. */ +export function steelseriesRival110ActiveCpiQuery(): Uint8Array { + return new Uint8Array(RIVAL110_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 steelseriesRival110DecodeActivePreset(payload: Uint8Array): 1 | 2 { + const level = payload[0]; + if (level !== 0x01 && level !== 0x02) { + throw new Rival110ProtocolError( + `SteelSeries Rival 110 active preset reply was not 1 or 2 (got ${level === undefined ? "nothing" : `0x${level.toString(16)}`}).`, + ); + } + return level; +} + +export interface SteelSeriesRival110Firmware { + /** 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. The specification's incoming + * struct orders the fields `fw_minor` then `fw_major`, so the display string + * puts them back in major.minor order. + */ +export function steelseriesRival110DecodeFirmware(payload: Uint8Array): SteelSeriesRival110Firmware { + if (payload.length < RIVAL110_FIRMWARE_RESPONSE_LENGTH) { + throw new Rival110ProtocolError("SteelSeries Rival 110 firmware response is shorter than two bytes."); + } + const bytes: [number, number] = [payload[0]!, payload[1]!]; + return { bytes, display: `${bytes[1]}.${bytes[0]}` }; +} + +// -- LED ------------------------------------------------------------------- + +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 Rival110ProtocolError("RGB channels must be integers 0–255."); + } + } + return [r, g, b]; +} + +/** + * `05 00 ` — the single LED zone color. The vendor struct also + * carries a `brightness` byte, but both the vendor's own color-sequencing + * template and rivalcfg's `rgbcolor` leave it zero, so this codec matches + * them rather than inventing a brightness semantic. + */ +export function steelseriesRival110EncodeLedColor(r: number, g: number, b: number): Uint8Array { + return new Uint8Array([...RIVAL110_COMMAND.ledColor, ...encodeRgb(r, g, b)]); +} + +/** `07 00 ` — lighting effect. */ +export const RIVAL110_LIGHT_EFFECT_TO_BYTE = { + steady: 0x01, + breath: 0x03, +} as const; + +export type Rival110LightEffect = keyof typeof RIVAL110_LIGHT_EFFECT_TO_BYTE; + +/** `07 00 ` — steady or breath. */ +export function steelseriesRival110EncodeLightEffect(effect: Rival110LightEffect): Uint8Array { + const byte = RIVAL110_LIGHT_EFFECT_TO_BYTE[effect]; + if (byte === undefined) { + throw new Rival110ProtocolError( + `SteelSeries Rival 110 light effect must be one of: ${Object.keys(RIVAL110_LIGHT_EFFECT_TO_BYTE).join(", ")}.`, + ); + } + return new Uint8Array([...RIVAL110_COMMAND.ledPulsation, byte]); +} + +/** `0B ` — the button-6 (CPI) button mode. */ +export const RIVAL110_BUTTON6_MODE_TO_BYTE = { + dpi: 0x00, + os: 0x01, +} as const; + +export type Rival110Button6Mode = keyof typeof RIVAL110_BUTTON6_MODE_TO_BYTE; + +/** `0B ` — dpi or os. */ +export function steelseriesRival110EncodeButton6Mode(mode: Rival110Button6Mode): Uint8Array { + const byte = RIVAL110_BUTTON6_MODE_TO_BYTE[mode]; + if (byte === undefined) { + throw new Rival110ProtocolError( + `SteelSeries Rival 110 button-6 mode must be one of: ${Object.keys(RIVAL110_BUTTON6_MODE_TO_BYTE).join(", ")}.`, + ); + } + return new Uint8Array([...RIVAL110_COMMAND.button6Mode, byte]); +}