From ac17fceb06e8d4170c5d949fe4f1eb7ea7253cb0 Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 17:39:05 -0600 Subject: [PATCH 01/15] feat: add SteelSeries Sensei Pro / Rival Pro protocol family Derived from the vendor's own SteelSeries GG 121.0.0 device specification (sensei_rival_pro_shared.lisp): raw-uint16 XY DPI presets with per-preset lift-off/landing (0x34), dual report/polling rates to 8000 Hz (0x2C/0x2D), 8-slot button mapping (0x2A), save (0x09). Registers the base station (1038:1880) and dongle (1038:1882); the four models share one spec and report the model themselves. Unverified on hardware. --- src/drivers/registry.ts | 4 +- .../steelseries/sensei-rival-pro-hid.test.ts | 110 ++++++ .../steelseries/sensei-rival-pro-hid.ts | 161 ++++++++ src/steelseries/devices.ts | 32 +- src/steelseries/index.ts | 1 + src/steelseries/sensei-rival-pro.test.ts | 171 ++++++++ src/steelseries/sensei-rival-pro.ts | 374 ++++++++++++++++++ 7 files changed, 851 insertions(+), 2 deletions(-) create mode 100644 src/drivers/steelseries/sensei-rival-pro-hid.test.ts create mode 100644 src/drivers/steelseries/sensei-rival-pro-hid.ts create mode 100644 src/steelseries/sensei-rival-pro.test.ts create mode 100644 src/steelseries/sensei-rival-pro.ts diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..427f584e 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -58,6 +58,7 @@ import { SteelSeriesRival310HidClient } from "./steelseries/rival310-hid.ts"; import { SteelSeriesPrimePlusHidClient } from "./steelseries/prime-plus-hid.ts"; import { SteelSeriesPrimeMiniWirelessHidClient } from "./steelseries/prime-mini-wireless-hid.ts"; import { SteelSeriesSenseiTenHidClient } from "./steelseries/sensei-ten-hid.ts"; +import { SteelSeriesSenseiRivalProHidClient } from "./steelseries/sensei-rival-pro-hid.ts"; import { GloriousHidClient } from "./glorious/hid.ts"; import { GloriousClassicHidClient } from "./glorious/classic-hid.ts"; import { GloriousCore2HidClient } from "./glorious/core2-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 | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesSenseiRivalProHidClient | 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; @@ -176,6 +177,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesPrimePlusHidClient.isSupported(device), create: (device) => new SteelSeriesPrimePlusHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesPrimeMiniWirelessHidClient.isSupported(device), create: (device) => new SteelSeriesPrimeMiniWirelessHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiTenHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiTenHidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiRivalProHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiRivalProHidClient(device), score: () => 6 }, { brand: "Glorious", supports: (device) => GloriousHidClient.isSupported(device), create: (device) => new GloriousHidClient(device), score: () => 5 }, { brand: "Glorious", supports: (device) => GloriousClassicHidClient.isSupported(device), create: (device) => new GloriousClassicHidClient(device), score: () => 5 }, // Same vendor id as the classic family; disjoint by product id. diff --git a/src/drivers/steelseries/sensei-rival-pro-hid.test.ts b/src/drivers/steelseries/sensei-rival-pro-hid.test.ts new file mode 100644 index 00000000..44614a8a --- /dev/null +++ b/src/drivers/steelseries/sensei-rival-pro-hid.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesSenseiRivalProHidClient } from "./sensei-rival-pro-hid.ts"; + +function fakeDevice(options: { productId?: number } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x1880, + productName: "SteelSeries Sensei Pro", + 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); + sent.push({ reportId, payload: [...view] }); + }, + sendFeatureReport: async () => { throw new Error("the Sensei Pro / Rival Pro protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Sensei Pro / Rival Pro protocol does not use feature reports"); }, + addEventListener: () => {}, + removeEventListener: () => {}, + }; + return { device: device as unknown as HIDDevice, sent }; +} + +test("claims only the base station and dongle product ids", () => { + const { device } = fakeDevice(); + assert.equal(SteelSeriesSenseiRivalProHidClient.isSupported(device), true); + assert.equal( + SteelSeriesSenseiRivalProHidClient.isSupported(fakeDevice({ productId: 0x1882 }).device), + true, + ); + // Neighboring SteelSeries mice must not be claimed. + assert.equal( + SteelSeriesSenseiRivalProHidClient.isSupported(fakeDevice({ productId: 0x1830 }).device), + false, + ); + assert.equal( + SteelSeriesSenseiRivalProHidClient.isSupported(fakeDevice({ productId: 0x1872 }).device), + false, + ); + assert.equal( + SteelSeriesSenseiRivalProHidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), + false, + ); +}); + +test("readStatus sends nothing and reports assumed defaults", async () => { + const { device, sent } = fakeDevice(); + const status = await new SteelSeriesSenseiRivalProHidClient(device).readStatus(); + assert.deepEqual(sent, []); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Sensei Pro"); + assert.deepEqual(status.firmware, []); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.batteryPercent, null); + assert.equal(status.batteryState, "Unknown"); + assert.equal(status.dpi, 400); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.ui?.valuesVerified, false); + assert.ok(status.ui?.pollingNote); + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000, 2000, 4000, 8000]); +}); + +test("setDpi writes a five-identical-preset table plus save", async () => { + const { device, sent } = fakeDevice(); + const dpi = await new SteelSeriesSenseiRivalProHidClient(device).setDpi(1600); + assert.equal(dpi, 1600); + assert.deepEqual(sent, [ + { + reportId: 0, + payload: [ + 0x34, 0x05, 0x00, + 0x40, 0x06, 0x40, 0x06, 0x00, 0x00, + 0x40, 0x06, 0x40, 0x06, 0x00, 0x00, + 0x40, 0x06, 0x40, 0x06, 0x00, 0x00, + 0x40, 0x06, 0x40, 0x06, 0x00, 0x00, + 0x40, 0x06, 0x40, 0x06, 0x00, 0x00, + ], + }, + { reportId: 0, payload: [0x09] }, + ]); +}); + +test("setPollingRate writes the single polling command plus save", async () => { + const { device, sent } = fakeDevice(); + const rate = await new SteelSeriesSenseiRivalProHidClient(device).setPollingRate(8000); + assert.equal(rate, 8000); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x2c, 0x00, 0x00] }, + { reportId: 0, payload: [0x09] }, + ]); +}); + +test("setButtonsMapping writes the 40-byte mapping plus save", async () => { + const { device, sent } = fakeDevice(); + await new SteelSeriesSenseiRivalProHidClient(device).setButtonsMapping({ + button1: { type: "button", target: "button1" }, + }); + assert.equal(sent.length, 2); + assert.equal(sent[0]?.reportId, 0); + assert.equal(sent[0]?.payload.length, 41); + assert.equal(sent[0]?.payload[0], 0x2a); + assert.equal(sent[0]?.payload[1], 0x01); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x09] }); +}); diff --git a/src/drivers/steelseries/sensei-rival-pro-hid.ts b/src/drivers/steelseries/sensei-rival-pro-hid.ts new file mode 100644 index 00000000..76f47585 --- /dev/null +++ b/src/drivers/steelseries/sensei-rival-pro-hid.ts @@ -0,0 +1,161 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + SENSEI_RIVAL_PRO_POLLING_RATES, + SENSEI_RIVAL_PRO_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesSenseiRivalProDpiOptions, + steelseriesSenseiRivalProEncodeButtonsMapping, + steelseriesSenseiRivalProEncodeDpiPresets, + steelseriesSenseiRivalProEncodePollingRate, + steelseriesSenseiRivalProSaveCommand, + type SenseiRivalProButtonAction, + type SenseiRivalProButtonName, + type SenseiRivalProDpiPreset, +} from "@openmouse/protocol/steelseries"; + +/** Vendor profile default (first factory preset) until this session writes a value. */ +const DEFAULT_DPI = 400; +const DEFAULT_POLLING_HZ = 1000; +/** Lift-off/landing bytes written with DPI presets; the vendor specification states no defaults. */ +const DEFAULT_LIFT_OFF_DISTANCE = 0; +const DEFAULT_LANDING_DISTANCE = 0; + +/** + * SteelSeries Sensei Pro / Rival Pro WebHID control (base station + * `1038:1880`, dongle `1038:1882`). + * + * DPI presets, polling rate, and buttons are effectively write-only in this + * driver: the vendor specification does define read-back commands (`0xB4` + * for DPI, `0xAC`/`0xAD` for polling, `0xAA` for buttons), but this driver + * does not decode them yet, so a successful `open()` is the only available + * connectivity signal and `readStatus` reports the session's last-written + * values (or documented defaults before any write) with + * `valuesVerified: false`. The four models share one specification and + * report the model themselves; see `../../steelseries/sensei-rival-pro.ts` + * for the full protocol writeup. + * + * `setDpi` replaces the whole five-preset table with five identical + * presets: the device exposes no getter, so existing presets cannot be + * read and preserved. Lift-off and landing distances reset to 0 for every + * preset — the specification states no defaults for those bytes, so record + * them in SteelSeries GG before testing and restore them there afterwards. + */ +export class SteelSeriesSenseiRivalProHidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private lastWritten: { dpi: number | null; pollingRateHz: number | null } = { + dpi: null, + pollingRateHz: null, + }; + + 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 === "sensei-rival-pro"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...SENSEI_RIVAL_PRO_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesSenseiRivalProDpiOptions(); } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + if (this.device.opened) await this.device.close(); + } + + async readStatus(): Promise { + return await this.run(async () => { + await this.open(); + const product = STEELSERIES_PRODUCTS.get(this.device.productId); + const name = this.device.productName?.trim() || `SteelSeries ${product?.model ?? "Sensei Pro / Rival Pro"}`; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-sensei-rival-pro", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: "The Sensei Pro / Rival Pro cannot report its current DPI or polling rate through this driver; values shown are the last written by this app, or assumed defaults.", + defaultDisplayName: "SteelSeries Sensei Pro / Rival Pro", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: this.lastWritten.dpi ?? DEFAULT_DPI, + pollingRateHz: this.lastWritten.pollingRateHz ?? DEFAULT_POLLING_HZ, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + connectionType: "Wireless", + liftOffDistance: null, + firmware: [], + }; + }); + } + + async setDpi(dpi: number): Promise { + const preset: SenseiRivalProDpiPreset = { + x: dpi, + y: dpi, + liftOffDistance: DEFAULT_LIFT_OFF_DISTANCE, + landingDistance: DEFAULT_LANDING_DISTANCE, + }; + const report = steelseriesSenseiRivalProEncodeDpiPresets( + [preset, preset, preset, preset, preset], + 0, + ); + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(50); + await this.write(steelseriesSenseiRivalProSaveCommand()); + this.lastWritten.dpi = dpi; + }); + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + const report = steelseriesSenseiRivalProEncodePollingRate(0, pollingRateHz); + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(50); + await this.write(steelseriesSenseiRivalProSaveCommand()); + this.lastWritten.pollingRateHz = pollingRateHz; + }); + return pollingRateHz; + } + + async setButtonsMapping(mapping: Partial>): Promise { + const report = steelseriesSenseiRivalProEncodeButtonsMapping(mapping); + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(50); + await this.write(steelseriesSenseiRivalProSaveCommand()); + }); + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(SENSEI_RIVAL_PRO_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..160fde3d 100644 --- a/src/steelseries/devices.ts +++ b/src/steelseries/devices.ts @@ -61,6 +61,15 @@ * `prime_wireless_wired.py`/`prime_wireless_wireless.py`, but the plain * Prime Wireless's PIDs (`0x1842` wired / `0x1840` 2.4 GHz) are out of scope * for this cluster and deliberately not added here. + * + * The Sensei Pro / Rival Pro (`"sensei-rival-pro"`, base station `0x1880` / + * dongle `0x1882` — see `./sensei-rival-pro.ts`) is its own family per the + * same rule: a distinct command set (raw-uint16 XY DPI with per-preset + * lift-off/landing, dual report/polling rates up to 8000 Hz) reconstructed + * from the vendor's own SteelSeries GG device specification rather than + * from rivalcfg, which does not cover these mice. The four models share one + * specification and report the model themselves; only the station and dongle + * USB product ids are registered. */ export const STEELSERIES_VENDOR_ID = 0x1038; @@ -77,7 +86,8 @@ export type SteelSeriesProtocolFamily = | "aerox9-wireless" | "prime-plus" | "prime-mini-wireless" - | "sensei-ten"; + | "sensei-ten" + | "sensei-rival-pro"; export interface SteelSeriesProduct { model: string; @@ -383,4 +393,24 @@ export const STEELSERIES_PRODUCTS: ReadonlyMap = new hasFirmwareQuery: true, verified: false, }], + // Sensei Pro / Rival Pro share one vendor specification: the mouse reports + // its model and color itself (`read_device_model`), and the host talks to + // the base station or the dongle. Only those two USB product ids appear in + // the shipped specification; individual mouse-side ids are not listed. + [0x1880, { + model: "Sensei Pro / Rival Pro (base station)", + family: "sensei-rival-pro", + wireless: true, + settingsReadable: false, + hasFirmwareQuery: false, + verified: false, + }], + [0x1882, { + model: "Sensei Pro / Rival Pro (dongle)", + family: "sensei-rival-pro", + wireless: true, + settingsReadable: false, + hasFirmwareQuery: false, + verified: false, + }], ]); diff --git a/src/steelseries/index.ts b/src/steelseries/index.ts index dacc7846..7148876d 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -10,4 +10,5 @@ export * from "./aerox9-wireless.js"; export * from "./prime-plus.js"; export * from "./prime-mini-wireless.js"; export * from "./sensei-ten.js"; +export * from "./sensei-rival-pro.js"; export * from "./devices.js"; diff --git a/src/steelseries/sensei-rival-pro.test.ts b/src/steelseries/sensei-rival-pro.test.ts new file mode 100644 index 00000000..210cb353 --- /dev/null +++ b/src/steelseries/sensei-rival-pro.test.ts @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + SENSEI_RIVAL_PRO_DEFAULT_DPI_PRESETS, + SENSEI_RIVAL_PRO_DPI_MAX, + SENSEI_RIVAL_PRO_DPI_MIN, + SENSEI_RIVAL_PRO_DPI_PRESETS, + SENSEI_RIVAL_PRO_POLLING_RATES, + SenseiRivalProProtocolError, + steelseriesSenseiRivalProEncodeButtonsMapping, + steelseriesSenseiRivalProEncodeDpiPresets, + steelseriesSenseiRivalProEncodePollingBatch, + steelseriesSenseiRivalProEncodePollingRate, + steelseriesSenseiRivalProSaveCommand, +} from "./sensei-rival-pro.ts"; + +test("DPI presets encode x/y little-endian plus lift-off and landing bytes", () => { + assert.deepEqual( + [...steelseriesSenseiRivalProEncodeDpiPresets( + [ + { x: 400, y: 400, liftOffDistance: 1, landingDistance: 1 }, + { x: 800, y: 800, liftOffDistance: 1, landingDistance: 1 }, + { x: 1600, y: 1600, liftOffDistance: 1, landingDistance: 1 }, + { x: 2400, y: 2400, liftOffDistance: 1, landingDistance: 1 }, + { x: 3200, y: 3200, liftOffDistance: 1, landingDistance: 1 }, + ], + 2, + )], + [ + 0x34, 0x05, 0x02, + 0x90, 0x01, 0x90, 0x01, 0x01, 0x01, + 0x20, 0x03, 0x20, 0x03, 0x01, 0x01, + 0x40, 0x06, 0x40, 0x06, 0x01, 0x01, + 0x60, 0x09, 0x60, 0x09, 0x01, 0x01, + 0x80, 0x0c, 0x80, 0x0c, 0x01, 0x01, + ], + ); +}); + +test("DPI presets keep independent X and Y axes", () => { + const report = steelseriesSenseiRivalProEncodeDpiPresets( + [ + { x: 800, y: 1600, liftOffDistance: 0, landingDistance: 0 }, + { x: 800, y: 1600, liftOffDistance: 0, landingDistance: 0 }, + { x: 800, y: 1600, liftOffDistance: 0, landingDistance: 0 }, + { x: 800, y: 1600, liftOffDistance: 0, landingDistance: 0 }, + { x: 800, y: 1600, liftOffDistance: 0, landingDistance: 0 }, + ], + 0, + ); + assert.deepEqual([...report.slice(0, 9)], [0x34, 0x05, 0x00, 0x20, 0x03, 0x40, 0x06, 0x00, 0x00]); +}); + +test("DPI presets require exactly five entries and a valid index", () => { + const preset = { x: 800, y: 800, liftOffDistance: 0, landingDistance: 0 }; + assert.throws( + () => steelseriesSenseiRivalProEncodeDpiPresets([preset, preset], 0), + SenseiRivalProProtocolError, + ); + assert.throws( + () => steelseriesSenseiRivalProEncodeDpiPresets([preset, preset, preset, preset, preset], 5), + SenseiRivalProProtocolError, + ); + assert.throws( + () => steelseriesSenseiRivalProEncodeDpiPresets( + [preset, preset, preset, preset, { x: 0, y: 800, liftOffDistance: 0, landingDistance: 0 }], + 0, + ), + SenseiRivalProProtocolError, + ); + assert.throws( + () => steelseriesSenseiRivalProEncodeDpiPresets( + [preset, preset, preset, preset, { x: 800, y: 800, liftOffDistance: 256, landingDistance: 0 }], + 0, + ), + SenseiRivalProProtocolError, + ); + assert.equal(SENSEI_RIVAL_PRO_DPI_MIN, 1); + assert.equal(SENSEI_RIVAL_PRO_DPI_MAX, 65000); + assert.equal(SENSEI_RIVAL_PRO_DPI_PRESETS, 5); + assert.deepEqual([...SENSEI_RIVAL_PRO_DEFAULT_DPI_PRESETS], [400, 800, 1600, 2400, 3200]); +}); + +test("polling rates use the vendor value table, including 8K", () => { + assert.deepEqual( + [...steelseriesSenseiRivalProEncodePollingRate(0, 8000)], + [0x2c, 0x00, 0x00], + ); + assert.deepEqual( + [...steelseriesSenseiRivalProEncodePollingRate(4, 125)], + [0x2c, 0x04, 0x06], + ); + assert.deepEqual( + [...steelseriesSenseiRivalProEncodePollingRate(2, 1000)], + [0x2c, 0x02, 0x03], + ); + assert.deepEqual([...SENSEI_RIVAL_PRO_POLLING_RATES], [125, 250, 500, 1000, 2000, 4000, 8000]); + assert.throws(() => steelseriesSenseiRivalProEncodePollingRate(0, 5000), SenseiRivalProProtocolError); + assert.throws(() => steelseriesSenseiRivalProEncodePollingRate(5, 1000), SenseiRivalProProtocolError); +}); + +test("polling batch writes five report/polling profile pairs", () => { + const profile = { reportRateHz: 8000, pollingRateHz: 8000 }; + assert.deepEqual( + [...steelseriesSenseiRivalProEncodePollingBatch([profile, profile, profile, profile, profile], 0)], + [0x2d, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + ); + assert.deepEqual( + [...steelseriesSenseiRivalProEncodePollingBatch( + [ + { reportRateHz: 1000, pollingRateHz: 8000 }, + { reportRateHz: 1000, pollingRateHz: 8000 }, + { reportRateHz: 1000, pollingRateHz: 8000 }, + { reportRateHz: 1000, pollingRateHz: 8000 }, + { reportRateHz: 1000, pollingRateHz: 8000 }, + ], + 4, + ).slice(0, 5)], + [0x2d, 0x05, 0x04, 0x03, 0x00], + ); + assert.throws( + () => steelseriesSenseiRivalProEncodePollingBatch([profile, profile], 0), + SenseiRivalProProtocolError, + ); +}); + +test("save command is one byte", () => { + assert.deepEqual([...steelseriesSenseiRivalProSaveCommand()], [0x09]); +}); + +test("button mapping encodes eight 5-byte slots", () => { + const report = steelseriesSenseiRivalProEncodeButtonsMapping({ + button1: { type: "button", target: "button1" }, + button6: { type: "dpiSwitch" }, + scrollUp: { type: "button", target: "scrollUp" }, + }); + assert.equal(report[0], 0x2a); + assert.equal(report.length, 1 + 8 * 5); + assert.equal(report[1], 0x01); + assert.equal(report[1 + 5 * 5], 0x30); + assert.equal(report[1 + 6 * 5], 0x31); + assert.equal(report[2], 0x00); +}); + +test("button mapping supports scroll, keyboard, and multimedia targets", () => { + const report = steelseriesSenseiRivalProEncodeButtonsMapping({ + button4: { type: "scrollUp" }, + button5: { type: "keyboard", code: 0x04 }, + button6: { type: "multimedia", code: 0xe9 }, + }); + assert.equal(report[1 + 3 * 5], 0x31); + assert.equal(report[1 + 4 * 5], 0x51); + assert.equal(report[1 + 4 * 5 + 1], 0x04); + assert.equal(report[1 + 5 * 5], 0x61); + assert.equal(report[1 + 5 * 5 + 1], 0xe9); +}); + +test("button mapping rejects unknown buttons and targets", () => { + assert.throws( + // @ts-expect-error unknown button name + () => steelseriesSenseiRivalProEncodeButtonsMapping({ button9: { type: "disabled" } }), + SenseiRivalProProtocolError, + ); + assert.throws( + () => steelseriesSenseiRivalProEncodeButtonsMapping({ + button1: { type: "button", target: "button9" as never }, + }), + SenseiRivalProProtocolError, + ); +}); diff --git a/src/steelseries/sensei-rival-pro.ts b/src/steelseries/sensei-rival-pro.ts new file mode 100644 index 00000000..6bef6b9c --- /dev/null +++ b/src/steelseries/sensei-rival-pro.ts @@ -0,0 +1,374 @@ +/** + * SteelSeries Sensei Pro / Rival Pro configuration protocol — pure encode helpers. + * + * Reconstructed from the vendor's own software, not from a third-party + * reimplementation: SteelSeries GG 121.0.0 ships per-device Lisp + * specifications under `apps/engine/install/deviceSpecifications/`, and this + * module transcribes `sensei_rival_pro_shared.lisp` (plus the `dongle` and + * `base_station` files for product ids). Those files are OpenPGP-armored + * ("DESCRIPTOR" armor, SKESK/AES-128); the bytes below are the documented + * HID report payloads, not guesses. No other public implementation of this + * protocol family was available to corroborate against — rivalcfg does not + * cover these mice — so treat every byte as vendor-documented but not yet + * hardware-verified. + * + * The four models (Sensei Pro, Sensei Pro Mini, Rival Pro, Rival Pro Mini) + * share one specification: the mouse reports its model and color itself + * (`read_device_model` / `read_color_spin`, `translate_device_model` + * 0 = sensei_pro, 1 = sensei_pro_mini, 2 = rival_pro, 3 = rival_pro_mini). + * The host talks to the base station (`1038:1880`) or the dongle + * (`1038:1882`); individual mouse USB product ids are not listed in the + * shipped specification, so only the station and dongle ids are registered + * in `./devices.ts`. + * + * Every command is an HID **output report** with report id 0x00, 65-byte + * reports (`chunk HIDIO 65` throughout the specification). + * + * DPI is carried as raw little-endian uint16 values — there is no sensor + * lookup table in this family (unlike TrueMove Core/Air). Each of the 5 + * presets is one 6-byte `mouse_sensitivity` struct: x_dpi (2 bytes), + * y_dpi (2 bytes), lift_off_distance (1 byte), landing_distance (1 byte). + * The specification fixes `num_dpi_levels` to the 0x01–0x05 range and the + * selected `index` to 0x00–0x04 (0-based), and its own write-success handler + * reads back all five 6-byte slots, so this codec always writes full + * five-preset tables rather than guessing at padding for shorter ones. + * No DPI minimum or maximum appears in the specification; the 1–65000 range + * accepted here follows SteelSeries' advertised range (42K sensor, GG + * allows up to 65K per contemporary reviews) and is flagged as such — it is + * not a vendor-file claim. + * + * Polling uses SteelSeries' dual-rate scheme: every level carries a sensor + * report rate and a polling rate side by side. The specification's own + * `translate_polling_and_report_rate_to_ui` maps value bytes to hertz: + * 0→8000, 1→4000, 2→2000, 3→1000, 4→500, 5→250, 6→125. The single-setting + * command (`0x2C`) takes an `index` (0–4) plus one `rate` byte; the batch + * command (`0x2D`) takes `num_levels` (1–5), an `index` (0–4), and five + * `{report_rate, polling_rate}` profile pairs, with the same full-table + * policy as DPI: exactly five profiles are written. + * + * Buttons are one 40-byte packet (8 slots × 5-byte `key_mapping` fields: + * function byte plus 4 key-code bytes), command `0x2A`: 6 physical buttons + * plus scroll up/down. Keyboard (`0x51`) and multimedia (`0x61`) targets + * take raw code bytes — the specification names no key tables, so unlike + * some sibling families there is nothing to corroborate names against. + * Scroll-up/scroll-down are valid remap targets but, as in the sibling + * families, only physical buttons are remap sources here. + * + * Known but deliberately not implemented — documented so nobody re-derives + * them, withheld until there is hardware evidence and a reason to ship them: + * + * - `0xB4` — read back the DPI preset table (same 5×6-byte layout). + * - `0xAC` / `0xAD` — read back the single/batch polling configuration. + * - `0xAA` — read back the button mapping (`command_status` + slots). + * - `0x92` — battery status read; `0x29` — sleep timer (uint32 ms); + * `0xA5` — LED indicator (DPI-level colors) read. + * - High-efficiency mode, wireless stability, mouse rotation, acceleration + * tables, scroll jump, motion sync, LAN mode (`0x15`–`0x17`, `0x95`–`0x97`, + * `0xBE`), and the base-station/dongle management commands. + * + * The mice expose no configurable RGB lighting — only the DPI-level + * indicator LED — so this family has no lighting encoder by design, not by + * omission. Settings apply on write; the save command (`0x09`, one byte) + * persists them. None of this has been verified on physical hardware by + * this project. + */ + +export const SENSEI_RIVAL_PRO_REPORT_ID = 0x00; + +/** Command prefixes; the payload is the prefix plus its arguments. */ +export const SENSEI_RIVAL_PRO_COMMAND = { + dpiPresets: [0x34], + pollingRate: [0x2c], + pollingBatch: [0x2d], + buttonsMapping: [0x2a], + /** `0xB4` — read back the DPI table; documented, not decoded here. */ + dpiPresetsQuery: [0xb4], + /** `0xAC` — read back the single polling rate; documented, not decoded here. */ + pollingRateQuery: [0xac], + /** `0xAD` — read back the batch polling table; documented, not decoded here. */ + pollingBatchQuery: [0xad], + /** `0xAA` — read back the button mapping; documented, not decoded here. */ + buttonsMappingQuery: [0xaa], +} as const; + +/** `0x09` — one byte; commit the current settings to onboard flash. */ +export const SENSEI_RIVAL_PRO_SAVE_COMMAND = [0x09] as const; + +export const SENSEI_RIVAL_PRO_POLLING_RATES = [125, 250, 500, 1000, 2000, 4000, 8000] as const; + +/** + * The specification's `translate_polling_and_report_rate_to_ui` table, + * verbatim: 0→8000, 1→4000, 2→2000, 3→1000, 4→500, 5→250, 6→125. + */ +const POLLING_RATE_TO_BYTE: ReadonlyMap = new Map([ + [8000, 0x00], + [4000, 0x01], + [2000, 0x02], + [1000, 0x03], + [500, 0x04], + [250, 0x05], + [125, 0x06], +]); + +/** + * No DPI bounds appear in the specification (raw uint16 per axis). The + * 1–65000 range follows SteelSeries' advertised range for these mice (42K + * sensor; GG accepts up to 65K per contemporary reviews) — it is not a + * vendor-file claim, and out-of-range values are rejected rather than + * clamped so a caller is never silently given a different sensitivity. + */ +export const SENSEI_RIVAL_PRO_DPI_MIN = 1; +export const SENSEI_RIVAL_PRO_DPI_MAX = 65000; +export const SENSEI_RIVAL_PRO_DPI_PRESETS = 5; + +/** Factory defaults (400/800/1600/2400/3200) per SteelSeries support. */ +export const SENSEI_RIVAL_PRO_DEFAULT_DPI_PRESETS = [400, 800, 1600, 2400, 3200] as const; + +/** + * UI DPI options: 50–65000 in 50 DPI steps. The wire format accepts any + * 1–65000 integer; this list is only a picker hint following the 50-step + * convention of the other linear SteelSeries families. + */ +export function steelseriesSenseiRivalProDpiOptions(): number[] { + const options: number[] = []; + for (let dpi = 50; dpi <= SENSEI_RIVAL_PRO_DPI_MAX; dpi += 50) { + options.push(dpi); + } + return options; +} + +export class SenseiRivalProProtocolError extends Error {} + +/** One DPI preset: independent X/Y sensitivity plus lift-off/landing distances. */ +export interface SenseiRivalProDpiPreset { + x: number; + y: number; + /** Lift-off distance byte; no vendor range — passed through. */ + liftOffDistance: number; + /** Landing distance byte; no vendor range — passed through. */ + landingDistance: number; +} + +function checkByte(value: number, what: string): number { + if (!Number.isInteger(value) || value < 0 || value > 255) { + throw new SenseiRivalProProtocolError(`${what} must be an integer 0–255.`); + } + return value; +} + +function checkDpi(value: number): void { + if (!Number.isInteger(value) || value < SENSEI_RIVAL_PRO_DPI_MIN || value > SENSEI_RIVAL_PRO_DPI_MAX) { + throw new SenseiRivalProProtocolError( + `SteelSeries Sensei Pro / Rival Pro DPI must be ${SENSEI_RIVAL_PRO_DPI_MIN}–${SENSEI_RIVAL_PRO_DPI_MAX.toLocaleString("en-US")}.`, + ); + } +} + +function encodePreset(preset: SenseiRivalProDpiPreset): number[] { + checkDpi(preset.x); + checkDpi(preset.y); + checkByte(preset.liftOffDistance, "Lift-off distance"); + checkByte(preset.landingDistance, "Landing distance"); + return [ + preset.x & 0xff, (preset.x >> 8) & 0xff, + preset.y & 0xff, (preset.y >> 8) & 0xff, + preset.liftOffDistance, preset.landingDistance, + ]; +} + +/** + * `34 <5× (x u16LE, y u16LE, lod, lad)>` — + * replaces the mouse's whole preset table. The specification's write-success + * handler always reads back all five 6-byte slots, so exactly five presets + * are required; `selectedIndex` is 0-based here and on the wire. + */ +export function steelseriesSenseiRivalProEncodeDpiPresets( + presets: readonly SenseiRivalProDpiPreset[], + selectedIndex: number, +): Uint8Array { + if (presets.length !== SENSEI_RIVAL_PRO_DPI_PRESETS) { + throw new SenseiRivalProProtocolError( + `SteelSeries Sensei Pro / Rival Pro requires exactly ${SENSEI_RIVAL_PRO_DPI_PRESETS} DPI presets.`, + ); + } + if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= presets.length) { + throw new SenseiRivalProProtocolError( + `Selected DPI preset must be an index into the ${presets.length} presets being written.`, + ); + } + return new Uint8Array([ + ...SENSEI_RIVAL_PRO_COMMAND.dpiPresets, + presets.length, + selectedIndex, + ...presets.flatMap(encodePreset), + ]); +} + +function pollingRateToByte(pollingRateHz: number): number { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new SenseiRivalProProtocolError( + `SteelSeries Sensei Pro / Rival Pro supports ${SENSEI_RIVAL_PRO_POLLING_RATES.join(", ")} Hz polling.`, + ); + } + return byte; +} + +/** + * `2C ` — one polling level. The same value byte selects + * both the polling and the sensor report rate scales. + */ +export function steelseriesSenseiRivalProEncodePollingRate( + index: number, + pollingRateHz: number, +): Uint8Array { + if (!Number.isInteger(index) || index < 0 || index > 4) { + throw new SenseiRivalProProtocolError("SteelSeries Sensei Pro / Rival Pro polling index must be 0–4."); + } + return new Uint8Array([...SENSEI_RIVAL_PRO_COMMAND.pollingRate, index, pollingRateToByte(pollingRateHz)]); +} + +/** One performance profile: sensor report rate plus polling rate. */ +export interface SenseiRivalProPollingProfile { + reportRateHz: number; + pollingRateHz: number; +} + +/** + * `2D <5× (report byte, polling byte)>` — the + * whole polling table. Exactly five profiles are required, matching what + * the specification reads back. + */ +export function steelseriesSenseiRivalProEncodePollingBatch( + profiles: readonly SenseiRivalProPollingProfile[], + selectedIndex: number, +): Uint8Array { + if (profiles.length !== SENSEI_RIVAL_PRO_DPI_PRESETS) { + throw new SenseiRivalProProtocolError( + "SteelSeries Sensei Pro / Rival Pro requires exactly 5 polling profiles.", + ); + } + if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= profiles.length) { + throw new SenseiRivalProProtocolError("Selected polling profile must be an index into the 5 profiles being written."); + } + return new Uint8Array([ + ...SENSEI_RIVAL_PRO_COMMAND.pollingBatch, + profiles.length, + selectedIndex, + ...profiles.flatMap((profile) => [ + pollingRateToByte(profile.reportRateHz), + pollingRateToByte(profile.pollingRateHz), + ]), + ]); +} + +/** `09` — commit the current settings to onboard flash. */ +export function steelseriesSenseiRivalProSaveCommand(): Uint8Array { + return new Uint8Array(SENSEI_RIVAL_PRO_SAVE_COMMAND); +} + +// -- Button mapping -------------------------------------------------------- + +/** `key_mapping.function` values from the specification. */ +export const SENSEI_RIVAL_PRO_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 }, + scrollUp: { id: 0x31, offset: 0x1e }, + scrollDown: { id: 0x32, offset: 0x23 }, +} as const satisfies Record; + +export type SenseiRivalProButtonName = keyof typeof SENSEI_RIVAL_PRO_BUTTONS; + +const BUTTON_FIELD_LENGTH = 5; +const BUTTON_DISABLE = 0x00; +const BUTTON_DPI_SWITCH = 0x30; +const BUTTON_KEYBOARD = 0x51; +const BUTTON_MULTIMEDIA = 0x61; + +/** + * One button's target. The specification names no keyboard/multimedia key + * tables, so those targets are raw code bytes. `0x71` (macro) is marked + * not supported by the specification and is rejected. + */ +export type SenseiRivalProButtonAction = + | { type: "button"; target: SenseiRivalProButtonName } + | { 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 SenseiRivalProProtocolError(`${kind} codes must be integers 0–255.`); + } + return code; +} + +/** + * `2A` + one 40-byte packet (8 buttons × 5-byte fields: function byte plus + * 4 key-code bytes), zero-filled except where a mapping is given. Pass + * every button explicitly — unmapped buttons are left disabled, never + * defaulted, so a partial mapping cannot silently wipe the rest. + */ +export function steelseriesSenseiRivalProEncodeButtonsMapping( + mapping: Partial>, +): Uint8Array { + for (const name of Object.keys(mapping)) { + if (!Object.hasOwn(SENSEI_RIVAL_PRO_BUTTONS, name)) { + throw new SenseiRivalProProtocolError(`Unknown SteelSeries Sensei Pro / Rival Pro button "${name}".`); + } + } + + const names = Object.keys(SENSEI_RIVAL_PRO_BUTTONS) as SenseiRivalProButtonName[]; + const packet = new Array(names.length * BUTTON_FIELD_LENGTH).fill(0x00); + + for (const [name, action] of Object.entries(mapping) as Array<[SenseiRivalProButtonName, SenseiRivalProButtonAction]>) { + const { offset } = SENSEI_RIVAL_PRO_BUTTONS[name]; + switch (action.type) { + case "button": { + if (!Object.hasOwn(SENSEI_RIVAL_PRO_BUTTONS, action.target)) { + throw new SenseiRivalProProtocolError(`Unknown SteelSeries Sensei Pro / Rival Pro button "${action.target}".`); + } + packet[offset] = SENSEI_RIVAL_PRO_BUTTONS[action.target].id; + break; + } + case "disabled": { + packet[offset] = BUTTON_DISABLE; + break; + } + case "dpiSwitch": { + packet[offset] = BUTTON_DPI_SWITCH; + break; + } + case "scrollUp": { + packet[offset] = SENSEI_RIVAL_PRO_BUTTONS.scrollUp.id; + break; + } + case "scrollDown": { + packet[offset] = SENSEI_RIVAL_PRO_BUTTONS.scrollDown.id; + break; + } + case "keyboard": { + packet[offset] = BUTTON_KEYBOARD; + packet[offset + 1] = encodeKeyCode(action.code, "Keyboard scan"); + break; + } + case "multimedia": { + packet[offset] = BUTTON_MULTIMEDIA; + packet[offset + 1] = encodeKeyCode(action.code, "Multimedia key"); + break; + } + default: { + throw new SenseiRivalProProtocolError("Unsupported SteelSeries Sensei Pro / Rival Pro button action."); + } + } + } + + return new Uint8Array([...SENSEI_RIVAL_PRO_COMMAND.buttonsMapping, ...packet]); +} From f02880323b2b7abbb4958e3a3f0031230272a843 Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 17:42:40 -0600 Subject: [PATCH 02/15] feat: register plain Prime and Prime Wireless product IDs prime.py is byte-identical to prime_plus.py and the plain Prime Wireless shares the Prime Mini Wireless settings dict, so 0x182E/0x182A/0x1856 and 0x1842/0x1840 reuse the prime-plus and prime-mini-wireless codecs with no new modules. Unverified on hardware. --- .../prime-mini-wireless-hid.test.ts | 9 ++- .../steelseries/prime-plus-hid.test.ts | 15 +++-- src/steelseries/devices.ts | 61 +++++++++++++++++-- src/steelseries/prime-mini-wireless.ts | 23 +++---- src/steelseries/prime-plus.ts | 9 +-- 5 files changed, 83 insertions(+), 34 deletions(-) diff --git a/src/drivers/steelseries/prime-mini-wireless-hid.test.ts b/src/drivers/steelseries/prime-mini-wireless-hid.test.ts index 095e02dd..15b6a601 100644 --- a/src/drivers/steelseries/prime-mini-wireless-hid.test.ts +++ b/src/drivers/steelseries/prime-mini-wireless-hid.test.ts @@ -40,9 +40,11 @@ function fakeDevice( return { device: device as unknown as HIDDevice, sent }; } -test("claims only the Prime Mini Wireless PIDs (both transports) and nothing else", () => { +test("claims the Prime Mini Wireless and plain Prime Wireless PIDs (both transports) and nothing else", () => { const { device } = fakeDevice(); - for (const productId of [0x184a, 0x1848]) { + // The plain Prime Wireless shares the Prime Mini Wireless settings dict, + // so both models share this driver. + for (const productId of [0x184a, 0x1848, 0x1842, 0x1840]) { assert.equal( SteelSeriesPrimeMiniWirelessHidClient.isSupported({ ...device, productId } as HIDDevice), true, @@ -61,9 +63,6 @@ test("claims only the Prime Mini Wireless PIDs (both transports) and nothing els assert.equal(SteelSeriesPrimeMiniWirelessHidClient.isSupported({ ...device, productId: 0x1852 } as HIDDevice), false); // Aerox 5 Wireless (2.4GHz) assert.equal(SteelSeriesPrimeMiniWirelessHidClient.isSupported({ ...device, productId: 0x185c } as HIDDevice), false); // Aerox 5 Wireless Destiny 2 (2.4GHz) assert.equal(SteelSeriesPrimeMiniWirelessHidClient.isSupported({ ...device, productId: 0x1860 } as HIDDevice), false); // Aerox 5 Wireless Diablo IV (2.4GHz) - // Out-of-scope sibling PIDs from the same rivalcfg profile (plain Prime Wireless) must not be claimed. - assert.equal(SteelSeriesPrimeMiniWirelessHidClient.isSupported({ ...device, productId: 0x1842 } as HIDDevice), false); // Prime Wireless (wired) - assert.equal(SteelSeriesPrimeMiniWirelessHidClient.isSupported({ ...device, productId: 0x1840 } as HIDDevice), false); // Prime Wireless (2.4GHz) assert.equal(SteelSeriesPrimeMiniWirelessHidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); }); diff --git a/src/drivers/steelseries/prime-plus-hid.test.ts b/src/drivers/steelseries/prime-plus-hid.test.ts index c3ae1476..0f7a751e 100644 --- a/src/drivers/steelseries/prime-plus-hid.test.ts +++ b/src/drivers/steelseries/prime-plus-hid.test.ts @@ -27,9 +27,17 @@ function fakeDevice(options: { productId?: number } = {}) { return { device: device as unknown as HIDDevice, sent }; } -test("claims only the Prime+ product id", () => { +test("claims the Prime family product ids and nothing else", () => { const { device } = fakeDevice(); - assert.equal(SteelSeriesPrimePlusHidClient.isSupported(device), true); + // prime.py is byte-identical to prime_plus.py, so the plain Prime models + // share this driver with Prime+. + for (const productId of [0x182c, 0x182e, 0x182a, 0x1856]) { + assert.equal( + SteelSeriesPrimePlusHidClient.isSupported({ ...device, productId } as HIDDevice), + true, + `expected 0x${productId.toString(16)} to be claimed`, + ); + } // Every sibling-cluster PID (Rival 3 Gen 1, Aerox 3, Rival 3 Wireless, // Aerox 5, Aerox 5 Wireless, Rival 650, Aerox 9 Wireless) must not be // claimed by this driver. @@ -45,9 +53,6 @@ test("claims only the Prime+ product id", () => { 0x1726, // Rival 650 Wireless (2.4 GHz mode) 0x185a, // Aerox 9 Wireless (wired mode) 0x1858, // Aerox 9 Wireless (2.4 GHz mode) - 0x182e, // plain Prime — must NOT be claimed - 0x182a, // Prime Rainbow 6 Siege Black Ice Edition — must NOT be claimed - 0x1856, // Prime CS:GO Neo Noir Edition — must NOT be claimed ]; for (const productId of foreignPids) { assert.equal( diff --git a/src/steelseries/devices.ts b/src/steelseries/devices.ts index 8efdb71c..8e89a3e6 100644 --- a/src/steelseries/devices.ts +++ b/src/steelseries/devices.ts @@ -50,17 +50,17 @@ * The Prime+ (`0x182C`, family `"prime-plus"`) is its own family per this * rollout's per-cluster scoping, even though — see `./prime-plus.ts`'s doc * comment for the full corroboration-gap disclosure — its rivalcfg profile - * (`prime_plus.py`) is byte-identical to plain Prime's (`prime.py`, PIDs - * `0x182E`/`0x182A`/`0x1856`, none of which are listed here: plain/non-Plus - * Prime is out of scope for this cluster and deliberately not added). + * (`prime_plus.py`) is byte-identical to plain Prime's (`prime.py`). The + * plain Prime PIDs (`0x182E`, plus `0x182A`/`0x1856`) reuse this family's + * codec; no separate module exists. * * The Prime Mini Wireless (`"prime-mini-wireless"`, PIDs `0x184A` wired / * `0x1848` 2.4 GHz — see `./prime-mini-wireless.ts`) is its own family for * the same reason: rivalcfg documents it and the plain (non-Mini) Prime * Wireless as one shared `settings` dict inside - * `prime_wireless_wired.py`/`prime_wireless_wireless.py`, but the plain - * Prime Wireless's PIDs (`0x1842` wired / `0x1840` 2.4 GHz) are out of scope - * for this cluster and deliberately not added here. + * `prime_wireless_wired.py`/`prime_wireless_wireless.py`. The plain Prime + * Wireless's PIDs (`0x1842` wired / `0x1840` 2.4 GHz) reuse this family's + * codec; no separate module exists. */ export const STEELSERIES_VENDOR_ID = 0x1038; @@ -311,6 +311,34 @@ export const STEELSERIES_PRODUCTS: ReadonlyMap = new hasFirmwareQuery: false, verified: false, }], + // prime.py is byte-identical to prime_plus.py (same sensitivity 0x61, + // polling 0x5D, color, brightness, buttons 0x5B, save 0x59 — verified by + // direct comparison when prime-plus.ts was written), so these PIDs reuse + // the Prime+ codec with no new module. + [0x182e, { + model: "Prime", + family: "prime-plus", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: false, + verified: false, + }], + [0x182a, { + model: "Prime 6 Siege Black Ice Edition", + family: "prime-plus", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: false, + verified: false, + }], + [0x1856, { + model: "Prime CS:GO Neo Noir Edition", + family: "prime-plus", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: false, + verified: false, + }], // rival310.py: wired only, endpoint 0. Own command set (0x53/0x54/0x5B/ // 0x31), a linear (non-table) DPI encoding, and a readable firmware // version behind 0x90 00 — see ./rival310.ts's doc comment, including its @@ -362,6 +390,27 @@ export const STEELSERIES_PRODUCTS: ReadonlyMap = new hasFirmwareQuery: false, verified: false, }], + // The plain (non-Mini) Prime Wireless shares the Prime Mini Wireless + // command set byte-for-byte (one shared `settings` dict in + // `prime_wireless_wired.py` / `prime_wireless_wireless.py`, confirmed + // against the vendor's own GG device specification), so these PIDs reuse + // the Prime Mini Wireless codec with no new module. + [0x1842, { + model: "Prime Wireless (wired mode)", + family: "prime-mini-wireless", + wireless: true, + settingsReadable: false, + hasFirmwareQuery: false, + verified: false, + }], + [0x1840, { + model: "Prime Wireless (2.4 GHz mode)", + family: "prime-mini-wireless", + wireless: true, + settingsReadable: false, + hasFirmwareQuery: false, + verified: false, + }], // sensei_ten.py: wired only, endpoint 0. Own command set (0x54/0x55/0x5B/ // 0x31/0x59), a linear (non-table) DPI encoding distinct from rival310's // (2-byte little-endian words vs. 1-byte), and a readable firmware version diff --git a/src/steelseries/prime-mini-wireless.ts b/src/steelseries/prime-mini-wireless.ts index df848e9b..6d9fbab5 100644 --- a/src/steelseries/prime-mini-wireless.ts +++ b/src/steelseries/prime-mini-wireless.ts @@ -15,11 +15,10 @@ * Mini Wireless), built by comprehending `prime_wireless_wired.py`'s * `settings`/`battery_level`/`save_command` through one `_patch_command` * helper — same transform this file implements as `applyPrimeMiniWirelessFlag`. - * Both files were read in full. This module implements only the two Prime - * Mini Wireless product ids (`0x184A` wired, `0x1848` 2.4 GHz) per the - * scope of this change — see "Scope" below for why the plain Prime - * Wireless PIDs (`0x1842`/`0x1840`) are deliberately not registered here - * even though the command set is identical. + * Both files were read in full. This module encodes the shared command set + * for the two Prime Mini Wireless product ids (`0x184A` wired, `0x1848` + * 2.4 GHz) and the two plain Prime Wireless product ids (`0x1842` wired, + * `0x1840` 2.4 GHz) — see "Scope" below. * - rivalcfg `rivalcfg/devices/dpi/truemove_air.py` — same TrueMove Air * sensor table as the Rival 3 Wireless and Aerox 5 Wireless — imported * from `./rival3-wireless.ts`'s `TRUEMOVE_AIR_DPI_TO_BYTE`, not re-derived. @@ -60,19 +59,15 @@ * in `PRIME_MINI_WIRELESS_COMMAND` below is the wired-mode value; * `applyPrimeMiniWirelessFlag` produces the 2.4 GHz-mode byte from it on demand. * - * ## Scope: Prime Mini Wireless only, not the plain Prime Wireless + * ## Scope: Prime Mini Wireless and the plain Prime Wireless * * `prime_wireless_wired.py`/`prime_wireless_wireless.py` document the Prime * Mini Wireless and the plain (non-Mini) Prime Wireless as one shared * `settings` dict — genuinely the same command bytes, same DPI table, same - * battery shape, differing only in physical product id. This change is - * scoped to the Prime Mini Wireless cluster only: `./devices.ts` registers - * `0x184A`/`0x1848` (Prime Mini Wireless) under family - * `"prime-mini-wireless"` and deliberately does **not** register - * `0x1842`/`0x1840` (plain Prime Wireless) — even though this module's - * exported functions would encode identical bytes for either PID group, the - * plain Prime Wireless is out of scope for this change and is left for - * whichever future change actually verifies and ships it. + * battery shape, differing only in physical product id. `./devices.ts` + * registers `0x184A`/`0x1848` (Prime Mini Wireless) and `0x1842`/`0x1840` + * (plain Prime Wireless) under family `"prime-mini-wireless"`; this + * module's exported functions encode identical bytes for either PID group. * * ## No corroboration beyond rivalcfg was possible in this environment * diff --git a/src/steelseries/prime-plus.ts b/src/steelseries/prime-plus.ts index 9f88568a..93d2dfb8 100644 --- a/src/steelseries/prime-plus.ts +++ b/src/steelseries/prime-plus.ts @@ -17,10 +17,11 @@ * libratbag, nor the OpenRGB SteelSeries controller sources reachable from * this environment (see corroboration gap below) expose any command for it. * Prime+ is still implemented as its own module/PID/family rather than - * folded into a shared "Prime" module, per this rollout's instruction to - * add Prime+ only and not touch plain Prime — but the wire protocol - * implemented here is not distinguishable from plain Prime's, and a reader - * should not infer an OLED command exists just because this module exists. + * folded into a shared "Prime" module; the plain Prime PIDs (`0x182E`, + * `0x182A`, `0x1856`) reuse this codec via `./devices.ts` because the + * wire protocol implemented here is not distinguishable from plain + * Prime's, and a reader should not infer an OLED command exists just + * because this module exists. * - rivalcfg `rivalcfg/handlers/multidpi_range.py` (DPI presets: `merge_bytes( * count, selected_preset, ...values)`, `count_mode: "number"`, 2-byte * little-endian DPI values here vs. Aerox 3's 1-byte) and From e0bc509222719862dfe92418112a1eaa89c85bb6 Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 17:47:21 -0600 Subject: [PATCH 03/15] feat: add SteelSeries Sensei 310 protocol family The vendor GG specification includes Rival 310's profile wholesale and overrides only the button table (8 slots vs 6), so this family mirrors the Rival 310 command set (DPI 0x53, polling 0x54, logo/wheel RGB, save 0x59, firmware 0x90) with its own 8-button mapping. Registers 1038:1722 with a matching WebHID driver. Unverified on hardware. --- src/drivers/registry.ts | 4 +- .../steelseries/sensei-310-hid.test.ts | 112 +++++++ src/drivers/steelseries/sensei-310-hid.ts | 211 ++++++++++++ src/steelseries/devices.ts | 14 +- src/steelseries/index.ts | 1 + src/steelseries/sensei-310.test.ts | 114 +++++++ src/steelseries/sensei-310.ts | 317 ++++++++++++++++++ 7 files changed, 771 insertions(+), 2 deletions(-) create mode 100644 src/drivers/steelseries/sensei-310-hid.test.ts create mode 100644 src/drivers/steelseries/sensei-310-hid.ts create mode 100644 src/steelseries/sensei-310.test.ts create mode 100644 src/steelseries/sensei-310.ts diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..5bb0c5ae 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -58,6 +58,7 @@ import { SteelSeriesRival310HidClient } from "./steelseries/rival310-hid.ts"; import { SteelSeriesPrimePlusHidClient } from "./steelseries/prime-plus-hid.ts"; import { SteelSeriesPrimeMiniWirelessHidClient } from "./steelseries/prime-mini-wireless-hid.ts"; import { SteelSeriesSenseiTenHidClient } from "./steelseries/sensei-ten-hid.ts"; +import { SteelSeriesSensei310HidClient } from "./steelseries/sensei-310-hid.ts"; import { GloriousHidClient } from "./glorious/hid.ts"; import { GloriousClassicHidClient } from "./glorious/classic-hid.ts"; import { GloriousCore2HidClient } from "./glorious/core2-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 | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesSensei310HidClient | 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; @@ -176,6 +177,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesPrimePlusHidClient.isSupported(device), create: (device) => new SteelSeriesPrimePlusHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesPrimeMiniWirelessHidClient.isSupported(device), create: (device) => new SteelSeriesPrimeMiniWirelessHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiTenHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiTenHidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesSensei310HidClient.isSupported(device), create: (device) => new SteelSeriesSensei310HidClient(device), score: () => 6 }, { brand: "Glorious", supports: (device) => GloriousHidClient.isSupported(device), create: (device) => new GloriousHidClient(device), score: () => 5 }, { brand: "Glorious", supports: (device) => GloriousClassicHidClient.isSupported(device), create: (device) => new GloriousClassicHidClient(device), score: () => 5 }, // Same vendor id as the classic family; disjoint by product id. diff --git a/src/drivers/steelseries/sensei-310-hid.test.ts b/src/drivers/steelseries/sensei-310-hid.test.ts new file mode 100644 index 00000000..4b1f4d1d --- /dev/null +++ b/src/drivers/steelseries/sensei-310-hid.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesSensei310HidClient } from "./sensei-310-hid.ts"; + +function fakeDevice(options: { productId?: number; firmware?: number[]; answerFirmware?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[]; feature?: boolean }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x1722, + productName: "SteelSeries Sensei 310", + 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] === 0x90 && 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 (reportId: number, data: BufferSource) => { + const view = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : new Uint8Array(data as ArrayBuffer); + sent.push({ reportId, payload: [...view], feature: true }); + }, + receiveFeatureReport: async () => { throw new Error("the Sensei 310 protocol does not read 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 Sensei 310 product id", () => { + const { device } = fakeDevice(); + assert.equal(SteelSeriesSensei310HidClient.isSupported(device), true); + // Rival 310 and every other SteelSeries family must not be claimed. + for (const productId of [0x1720, 0x171e, 0x1736, 0x1832, 0x1824, 0x1836, 0x1830, 0x1850]) { + assert.equal( + SteelSeriesSensei310HidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesSensei310HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus probes firmware and never claims to have read DPI/polling", async () => { + const { device, sent } = fakeDevice(); + const status = await new SteelSeriesSensei310HidClient(device).readStatus(); + assert.deepEqual(sent, [{ reportId: 0, payload: [0x90, 0x00] }]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Sensei 310"); + assert.deepEqual(status.firmware, ["37.0"]); + assert.equal(status.connectionType, "Wired"); + 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 firmware probe loudly", async () => { + const { device } = fakeDevice({ answerFirmware: false }); + const client = new SteelSeriesSensei310HidClient(device); + await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); +}); + +test("setDpi writes sensitivity preset 1 plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesSensei310HidClient(device); + await client.readStatus(); + sent.length = 0; + const dpi = await client.setDpi(800); + assert.equal(dpi, 800); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x53, 0x00, 0x01, 0x07, 0x00, 0x42] }, + { reportId: 0, payload: [0x59, 0x00] }, + ]); +}); + +test("setPollingRate, setLedColor, and setButtonsMapping write plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesSensei310HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setPollingRate(500); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x54, 0x00, 0x02] }, + { reportId: 0, payload: [0x59, 0x00] }, + ]); + sent.length = 0; + await client.setLedColor("wheel", 0, 255, 0); + assert.equal(sent.length, 2); + assert.equal(sent[0]?.feature, true); + assert.equal(sent[0]?.payload[0], 0x5b); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x59, 0x00] }); + sent.length = 0; + await client.setButtonsMapping({ button7: { type: "dpiSwitch" } }); + assert.equal(sent.length, 2); + assert.equal(sent[0]?.payload[0], 0x31); + assert.equal(sent[0]?.payload.length, 2 + 8 * 5); + assert.equal(sent[0]?.payload[2 + 0x1e], 0x30); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x59, 0x00] }); +}); diff --git a/src/drivers/steelseries/sensei-310-hid.ts b/src/drivers/steelseries/sensei-310-hid.ts new file mode 100644 index 00000000..07aad77b --- /dev/null +++ b/src/drivers/steelseries/sensei-310-hid.ts @@ -0,0 +1,211 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + SENSEI310_POLLING_RATES, + STEELSERIES_PRODUCTS, + STEELSERIES_SENSEI310_REPORT_ID, + STEELSERIES_VENDOR_ID, + steelseriesSensei310DecodeFirmware, + steelseriesSensei310DpiOptions, + steelseriesSensei310EncodeButtonsMapping, + steelseriesSensei310EncodeLedColor, + steelseriesSensei310EncodePollingRate, + steelseriesSensei310EncodeSensitivity1, + steelseriesSensei310FirmwareQuery, + steelseriesSensei310SaveCommand, + type Sensei310ButtonAction, + type Sensei310ButtonName, + type Sensei310Led, + type SteelSeriesSensei310Firmware, +} from "@openmouse/protocol/steelseries"; + +/** rivalcfg's `command_approve_delay`, shared across every SteelSeries family. */ +const COMMAND_DELAY_MS = 50; +/** rivalcfg reads the firmware response with a 200 ms timeout; allow slack. */ +const FIRMWARE_TIMEOUT_MS = 500; +/** Vendor default DPI levels (`raw_cpi` 800/1600 in the GG seed), shown only until this session writes a value. */ +const DEFAULT_DPI = 800; +const DEFAULT_POLLING_HZ = 1000; + +/** + * SteelSeries Sensei 310 WebHID control (`1038:1722`). + * + * The vendor GG specification includes Rival 310's profile wholesale and + * overrides only the button table (8 slots instead of 6), so this driver + * mirrors `SteelSeriesRival310HidClient`: write-only settings, with the + * two-byte firmware version (`90 00`) as the only readable value and the + * connectivity probe. See `../../steelseries/sensei-310.ts` for the full + * protocol writeup. + * + * The config channel is hidapi `endpoint 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. + * + * LED color writes are HID **feature** reports, not output reports — + * `setLedColor` routes through `device.sendFeatureReport`, unlike every + * other setter here. + */ +export class SteelSeriesSensei310HidClient { + 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 { + if (device.vendorId !== STEELSERIES_VENDOR_ID) return false; + return STEELSERIES_PRODUCTS.get(device.productId)?.family === "sensei-310"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...SENSEI310_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesSensei310DpiOptions(); } + + 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 ?? "Sensei 310"}`; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-sensei-310", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: "The Sensei 310 cannot report its current settings; values shown are the last written by this app, or assumed defaults.", + defaultDisplayName: "SteelSeries Sensei 310", + }, + 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], + }; + }); + } + + /** + * Writes sensitivity preset 1 only. The device is write-only, so the + * existing preset 2 and button/lighting configuration cannot be read and + * preserved — record them before testing. + */ + async setDpi(dpi: number): Promise { + const report = steelseriesSensei310EncodeSensitivity1(dpi); + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesSensei310SaveCommand()); + this.lastWritten.dpi = dpi; + }); + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + const report = steelseriesSensei310EncodePollingRate(pollingRateHz); + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesSensei310SaveCommand()); + this.lastWritten.pollingRateHz = pollingRateHz; + }); + return pollingRateHz; + } + + async setLedColor(led: Sensei310Led, r: number, g: number, b: number): Promise { + const report = steelseriesSensei310EncodeLedColor(led, r, g, b); + await this.run(async () => { + await this.open(); + await this.device.sendFeatureReport(STEELSERIES_SENSEI310_REPORT_ID, report.buffer as ArrayBuffer); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesSensei310SaveCommand()); + }); + } + + async setButtonsMapping(mapping: Partial>): Promise { + const report = steelseriesSensei310EncodeButtonsMapping(mapping); + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesSensei310SaveCommand()); + }); + } + + /** + * The `90 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(steelseriesSensei310FirmwareQuery()); + const payload = await response; + if (!payload) { + throw new Error("The Sensei 310 did not answer the firmware query on this interface. Add the device again and choose another entry."); + } + return steelseriesSensei310DecodeFirmware(payload); + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(STEELSERIES_SENSEI310_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..2bb083e6 100644 --- a/src/steelseries/devices.ts +++ b/src/steelseries/devices.ts @@ -77,7 +77,8 @@ export type SteelSeriesProtocolFamily = | "aerox9-wireless" | "prime-plus" | "prime-mini-wireless" - | "sensei-ten"; + | "sensei-ten" + | "sensei-310"; export interface SteelSeriesProduct { model: string; @@ -383,4 +384,15 @@ export const STEELSERIES_PRODUCTS: ReadonlyMap = new hasFirmwareQuery: true, verified: false, }], + // sensei-310 shares Rival 310's command set wholesale (the vendor + // specification includes it and overrides only the button table) — see + // ./sensei-310.ts. Own family because the button mapping differs. + [0x1722, { + model: "Sensei 310", + family: "sensei-310", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], ]); diff --git a/src/steelseries/index.ts b/src/steelseries/index.ts index dacc7846..3503ded1 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -10,4 +10,5 @@ export * from "./aerox9-wireless.js"; export * from "./prime-plus.js"; export * from "./prime-mini-wireless.js"; export * from "./sensei-ten.js"; +export * from "./sensei-310.js"; export * from "./devices.js"; diff --git a/src/steelseries/sensei-310.test.ts b/src/steelseries/sensei-310.test.ts new file mode 100644 index 00000000..7aab753c --- /dev/null +++ b/src/steelseries/sensei-310.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + SENSEI310_DPI_MAX, + SENSEI310_DPI_MIN, + SENSEI310_FIRMWARE_RESPONSE_LENGTH, + SENSEI310_POLLING_RATES, + Sensei310ProtocolError, + steelseriesSensei310DecodeFirmware, + steelseriesSensei310DpiOptions, + steelseriesSensei310EncodeButtonsMapping, + steelseriesSensei310EncodeLedColor, + steelseriesSensei310EncodePollingRate, + steelseriesSensei310EncodeSensitivity1, + steelseriesSensei310EncodeSensitivity2, + steelseriesSensei310FirmwareQuery, + steelseriesSensei310SaveCommand, +} from "./sensei-310.ts"; + +test("DPI options are the full linear 100..12000 range in 100 DPI steps", () => { + const options = steelseriesSensei310DpiOptions(); + assert.equal(options[0], SENSEI310_DPI_MIN); + assert.equal(options.at(-1), SENSEI310_DPI_MAX); + assert.equal(options.length, (SENSEI310_DPI_MAX - SENSEI310_DPI_MIN) / 100 + 1); +}); + +test("encodes sensitivity presets with the linear byte and command suffix", () => { + // byte = (dpi - 100) / 100 + assert.deepEqual([...steelseriesSensei310EncodeSensitivity1(100)], [0x53, 0x00, 0x01, 0x00, 0x00, 0x42]); + assert.deepEqual([...steelseriesSensei310EncodeSensitivity1(800)], [0x53, 0x00, 0x01, 0x07, 0x00, 0x42]); + assert.deepEqual([...steelseriesSensei310EncodeSensitivity2(1600)], [0x53, 0x00, 0x02, 0x0f, 0x00, 0x42]); + assert.deepEqual([...steelseriesSensei310EncodeSensitivity1(12000)], [0x53, 0x00, 0x01, 0x77, 0x00, 0x42]); + assert.throws(() => steelseriesSensei310EncodeSensitivity1(150), Sensei310ProtocolError); + assert.throws(() => steelseriesSensei310EncodeSensitivity2(12100), Sensei310ProtocolError); +}); + +test("encodes polling rates with the Rival 310 byte values", () => { + assert.deepEqual([...steelseriesSensei310EncodePollingRate(1000)], [0x54, 0x00, 0x01]); + assert.deepEqual([...steelseriesSensei310EncodePollingRate(500)], [0x54, 0x00, 0x02]); + assert.deepEqual([...steelseriesSensei310EncodePollingRate(250)], [0x54, 0x00, 0x03]); + assert.deepEqual([...steelseriesSensei310EncodePollingRate(125)], [0x54, 0x00, 0x04]); + assert.deepEqual([...SENSEI310_POLLING_RATES], [125, 250, 500, 1000]); + assert.throws(() => steelseriesSensei310EncodePollingRate(2000), Sensei310ProtocolError); +}); + +test("save and firmware query commands", () => { + assert.deepEqual([...steelseriesSensei310SaveCommand()], [0x59, 0x00]); + assert.deepEqual([...steelseriesSensei310FirmwareQuery()], [0x90, 0x00]); + assert.equal(SENSEI310_FIRMWARE_RESPONSE_LENGTH, 2); + assert.deepEqual(steelseriesSensei310DecodeFirmware(new Uint8Array([0x25, 0x00])), { + bytes: [0x25, 0x00], + display: "37.0", + }); + assert.throws(() => steelseriesSensei310DecodeFirmware(new Uint8Array([0x25])), Sensei310ProtocolError); +}); + +test("LED color uses the 26-byte header plus 7-byte body", () => { + const report = steelseriesSensei310EncodeLedColor("logo", 0xff, 0x00, 0x00); + assert.equal(report.length, 2 + 26 + 7); + assert.deepEqual([...report.slice(0, 2)], [0x5b, 0x00]); + const header = report.slice(2, 28); + assert.equal(header[0], 0x00); // led id (logo) + assert.equal(header[1], 0xe8); // duration LE low byte (1000 = 0x03E8) + assert.equal(header[2], 0x03); // duration LE high byte + assert.equal(header[17], 0x01); // repeat + assert.equal(header[21], 0x00); // triggers + assert.equal(header[25], 0x01); // color count + const body = report.slice(28); + assert.deepEqual([...body], [0xff, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00]); +}); + +test("wheel LED uses led id 0x01; other LED names are rejected", () => { + const report = steelseriesSensei310EncodeLedColor("wheel", 0x00, 0xff, 0x00); + assert.equal(report[2], 0x01); + assert.throws( + // @ts-expect-error invalid LED name + () => steelseriesSensei310EncodeLedColor("strip", 0, 0, 0), + Sensei310ProtocolError, + ); + assert.throws(() => steelseriesSensei310EncodeLedColor("logo", 256, 0, 0), Sensei310ProtocolError); +}); + +test("button mapping covers eight slots, unlike Rival 310's six", () => { + const report = steelseriesSensei310EncodeButtonsMapping({ + button1: { type: "button", target: "button1" }, + button7: { type: "button", target: "button7" }, + button8: { type: "dpiSwitch" }, + }); + assert.equal(report[0], 0x31); + assert.equal(report[1], 0x00); + assert.equal(report.length, 2 + 8 * 5); + assert.equal(report[2], 0x01); + assert.equal(report[2 + 0x1e], 0x07); + assert.equal(report[2 + 0x23], 0x30); +}); + +test("button mapping rejects unknown buttons and bad codes", () => { + assert.throws( + // @ts-expect-error invalid button name + () => steelseriesSensei310EncodeButtonsMapping({ button9: { type: "disabled" } }), + Sensei310ProtocolError, + ); + assert.throws( + () => steelseriesSensei310EncodeButtonsMapping({ + button1: { type: "button", target: "button9" as never }, + }), + Sensei310ProtocolError, + ); + assert.throws( + () => steelseriesSensei310EncodeButtonsMapping({ button1: { type: "keyboard", code: 300 } }), + Sensei310ProtocolError, + ); +}); diff --git a/src/steelseries/sensei-310.ts b/src/steelseries/sensei-310.ts new file mode 100644 index 00000000..085d8da4 --- /dev/null +++ b/src/steelseries/sensei-310.ts @@ -0,0 +1,317 @@ +/** + * SteelSeries Sensei 310 configuration protocol — pure encode/decode helpers. + * + * Reconstructed from public open-source implementations and the vendor's own + * software, not from vendor source code: + * + * - rivalcfg `rivalcfg/devices/sensei310.py` — same command set as + * `./rival310.ts`'s `rival310.py` (sensitivity `0x53`, polling `0x54`, + * buttons `0x31`, save `0x59`, firmware `0x90 00`), which is why this + * module mirrors that file's structure and constants. + * - The vendor's SteelSeries GG device specification (`sensei-310.lisp`, + * GG 121.0.0) is a one-page file: `(include "rival-310")` plus an + * 8-entry `button_mapping_data` override (`repeat 8` instead of Rival + * 310's 6) with the same `0x31` command. Everything except the button + * table is inherited byte-for-byte, so this module keeps the Rival 310 + * command bytes, DPI encoding, polling values, RGB handling, save + * command, and firmware query identical, with Sensei 310 naming. + * + * Button 7/8 use ids `0x07`/`0x08` at offsets `0x1e`/`0x23`, continuing the + * sequential id/offset pattern and matching `./sensei-ten.ts`'s 8-button + * table. The vendor file states only the slot count, so those two ids are + * an inference from the shared convention, flagged here rather than + * asserted as observed. + * + * Like `./rival310.ts`, this module is rivalcfg-only corroboration: no + * libratbag or OpenRGB source was reachable for this device. Nothing here + * has been verified on physical Sensei 310 hardware by this project. + * + * Every command is an HID **output report** with report id 0x00 on the + * vendor configuration interface (`"endpoint": 0`, as in `rival310.py`), + * except the LED color commands, which are HID **feature** reports. + * + * **This device is write-only for settings**: no getter exists for DPI, + * polling, lighting, or buttons. The one readable value is the two-byte + * firmware version behind command `90 00`. + * + * Settings apply immediately; the save command (`59 00`) commits them to + * onboard flash. + */ + +export const STEELSERIES_SENSEI310_REPORT_ID = 0x00; + +/** Two-byte command prefixes; the payload is the prefix plus its arguments. */ +export const SENSEI310_COMMAND = { + sensitivity1: [0x53, 0x00, 0x01], + sensitivity2: [0x53, 0x00, 0x02], + pollingRate: [0x54, 0x00], + ledColor: [0x5b, 0x00], + buttonsMapping: [0x31, 0x00], + save: [0x59, 0x00], + firmware: [0x90, 0x00], +} as const; + +/** `command_suffix` appended after the DPI byte for both sensitivity presets. */ +const SENSITIVITY_SUFFIX = [0x00, 0x42] as const; + +/** `59 00` — commit the current settings to onboard flash. */ +export const SENSEI310_SAVE_COMMAND = [0x59, 0x00] as const; + +export const SENSEI310_FIRMWARE_RESPONSE_LENGTH = 2; + +export const SENSEI310_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 SENSEI310_DPI_MIN = 100; +export const SENSEI310_DPI_MAX = 12000; +export const SENSEI310_DPI_STEP = 100; + +export class Sensei310ProtocolError extends Error {} + +/** + * `input_range: [100, 12000, 100]` mapped onto a plain one-byte linear + * encoding (`byte = (dpi - 100) / 100`) — the same encoding as + * `./rival310.ts`, inherited through the shared vendor profile. Off-grid + * values are rejected rather than rounded, matching this package's DPI + * validation policy. + */ +function encodeDpiByte(dpi: number): number { + if ( + !Number.isInteger(dpi) || + dpi < SENSEI310_DPI_MIN || + dpi > SENSEI310_DPI_MAX || + (dpi - SENSEI310_DPI_MIN) % SENSEI310_DPI_STEP !== 0 + ) { + throw new Sensei310ProtocolError( + `SteelSeries Sensei 310 DPI must be ${SENSEI310_DPI_MIN}–${SENSEI310_DPI_MAX.toLocaleString()} in ${SENSEI310_DPI_STEP} DPI steps.`, + ); + } + return (dpi - SENSEI310_DPI_MIN) / SENSEI310_DPI_STEP; +} + +/** The 120 DPI values the linear encoding can express, ascending. */ +export function steelseriesSensei310DpiOptions(): number[] { + const options: number[] = []; + for (let dpi = SENSEI310_DPI_MIN; dpi <= SENSEI310_DPI_MAX; dpi += SENSEI310_DPI_STEP) { + options.push(dpi); + } + return options; +} + +/** `53 00 01 00 42` — sensitivity preset 1. */ +export function steelseriesSensei310EncodeSensitivity1(dpi: number): Uint8Array { + return new Uint8Array([...SENSEI310_COMMAND.sensitivity1, encodeDpiByte(dpi), ...SENSITIVITY_SUFFIX]); +} + +/** `53 00 02 00 42` — sensitivity preset 2. */ +export function steelseriesSensei310EncodeSensitivity2(dpi: number): Uint8Array { + return new Uint8Array([...SENSEI310_COMMAND.sensitivity2, encodeDpiByte(dpi), ...SENSITIVITY_SUFFIX]); +} + +/** `54 00 ` with 1000→0x01, 500→0x02, 250→0x03, 125→0x04. */ +export function steelseriesSensei310EncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new Sensei310ProtocolError("SteelSeries Sensei 310 supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...SENSEI310_COMMAND.pollingRate, byte]); +} + +/** `59 00` — commit the current settings to onboard flash. */ +export function steelseriesSensei310SaveCommand(): Uint8Array { + return new Uint8Array(SENSEI310_SAVE_COMMAND); +} + +/** `90 00` — the device answers with a two-byte input report. */ +export function steelseriesSensei310FirmwareQuery(): Uint8Array { + return new Uint8Array(SENSEI310_COMMAND.firmware); +} + +export interface SteelSeriesSensei310Firmware { + /** 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, following `./rival310.ts`'s + * read-order convention until hardware settles the byte order. + */ +export function steelseriesSensei310DecodeFirmware(payload: Uint8Array): SteelSeriesSensei310Firmware { + if (payload.length < SENSEI310_FIRMWARE_RESPONSE_LENGTH) { + throw new Sensei310ProtocolError("SteelSeries Sensei 310 firmware response is shorter than two bytes."); + } + const bytes: [number, number] = [payload[0]!, payload[1]!]; + return { bytes, display: `${bytes[0]}.${bytes[1]}` }; +} + +// -- LED color (steady color only, same scope as rival310.ts) ----------------- + +export type Sensei310Led = "logo" | "wheel"; + +const LED_ID: Record = { logo: 0x00, wheel: 0x01 }; + +const RGBGRADIENT_HEADER_LENGTH = 26; +const RGBGRADIENT_DURATION_OFFSET = 1; +const RGBGRADIENT_REPEAT_OFFSET = 17; +const RGBGRADIENT_TRIGGERS_OFFSET = 21; +const RGBGRADIENT_COLOR_COUNT_OFFSET = 25; +const RGBGRADIENT_DEFAULT_DURATION_MS = 1000; + +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 Sensei310ProtocolError("RGB channels must be integers 0–255."); + } + } + return [r, g, b]; +} + +/** + * `5B 00` (feature report) + 26-byte header + 7-byte body — sets one LED to + * a steady color. Same single-color path through rivalcfg's `rgbgradient` + * handler as `./rival310.ts`; multi-stop gradients are out of scope here + * too. This is a **feature** report, unlike every other command in this + * module. + */ +export function steelseriesSensei310EncodeLedColor(led: Sensei310Led, r: number, g: number, b: number): Uint8Array { + const [red, green, blue] = encodeRgb(r, g, b); + const ledId = LED_ID[led]; + if (ledId === undefined) { + throw new Sensei310ProtocolError('SteelSeries Sensei 310 LEDs are "logo" and "wheel" only.'); + } + + const header = new Array(RGBGRADIENT_HEADER_LENGTH).fill(0x00); + header[0] = ledId; + header[RGBGRADIENT_DURATION_OFFSET] = RGBGRADIENT_DEFAULT_DURATION_MS & 0xff; + header[RGBGRADIENT_DURATION_OFFSET + 1] = (RGBGRADIENT_DEFAULT_DURATION_MS >> 8) & 0xff; + header[RGBGRADIENT_REPEAT_OFFSET] = 0x01; + header[RGBGRADIENT_TRIGGERS_OFFSET] = 0x00; + header[RGBGRADIENT_COLOR_COUNT_OFFSET] = 0x01; + + const body = [red, green, blue, red, green, blue, 0x00]; + + return new Uint8Array([...SENSEI310_COMMAND.ledColor, ...header, ...body]); +} + +// -- Button mapping ----------------------------------------------------- + +interface Sensei310ButtonSlot { + id: number; + offset: number; +} + +/** + * The vendor specification overrides Rival 310's 6-entry table with 8 + * entries (`repeat 8`). Button 7/8 ids continue the sequential pattern; + * see the module doc comment for the disclosure. + */ +export const SENSEI310_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 }, + button7: { id: 0x07, offset: 0x1e }, + button8: { id: 0x08, offset: 0x23 }, +} as const satisfies Record; + +export type Sensei310ButtonName = keyof typeof SENSEI310_BUTTONS; + +const SENSEI310_BUTTON_FIELD_LENGTH = 5; +const SENSEI310_BUTTON_DISABLE = 0x00; +const SENSEI310_BUTTON_KEYBOARD = 0x51; +const SENSEI310_BUTTON_MULTIMEDIA = 0x61; +const SENSEI310_BUTTON_DPI_SWITCH = 0x30; +const SENSEI310_BUTTON_SCROLL_UP = 0x31; +const SENSEI310_BUTTON_SCROLL_DOWN = 0x32; + +/** + * One button's target. Like `./rival310.ts`, there are no scroll-wheel + * button slots — scroll up/down are special *action* bytes any button can + * be assigned. + */ +export type Sensei310ButtonAction = + | { type: "button"; target: Sensei310ButtonName } + | { type: "disabled" } + | { type: "dpiSwitch" } + | { type: "scrollUp" } + | { type: "scrollDown" } + | { type: "keyboard"; code: number } + | { type: "multimedia"; code: number }; + +/** + * `31 00` + one 40-byte (`8 buttons × 5-byte field length`) packet, + * 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 steelseriesSensei310EncodeButtonsMapping( + mapping: Partial>, +): Uint8Array { + const names = Object.keys(SENSEI310_BUTTONS) as Sensei310ButtonName[]; + const packet = new Array(names.length * SENSEI310_BUTTON_FIELD_LENGTH).fill(0x00); + + for (const [name, action] of Object.entries(mapping) as Array<[Sensei310ButtonName, Sensei310ButtonAction]>) { + const slot = SENSEI310_BUTTONS[name]; + if (!slot) { + throw new Sensei310ProtocolError(`Unknown SteelSeries Sensei 310 button "${name}".`); + } + const { offset } = slot; + switch (action.type) { + case "button": { + const target = SENSEI310_BUTTONS[action.target]; + if (!target) { + throw new Sensei310ProtocolError(`Unknown SteelSeries Sensei 310 button "${action.target}".`); + } + packet[offset] = target.id; + break; + } + case "disabled": { + packet[offset] = SENSEI310_BUTTON_DISABLE; + break; + } + case "dpiSwitch": { + packet[offset] = SENSEI310_BUTTON_DPI_SWITCH; + break; + } + case "scrollUp": { + packet[offset] = SENSEI310_BUTTON_SCROLL_UP; + break; + } + case "scrollDown": { + packet[offset] = SENSEI310_BUTTON_SCROLL_DOWN; + break; + } + case "keyboard": { + if (!Number.isInteger(action.code) || action.code < 0 || action.code > 255) { + throw new Sensei310ProtocolError("Keyboard scan codes must be integers 0–255."); + } + packet[offset] = SENSEI310_BUTTON_KEYBOARD; + packet[offset + 1] = action.code; + break; + } + case "multimedia": { + if (!Number.isInteger(action.code) || action.code < 0 || action.code > 255) { + throw new Sensei310ProtocolError("Multimedia key codes must be integers 0–255."); + } + packet[offset] = SENSEI310_BUTTON_MULTIMEDIA; + packet[offset + 1] = action.code; + break; + } + default: { + throw new Sensei310ProtocolError("Unsupported SteelSeries Sensei 310 button action."); + } + } + } + + return new Uint8Array([...SENSEI310_COMMAND.buttonsMapping, ...packet]); +} From 1f6850b882dab47409450a8c99d7fdb3dbe8544f Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 17:50:49 -0600 Subject: [PATCH 04/15] feat: add SteelSeries Prime Mini protocol family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wired-only Prime Mini (1038:184D), reconstructed from the vendor's own GG 121.0.0 device specification (prime_mini.lisp) — not rivalcfg, which does not cover this mouse. Not the Prime Mini Wireless (different command set): DPI batch 0x2D with uint16 factors (x50, 50-18000), polling 0x2B with read-back 0xAB, 8-slot buttons 0x2A, single-zone lighting (color 0x21, brightness 0x23, default 0x27, reset 0x22), save 0x11, firmware 0x90 (15-byte string, used as the connectivity probe). Unverified on hardware. --- src/drivers/registry.ts | 4 +- .../steelseries/prime-mini-hid.test.ts | 127 +++++++ src/drivers/steelseries/prime-mini-hid.ts | 224 ++++++++++++ src/steelseries/devices.ts | 14 + src/steelseries/index.ts | 1 + src/steelseries/prime-mini.test.ts | 114 ++++++ src/steelseries/prime-mini.ts | 328 ++++++++++++++++++ 7 files changed, 811 insertions(+), 1 deletion(-) create mode 100644 src/drivers/steelseries/prime-mini-hid.test.ts create mode 100644 src/drivers/steelseries/prime-mini-hid.ts create mode 100644 src/steelseries/prime-mini.test.ts create mode 100644 src/steelseries/prime-mini.ts diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..4564a436 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -57,6 +57,7 @@ import { SteelSeriesAerox9WirelessHidClient } from "./steelseries/aerox9-wireles import { SteelSeriesRival310HidClient } from "./steelseries/rival310-hid.ts"; import { SteelSeriesPrimePlusHidClient } from "./steelseries/prime-plus-hid.ts"; import { SteelSeriesPrimeMiniWirelessHidClient } from "./steelseries/prime-mini-wireless-hid.ts"; +import { SteelSeriesPrimeMiniHidClient } from "./steelseries/prime-mini-hid.ts"; import { SteelSeriesSenseiTenHidClient } from "./steelseries/sensei-ten-hid.ts"; import { GloriousHidClient } from "./glorious/hid.ts"; import { GloriousClassicHidClient } from "./glorious/classic-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 | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesPrimeMiniHidClient | 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; @@ -175,6 +176,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesRival310HidClient.isSupported(device), create: (device) => new SteelSeriesRival310HidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesPrimePlusHidClient.isSupported(device), create: (device) => new SteelSeriesPrimePlusHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesPrimeMiniWirelessHidClient.isSupported(device), create: (device) => new SteelSeriesPrimeMiniWirelessHidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesPrimeMiniHidClient.isSupported(device), create: (device) => new SteelSeriesPrimeMiniHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiTenHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiTenHidClient(device), score: () => 6 }, { brand: "Glorious", supports: (device) => GloriousHidClient.isSupported(device), create: (device) => new GloriousHidClient(device), score: () => 5 }, { brand: "Glorious", supports: (device) => GloriousClassicHidClient.isSupported(device), create: (device) => new GloriousClassicHidClient(device), score: () => 5 }, diff --git a/src/drivers/steelseries/prime-mini-hid.test.ts b/src/drivers/steelseries/prime-mini-hid.test.ts new file mode 100644 index 00000000..fb154f90 --- /dev/null +++ b/src/drivers/steelseries/prime-mini-hid.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesPrimeMiniHidClient } from "./prime-mini-hid.ts"; + +function fakeDevice(options: { productId?: number; firmware?: number[]; answerFirmware?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x184d, + productName: "SteelSeries Prime Mini", + 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] === 0x90 && payload[1] === 0x00 && options.answerFirmware !== false) { + const response = new Uint8Array( + options.firmware ?? [0x31, 0x2e, 0x30, 0x2e, 0x31, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + ); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + } + }, + sendFeatureReport: async () => { throw new Error("the Prime Mini protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Prime Mini 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 Prime Mini product id", () => { + const { device } = fakeDevice(); + assert.equal(SteelSeriesPrimeMiniHidClient.isSupported(device), true); + // Prime Wireless twins and every other SteelSeries family must not be claimed. + for (const productId of [0x184a, 0x1848, 0x1842, 0x1840, 0x182c, 0x182e, 0x1830, 0x1850, 0x1836]) { + assert.equal( + SteelSeriesPrimeMiniHidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesPrimeMiniHidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus probes firmware and reports stage defaults", async () => { + const { device, sent } = fakeDevice(); + const status = await new SteelSeriesPrimeMiniHidClient(device).readStatus(); + assert.deepEqual(sent, [{ reportId: 0, payload: [0x90, 0x00] }]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Prime Mini"); + assert.deepEqual(status.firmware, ["1.0.11"]); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.dpi, 400); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.ui?.valuesVerified, false); + assert.ok(status.ui?.pollingNote); +}); + +test("a silent interface fails the firmware probe loudly", async () => { + const { device } = fakeDevice({ answerFirmware: false }); + const client = new SteelSeriesPrimeMiniHidClient(device); + await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); +}); + +test("setDpi edits the active stage and rewrites the table plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesPrimeMiniHidClient(device); + await client.readStatus(); + sent.length = 0; + const dpi = await client.setDpi(1600); + assert.equal(dpi, 1600); + assert.deepEqual(sent, [ + { + reportId: 0, + payload: [0x2d, 0x05, 0x00, 0x20, 0x00, 0x10, 0x00, 0x18, 0x00, 0x30, 0x00, 0x40, 0x00], + }, + { reportId: 0, payload: [0x11] }, + ]); +}); + +test("setActiveDpiStage switches the stage without changing values", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesPrimeMiniHidClient(device); + await client.readStatus(); + sent.length = 0; + const stage = await client.setActiveDpiStage(2); + assert.equal(stage, 2); + assert.deepEqual(sent[0], { + reportId: 0, + payload: [0x2d, 0x05, 0x02, 0x08, 0x00, 0x10, 0x00, 0x18, 0x00, 0x30, 0x00, 0x40, 0x00], + }); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x11] }); + await assert.rejects(() => client.setActiveDpiStage(5), /holds 5 DPI stages/); +}); + +test("setPollingRate, setButtonsMapping, and setZoneColor write plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesPrimeMiniHidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setPollingRate(500); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x2b, 0x02] }, + { reportId: 0, payload: [0x11] }, + ]); + sent.length = 0; + await client.setButtonsMapping({ button6: { type: "disabled" } }); + assert.equal(sent.length, 2); + assert.equal(sent[0]?.payload[0], 0x2a); + assert.equal(sent[0]?.payload.length, 41); + assert.equal(sent[0]?.payload[1 + 5 * 5], 0x00); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x11] }); + sent.length = 0; + await client.setZoneColor(0, 0, 255); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x21, 0x00, 0x00, 0x00, 0xff] }, + { reportId: 0, payload: [0x11] }, + ]); +}); diff --git a/src/drivers/steelseries/prime-mini-hid.ts b/src/drivers/steelseries/prime-mini-hid.ts new file mode 100644 index 00000000..82e4d372 --- /dev/null +++ b/src/drivers/steelseries/prime-mini-hid.ts @@ -0,0 +1,224 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + PRIME_MINI_DEFAULT_DPI_PRESETS, + PRIME_MINI_DEFAULT_POLLING_RATE, + PRIME_MINI_MAX_DPI_PRESETS, + PRIME_MINI_POLLING_RATES, + PRIME_MINI_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesPrimeMiniDecodeFirmware, + steelseriesPrimeMiniDpiOptions, + steelseriesPrimeMiniEncodeButtonsMapping, + steelseriesPrimeMiniEncodeDpiPresets, + steelseriesPrimeMiniEncodePollingRate, + steelseriesPrimeMiniEncodeZoneColor, + steelseriesPrimeMiniFirmwareQuery, + steelseriesPrimeMiniSaveCommand, + type PrimeMiniButtonAction, + type PrimeMiniButtonName, +} from "@openmouse/protocol/steelseries"; + +/** rivalcfg's `command_approve_delay`, shared across every SteelSeries family. */ +const COMMAND_DELAY_MS = 50; +/** Firmware replies are small; allow slack over rivalcfg-style timeouts. */ +const FIRMWARE_TIMEOUT_MS = 500; + +/** + * SteelSeries Prime Mini WebHID control (`1038:184D`, wired only). + * + * DPI presets, polling rate, buttons, and lighting are write-only; only + * the 15-byte firmware version string (`90 00`) can be read back, and this + * driver uses it as its connectivity probe. `readStatus` therefore reports + * the session's last-written values (or the vendor's documented defaults + * before any write) with `valuesVerified: false`. Every setter follows its + * write with the save command so the change persists in the mouse's onboard + * memory. See `../../steelseries/prime-mini.ts` for the full protocol + * writeup — reconstructed from the vendor's GG device specification, not + * from rivalcfg, which does not cover this mouse. + * + * DPI is managed as five stages (the vendor's factory table), mirroring + * the Aerox wireless drivers: `setDpi` edits the active stage, and the + * whole table is rewritten on every change because the device exposes no + * getter. Do not confuse this mouse with the Prime Mini Wireless + * (`1038:184A`/`1038:1848`, a different command set). + */ +export class SteelSeriesPrimeMiniHidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private listenerAttached = false; + private readonly inputWaiters = new Set<(payload: Uint8Array) => void>(); + private dpiStages: number[] = [...PRIME_MINI_DEFAULT_DPI_PRESETS]; + private activeDpiStage = 0; + private pollingRateHz: number = PRIME_MINI_DEFAULT_POLLING_RATE; + + 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 === "prime-mini"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...PRIME_MINI_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesPrimeMiniDpiOptions(); } + + 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 ?? "Prime Mini"}`; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-prime-mini", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: "The Prime Mini cannot report its current settings; values shown are the last written by this app, or assumed defaults.", + defaultDisplayName: "SteelSeries Prime Mini", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: this.dpiStages[this.activeDpiStage] ?? PRIME_MINI_DEFAULT_DPI_PRESETS[0]!, + pollingRateHz: this.pollingRateHz, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + connectionType: "Wired", + liftOffDistance: null, + firmware: [firmware], + }; + }); + } + + /** writes `dpi` into the active stage. */ + async setDpi(dpi: number): Promise { + return await this.setDpiStageValue(this.activeDpiStage, dpi); + } + + async setDpiStageValue(stage: number, dpi: number): Promise { + this.requireStage(stage); + const stages = this.dpiStages.map((value, index) => (index === stage ? dpi : value)); + await this.writePresets(stages, this.activeDpiStage); + return dpi; + } + + async setActiveDpiStage(stage: number): Promise { + this.requireStage(stage); + await this.writePresets(this.dpiStages, stage); + return stage; + } + + async setDpiStages(stages: readonly number[], activeStage = 0): Promise { + await this.writePresets(stages, activeStage); + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesPrimeMiniEncodePollingRate(pollingRateHz)); + this.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + async setButtonsMapping(mapping: Partial>): Promise { + await this.commit(steelseriesPrimeMiniEncodeButtonsMapping(mapping)); + } + + async setZoneColor(r: number, g: number, b: number): Promise { + await this.commit(steelseriesPrimeMiniEncodeZoneColor(r, g, b)); + } + + private requireStage(stage: number): void { + if (!Number.isInteger(stage) || stage < 0 || stage >= PRIME_MINI_MAX_DPI_PRESETS) { + throw new Error(`The Prime Mini holds ${PRIME_MINI_MAX_DPI_PRESETS} DPI stages.`); + } + } + + private async writePresets(stages: readonly number[], activeStage: number): Promise { + this.requireStage(activeStage); + const report = steelseriesPrimeMiniEncodeDpiPresets(stages, activeStage); + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesPrimeMiniSaveCommand()); + this.dpiStages = [...stages]; + this.activeDpiStage = activeStage; + }); + } + + 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(steelseriesPrimeMiniSaveCommand()); + }); + } + + /** + * `90 00` — 15-byte firmware version string. 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(steelseriesPrimeMiniFirmwareQuery()); + const payload = await response; + if (!payload) { + throw new Error("The Prime Mini did not answer the firmware query on this interface. Add the device again and choose another entry."); + } + return steelseriesPrimeMiniDecodeFirmware(payload); + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(PRIME_MINI_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..67275db0 100644 --- a/src/steelseries/devices.ts +++ b/src/steelseries/devices.ts @@ -77,6 +77,7 @@ export type SteelSeriesProtocolFamily = | "aerox9-wireless" | "prime-plus" | "prime-mini-wireless" + | "prime-mini" | "sensei-ten"; export interface SteelSeriesProduct { @@ -311,6 +312,19 @@ export const STEELSERIES_PRODUCTS: ReadonlyMap = new hasFirmwareQuery: false, verified: false, }], + // prime_mini.lisp (vendor GG specification): wired only. Own command set + // (DPI batch 0x2D with uint16 factors, polling 0x2B, buttons 0x2A, + // single-zone lighting, firmware 0x90) — see ./prime-mini.ts. Not the + // Prime Mini Wireless (0x184A/0x1848, family "prime-mini-wireless"), + // which shares the plain Prime Wireless command set instead. + [0x184d, { + model: "Prime Mini", + family: "prime-mini", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], // rival310.py: wired only, endpoint 0. Own command set (0x53/0x54/0x5B/ // 0x31), a linear (non-table) DPI encoding, and a readable firmware // version behind 0x90 00 — see ./rival310.ts's doc comment, including its diff --git a/src/steelseries/index.ts b/src/steelseries/index.ts index dacc7846..12e45654 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -10,4 +10,5 @@ export * from "./aerox9-wireless.js"; export * from "./prime-plus.js"; export * from "./prime-mini-wireless.js"; export * from "./sensei-ten.js"; +export * from "./prime-mini.js"; export * from "./devices.js"; diff --git a/src/steelseries/prime-mini.test.ts b/src/steelseries/prime-mini.test.ts new file mode 100644 index 00000000..06c413c2 --- /dev/null +++ b/src/steelseries/prime-mini.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + PRIME_MINI_DEFAULT_DPI_PRESETS, + PRIME_MINI_DEFAULT_POLLING_RATE, + PRIME_MINI_DPI_MAX, + PRIME_MINI_DPI_MIN, + PRIME_MINI_MAX_DPI_PRESETS, + PRIME_MINI_POLLING_RATES, + PrimeMiniProtocolError, + steelseriesPrimeMiniDecodeFirmware, + steelseriesPrimeMiniDpiOptions, + steelseriesPrimeMiniEncodeButtonsMapping, + steelseriesPrimeMiniEncodeDefaultLighting, + steelseriesPrimeMiniEncodeDpiPresets, + steelseriesPrimeMiniEncodeLedBrightness, + steelseriesPrimeMiniEncodePollingRate, + steelseriesPrimeMiniEncodeZoneColor, + steelseriesPrimeMiniFirmwareQuery, + steelseriesPrimeMiniResetLightingCommand, + steelseriesPrimeMiniSaveCommand, +} from "./prime-mini.ts"; + +test("DPI options are the 50..18000 range in 50 DPI steps", () => { + const options = steelseriesPrimeMiniDpiOptions(); + assert.equal(options[0], PRIME_MINI_DPI_MIN); + assert.equal(options.at(-1), PRIME_MINI_DPI_MAX); + assert.equal(options.length, (PRIME_MINI_DPI_MAX - PRIME_MINI_DPI_MIN) / 50 + 1); + assert.deepEqual([...PRIME_MINI_DEFAULT_DPI_PRESETS], [400, 800, 1200, 2400, 3200]); + assert.equal(PRIME_MINI_DEFAULT_POLLING_RATE, 1000); + assert.equal(PRIME_MINI_MAX_DPI_PRESETS, 5); +}); + +test("DPI presets encode factors little-endian with count and index", () => { + // factor = dpi / 50: 400 -> 8, 3200 -> 64 + assert.deepEqual( + [...steelseriesPrimeMiniEncodeDpiPresets([400, 800, 1200, 2400, 3200], 0)], + [0x2d, 0x05, 0x00, 0x08, 0x00, 0x10, 0x00, 0x18, 0x00, 0x30, 0x00, 0x40, 0x00], + ); + assert.deepEqual( + [...steelseriesPrimeMiniEncodeDpiPresets([400, 800, 1200, 2400, 3200], 4).slice(0, 3)], + [0x2d, 0x05, 0x04], + ); +}); + +test("DPI presets reject off-grid values, bad counts, and bad indices", () => { + const presets = [400, 800, 1200, 2400, 3200] as const; + assert.throws(() => steelseriesPrimeMiniEncodeDpiPresets([400, 800], 0), PrimeMiniProtocolError); + assert.throws(() => steelseriesPrimeMiniEncodeDpiPresets([...presets], 5), PrimeMiniProtocolError); + assert.deepEqual( + [...steelseriesPrimeMiniEncodeDpiPresets([100, 800, 1200, 2400, 3200], 0).slice(3, 5)], + [0x02, 0x00], + ); + assert.throws(() => steelseriesPrimeMiniEncodeDpiPresets([25, 800, 1200, 2400, 3200], 0), PrimeMiniProtocolError); + assert.throws(() => steelseriesPrimeMiniEncodeDpiPresets([125, 800, 1200, 2400, 3200], 0), PrimeMiniProtocolError); + assert.throws(() => steelseriesPrimeMiniEncodeDpiPresets([18050, 800, 1200, 2400, 3200], 0), PrimeMiniProtocolError); +}); + +test("polling rates use 1000->0x01 down to 125->0x04", () => { + assert.deepEqual([...steelseriesPrimeMiniEncodePollingRate(1000)], [0x2b, 0x01]); + assert.deepEqual([...steelseriesPrimeMiniEncodePollingRate(500)], [0x2b, 0x02]); + assert.deepEqual([...steelseriesPrimeMiniEncodePollingRate(250)], [0x2b, 0x03]); + assert.deepEqual([...steelseriesPrimeMiniEncodePollingRate(125)], [0x2b, 0x04]); + assert.deepEqual([...PRIME_MINI_POLLING_RATES], [125, 250, 500, 1000]); + assert.throws(() => steelseriesPrimeMiniEncodePollingRate(2000), PrimeMiniProtocolError); +}); + +test("save is one byte; firmware query is 90 00 with a 15-byte string reply", () => { + assert.deepEqual([...steelseriesPrimeMiniSaveCommand()], [0x11]); + assert.deepEqual([...steelseriesPrimeMiniFirmwareQuery()], [0x90, 0x00]); + const payload = new Uint8Array([0x31, 0x2e, 0x30, 0x2e, 0x31, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + assert.equal(steelseriesPrimeMiniDecodeFirmware(payload), "1.0.11"); + assert.throws(() => steelseriesPrimeMiniDecodeFirmware(new Uint8Array(14)), PrimeMiniProtocolError); +}); + +test("lighting commands use the single zone", () => { + assert.deepEqual([...steelseriesPrimeMiniEncodeZoneColor(255, 0, 0)], [0x21, 0x00, 0xff, 0x00, 0x00]); + assert.deepEqual([...steelseriesPrimeMiniEncodeLedBrightness(100)], [0x23, 100]); + assert.throws(() => steelseriesPrimeMiniEncodeLedBrightness(101), PrimeMiniProtocolError); + assert.deepEqual([...steelseriesPrimeMiniEncodeDefaultLighting("rainbow")], [0x27, 0x01]); + assert.deepEqual([...steelseriesPrimeMiniEncodeDefaultLighting("off")], [0x27, 0x00]); + assert.throws( + () => steelseriesPrimeMiniEncodeDefaultLighting("disco" as never), + PrimeMiniProtocolError, + ); + assert.deepEqual([...steelseriesPrimeMiniResetLightingCommand()], [0x22]); + assert.throws(() => steelseriesPrimeMiniEncodeZoneColor(0, 0, 300), PrimeMiniProtocolError); +}); + +test("button mapping keeps the DPI button on DPI-switch by default", () => { + const report = steelseriesPrimeMiniEncodeButtonsMapping({ + button1: { type: "button", target: "button1" }, + }); + assert.equal(report[0], 0x2a); + assert.equal(report.length, 1 + 8 * 5); + assert.equal(report[1], 0x01); + assert.equal(report[1 + 5 * 5], 0x30); + assert.equal(report[1 + 6 * 5], 0x31); + assert.equal(report[1 + 7 * 5], 0x32); +}); + +test("button mapping rejects unknown buttons and bad codes", () => { + assert.throws( + () => steelseriesPrimeMiniEncodeButtonsMapping({ ["button9" as never]: { type: "disabled" } }), + PrimeMiniProtocolError, + ); + assert.throws( + () => steelseriesPrimeMiniEncodeButtonsMapping({ + button1: { type: "keyboard", code: 512 }, + }), + PrimeMiniProtocolError, + ); +}); diff --git a/src/steelseries/prime-mini.ts b/src/steelseries/prime-mini.ts new file mode 100644 index 00000000..4dab6c8d --- /dev/null +++ b/src/steelseries/prime-mini.ts @@ -0,0 +1,328 @@ +/** + * SteelSeries Prime Mini 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 `prime_mini.lisp` under + * `apps/engine/install/deviceSpecifications/`, and this module transcribes + * it. Those files are OpenPGP-armored ("DESCRIPTOR" armor, SKESK/AES-128); + * the bytes below are the documented HID report payloads, not guesses. + * Do not confuse this wired-only mouse (`1038:184D`) with the Prime Mini + * Wireless (`1038:184A`/`1038:1848`, family `"prime-mini-wireless"`), + * which shares the plain Prime Wireless command set — see + * `./prime-mini-wireless.ts`. No other public implementation of this + * protocol was available to corroborate against, so treat every byte as + * vendor-documented but not yet hardware-verified. + * + * Every command is an HID **output report** with report id 0x00 (65-byte + * reports throughout the specification). + * + * DPI uses a 5-preset batch (`0x2D`): `level_max` (1–5), `active_level` + * (0–4, 0-based), then five uint16 little-endian factors, each multiplied + * by 50 (`DPI = factor × 50`, range factor 1–360, i.e. 50–18000 DPI). The + * specification always carries all five slots, so this codec requires + * exactly five presets rather than guessing at padding for shorter tables. + * The factory table is 400/800/1200/2400/3200 (sensor bytes 8/16/24/48/64 + * in the GG seed data). + * + * Polling (`0x2B`, values 1000→0x01, 500→0x02, 250→0x03, 125→0x04) has a + * read-back (`0xAB`, same values), defined but not decoded here. Buttons + * are one 40-byte packet (8 slots × 5-byte fields), command `0x2A`: the + * vendor frontend always forces the 6th slot to DPI-switch and only exposes + * 5 remappable buttons plus scroll, so this codec defaults that slot to + * DPI-switch while still encoding an explicit caller override verbatim. + * + * Lighting is a single zone: color (`0x21`, zone byte `0x00` + RGB), + * brightness (`0x23`, 0–100), default lighting (`0x27`, off/rainbow) with a + * read-back, and reset (`0x22`). + * + * Known but deliberately not implemented — documented so nobody re-derives + * them, withheld until there is hardware evidence and a reason to ship them: + * + * - `0xAB` — polling read-back; `0x27` — default-lighting read-back. + * - `0x90 0x00` — firmware version (15-byte string reply); implemented as + * query + decoder below because the driver needs a connectivity probe. + * - Accelerometer/deceleration, angle snapping, sleep timers: driver-side + * (`SSE`) settings, not HID commands, out of scope for this codec. + * + * Settings apply immediately; the save command (`0x11`, one byte — no + * `0x00` suffix, unlike Aerox's `11 00`) persists them to onboard flash. + * None of this has been verified on physical hardware by this project. + */ + +export const PRIME_MINI_REPORT_ID = 0x00; + +/** Command prefixes; the payload is the prefix plus its arguments. */ +export const PRIME_MINI_COMMAND = { + dpiPresets: [0x2d], + pollingRate: [0x2b], + buttonsMapping: [0x2a], + ledBrightness: [0x23], + defaultLighting: [0x27], + zoneColor: [0x21], + resetLighting: [0x22], + firmware: [0x90, 0x00], +} as const; + +/** `0xAB` — polling read-back; documented, not decoded here. */ +export const PRIME_MINI_POLLING_QUERY = [0xab] as const; + +/** `0x11` — one byte, no suffix. Distinct from Aerox's two-byte `11 00`. */ +export const PRIME_MINI_SAVE_COMMAND = [0x11] as const; + +export const PRIME_MINI_POLLING_RATES = [125, 250, 500, 1000] as const; + +const POLLING_RATE_TO_BYTE: ReadonlyMap = new Map([ + [1000, 0x01], + [500, 0x02], + [250, 0x03], + [125, 0x04], +]); + +/** DPI = factor × 50; factor 1–360, i.e. 50–18000 DPI in 50 DPI steps. */ +export const PRIME_MINI_DPI_MIN = 50; +export const PRIME_MINI_DPI_MAX = 18000; +export const PRIME_MINI_DPI_STEP = 50; +export const PRIME_MINI_MAX_DPI_PRESETS = 5; +export const PRIME_MINI_FIRMWARE_RESPONSE_LENGTH = 15; + +/** Factory table from the GG seed data (sensor bytes 8/16/24/48/64). */ +export const PRIME_MINI_DEFAULT_DPI_PRESETS = [400, 800, 1200, 2400, 3200] as const; +export const PRIME_MINI_DEFAULT_POLLING_RATE = 1000; + +export class PrimeMiniProtocolError extends Error {} + +/** Every DPI value the factor encoding can express, ascending. */ +export function steelseriesPrimeMiniDpiOptions(): number[] { + const options: number[] = []; + for (let dpi = PRIME_MINI_DPI_MIN; dpi <= PRIME_MINI_DPI_MAX; dpi += PRIME_MINI_DPI_STEP) { + options.push(dpi); + } + return options; +} + +function encodeDpiFactor(dpi: number): [number, number] { + if ( + !Number.isInteger(dpi) || + dpi < PRIME_MINI_DPI_MIN || + dpi > PRIME_MINI_DPI_MAX || + (dpi - PRIME_MINI_DPI_MIN) % PRIME_MINI_DPI_STEP !== 0 + ) { + throw new PrimeMiniProtocolError( + `SteelSeries Prime Mini DPI must be ${PRIME_MINI_DPI_MIN}–${PRIME_MINI_DPI_MAX.toLocaleString("en-US")} in ${PRIME_MINI_DPI_STEP} DPI steps.`, + ); + } + const factor = dpi / PRIME_MINI_DPI_STEP; + return [factor & 0xff, (factor >> 8) & 0xff]; +} + +/** + * `2D <5× u16LE factor>` — replaces the + * mouse's whole preset table. Exactly five presets are required (see the + * module doc comment); `selectedIndex` is 0-based here and on the wire. + */ +export function steelseriesPrimeMiniEncodeDpiPresets( + presets: readonly number[], + selectedIndex: number, +): Uint8Array { + if (presets.length !== PRIME_MINI_MAX_DPI_PRESETS) { + throw new PrimeMiniProtocolError( + `SteelSeries Prime Mini requires exactly ${PRIME_MINI_MAX_DPI_PRESETS} DPI presets.`, + ); + } + if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= presets.length) { + throw new PrimeMiniProtocolError( + `Selected DPI preset must be an index into the ${presets.length} presets being written.`, + ); + } + return new Uint8Array([ + ...PRIME_MINI_COMMAND.dpiPresets, + presets.length, + selectedIndex, + ...presets.flatMap(encodeDpiFactor), + ]); +} + +/** `2B ` with 1000→0x01, 500→0x02, 250→0x03, 125→0x04. */ +export function steelseriesPrimeMiniEncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new PrimeMiniProtocolError("SteelSeries Prime Mini supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...PRIME_MINI_COMMAND.pollingRate, byte]); +} + +/** `11` — commit the current settings to onboard flash. */ +export function steelseriesPrimeMiniSaveCommand(): Uint8Array { + return new Uint8Array(PRIME_MINI_SAVE_COMMAND); +} + +/** `90 00` — the device answers with a 15-byte version string. */ +export function steelseriesPrimeMiniFirmwareQuery(): Uint8Array { + return new Uint8Array(PRIME_MINI_COMMAND.firmware); +} + +/** + * Decode the 15-byte firmware version reply as ASCII, stopping at the + * first NUL. The string shape (dotted version text) follows the + * specification's `version_str bytearray (size 15)` field; trailing + * padding is not part of the version. + */ +export function steelseriesPrimeMiniDecodeFirmware(payload: Uint8Array): string { + if (payload.length < PRIME_MINI_FIRMWARE_RESPONSE_LENGTH) { + throw new PrimeMiniProtocolError("SteelSeries Prime Mini firmware response is shorter than 15 bytes."); + } + const bytes = payload.slice(0, PRIME_MINI_FIRMWARE_RESPONSE_LENGTH); + const nul = bytes.indexOf(0x00); + const text = new TextDecoder("ascii").decode(nul === -1 ? bytes : bytes.slice(0, nul)); + return text; +} + +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 PrimeMiniProtocolError("RGB channels must be integers 0–255."); + } + } + return [r, g, b]; +} + +/** `21 00 ` — the single LED zone color. */ +export function steelseriesPrimeMiniEncodeZoneColor(r: number, g: number, b: number): Uint8Array { + return new Uint8Array([...PRIME_MINI_COMMAND.zoneColor, 0x00, ...encodeRgb(r, g, b)]); +} + +/** `23 ` — LED brightness, linear 0–100 passthrough. */ +export function steelseriesPrimeMiniEncodeLedBrightness(percent: number): Uint8Array { + if (!Number.isInteger(percent) || percent < 0 || percent > 100) { + throw new PrimeMiniProtocolError("SteelSeries Prime Mini LED brightness must be an integer 0–100."); + } + return new Uint8Array([...PRIME_MINI_COMMAND.ledBrightness, percent]); +} + +export const PRIME_MINI_DEFAULT_LIGHTING = { + off: 0x00, + rainbow: 0x01, +} as const; + +export type PrimeMiniDefaultLighting = keyof typeof PRIME_MINI_DEFAULT_LIGHTING; + +/** `27 ` — boot lighting: off or rainbow. */ +export function steelseriesPrimeMiniEncodeDefaultLighting(mode: PrimeMiniDefaultLighting): Uint8Array { + const byte = PRIME_MINI_DEFAULT_LIGHTING[mode]; + if (byte === undefined) { + throw new PrimeMiniProtocolError( + `SteelSeries Prime Mini default lighting must be one of: ${Object.keys(PRIME_MINI_DEFAULT_LIGHTING).join(", ")}.`, + ); + } + return new Uint8Array([...PRIME_MINI_COMMAND.defaultLighting, byte]); +} + +/** `22` — reset the lighting to boot defaults. */ +export function steelseriesPrimeMiniResetLightingCommand(): Uint8Array { + return new Uint8Array(PRIME_MINI_COMMAND.resetLighting); +} + +// -- Button mapping -------------------------------------------------------- + +/** `buttons_mapping` slots: 6 physical buttons plus scroll up/down. */ +export const PRIME_MINI_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 }, + scrollUp: { id: 0x31, offset: 0x1e }, + scrollDown: { id: 0x32, offset: 0x23 }, +} as const satisfies Record; + +export type PrimeMiniButtonName = keyof typeof PRIME_MINI_BUTTONS; + +const BUTTON_FIELD_LENGTH = 5; +const BUTTON_DISABLE = 0x00; +const BUTTON_DPI_SWITCH = 0x30; +const BUTTON_KEYBOARD = 0x51; +const BUTTON_MULTIMEDIA = 0x61; + +export type PrimeMiniButtonAction = + | { type: "button"; target: PrimeMiniButtonName } + | { type: "disabled" } + | { type: "dpiSwitch" } + | { type: "keyboard"; code: number } + | { type: "multimedia"; code: number }; + +/** The vendor frontend forces this slot to DPI-switch; the codec default matches. */ +export const PRIME_MINI_DEFAULT_BUTTONS: Readonly> = { + button1: { type: "button", target: "button1" }, + button2: { type: "button", target: "button2" }, + button3: { type: "button", target: "button3" }, + button4: { type: "button", target: "button4" }, + button5: { type: "button", target: "button5" }, + button6: { type: "dpiSwitch" }, + scrollUp: { type: "button", target: "scrollUp" }, + scrollDown: { type: "button", target: "scrollDown" }, +}; + +function encodeKeyCode(code: number, kind: string): number { + if (!Number.isInteger(code) || code < 0 || code > 255) { + throw new PrimeMiniProtocolError(`${kind} codes must be integers 0–255.`); + } + return code; +} + +/** + * `2A` + one 40-byte packet (8 slots × 5-byte fields). Like the Aerox + * wireless families, a button missing from `mapping` keeps its default + * action (identity, except the DPI button, which keeps DPI-switch per the + * vendor frontend's forced mapping), so remapping one button never + * disables the others. An explicit `button6` entry is encoded verbatim. + */ +export function steelseriesPrimeMiniEncodeButtonsMapping( + mapping: Partial>, +): Uint8Array { + for (const name of Object.keys(mapping)) { + if (!Object.hasOwn(PRIME_MINI_BUTTONS, name)) { + throw new PrimeMiniProtocolError(`Unknown SteelSeries Prime Mini button "${name}".`); + } + } + + const names = Object.keys(PRIME_MINI_BUTTONS) as PrimeMiniButtonName[]; + const packet = new Array(names.length * BUTTON_FIELD_LENGTH).fill(0x00); + + for (const name of names) { + const action = mapping[name] ?? PRIME_MINI_DEFAULT_BUTTONS[name]; + const { offset } = PRIME_MINI_BUTTONS[name]; + switch (action.type) { + case "button": { + if (!Object.hasOwn(PRIME_MINI_BUTTONS, action.target)) { + throw new PrimeMiniProtocolError(`Unknown SteelSeries Prime Mini button "${action.target}".`); + } + packet[offset] = PRIME_MINI_BUTTONS[action.target].id; + break; + } + case "disabled": { + packet[offset] = BUTTON_DISABLE; + break; + } + case "dpiSwitch": { + packet[offset] = BUTTON_DPI_SWITCH; + break; + } + case "keyboard": { + packet[offset] = BUTTON_KEYBOARD; + packet[offset + 1] = encodeKeyCode(action.code, "Keyboard scan"); + break; + } + case "multimedia": { + packet[offset] = BUTTON_MULTIMEDIA; + packet[offset + 1] = encodeKeyCode(action.code, "Multimedia key"); + break; + } + default: { + throw new PrimeMiniProtocolError("Unsupported SteelSeries Prime Mini button action."); + } + } + } + + return new Uint8Array([...PRIME_MINI_COMMAND.buttonsMapping, ...packet]); +} From 3f8fdb7abd13dae5fe81ed79d1942356689588bd Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 17:53:34 -0600 Subject: [PATCH 05/15] feat: add SteelSeries Rival 5 protocol family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wired-only Rival 5 (1038:183C), reconstructed from the vendor's own GG 121.0.0 device specification (rival-5.lisp) — no rivalcfg profile exists. DPI batch 0x2D with TrueMove Air bytes (spot-checked 0x00/0x04/0x12/0xD6 against the vendor's inline table), polling 0x2B, 11-slot buttons 0x2A, save 0x11, firmware 0x90 (32-byte string, used as the connectivity probe). Unverified on hardware. --- src/drivers/registry.ts | 4 +- src/drivers/steelseries/rival-5-hid.test.ts | 100 ++++++++ src/drivers/steelseries/rival-5-hid.ts | 216 +++++++++++++++++ src/steelseries/devices.ts | 15 +- src/steelseries/index.ts | 1 + src/steelseries/rival-5.test.ts | 88 +++++++ src/steelseries/rival-5.ts | 255 ++++++++++++++++++++ 7 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 src/drivers/steelseries/rival-5-hid.test.ts create mode 100644 src/drivers/steelseries/rival-5-hid.ts create mode 100644 src/steelseries/rival-5.test.ts create mode 100644 src/steelseries/rival-5.ts diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..6f35b73d 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -58,6 +58,7 @@ import { SteelSeriesRival310HidClient } from "./steelseries/rival310-hid.ts"; import { SteelSeriesPrimePlusHidClient } from "./steelseries/prime-plus-hid.ts"; import { SteelSeriesPrimeMiniWirelessHidClient } from "./steelseries/prime-mini-wireless-hid.ts"; import { SteelSeriesSenseiTenHidClient } from "./steelseries/sensei-ten-hid.ts"; +import { SteelSeriesRival5HidClient } from "./steelseries/rival-5-hid.ts"; import { GloriousHidClient } from "./glorious/hid.ts"; import { GloriousClassicHidClient } from "./glorious/classic-hid.ts"; import { GloriousCore2HidClient } from "./glorious/core2-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 | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesRival5HidClient | 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; @@ -176,6 +177,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesPrimePlusHidClient.isSupported(device), create: (device) => new SteelSeriesPrimePlusHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesPrimeMiniWirelessHidClient.isSupported(device), create: (device) => new SteelSeriesPrimeMiniWirelessHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiTenHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiTenHidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesRival5HidClient.isSupported(device), create: (device) => new SteelSeriesRival5HidClient(device), score: () => 6 }, { brand: "Glorious", supports: (device) => GloriousHidClient.isSupported(device), create: (device) => new GloriousHidClient(device), score: () => 5 }, { brand: "Glorious", supports: (device) => GloriousClassicHidClient.isSupported(device), create: (device) => new GloriousClassicHidClient(device), score: () => 5 }, // Same vendor id as the classic family; disjoint by product id. diff --git a/src/drivers/steelseries/rival-5-hid.test.ts b/src/drivers/steelseries/rival-5-hid.test.ts new file mode 100644 index 00000000..f2120bbd --- /dev/null +++ b/src/drivers/steelseries/rival-5-hid.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesRival5HidClient } from "./rival-5-hid.ts"; + +function fakeDevice(options: { productId?: number; firmware?: number[]; answerFirmware?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x183c, + productName: "SteelSeries Rival 5", + 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] === 0x90 && options.answerFirmware !== false) { + const response = new Uint8Array(options.firmware ?? [...[0x31, 0x2e, 0x30, 0x2e, 0x31], ...new Array(27).fill(0x00)]); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + } + }, + sendFeatureReport: async () => { throw new Error("the Rival 5 protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Rival 5 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 Rival 5 product id", () => { + const { device } = fakeDevice(); + assert.equal(SteelSeriesRival5HidClient.isSupported(device), true); + for (const productId of [0x1836, 0x1850, 0x1830, 0x1824, 0x1720, 0x1832]) { + assert.equal( + SteelSeriesRival5HidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesRival5HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus probes firmware and reports stage defaults", async () => { + const { device, sent } = fakeDevice(); + const status = await new SteelSeriesRival5HidClient(device).readStatus(); + assert.deepEqual(sent, [{ reportId: 0, payload: [0x90] }]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Rival 5"); + assert.deepEqual(status.firmware, ["1.0.1"]); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.dpi, 400); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.ui?.valuesVerified, false); + assert.ok(status.ui?.pollingNote); +}); + +test("a silent interface fails the firmware probe loudly", async () => { + const { device } = fakeDevice({ answerFirmware: false }); + const client = new SteelSeriesRival5HidClient(device); + await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); +}); + +test("setDpi edits the active stage and rewrites the table plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival5HidClient(device); + await client.readStatus(); + sent.length = 0; + const dpi = await client.setDpi(1600); + assert.equal(dpi, 1600); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x2d, 0x05, 0x00, 0x12, 0x09, 0x0d, 0x1b, 0x26] }, + { reportId: 0, payload: [0x11] }, + ]); +}); + +test("setPollingRate and setButtonsMapping write plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival5HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setPollingRate(500); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x2b, 0x02] }, + { reportId: 0, payload: [0x11] }, + ]); + sent.length = 0; + await client.setButtonsMapping({ button9: { type: "dpiSwitch" } }); + assert.equal(sent.length, 2); + assert.equal(sent[0]?.payload[0], 0x2a); + assert.equal(sent[0]?.payload.length, 1 + 11 * 5); + assert.equal(sent[0]?.payload[1 + 8 * 5], 0x30); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x11] }); +}); diff --git a/src/drivers/steelseries/rival-5-hid.ts b/src/drivers/steelseries/rival-5-hid.ts new file mode 100644 index 00000000..01fed01f --- /dev/null +++ b/src/drivers/steelseries/rival-5-hid.ts @@ -0,0 +1,216 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + RIVAL5_DEFAULT_DPI_PRESETS, + RIVAL5_DEFAULT_POLLING_RATE, + RIVAL5_MAX_DPI_PRESETS, + RIVAL5_POLLING_RATES, + RIVAL5_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesRival5DecodeFirmware, + steelseriesRival5DpiOptions, + steelseriesRival5EncodeButtonsMapping, + steelseriesRival5EncodeDpiPresets, + steelseriesRival5EncodePollingRate, + steelseriesRival5FirmwareQuery, + steelseriesRival5SaveCommand, + type Rival5ButtonAction, + type Rival5ButtonName, +} from "@openmouse/protocol/steelseries"; + +/** rivalcfg's `command_approve_delay`, shared across every SteelSeries family. */ +const COMMAND_DELAY_MS = 50; +/** Firmware replies are small; allow slack over rivalcfg-style timeouts. */ +const FIRMWARE_TIMEOUT_MS = 500; + +/** + * SteelSeries Rival 5 WebHID control (`1038:183C`, wired only). + * + * DPI presets, polling rate, and buttons are write-only; only the 32-byte + * firmware version string (`90`) can be read back, and this driver uses it + * as its connectivity probe. `readStatus` therefore reports the session's + * last-written values (or the vendor's documented defaults before any + * write) with `valuesVerified: false`. Every setter follows its write with + * the save command so the change persists in the mouse's onboard memory. + * See `../../steelseries/rival-5.ts` for the full protocol writeup — + * reconstructed from the vendor's GG device specification, not from + * rivalcfg, which has no Rival 5 profile. + * + * The config channel shape has not been captured yet, so the picker offers + * every interface and the wrong ones fail the firmware probe loudly. + */ +export class SteelSeriesRival5HidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private listenerAttached = false; + private readonly inputWaiters = new Set<(payload: Uint8Array) => void>(); + private dpiStages: number[] = [...RIVAL5_DEFAULT_DPI_PRESETS]; + private activeDpiStage = 0; + private pollingRateHz: number = RIVAL5_DEFAULT_POLLING_RATE; + + 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-5"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...RIVAL5_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesRival5DpiOptions(); } + + 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 5"}`; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-rival-5", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: "The Rival 5 cannot report its current settings; values shown are the last written by this app, or assumed defaults.", + defaultDisplayName: "SteelSeries Rival 5", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: this.dpiStages[this.activeDpiStage] ?? RIVAL5_DEFAULT_DPI_PRESETS[0]!, + pollingRateHz: this.pollingRateHz, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + connectionType: "Wired", + liftOffDistance: null, + firmware: [firmware], + }; + }); + } + + /** writes `dpi` into the active stage. */ + async setDpi(dpi: number): Promise { + return await this.setDpiStageValue(this.activeDpiStage, dpi); + } + + async setDpiStageValue(stage: number, dpi: number): Promise { + this.requireStage(stage); + const stages = this.dpiStages.map((value, index) => (index === stage ? dpi : value)); + await this.writePresets(stages, this.activeDpiStage); + return dpi; + } + + async setActiveDpiStage(stage: number): Promise { + this.requireStage(stage); + await this.writePresets(this.dpiStages, stage); + return stage; + } + + async setDpiStages(stages: readonly number[], activeStage = 0): Promise { + await this.writePresets(stages, activeStage); + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesRival5EncodePollingRate(pollingRateHz)); + this.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + async setButtonsMapping(mapping: Partial>): Promise { + await this.commit(steelseriesRival5EncodeButtonsMapping(mapping)); + } + + private requireStage(stage: number): void { + if (!Number.isInteger(stage) || stage < 0 || stage >= RIVAL5_MAX_DPI_PRESETS) { + throw new Error(`The Rival 5 holds ${RIVAL5_MAX_DPI_PRESETS} DPI stages.`); + } + } + + private async writePresets(stages: readonly number[], activeStage: number): Promise { + this.requireStage(activeStage); + const report = steelseriesRival5EncodeDpiPresets(stages, activeStage); + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival5SaveCommand()); + this.dpiStages = [...stages]; + this.activeDpiStage = activeStage; + }); + } + + 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(steelseriesRival5SaveCommand()); + }); + } + + /** + * `90` — 32-byte firmware version string. 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(steelseriesRival5FirmwareQuery()); + const payload = await response; + if (!payload) { + throw new Error("The Rival 5 did not answer the firmware query on this interface. Add the device again and choose another entry."); + } + return steelseriesRival5DecodeFirmware(payload); + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(RIVAL5_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..6d52e941 100644 --- a/src/steelseries/devices.ts +++ b/src/steelseries/devices.ts @@ -77,7 +77,8 @@ export type SteelSeriesProtocolFamily = | "aerox9-wireless" | "prime-plus" | "prime-mini-wireless" - | "sensei-ten"; + | "sensei-ten" + | "rival-5"; export interface SteelSeriesProduct { model: string; @@ -383,4 +384,16 @@ export const STEELSERIES_PRODUCTS: ReadonlyMap = new hasFirmwareQuery: true, verified: false, }], + // rival-5.lisp (vendor GG specification): wired only. Own command set + // (DPI batch 0x2D with TrueMove Air bytes, polling 0x2B, 11-slot buttons + // 0x2A, 10-zone lighting, firmware 0x90) — see ./rival-5.ts. No + // rivalcfg profile exists for this mouse. + [0x183c, { + model: "Rival 5", + family: "rival-5", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], ]); diff --git a/src/steelseries/index.ts b/src/steelseries/index.ts index dacc7846..c705f6cd 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -10,4 +10,5 @@ export * from "./aerox9-wireless.js"; export * from "./prime-plus.js"; export * from "./prime-mini-wireless.js"; export * from "./sensei-ten.js"; +export * from "./rival-5.js"; export * from "./devices.js"; diff --git a/src/steelseries/rival-5.test.ts b/src/steelseries/rival-5.test.ts new file mode 100644 index 00000000..5cdd2e56 --- /dev/null +++ b/src/steelseries/rival-5.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RIVAL5_DPI_MAX, + RIVAL5_DPI_MIN, + RIVAL5_MAX_DPI_PRESETS, + RIVAL5_POLLING_RATES, + Rival5ProtocolError, + steelseriesRival5DecodeFirmware, + steelseriesRival5DpiOptions, + steelseriesRival5EncodeButtonsMapping, + steelseriesRival5EncodeDpiPresets, + steelseriesRival5EncodePollingRate, + steelseriesRival5FirmwareQuery, + steelseriesRival5SaveCommand, +} from "./rival-5.ts"; +import { TRUEMOVE_AIR_DPI_TO_BYTE } from "./rival3-wireless.js"; + +test("DPI options mirror the shared TrueMove Air table", () => { + const options = steelseriesRival5DpiOptions(); + assert.equal(options[0], RIVAL5_DPI_MIN); + assert.equal(options.at(-1), RIVAL5_DPI_MAX); + assert.equal(TRUEMOVE_AIR_DPI_TO_BYTE.get(400), 0x04); + assert.equal(TRUEMOVE_AIR_DPI_TO_BYTE.get(1600), 0x12); + assert.equal(TRUEMOVE_AIR_DPI_TO_BYTE.get(18000), 0xd6); +}); + +test("DPI presets encode five sensor bytes with count and 0-based index", () => { + assert.deepEqual( + [...steelseriesRival5EncodeDpiPresets([400, 800, 1200, 2400, 3200], 0)], + [0x2d, 0x05, 0x00, 0x04, 0x09, 0x0d, 0x1b, 0x26], + ); + assert.deepEqual( + [...steelseriesRival5EncodeDpiPresets([400, 800, 1200, 2400, 3200], 4).slice(0, 3)], + [0x2d, 0x05, 0x04], + ); +}); + +test("DPI presets require exactly five on-grid values and a valid index", () => { + assert.throws(() => steelseriesRival5EncodeDpiPresets([400, 800], 0), Rival5ProtocolError); + assert.throws( + () => steelseriesRival5EncodeDpiPresets([400, 800, 1200, 2400, 3200], 5), + Rival5ProtocolError, + ); + assert.throws(() => steelseriesRival5EncodeDpiPresets([150, 800, 1200, 2400, 3200], 0), Rival5ProtocolError); + assert.equal(RIVAL5_MAX_DPI_PRESETS, 5); +}); + +test("polling rates use 1000->0x01 down to 125->0x04", () => { + assert.deepEqual([...steelseriesRival5EncodePollingRate(1000)], [0x2b, 0x01]); + assert.deepEqual([...steelseriesRival5EncodePollingRate(125)], [0x2b, 0x04]); + assert.deepEqual([...RIVAL5_POLLING_RATES], [125, 250, 500, 1000]); + assert.throws(() => steelseriesRival5EncodePollingRate(8000), Rival5ProtocolError); +}); + +test("save is one byte; firmware is a 32-byte string query", () => { + assert.deepEqual([...steelseriesRival5SaveCommand()], [0x11]); + assert.deepEqual([...steelseriesRival5FirmwareQuery()], [0x90]); + const payload = new Uint8Array(32); + payload.set([0x31, 0x2e, 0x30, 0x2e, 0x31]); + assert.equal(steelseriesRival5DecodeFirmware(payload), "1.0.1"); + assert.throws(() => steelseriesRival5DecodeFirmware(new Uint8Array(31)), Rival5ProtocolError); +}); + +test("button mapping covers eleven slots", () => { + const report = steelseriesRival5EncodeButtonsMapping({ + button1: { type: "button", target: "button1" }, + button9: { type: "dpiSwitch" }, + scrollDown: { type: "button", target: "scrollDown" }, + }); + assert.equal(report[0], 0x2a); + assert.equal(report.length, 1 + 11 * 5); + assert.equal(report[1], 0x01); + assert.equal(report[1 + 8 * 5], 0x30); + assert.equal(report[1 + 10 * 5], 0x32); +}); + +test("button mapping rejects unknown buttons and bad codes", () => { + assert.throws( + () => steelseriesRival5EncodeButtonsMapping({ ["button10" as never]: { type: "disabled" } }), + Rival5ProtocolError, + ); + assert.throws( + () => steelseriesRival5EncodeButtonsMapping({ button1: { type: "multimedia", code: 300 } }), + Rival5ProtocolError, + ); +}); diff --git a/src/steelseries/rival-5.ts b/src/steelseries/rival-5.ts new file mode 100644 index 00000000..19d48292 --- /dev/null +++ b/src/steelseries/rival-5.ts @@ -0,0 +1,255 @@ +/** + * SteelSeries Rival 5 configuration protocol — pure encode/decode helpers. + * + * Reconstructed from public open-source implementations and the vendor's + * own software, not from vendor source code: + * + * - rivalcfg has no Rival 5 profile, so there is no rivalcfg corroboration + * for any byte here — unlike every other wired SteelSeries family in this + * package, which all trace to rivalcfg first. + * - The vendor's SteelSeries GG device specification (`rival-5.lisp`, + * GG 121.0.0) documents the full command set: DPI batch `0x2D`, polling + * `0x2B` (+ read `0xAB`), 11-slot buttons `0x2A`, lighting `0x21`–`0x27`, + * save `0x11`, firmware `0x90`. Those files are OpenPGP-armored + * ("DESCRIPTOR" armor, SKESK/AES-128); the bytes below are the documented + * HID report payloads. + * - The DPI sensor table is byte-identical to TrueMove Air (spot-checked + * against the vendor file's inline case table: `0x00→100`, `0x04→400`, + * `0x12→1600`, `0xD6→18000`), so it is imported from + * `./rival3-wireless.ts`'s `TRUEMOVE_AIR_DPI_TO_BYTE` rather than + * re-derived — the same reuse `./aerox5.ts` makes. + * + * Every command is an HID **output report** with report id 0x00 (65-byte + * reports throughout the specification). + * + * DPI uses a 5-preset batch (`0x2D`): `level_max` (1–5), `level_active` + * (0–4, 0-based — same convention as `./aerox5.ts`'s `first_preset: 0`), + * then five one-byte sensor values (`0x00–0xD6`). The specification always + * carries all five slots, so this codec requires exactly five presets + * rather than guessing at padding for shorter tables. + * + * Polling (`0x2B`, values 1000→0x01, 500→0x02, 250→0x03, 125→0x04) has a + * read-back (`0xAB`), defined but not decoded here. Buttons are one packet + * (11 slots × 5-byte fields: 9 physical buttons plus scroll up/down), + * command `0x2A`. + * + * Known but deliberately not implemented — documented so nobody re-derives + * them, withheld until there is hardware evidence and a reason to ship them: + * + * - The 10-zone `direct_write` (`0x21` + uint16 LED mask + 10 RGB triples), + * `write_all_illumination` (mask `0x3FF`), brightness (`0x23`, 0–100), + * reactive color (`0x26`), default lighting (`0x27`), and reset (`0x22`). + * The mega-strip zone addressing is not needed for DPI/polling/buttons. + * - `0xAB` — polling read-back. + * - Accelerometer/deceleration and angle snapping: driver-side (`SSE`) + * settings, not HID commands, out of scope for this codec. + * + * Settings apply immediately; the save command (`0x11`, one byte) persists + * them to onboard flash. None of this has been verified on physical + * hardware by this project. + */ + +import { TRUEMOVE_AIR_DPI_TO_BYTE } from "./rival3-wireless.js"; + +export const RIVAL5_REPORT_ID = 0x00; + +/** Command prefixes; the payload is the prefix plus its arguments. */ +export const RIVAL5_COMMAND = { + dpiPresets: [0x2d], + pollingRate: [0x2b], + buttonsMapping: [0x2a], +} as const; + +/** `0xAB` — polling read-back; documented, not decoded here. */ +export const RIVAL5_POLLING_QUERY = [0xab] as const; + +/** `0x90` — firmware version (32-byte string reply, see below). */ +export const RIVAL5_FIRMWARE_QUERY = [0x90] as const; + +/** `0x11` — one byte; commit the current settings to onboard flash. */ +export const RIVAL5_SAVE_COMMAND = [0x11] as const; + +export const RIVAL5_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 RIVAL5_DPI_MIN = 100; +export const RIVAL5_DPI_MAX = 18000; +export const RIVAL5_DPI_STEP = 100; +export const RIVAL5_MAX_DPI_PRESETS = 5; +export const RIVAL5_FIRMWARE_RESPONSE_LENGTH = 32; + +/** Factory table from the GG seed data (400/800/1200/2400/3200). */ +export const RIVAL5_DEFAULT_DPI_PRESETS = [400, 800, 1200, 2400, 3200] as const; +export const RIVAL5_DEFAULT_POLLING_RATE = 1000; + +export class Rival5ProtocolError extends Error {} + +/** Every DPI value the shared TrueMove Air table can express, ascending. */ +export function steelseriesRival5DpiOptions(): number[] { + return [...TRUEMOVE_AIR_DPI_TO_BYTE.keys()].sort((a, b) => a - b); +} + +/** + * `2D <5 sensor bytes>` — replaces the + * mouse's whole preset table. Exactly five presets are required (see the + * module doc comment); DPI values must be exact TrueMove Air table keys — + * off-grid values are rejected rather than rounded, matching how the rest + * of this package validates DPI. + */ +export function steelseriesRival5EncodeDpiPresets( + presets: readonly number[], + selectedIndex: number, +): Uint8Array { + if (presets.length !== RIVAL5_MAX_DPI_PRESETS) { + throw new Rival5ProtocolError( + `SteelSeries Rival 5 requires exactly ${RIVAL5_MAX_DPI_PRESETS} DPI presets.`, + ); + } + if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= presets.length) { + throw new Rival5ProtocolError( + `Selected DPI preset must be an index into the ${presets.length} presets being written.`, + ); + } + const encoded = presets.map((dpi) => { + const byte = TRUEMOVE_AIR_DPI_TO_BYTE.get(dpi); + if (byte === undefined) { + throw new Rival5ProtocolError( + `SteelSeries Rival 5 DPI must be ${RIVAL5_DPI_MIN}–${RIVAL5_DPI_MAX.toLocaleString("en-US")} in ${RIVAL5_DPI_STEP} DPI steps.`, + ); + } + return byte; + }); + return new Uint8Array([...RIVAL5_COMMAND.dpiPresets, presets.length, selectedIndex, ...encoded]); +} + +/** `2B ` with 1000→0x01, 500→0x02, 250→0x03, 125→0x04. */ +export function steelseriesRival5EncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new Rival5ProtocolError("SteelSeries Rival 5 supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...RIVAL5_COMMAND.pollingRate, byte]); +} + +/** `11` — commit the current settings to onboard flash. */ +export function steelseriesRival5SaveCommand(): Uint8Array { + return new Uint8Array(RIVAL5_SAVE_COMMAND); +} + +/** `90` — the device answers with a 32-byte version string. */ +export function steelseriesRival5FirmwareQuery(): Uint8Array { + return new Uint8Array(RIVAL5_FIRMWARE_QUERY); +} + +/** + * Decode the 32-byte firmware version reply as ASCII, stopping at the + * first NUL. The string shape follows the specification's `version_str + * bytearray (size 32)` field; trailing padding is not part of the version. + */ +export function steelseriesRival5DecodeFirmware(payload: Uint8Array): string { + if (payload.length < RIVAL5_FIRMWARE_RESPONSE_LENGTH) { + throw new Rival5ProtocolError("SteelSeries Rival 5 firmware response is shorter than 32 bytes."); + } + const bytes = payload.slice(0, RIVAL5_FIRMWARE_RESPONSE_LENGTH); + const nul = bytes.indexOf(0x00); + return new TextDecoder("ascii").decode(nul === -1 ? bytes : bytes.slice(0, nul)); +} + +// -- Button mapping -------------------------------------------------------- + +/** 9 physical buttons plus scroll up/down (11 slots, 55 bytes total). */ +export const RIVAL5_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 }, + button7: { id: 0x07, offset: 0x1e }, + button8: { id: 0x08, offset: 0x23 }, + button9: { id: 0x09, offset: 0x28 }, + scrollUp: { id: 0x31, offset: 0x2d }, + scrollDown: { id: 0x32, offset: 0x32 }, +} as const satisfies Record; + +export type Rival5ButtonName = keyof typeof RIVAL5_BUTTONS; + +const BUTTON_FIELD_LENGTH = 5; +const BUTTON_DISABLE = 0x00; +const BUTTON_DPI_SWITCH = 0x30; +const BUTTON_KEYBOARD = 0x51; +const BUTTON_MULTIMEDIA = 0x61; + +export type Rival5ButtonAction = + | { type: "button"; target: Rival5ButtonName } + | { type: "disabled" } + | { type: "dpiSwitch" } + | { 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 Rival5ProtocolError(`${kind} codes must be integers 0–255.`); + } + return code; +} + +/** + * `2A` + one 55-byte packet (11 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 steelseriesRival5EncodeButtonsMapping( + mapping: Partial>, +): Uint8Array { + for (const name of Object.keys(mapping)) { + if (!Object.hasOwn(RIVAL5_BUTTONS, name)) { + throw new Rival5ProtocolError(`Unknown SteelSeries Rival 5 button "${name}".`); + } + } + + const names = Object.keys(RIVAL5_BUTTONS) as Rival5ButtonName[]; + const packet = new Array(names.length * BUTTON_FIELD_LENGTH).fill(0x00); + + for (const [name, action] of Object.entries(mapping) as Array<[Rival5ButtonName, Rival5ButtonAction]>) { + const { offset } = RIVAL5_BUTTONS[name]; + switch (action.type) { + case "button": { + if (!Object.hasOwn(RIVAL5_BUTTONS, action.target)) { + throw new Rival5ProtocolError(`Unknown SteelSeries Rival 5 button "${action.target}".`); + } + packet[offset] = RIVAL5_BUTTONS[action.target].id; + break; + } + case "disabled": { + packet[offset] = BUTTON_DISABLE; + break; + } + case "dpiSwitch": { + packet[offset] = BUTTON_DPI_SWITCH; + break; + } + case "keyboard": { + packet[offset] = BUTTON_KEYBOARD; + packet[offset + 1] = encodeKeyCode(action.code, "Keyboard scan"); + break; + } + case "multimedia": { + packet[offset] = BUTTON_MULTIMEDIA; + packet[offset + 1] = encodeKeyCode(action.code, "Multimedia key"); + break; + } + default: { + throw new Rival5ProtocolError("Unsupported SteelSeries Rival 5 button action."); + } + } + } + + return new Uint8Array([...RIVAL5_COMMAND.buttonsMapping, ...packet]); +} From c06ee804bd74b1b526f043bd0535b5dfcf437d2d Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 18:01:21 -0600 Subject: [PATCH 06/15] feat: add SteelSeries Rival 3 Gen 2 protocol family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wired-only Rival 3 Gen 2 (1038:1870), reconstructed from the vendor's own GG 121.0.0 device specification (rival-3-v2.lisp); rivalcfg corroborates the PID and command bytes but not the DPI table. XY DPI batch 0x34 (interleaved pairs, vendor TrueMove Core table with 0x49 for 3200 — diverges from rivalcfg's table in 23 entries, flagged in-code), polling 0x2B, 8-slot buttons 0x2A, save 0x11, firmware 0x90 (15-byte string, used as the connectivity probe). Unverified on hardware. --- src/drivers/registry.ts | 4 +- .../steelseries/rival-3-v2-hid.test.ts | 106 +++++++ src/drivers/steelseries/rival-3-v2-hid.ts | 223 +++++++++++++ src/steelseries/aerox3.ts | 6 +- src/steelseries/devices.ts | 29 +- src/steelseries/index.ts | 1 + src/steelseries/rival-3-v2.test.ts | 94 ++++++ src/steelseries/rival-3-v2.ts | 292 ++++++++++++++++++ 8 files changed, 744 insertions(+), 11 deletions(-) create mode 100644 src/drivers/steelseries/rival-3-v2-hid.test.ts create mode 100644 src/drivers/steelseries/rival-3-v2-hid.ts create mode 100644 src/steelseries/rival-3-v2.test.ts create mode 100644 src/steelseries/rival-3-v2.ts diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..658802c0 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -58,6 +58,7 @@ import { SteelSeriesRival310HidClient } from "./steelseries/rival310-hid.ts"; import { SteelSeriesPrimePlusHidClient } from "./steelseries/prime-plus-hid.ts"; import { SteelSeriesPrimeMiniWirelessHidClient } from "./steelseries/prime-mini-wireless-hid.ts"; import { SteelSeriesSenseiTenHidClient } from "./steelseries/sensei-ten-hid.ts"; +import { SteelSeriesRival3Gen2HidClient } from "./steelseries/rival-3-v2-hid.ts"; import { GloriousHidClient } from "./glorious/hid.ts"; import { GloriousClassicHidClient } from "./glorious/classic-hid.ts"; import { GloriousCore2HidClient } from "./glorious/core2-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 | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesRival3Gen2HidClient | 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; @@ -176,6 +177,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesPrimePlusHidClient.isSupported(device), create: (device) => new SteelSeriesPrimePlusHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesPrimeMiniWirelessHidClient.isSupported(device), create: (device) => new SteelSeriesPrimeMiniWirelessHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiTenHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiTenHidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesRival3Gen2HidClient.isSupported(device), create: (device) => new SteelSeriesRival3Gen2HidClient(device), score: () => 6 }, { brand: "Glorious", supports: (device) => GloriousHidClient.isSupported(device), create: (device) => new GloriousHidClient(device), score: () => 5 }, { brand: "Glorious", supports: (device) => GloriousClassicHidClient.isSupported(device), create: (device) => new GloriousClassicHidClient(device), score: () => 5 }, // Same vendor id as the classic family; disjoint by product id. diff --git a/src/drivers/steelseries/rival-3-v2-hid.test.ts b/src/drivers/steelseries/rival-3-v2-hid.test.ts new file mode 100644 index 00000000..37bf5eac --- /dev/null +++ b/src/drivers/steelseries/rival-3-v2-hid.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesRival3Gen2HidClient } from "./rival-3-v2-hid.ts"; + +function fakeDevice(options: { productId?: number; firmware?: number[]; answerFirmware?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x1870, + productName: "SteelSeries Rival 3 Gen 2", + 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] === 0x90 && options.answerFirmware !== false) { + const response = new Uint8Array( + options.firmware ?? [0x31, 0x2e, 0x31, 0x2e, 0x36, ...new Array(10).fill(0x00)], + ); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + } + }, + sendFeatureReport: async () => { throw new Error("the Rival 3 Gen 2 protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Rival 3 Gen 2 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 Rival 3 Gen 2 product id", () => { + const { device } = fakeDevice(); + assert.equal(SteelSeriesRival3Gen2HidClient.isSupported(device), true); + // Gen 1, Aerox 3, and the wireless siblings must not be claimed. + for (const productId of [0x1824, 0x184c, 0x1836, 0x1830, 0x1872, 0x1850]) { + assert.equal( + SteelSeriesRival3Gen2HidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesRival3Gen2HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus probes firmware and reports stage defaults", async () => { + const { device, sent } = fakeDevice(); + const status = await new SteelSeriesRival3Gen2HidClient(device).readStatus(); + assert.deepEqual(sent, [{ reportId: 0, payload: [0x90] }]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Rival 3 Gen 2"); + assert.deepEqual(status.firmware, ["1.1.6"]); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.dpi, 400); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.ui?.valuesVerified, false); + assert.ok(status.ui?.pollingNote); +}); + +test("a silent interface fails the firmware probe loudly", async () => { + const { device } = fakeDevice({ answerFirmware: false }); + const client = new SteelSeriesRival3Gen2HidClient(device); + await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); +}); + +test("setDpi edits both axes of the active stage plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival3Gen2HidClient(device); + await client.readStatus(); + sent.length = 0; + const dpi = await client.setDpi(1600); + assert.equal(dpi, 1600); + assert.deepEqual(sent, [ + { + reportId: 0, + payload: [0x34, 0x05, 0x00, 0x24, 0x24, 0x12, 0x12, 0x24, 0x24, 0x37, 0x37, 0x49, 0x49], + }, + { reportId: 0, payload: [0x11] }, + ]); +}); + +test("setPollingRate and setButtonsMapping write plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival3Gen2HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setPollingRate(500); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x2b, 0x02] }, + { reportId: 0, payload: [0x11] }, + ]); + sent.length = 0; + await client.setButtonsMapping({ button6: { type: "scrollUp" } }); + assert.equal(sent.length, 2); + assert.equal(sent[0]?.payload[0], 0x2a); + assert.equal(sent[0]?.payload.length, 1 + 8 * 5); + assert.equal(sent[0]?.payload[1 + 5 * 5], 0x31); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x11] }); +}); diff --git a/src/drivers/steelseries/rival-3-v2-hid.ts b/src/drivers/steelseries/rival-3-v2-hid.ts new file mode 100644 index 00000000..64d3b319 --- /dev/null +++ b/src/drivers/steelseries/rival-3-v2-hid.ts @@ -0,0 +1,223 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + RIVAL3_GEN2_DEFAULT_DPI_PRESETS, + RIVAL3_GEN2_DEFAULT_POLLING_RATE, + RIVAL3_GEN2_MAX_DPI_PRESETS, + RIVAL3_GEN2_POLLING_RATES, + RIVAL3_GEN2_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesRival3Gen2DecodeFirmware, + steelseriesRival3Gen2DpiOptions, + steelseriesRival3Gen2EncodeButtonsMapping, + steelseriesRival3Gen2EncodeDpiPresets, + steelseriesRival3Gen2EncodePollingRate, + steelseriesRival3Gen2FirmwareQuery, + steelseriesRival3Gen2SaveCommand, + type Rival3Gen2ButtonAction, + type Rival3Gen2ButtonName, + type Rival3Gen2DpiPreset, +} from "@openmouse/protocol/steelseries"; + +/** rivalcfg's `command_approve_delay`, shared across every SteelSeries family. */ +const COMMAND_DELAY_MS = 50; +/** Firmware replies are small; allow slack over rivalcfg-style timeouts. */ +const FIRMWARE_TIMEOUT_MS = 500; + +/** + * SteelSeries Rival 3 Gen 2 WebHID control (`1038:1870`, wired only). + * + * DPI presets, polling rate, and buttons are write-only; only the 15-byte + * firmware version string (`90`) can be read back, and this driver uses it + * as its connectivity probe. `readStatus` therefore reports the session's + * last-written values (or the documented defaults before any write) with + * `valuesVerified: false`. Every setter follows its write with the save + * command so the change persists in the mouse's onboard memory. See + * `../../steelseries/rival-3-v2.ts` for the full protocol writeup — + * reconstructed from the vendor's GG device specification, with the DPI + * table transcribed from the vendor file (note the documented divergence + * from rivalcfg's TrueMove Core table there). + * + * DPI is managed as five XY stages (the common factory table), mirroring + * the Aerox wireless drivers: `setDpi` edits both axes of the active stage, + * and the whole table is rewritten on every change because the device + * exposes no per-preset getter. Do not confuse this mouse with Rival 3 Gen + * 1 (`1038:1824`/`1038:184C`, a different command set). + */ +export class SteelSeriesRival3Gen2HidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private listenerAttached = false; + private readonly inputWaiters = new Set<(payload: Uint8Array) => void>(); + private dpiStages: Rival3Gen2DpiPreset[] = RIVAL3_GEN2_DEFAULT_DPI_PRESETS.map((dpi) => ({ x: dpi, y: dpi })); + private activeDpiStage = 0; + private pollingRateHz: number = RIVAL3_GEN2_DEFAULT_POLLING_RATE; + + 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 === "rival3-v2"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...RIVAL3_GEN2_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesRival3Gen2DpiOptions(); } + + 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 Gen 2"}`; + const active = this.dpiStages[this.activeDpiStage]; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-rival3-v2", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: "The Rival 3 Gen 2 cannot report its current settings; values shown are the last written by this app, or assumed defaults.", + defaultDisplayName: "SteelSeries Rival 3 Gen 2", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: active?.x ?? RIVAL3_GEN2_DEFAULT_DPI_PRESETS[0]!, + pollingRateHz: this.pollingRateHz, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + connectionType: "Wired", + liftOffDistance: null, + firmware: [firmware], + }; + }); + } + + /** writes `dpi` into both axes of the active stage. */ + async setDpi(dpi: number): Promise { + await this.setDpiStageValue(this.activeDpiStage, { x: dpi, y: dpi }); + return dpi; + } + + async setDpiStageValue(stage: number, preset: Rival3Gen2DpiPreset): Promise { + this.requireStage(stage); + const stages = this.dpiStages.map((value, index) => (index === stage ? preset : value)); + await this.writePresets(stages, this.activeDpiStage); + return preset; + } + + async setActiveDpiStage(stage: number): Promise { + this.requireStage(stage); + await this.writePresets(this.dpiStages, stage); + return stage; + } + + async setDpiStages(stages: readonly Rival3Gen2DpiPreset[], activeStage = 0): Promise { + await this.writePresets(stages, activeStage); + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesRival3Gen2EncodePollingRate(pollingRateHz)); + this.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + async setButtonsMapping(mapping: Partial>): Promise { + await this.commit(steelseriesRival3Gen2EncodeButtonsMapping(mapping)); + } + + private requireStage(stage: number): void { + if (!Number.isInteger(stage) || stage < 0 || stage >= RIVAL3_GEN2_MAX_DPI_PRESETS) { + throw new Error(`The Rival 3 Gen 2 holds ${RIVAL3_GEN2_MAX_DPI_PRESETS} DPI stages.`); + } + } + + private async writePresets(stages: readonly Rival3Gen2DpiPreset[], activeStage: number): Promise { + this.requireStage(activeStage); + const report = steelseriesRival3Gen2EncodeDpiPresets(stages, activeStage); + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival3Gen2SaveCommand()); + this.dpiStages = [...stages]; + this.activeDpiStage = activeStage; + }); + } + + 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(steelseriesRival3Gen2SaveCommand()); + }); + } + + /** + * `90` — 15-byte firmware version string. 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(steelseriesRival3Gen2FirmwareQuery()); + const payload = await response; + if (!payload) { + throw new Error("The Rival 3 Gen 2 did not answer the firmware query on this interface. Add the device again and choose another entry."); + } + return steelseriesRival3Gen2DecodeFirmware(payload); + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(RIVAL3_GEN2_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/aerox3.ts b/src/steelseries/aerox3.ts index 99e104cd..d8135be1 100644 --- a/src/steelseries/aerox3.ts +++ b/src/steelseries/aerox3.ts @@ -41,9 +41,9 @@ * two mainline device files. Per this rollout's instructions, rivalcfg's * mainline file is treated as authoritative over the PR description, and * because the DPI command genuinely differs, `1038:0x1870` is **not** added - * to this module's or any registry's product table here — a Rival 3 Gen 2 - * driver needs its own DPI encoder and is out of scope for this cluster - * (Aerox 3 only). Nothing in this file claims or matches that PID. + * to this module's product table — it is registered under its own + * `"rival3-v2"` family instead (see `./rival-3-v2.ts`), which is also where + * its DPI encoder lives. Nothing in this file claims or matches that PID. * * Every command is an HID **output report** with report id 0x00 on the * vendor configuration interface (hidapi `endpoint` / `interface_number == diff --git a/src/steelseries/devices.ts b/src/steelseries/devices.ts index 8efdb71c..8dbd7cc3 100644 --- a/src/steelseries/devices.ts +++ b/src/steelseries/devices.ts @@ -9,10 +9,12 @@ * codec branches on product id. * * Only PIDs whose protocol has been traced to a public implementation belong - * here. The Rival 3 Wireless Gen 2 (`0x1872`) and Rival 3 Gen 2 (`0x1870`) - * are deliberately absent: their command sets are documented as different - * from every family below, and listing them would claim devices this codec - * would misprogram. The Rival 3 Wireless (`0x1830`) is present with family + * here. The Rival 3 Wireless Gen 2 (`0x1872`) is deliberately absent: its + * command set is documented as different from every family below, and + * listing it would claim a device this codec would misprogram. The Rival 3 + * Gen 2 (`0x1870`) is present with family `"rival3-v2"` — its own module, + * `./rival-3-v2.ts`, with an XY DPI command no other family shares. The + * Rival 3 Wireless (`0x1830`) is present with family * `"rival3-wireless"` — its own module, `./rival3-wireless.ts`, distinct from * both Rival 3 Gen 1 and Aerox 3 (different command bytes throughout, a * different DPI sensor table, and, unlike either wired family, a readable @@ -21,9 +23,9 @@ * The Aerox 3 (`0x1836`, family `"aerox3"`) is a separate protocol family * from Rival 3 despite both being SteelSeries, single-byte vs. two-byte * command prefixes and all — see `./aerox3.ts`'s doc comment, including its - * reconciliation of PR flozz/rivalcfg#269's Rival 3 Gen 2 claim (not added - * here: its DPI command differs from Aerox 3's even though most other - * commands match). + * reconciliation of PR flozz/rivalcfg#269's Rival 3 Gen 2 claim (shipped as + * its own `"rival3-v2"` family instead: its DPI command differs from + * Aerox 3's even though most other commands match). * * The Aerox 3 Wireless (`"aerox3-wireless"`, four PIDs across its USB-cabled * and 2.4 GHz dongle modes, see `./aerox3-wireless.ts`) shares nothing with @@ -67,6 +69,7 @@ export const STEELSERIES_VENDOR_ID = 0x1038; export type SteelSeriesProtocolFamily = | "rival3" + | "rival3-v2" | "rival310" | "aerox3" | "aerox3-wireless" @@ -119,6 +122,18 @@ export const STEELSERIES_PRODUCTS: ReadonlyMap = new hasFirmwareQuery: true, verified: false, }], + // rival-3-v2 (vendor GG specification): wired only. Own command set (XY + // DPI 0x34, polling 0x2B, buttons 0x2A, RGB 0x21, save 0x11, firmware + // 0x90) — see ./rival-3-v2.ts. Must not reuse the Rival 3 Gen 1 or Aerox + // 3 modules despite the shared name. + [0x1870, { + model: "Rival 3 Gen 2", + family: "rival3-v2", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], // aerox3.py defines no getter and no firmware-query command for this family. [0x1836, { model: "Aerox 3", diff --git a/src/steelseries/index.ts b/src/steelseries/index.ts index dacc7846..1c01e51c 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -10,4 +10,5 @@ export * from "./aerox9-wireless.js"; export * from "./prime-plus.js"; export * from "./prime-mini-wireless.js"; export * from "./sensei-ten.js"; +export * from "./rival-3-v2.js"; export * from "./devices.js"; diff --git a/src/steelseries/rival-3-v2.test.ts b/src/steelseries/rival-3-v2.test.ts new file mode 100644 index 00000000..e16c7f92 --- /dev/null +++ b/src/steelseries/rival-3-v2.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RIVAL3_GEN2_DEFAULT_DPI_PRESETS, + RIVAL3_GEN2_DEFAULT_POLLING_RATE, + RIVAL3_GEN2_DPI_MAX, + RIVAL3_GEN2_DPI_MIN, + RIVAL3_GEN2_DPI_TO_BYTE, + RIVAL3_GEN2_MAX_DPI_PRESETS, + RIVAL3_GEN2_POLLING_RATES, + Rival3Gen2ProtocolError, + steelseriesRival3Gen2DecodeFirmware, + steelseriesRival3Gen2DpiOptions, + steelseriesRival3Gen2EncodeButtonsMapping, + steelseriesRival3Gen2EncodeDpiPresets, + steelseriesRival3Gen2EncodePollingRate, + steelseriesRival3Gen2FirmwareQuery, + steelseriesRival3Gen2SaveCommand, +} from "./rival-3-v2.ts"; + +test("DPI table follows the vendor file, including 0x49 for 3200", () => { + assert.equal(RIVAL3_GEN2_DPI_TO_BYTE.get(400), 0x08); + assert.equal(RIVAL3_GEN2_DPI_TO_BYTE.get(3200), 0x49); + assert.equal(RIVAL3_GEN2_DPI_TO_BYTE.get(8500), 0xc5); + assert.ok(![...RIVAL3_GEN2_DPI_TO_BYTE.values()].includes(0x4a)); + const options = steelseriesRival3Gen2DpiOptions(); + assert.equal(options[0], RIVAL3_GEN2_DPI_MIN); + assert.equal(options.at(-1), RIVAL3_GEN2_DPI_MAX); + assert.equal(options.length, 84); + assert.deepEqual([...RIVAL3_GEN2_DEFAULT_DPI_PRESETS], [400, 800, 1600, 2400, 3200]); + assert.equal(RIVAL3_GEN2_DEFAULT_POLLING_RATE, 1000); + assert.equal(RIVAL3_GEN2_MAX_DPI_PRESETS, 5); +}); + +test("DPI presets interleave X and Y bytes with count and index", () => { + const pair = { x: 800, y: 1600 }; + assert.deepEqual( + [...steelseriesRival3Gen2EncodeDpiPresets([pair, pair, pair, pair, pair], 1)], + [0x34, 0x05, 0x01, 0x12, 0x24, 0x12, 0x24, 0x12, 0x24, 0x12, 0x24, 0x12, 0x24], + ); +}); + +test("DPI presets require exactly five on-grid pairs and a valid index", () => { + const pair = { x: 800, y: 800 }; + assert.throws(() => steelseriesRival3Gen2EncodeDpiPresets([pair, pair], 0), Rival3Gen2ProtocolError); + assert.throws( + () => steelseriesRival3Gen2EncodeDpiPresets([pair, pair, pair, pair, pair], 5), + Rival3Gen2ProtocolError, + ); + assert.throws( + () => steelseriesRival3Gen2EncodeDpiPresets([pair, pair, pair, pair, { x: 150, y: 800 }], 0), + Rival3Gen2ProtocolError, + ); +}); + +test("polling rates use 1000->0x01 down to 125->0x04", () => { + assert.deepEqual([...steelseriesRival3Gen2EncodePollingRate(1000)], [0x2b, 0x01]); + assert.deepEqual([...steelseriesRival3Gen2EncodePollingRate(125)], [0x2b, 0x04]); + assert.deepEqual([...RIVAL3_GEN2_POLLING_RATES], [125, 250, 500, 1000]); + assert.throws(() => steelseriesRival3Gen2EncodePollingRate(2000), Rival3Gen2ProtocolError); +}); + +test("save is one byte; firmware is a 15-byte string query", () => { + assert.deepEqual([...steelseriesRival3Gen2SaveCommand()], [0x11]); + assert.deepEqual([...steelseriesRival3Gen2FirmwareQuery()], [0x90]); + const payload = new Uint8Array([0x31, 0x2e, 0x31, 0x2e, 0x36, ...new Array(10).fill(0x00)]); + assert.equal(steelseriesRival3Gen2DecodeFirmware(payload), "1.1.6"); + assert.throws(() => steelseriesRival3Gen2DecodeFirmware(new Uint8Array(14)), Rival3Gen2ProtocolError); +}); + +test("button mapping covers eight slots", () => { + const report = steelseriesRival3Gen2EncodeButtonsMapping({ + button1: { type: "button", target: "button1" }, + button6: { type: "scrollUp" }, + scrollDown: { type: "dpiSwitch" }, + }); + assert.equal(report[0], 0x2a); + assert.equal(report.length, 1 + 8 * 5); + assert.equal(report[1], 0x01); + assert.equal(report[1 + 5 * 5], 0x31); + assert.equal(report[1 + 7 * 5], 0x30); +}); + +test("button mapping rejects unknown buttons and bad codes", () => { + assert.throws( + () => steelseriesRival3Gen2EncodeButtonsMapping({ ["button7" as never]: { type: "disabled" } }), + Rival3Gen2ProtocolError, + ); + assert.throws( + () => steelseriesRival3Gen2EncodeButtonsMapping({ button1: { type: "keyboard", code: -1 } }), + Rival3Gen2ProtocolError, + ); +}); diff --git a/src/steelseries/rival-3-v2.ts b/src/steelseries/rival-3-v2.ts new file mode 100644 index 00000000..5d8f35cb --- /dev/null +++ b/src/steelseries/rival-3-v2.ts @@ -0,0 +1,292 @@ +/** + * SteelSeries Rival 3 Gen 2 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-3-v2.lisp` under + * `apps/engine/install/deviceSpecifications/`, and this module transcribes + * it. Those files are OpenPGP-armored ("DESCRIPTOR" armor, SKESK/AES-128); + * the bytes below are the documented HID report payloads, not guesses. + * rivalcfg's mainline `rival3_gen2.py` corroborates the product id, the + * `0x34` XY-DPI command shape, and most other command bytes — but see the + * DPI table note below, where the two sources disagree. + * + * Despite the shared "Rival 3" name this is a different command set from + * Rival 3 Gen 1 (`./rival3.ts`: two-byte `0x0B 0x00` DPI, `0x04 0x00` + * polling, `0x09 0x00` save) and from Aerox 3 (`./aerox3.ts`). Command + * bytes here are single-byte prefixes with a `write-op`/`read-op` framing + * convention; on this wired-only mouse both masks are `0x00`, so the bytes + * go on the wire as written. + * + * Every command is an HID **output report** with report id 0x00 (65-byte + * reports throughout the specification). + * + * DPI uses an XY batch (`0x34`): `num_cpi_levels` (1–5), `index` (0–4, + * 0-based), then a 10-byte interleaved table (x0, y0, x1, y1, …), one + * sensor byte per axis value. The sensor table is TrueMove Core shaped + * (200–8500), transcribed here from the vendor file's own + * `convert_cpi_fw_to_driver` case table — **not** imported from + * `./rival3.ts`'s `TRUEMOVE_CORE_DPI_TO_BYTE`, because the two disagree on + * 23 entries: the vendor file says `0x49→3200` where rivalcfg says + * `0x4A→3200`, and similarly one byte lower at 22 other DPI steps (2300, + * 2600, 2900, 3500, 3800, …). The vendor table is self-consistent with the + * vendor's own factory defaults (Aerox 3's default preset bytes + * 8/18/27/55/73 decode to exactly 400/800/1200/2400/3200 under it, while + * `0x49` is absent from rivalcfg's table), so this module follows the + * vendor file and flags the divergence for the shared table to be + * reconciled separately. Off-grid DPI values are rejected rather than + * rounded. + * + * The specification always carries all five XY pairs, so this codec + * requires exactly five presets rather than guessing at padding for + * shorter tables. + * + * Polling (`0x2B`, values 1000→0x01, 500→0x02, 250→0x03, 125→0x04) has a + * read-back (same values), defined but not decoded here. Buttons are one + * 40-byte packet (8 slots × 5-byte fields: 6 physical buttons plus scroll + * up/down), command `0x2A`. + * + * Known but deliberately not implemented — documented so nobody re-derives + * them, withheld until there is hardware evidence and a reason to ship them: + * + * - The 3-zone RGB commands (`0x21`, per-zone bitmasks), reactive color + * (`0x26`), lighting enable (`0x27`), and reset (`0x22`, mask `0x07`). + * - `read_polling_rate` and the unique-id read. + * - Angle snapping: driver-side (`SSE`) settings, not HID commands. + * + * Settings apply immediately; the save command (`0x11`, one byte) persists + * them to onboard flash. None of this has been verified on physical + * hardware by this project. + */ + +/** Vendor TrueMove Core table, transcribed from the specification's own conversion table. */ +export const RIVAL3_GEN2_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], [7300, 0xa9], + [7400, 0xab], [7500, 0xad], [7600, 0xb0], [7700, 0xb2], [7800, 0xb4], [7900, 0xb7], + [8000, 0xb9], [8100, 0xbc], [8200, 0xbe], [8300, 0xc0], [8400, 0xc3], [8500, 0xc5], +]); + +export const RIVAL3_GEN2_REPORT_ID = 0x00; + +/** Command prefixes; the payload is the prefix plus its arguments. */ +export const RIVAL3_GEN2_COMMAND = { + dpiPresets: [0x34], + pollingRate: [0x2b], + buttonsMapping: [0x2a], + firmware: [0x90], +} as const; + +/** `0x11` — one byte; commit the current settings to onboard flash. */ +export const RIVAL3_GEN2_SAVE_COMMAND = [0x11] as const; + +export const RIVAL3_GEN2_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_GEN2_DPI_MIN = 200; +export const RIVAL3_GEN2_DPI_MAX = 8500; +export const RIVAL3_GEN2_DPI_STEP = 100; +export const RIVAL3_GEN2_MAX_DPI_PRESETS = 5; +export const RIVAL3_GEN2_FIRMWARE_RESPONSE_LENGTH = 15; + +/** Common factory table shared across the modern SteelSeries families. */ +export const RIVAL3_GEN2_DEFAULT_DPI_PRESETS = [400, 800, 1600, 2400, 3200] as const; +export const RIVAL3_GEN2_DEFAULT_POLLING_RATE = 1000; + +export class Rival3Gen2ProtocolError extends Error {} + +/** One DPI preset: independent X/Y sensitivities. */ +export interface Rival3Gen2DpiPreset { + x: number; + y: number; +} + +/** The 84 DPI values the vendor sensor table can express, ascending. */ +export function steelseriesRival3Gen2DpiOptions(): number[] { + return [...RIVAL3_GEN2_DPI_TO_BYTE.keys()].sort((a, b) => a - b); +} + +function dpiToByte(dpi: number): number { + const byte = RIVAL3_GEN2_DPI_TO_BYTE.get(dpi); + if (byte === undefined) { + throw new Rival3Gen2ProtocolError( + `SteelSeries Rival 3 Gen 2 DPI must be ${RIVAL3_GEN2_DPI_MIN}–${RIVAL3_GEN2_DPI_MAX.toLocaleString("en-US")} in ${RIVAL3_GEN2_DPI_STEP} DPI steps.`, + ); + } + return byte; +} + +/** + * `34 ` — replaces the + * mouse's whole preset table with five interleaved XY pairs. Exactly five + * presets are required (see the module doc comment). + */ +export function steelseriesRival3Gen2EncodeDpiPresets( + presets: readonly Rival3Gen2DpiPreset[], + selectedIndex: number, +): Uint8Array { + if (presets.length !== RIVAL3_GEN2_MAX_DPI_PRESETS) { + throw new Rival3Gen2ProtocolError( + `SteelSeries Rival 3 Gen 2 requires exactly ${RIVAL3_GEN2_MAX_DPI_PRESETS} DPI presets.`, + ); + } + if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= presets.length) { + throw new Rival3Gen2ProtocolError( + `Selected DPI preset must be an index into the ${presets.length} presets being written.`, + ); + } + return new Uint8Array([ + ...RIVAL3_GEN2_COMMAND.dpiPresets, + presets.length, + selectedIndex, + ...presets.flatMap((preset) => [dpiToByte(preset.x), dpiToByte(preset.y)]), + ]); +} + +/** `2B ` with 1000→0x01, 500→0x02, 250→0x03, 125→0x04. */ +export function steelseriesRival3Gen2EncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new Rival3Gen2ProtocolError("SteelSeries Rival 3 Gen 2 supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...RIVAL3_GEN2_COMMAND.pollingRate, byte]); +} + +/** `11` — commit the current settings to onboard flash. */ +export function steelseriesRival3Gen2SaveCommand(): Uint8Array { + return new Uint8Array(RIVAL3_GEN2_SAVE_COMMAND); +} + +/** `90` — the device answers with a 15-byte version string. */ +export function steelseriesRival3Gen2FirmwareQuery(): Uint8Array { + return new Uint8Array(RIVAL3_GEN2_COMMAND.firmware); +} + +/** + * Decode the 15-byte firmware version reply as ASCII, stopping at the + * first NUL. Same shape as the Prime Mini's reply; trailing padding is not + * part of the version. + */ +export function steelseriesRival3Gen2DecodeFirmware(payload: Uint8Array): string { + if (payload.length < RIVAL3_GEN2_FIRMWARE_RESPONSE_LENGTH) { + throw new Rival3Gen2ProtocolError("SteelSeries Rival 3 Gen 2 firmware response is shorter than 15 bytes."); + } + const bytes = payload.slice(0, RIVAL3_GEN2_FIRMWARE_RESPONSE_LENGTH); + const nul = bytes.indexOf(0x00); + return new TextDecoder("ascii").decode(nul === -1 ? bytes : bytes.slice(0, nul)); +} + +// -- Button mapping -------------------------------------------------------- + +/** 6 physical buttons plus scroll up/down (8 slots). */ +export const RIVAL3_GEN2_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 }, + scrollUp: { id: 0x31, offset: 0x1e }, + scrollDown: { id: 0x32, offset: 0x23 }, +} as const satisfies Record; + +export type Rival3Gen2ButtonName = keyof typeof RIVAL3_GEN2_BUTTONS; + +const BUTTON_FIELD_LENGTH = 5; +const BUTTON_DISABLE = 0x00; +const BUTTON_DPI_SWITCH = 0x30; +const BUTTON_KEYBOARD = 0x51; +const BUTTON_MULTIMEDIA = 0x61; + +export type Rival3Gen2ButtonAction = + | { type: "button"; target: Rival3Gen2ButtonName } + | { 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 Rival3Gen2ProtocolError(`${kind} codes must be integers 0–255.`); + } + return code; +} + +/** + * `2A` + one 40-byte packet (8 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 steelseriesRival3Gen2EncodeButtonsMapping( + mapping: Partial>, +): Uint8Array { + for (const name of Object.keys(mapping)) { + if (!Object.hasOwn(RIVAL3_GEN2_BUTTONS, name)) { + throw new Rival3Gen2ProtocolError(`Unknown SteelSeries Rival 3 Gen 2 button "${name}".`); + } + } + + const names = Object.keys(RIVAL3_GEN2_BUTTONS) as Rival3Gen2ButtonName[]; + const packet = new Array(names.length * BUTTON_FIELD_LENGTH).fill(0x00); + + for (const [name, action] of Object.entries(mapping) as Array<[Rival3Gen2ButtonName, Rival3Gen2ButtonAction]>) { + const { offset } = RIVAL3_GEN2_BUTTONS[name]; + switch (action.type) { + case "button": { + if (!Object.hasOwn(RIVAL3_GEN2_BUTTONS, action.target)) { + throw new Rival3Gen2ProtocolError(`Unknown SteelSeries Rival 3 Gen 2 button "${action.target}".`); + } + packet[offset] = RIVAL3_GEN2_BUTTONS[action.target].id; + break; + } + case "disabled": { + packet[offset] = BUTTON_DISABLE; + break; + } + case "dpiSwitch": { + packet[offset] = BUTTON_DPI_SWITCH; + break; + } + case "scrollUp": { + packet[offset] = RIVAL3_GEN2_BUTTONS.scrollUp.id; + break; + } + case "scrollDown": { + packet[offset] = RIVAL3_GEN2_BUTTONS.scrollDown.id; + break; + } + case "keyboard": { + packet[offset] = BUTTON_KEYBOARD; + packet[offset + 1] = encodeKeyCode(action.code, "Keyboard scan"); + break; + } + case "multimedia": { + packet[offset] = BUTTON_MULTIMEDIA; + packet[offset + 1] = encodeKeyCode(action.code, "Multimedia key"); + break; + } + default: { + throw new Rival3Gen2ProtocolError("Unsupported SteelSeries Rival 3 Gen 2 button action."); + } + } + } + + return new Uint8Array([...RIVAL3_GEN2_COMMAND.buttonsMapping, ...packet]); +} From 13556fac6a142ddd048e50b8ee2a1ef04c69be02 Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 18:05:13 -0600 Subject: [PATCH 07/15] feat: add SteelSeries Rival 3 Wireless Gen 2 protocol family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.4 GHz Rival 3 Wireless Gen 2 (1038:1872), reconstructed from the vendor's own GG 121.0.0 device specification (rival-3-wireless-v2.lisp) — no readable rivalcfg profile exists. Split XY DPI batch 0x2C (1-based index, TrueMove Air), polling 0x17, 7-slot buttons 0x19 plus 2-slot wheel mapping 0x1A, battery 0xAA 0x01 (2-byte level, no charging flag), firmware 0x10, save 0x09. No lighting commands exist on this mouse. Unverified on hardware. --- src/drivers/registry.ts | 4 +- .../rival-3-wireless-v2-hid.test.ts | 117 ++++++ .../steelseries/rival-3-wireless-v2-hid.ts | 251 ++++++++++++ src/steelseries/devices.ts | 25 +- src/steelseries/index.ts | 1 + src/steelseries/rival-3-wireless-v2.test.ts | 133 +++++++ src/steelseries/rival-3-wireless-v2.ts | 360 ++++++++++++++++++ 7 files changed, 885 insertions(+), 6 deletions(-) create mode 100644 src/drivers/steelseries/rival-3-wireless-v2-hid.test.ts create mode 100644 src/drivers/steelseries/rival-3-wireless-v2-hid.ts create mode 100644 src/steelseries/rival-3-wireless-v2.test.ts create mode 100644 src/steelseries/rival-3-wireless-v2.ts diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..2ce014bc 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -58,6 +58,7 @@ import { SteelSeriesRival310HidClient } from "./steelseries/rival310-hid.ts"; import { SteelSeriesPrimePlusHidClient } from "./steelseries/prime-plus-hid.ts"; import { SteelSeriesPrimeMiniWirelessHidClient } from "./steelseries/prime-mini-wireless-hid.ts"; import { SteelSeriesSenseiTenHidClient } from "./steelseries/sensei-ten-hid.ts"; +import { SteelSeriesRival3WirelessV2HidClient } from "./steelseries/rival-3-wireless-v2-hid.ts"; import { GloriousHidClient } from "./glorious/hid.ts"; import { GloriousClassicHidClient } from "./glorious/classic-hid.ts"; import { GloriousCore2HidClient } from "./glorious/core2-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 | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesRival3WirelessV2HidClient | 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; @@ -176,6 +177,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesPrimePlusHidClient.isSupported(device), create: (device) => new SteelSeriesPrimePlusHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesPrimeMiniWirelessHidClient.isSupported(device), create: (device) => new SteelSeriesPrimeMiniWirelessHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiTenHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiTenHidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesRival3WirelessV2HidClient.isSupported(device), create: (device) => new SteelSeriesRival3WirelessV2HidClient(device), score: () => 6 }, { brand: "Glorious", supports: (device) => GloriousHidClient.isSupported(device), create: (device) => new GloriousHidClient(device), score: () => 5 }, { brand: "Glorious", supports: (device) => GloriousClassicHidClient.isSupported(device), create: (device) => new GloriousClassicHidClient(device), score: () => 5 }, // Same vendor id as the classic family; disjoint by product id. diff --git a/src/drivers/steelseries/rival-3-wireless-v2-hid.test.ts b/src/drivers/steelseries/rival-3-wireless-v2-hid.test.ts new file mode 100644 index 00000000..25efb3ca --- /dev/null +++ b/src/drivers/steelseries/rival-3-wireless-v2-hid.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesRival3WirelessV2HidClient } from "./rival-3-wireless-v2-hid.ts"; + +function fakeDevice(options: { productId?: number; battery?: number[]; firmware?: number[]; answer?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x1872, + productName: "SteelSeries Rival 3 Wireless Gen 2", + 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] === 0xaa && payload[1] === 0x01) { + const response = new Uint8Array(options.battery ?? [0x64, 0x00]); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + } + if (payload[0] === 0x10 && payload.length === 1) { + const response = new Uint8Array(options.firmware ?? [0x01, 0x05]); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + } + }, + sendFeatureReport: async () => { throw new Error("the Rival 3 Wireless Gen 2 protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Rival 3 Wireless Gen 2 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 Rival 3 Wireless Gen 2 product id", () => { + const { device } = fakeDevice(); + assert.equal(SteelSeriesRival3WirelessV2HidClient.isSupported(device), true); + for (const productId of [0x1830, 0x1824, 0x1836, 0x1870, 0x1850]) { + assert.equal( + SteelSeriesRival3WirelessV2HidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesRival3WirelessV2HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus probes battery and firmware without claiming charging state", async () => { + const { device, sent } = fakeDevice(); + const status = await new SteelSeriesRival3WirelessV2HidClient(device).readStatus(); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0xaa, 0x01] }, + { reportId: 0, payload: [0x10] }, + ]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Rival 3 Wireless Gen 2"); + assert.deepEqual(status.firmware, ["1.5"]); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.batteryPercent, 100); + assert.equal(status.batteryState, "Unknown"); + assert.equal(status.dpi, 400); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.ui?.valuesVerified, false); + assert.ok(status.ui?.pollingNote); +}); + +test("a silent interface fails the battery probe loudly and names SteelSeries GG", async () => { + const { device } = fakeDevice({ answer: false }); + const client = new SteelSeriesRival3WirelessV2HidClient(device); + await assert.rejects(() => client.readStatus(), /Close SteelSeries GG/); +}); + +test("setDpi edits both axes of the active stage plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival3WirelessV2HidClient(device); + await client.readStatus(); + sent.length = 0; + const dpi = await client.setDpi(1600); + assert.equal(dpi, 1600); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x2c, 0x05, 0x01, 0x12, 0x09, 0x12, 0x1b, 0x26, 0x12, 0x09, 0x12, 0x1b, 0x26] }, + { reportId: 0, payload: [0x09] }, + ]); +}); + +test("setPollingRate, setButtonsMapping, and setWheelMapping write plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival3WirelessV2HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setPollingRate(500); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x17, 0x01] }, + { reportId: 0, payload: [0x09] }, + ]); + sent.length = 0; + await client.setButtonsMapping({ button7: { type: "dpiSwitch" } }); + assert.equal(sent.length, 2); + assert.equal(sent[0]?.payload[0], 0x19); + assert.equal(sent[0]?.payload.length, 1 + 7 * 5); + assert.equal(sent[0]?.payload[1 + 6 * 5], 0x30); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x09] }); + sent.length = 0; + await client.setWheelMapping({ scrollUp: { type: "button", target: "scrollUp" } }); + assert.equal(sent.length, 2); + assert.equal(sent[0]?.payload[0], 0x1a); + assert.equal(sent[0]?.payload.length, 1 + 2 * 5); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x09] }); +}); diff --git a/src/drivers/steelseries/rival-3-wireless-v2-hid.ts b/src/drivers/steelseries/rival-3-wireless-v2-hid.ts new file mode 100644 index 00000000..1463fa28 --- /dev/null +++ b/src/drivers/steelseries/rival-3-wireless-v2-hid.ts @@ -0,0 +1,251 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + RIVAL3_WIRELESS_V2_DEFAULT_DPI_PRESETS, + RIVAL3_WIRELESS_V2_DEFAULT_POLLING_RATE, + RIVAL3_WIRELESS_V2_MAX_DPI_PRESETS, + RIVAL3_WIRELESS_V2_POLLING_RATES, + RIVAL3_WIRELESS_V2_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesRival3WirelessV2DecodeBattery, + steelseriesRival3WirelessV2DecodeFirmware, + steelseriesRival3WirelessV2DpiOptions, + steelseriesRival3WirelessV2BatteryQuery, + steelseriesRival3WirelessV2EncodeButtonsMapping, + steelseriesRival3WirelessV2EncodeDpiPresets, + steelseriesRival3WirelessV2EncodePollingRate, + steelseriesRival3WirelessV2EncodeWheelMapping, + steelseriesRival3WirelessV2FirmwareQuery, + steelseriesRival3WirelessV2SaveCommand, + type Rival3WirelessV2Battery, + type Rival3WirelessV2ButtonAction, + type Rival3WirelessV2ButtonName, + type Rival3WirelessV2DpiPreset, + type Rival3WirelessV2Firmware, + type Rival3WirelessV2WheelAction, + type Rival3WirelessV2WheelName, +} from "@openmouse/protocol/steelseries"; + +/** Vendor profile default delay between a write and its save. */ +const COMMAND_DELAY_MS = 50; +/** Battery/firmware replies are small; allow slack over profile timeouts. */ +const RESPONSE_TIMEOUT_MS = 500; + +/** + * SteelSeries Rival 3 Wireless Gen 2 WebHID control (`1038:1872`, 2.4 GHz + * mode). + * + * DPI presets, polling rate, and buttons are write-only, but unlike Gen 1 + * this mouse IS partially readable: `battery_level` (`AA 01`) reports a + * two-byte charge level, and `firmware_version` (`10`) reports + * major/minor bytes. `readStatus` uses the battery query as its + * connectivity probe (the value most worth reading live for a wireless + * mouse) and also reads firmware; DPI and polling rate remain the + * session's last-written values (or documented defaults before any write), + * with `valuesVerified: false` reflecting that those two specific values + * are still unread. See `../../steelseries/rival-3-wireless-v2.ts` for the + * full protocol writeup — reconstructed from the vendor's GG device + * specification. Do not confuse this mouse with Gen 1 (`1038:1830`, a + * different command set). + * + * The config channel shape has not been captured yet, so the picker offers + * every interface and the wrong ones fail the battery probe loudly. + */ +export class SteelSeriesRival3WirelessV2HidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private listenerAttached = false; + private readonly inputWaiters = new Set<(payload: Uint8Array) => void>(); + private dpiStages: Rival3WirelessV2DpiPreset[] = RIVAL3_WIRELESS_V2_DEFAULT_DPI_PRESETS.map((dpi) => ({ x: dpi, y: dpi })); + private activeDpiStage = 0; + private pollingRateHz: number = RIVAL3_WIRELESS_V2_DEFAULT_POLLING_RATE; + + 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 === "rival3-wireless-v2"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...RIVAL3_WIRELESS_V2_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesRival3WirelessV2DpiOptions(); } + + 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 battery = await this.probeBattery(); + const firmware = await this.probeFirmware(); + const product = STEELSERIES_PRODUCTS.get(this.device.productId); + const name = this.device.productName?.trim() || `SteelSeries ${product?.model ?? "Rival 3 Wireless Gen 2"}`; + const active = this.dpiStages[this.activeDpiStage]; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-rival3-wireless-v2", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: "The Rival 3 Wireless Gen 2 cannot report its current DPI or polling rate; values shown are the last written by this app, or assumed defaults.", + defaultDisplayName: "SteelSeries Rival 3 Wireless Gen 2", + }, + batteryPercent: battery.level, + batteryState: "Unknown", + dpi: active?.x ?? RIVAL3_WIRELESS_V2_DEFAULT_DPI_PRESETS[0]!, + pollingRateHz: this.pollingRateHz, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + connectionType: "Wireless", + liftOffDistance: null, + firmware: [firmware.display], + }; + }); + } + + /** writes `dpi` into both axes of the active stage. */ + async setDpi(dpi: number): Promise { + await this.setDpiStageValue(this.activeDpiStage, { x: dpi, y: dpi }); + return dpi; + } + + async setDpiStageValue(stage: number, preset: Rival3WirelessV2DpiPreset): Promise { + this.requireStage(stage); + const stages = this.dpiStages.map((value, index) => (index === stage ? preset : value)); + await this.writePresets(stages, this.activeDpiStage); + return preset; + } + + async setActiveDpiStage(stage: number): Promise { + this.requireStage(stage); + await this.writePresets(this.dpiStages, stage); + return stage; + } + + async setDpiStages(stages: readonly Rival3WirelessV2DpiPreset[], activeStage = 0): Promise { + await this.writePresets(stages, activeStage); + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesRival3WirelessV2EncodePollingRate(pollingRateHz)); + this.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + async setButtonsMapping(mapping: Partial>): Promise { + await this.commit(steelseriesRival3WirelessV2EncodeButtonsMapping(mapping)); + } + + async setWheelMapping(mapping: Partial>): Promise { + await this.commit(steelseriesRival3WirelessV2EncodeWheelMapping(mapping)); + } + + /** + * `AA 01` — battery level. Doubles as the proof that the granted + * interface is the config channel. Unlike Gen 1's reply this packet + * carries no charging flag, so charging state stays unknown. + */ + private async probeBattery(): Promise { + const payload = await this.awaitResponse(steelseriesRival3WirelessV2BatteryQuery()); + if (!payload) { + throw new Error( + "The Rival 3 Wireless Gen 2 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 steelseriesRival3WirelessV2DecodeBattery(payload); + } + + /** `10` — major/minor firmware bytes, read the same way. */ + private async probeFirmware(): Promise { + const payload = await this.awaitResponse(steelseriesRival3WirelessV2FirmwareQuery()); + if (!payload) { + throw new Error("The Rival 3 Wireless Gen 2 did not answer the firmware query on this interface."); + } + return steelseriesRival3WirelessV2DecodeFirmware(payload); + } + + 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 requireStage(stage: number): void { + if (!Number.isInteger(stage) || stage < 0 || stage >= RIVAL3_WIRELESS_V2_MAX_DPI_PRESETS) { + throw new Error(`The Rival 3 Wireless Gen 2 holds ${RIVAL3_WIRELESS_V2_MAX_DPI_PRESETS} DPI stages.`); + } + } + + private async writePresets(stages: readonly Rival3WirelessV2DpiPreset[], activeStage: number): Promise { + this.requireStage(activeStage); + const report = steelseriesRival3WirelessV2EncodeDpiPresets(stages, activeStage); + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival3WirelessV2SaveCommand()); + this.dpiStages = [...stages]; + this.activeDpiStage = activeStage; + }); + } + + 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(steelseriesRival3WirelessV2SaveCommand()); + }); + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(RIVAL3_WIRELESS_V2_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..8480bdb3 100644 --- a/src/steelseries/devices.ts +++ b/src/steelseries/devices.ts @@ -9,10 +9,12 @@ * codec branches on product id. * * Only PIDs whose protocol has been traced to a public implementation belong - * here. The Rival 3 Wireless Gen 2 (`0x1872`) and Rival 3 Gen 2 (`0x1870`) - * are deliberately absent: their command sets are documented as different - * from every family below, and listing them would claim devices this codec - * would misprogram. The Rival 3 Wireless (`0x1830`) is present with family + * here. The Rival 3 Gen 2 (`0x1870`) is deliberately absent: its command + * set is documented as different from every family below, and listing it + * would claim a device this codec would misprogram. The Rival 3 Wireless + * Gen 2 (`0x1872`) is present with family `"rival3-wireless-v2"` — its own + * module, `./rival-3-wireless-v2.ts`, with an XY DPI command and no + * lighting. The Rival 3 Wireless (`0x1830`) is present with family * `"rival3-wireless"` — its own module, `./rival3-wireless.ts`, distinct from * both Rival 3 Gen 1 and Aerox 3 (different command bytes throughout, a * different DPI sensor table, and, unlike either wired family, a readable @@ -77,7 +79,8 @@ export type SteelSeriesProtocolFamily = | "aerox9-wireless" | "prime-plus" | "prime-mini-wireless" - | "sensei-ten"; + | "sensei-ten" + | "rival3-wireless-v2"; export interface SteelSeriesProduct { model: string; @@ -175,6 +178,18 @@ export const STEELSERIES_PRODUCTS: ReadonlyMap = new hasFirmwareQuery: true, verified: false, }], + // rival-3-wireless-v2 (vendor GG specification): 2.4 GHz mode. Own command + // set (XY DPI 0x2C, polling 0x17, buttons 0x19, wheel 0x1A, battery + // 0xAA 0x01, firmware 0x10) — see ./rival-3-wireless-v2.ts. No lighting + // commands exist on this mouse. + [0x1872, { + model: "Rival 3 Wireless Gen 2", + family: "rival3-wireless-v2", + wireless: true, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], // aerox5.py: plain wired Aerox 5. No getter, no firmware-query command. [0x1850, { model: "Aerox 5", diff --git a/src/steelseries/index.ts b/src/steelseries/index.ts index dacc7846..3ccbbcb1 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -10,4 +10,5 @@ export * from "./aerox9-wireless.js"; export * from "./prime-plus.js"; export * from "./prime-mini-wireless.js"; export * from "./sensei-ten.js"; +export * from "./rival-3-wireless-v2.js"; export * from "./devices.js"; diff --git a/src/steelseries/rival-3-wireless-v2.test.ts b/src/steelseries/rival-3-wireless-v2.test.ts new file mode 100644 index 00000000..c2d2b423 --- /dev/null +++ b/src/steelseries/rival-3-wireless-v2.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RIVAL3_WIRELESS_V2_BATTERY_RESPONSE_LENGTH, + RIVAL3_WIRELESS_V2_DEFAULT_DPI_PRESETS, + RIVAL3_WIRELESS_V2_DEFAULT_POLLING_RATE, + RIVAL3_WIRELESS_V2_DPI_MAX, + RIVAL3_WIRELESS_V2_DPI_MIN, + RIVAL3_WIRELESS_V2_MAX_DPI_PRESETS, + RIVAL3_WIRELESS_V2_POLLING_RATES, + Rival3WirelessV2ProtocolError, + steelseriesRival3WirelessV2BatteryQuery, + steelseriesRival3WirelessV2DecodeBattery, + steelseriesRival3WirelessV2DecodeFirmware, + steelseriesRival3WirelessV2DpiOptions, + steelseriesRival3WirelessV2EncodeButtonsMapping, + steelseriesRival3WirelessV2EncodeDpiPresets, + steelseriesRival3WirelessV2EncodePollingRate, + steelseriesRival3WirelessV2EncodeWheelMapping, + steelseriesRival3WirelessV2FirmwareQuery, + steelseriesRival3WirelessV2SaveCommand, +} from "./rival-3-wireless-v2.ts"; +import { TRUEMOVE_AIR_DPI_TO_BYTE } from "./rival3-wireless.js"; + +test("DPI options mirror the shared TrueMove Air table", () => { + const options = steelseriesRival3WirelessV2DpiOptions(); + assert.equal(options[0], RIVAL3_WIRELESS_V2_DPI_MIN); + assert.equal(options.at(-1), RIVAL3_WIRELESS_V2_DPI_MAX); + assert.equal(TRUEMOVE_AIR_DPI_TO_BYTE.get(400), 0x04); + assert.equal(TRUEMOVE_AIR_DPI_TO_BYTE.get(3200), 0x26); + assert.deepEqual([...RIVAL3_WIRELESS_V2_DEFAULT_DPI_PRESETS], [400, 800, 1600, 2400, 3200]); + assert.equal(RIVAL3_WIRELESS_V2_DEFAULT_POLLING_RATE, 1000); + assert.equal(RIVAL3_WIRELESS_V2_MAX_DPI_PRESETS, 5); +}); + +test("DPI presets write split X/Y arrays with a 1-based index", () => { + assert.deepEqual( + [...steelseriesRival3WirelessV2EncodeDpiPresets( + [ + { x: 400, y: 400 }, + { x: 800, y: 800 }, + { x: 1600, y: 1600 }, + { x: 2400, y: 2400 }, + { x: 3200, y: 3200 }, + ], + 0, + )], + [0x2c, 0x05, 0x01, 0x04, 0x09, 0x12, 0x1b, 0x26, 0x04, 0x09, 0x12, 0x1b, 0x26], + ); + assert.deepEqual( + [...steelseriesRival3WirelessV2EncodeDpiPresets( + [ + { x: 800, y: 1600 }, + { x: 800, y: 1600 }, + { x: 800, y: 1600 }, + { x: 800, y: 1600 }, + { x: 800, y: 1600 }, + ], + 4, + ).slice(0, 4)], + [0x2c, 0x05, 0x05, 0x09], + ); +}); + +test("DPI presets require exactly five on-grid pairs and a valid index", () => { + const pair = { x: 800, y: 800 }; + assert.throws(() => steelseriesRival3WirelessV2EncodeDpiPresets([pair, pair], 0), Rival3WirelessV2ProtocolError); + assert.throws( + () => steelseriesRival3WirelessV2EncodeDpiPresets([pair, pair, pair, pair, pair], 5), + Rival3WirelessV2ProtocolError, + ); + assert.throws( + () => steelseriesRival3WirelessV2EncodeDpiPresets([pair, pair, pair, pair, { x: 150, y: 800 }], 0), + Rival3WirelessV2ProtocolError, + ); +}); + +test("polling rates use 1000->0x00 down to 125->0x03", () => { + assert.deepEqual([...steelseriesRival3WirelessV2EncodePollingRate(1000)], [0x17, 0x00]); + assert.deepEqual([...steelseriesRival3WirelessV2EncodePollingRate(125)], [0x17, 0x03]); + assert.deepEqual([...RIVAL3_WIRELESS_V2_POLLING_RATES], [125, 250, 500, 1000]); + assert.throws(() => steelseriesRival3WirelessV2EncodePollingRate(2000), Rival3WirelessV2ProtocolError); + assert.equal(RIVAL3_WIRELESS_V2_BATTERY_RESPONSE_LENGTH, 2); +}); + +test("save, battery, and firmware commands", () => { + assert.deepEqual([...steelseriesRival3WirelessV2SaveCommand()], [0x09]); + assert.deepEqual([...steelseriesRival3WirelessV2BatteryQuery()], [0xaa, 0x01]); + assert.deepEqual(steelseriesRival3WirelessV2DecodeBattery(new Uint8Array([0x64, 0x00])), { level: 100 }); + assert.throws(() => steelseriesRival3WirelessV2DecodeBattery(new Uint8Array([0x64])), Rival3WirelessV2ProtocolError); + assert.deepEqual([...steelseriesRival3WirelessV2FirmwareQuery()], [0x10]); + assert.deepEqual(steelseriesRival3WirelessV2DecodeFirmware(new Uint8Array([0x01, 0x05])), { + bytes: [0x01, 0x05], + display: "1.5", + }); + assert.throws(() => steelseriesRival3WirelessV2DecodeFirmware(new Uint8Array([0x01])), Rival3WirelessV2ProtocolError); +}); + +test("button mapping covers seven slots", () => { + const report = steelseriesRival3WirelessV2EncodeButtonsMapping({ + button1: { type: "button", target: "button1" }, + button6: { type: "dpiSwitch" }, + button7: { type: "scrollUp" }, + }); + assert.equal(report[0], 0x19); + assert.equal(report.length, 1 + 7 * 5); + assert.equal(report[1], 0x01); + assert.equal(report[1 + 5 * 5], 0x30); + assert.equal(report[1 + 6 * 5], 0x31); +}); + +test("wheel mapping covers two slots under its own command", () => { + const report = steelseriesRival3WirelessV2EncodeWheelMapping({ + scrollUp: { type: "button", target: "scrollUp" }, + scrollDown: { type: "disabled" }, + }); + assert.equal(report[0], 0x1a); + assert.equal(report.length, 1 + 2 * 5); + assert.equal(report[1], 0x31); + assert.equal(report[6], 0x00); +}); + +test("button mapping rejects unknown buttons and bad codes", () => { + assert.throws( + () => steelseriesRival3WirelessV2EncodeButtonsMapping({ ["button8" as never]: { type: "disabled" } }), + Rival3WirelessV2ProtocolError, + ); + assert.throws( + () => steelseriesRival3WirelessV2EncodeWheelMapping({ scrollUp: { type: "multimedia", code: 999 } }), + Rival3WirelessV2ProtocolError, + ); +}); diff --git a/src/steelseries/rival-3-wireless-v2.ts b/src/steelseries/rival-3-wireless-v2.ts new file mode 100644 index 00000000..e5ecfd3d --- /dev/null +++ b/src/steelseries/rival-3-wireless-v2.ts @@ -0,0 +1,360 @@ +/** + * SteelSeries Rival 3 Wireless Gen 2 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-3-wireless-v2.lisp` under + * `apps/engine/install/deviceSpecifications/`, and this module transcribes + * it. Those files are OpenPGP-armored ("DESCRIPTOR" armor, SKESK/AES-128); + * the bytes below are the documented HID report payloads, not guesses. + * rivalcfg lists a `rival3_wireless_gen2` profile (PID `1038:1872`) but its + * command set was never readable in this environment, so nothing here is + * corroborated beyond the vendor file — treat every byte as + * vendor-documented but not yet hardware-verified. + * + * Despite the shared "Rival 3 Wireless" name this is a different command + * set from Rival 3 Wireless Gen 1 (`./rival3-wireless.ts`: single-byte DPI + * `0x20`, 6 button slots, single lighting zone, `0xAA` 3-byte battery + * shape). The Gen 2 uses XY DPI (`0x2C`), 7 button slots plus a separate + * 2-slot wheel mapping, no lighting at all, and a 2-byte battery reply. + * + * Every command is an HID **output report** with report id 0x00 (65-byte + * reports throughout the specification). The specification frames commands + * through `write-op`/`read-op` mask helpers; on this 2.4 GHz-mode product + * id both masks are `0x00`, so the bytes go on the wire as written. + * + * DPI uses an XY batch (`0x2C`): `max_level` (0x01–0x05), `index` + * (0x01–0x05, **1-based** — unlike every other SteelSeries XY family in + * this package, which are 0-based), then five horizontal bytes followed by + * five vertical bytes (split arrays, not interleaved). The sensor table is + * TrueMove Air (100–18000), imported from `./rival3-wireless.ts`'s + * `TRUEMOVE_AIR_DPI_TO_BYTE` — the vendor file includes the same table + * (spot-checked `0x04→400`, `0x12→1600`, `0xD6→18000`). The specification + * always carries all five pairs per axis, so this codec requires exactly + * five presets rather than guessing at padding for shorter tables. + * + * Polling (`0x17`, values 1000→0x00, 500→0x01, 250→0x02, 125→0x03 — the + * same mapping as Gen 1's identical command byte, confirmed by direct + * comparison, not assumed) has a read-back, defined but not decoded here. + * Buttons are one 35-byte packet (7 slots × 5-byte fields: function byte + * plus 4 key-code bytes), command `0x19`, plus a separate 2-slot wheel + * mapping, command `0x1A`. The 7th button slot has no confirmed physical + * identity (the GG seed data exposes 6 buttons); it is encodable as + * `button7` and zero-filled when unmapped, both flagged here. + * + * Known but deliberately not implemented — documented so nobody re-derives + * them, withheld until there is hardware evidence and a reason to ship them: + * + * - `read_polling_rate` — polling read-back. + * - `power_mode` (`0x2B`, sleep timer only) and `wireless_power_mode` + * (`0x2E`, no-redundancy / boosted-bluetooth flags). + * - Accelerometer/deceleration and angle snapping: driver-side (`SSE`) + * settings, not HID commands, out of scope for this codec. + * + * This mouse has no lighting commands at all. Settings apply immediately; + * the save command (`0x09`, one byte) persists them to onboard flash. None + * of this has been verified on physical hardware by this project. + */ + +import { TRUEMOVE_AIR_DPI_TO_BYTE } from "./rival3-wireless.js"; + +export const RIVAL3_WIRELESS_V2_REPORT_ID = 0x00; + +/** Command prefixes; the payload is the prefix plus its arguments. */ +export const RIVAL3_WIRELESS_V2_COMMAND = { + dpiPresets: [0x2c], + pollingRate: [0x17], + buttonsMapping: [0x19], + wheelMapping: [0x1a], + batteryLevel: [0xaa, 0x01], + firmware: [0x10], +} as const; + +/** `0x09` — one byte; commit the current settings to onboard flash. */ +export const RIVAL3_WIRELESS_V2_SAVE_COMMAND = [0x09] as const; + +export const RIVAL3_WIRELESS_V2_POLLING_RATES = [125, 250, 500, 1000] as const; + +const POLLING_RATE_TO_BYTE: ReadonlyMap = new Map([ + [1000, 0x00], + [500, 0x01], + [250, 0x02], + [125, 0x03], +]); + +export const RIVAL3_WIRELESS_V2_DPI_MIN = 100; +export const RIVAL3_WIRELESS_V2_DPI_MAX = 18000; +export const RIVAL3_WIRELESS_V2_DPI_STEP = 100; +export const RIVAL3_WIRELESS_V2_MAX_DPI_PRESETS = 5; +export const RIVAL3_WIRELESS_V2_BATTERY_RESPONSE_LENGTH = 2; + +/** Factory table shared across the modern SteelSeries families. */ +export const RIVAL3_WIRELESS_V2_DEFAULT_DPI_PRESETS = [400, 800, 1600, 2400, 3200] as const; +export const RIVAL3_WIRELESS_V2_DEFAULT_POLLING_RATE = 1000; + +export class Rival3WirelessV2ProtocolError extends Error {} + +/** One DPI preset: independent X/Y sensitivities. */ +export interface Rival3WirelessV2DpiPreset { + x: number; + y: number; +} + +/** Every DPI value the shared TrueMove Air table can express, ascending. */ +export function steelseriesRival3WirelessV2DpiOptions(): number[] { + return [...TRUEMOVE_AIR_DPI_TO_BYTE.keys()].sort((a, b) => a - b); +} + +function dpiToByte(dpi: number): number { + const byte = TRUEMOVE_AIR_DPI_TO_BYTE.get(dpi); + if (byte === undefined) { + throw new Rival3WirelessV2ProtocolError( + `SteelSeries Rival 3 Wireless Gen 2 DPI must be ${RIVAL3_WIRELESS_V2_DPI_MIN}–${RIVAL3_WIRELESS_V2_DPI_MAX.toLocaleString("en-US")} in ${RIVAL3_WIRELESS_V2_DPI_STEP} DPI steps.`, + ); + } + return byte; +} + +/** + * `2C ` — replaces the + * mouse's whole preset table with five horizontal bytes followed by five + * vertical bytes. Exactly five presets are required (see the module doc + * comment); `selectedIndex` is 0-based here and encoded 1-based on the + * wire — the reverse of the Gen 1 convention, so do not reuse that + * encoder. + */ +export function steelseriesRival3WirelessV2EncodeDpiPresets( + presets: readonly Rival3WirelessV2DpiPreset[], + selectedIndex: number, +): Uint8Array { + if (presets.length !== RIVAL3_WIRELESS_V2_MAX_DPI_PRESETS) { + throw new Rival3WirelessV2ProtocolError( + `SteelSeries Rival 3 Wireless Gen 2 requires exactly ${RIVAL3_WIRELESS_V2_MAX_DPI_PRESETS} DPI presets.`, + ); + } + if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= presets.length) { + throw new Rival3WirelessV2ProtocolError( + `Selected DPI preset must be an index into the ${presets.length} presets being written.`, + ); + } + return new Uint8Array([ + ...RIVAL3_WIRELESS_V2_COMMAND.dpiPresets, + presets.length, + selectedIndex + 1, + ...presets.map((preset) => dpiToByte(preset.x)), + ...presets.map((preset) => dpiToByte(preset.y)), + ]); +} + +/** `17 ` with 1000→0x00, 500→0x01, 250→0x02, 125→0x03. */ +export function steelseriesRival3WirelessV2EncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new Rival3WirelessV2ProtocolError("SteelSeries Rival 3 Wireless Gen 2 supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...RIVAL3_WIRELESS_V2_COMMAND.pollingRate, byte]); +} + +/** `09` — commit the current settings to onboard flash. */ +export function steelseriesRival3WirelessV2SaveCommand(): Uint8Array { + return new Uint8Array(RIVAL3_WIRELESS_V2_SAVE_COMMAND); +} + +/** `AA 01` — answered by a two-byte input report. */ +export function steelseriesRival3WirelessV2BatteryQuery(): Uint8Array { + return new Uint8Array(RIVAL3_WIRELESS_V2_COMMAND.batteryLevel); +} + +export interface Rival3WirelessV2Battery { + /** Battery level as reported (0–100 scale per the specification's GG mapping). */ + level: number; +} + +/** + * Decode the two-byte battery reply as a little-endian level. Unlike Gen + * 1's 3-byte reply this packet carries no charging flag, so charging state + * stays unknown — do not infer it. + */ +export function steelseriesRival3WirelessV2DecodeBattery(payload: Uint8Array): Rival3WirelessV2Battery { + if (payload.length < RIVAL3_WIRELESS_V2_BATTERY_RESPONSE_LENGTH) { + throw new Rival3WirelessV2ProtocolError( + "SteelSeries Rival 3 Wireless Gen 2 battery response is shorter than two bytes.", + ); + } + return { level: payload[0]! | (payload[1]! << 8) }; +} + +/** `10` — the mouse answers with major/minor firmware bytes. */ +export function steelseriesRival3WirelessV2FirmwareQuery(): Uint8Array { + return new Uint8Array(RIVAL3_WIRELESS_V2_COMMAND.firmware); +} + +export interface Rival3WirelessV2Firmware { + /** The two raw response bytes, in the order the device sent them. */ + bytes: [number, number]; + /** The bytes joined in read order, e.g. "1.5". */ + display: string; +} + +/** Decode the two-byte firmware reply, read-order convention as usual. */ +export function steelseriesRival3WirelessV2DecodeFirmware(payload: Uint8Array): Rival3WirelessV2Firmware { + if (payload.length < 2) { + throw new Rival3WirelessV2ProtocolError("SteelSeries Rival 3 Wireless Gen 2 firmware response is shorter than two bytes."); + } + const bytes: [number, number] = [payload[0]!, payload[1]!]; + return { bytes, display: `${bytes[0]}.${bytes[1]}` }; +} + +// -- Button mapping -------------------------------------------------------- + +/** + * 7 button slots. The 7th slot's physical identity is unconfirmed (the GG + * seed exposes 6 buttons); it is encodable and zero-filled when unmapped — + * see the module doc comment. + */ +export const RIVAL3_WIRELESS_V2_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 }, + button7: { id: 0x07, offset: 0x1e }, +} as const satisfies Record; + +export type Rival3WirelessV2ButtonName = keyof typeof RIVAL3_WIRELESS_V2_BUTTONS; + +/** The 2 scroll-wheel slots use the same field shape under command `0x1A`. */ +export const RIVAL3_WIRELESS_V2_WHEEL_BUTTONS = { + scrollUp: { id: 0x31, offset: 0x00 }, + scrollDown: { id: 0x32, offset: 0x05 }, +} as const satisfies Record; + +export type Rival3WirelessV2WheelName = keyof typeof RIVAL3_WIRELESS_V2_WHEEL_BUTTONS; + +const BUTTON_FIELD_LENGTH = 5; +const BUTTON_DISABLE = 0x00; +const BUTTON_DPI_SWITCH = 0x30; +const BUTTON_KEYBOARD = 0x51; +const BUTTON_MULTIMEDIA = 0x61; + +export type Rival3WirelessV2ButtonAction = + | { type: "button"; target: Rival3WirelessV2ButtonName } + | { type: "disabled" } + | { type: "dpiSwitch" } + | { type: "scrollUp" } + | { type: "scrollDown" } + | { type: "keyboard"; code: number } + | { type: "multimedia"; code: number }; + +export type Rival3WirelessV2WheelAction = + | { type: "button"; target: Rival3WirelessV2ButtonName | Rival3WirelessV2WheelName } + | { 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 Rival3WirelessV2ProtocolError(`${kind} codes must be integers 0–255.`); + } + return code; +} + +function encodeAction( + packet: number[], + offset: number, + action: Rival3WirelessV2ButtonAction | Rival3WirelessV2WheelAction, +): void { + switch (action.type) { + case "button": { + const target = + (RIVAL3_WIRELESS_V2_BUTTONS as Record)[action.target] ?? + (RIVAL3_WIRELESS_V2_WHEEL_BUTTONS as Record)[action.target]; + if (!target) { + throw new Rival3WirelessV2ProtocolError(`Unknown SteelSeries Rival 3 Wireless Gen 2 button "${action.target}".`); + } + packet[offset] = target.id; + break; + } + case "disabled": { + packet[offset] = BUTTON_DISABLE; + break; + } + case "dpiSwitch": { + packet[offset] = BUTTON_DPI_SWITCH; + break; + } + case "scrollUp": { + packet[offset] = RIVAL3_WIRELESS_V2_WHEEL_BUTTONS.scrollUp.id; + break; + } + case "scrollDown": { + packet[offset] = RIVAL3_WIRELESS_V2_WHEEL_BUTTONS.scrollDown.id; + break; + } + case "keyboard": { + packet[offset] = BUTTON_KEYBOARD; + packet[offset + 1] = encodeKeyCode(action.code, "Keyboard scan"); + break; + } + case "multimedia": { + packet[offset] = BUTTON_MULTIMEDIA; + packet[offset + 1] = encodeKeyCode(action.code, "Multimedia key"); + break; + } + default: { + throw new Rival3WirelessV2ProtocolError("Unsupported SteelSeries Rival 3 Wireless Gen 2 button action."); + } + } +} + +/** + * `19` + one 35-byte packet (7 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 steelseriesRival3WirelessV2EncodeButtonsMapping( + mapping: Partial>, +): Uint8Array { + for (const name of Object.keys(mapping)) { + if (!Object.hasOwn(RIVAL3_WIRELESS_V2_BUTTONS, name)) { + throw new Rival3WirelessV2ProtocolError(`Unknown SteelSeries Rival 3 Wireless Gen 2 button "${name}".`); + } + } + + const names = Object.keys(RIVAL3_WIRELESS_V2_BUTTONS) as Rival3WirelessV2ButtonName[]; + const packet = new Array(names.length * BUTTON_FIELD_LENGTH).fill(0x00); + + for (const [name, action] of Object.entries(mapping) as Array<[Rival3WirelessV2ButtonName, Rival3WirelessV2ButtonAction]>) { + encodeAction(packet, RIVAL3_WIRELESS_V2_BUTTONS[name].offset, action); + } + + return new Uint8Array([...RIVAL3_WIRELESS_V2_COMMAND.buttonsMapping, ...packet]); +} + +/** + * `1A` + one 10-byte packet (2 wheel slots × 5-byte fields). The wheel + * mapping is always written to flash alongside buttons on the real device. + */ +export function steelseriesRival3WirelessV2EncodeWheelMapping( + mapping: Partial>, +): Uint8Array { + for (const name of Object.keys(mapping)) { + if (!Object.hasOwn(RIVAL3_WIRELESS_V2_WHEEL_BUTTONS, name)) { + throw new Rival3WirelessV2ProtocolError(`Unknown SteelSeries Rival 3 Wireless Gen 2 wheel "${name}".`); + } + } + + const names = Object.keys(RIVAL3_WIRELESS_V2_WHEEL_BUTTONS) as Rival3WirelessV2WheelName[]; + const packet = new Array(names.length * BUTTON_FIELD_LENGTH).fill(0x00); + + for (const [name, action] of Object.entries(mapping) as Array<[Rival3WirelessV2WheelName, Rival3WirelessV2WheelAction]>) { + encodeAction(packet, RIVAL3_WIRELESS_V2_WHEEL_BUTTONS[name].offset, action); + } + + return new Uint8Array([...RIVAL3_WIRELESS_V2_COMMAND.wheelMapping, ...packet]); +} From 4b0075a2c928e83b910f82464b04c66ceb94a729 Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 18:10:49 -0600 Subject: [PATCH 08/15] feat: add SteelSeries Aerox 3 Wireless Gen 2 protocol family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aerox 3 Wireless Gen 2 (1038:1892 wired, 1038:1890 dongle), reconstructed from the vendor's own GG 121.0.0 device specification — no rivalcfg profile exists. Raw-uint16 XY DPI with per-preset lift-off (0x2D), split wired/wireless polling to 4000 Hz (0x2B, inferred value map flagged in-code), 8-slot buttons (0x2A), battery (0x92, direct percent), firmware 0x90 (12-byte string, used with battery as connectivity probes), save 0x11 0x00. Unverified on hardware. --- src/drivers/registry.ts | 4 +- .../aerox-3-wireless-gen2-hid.test.ts | 119 ++++++ .../steelseries/aerox-3-wireless-gen2-hid.ts | 252 ++++++++++++ src/steelseries/aerox-3-wireless-gen2.test.ts | 135 ++++++ src/steelseries/aerox-3-wireless-gen2.ts | 386 ++++++++++++++++++ src/steelseries/devices.ts | 21 + src/steelseries/index.ts | 1 + 7 files changed, 917 insertions(+), 1 deletion(-) create mode 100644 src/drivers/steelseries/aerox-3-wireless-gen2-hid.test.ts create mode 100644 src/drivers/steelseries/aerox-3-wireless-gen2-hid.ts create mode 100644 src/steelseries/aerox-3-wireless-gen2.test.ts create mode 100644 src/steelseries/aerox-3-wireless-gen2.ts diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..942f63f8 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -58,6 +58,7 @@ import { SteelSeriesRival310HidClient } from "./steelseries/rival310-hid.ts"; import { SteelSeriesPrimePlusHidClient } from "./steelseries/prime-plus-hid.ts"; import { SteelSeriesPrimeMiniWirelessHidClient } from "./steelseries/prime-mini-wireless-hid.ts"; import { SteelSeriesSenseiTenHidClient } from "./steelseries/sensei-ten-hid.ts"; +import { SteelSeriesAerox3WirelessGen2HidClient } from "./steelseries/aerox-3-wireless-gen2-hid.ts"; import { GloriousHidClient } from "./glorious/hid.ts"; import { GloriousClassicHidClient } from "./glorious/classic-hid.ts"; import { GloriousCore2HidClient } from "./glorious/core2-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 | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesAerox3WirelessGen2HidClient | 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; @@ -176,6 +177,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesPrimePlusHidClient.isSupported(device), create: (device) => new SteelSeriesPrimePlusHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesPrimeMiniWirelessHidClient.isSupported(device), create: (device) => new SteelSeriesPrimeMiniWirelessHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiTenHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiTenHidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesAerox3WirelessGen2HidClient.isSupported(device), create: (device) => new SteelSeriesAerox3WirelessGen2HidClient(device), score: () => 6 }, { brand: "Glorious", supports: (device) => GloriousHidClient.isSupported(device), create: (device) => new GloriousHidClient(device), score: () => 5 }, { brand: "Glorious", supports: (device) => GloriousClassicHidClient.isSupported(device), create: (device) => new GloriousClassicHidClient(device), score: () => 5 }, // Same vendor id as the classic family; disjoint by product id. diff --git a/src/drivers/steelseries/aerox-3-wireless-gen2-hid.test.ts b/src/drivers/steelseries/aerox-3-wireless-gen2-hid.test.ts new file mode 100644 index 00000000..1a67b049 --- /dev/null +++ b/src/drivers/steelseries/aerox-3-wireless-gen2-hid.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesAerox3WirelessGen2HidClient } from "./aerox-3-wireless-gen2-hid.ts"; + +function fakeDevice(options: { productId?: number; battery?: number[]; firmware?: number[]; answer?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x1892, + productName: "SteelSeries Aerox 3 Wireless Gen 2", + 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] === 0x92 || payload[0] === 0xd2) && payload.length === 1) { + const response = new Uint8Array(options.battery ?? [0x01, 0x64]); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + } + if ((payload[0] === 0x90 || payload[0] === 0xd0) && payload[1] === 0x00) { + const response = new Uint8Array(options.firmware ?? [0x31, 0x2e, 0x31, 0x2e, 0x35, ...new Array(7).fill(0x00)]); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + } + }, + sendFeatureReport: async () => { throw new Error("the Aerox 3 Wireless Gen 2 protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Aerox 3 Wireless Gen 2 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 wired and dongle product ids, and selects transport by PID", () => { + const { device } = fakeDevice(); + assert.equal(SteelSeriesAerox3WirelessGen2HidClient.isSupported(device), true); + assert.equal( + SteelSeriesAerox3WirelessGen2HidClient.isSupported(fakeDevice({ productId: 0x1890 }).device), + true, + ); + for (const productId of [0x183a, 0x1838, 0x1854, 0x1852, 0x1830, 0x1836]) { + assert.equal( + SteelSeriesAerox3WirelessGen2HidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesAerox3WirelessGen2HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus probes battery and firmware without claiming charging state", async () => { + const { device, sent } = fakeDevice(); + const status = await new SteelSeriesAerox3WirelessGen2HidClient(device).readStatus(); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x92] }, + { reportId: 0, payload: [0x90, 0x00] }, + ]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Aerox 3 Wireless Gen 2"); + assert.deepEqual(status.firmware, ["1.1.5"]); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.batteryPercent, 100); + assert.equal(status.batteryState, "Unknown"); + assert.equal(status.dpi, 400); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.ui?.valuesVerified, false); + assert.ok(status.ui?.pollingNote); + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000, 2000, 4000]); +}); + +test("a silent interface fails the battery probe loudly and names SteelSeries GG", async () => { + const { device } = fakeDevice({ answer: false }); + const client = new SteelSeriesAerox3WirelessGen2HidClient(device); + await assert.rejects(() => client.readStatus(), /Close SteelSeries GG/); +}); + +test("dongle mode ORs the wireless flag into every command", async () => { + const { device, sent } = fakeDevice({ productId: 0x1890 }); + const client = new SteelSeriesAerox3WirelessGen2HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setPollingRate(1000); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x6b, 0x02, 0x02] }, + { reportId: 0, payload: [0x51, 0x00] }, + ]); +}); + +test("setDpi edits the active stage plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesAerox3WirelessGen2HidClient(device); + await client.readStatus(); + sent.length = 0; + const dpi = await client.setDpi(1600); + assert.equal(dpi, 1600); + assert.deepEqual(sent, [ + { + reportId: 0, + payload: [ + 0x2d, 0x05, 0x00, + 0x40, 0x06, 0x40, 0x06, 0x00, + 0x20, 0x03, 0x20, 0x03, 0x00, + 0x40, 0x06, 0x40, 0x06, 0x00, + 0x60, 0x09, 0x60, 0x09, 0x00, + 0x80, 0x0c, 0x80, 0x0c, 0x00, + ], + }, + { reportId: 0, payload: [0x11, 0x00] }, + ]); +}); diff --git a/src/drivers/steelseries/aerox-3-wireless-gen2-hid.ts b/src/drivers/steelseries/aerox-3-wireless-gen2-hid.ts new file mode 100644 index 00000000..a2c27198 --- /dev/null +++ b/src/drivers/steelseries/aerox-3-wireless-gen2-hid.ts @@ -0,0 +1,252 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + AEROX3_WIRELESS_GEN2_DEFAULT_DPI_PRESETS, + AEROX3_WIRELESS_GEN2_DEFAULT_POLLING_RATE, + AEROX3_WIRELESS_GEN2_MAX_DPI_PRESETS, + AEROX3_WIRELESS_GEN2_POLLING_RATES, + AEROX3_WIRELESS_GEN2_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesAerox3WirelessGen2BatteryQuery, + steelseriesAerox3WirelessGen2DecodeBattery, + steelseriesAerox3WirelessGen2DecodeFirmware, + steelseriesAerox3WirelessGen2DpiOptions, + steelseriesAerox3WirelessGen2EncodeButtonsMapping, + steelseriesAerox3WirelessGen2EncodeDpiPresets, + steelseriesAerox3WirelessGen2EncodePollingRateSingle, + steelseriesAerox3WirelessGen2FirmwareQuery, + steelseriesAerox3WirelessGen2SaveCommand, + type Aerox3WirelessGen2Battery, + type Aerox3WirelessGen2ButtonAction, + type Aerox3WirelessGen2ButtonName, + type Aerox3WirelessGen2DpiPreset, +} from "@openmouse/protocol/steelseries"; + +/** Vendor profile default delay between a write and its save. */ +const COMMAND_DELAY_MS = 50; +/** Battery/firmware replies are small; allow slack over profile timeouts. */ +const RESPONSE_TIMEOUT_MS = 500; +/** Lift-off distance written with DPI presets; the specification states no default. */ +const DEFAULT_LIFT_OFF_DISTANCE = 0; + +/** + * SteelSeries Aerox 3 Wireless Gen 2 WebHID control (wired mode `1038:1892`, + * 2.4 GHz dongle mode `1038:1890`). + * + * DPI presets, polling rate, and buttons are write-only, but unlike Gen 1 + * this mouse IS partially readable: `battery_level` (`92`) reports charge + * level, and `firmware_version` (`90 00`) reports the version string, both + * via input-report replies. `readStatus` uses the battery query as its + * connectivity probe (the value most worth reading live for a wireless + * mouse) and also reads firmware; DPI and polling rate remain the + * session's last-written values (or documented defaults before any write), + * with `valuesVerified: false` reflecting that those two specific values + * are still unread. See `../../steelseries/aerox-3-wireless-gen2.ts` for + * the full protocol writeup — reconstructed from the vendor's GG device + * specification. Do not confuse this mouse with Gen 1 (`1038:183A`/ + * `1038:1838`, a different command set). + * + * The config channel is the usage page `0xFFC0` collection; its WebHID + * shape has not been captured, so the picker offers every interface and + * the wrong ones fail the battery probe loudly. + */ +export class SteelSeriesAerox3WirelessGen2HidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private listenerAttached = false; + private readonly inputWaiters = new Set<(payload: Uint8Array) => void>(); + private readonly wireless: boolean; + private dpiStages: Aerox3WirelessGen2DpiPreset[] = AEROX3_WIRELESS_GEN2_DEFAULT_DPI_PRESETS.map((dpi) => ({ + x: dpi, + y: dpi, + liftOffDistance: DEFAULT_LIFT_OFF_DISTANCE, + })); + private activeDpiStage = 0; + private pollingRateHz: number = AEROX3_WIRELESS_GEN2_DEFAULT_POLLING_RATE; + + 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; + this.wireless = device.productId !== 0x1892; + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== STEELSERIES_VENDOR_ID) return false; + return STEELSERIES_PRODUCTS.get(device.productId)?.family === "aerox3-wireless-gen2"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...AEROX3_WIRELESS_GEN2_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesAerox3WirelessGen2DpiOptions(); } + + 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 battery = await this.probeBattery(); + const firmware = await this.probeFirmware(); + const product = STEELSERIES_PRODUCTS.get(this.device.productId); + const name = this.device.productName?.trim() || `SteelSeries ${product?.model ?? "Aerox 3 Wireless Gen 2"}`; + const active = this.dpiStages[this.activeDpiStage]; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-aerox3-wireless-gen2", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: "The Aerox 3 Wireless Gen 2 cannot report its current DPI or polling rate; values shown are the last written by this app, or assumed defaults.", + defaultDisplayName: "SteelSeries Aerox 3 Wireless Gen 2", + }, + batteryPercent: battery.level, + batteryState: "Unknown", + dpi: active?.x ?? AEROX3_WIRELESS_GEN2_DEFAULT_DPI_PRESETS[0]!, + pollingRateHz: this.pollingRateHz, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + connectionType: "Wireless", + liftOffDistance: null, + firmware: [firmware], + }; + }); + } + + /** writes `dpi` into both axes of the active stage. */ + async setDpi(dpi: number): Promise { + await this.setDpiStageValue(this.activeDpiStage, { x: dpi, y: dpi, liftOffDistance: DEFAULT_LIFT_OFF_DISTANCE }); + return dpi; + } + + async setDpiStageValue(stage: number, preset: Aerox3WirelessGen2DpiPreset): Promise { + this.requireStage(stage); + const stages = this.dpiStages.map((value, index) => (index === stage ? preset : value)); + await this.writePresets(stages, this.activeDpiStage); + return preset; + } + + async setActiveDpiStage(stage: number): Promise { + this.requireStage(stage); + await this.writePresets(this.dpiStages, stage); + return stage; + } + + async setDpiStages(stages: readonly Aerox3WirelessGen2DpiPreset[], activeStage = 0): Promise { + await this.writePresets(stages, activeStage); + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesAerox3WirelessGen2EncodePollingRateSingle(pollingRateHz, this.wireless)); + this.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + async setButtonsMapping(mapping: Partial>): Promise { + await this.commit(steelseriesAerox3WirelessGen2EncodeButtonsMapping(mapping, this.wireless)); + } + + /** + * `92` — battery level. Doubles as the proof that the granted interface + * is the config channel. The status byte's encoding is undocumented, so + * charging state stays unknown. + */ + private async probeBattery(): Promise { + const payload = await this.awaitResponse(steelseriesAerox3WirelessGen2BatteryQuery(this.wireless)); + if (!payload) { + throw new Error( + "The Aerox 3 Wireless Gen 2 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 steelseriesAerox3WirelessGen2DecodeBattery(payload); + } + + /** `90 00` — firmware version string, read the same way. */ + private async probeFirmware(): Promise { + const payload = await this.awaitResponse(steelseriesAerox3WirelessGen2FirmwareQuery(this.wireless)); + if (!payload) { + throw new Error("The Aerox 3 Wireless Gen 2 did not answer the firmware query on this interface."); + } + return steelseriesAerox3WirelessGen2DecodeFirmware(payload); + } + + 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 requireStage(stage: number): void { + if (!Number.isInteger(stage) || stage < 0 || stage >= AEROX3_WIRELESS_GEN2_MAX_DPI_PRESETS) { + throw new Error(`The Aerox 3 Wireless Gen 2 holds ${AEROX3_WIRELESS_GEN2_MAX_DPI_PRESETS} DPI stages.`); + } + } + + private async writePresets(stages: readonly Aerox3WirelessGen2DpiPreset[], activeStage: number): Promise { + this.requireStage(activeStage); + const report = steelseriesAerox3WirelessGen2EncodeDpiPresets(stages, activeStage, this.wireless); + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesAerox3WirelessGen2SaveCommand(this.wireless)); + this.dpiStages = [...stages]; + this.activeDpiStage = activeStage; + }); + } + + 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(steelseriesAerox3WirelessGen2SaveCommand(this.wireless)); + }); + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(AEROX3_WIRELESS_GEN2_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/aerox-3-wireless-gen2.test.ts b/src/steelseries/aerox-3-wireless-gen2.test.ts new file mode 100644 index 00000000..64b6eb4a --- /dev/null +++ b/src/steelseries/aerox-3-wireless-gen2.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + AEROX3_WIRELESS_GEN2_BATTERY_RESPONSE_LENGTH, + AEROX3_WIRELESS_GEN2_DEFAULT_DPI_PRESETS, + AEROX3_WIRELESS_GEN2_DEFAULT_POLLING_RATE, + AEROX3_WIRELESS_GEN2_DPI_MAX, + AEROX3_WIRELESS_GEN2_DPI_MIN, + AEROX3_WIRELESS_GEN2_MAX_DPI_PRESETS, + AEROX3_WIRELESS_GEN2_POLLING_RATES, + Aerox3WirelessGen2ProtocolError, + applyAerox3WirelessGen2Flag, + steelseriesAerox3WirelessGen2BatteryQuery, + steelseriesAerox3WirelessGen2DecodeBattery, + steelseriesAerox3WirelessGen2DecodeFirmware, + steelseriesAerox3WirelessGen2DpiOptions, + steelseriesAerox3WirelessGen2EncodeButtonsMapping, + steelseriesAerox3WirelessGen2EncodeDpiPresets, + steelseriesAerox3WirelessGen2EncodePollingRate, + steelseriesAerox3WirelessGen2EncodePollingRateSingle, + steelseriesAerox3WirelessGen2FirmwareQuery, + steelseriesAerox3WirelessGen2SaveCommand, +} from "./aerox-3-wireless-gen2.ts"; + +test("wireless flag ORs 0x40 into byte 0 only", () => { + assert.deepEqual(applyAerox3WirelessGen2Flag([0x2d]), [0x6d]); + assert.deepEqual(applyAerox3WirelessGen2Flag([0x11, 0x00]), [0x51, 0x00]); + assert.deepEqual(applyAerox3WirelessGen2Flag([0x92]), [0xd2]); + assert.throws(() => applyAerox3WirelessGen2Flag([]), Aerox3WirelessGen2ProtocolError); +}); + +test("DPI options run 100..26000 in 100 DPI steps", () => { + const options = steelseriesAerox3WirelessGen2DpiOptions(); + assert.equal(options[0], AEROX3_WIRELESS_GEN2_DPI_MIN); + assert.equal(options.at(-1), AEROX3_WIRELESS_GEN2_DPI_MAX); + assert.equal(options.length, (AEROX3_WIRELESS_GEN2_DPI_MAX - AEROX3_WIRELESS_GEN2_DPI_MIN) / 100 + 1); + assert.deepEqual([...AEROX3_WIRELESS_GEN2_DEFAULT_DPI_PRESETS], [400, 800, 1600, 2400, 3200]); + assert.equal(AEROX3_WIRELESS_GEN2_DEFAULT_POLLING_RATE, 1000); + assert.equal(AEROX3_WIRELESS_GEN2_MAX_DPI_PRESETS, 5); +}); + +function fivePresets(): [ + { x: number; y: number; liftOffDistance: number }, + { x: number; y: number; liftOffDistance: number }, + { x: number; y: number; liftOffDistance: number }, + { x: number; y: number; liftOffDistance: number }, + { x: number; y: number; liftOffDistance: number }, +] { + const preset = { x: 800, y: 1600, liftOffDistance: 2 }; + return [preset, preset, preset, preset, preset]; +} + +test("DPI presets encode x/y little-endian plus lift-off, wired vs dongle", () => { + assert.deepEqual( + [...steelseriesAerox3WirelessGen2EncodeDpiPresets(fivePresets(), 1, false)], + [ + 0x2d, 0x05, 0x01, + 0x20, 0x03, 0x40, 0x06, 0x02, + 0x20, 0x03, 0x40, 0x06, 0x02, + 0x20, 0x03, 0x40, 0x06, 0x02, + 0x20, 0x03, 0x40, 0x06, 0x02, + 0x20, 0x03, 0x40, 0x06, 0x02, + ], + ); + assert.deepEqual( + [...steelseriesAerox3WirelessGen2EncodeDpiPresets(fivePresets(), 1, true).slice(0, 1)], + [0x6d], + ); +}); + +test("DPI presets require exactly five entries, valid values, and a valid index", () => { + assert.throws(() => steelseriesAerox3WirelessGen2EncodeDpiPresets(fivePresets().slice(0, 4), 0, false), Aerox3WirelessGen2ProtocolError); + assert.throws(() => steelseriesAerox3WirelessGen2EncodeDpiPresets(fivePresets(), 5, false), Aerox3WirelessGen2ProtocolError); + const bad = fivePresets(); + bad[0] = { x: 50, y: 1600, liftOffDistance: 0 }; + assert.throws(() => steelseriesAerox3WirelessGen2EncodeDpiPresets(bad, 0, false), Aerox3WirelessGen2ProtocolError); + const badLod = fivePresets(); + badLod[0] = { x: 800, y: 1600, liftOffDistance: 256 }; + assert.throws(() => steelseriesAerox3WirelessGen2EncodeDpiPresets(badLod, 0, false), Aerox3WirelessGen2ProtocolError); +}); + +test("polling rates cover 125..4000 with split wired/wireless bytes", () => { + assert.deepEqual([...steelseriesAerox3WirelessGen2EncodePollingRate(4000, 1000, false)], [0x2b, 0x00, 0x02]); + assert.deepEqual([...steelseriesAerox3WirelessGen2EncodePollingRate(4000, 1000, true)], [0x6b, 0x00, 0x02]); + assert.deepEqual([...steelseriesAerox3WirelessGen2EncodePollingRate(125, 125, false)], [0x2b, 0x05, 0x05]); + assert.deepEqual([...AEROX3_WIRELESS_GEN2_POLLING_RATES], [125, 250, 500, 1000, 2000, 4000]); + assert.throws(() => steelseriesAerox3WirelessGen2EncodePollingRate(8000, 1000, false), Aerox3WirelessGen2ProtocolError); + assert.throws(() => steelseriesAerox3WirelessGen2EncodePollingRate(1000, 2000, false), Aerox3WirelessGen2ProtocolError); +}); + +test("single-rate polling caps wired at 1000 Hz", () => { + assert.deepEqual([...steelseriesAerox3WirelessGen2EncodePollingRateSingle(4000, false)], [0x2b, 0x00, 0x02]); + assert.deepEqual([...steelseriesAerox3WirelessGen2EncodePollingRateSingle(250, false)], [0x2b, 0x04, 0x04]); +}); + +test("save, battery, and firmware commands", () => { + assert.deepEqual([...steelseriesAerox3WirelessGen2SaveCommand(false)], [0x11, 0x00]); + assert.deepEqual([...steelseriesAerox3WirelessGen2SaveCommand(true)], [0x51, 0x00]); + assert.deepEqual([...steelseriesAerox3WirelessGen2BatteryQuery(false)], [0x92]); + assert.deepEqual([...steelseriesAerox3WirelessGen2BatteryQuery(true)], [0xd2]); + assert.deepEqual(steelseriesAerox3WirelessGen2DecodeBattery(new Uint8Array([0x01, 0x64])), { level: 100, status: 1 }); + assert.throws(() => steelseriesAerox3WirelessGen2DecodeBattery(new Uint8Array([0x01])), Aerox3WirelessGen2ProtocolError); + assert.deepEqual([...steelseriesAerox3WirelessGen2FirmwareQuery(false)], [0x90, 0x00]); + assert.equal(AEROX3_WIRELESS_GEN2_BATTERY_RESPONSE_LENGTH, 2); + const fw = new Uint8Array([0x31, 0x2e, 0x31, 0x2e, 0x35, ...new Array(7).fill(0x00)]); + assert.equal(steelseriesAerox3WirelessGen2DecodeFirmware(fw), "1.1.5"); + assert.throws(() => steelseriesAerox3WirelessGen2DecodeFirmware(new Uint8Array(11)), Aerox3WirelessGen2ProtocolError); +}); + +test("button mapping encodes eight slots", () => { + const report = steelseriesAerox3WirelessGen2EncodeButtonsMapping({ + button1: { type: "button", target: "button1" }, + button6: { type: "dpiSwitch" }, + }, false); + assert.equal(report[0], 0x2a); + assert.equal(report.length, 1 + 8 * 5); + assert.equal(report[1], 0x01); + assert.equal(report[1 + 5 * 5], 0x30); + assert.deepEqual( + [...steelseriesAerox3WirelessGen2EncodeButtonsMapping({ button1: { type: "button", target: "button1" } }, true).slice(0, 1)], + [0x6a], + ); +}); + +test("button mapping rejects unknown buttons and bad codes", () => { + assert.throws( + () => steelseriesAerox3WirelessGen2EncodeButtonsMapping({ ["button9" as never]: { type: "disabled" } }, false), + Aerox3WirelessGen2ProtocolError, + ); + assert.throws( + () => steelseriesAerox3WirelessGen2EncodeButtonsMapping({ button1: { type: "keyboard", code: 256 } }, false), + Aerox3WirelessGen2ProtocolError, + ); +}); diff --git a/src/steelseries/aerox-3-wireless-gen2.ts b/src/steelseries/aerox-3-wireless-gen2.ts new file mode 100644 index 00000000..b05cf2fb --- /dev/null +++ b/src/steelseries/aerox-3-wireless-gen2.ts @@ -0,0 +1,386 @@ +/** + * SteelSeries Aerox 3 Wireless Gen 2 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 + * `aerox_3_wireless_gen2_{mouse,dongle,shared}.lisp` under + * `apps/engine/install/deviceSpecifications/`, and this module transcribes + * them. Those files are OpenPGP-armored ("DESCRIPTOR" armor, SKESK/AES-128); + * the bytes below are the documented HID report payloads, not guesses. No + * other public implementation of this protocol was available to corroborate + * against — rivalcfg has no Aerox 3 Wireless Gen 2 profile — so treat every + * byte as vendor-documented but not yet hardware-verified. + * + * Despite the shared "Aerox 3 Wireless" name this is a different command + * set from the Gen 1 (`./aerox3-wireless.ts`: one-byte TrueMove Air DPI + * `0x2D`, polling `0x2B` with values `00–03`, sleep/dim timers, fixed + * rainbow enable). Gen 2 carries raw-uint16 XY DPI with per-preset + * lift-off, split wired/wireless polling rates, and a different lighting + * and power scheme. Do not reuse the Gen 1 module. + * + * Transports share one module via the usual SteelSeries wireless + * transform: the mouse (`1038:1892`) uses command bytes as documented, and + * the 2.4 GHz dongle (`1038:1890`) ORs `0b01000000` into byte 0 of every + * command (`applyAerox3WirelessGen2Flag`), confirmed by diffing the + * mouse/dongle specification files, not assumed from precedent. + * + * Every command is an HID **output report** with report id 0x00 (65-byte + * reports throughout the specification). + * + * DPI uses an XY batch (`0x2D`): `num_dpi_levels` (0x01–0x05), `index` + * (0x00–0x04, 0-based), then five 5-byte `mouse_sensitivity` structs (x + * uint16LE, y uint16LE, lift_off_distance uint8). Values are raw uint16 — + * no sensor lookup table. The specification states no DPI bounds; the + * 100–26000 range accepted here follows the TrueMove 26K sensor cap and + * the 100-DPI floor of every other modern SteelSeries family, and is + * flagged as such rather than as a vendor-file claim. The lift-off byte + * has no documented range and is passed through. The specification always + * carries all five structs, so this codec requires exactly five presets + * rather than guessing at padding for shorter tables. + * + * Polling (`0x2B`) carries separate `wireless_rate` (0x00–0x05) and + * `wired_rate` (0x02–0x05) bytes plus a 300 ms settle delay, with a + * read-back (`0xAB`, same fields). No value→Hz table appears in the + * specification; the mapping used here (0→4000, 1→2000, 2→1000, 3→500, + * 4→250, 5→125) is inferred from two facts: the mouse is advertised as 4K + * wireless, and the wired range starts one step in (02), which only makes + * sense if 02 is 1000 Hz — flag it as inference, not a file claim. + * + * Buttons are one 40-byte packet (8 slots × 5-byte fields: 6 physical + * buttons plus scroll up/down), command `0x2A`, with a read-back (`0xAA`) + * defined but not decoded here. + * + * Known but deliberately not implemented — documented so nobody re-derives + * them, withheld until there is hardware evidence and a reason to ship them: + * + * - The 3-zone lighting commands (`direct_write`/`write_all_illumination` + * `0x21`, `lighting_settings` `0x23` + read `0xA3`, restart/disable + * `0x24`, idle `0x25`, `read_color_spin` `0xBE`). There is no default or + * reactive lighting on this mouse, unlike Gen 1. + * - `high_efficiency_mode` (`0x28` + read `0xA8`), `wireless_stability` + * (`0x15` + read `0x95`), angle snapping / mouse rotation / scroll jump / + * acceleration tables (`0x16`/`0x17` + reads `0x96`/`0x97`). + * - `restore_factory_default` (`0x91`). + * + * Settings apply immediately; the save command (`11 00`) persists them to + * onboard flash. None of this has been verified on physical hardware by + * this project. + */ + +const WIRELESS_FLAG = 0b01000000; + +/** Wired (mouse-mode) command bytes; see `applyAerox3WirelessGen2Flag` for dongle mode. */ +export const AEROX3_WIRELESS_GEN2_COMMAND = { + dpiPresets: [0x2d], + pollingRate: [0x2b], + buttonsMapping: [0x2a], + batteryLevel: [0x92], + firmware: [0x90, 0x00], + save: [0x11, 0x00], +} as const; + +export const AEROX3_WIRELESS_GEN2_REPORT_ID = 0x00; + +/** + * Dongle-mode transform: OR `0b01000000` into the command's first byte. + * Pass the wired-mode command bytes; returns the 2.4 GHz-mode equivalent. + */ +export function applyAerox3WirelessGen2Flag(command: readonly number[]): number[] { + if (command.length === 0) { + throw new Aerox3WirelessGen2ProtocolError("Cannot apply the wireless flag to an empty command."); + } + return [command[0]! | WIRELESS_FLAG, ...command.slice(1)]; +} + +function frame(command: readonly number[], wireless: boolean): number[] { + return wireless ? applyAerox3WirelessGen2Flag(command) : [...command]; +} + +/** + * Inferred value map (see the module doc comment): 0→4000, 1→2000, + * 2→1000, 3→500, 4→250, 5→125. The wireless field accepts all six; the + * wired field accepts 2–5 (1000 Hz and below). + */ +export const AEROX3_WIRELESS_GEN2_POLLING_RATES = [125, 250, 500, 1000, 2000, 4000] as const; + +const POLLING_RATE_TO_BYTE: ReadonlyMap = new Map([ + [4000, 0x00], + [2000, 0x01], + [1000, 0x02], + [500, 0x03], + [250, 0x04], + [125, 0x05], +]); + +/** No DPI bounds appear in the specification; see the module doc comment. */ +export const AEROX3_WIRELESS_GEN2_DPI_MIN = 100; +export const AEROX3_WIRELESS_GEN2_DPI_MAX = 26000; +export const AEROX3_WIRELESS_GEN2_DPI_STEP = 100; +export const AEROX3_WIRELESS_GEN2_MAX_DPI_PRESETS = 5; +export const AEROX3_WIRELESS_GEN2_BATTERY_RESPONSE_LENGTH = 2; +export const AEROX3_WIRELESS_GEN2_FIRMWARE_RESPONSE_LENGTH = 12; + +/** Common factory table shared across the modern SteelSeries families. */ +export const AEROX3_WIRELESS_GEN2_DEFAULT_DPI_PRESETS = [400, 800, 1600, 2400, 3200] as const; +export const AEROX3_WIRELESS_GEN2_DEFAULT_POLLING_RATE = 1000; + +export class Aerox3WirelessGen2ProtocolError extends Error {} + +/** One DPI preset: independent X/Y sensitivities plus lift-off distance. */ +export interface Aerox3WirelessGen2DpiPreset { + x: number; + y: number; + /** Lift-off distance byte; no vendor range — passed through. */ + liftOffDistance: number; +} + +/** UI DPI options: 100–26000 in 100 DPI steps (see bounds disclosure above). */ +export function steelseriesAerox3WirelessGen2DpiOptions(): number[] { + const options: number[] = []; + for (let dpi = AEROX3_WIRELESS_GEN2_DPI_MIN; dpi <= AEROX3_WIRELESS_GEN2_DPI_MAX; dpi += AEROX3_WIRELESS_GEN2_DPI_STEP) { + options.push(dpi); + } + return options; +} + +function checkDpi(value: number): void { + if ( + !Number.isInteger(value) || + value < AEROX3_WIRELESS_GEN2_DPI_MIN || + value > AEROX3_WIRELESS_GEN2_DPI_MAX || + (value - AEROX3_WIRELESS_GEN2_DPI_MIN) % AEROX3_WIRELESS_GEN2_DPI_STEP !== 0 + ) { + throw new Aerox3WirelessGen2ProtocolError( + `SteelSeries Aerox 3 Wireless Gen 2 DPI must be ${AEROX3_WIRELESS_GEN2_DPI_MIN}–${AEROX3_WIRELESS_GEN2_DPI_MAX.toLocaleString("en-US")} in ${AEROX3_WIRELESS_GEN2_DPI_STEP} DPI steps.`, + ); + } +} + +function checkByte(value: number, what: string): number { + if (!Number.isInteger(value) || value < 0 || value > 255) { + throw new Aerox3WirelessGen2ProtocolError(`${what} must be an integer 0–255.`); + } + return value; +} + +function encodePreset(preset: Aerox3WirelessGen2DpiPreset): number[] { + checkDpi(preset.x); + checkDpi(preset.y); + checkByte(preset.liftOffDistance, "Lift-off distance"); + return [ + preset.x & 0xff, (preset.x >> 8) & 0xff, + preset.y & 0xff, (preset.y >> 8) & 0xff, + preset.liftOffDistance, + ]; +} + +/** + * `2D <5× (x u16LE, y u16LE, lod)>` — + * replaces the mouse's whole preset table. Exactly five presets are + * required (see the module doc comment). + */ +export function steelseriesAerox3WirelessGen2EncodeDpiPresets( + presets: readonly Aerox3WirelessGen2DpiPreset[], + selectedIndex: number, + wireless: boolean, +): Uint8Array { + if (presets.length !== AEROX3_WIRELESS_GEN2_MAX_DPI_PRESETS) { + throw new Aerox3WirelessGen2ProtocolError( + `SteelSeries Aerox 3 Wireless Gen 2 requires exactly ${AEROX3_WIRELESS_GEN2_MAX_DPI_PRESETS} DPI presets.`, + ); + } + if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= presets.length) { + throw new Aerox3WirelessGen2ProtocolError( + `Selected DPI preset must be an index into the ${presets.length} presets being written.`, + ); + } + return new Uint8Array([ + ...frame(AEROX3_WIRELESS_GEN2_COMMAND.dpiPresets, wireless), + presets.length, + selectedIndex, + ...presets.flatMap(encodePreset), + ]); +} + +function pollingRateToByte(pollingRateHz: number): number { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new Aerox3WirelessGen2ProtocolError( + `SteelSeries Aerox 3 Wireless Gen 2 supports ${AEROX3_WIRELESS_GEN2_POLLING_RATES.join(", ")} Hz polling.`, + ); + } + return byte; +} + +/** + * `2B ` — one polling level for each transport. + * Rates above 1000 Hz exceed the wired field's range, so the wired byte is + * capped at 1000 Hz; both assumptions are flagged in the module doc + * comment. + */ +export function steelseriesAerox3WirelessGen2EncodePollingRate( + wirelessHz: number, + wiredHz: number, + wireless: boolean, +): Uint8Array { + const wirelessByte = pollingRateToByte(wirelessHz); + const wiredByte = pollingRateToByte(wiredHz); + if (wiredByte < 0x02) { + throw new Aerox3WirelessGen2ProtocolError( + "SteelSeries Aerox 3 Wireless Gen 2 wired rate must be 1000 Hz or below.", + ); + } + return new Uint8Array([...frame(AEROX3_WIRELESS_GEN2_COMMAND.pollingRate, wireless), wirelessByte, wiredByte]); +} + +/** Convenience form: one rate for wireless, capped at 1000 Hz for wired. */ +export function steelseriesAerox3WirelessGen2EncodePollingRateSingle( + pollingRateHz: number, + wireless: boolean, +): Uint8Array { + const byte = pollingRateToByte(pollingRateHz); + return steelseriesAerox3WirelessGen2EncodePollingRate( + pollingRateHz, + byte < 0x02 ? 1000 : pollingRateHz, + wireless, + ); +} + +/** `11 00` wired / `51 00` dongle — commit the current settings to onboard flash. */ +export function steelseriesAerox3WirelessGen2SaveCommand(wireless: boolean): Uint8Array { + return new Uint8Array(frame(AEROX3_WIRELESS_GEN2_COMMAND.save, wireless)); +} + +/** `92` / `D2` — answered by a two-byte input report. */ +export function steelseriesAerox3WirelessGen2BatteryQuery(wireless: boolean): Uint8Array { + return new Uint8Array(frame(AEROX3_WIRELESS_GEN2_COMMAND.batteryLevel, wireless)); +} + +export interface Aerox3WirelessGen2Battery { + /** Battery percentage, reported directly. */ + level: number; + /** Charging/status byte, passed through — its encoding is undocumented. */ + status: number; +} + +/** + * Decode the two-byte battery reply (`battery_status`, `battery_value`). + * The value is a direct percentage (the specification forwards it to the + * UI unchanged); the status byte's encoding is undocumented, so it is + * returned raw and must not be interpreted as a charging flag. + */ +export function steelseriesAerox3WirelessGen2DecodeBattery(payload: Uint8Array): Aerox3WirelessGen2Battery { + if (payload.length < AEROX3_WIRELESS_GEN2_BATTERY_RESPONSE_LENGTH) { + throw new Aerox3WirelessGen2ProtocolError( + "SteelSeries Aerox 3 Wireless Gen 2 battery response is shorter than two bytes.", + ); + } + return { level: payload[1]!, status: payload[0]! }; +} + +/** `90 00` / `D0 00` — the device answers with a 12-byte version string. */ +export function steelseriesAerox3WirelessGen2FirmwareQuery(wireless: boolean): Uint8Array { + return new Uint8Array(frame(AEROX3_WIRELESS_GEN2_COMMAND.firmware, wireless)); +} + +/** Decode the 12-byte firmware version reply as ASCII, stopping at the first NUL. */ +export function steelseriesAerox3WirelessGen2DecodeFirmware(payload: Uint8Array): string { + if (payload.length < AEROX3_WIRELESS_GEN2_FIRMWARE_RESPONSE_LENGTH) { + throw new Aerox3WirelessGen2ProtocolError("SteelSeries Aerox 3 Wireless Gen 2 firmware response is shorter than 12 bytes."); + } + const bytes = payload.slice(0, AEROX3_WIRELESS_GEN2_FIRMWARE_RESPONSE_LENGTH); + const nul = bytes.indexOf(0x00); + return new TextDecoder("ascii").decode(nul === -1 ? bytes : bytes.slice(0, nul)); +} + +// -- Button mapping -------------------------------------------------------- + +/** 6 physical buttons plus scroll up/down (8 slots). */ +export const AEROX3_WIRELESS_GEN2_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 }, + scrollUp: { id: 0x31, offset: 0x1e }, + scrollDown: { id: 0x32, offset: 0x23 }, +} as const satisfies Record; + +export type Aerox3WirelessGen2ButtonName = keyof typeof AEROX3_WIRELESS_GEN2_BUTTONS; + +const BUTTON_FIELD_LENGTH = 5; +const BUTTON_DISABLE = 0x00; +const BUTTON_DPI_SWITCH = 0x30; +const BUTTON_KEYBOARD = 0x51; +const BUTTON_MULTIMEDIA = 0x61; + +export type Aerox3WirelessGen2ButtonAction = + | { type: "button"; target: Aerox3WirelessGen2ButtonName } + | { type: "disabled" } + | { type: "dpiSwitch" } + | { 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 Aerox3WirelessGen2ProtocolError(`${kind} codes must be integers 0–255.`); + } + return code; +} + +/** + * `2A` + one 40-byte packet (8 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 steelseriesAerox3WirelessGen2EncodeButtonsMapping( + mapping: Partial>, + wireless: boolean, +): Uint8Array { + for (const name of Object.keys(mapping)) { + if (!Object.hasOwn(AEROX3_WIRELESS_GEN2_BUTTONS, name)) { + throw new Aerox3WirelessGen2ProtocolError(`Unknown SteelSeries Aerox 3 Wireless Gen 2 button "${name}".`); + } + } + + const names = Object.keys(AEROX3_WIRELESS_GEN2_BUTTONS) as Aerox3WirelessGen2ButtonName[]; + const packet = new Array(names.length * BUTTON_FIELD_LENGTH).fill(0x00); + + for (const [name, action] of Object.entries(mapping) as Array<[Aerox3WirelessGen2ButtonName, Aerox3WirelessGen2ButtonAction]>) { + const { offset } = AEROX3_WIRELESS_GEN2_BUTTONS[name]; + switch (action.type) { + case "button": { + if (!Object.hasOwn(AEROX3_WIRELESS_GEN2_BUTTONS, action.target)) { + throw new Aerox3WirelessGen2ProtocolError(`Unknown SteelSeries Aerox 3 Wireless Gen 2 button "${action.target}".`); + } + packet[offset] = AEROX3_WIRELESS_GEN2_BUTTONS[action.target].id; + break; + } + case "disabled": { + packet[offset] = BUTTON_DISABLE; + break; + } + case "dpiSwitch": { + packet[offset] = BUTTON_DPI_SWITCH; + break; + } + case "keyboard": { + packet[offset] = BUTTON_KEYBOARD; + packet[offset + 1] = encodeKeyCode(action.code, "Keyboard scan"); + break; + } + case "multimedia": { + packet[offset] = BUTTON_MULTIMEDIA; + packet[offset + 1] = encodeKeyCode(action.code, "Multimedia key"); + break; + } + default: { + throw new Aerox3WirelessGen2ProtocolError("Unsupported SteelSeries Aerox 3 Wireless Gen 2 button action."); + } + } + } + + return new Uint8Array([...frame(AEROX3_WIRELESS_GEN2_COMMAND.buttonsMapping, wireless), ...packet]); +} diff --git a/src/steelseries/devices.ts b/src/steelseries/devices.ts index 8efdb71c..27edec4e 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" + | "aerox3-wireless-gen2" | "rival310" | "aerox3" | "aerox3-wireless" @@ -156,6 +157,26 @@ export const STEELSERIES_PRODUCTS: ReadonlyMap = new hasFirmwareQuery: false, verified: true, }], + // aerox_3_wireless_gen2 (vendor GG specification): Gen 2 shares nothing + // with the Gen 1 command set beyond the name — raw-uint16 XY DPI with + // per-preset lift-off, split wired/wireless polling to 4000 Hz. See + // ./aerox-3-wireless-gen2.ts. Mouse (USB-cabled) and 2.4 GHz dongle modes. + [0x1892, { + model: "Aerox 3 Wireless Gen 2 (wired mode)", + family: "aerox3-wireless-gen2", + wireless: true, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1890, { + model: "Aerox 3 Wireless Gen 2 (2.4 GHz mode)", + family: "aerox3-wireless-gen2", + wireless: true, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], [0x1878, { model: "Aerox 3 Wireless CS2 Dragon Lore Edition (2.4 GHz mode)", family: "aerox3-wireless", diff --git a/src/steelseries/index.ts b/src/steelseries/index.ts index dacc7846..065c1335 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -10,4 +10,5 @@ export * from "./aerox9-wireless.js"; export * from "./prime-plus.js"; export * from "./prime-mini-wireless.js"; export * from "./sensei-ten.js"; +export * from "./aerox-3-wireless-gen2.js"; export * from "./devices.js"; From 78b50c087ba00d0a882ba2478b88ccbec670f93a Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 18:19:30 -0600 Subject: [PATCH 09/15] feat: add SteelSeries Rival 600 protocol family Wired Rival 600 (1038:1724) and Dota 2 Edition (1038:172E), reconstructed from the vendor's own GG 121.0.0 device specification (rival-600.lisp, dota2-rival-600.lisp) with rivalcfg's rival600.py corroborating every command byte. Distinct command set from the Rival 650 Wireless despite the shared A3360 sensor: sensitivity 0x03 per-level (linear byte, 100-12000), polling 0x04, button feature report 0x31, proximity 0x96, save 0x09, firmware 0x10 (2-byte, used as the connectivity probe). 33-byte reports. Lighting withheld (vendor 0x07/0x27 vs rivalcfg 0x05 disagree). Unverified on hardware. --- src/drivers/registry.ts | 4 +- src/drivers/steelseries/rival-600-hid.test.ts | 127 +++++++ src/drivers/steelseries/rival-600-hid.ts | 220 ++++++++++++ src/steelseries/devices.ts | 22 ++ src/steelseries/index.ts | 1 + src/steelseries/rival-600.test.ts | 106 ++++++ src/steelseries/rival-600.ts | 318 ++++++++++++++++++ 7 files changed, 797 insertions(+), 1 deletion(-) create mode 100644 src/drivers/steelseries/rival-600-hid.test.ts create mode 100644 src/drivers/steelseries/rival-600-hid.ts create mode 100644 src/steelseries/rival-600.test.ts create mode 100644 src/steelseries/rival-600.ts diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..a7c41f12 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -53,6 +53,7 @@ import { SteelSeriesRival3WirelessHidClient } from "./steelseries/rival3-wireles import { SteelSeriesAerox5HidClient } from "./steelseries/aerox5-hid.ts"; import { SteelSeriesAerox5WirelessHidClient } from "./steelseries/aerox5-wireless-hid.ts"; import { SteelSeriesRival650HidClient } from "./steelseries/rival650-hid.ts"; +import { SteelSeriesRival600HidClient } from "./steelseries/rival-600-hid.ts"; import { SteelSeriesAerox9WirelessHidClient } from "./steelseries/aerox9-wireless-hid.ts"; import { SteelSeriesRival310HidClient } from "./steelseries/rival310-hid.ts"; import { SteelSeriesPrimePlusHidClient } from "./steelseries/prime-plus-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 | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesRival600HidClient | 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; @@ -171,6 +172,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesAerox5HidClient.isSupported(device), create: (device) => new SteelSeriesAerox5HidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesAerox5WirelessHidClient.isSupported(device), create: (device) => new SteelSeriesAerox5WirelessHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesRival650HidClient.isSupported(device), create: (device) => new SteelSeriesRival650HidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesRival600HidClient.isSupported(device), create: (device) => new SteelSeriesRival600HidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesAerox9WirelessHidClient.isSupported(device), create: (device) => new SteelSeriesAerox9WirelessHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesRival310HidClient.isSupported(device), create: (device) => new SteelSeriesRival310HidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesPrimePlusHidClient.isSupported(device), create: (device) => new SteelSeriesPrimePlusHidClient(device), score: () => 6 }, diff --git a/src/drivers/steelseries/rival-600-hid.test.ts b/src/drivers/steelseries/rival-600-hid.test.ts new file mode 100644 index 00000000..07629b78 --- /dev/null +++ b/src/drivers/steelseries/rival-600-hid.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesRival600HidClient } from "./rival-600-hid.ts"; + +function fakeDevice(options: { productId?: number; firmware?: number[]; answerFirmware?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[]; feature?: boolean }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x1724, + productName: "SteelSeries Rival 600", + 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 ?? [0x34, 0x02]); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + } + }, + sendFeatureReport: async (reportId: number, data: BufferSource) => { + const view = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : new Uint8Array(data as ArrayBuffer); + sent.push({ reportId, payload: [...view], feature: true }); + }, + receiveFeatureReport: async () => { throw new Error("the Rival 600 protocol does not read 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 600 product ids and nothing else", () => { + const { device } = fakeDevice(); + assert.equal(SteelSeriesRival600HidClient.isSupported(device), true); + assert.equal(SteelSeriesRival600HidClient.isSupported({ ...device, productId: 0x172e } as HIDDevice), true); + // Rival 650 Wireless, Rival 310, Rival 3, and siblings must not be claimed. + for (const productId of [0x172b, 0x1726, 0x1720, 0x1824, 0x1836, 0x1830]) { + assert.equal( + SteelSeriesRival600HidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesRival600HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus probes firmware and never claims to have read DPI/polling", async () => { + const { device, sent } = fakeDevice(); + const status = await new SteelSeriesRival600HidClient(device).readStatus(); + assert.deepEqual(sent, [{ reportId: 0, payload: [0x10, 0x00] }]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Rival 600"); + assert.deepEqual(status.firmware, ["2.52"]); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.batteryPercent, null); + 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 firmware probe loudly", async () => { + const { device } = fakeDevice({ answerFirmware: false }); + const client = new SteelSeriesRival600HidClient(device); + await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); +}); + +test("setDpi writes preset 1 plus save; setDpiPreset2 writes preset 2", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival600HidClient(device); + await client.readStatus(); + sent.length = 0; + const dpi = await client.setDpi(800); + assert.equal(dpi, 800); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x03, 0x00, 0x01, 0x07, 0x00, 0x42] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setDpiPreset2(1600); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x03, 0x00, 0x02, 0x0f, 0x00, 0x42] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); + +test("setPollingRate and setProximity write plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival600HidClient(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.setProximity(true, 0x10, 0x80); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x96, 0x00, 0x01, 0x10, 0x80] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); + +test("setButtonsMapping sends a feature report then a save output report", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival600HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setButtonsMapping({ button7: { type: "dpiSwitch" } }); + assert.equal(sent.length, 2); + assert.equal(sent[0]?.feature, true); + assert.equal(sent[0]?.payload[0], 0x31); + assert.equal(sent[0]?.payload.length, 2 + 7 * 5); + assert.equal(sent[0]?.payload[2 + 6 * 5], 0x30); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x09, 0x00] }); +}); diff --git a/src/drivers/steelseries/rival-600-hid.ts b/src/drivers/steelseries/rival-600-hid.ts new file mode 100644 index 00000000..93a95346 --- /dev/null +++ b/src/drivers/steelseries/rival-600-hid.ts @@ -0,0 +1,220 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + RIVAL600_POLLING_RATES, + RIVAL600_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesRival600DecodeFirmware, + steelseriesRival600DpiOptions, + steelseriesRival600EncodeButtonsMapping, + steelseriesRival600EncodePollingRate, + steelseriesRival600EncodeProximity, + steelseriesRival600EncodeSensitivity1, + steelseriesRival600EncodeSensitivity2, + steelseriesRival600FirmwareQuery, + steelseriesRival600SaveCommand, + type Rival600ButtonAction, + type Rival600ButtonName, + type SteelSeriesRival600Firmware, +} 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; +/** rivalcfg profile default (`rival600.py`: `sensitivity1` default 800). */ +const DEFAULT_DPI = 800; +const DEFAULT_POLLING_HZ = 1000; + +/** + * SteelSeries Rival 600 WebHID control (`1038:1724`, and the Dota 2 Edition + * `1038:172E` which includes the same profile). + * + * DPI (two independent presets, sensitivity `03 00 01`/`03 00 02` — this + * codec exposes `setDpi` against preset 1 only, matching every other + * SteelSeries family's single-`setDpi` surface, plus `setDpiPreset2`), + * polling rate, buttons, and the proximity (lift-off) threshold are all + * write-only. The one readable value is the two-byte firmware version + * (`10 00`), which this client uses as its connectivity probe; the active + * preset level (`81 00`) is also readable but not needed here. + * `readStatus` therefore has `valuesVerified: false`: DPI and polling rate + * are the session's last-written values (or rivalcfg's documented defaults + * before any write). See `../../steelseries/rival-600.ts` for the full + * protocol writeup — reconstructed from the vendor's GG device + * specification, with rivalcfg's `rival600.py` independently corroborating + * every command byte. Do not confuse this mouse with the Rival 650 Wireless + * (`1038:172B`/`1038:1726`, a different command set). + * + * Output reports are 33 bytes (`HIDIO 33` in the specification), unused + * padding is zero; the button mapping is a HID **feature** report, routed + * through `device.sendFeatureReport`, unlike every other setter here. 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 SteelSeriesRival600HidClient { + 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 { + if (device.vendorId !== STEELSERIES_VENDOR_ID) return false; + return STEELSERIES_PRODUCTS.get(device.productId)?.family === "rival-600"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...RIVAL600_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesRival600DpiOptions(); } + + 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 600"}`; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-rival-600", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: "The Rival 600 cannot report its current DPI or polling rate; values shown are the last written by this app, or assumed defaults.", + defaultDisplayName: "SteelSeries Rival 600", + }, + 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], + }; + }); + } + + /** + * `03 00 01 00 42` — writes sensitivity preset 1. The device has no + * per-preset getter, so preset 2 is left untouched by this call. + */ + async setDpi(dpi: number): Promise { + const report = steelseriesRival600EncodeSensitivity1(dpi); + await this.commit(report); + this.lastWritten.dpi = dpi; + return dpi; + } + + /** `03 00 02 00 42` — writes sensitivity preset 2. */ + async setDpiPreset2(dpi: number): Promise { + const report = steelseriesRival600EncodeSensitivity2(dpi); + await this.commit(report); + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + const report = steelseriesRival600EncodePollingRate(pollingRateHz); + await this.commit(report); + this.lastWritten.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + async setButtonsMapping(mapping: Partial>): Promise { + const report = steelseriesRival600EncodeButtonsMapping(mapping); + await this.run(async () => { + await this.open(); + await this.device.sendFeatureReport(RIVAL600_REPORT_ID, report.buffer as ArrayBuffer); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival600SaveCommand()); + }); + } + + /** `96 00 ` — proximity (lift-off) sensor threshold. Not wired to any UI control. */ + async setProximity(enable: boolean, low: number, high: number): Promise { + const report = steelseriesRival600EncodeProximity(enable, low, high); + await this.commit(report); + } + + 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(steelseriesRival600SaveCommand()); + }); + } + + /** + * `10 00` — two-byte firmware version. 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), RESPONSE_TIMEOUT_MS); + this.inputWaiters.add(finish as (payload: Uint8Array) => void); + }); + await this.write(steelseriesRival600FirmwareQuery()); + const payload = await response; + if (!payload) { + throw new Error("The Rival 600 did not answer the firmware query on this interface. Add the device again and choose another entry."); + } + return steelseriesRival600DecodeFirmware(payload); + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(RIVAL600_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..8214389b 100644 --- a/src/steelseries/devices.ts +++ b/src/steelseries/devices.ts @@ -74,6 +74,7 @@ export type SteelSeriesProtocolFamily = | "aerox5" | "aerox5-wireless" | "rival650" + | "rival-600" | "aerox9-wireless" | "prime-plus" | "prime-mini-wireless" @@ -261,6 +262,27 @@ export const STEELSERIES_PRODUCTS: ReadonlyMap = new hasFirmwareQuery: false, verified: false, }], + // rival-600.lisp (vendor GG specification): wired only. Own command set + // (sensitivity 0x03 per-level, polling 0x04, button feature report 0x31, + // proximity 0x96, save 0x09, firmware 0x10) — see ./rival-600.ts. + // Distinct from the Rival 650 Wireless despite the shared A3360 sensor. + [0x1724, { + model: "Rival 600", + family: "rival-600", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + // dota2-rival-600.lisp includes rival-600.lisp unchanged. + [0x172e, { + model: "Rival 600 Dota 2 Edition", + family: "rival-600", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], // aerox9_wireless_wired.py: Aerox 9 Wireless in USB-cabled mode. Shares // the applyWirelessFlag transform with Aerox 5 Wireless (confirmed by // diffing the two rivalcfg wired/wireless file pairs) but defines no diff --git a/src/steelseries/index.ts b/src/steelseries/index.ts index dacc7846..9f72a641 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -6,6 +6,7 @@ export * from "./rival3-wireless.js"; export * from "./aerox5.js"; export * from "./aerox5-wireless.js"; export * from "./rival650.js"; +export * from "./rival-600.js"; export * from "./aerox9-wireless.js"; export * from "./prime-plus.js"; export * from "./prime-mini-wireless.js"; diff --git a/src/steelseries/rival-600.test.ts b/src/steelseries/rival-600.test.ts new file mode 100644 index 00000000..94771620 --- /dev/null +++ b/src/steelseries/rival-600.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RIVAL600_DEFAULT_DPI_PRESET_1, + RIVAL600_DEFAULT_DPI_PRESET_2, + RIVAL600_DEFAULT_POLLING_RATE, + RIVAL600_DPI_MAX, + RIVAL600_DPI_MIN, + RIVAL600_DPI_PRESET_COUNT, + RIVAL600_FIRMWARE_RESPONSE_LENGTH, + RIVAL600_POLLING_RATES, + Rival600ProtocolError, + steelseriesRival600DecodeFirmware, + steelseriesRival600DpiOptions, + steelseriesRival600EncodeButtonsMapping, + steelseriesRival600EncodePollingRate, + steelseriesRival600EncodeProximity, + steelseriesRival600EncodeSensitivity1, + steelseriesRival600EncodeSensitivity2, + steelseriesRival600FirmwareQuery, + steelseriesRival600LevelQuery, + steelseriesRival600SaveCommand, +} from "./rival-600.ts"; + +test("DPI options are the full linear 100..12000 range in 100 DPI steps", () => { + const options = steelseriesRival600DpiOptions(); + assert.equal(options[0], RIVAL600_DPI_MIN); + assert.equal(options.at(-1), RIVAL600_DPI_MAX); + assert.equal(options.length, (RIVAL600_DPI_MAX - RIVAL600_DPI_MIN) / 100 + 1); + assert.equal(RIVAL600_DPI_PRESET_COUNT, 2); + assert.equal(RIVAL600_DEFAULT_DPI_PRESET_1, 800); + assert.equal(RIVAL600_DEFAULT_DPI_PRESET_2, 1600); + assert.equal(RIVAL600_DEFAULT_POLLING_RATE, 1000); +}); + +test("encodes both sensitivity presets with the linear byte and 00 42 suffix", () => { + // byte = (dpi - 100) / 100 + assert.deepEqual([...steelseriesRival600EncodeSensitivity1(100)], [0x03, 0x00, 0x01, 0x00, 0x00, 0x42]); + assert.deepEqual([...steelseriesRival600EncodeSensitivity1(800)], [0x03, 0x00, 0x01, 0x07, 0x00, 0x42]); + assert.deepEqual([...steelseriesRival600EncodeSensitivity2(1600)], [0x03, 0x00, 0x02, 0x0f, 0x00, 0x42]); + assert.deepEqual([...steelseriesRival600EncodeSensitivity1(12000)], [0x03, 0x00, 0x01, 0x77, 0x00, 0x42]); + assert.throws(() => steelseriesRival600EncodeSensitivity1(150), Rival600ProtocolError); + assert.throws(() => steelseriesRival600EncodeSensitivity2(12100), Rival600ProtocolError); +}); + +test("encodes polling rates with 1000->0x01 down to 125->0x04", () => { + assert.deepEqual([...steelseriesRival600EncodePollingRate(1000)], [0x04, 0x00, 0x01]); + assert.deepEqual([...steelseriesRival600EncodePollingRate(500)], [0x04, 0x00, 0x02]); + assert.deepEqual([...steelseriesRival600EncodePollingRate(250)], [0x04, 0x00, 0x03]); + assert.deepEqual([...steelseriesRival600EncodePollingRate(125)], [0x04, 0x00, 0x04]); + assert.deepEqual([...RIVAL600_POLLING_RATES], [125, 250, 500, 1000]); + assert.throws(() => steelseriesRival600EncodePollingRate(2000), Rival600ProtocolError); +}); + +test("save, level, and firmware queries", () => { + assert.deepEqual([...steelseriesRival600SaveCommand()], [0x09, 0x00]); + assert.deepEqual([...steelseriesRival600LevelQuery()], [0x81, 0x00]); + assert.deepEqual([...steelseriesRival600FirmwareQuery()], [0x10, 0x00]); + assert.equal(RIVAL600_FIRMWARE_RESPONSE_LENGTH, 2); + // Incoming struct order is fw_minor then fw_major; display is major.minor. + assert.deepEqual(steelseriesRival600DecodeFirmware(new Uint8Array([0x34, 0x02])), { + bytes: [0x34, 0x02], + display: "2.52", + }); + assert.throws(() => steelseriesRival600DecodeFirmware(new Uint8Array([0x34])), Rival600ProtocolError); +}); + +test("proximity (lift-off) command carries enable and thresholds", () => { + assert.deepEqual([...steelseriesRival600EncodeProximity(true, 0x10, 0x80)], [0x96, 0x00, 0x01, 0x10, 0x80]); + assert.deepEqual([...steelseriesRival600EncodeProximity(false, 0x00, 0xff)], [0x96, 0x00, 0x00, 0x00, 0xff]); + assert.throws(() => steelseriesRival600EncodeProximity(true, -1, 0), Rival600ProtocolError); + assert.throws(() => steelseriesRival600EncodeProximity(true, 0, 256), Rival600ProtocolError); +}); + +test("button mapping is a feature-report 35-byte table with 7 slots", () => { + const report = steelseriesRival600EncodeButtonsMapping({ + button1: { type: "button", target: "button1" }, + button6: { type: "disabled" }, + button7: { type: "dpiSwitch" }, + }); + assert.equal(report[0], 0x31); + assert.equal(report[1], 0x00); + assert.equal(report.length, 2 + 7 * 5); + assert.equal(report[2], 0x01); + assert.equal(report[2 + 5 * 5], 0x00); + assert.equal(report[2 + 6 * 5], 0x30); +}); + +test("button mapping rejects unknown slots and bad codes, and button7 target encodes as 0x00", () => { + assert.throws( + () => steelseriesRival600EncodeButtonsMapping({ ["button8" as never]: { type: "disabled" } }), + Rival600ProtocolError, + ); + // `button7`'s id is 0x00, colliding with the disable byte, which is why it + // is excluded from the `"button"` target type; forced through at runtime it + // simply encodes the disable byte (the documented collision). + const collision = steelseriesRival600EncodeButtonsMapping({ + button1: { type: "button", target: "button7" as never }, + }); + assert.equal(collision[2], 0x00); + assert.throws( + () => steelseriesRival600EncodeButtonsMapping({ button1: { type: "keyboard", code: 256 } }), + Rival600ProtocolError, + ); +}); diff --git a/src/steelseries/rival-600.ts b/src/steelseries/rival-600.ts new file mode 100644 index 00000000..9e27c3a3 --- /dev/null +++ b/src/steelseries/rival-600.ts @@ -0,0 +1,318 @@ +/** + * SteelSeries Rival 600 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-600.lisp` (and + * `dota2-rival-600.lisp`, which includes it) under + * `apps/engine/install/deviceSpecifications/`, and this module transcribes + * it. Those files are OpenPGP-armored ("DESCRIPTOR" armor, SKESK/AES-128); + * the bytes below are the documented HID report payloads, not guesses. + * rivalcfg's `rival600.py` independently corroborates every command byte + * and the DPI range here (see below), so this family is dual-sourced. + * + * Despite the shared A3360/TrueMove3 sensor and the "600/650" naming, the + * Rival 600 does NOT share a command set with the Rival 650 Wireless + * (`./rival650.ts`, family `"rival650"`): sensitivity `0x03` (two per-level + * commands) vs. `0x15`, polling `0x04` vs. `0x17`, buttons `0x31` (feature + * report) vs. `0x19` (output), and a firmware query `0x10` the 650 lacks. + * The two vendor specification files confirm the split, so this is a + * separate family/module, not a reuse. + * + * Every command is an HID **output report** with report id 0x00 (33-byte + * reports — `HIDIO 33` throughout the specification — smaller than every + * other family's 65-byte reports), except the button-mapping command, + * which is an HID **feature** report (`0x31`, `HIDFEATURE`). + * + * DPI uses two per-level commands (`03 00 01`/`03 00 02`): a linear + * one-byte encoding, `byte = (dpi - 100) / 100`, range 0x00–0x77 + * (100–12000 in 100 DPI steps), followed by the suffix `00 42` (the A3360 + * sensor PID). The vendor file states the byte and suffix but no range; + * rivalcfg's `input_range [100, 12000, 100]` / `output_range [0x00, 0x77, + * 1]` supplies the mapping, and it is the same linear encoding Rival 310 + * uses. Off-grid values are rejected rather than rounded. + * + * Polling (`04 00`, values 1000→0x01, 500→0x02, 250→0x03, 125→0x04) is + * write-only. Buttons are one 35-byte packet (7 slots × 5-byte fields) as + * a feature report, command `0x31`: `button7` has id `0x00`, colliding + * with the disable byte, so it is a valid remap *source* but not a valid + * *target* — see `steelseriesRival600EncodeButtonsMapping`. + * + * The proximity (lift-off) sensor command `0x96` carries `enable`, a low + * threshold, and a high threshold byte; the vendor file states no range + * for the two thresholds, so this codec passes them through. + * + * Known but deliberately not implemented — documented so nobody re-derives + * them, withheld until there is hardware evidence and a reason to ship + * them: + * + * - The 8-zone lighting path: the specification uses `direct_write` `0x27` + * (mask + 8 RGB triples) and `single_zone_direct_write` `0x07`, while + * rivalcfg's `rival600.py` instead uses a `05 00` feature-report + * rgbgradient path. The two sources disagree on the LED command, so + * lighting is left out until the difference is resolved on hardware. + * - `reset_all_illumination` `0x08`, `current_sensor` read `0x60`, + * `resolution_level` read `0x81`(read-op), macros, and factory restore. + * - Deceleration/acceleration (`MOUSEACCELDECEL`) and angle snapping + * (`MOUSESMOOTHING`): these are kernel-driver `SSE` settings, not HID + * commands, and are out of scope for this codec. + * + * `read resolution_level` (read-op) returns the active preset level; this + * module exposes it as `steelseriesRival600LevelQuery`, though the driver + * does not rely on it. 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 RIVAL600_REPORT_ID = 0x00; + +/** Command prefixes; the payload is the prefix plus its arguments. */ +export const RIVAL600_COMMAND = { + sensitivity1: [0x03, 0x00, 0x01], + sensitivity2: [0x03, 0x00, 0x02], + pollingRate: [0x04, 0x00], + /** Read-op for the active preset level (`81 00`). */ + activeLevel: [0x81, 0x00], + /** Feature report; 7×5-byte button table follows the selector. */ + buttonsMapping: [0x31, 0x00], + proximity: [0x96, 0x00], + save: [0x09, 0x00], + firmware: [0x10, 0x00], +} as const; + +/** `command_suffix` appended after the DPI byte for both sensitivity presets. */ +const SENSITIVITY_SUFFIX = [0x00, 0x42] as const; + +/** `09 00` — commit the current settings to onboard flash. */ +export const RIVAL600_SAVE_COMMAND = [0x09, 0x00] as const; + +/** The two sensitivity presets the specification defines (levels 1 and 2). */ +export const RIVAL600_DPI_PRESET_COUNT = 2; + +export const RIVAL600_FIRMWARE_RESPONSE_LENGTH = 2; + +export const RIVAL600_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 RIVAL600_DPI_MIN = 100; +export const RIVAL600_DPI_MAX = 12000; +export const RIVAL600_DPI_STEP = 100; + +/** rivalcfg `rival600.py` defaults for the two presets. */ +export const RIVAL600_DEFAULT_DPI_PRESET_1 = 800; +export const RIVAL600_DEFAULT_DPI_PRESET_2 = 1600; +export const RIVAL600_DEFAULT_POLLING_RATE = 1000; + +export class Rival600ProtocolError extends Error {} + +/** + * `input_range [100, 12000, 100]` -> `output_range [0x00, 0x77, 1]`: a + * plain linear one-byte encoding, `byte = (dpi - 100) / 100`, range + * 0x00–0x77 (119). Values must land exactly on a 100 DPI step; off-grid + * input is rejected, matching how the rest of this package validates DPI. + */ +function encodeDpiByte(dpi: number): number { + if ( + !Number.isInteger(dpi) || + dpi < RIVAL600_DPI_MIN || + dpi > RIVAL600_DPI_MAX || + (dpi - RIVAL600_DPI_MIN) % RIVAL600_DPI_STEP !== 0 + ) { + throw new Rival600ProtocolError( + `SteelSeries Rival 600 DPI must be ${RIVAL600_DPI_MIN}–${RIVAL600_DPI_MAX.toLocaleString("en-US")} in ${RIVAL600_DPI_STEP} DPI steps.`, + ); + } + return (dpi - RIVAL600_DPI_MIN) / RIVAL600_DPI_STEP; +} + +/** The 120 DPI values the linear encoding can express, ascending. */ +export function steelseriesRival600DpiOptions(): number[] { + const options: number[] = []; + for (let dpi = RIVAL600_DPI_MIN; dpi <= RIVAL600_DPI_MAX; dpi += RIVAL600_DPI_STEP) { + options.push(dpi); + } + return options; +} + +/** `03 00 01 00 42` — sensitivity preset 1. */ +export function steelseriesRival600EncodeSensitivity1(dpi: number): Uint8Array { + return new Uint8Array([...RIVAL600_COMMAND.sensitivity1, encodeDpiByte(dpi), ...SENSITIVITY_SUFFIX]); +} + +/** `03 00 02 00 42` — sensitivity preset 2. */ +export function steelseriesRival600EncodeSensitivity2(dpi: number): Uint8Array { + return new Uint8Array([...RIVAL600_COMMAND.sensitivity2, encodeDpiByte(dpi), ...SENSITIVITY_SUFFIX]); +} + +/** `04 00 ` with 1000→0x01, 500→0x02, 250→0x03, 125→0x04. */ +export function steelseriesRival600EncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new Rival600ProtocolError("SteelSeries Rival 600 supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...RIVAL600_COMMAND.pollingRate, byte]); +} + +/** `09 00` — commit the current settings to onboard flash. */ +export function steelseriesRival600SaveCommand(): Uint8Array { + return new Uint8Array(RIVAL600_SAVE_COMMAND); +} + +/** `81 00` — read the active preset level. */ +export function steelseriesRival600LevelQuery(): Uint8Array { + return new Uint8Array(RIVAL600_COMMAND.activeLevel); +} + +/** `10 00` — the device answers with minor then major firmware bytes. */ +export function steelseriesRival600FirmwareQuery(): Uint8Array { + return new Uint8Array(RIVAL600_COMMAND.firmware); +} + +export interface SteelSeriesRival600Firmware { + /** 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. "2.52". */ + 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 steelseriesRival600DecodeFirmware(payload: Uint8Array): SteelSeriesRival600Firmware { + if (payload.length < RIVAL600_FIRMWARE_RESPONSE_LENGTH) { + throw new Rival600ProtocolError("SteelSeries Rival 600 firmware response is shorter than two bytes."); + } + const bytes: [number, number] = [payload[0]!, payload[1]!]; + return { bytes, display: `${bytes[1]}.${bytes[0]}` }; +} + +/** + * `96 00 ` — proximity (lift-off) sensor threshold. + * `low`/`high` have no stated range in the vendor file; they are passed + * through as bytes. + */ +export function steelseriesRival600EncodeProximity(enable: boolean, low: number, high: number): Uint8Array { + for (const [value, name] of [[low, "Low"], [high, "High"]] as const) { + if (!Number.isInteger(value) || value < 0 || value > 255) { + throw new Rival600ProtocolError(`SteelSeries Rival 600 proximity ${name} threshold must be an integer 0–255.`); + } + } + return new Uint8Array([...RIVAL600_COMMAND.proximity, enable ? 0x01 : 0x00, low, high]); +} + +// -- Button mapping -------------------------------------------------------- + +/** 7 button slots, 5-byte fields — the same layout as the Rival 650. */ +export const RIVAL600_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 }, + /** + * `"id": 0x00` in rivalcfg's `rival600.py` (and the vendor table's CPI + * button) — collides with `button_disable`, so this button cannot be a + * valid remap *target*. It still has its own `offset` and defaults to the + * `dpi` special action. + */ + button7: { id: 0x00, offset: 0x1e }, +} as const satisfies Record; + +export type Rival600ButtonName = keyof typeof RIVAL600_BUTTONS; + +const RIVAL600_BUTTON_FIELD_LENGTH = 5; +const RIVAL600_BUTTON_DISABLE = 0x00; +const RIVAL600_BUTTON_DPI_SWITCH = 0x30; +const RIVAL600_BUTTON_SCROLL_UP = 0x31; +const RIVAL600_BUTTON_SCROLL_DOWN = 0x32; +const RIVAL600_BUTTON_KEYBOARD = 0x51; +const RIVAL600_BUTTON_MULTIMEDIA = 0x61; + +/** + * One button's target. `button7`'s `id` (`0x00`) collides with `disabled`, + * so — like `./rival650.ts` — it is a valid remap *source* but excluded + * from `"button"` as a remap *target*. + */ +export type Rival600ButtonAction = + | { type: "button"; target: Exclude } + | { 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 Rival600ProtocolError(`${kind} codes must be integers 0–255.`); + } + return code; +} + +/** + * `31 00` (feature report) + one 35-byte packet (7 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 steelseriesRival600EncodeButtonsMapping( + mapping: Partial>, +): Uint8Array { + for (const name of Object.keys(mapping)) { + if (!Object.hasOwn(RIVAL600_BUTTONS, name)) { + throw new Rival600ProtocolError(`Unknown SteelSeries Rival 600 button "${name}".`); + } + } + + const names = Object.keys(RIVAL600_BUTTONS) as Rival600ButtonName[]; + const packet = new Array(names.length * RIVAL600_BUTTON_FIELD_LENGTH).fill(0x00); + + for (const [name, action] of Object.entries(mapping) as Array<[Rival600ButtonName, Rival600ButtonAction]>) { + const { offset } = RIVAL600_BUTTONS[name]; + switch (action.type) { + case "button": { + packet[offset] = RIVAL600_BUTTONS[action.target].id; + break; + } + case "disabled": { + packet[offset] = RIVAL600_BUTTON_DISABLE; + break; + } + case "dpiSwitch": { + packet[offset] = RIVAL600_BUTTON_DPI_SWITCH; + break; + } + case "scrollUp": { + packet[offset] = RIVAL600_BUTTON_SCROLL_UP; + break; + } + case "scrollDown": { + packet[offset] = RIVAL600_BUTTON_SCROLL_DOWN; + break; + } + case "keyboard": { + packet[offset] = RIVAL600_BUTTON_KEYBOARD; + packet[offset + 1] = encodeKeyCode(action.code, "Keyboard scan"); + break; + } + case "multimedia": { + packet[offset] = RIVAL600_BUTTON_MULTIMEDIA; + packet[offset + 1] = encodeKeyCode(action.code, "Multimedia key"); + break; + } + default: { + throw new Rival600ProtocolError("Unsupported SteelSeries Rival 600 button action."); + } + } + } + + return new Uint8Array([...RIVAL600_COMMAND.buttonsMapping, ...packet]); +} From e22eabc76ab57269fa791088020cc8431f0a461a Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 18:27:33 -0600 Subject: [PATCH 10/15] feat: add SteelSeries Rival 100 family protocol Rival 100 (1038:1702) with its PC Bang/Dell China/Dota 2 special editions and the Rival 105 (1038:1814), all sharing the vendor GG 121.0.0 rival-100.lisp command set: two-preset DPI 0x03 with a choice byte (250-4000, rivalcfg-supplied table), polling 0x04, single-zone LED 0x05/0x07, button-6 mode 0x0B (with the PC Bang pre-packet), save 0x09, firmware 0x10 (used as the connectivity probe), no button remapping. Unverified on hardware. --- src/drivers/registry.ts | 4 +- src/drivers/steelseries/rival-100-hid.test.ts | 141 +++++++++++ src/drivers/steelseries/rival-100-hid.ts | 220 ++++++++++++++++ src/steelseries/devices.ts | 57 +++++ src/steelseries/index.ts | 1 + src/steelseries/rival-100.test.ts | 81 ++++++ src/steelseries/rival-100.ts | 236 ++++++++++++++++++ 7 files changed, 739 insertions(+), 1 deletion(-) create mode 100644 src/drivers/steelseries/rival-100-hid.test.ts create mode 100644 src/drivers/steelseries/rival-100-hid.ts create mode 100644 src/steelseries/rival-100.test.ts create mode 100644 src/steelseries/rival-100.ts diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..2b38296e 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 { SteelSeriesRival100HidClient } from "./steelseries/rival-100-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 | SteelSeriesRival100HidClient | 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) => SteelSeriesRival100HidClient.isSupported(device), create: (device) => new SteelSeriesRival100HidClient(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-100-hid.test.ts b/src/drivers/steelseries/rival-100-hid.test.ts new file mode 100644 index 00000000..12ac0c29 --- /dev/null +++ b/src/drivers/steelseries/rival-100-hid.test.ts @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesRival100HidClient } from "./rival-100-hid.ts"; + +function fakeDevice(options: { productId?: number; firmware?: number[]; answerFirmware?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x1702, + productName: "SteelSeries Rival 100", + 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 ?? [0x03, 0x01]); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + } + }, + sendFeatureReport: async () => { throw new Error("the Rival 100 protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Rival 100 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 100 family product ids and nothing else", () => { + const { device } = fakeDevice(); + for (const productId of [0x1702, 0x1708, 0x170a, 0x170b, 0x170c, 0x1814]) { + assert.equal( + SteelSeriesRival100HidClient.isSupported({ ...device, productId } as HIDDevice), + true, + `expected 0x${productId.toString(16)} to be claimed`, + ); + } + // Rival 110/106/300S (a different sensor table) and modern families must not be claimed. + for (const productId of [0x1729, 0x1816, 0x1810, 0x1824, 0x1836]) { + assert.equal( + SteelSeriesRival100HidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesRival100HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus probes firmware and never claims to have read DPI/polling", async () => { + const { device, sent } = fakeDevice(); + const status = await new SteelSeriesRival100HidClient(device).readStatus(); + assert.deepEqual(sent, [{ reportId: 0, payload: [0x10, 0x00] }]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Rival 100"); + assert.deepEqual(status.firmware, ["1.3"]); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.dpi, 1000); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.ui?.valuesVerified, false); + assert.ok(status.ui?.pollingNote); +}); + +test("a silent interface fails the firmware probe loudly", async () => { + const { device } = fakeDevice({ answerFirmware: false }); + const client = new SteelSeriesRival100HidClient(device); + await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); +}); + +test("setDpi writes preset 1 plus save; setDpiPreset2 writes preset 2", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival100HidClient(device); + await client.readStatus(); + sent.length = 0; + const dpi = await client.setDpi(1000); + assert.equal(dpi, 1000); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x03, 0x01, 0x06] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setDpiPreset2(2000); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x03, 0x02, 0x02] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); + +test("setPollingRate, setLedColor, and setLightEffect write plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival100HidClient(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] }, + ]); +}); + +test("setButton6Mode sends the plain packet, but the PC Bang edition gets its prefix", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival100HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setButton6Mode("os"); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x0b, 0x01] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + + const pcBang = fakeDevice({ productId: 0x1708 }); + const pcBangClient = new SteelSeriesRival100HidClient(pcBang.device); + await pcBangClient.readStatus(); + pcBang.sent.length = 0; + await pcBangClient.setButton6Mode("os"); + assert.deepEqual(pcBang.sent, [ + { reportId: 0, payload: [0x00, 0x0e, 0x01] }, + { reportId: 0, payload: [0x0b, 0x01] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); diff --git a/src/drivers/steelseries/rival-100-hid.ts b/src/drivers/steelseries/rival-100-hid.ts new file mode 100644 index 00000000..94fde434 --- /dev/null +++ b/src/drivers/steelseries/rival-100-hid.ts @@ -0,0 +1,220 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + RIVAL100_PC_BANG_BUTTON6_PREFIX, + RIVAL100_POLLING_RATES, + RIVAL100_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesRival100DecodeFirmware, + steelseriesRival100DpiOptions, + steelseriesRival100EncodeButton6Mode, + steelseriesRival100EncodeLedColor, + steelseriesRival100EncodeLightEffect, + steelseriesRival100EncodePollingRate, + steelseriesRival100EncodeSensitivity1, + steelseriesRival100EncodeSensitivity2, + steelseriesRival100FirmwareQuery, + steelseriesRival100SaveCommand, + type Rival100Button6Mode, + type Rival100LightEffect, + type SteelSeriesRival100Firmware, +} 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; +/** rivalcfg profile default (`rival100.py`: `sensitivity1` default 1000). */ +const DEFAULT_DPI = 1000; +const DEFAULT_POLLING_HZ = 1000; +/** The Rival 100 PC Bang edition needs this pre-packet before a button-6 write. */ +const PC_BANG_PRODUCT_ID = 0x1708; + +/** + * SteelSeries Rival 100 WebHID control (Rival 100 `1038:1702` and its + * special editions `1038:1708` PC Bang / `1038:170A` Dell China / + * `1038:170B`+`1038:170C` Dota 2, plus the Rival 105 `1038:1814` — one + * shared vendor profile; see `../../steelseries/rival-100.ts`). + * + * DPI (two presets, `03 01`/`03 02`) and polling 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 `rival100.py` (note: the vendor file has that + * query commented out for the Rival 100 base model; see the codec). DPI + * presets, polling, and the LED are the session's last-written values (or + * rivalcfg's documented defaults before any write) with + * `valuesVerified: false`. + * + * This mouse has **no button remapping at all** — the codec exposes only the + * button-6 (CPI) mode toggle. 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 SteelSeriesRival100HidClient { + 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 { + if (device.vendorId !== STEELSERIES_VENDOR_ID) return false; + return STEELSERIES_PRODUCTS.get(device.productId)?.family === "rival-100"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...RIVAL100_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesRival100DpiOptions(); } + + 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 100"}`; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-rival-100", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: "The Rival 100 cannot report its current DPI or polling rate; values shown are the last written by this app, or assumed defaults.", + defaultDisplayName: "SteelSeries Rival 100", + }, + 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], + }; + }); + } + + /** `03 01 ` — writes sensitivity preset 1. The device has no getter, so preset 2 is untouched. */ + async setDpi(dpi: number): Promise { + await this.commit(steelseriesRival100EncodeSensitivity1(dpi)); + this.lastWritten.dpi = dpi; + return dpi; + } + + /** `03 02 ` — writes sensitivity preset 2. */ + async setDpiPreset2(dpi: number): Promise { + await this.commit(steelseriesRival100EncodeSensitivity2(dpi)); + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesRival100EncodePollingRate(pollingRateHz)); + this.lastWritten.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + async setLedColor(r: number, g: number, b: number): Promise { + await this.commit(steelseriesRival100EncodeLedColor(r, g, b)); + } + + async setLightEffect(effect: Rival100LightEffect): Promise { + await this.commit(steelseriesRival100EncodeLightEffect(effect)); + } + + /** `0B ` — CPI button mode. The PC Bang edition gets its pre-packet first. */ + async setButton6Mode(mode: Rival100Button6Mode): Promise { + const report = steelseriesRival100EncodeButton6Mode(mode); + const isPcBang = this.device.productId === PC_BANG_PRODUCT_ID; + await this.run(async () => { + await this.open(); + if (isPcBang) { + await this.write(new Uint8Array(RIVAL100_PC_BANG_BUTTON6_PREFIX)); + await this.delay(COMMAND_DELAY_MS); + } + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival100SaveCommand()); + }); + } + + 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(steelseriesRival100SaveCommand()); + }); + } + + /** + * `10 00` — two-byte firmware version. 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), RESPONSE_TIMEOUT_MS); + this.inputWaiters.add(finish as (payload: Uint8Array) => void); + }); + await this.write(steelseriesRival100FirmwareQuery()); + const payload = await response; + if (!payload) { + throw new Error("The Rival 100 did not answer the firmware query on this interface. Add the device again and choose another entry."); + } + return steelseriesRival100DecodeFirmware(payload); + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(RIVAL100_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..c81f9993 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-100" | "rival310" | "aerox3" | "aerox3-wireless" @@ -100,6 +101,62 @@ export interface SteelSeriesProduct { } export const STEELSERIES_PRODUCTS: ReadonlyMap = new Map([ + // rival-100.lisp (vendor GG specification): the first-gen "Rival 100 family" + // command set (DPI choice 0x03, polling 0x04, LED 0x05/0x07, button-6 mode + // 0x0B, save 0x09, firmware 0x10, no button remapping). Shared by the Rival + // 100, its special editions, and the Rival 105 — see ./rival-100.ts. This is + // NOT the Rival 110/106/300S table (different sensor). + [0x1702, { + model: "Rival 100", + family: "rival-100", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + // rival-100-pc-bang.lisp: same profile plus a `00 0E 01` pre-packet before + // the button-6 command (handled in the driver for this PID). + [0x1708, { + model: "Rival 100 PC Bang Edition", + family: "rival-100", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x170a, { + model: "Rival 100 Dell China Edition", + family: "rival-100", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x170b, { + model: "Rival 100 Dota 2 Edition (Retail)", + family: "rival-100", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x170c, { + model: "Rival 100 Dota 2 Edition (Lenovo)", + family: "rival-100", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + // rival-105.lisp: includes rival-100 and adds the firmware query. + [0x1814, { + model: "Rival 105", + family: "rival-100", + 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..ab5ce69f 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -1,4 +1,5 @@ export * from "./rival3.js"; +export * from "./rival-100.js"; export * from "./rival310.js"; export * from "./aerox3.js"; export * from "./aerox3-wireless.js"; diff --git a/src/steelseries/rival-100.test.ts b/src/steelseries/rival-100.test.ts new file mode 100644 index 00000000..bdedc32e --- /dev/null +++ b/src/steelseries/rival-100.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RIVAL100_DEFAULT_DPI_PRESET_1, + RIVAL100_DEFAULT_DPI_PRESET_2, + RIVAL100_DEFAULT_POLLING_RATE, + RIVAL100_DPI_TO_BYTE, + RIVAL100_FIRMWARE_RESPONSE_LENGTH, + RIVAL100_PC_BANG_BUTTON6_PREFIX, + RIVAL100_POLLING_RATES, + Rival100ProtocolError, + steelseriesRival100ActiveCpiQuery, + steelseriesRival100DecodeFirmware, + steelseriesRival100DpiOptions, + steelseriesRival100EncodeButton6Mode, + steelseriesRival100EncodeLedColor, + steelseriesRival100EncodeLightEffect, + steelseriesRival100EncodePollingRate, + steelseriesRival100EncodeSensitivity1, + steelseriesRival100EncodeSensitivity2, + steelseriesRival100FirmwareQuery, + steelseriesRival100SaveCommand, +} from "./rival-100.ts"; + +test("DPI options are the eight Rival 100 hardware steps, inverted byte order", () => { + assert.deepEqual(steelseriesRival100DpiOptions(), [250, 500, 1000, 1250, 1500, 1750, 2000, 4000]); + assert.equal(RIVAL100_DPI_TO_BYTE.get(250), 0x08); + assert.equal(RIVAL100_DPI_TO_BYTE.get(1000), 0x06); + assert.equal(RIVAL100_DPI_TO_BYTE.get(4000), 0x01); + assert.equal(RIVAL100_DEFAULT_DPI_PRESET_1, 1000); + assert.equal(RIVAL100_DEFAULT_DPI_PRESET_2, 2000); + assert.equal(RIVAL100_DEFAULT_POLLING_RATE, 1000); +}); + +test("encodes both sensitivity presets with the choice byte", () => { + assert.deepEqual([...steelseriesRival100EncodeSensitivity1(250)], [0x03, 0x01, 0x08]); + assert.deepEqual([...steelseriesRival100EncodeSensitivity1(1000)], [0x03, 0x01, 0x06]); + assert.deepEqual([...steelseriesRival100EncodeSensitivity2(2000)], [0x03, 0x02, 0x02]); + assert.deepEqual([...steelseriesRival100EncodeSensitivity2(4000)], [0x03, 0x02, 0x01]); + // Off-table values are rejected. + assert.throws(() => steelseriesRival100EncodeSensitivity1(800), Rival100ProtocolError); + assert.throws(() => steelseriesRival100EncodeSensitivity2(7200), Rival100ProtocolError); +}); + +test("encodes polling rates with 1000->0x01 down to 125->0x04", () => { + assert.deepEqual([...steelseriesRival100EncodePollingRate(1000)], [0x04, 0x00, 0x01]); + assert.deepEqual([...steelseriesRival100EncodePollingRate(500)], [0x04, 0x00, 0x02]); + assert.deepEqual([...steelseriesRival100EncodePollingRate(250)], [0x04, 0x00, 0x03]); + assert.deepEqual([...steelseriesRival100EncodePollingRate(125)], [0x04, 0x00, 0x04]); + assert.deepEqual([...RIVAL100_POLLING_RATES], [125, 250, 500, 1000]); + assert.throws(() => steelseriesRival100EncodePollingRate(2000), Rival100ProtocolError); +}); + +test("save, firmware, and active-CPI queries", () => { + assert.deepEqual([...steelseriesRival100SaveCommand()], [0x09, 0x00]); + assert.deepEqual([...steelseriesRival100FirmwareQuery()], [0x10, 0x00]); + assert.deepEqual([...steelseriesRival100ActiveCpiQuery()], [0x16, 0x00]); + assert.equal(RIVAL100_FIRMWARE_RESPONSE_LENGTH, 2); + // Incoming struct order is fw_minor then fw_major; display is major.minor. + assert.deepEqual(steelseriesRival100DecodeFirmware(new Uint8Array([0x03, 0x01])), { + bytes: [0x03, 0x01], + display: "1.3", + }); + assert.throws(() => steelseriesRival100DecodeFirmware(new Uint8Array([0x03])), Rival100ProtocolError); +}); + +test("LED color sends r/g/b after the command (no brightness byte)", () => { + assert.deepEqual([...steelseriesRival100EncodeLedColor(255, 0x18, 0x00)], [0x05, 0x00, 0xff, 0x18, 0x00]); + assert.throws(() => steelseriesRival100EncodeLedColor(256, 0, 0), Rival100ProtocolError); +}); + +test("light effect and button-6 mode are choice encodings", () => { + assert.deepEqual([...steelseriesRival100EncodeLightEffect("steady")], [0x07, 0x00, 0x01]); + assert.deepEqual([...steelseriesRival100EncodeLightEffect("breath")], [0x07, 0x00, 0x03]); + assert.throws(() => steelseriesRival100EncodeLightEffect("pulse" as never), Rival100ProtocolError); + assert.deepEqual([...steelseriesRival100EncodeButton6Mode("dpi")], [0x0b, 0x00]); + assert.deepEqual([...steelseriesRival100EncodeButton6Mode("os")], [0x0b, 0x01]); + assert.throws(() => steelseriesRival100EncodeButton6Mode("wheel" as never), Rival100ProtocolError); + assert.deepEqual([...RIVAL100_PC_BANG_BUTTON6_PREFIX], [0x00, 0x0e, 0x01]); +}); diff --git a/src/steelseries/rival-100.ts b/src/steelseries/rival-100.ts new file mode 100644 index 00000000..cc77b114 --- /dev/null +++ b/src/steelseries/rival-100.ts @@ -0,0 +1,236 @@ +/** + * SteelSeries Rival 100 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-100.lisp` (plus the + * `rival-100-dell-china`, `rival-100-pc-bang`, `dota2-rival-100-retail`, + * `dota2-rival-100-lenovo` and `rival-105` files, all of which `include + * "rival-100"`) under `apps/engine/install/deviceSpecifications/`, and this + * module transcribes it. Those files are OpenPGP-armored ("DESCRIPTOR" + * armor, SKESK/AES-128); the bytes below are the documented HID report + * payloads, not guesses. rivalcfg's `rival100.py` (which lists the same + * Rival 100/105 product ids) independently corroborates the command bytes. + * + * This is the first-generation "rival 100 family" command set shared by the + * Rival 100, Rival 105, and every Rival 100 special edition. It is not the + * Rival 110/106/300S command set: those share the same command bytes but use + * a different sensor table (PixArt PMW3326, 200–7200 DPI), which needs its + * own module. Do not extend this one to cover them. + * + * Every command is an HID **output report** with report id 0x00 (33-byte + * reports — `HIDIO 33`). Unlike the modern families, **buttons are not + * remappable** at all: the vendor file stubs `button_mappings`/ + * `wheel_mappings` out as empty, and the only button control is a "button 6 + * mode" toggle (`0B`), which switches the CPI button under the wheel between + * hardware DPI switching and an OS-bound button. + * + * DPI uses two per-level commands (`03 01`/`03 02`) with a **choice** byte, + * not a linear encoding: the vendor file states the byte but no table (the + * Rival 100 sensor exposes only eight hardware steps). rivalcfg's + * `rival100.py` supplies that table — 250→0x08, 500→0x07, 1000→0x06, + * 1250→0x05, 1500→0x04, 1750→0x03, 2000→0x02, 4000→0x01 (note the + * *inverted* byte order) — and is the sole source for the mapping, flagged + * here. Off-table DPI values are rejected. The active preset level is the + * one readable value (`16 00`). + * + * 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` + mode). A firmware version (`10 00`, two bytes) is defined for + * the Rival 105 and the derived files; the Rival 100 base file has the same + * struct commented out ("removed for security reasons"), but rivalcfg reads + * it on the Rival 100, so it is exposed here and used by the driver as its + * connectivity probe (flagged). + * + * 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 RIVAL100_REPORT_ID = 0x00; + +/** Command prefixes; the payload is the prefix plus its arguments. */ +export const RIVAL100_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 RIVAL100_SAVE_COMMAND = [0x09, 0x00] as const; + +export const RIVAL100_FIRMWARE_RESPONSE_LENGTH = 2; + +export const RIVAL100_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 eight hardware DPI steps the Rival 100 sensor exposes, from + * rivalcfg's `rival100.py` `choices` (the vendor file gives no table). Note + * the inverted byte order: the largest DPI is the smallest byte. + */ +export const RIVAL100_DPI_TO_BYTE: ReadonlyMap = new Map([ + [250, 0x08], + [500, 0x07], + [1000, 0x06], + [1250, 0x05], + [1500, 0x04], + [1750, 0x03], + [2000, 0x02], + [4000, 0x01], +]); + +/** rivalcfg `rival100.py` defaults for the two presets. */ +export const RIVAL100_DEFAULT_DPI_PRESET_1 = 1000; +export const RIVAL100_DEFAULT_DPI_PRESET_2 = 2000; +export const RIVAL100_DEFAULT_POLLING_RATE = 1000; + +export class Rival100ProtocolError extends Error {} + +/** The eight supported DPI values, ascending. */ +export function steelseriesRival100DpiOptions(): number[] { + return [...RIVAL100_DPI_TO_BYTE.keys()].sort((a, b) => a - b); +} + +function encodeDpiByte(dpi: number): number { + const byte = RIVAL100_DPI_TO_BYTE.get(dpi); + if (byte === undefined) { + throw new Rival100ProtocolError( + `SteelSeries Rival 100 DPI must be one of: ${steelseriesRival100DpiOptions().join(", ")}.`, + ); + } + return byte; +} + +/** `03 01 ` — sensitivity preset 1. */ +export function steelseriesRival100EncodeSensitivity1(dpi: number): Uint8Array { + return new Uint8Array([...RIVAL100_COMMAND.sensitivity1, encodeDpiByte(dpi)]); +} + +/** `03 02 ` — sensitivity preset 2. */ +export function steelseriesRival100EncodeSensitivity2(dpi: number): Uint8Array { + return new Uint8Array([...RIVAL100_COMMAND.sensitivity2, encodeDpiByte(dpi)]); +} + +/** `04 00 ` with 1000→0x01, 500→0x02, 250→0x03, 125→0x04. */ +export function steelseriesRival100EncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new Rival100ProtocolError("SteelSeries Rival 100 supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...RIVAL100_COMMAND.pollingRate, byte]); +} + +/** `09 00` — commit the current settings to onboard flash. */ +export function steelseriesRival100SaveCommand(): Uint8Array { + return new Uint8Array(RIVAL100_SAVE_COMMAND); +} + +/** `10 00` — the device answers with minor then major firmware bytes. */ +export function steelseriesRival100FirmwareQuery(): Uint8Array { + return new Uint8Array(RIVAL100_COMMAND.firmware); +} + +/** `16 00` — read the active preset index. */ +export function steelseriesRival100ActiveCpiQuery(): Uint8Array { + return new Uint8Array(RIVAL100_COMMAND.activeCpiIndex); +} + +export interface SteelSeriesRival100Firmware { + /** 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 steelseriesRival100DecodeFirmware(payload: Uint8Array): SteelSeriesRival100Firmware { + if (payload.length < RIVAL100_FIRMWARE_RESPONSE_LENGTH) { + throw new Rival100ProtocolError("SteelSeries Rival 100 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 Rival100ProtocolError("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 steelseriesRival100EncodeLedColor(r: number, g: number, b: number): Uint8Array { + return new Uint8Array([...RIVAL100_COMMAND.ledColor, ...encodeRgb(r, g, b)]); +} + +/** `07 00 ` — lighting effect. See `steelseriesRival100EncodeLightEffect`. */ +export const RIVAL100_LIGHT_EFFECT_TO_BYTE = { + steady: 0x01, + breath: 0x03, +} as const; + +export type Rival100LightEffect = keyof typeof RIVAL100_LIGHT_EFFECT_TO_BYTE; + +/** `07 00 ` — steady or breath. */ +export function steelseriesRival100EncodeLightEffect(effect: Rival100LightEffect): Uint8Array { + const byte = RIVAL100_LIGHT_EFFECT_TO_BYTE[effect]; + if (byte === undefined) { + throw new Rival100ProtocolError( + `SteelSeries Rival 100 light effect must be one of: ${Object.keys(RIVAL100_LIGHT_EFFECT_TO_BYTE).join(", ")}.`, + ); + } + return new Uint8Array([...RIVAL100_COMMAND.ledPulsation, byte]); +} + +/** + * `0B ` — the button-6 (CPI) button mode. `dpi` keeps hardware DPI + * switching; `os` reports it as a normal OS-bound button. The Rival 100 + * "PC Bang" edition sends an extra `00 0E 01` packet before this one (see + * `rival-100-pc-bang.lisp`); that quirk is handled by the driver for that + * product id. + */ +export const RIVAL100_BUTTON6_MODE_TO_BYTE = { + dpi: 0x00, + os: 0x01, +} as const; + +export type Rival100Button6Mode = keyof typeof RIVAL100_BUTTON6_MODE_TO_BYTE; + +/** `0B ` — dpi or os. */ +export function steelseriesRival100EncodeButton6Mode(mode: Rival100Button6Mode): Uint8Array { + const byte = RIVAL100_BUTTON6_MODE_TO_BYTE[mode]; + if (byte === undefined) { + throw new Rival100ProtocolError( + `SteelSeries Rival 100 button-6 mode must be one of: ${Object.keys(RIVAL100_BUTTON6_MODE_TO_BYTE).join(", ")}.`, + ); + } + return new Uint8Array([...RIVAL100_COMMAND.button6Mode, byte]); +} + +/** `00 0E 01` — the pre-packet the Rival 100 PC Bang edition sends before `0B`. */ +export const RIVAL100_PC_BANG_BUTTON6_PREFIX = [0x00, 0x0e, 0x01] as const; From 947b467d9d535a60bdc1ea0d7a02dce3e1c69f2f Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 18:35:47 -0600 Subject: [PATCH 11/15] feat: add SteelSeries Rival 110 family protocol Rival 110 (1038:1729), Rival 106 (1038:1816) and Rival 300S (1038:1810), reconstructed from the vendor's own GG 121.0.0 device specifications (rival-110.lisp/rival-106.lisp/rival-300s.lisp, all including rival-100) with rivalcfg's rival110.py corroborating every byte. Shares the Rival 100 command set but uses the PixArt PMW3326 sensor curve (200-7200, the vendor file gives no table), so it is a separate family from ./rival-100.ts. Unverified on hardware. --- src/drivers/registry.ts | 4 +- src/drivers/steelseries/rival-110-hid.test.ts | 124 ++++++++++ src/drivers/steelseries/rival-110-hid.ts | 205 ++++++++++++++++ src/steelseries/devices.ts | 31 +++ src/steelseries/index.ts | 1 + src/steelseries/rival-110.test.ts | 83 +++++++ src/steelseries/rival-110.ts | 232 ++++++++++++++++++ 7 files changed, 679 insertions(+), 1 deletion(-) create mode 100644 src/drivers/steelseries/rival-110-hid.test.ts create mode 100644 src/drivers/steelseries/rival-110-hid.ts create mode 100644 src/steelseries/rival-110.test.ts create mode 100644 src/steelseries/rival-110.ts 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..0af0b9f9 --- /dev/null +++ b/src/drivers/steelseries/rival-110-hid.test.ts @@ -0,0 +1,124 @@ +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[]; answerFirmware?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + 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 (payload[0] === 0x10 && payload[1] === 0x00 && options.answerFirmware !== false) { + const response = new Uint8Array(options.firmware ?? [0x05, 0x02]); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + } + }, + 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`, + ); + } + // The Rival 100/105 family (a different sensor table) and modern families must not 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 never claims to have read DPI/polling", async () => { + const { device, sent } = fakeDevice(); + const status = await new SteelSeriesRival110HidClient(device).readStatus(); + assert.deepEqual(sent, [{ reportId: 0, payload: [0x10, 0x00] }]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Rival 110"); + assert.deepEqual(status.firmware, ["2.5"]); + assert.equal(status.connectionType, "Wired"); + 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 firmware probe loudly", async () => { + const { device } = fakeDevice({ answerFirmware: false }); + const client = new SteelSeriesRival110HidClient(device); + await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); +}); + +test("setDpi writes preset 1 plus save; setDpiPreset2 writes preset 2", async () => { + const { device, sent } = fakeDevice(); + 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: [0x03, 0x01, 0x12] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + 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..741a24d4 --- /dev/null +++ b/src/drivers/steelseries/rival-110-hid.ts @@ -0,0 +1,205 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + RIVAL110_POLLING_RATES, + RIVAL110_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + 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; +/** rivalcfg profile default (`rival110.py`: `sensitivity1` default 800). */ +const DEFAULT_DPI = 800; +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`. DPI presets, + * polling, and the LED are the session's last-written values (or rivalcfg's + * documented defaults before any write) 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>(); + 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 { + 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 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 its current DPI or polling rate; values shown are the last written by this app, or assumed defaults.", + defaultDisplayName: "SteelSeries Rival 110", + }, + 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], + }; + }); + } + + /** `03 01 ` — writes sensitivity preset 1. The device has no getter, so preset 2 is untouched. */ + async setDpi(dpi: number): Promise { + await this.commit(steelseriesRival110EncodeSensitivity1(dpi)); + this.lastWritten.dpi = dpi; + return dpi; + } + + /** `03 02 ` — writes sensitivity preset 2. */ + async setDpiPreset2(dpi: number): Promise { + await this.commit(steelseriesRival110EncodeSensitivity2(dpi)); + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesRival110EncodePollingRate(pollingRateHz)); + this.lastWritten.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()); + }); + } + + /** + * `10 00` — two-byte firmware version. 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), RESPONSE_TIMEOUT_MS); + this.inputWaiters.add(finish as (payload: Uint8Array) => void); + }); + await this.write(steelseriesRival110FirmwareQuery()); + const payload = await response; + 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); + } + + 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..97c19172 --- /dev/null +++ b/src/steelseries/rival-110.test.ts @@ -0,0 +1,83 @@ +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, + 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); +}); + +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..8960b868 --- /dev/null +++ b/src/steelseries/rival-110.ts @@ -0,0 +1,232 @@ +/** + * 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); +} + +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]); +} From df5b845fa0549c18ce07f3ad55d64ffac95578f1 Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 18:42:14 -0600 Subject: [PATCH 12/15] feat: add SteelSeries Rival 300 family protocol Rival 300 (1038:1710) and its editions (Dota 2 1038:1392, CS:GO Fade 1038:1394, Fallout 4 1038:1712, Acer Predator 1038:1714, CS:GO Fade stm32 1038:1716, HP OMEN 1038:1718, CS:GO Hyper Beast 1038:171A, Evil Geniuses 1038:171C), reconstructed from the vendor's own GG 121.0.0 rival_base.lisp profile with rivalcfg's rival300.py corroborating every byte. Linear DPI 0x03 (50-6500 in 50 steps), polling 0x04, two-zone LED 0x08/0x07, six remappable buttons 0x31, save 0x09, firmware 0x10. Unverified on hardware. --- src/drivers/registry.ts | 5 +- src/drivers/steelseries/rival-300-hid.test.ts | 124 +++++++ src/drivers/steelseries/rival-300-hid.ts | 207 ++++++++++++ src/steelseries/devices.ts | 78 +++++ src/steelseries/index.ts | 1 + src/steelseries/rival-300.test.ts | 90 +++++ src/steelseries/rival-300.ts | 314 ++++++++++++++++++ 7 files changed, 817 insertions(+), 2 deletions(-) create mode 100644 src/drivers/steelseries/rival-300-hid.test.ts create mode 100644 src/drivers/steelseries/rival-300-hid.ts create mode 100644 src/steelseries/rival-300.test.ts create mode 100644 src/steelseries/rival-300.ts 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..10f51af0 --- /dev/null +++ b/src/drivers/steelseries/rival-300-hid.test.ts @@ -0,0 +1,124 @@ +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[]; answerFirmware?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + 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 (payload[0] === 0x10 && payload[1] === 0x00 && options.answerFirmware !== false) { + const response = new Uint8Array(options.firmware ?? [0x03, 0x01]); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + } + }, + 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 never claims to have read DPI/polling", async () => { + const { device, sent } = fakeDevice(); + const status = await new SteelSeriesRival300HidClient(device).readStatus(); + assert.deepEqual(sent, [{ reportId: 0, payload: [0x10, 0x00] }]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Rival 300"); + assert.deepEqual(status.firmware, ["1.3"]); + assert.equal(status.connectionType, "Wired"); + 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 firmware probe loudly", async () => { + const { device } = fakeDevice({ answerFirmware: false }); + const client = new SteelSeriesRival300HidClient(device); + await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); +}); + +test("setDpi writes preset 1 plus save; setDpiPreset2 writes preset 2", async () => { + const { device, sent } = fakeDevice(); + 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: [0x03, 0x01, 0x10] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + 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..236ddbe5 --- /dev/null +++ b/src/drivers/steelseries/rival-300-hid.ts @@ -0,0 +1,207 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + RIVAL300_POLLING_RATES, + RIVAL300_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + 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; +/** rivalcfg profile default (`rival300.py`: `sensitivity1` default 800). */ +const DEFAULT_DPI = 800; +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`. DPI presets, polling, and the LED are the session's + * last-written values (or rivalcfg's documented defaults before any write) + * 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>(); + 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 { + 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 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 its current DPI or polling rate; values shown are the last written by this app, or assumed defaults.", + defaultDisplayName: "SteelSeries Rival 300", + }, + 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], + }; + }); + } + + /** `03 01 ` — writes sensitivity preset 1. The device has no getter, so preset 2 is untouched. */ + async setDpi(dpi: number): Promise { + await this.commit(steelseriesRival300EncodeSensitivity1(dpi)); + this.lastWritten.dpi = dpi; + return dpi; + } + + /** `03 02 ` — writes sensitivity preset 2. */ + async setDpiPreset2(dpi: number): Promise { + await this.commit(steelseriesRival300EncodeSensitivity2(dpi)); + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesRival300EncodePollingRate(pollingRateHz)); + this.lastWritten.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()); + }); + } + + /** + * `10 00` — two-byte firmware version. 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), RESPONSE_TIMEOUT_MS); + this.inputWaiters.add(finish as (payload: Uint8Array) => void); + }); + await this.write(steelseriesRival300FirmwareQuery()); + const payload = await response; + 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); + } + + 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..a6844edc --- /dev/null +++ b/src/steelseries/rival-300.test.ts @@ -0,0 +1,90 @@ +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, + 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); +}); + +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..e7cf5c46 --- /dev/null +++ b/src/steelseries/rival-300.ts @@ -0,0 +1,314 @@ +/** + * 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); +} + +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]); +} From 9ae9e48988d2232adea289b312c738f77bcf8c16 Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 18:52:10 -0600 Subject: [PATCH 13/15] fix(rival-110): write the active DPI preset, not always preset 1 Addresses CodeRabbit: when the CPI button has selected preset 2, setDpi() now reads the active preset index (0x16) and writes that preset, so the change takes effect; readStatus() reports the active preset's DPI. Both presets are tracked independently (falling back to their rivalcfg defaults when this session has not written them). setDpiPreset2 still writes preset 2 explicitly. Adds steelseriesRival110DecodeActivePreset. --- src/drivers/steelseries/rival-110-hid.test.ts | 60 +++++++++---- src/drivers/steelseries/rival-110-hid.ts | 90 ++++++++++++++----- src/steelseries/rival-110.test.ts | 6 ++ src/steelseries/rival-110.ts | 17 ++++ 4 files changed, 135 insertions(+), 38 deletions(-) diff --git a/src/drivers/steelseries/rival-110-hid.test.ts b/src/drivers/steelseries/rival-110-hid.test.ts index 0af0b9f9..75f0751d 100644 --- a/src/drivers/steelseries/rival-110-hid.test.ts +++ b/src/drivers/steelseries/rival-110-hid.test.ts @@ -3,9 +3,14 @@ import test from "node:test"; import { SteelSeriesRival110HidClient } from "./rival-110-hid.ts"; -function fakeDevice(options: { productId?: number; firmware?: number[]; answerFirmware?: boolean } = {}) { +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, @@ -20,11 +25,9 @@ function fakeDevice(options: { productId?: number; firmware?: number[]; answerFi : 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 ?? [0x05, 0x02]); - queueMicrotask(() => - listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); - } + 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"); }, @@ -43,7 +46,6 @@ test("claims the Rival 110 family product ids and nothing else", () => { `expected 0x${productId.toString(16)} to be claimed`, ); } - // The Rival 100/105 family (a different sensor table) and modern families must not be claimed. for (const productId of [0x1702, 0x1814, 0x1824, 0x1836]) { assert.equal( SteelSeriesRival110HidClient.isSupported({ ...device, productId } as HIDDevice), @@ -54,37 +56,65 @@ test("claims the Rival 110 family product ids and nothing else", () => { assert.equal(SteelSeriesRival110HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); }); -test("readStatus probes firmware and never claims to have read DPI/polling", async () => { - const { device, sent } = fakeDevice(); +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] }]); + 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"); - assert.equal(status.dpi, 800); + // 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); + assert.ok(status.ui?.pollingNote?.includes("active preset (2)")); }); test("a silent interface fails the firmware probe loudly", async () => { - const { device } = fakeDevice({ answerFirmware: false }); + const { device } = fakeDevice({ answer: false }); const client = new SteelSeriesRival110HidClient(device); await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); }); -test("setDpi writes preset 1 plus save; setDpiPreset2 writes preset 2", async () => { - const { device, sent } = fakeDevice(); +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, [ diff --git a/src/drivers/steelseries/rival-110-hid.ts b/src/drivers/steelseries/rival-110-hid.ts index 741a24d4..91bad516 100644 --- a/src/drivers/steelseries/rival-110-hid.ts +++ b/src/drivers/steelseries/rival-110-hid.ts @@ -1,9 +1,13 @@ 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, @@ -23,8 +27,6 @@ import { const COMMAND_DELAY_MS = 50; /** rivalcfg reads input-report replies with a 200 ms timeout; allow slack. */ const RESPONSE_TIMEOUT_MS = 500; -/** rivalcfg profile default (`rival110.py`: `sensitivity1` default 800). */ -const DEFAULT_DPI = 800; const DEFAULT_POLLING_HZ = 1000; /** @@ -35,9 +37,15 @@ const DEFAULT_POLLING_HZ = 1000; * 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`. DPI presets, - * polling, and the LED are the session's last-written values (or rivalcfg's - * documented defaults before any write) with `valuesVerified: false`. + * 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 / @@ -51,10 +59,9 @@ export class SteelSeriesRival110HidClient { 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, - }; + /** 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( @@ -98,6 +105,7 @@ export class SteelSeriesRival110HidClient { 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 { @@ -109,13 +117,13 @@ export class SteelSeriesRival110HidClient { valuesVerified: false, hideUnsupportedPollingRates: true, hideProcessingCard: true, - pollingNote: "The Rival 110 cannot report its current DPI or polling rate; values shown are the last written by this app, or assumed defaults.", + 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.lastWritten.dpi ?? DEFAULT_DPI, - pollingRateHz: this.lastWritten.pollingRateHz ?? DEFAULT_POLLING_HZ, + dpi: this.dpiForPreset(activePreset), + pollingRateHz: this.pollingRateHz ?? DEFAULT_POLLING_HZ, supportedPollingRates: this.supportedPollingRates, activeProfile: null, connectionType: "Wired", @@ -125,22 +133,34 @@ export class SteelSeriesRival110HidClient { }); } - /** `03 01 ` — writes sensitivity preset 1. The device has no getter, so preset 2 is untouched. */ + /** + * 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.commit(steelseriesRival110EncodeSensitivity1(dpi)); - this.lastWritten.dpi = dpi; + 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. */ + /** `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.lastWritten.pollingRateHz = pollingRateHz; + this.pollingRateHz = pollingRateHz; return pollingRateHz; } @@ -166,11 +186,39 @@ export class SteelSeriesRival110HidClient { }); } + /** 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 => { @@ -181,12 +229,8 @@ export class SteelSeriesRival110HidClient { timer = setTimeout(() => finish(null), RESPONSE_TIMEOUT_MS); this.inputWaiters.add(finish as (payload: Uint8Array) => void); }); - await this.write(steelseriesRival110FirmwareQuery()); - const payload = await response; - 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); + await this.write(query); + return await response; } private async write(payload: Uint8Array): Promise { diff --git a/src/steelseries/rival-110.test.ts b/src/steelseries/rival-110.test.ts index 97c19172..6d2eecbc 100644 --- a/src/steelseries/rival-110.test.ts +++ b/src/steelseries/rival-110.test.ts @@ -13,6 +13,7 @@ import { RIVAL110_POLLING_RATES, Rival110ProtocolError, steelseriesRival110ActiveCpiQuery, + steelseriesRival110DecodeActivePreset, steelseriesRival110DecodeFirmware, steelseriesRival110DpiOptions, steelseriesRival110EncodeButton6Mode, @@ -68,6 +69,11 @@ test("save, firmware, and active-CPI queries", () => { 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", () => { diff --git a/src/steelseries/rival-110.ts b/src/steelseries/rival-110.ts index 8960b868..b6b0bcf7 100644 --- a/src/steelseries/rival-110.ts +++ b/src/steelseries/rival-110.ts @@ -152,6 +152,23 @@ 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]; From 55b900467a506374fd38e05048dbcb29d5e05a8f Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 18:54:45 -0600 Subject: [PATCH 14/15] fix(rival-300): write the active DPI preset and track both presets Addresses CodeRabbit: readStatus now reports the active preset's DPI and setDpi writes the active preset (read via 0x16) rather than always preset 1, so a change takes effect after the CPI button switches presets. Both presets are now tracked independently (each falling back to its rivalcfg default), fixing the setDpi/setDpiPreset2 asymmetry. setDpiPreset2 still writes preset 2 explicitly. Adds steelseriesRival300DecodeActivePreset. --- src/drivers/steelseries/rival-300-hid.test.ts | 59 +++++++++--- src/drivers/steelseries/rival-300-hid.ts | 90 ++++++++++++++----- src/steelseries/rival-300.test.ts | 6 ++ src/steelseries/rival-300.ts | 17 ++++ 4 files changed, 135 insertions(+), 37 deletions(-) diff --git a/src/drivers/steelseries/rival-300-hid.test.ts b/src/drivers/steelseries/rival-300-hid.test.ts index 10f51af0..06772c8e 100644 --- a/src/drivers/steelseries/rival-300-hid.test.ts +++ b/src/drivers/steelseries/rival-300-hid.test.ts @@ -3,9 +3,14 @@ import test from "node:test"; import { SteelSeriesRival300HidClient } from "./rival-300-hid.ts"; -function fakeDevice(options: { productId?: number; firmware?: number[]; answerFirmware?: boolean } = {}) { +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, @@ -20,11 +25,9 @@ function fakeDevice(options: { productId?: number; firmware?: number[]; answerFi : 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 ?? [0x03, 0x01]); - queueMicrotask(() => - listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); - } + 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"); }, @@ -53,37 +56,65 @@ test("claims the Rival 300 family product ids and nothing else", () => { assert.equal(SteelSeriesRival300HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); }); -test("readStatus probes firmware and never claims to have read DPI/polling", async () => { - const { device, sent } = fakeDevice(); +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] }]); + 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"); - assert.equal(status.dpi, 800); + // 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); + assert.ok(status.ui?.pollingNote?.includes("active preset (2)")); }); test("a silent interface fails the firmware probe loudly", async () => { - const { device } = fakeDevice({ answerFirmware: false }); + const { device } = fakeDevice({ answer: false }); const client = new SteelSeriesRival300HidClient(device); await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); }); -test("setDpi writes preset 1 plus save; setDpiPreset2 writes preset 2", async () => { - const { device, sent } = fakeDevice(); +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, [ diff --git a/src/drivers/steelseries/rival-300-hid.ts b/src/drivers/steelseries/rival-300-hid.ts index 236ddbe5..38b3227f 100644 --- a/src/drivers/steelseries/rival-300-hid.ts +++ b/src/drivers/steelseries/rival-300-hid.ts @@ -1,9 +1,13 @@ 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, @@ -25,8 +29,6 @@ import { const COMMAND_DELAY_MS = 50; /** rivalcfg reads input-report replies with a 200 ms timeout; allow slack. */ const RESPONSE_TIMEOUT_MS = 500; -/** rivalcfg profile default (`rival300.py`: `sensitivity1` default 800). */ -const DEFAULT_DPI = 800; const DEFAULT_POLLING_HZ = 1000; /** @@ -41,9 +43,15 @@ const DEFAULT_POLLING_HZ = 1000; * 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`. DPI presets, polling, and the LED are the session's - * last-written values (or rivalcfg's documented defaults before any write) - * with `valuesVerified: false`. + * `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 @@ -54,10 +62,9 @@ export class SteelSeriesRival300HidClient { 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, - }; + /** 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( @@ -101,6 +108,7 @@ export class SteelSeriesRival300HidClient { 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 { @@ -112,13 +120,13 @@ export class SteelSeriesRival300HidClient { valuesVerified: false, hideUnsupportedPollingRates: true, hideProcessingCard: true, - pollingNote: "The Rival 300 cannot report its current DPI or polling rate; values shown are the last written by this app, or assumed defaults.", + 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.lastWritten.dpi ?? DEFAULT_DPI, - pollingRateHz: this.lastWritten.pollingRateHz ?? DEFAULT_POLLING_HZ, + dpi: this.dpiForPreset(activePreset), + pollingRateHz: this.pollingRateHz ?? DEFAULT_POLLING_HZ, supportedPollingRates: this.supportedPollingRates, activeProfile: null, connectionType: "Wired", @@ -128,22 +136,34 @@ export class SteelSeriesRival300HidClient { }); } - /** `03 01 ` — writes sensitivity preset 1. The device has no getter, so preset 2 is untouched. */ + /** + * 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.commit(steelseriesRival300EncodeSensitivity1(dpi)); - this.lastWritten.dpi = dpi; + 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. */ + /** `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.lastWritten.pollingRateHz = pollingRateHz; + this.pollingRateHz = pollingRateHz; return pollingRateHz; } @@ -168,11 +188,39 @@ export class SteelSeriesRival300HidClient { }); } + /** 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 => { @@ -183,12 +231,8 @@ export class SteelSeriesRival300HidClient { timer = setTimeout(() => finish(null), RESPONSE_TIMEOUT_MS); this.inputWaiters.add(finish as (payload: Uint8Array) => void); }); - await this.write(steelseriesRival300FirmwareQuery()); - const payload = await response; - 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); + await this.write(query); + return await response; } private async write(payload: Uint8Array): Promise { diff --git a/src/steelseries/rival-300.test.ts b/src/steelseries/rival-300.test.ts index a6844edc..b434b003 100644 --- a/src/steelseries/rival-300.test.ts +++ b/src/steelseries/rival-300.test.ts @@ -10,6 +10,7 @@ import { RIVAL300_POLLING_RATES, Rival300ProtocolError, steelseriesRival300ActiveCpiQuery, + steelseriesRival300DecodeActivePreset, steelseriesRival300DecodeFirmware, steelseriesRival300DpiOptions, steelseriesRival300EncodeButtonsMapping, @@ -55,6 +56,11 @@ test("polling, save, firmware, and active-CPI queries", () => { 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", () => { diff --git a/src/steelseries/rival-300.ts b/src/steelseries/rival-300.ts index e7cf5c46..94cb3b3b 100644 --- a/src/steelseries/rival-300.ts +++ b/src/steelseries/rival-300.ts @@ -142,6 +142,23 @@ 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]; From a1a2e3c113e273583cd94eae7a6edad2507db2e0 Mon Sep 17 00:00:00 2001 From: snekxs Date: Mon, 12 Oct 2026 19:00:59 -0600 Subject: [PATCH 15/15] feat: add SteelSeries Sensei [RAW] protocol family Sensei [RAW] (1038:1369) and its Diablo III / Guild Wars 2 / Black Ops II / World of Tanks / Heroes of the Storm editions, reconstructed from the vendor's own GG 121.0.0 sensei-raw_base.lisp profile with rivalcfg's sensei_raw.py corroborating every byte. Linear two-preset DPI 0x03 (90-5670), polling 0x04, single-LED brightness 0x05 and effect 0x07, eight 3-byte button slots 0x31, save 0x09, firmware 0x10. setDpi writes the active preset (read via 0x16). Unverified on hardware. --- src/drivers/registry.ts | 4 +- .../steelseries/sensei-raw-hid.test.ts | 139 +++++++++ src/drivers/steelseries/sensei-raw-hid.ts | 244 +++++++++++++++ src/steelseries/devices.ts | 57 +++- src/steelseries/index.ts | 1 + src/steelseries/sensei-raw.test.ts | 97 ++++++ src/steelseries/sensei-raw.ts | 291 ++++++++++++++++++ 7 files changed, 831 insertions(+), 2 deletions(-) create mode 100644 src/drivers/steelseries/sensei-raw-hid.test.ts create mode 100644 src/drivers/steelseries/sensei-raw-hid.ts create mode 100644 src/steelseries/sensei-raw.test.ts create mode 100644 src/steelseries/sensei-raw.ts diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..eb2bf881 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -58,6 +58,7 @@ import { SteelSeriesRival310HidClient } from "./steelseries/rival310-hid.ts"; import { SteelSeriesPrimePlusHidClient } from "./steelseries/prime-plus-hid.ts"; import { SteelSeriesPrimeMiniWirelessHidClient } from "./steelseries/prime-mini-wireless-hid.ts"; import { SteelSeriesSenseiTenHidClient } from "./steelseries/sensei-ten-hid.ts"; +import { SteelSeriesSenseiRawHidClient } from "./steelseries/sensei-raw-hid.ts"; import { GloriousHidClient } from "./glorious/hid.ts"; import { GloriousClassicHidClient } from "./glorious/classic-hid.ts"; import { GloriousCore2HidClient } from "./glorious/core2-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 | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesSenseiRawHidClient | 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; @@ -176,6 +177,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesPrimePlusHidClient.isSupported(device), create: (device) => new SteelSeriesPrimePlusHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesPrimeMiniWirelessHidClient.isSupported(device), create: (device) => new SteelSeriesPrimeMiniWirelessHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiTenHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiTenHidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiRawHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiRawHidClient(device), score: () => 6 }, { brand: "Glorious", supports: (device) => GloriousHidClient.isSupported(device), create: (device) => new GloriousHidClient(device), score: () => 5 }, { brand: "Glorious", supports: (device) => GloriousClassicHidClient.isSupported(device), create: (device) => new GloriousClassicHidClient(device), score: () => 5 }, // Same vendor id as the classic family; disjoint by product id. diff --git a/src/drivers/steelseries/sensei-raw-hid.test.ts b/src/drivers/steelseries/sensei-raw-hid.test.ts new file mode 100644 index 00000000..0c2fe0b9 --- /dev/null +++ b/src/drivers/steelseries/sensei-raw-hid.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesSenseiRawHidClient } from "./sensei-raw-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 ?? 0x1369, + productName: "SteelSeries Sensei [RAW]", + 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 Sensei [RAW] protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Sensei [RAW] 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 Sensei [RAW] family product ids and nothing else", () => { + const { device } = fakeDevice(); + for (const productId of [0x1369, 0x1362, 0x136d, 0x136f, 0x1380, 0x1390]) { + assert.equal( + SteelSeriesSenseiRawHidClient.isSupported({ ...device, productId } as HIDDevice), + true, + `expected 0x${productId.toString(16)} to be claimed`, + ); + } + // Sensei TEN, Sensei [RAW] Optical RGB (rival-310 based) and modern families must not be claimed. + for (const productId of [0x1832, 0x1800, 0x1826, 0x1824, 0x1729]) { + assert.equal( + SteelSeriesSenseiRawHidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesSenseiRawHidClient.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 SteelSeriesSenseiRawHidClient(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 Sensei [RAW]"); + assert.deepEqual(status.firmware, ["1.3"]); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.dpi, 3240); + 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 SteelSeriesSenseiRawHidClient(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 SteelSeriesSenseiRawHidClient(device); + await client.readStatus(); + sent.length = 0; + const dpi = await client.setDpi(1620); + assert.equal(dpi, 1620); + 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 SteelSeriesSenseiRawHidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setDpi(3240); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x16, 0x00] }, + { reportId: 0, payload: [0x03, 0x02, 0x24] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); + +test("setPollingRate, LED brightness/effect, and buttons mapping write plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesSenseiRawHidClient(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.setLedBrightness("medium"); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x05, 0x01, 0x03] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setLedEffect("trigger"); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x07, 0x01, 0x05] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setButtonsMapping({ button8: { type: "dpiSwitch" } }); + assert.equal(sent.length, 2); + assert.equal(sent[0]?.payload[0], 0x31); + assert.equal(sent[0]?.payload.length, 2 + 8 * 3); + assert.equal(sent[0]?.payload[2 + 0x15], 0x30); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x09, 0x00] }); +}); diff --git a/src/drivers/steelseries/sensei-raw-hid.ts b/src/drivers/steelseries/sensei-raw-hid.ts new file mode 100644 index 00000000..60b17448 --- /dev/null +++ b/src/drivers/steelseries/sensei-raw-hid.ts @@ -0,0 +1,244 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + SENSEI_RAW_DEFAULT_DPI_PRESET_1, + SENSEI_RAW_DEFAULT_DPI_PRESET_2, + SENSEI_RAW_POLLING_RATES, + SENSEI_RAW_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesSenseiRawActiveCpiQuery, + steelseriesSenseiRawDecodeFirmware, + steelseriesSenseiRawDpiOptions, + steelseriesSenseiRawEncodeButtonsMapping, + steelseriesSenseiRawEncodeLedBrightness, + steelseriesSenseiRawEncodeLedEffect, + steelseriesSenseiRawEncodePollingRate, + steelseriesSenseiRawEncodeSensitivity1, + steelseriesSenseiRawEncodeSensitivity2, + steelseriesSenseiRawFirmwareQuery, + steelseriesSenseiRawSaveCommand, + type SenseiRawBrightness, + type SenseiRawButtonAction, + type SenseiRawButtonName, + type SenseiRawEffect, + type SteelSeriesSenseiRawFirmware, +} 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 Sensei [RAW] WebHID control (`1038:1369` and its Diablo III / + * Guild Wars 2 / CoD Black Ops II / World of Tanks / Heroes of the Storm + * editions, one shared vendor profile — see + * `../../steelseries/sensei-raw.ts`). + * + * DPI (two presets, `03 01`/`03 02`), polling, and the single 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 `sensei_raw.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 SteelSeriesSenseiRawHidClient { + 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 === "sensei-raw"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...SENSEI_RAW_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesSenseiRawDpiOptions(); } + + 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 ?? "Sensei [RAW]"}`; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-sensei-raw", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: `The Sensei [RAW] 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 Sensei [RAW]", + }, + 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 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 ? steelseriesSenseiRawEncodeSensitivity2(dpi) : steelseriesSenseiRawEncodeSensitivity1(dpi)); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesSenseiRawSaveCommand()); + this.presetDpi[preset] = dpi; + }); + return dpi; + } + + /** `03 02 ` — writes sensitivity preset 2 explicitly. */ + async setDpiPreset2(dpi: number): Promise { + await this.commit(steelseriesSenseiRawEncodeSensitivity2(dpi)); + this.presetDpi[2] = dpi; + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesSenseiRawEncodePollingRate(pollingRateHz)); + this.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + async setLedBrightness(brightness: SenseiRawBrightness): Promise { + await this.commit(steelseriesSenseiRawEncodeLedBrightness(brightness)); + } + + async setLedEffect(effect: SenseiRawEffect): Promise { + await this.commit(steelseriesSenseiRawEncodeLedEffect(effect)); + } + + async setButtonsMapping(mapping: Partial>): Promise { + await this.commit(steelseriesSenseiRawEncodeButtonsMapping(mapping)); + } + + /** 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 ? SENSEI_RAW_DEFAULT_DPI_PRESET_2 : SENSEI_RAW_DEFAULT_DPI_PRESET_1); + } + + 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(steelseriesSenseiRawSaveCommand()); + }); + } + + /** + * `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(steelseriesSenseiRawFirmwareQuery()); + if (!payload) { + throw new Error("The Sensei [RAW] did not answer the firmware query on this interface. Add the device again and choose another entry."); + } + return steelseriesSenseiRawDecodeFirmware(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(steelseriesSenseiRawActiveCpiQuery()); + if (!payload) return 1; + const level = payload[0]; + return level === 0x02 ? 2 : 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(SENSEI_RAW_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..b449009e 100644 --- a/src/steelseries/devices.ts +++ b/src/steelseries/devices.ts @@ -77,7 +77,8 @@ export type SteelSeriesProtocolFamily = | "aerox9-wireless" | "prime-plus" | "prime-mini-wireless" - | "sensei-ten"; + | "sensei-ten" + | "sensei-raw"; export interface SteelSeriesProduct { model: string; @@ -100,6 +101,60 @@ export interface SteelSeriesProduct { } export const STEELSERIES_PRODUCTS: ReadonlyMap = new Map([ + // sensei-raw_base.lisp (vendor GG specification): the original Sensei [RAW] + // command set — linear two-preset DPI 0x03 (90-5670), polling 0x04, single + // LED brightness/effect 0x05/0x07, eight 3-byte button slots 0x31, save + // 0x09, firmware 0x10. Shared by the base and its editions — see + // ./sensei-raw.ts. Distinct from the Sensei [RAW] Optical RGB (0x1800), + // which uses the Rival 310 command set. + [0x1369, { + model: "Sensei [RAW]", + family: "sensei-raw", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1362, { + model: "Sensei [RAW] Diablo III Edition", + family: "sensei-raw", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x136d, { + model: "Sensei [RAW] Guild Wars 2 Edition", + family: "sensei-raw", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x136f, { + model: "Sensei [RAW] Call of Duty: Black Ops II Edition", + family: "sensei-raw", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1380, { + model: "Sensei [RAW] World of Tanks Edition", + family: "sensei-raw", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1390, { + model: "Sensei [RAW] Heroes of the Storm Edition", + family: "sensei-raw", + 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..0b7cfc37 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -10,4 +10,5 @@ export * from "./aerox9-wireless.js"; export * from "./prime-plus.js"; export * from "./prime-mini-wireless.js"; export * from "./sensei-ten.js"; +export * from "./sensei-raw.js"; export * from "./devices.js"; diff --git a/src/steelseries/sensei-raw.test.ts b/src/steelseries/sensei-raw.test.ts new file mode 100644 index 00000000..a92b4dd7 --- /dev/null +++ b/src/steelseries/sensei-raw.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + SENSEI_RAW_BRIGHTNESS_TO_BYTE, + SENSEI_RAW_DEFAULT_DPI_PRESET_1, + SENSEI_RAW_DEFAULT_DPI_PRESET_2, + SENSEI_RAW_DEFAULT_POLLING_RATE, + SENSEI_RAW_DPI_MAX, + SENSEI_RAW_DPI_MIN, + SENSEI_RAW_EFFECT_TO_BYTE, + SENSEI_RAW_POLLING_RATES, + SenseiRawProtocolError, + steelseriesSenseiRawActiveCpiQuery, + steelseriesSenseiRawDecodeFirmware, + steelseriesSenseiRawDpiOptions, + steelseriesSenseiRawEncodeButtonsMapping, + steelseriesSenseiRawEncodeLedBrightness, + steelseriesSenseiRawEncodeLedEffect, + steelseriesSenseiRawEncodePollingRate, + steelseriesSenseiRawEncodeSensitivity1, + steelseriesSenseiRawEncodeSensitivity2, + steelseriesSenseiRawFirmwareQuery, + steelseriesSenseiRawSaveCommand, +} from "./sensei-raw.ts"; + +test("DPI options are the linear 90..5670 range in 90 DPI steps", () => { + const options = steelseriesSenseiRawDpiOptions(); + assert.equal(options[0], SENSEI_RAW_DPI_MIN); + assert.equal(options.at(-1), SENSEI_RAW_DPI_MAX); + assert.equal(options.length, (SENSEI_RAW_DPI_MAX - SENSEI_RAW_DPI_MIN) / 90 + 1); + assert.equal(options.length, 63); + assert.equal(SENSEI_RAW_DEFAULT_DPI_PRESET_1, 1620); + assert.equal(SENSEI_RAW_DEFAULT_DPI_PRESET_2, 3240); + assert.equal(SENSEI_RAW_DEFAULT_POLLING_RATE, 1000); +}); + +test("encodes sensitivity presets with the linear+1 byte", () => { + // byte = (dpi - 90) / 90 + 1 + assert.deepEqual([...steelseriesSenseiRawEncodeSensitivity1(90)], [0x03, 0x01, 0x01]); + assert.deepEqual([...steelseriesSenseiRawEncodeSensitivity1(1620)], [0x03, 0x01, 0x12]); + assert.deepEqual([...steelseriesSenseiRawEncodeSensitivity2(3240)], [0x03, 0x02, 0x24]); + assert.deepEqual([...steelseriesSenseiRawEncodeSensitivity1(5670)], [0x03, 0x01, 0x3f]); + assert.throws(() => steelseriesSenseiRawEncodeSensitivity1(135), SenseiRawProtocolError); + assert.throws(() => steelseriesSenseiRawEncodeSensitivity2(5760), SenseiRawProtocolError); +}); + +test("polling, save, firmware, and active-CPI queries", () => { + assert.deepEqual([...steelseriesSenseiRawEncodePollingRate(1000)], [0x04, 0x00, 0x01]); + assert.deepEqual([...steelseriesSenseiRawEncodePollingRate(125)], [0x04, 0x00, 0x04]); + assert.deepEqual([...SENSEI_RAW_POLLING_RATES], [125, 250, 500, 1000]); + assert.throws(() => steelseriesSenseiRawEncodePollingRate(2000), SenseiRawProtocolError); + assert.deepEqual([...steelseriesSenseiRawSaveCommand()], [0x09, 0x00]); + assert.deepEqual([...steelseriesSenseiRawFirmwareQuery()], [0x10, 0x00]); + assert.deepEqual([...steelseriesSenseiRawActiveCpiQuery()], [0x16, 0x00]); + assert.deepEqual(steelseriesSenseiRawDecodeFirmware(new Uint8Array([0x03, 0x01])), { + bytes: [0x03, 0x01], + display: "1.3", + }); + assert.throws(() => steelseriesSenseiRawDecodeFirmware(new Uint8Array([0x03])), SenseiRawProtocolError); +}); + +test("LED brightness and effect are choice encodings on the single LED", () => { + assert.deepEqual([...steelseriesSenseiRawEncodeLedBrightness("off")], [0x05, 0x01, 0x01]); + assert.deepEqual([...steelseriesSenseiRawEncodeLedBrightness("high")], [0x05, 0x01, 0x04]); + assert.deepEqual([...steelseriesSenseiRawEncodeLedEffect("steady")], [0x07, 0x01, 0x01]); + assert.deepEqual([...steelseriesSenseiRawEncodeLedEffect("trigger")], [0x07, 0x01, 0x05]); + assert.throws(() => steelseriesSenseiRawEncodeLedBrightness("max" as never), SenseiRawProtocolError); + assert.throws(() => steelseriesSenseiRawEncodeLedEffect("pulse" as never), SenseiRawProtocolError); + assert.deepEqual(SENSEI_RAW_BRIGHTNESS_TO_BYTE, { off: 0x01, low: 0x02, medium: 0x03, high: 0x04 }); + assert.deepEqual(SENSEI_RAW_EFFECT_TO_BYTE, { steady: 0x01, breath: 0x03, trigger: 0x05 }); +}); + +test("button mapping covers eight 3-byte slots with rivalcfg's offset order", () => { + const report = steelseriesSenseiRawEncodeButtonsMapping({ + button1: { type: "button", target: "button1" }, + button6: { type: "dpiSwitch" }, + button7: { type: "keyboard", code: 0x4a }, // H + }); + assert.equal(report[0], 0x31); + assert.equal(report[1], 0x00); + assert.equal(report.length, 2 + 8 * 3); + assert.equal(report[2 + 0x12], 0x30); // button6 -> dpi switch + assert.equal(report[2 + 0x0f], 0x10); // button7 -> keyboard + assert.equal(report[2 + 0x0f + 1], 0x4a); +}); + +test("button mapping rejects unknown slots and bad codes", () => { + assert.throws( + () => steelseriesSenseiRawEncodeButtonsMapping({ ["button9" as never]: { type: "disabled" } }), + SenseiRawProtocolError, + ); + assert.throws( + () => steelseriesSenseiRawEncodeButtonsMapping({ button1: { type: "multimedia", code: 300 } }), + SenseiRawProtocolError, + ); +}); diff --git a/src/steelseries/sensei-raw.ts b/src/steelseries/sensei-raw.ts new file mode 100644 index 00000000..838a11e0 --- /dev/null +++ b/src/steelseries/sensei-raw.ts @@ -0,0 +1,291 @@ +/** + * SteelSeries Sensei [RAW] 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 `sensei-raw_base.lisp` + * under `apps/engine/install/deviceSpecifications/`, and this module + * transcribes it. That file is OpenPGP-armored ("DESCRIPTOR" armor, + * SKESK/AES-128); the bytes below are the documented HID report payloads, + * not guesses. rivalcfg's `sensei_raw.py` (which lists the same product ids) + * independently corroborates every command byte and supplies the DPI range. + * + * This is the original **Sensei [RAW]** command set — a different device + * family from the Sensei (`./sensei-ten.ts`? no: the plain Sensei), the + * Sensei [RAW] Optical RGB (`0x1800`, which uses the Rival 310 command set), + * and the Sensei 310 (`./sensei-310.ts`). The [RAW] has a single white/red + * LED (brightness + effect only, **no color command**), eight buttons with + * 3-byte fields, and a linear two-preset DPI. + * + * 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 [90, 5670, 90]` against `output_range [0x01, + * 0x3F, 1]`, so `byte = (dpi - 90) / 90 + 1`, range 0x01–0x3F + * (90–5670 DPI in 90 DPI steps). Off-grid values are rejected. + * + * Polling (`04 00`) is write-only (1000→0x01, 500→0x02, 250→0x03, + * 125→0x04). The single LED has a brightness level (`05 01`, off/low/ + * medium/high → 0x01–0x04) and an effect (`07 01`, steady 0x01 / breath + * 0x03 / trigger 0x05). Buttons are one 24-byte packet (8 slots × 3-byte + * fields), command `31 00`; the button-6/7 slot order is odd (see + * `SENSEI_RAW_BUTTONS`). + * + * 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 SENSEI_RAW_REPORT_ID = 0x00; + +/** Command prefixes; the payload is the prefix plus its arguments. */ +export const SENSEI_RAW_COMMAND = { + sensitivity1: [0x03, 0x01], + sensitivity2: [0x03, 0x02], + pollingRate: [0x04, 0x00], + ledBrightness: [0x05, 0x01], + ledEffect: [0x07, 0x01], + 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 SENSEI_RAW_SAVE_COMMAND = [0x09, 0x00] as const; + +export const SENSEI_RAW_FIRMWARE_RESPONSE_LENGTH = 2; +export const SENSEI_RAW_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 SENSEI_RAW_DPI_MIN = 90; +export const SENSEI_RAW_DPI_MAX = 5670; +export const SENSEI_RAW_DPI_STEP = 90; + +/** rivalcfg `sensei_raw.py` defaults for the two presets. */ +export const SENSEI_RAW_DEFAULT_DPI_PRESET_1 = 1620; +export const SENSEI_RAW_DEFAULT_DPI_PRESET_2 = 3240; +export const SENSEI_RAW_DEFAULT_POLLING_RATE = 1000; + +export class SenseiRawProtocolError extends Error {} + +/** The 63 DPI values the linear encoding can express, ascending. */ +export function steelseriesSenseiRawDpiOptions(): number[] { + const options: number[] = []; + for (let dpi = SENSEI_RAW_DPI_MIN; dpi <= SENSEI_RAW_DPI_MAX; dpi += SENSEI_RAW_DPI_STEP) { + options.push(dpi); + } + return options; +} + +/** `input_range [90, 5670, 90]` -> `output_range [0x01, 0x3F, 1]`. */ +function encodeDpiByte(dpi: number): number { + if ( + !Number.isInteger(dpi) || + dpi < SENSEI_RAW_DPI_MIN || + dpi > SENSEI_RAW_DPI_MAX || + (dpi - SENSEI_RAW_DPI_MIN) % SENSEI_RAW_DPI_STEP !== 0 + ) { + throw new SenseiRawProtocolError( + `SteelSeries Sensei [RAW] DPI must be ${SENSEI_RAW_DPI_MIN}–${SENSEI_RAW_DPI_MAX.toLocaleString("en-US")} in ${SENSEI_RAW_DPI_STEP} DPI steps.`, + ); + } + return (dpi - SENSEI_RAW_DPI_MIN) / SENSEI_RAW_DPI_STEP + 1; +} + +/** `03 01 ` — sensitivity preset 1. */ +export function steelseriesSenseiRawEncodeSensitivity1(dpi: number): Uint8Array { + return new Uint8Array([...SENSEI_RAW_COMMAND.sensitivity1, encodeDpiByte(dpi)]); +} + +/** `03 02 ` — sensitivity preset 2. */ +export function steelseriesSenseiRawEncodeSensitivity2(dpi: number): Uint8Array { + return new Uint8Array([...SENSEI_RAW_COMMAND.sensitivity2, encodeDpiByte(dpi)]); +} + +/** `04 00 ` with 1000→0x01, 500→0x02, 250→0x03, 125→0x04. */ +export function steelseriesSenseiRawEncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new SenseiRawProtocolError("SteelSeries Sensei [RAW] supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...SENSEI_RAW_COMMAND.pollingRate, byte]); +} + +/** `09 00` — commit the current settings to onboard flash. */ +export function steelseriesSenseiRawSaveCommand(): Uint8Array { + return new Uint8Array(SENSEI_RAW_SAVE_COMMAND); +} + +/** `10 00` — the device answers with minor then major firmware bytes. */ +export function steelseriesSenseiRawFirmwareQuery(): Uint8Array { + return new Uint8Array(SENSEI_RAW_COMMAND.firmware); +} + +/** `16 00` — read the active preset index. */ +export function steelseriesSenseiRawActiveCpiQuery(): Uint8Array { + return new Uint8Array(SENSEI_RAW_COMMAND.activeCpiIndex); +} + +export interface SteelSeriesSenseiRawFirmware { + /** 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 steelseriesSenseiRawDecodeFirmware(payload: Uint8Array): SteelSeriesSenseiRawFirmware { + if (payload.length < SENSEI_RAW_FIRMWARE_RESPONSE_LENGTH) { + throw new SenseiRawProtocolError("SteelSeries Sensei [RAW] firmware response is shorter than two bytes."); + } + const bytes: [number, number] = [payload[0]!, payload[1]!]; + return { bytes, display: `${bytes[1]}.${bytes[0]}` }; +} + +// -- LED ------------------------------------------------------------------- + +/** `05 01 ` — LED brightness; off/low/medium/high. */ +export const SENSEI_RAW_BRIGHTNESS_TO_BYTE = { + off: 0x01, + low: 0x02, + medium: 0x03, + high: 0x04, +} as const; + +export type SenseiRawBrightness = keyof typeof SENSEI_RAW_BRIGHTNESS_TO_BYTE; + +/** `05 01 ` — LED brightness. */ +export function steelseriesSenseiRawEncodeLedBrightness(brightness: SenseiRawBrightness): Uint8Array { + const byte = SENSEI_RAW_BRIGHTNESS_TO_BYTE[brightness]; + if (byte === undefined) { + throw new SenseiRawProtocolError( + `SteelSeries Sensei [RAW] brightness must be one of: ${Object.keys(SENSEI_RAW_BRIGHTNESS_TO_BYTE).join(", ")}.`, + ); + } + return new Uint8Array([...SENSEI_RAW_COMMAND.ledBrightness, byte]); +} + +/** `07 01 ` — LED effect; steady/breath/trigger. */ +export const SENSEI_RAW_EFFECT_TO_BYTE = { + steady: 0x01, + breath: 0x03, + trigger: 0x05, +} as const; + +export type SenseiRawEffect = keyof typeof SENSEI_RAW_EFFECT_TO_BYTE; + +/** `07 01 ` — LED effect. */ +export function steelseriesSenseiRawEncodeLedEffect(effect: SenseiRawEffect): Uint8Array { + const byte = SENSEI_RAW_EFFECT_TO_BYTE[effect]; + if (byte === undefined) { + throw new SenseiRawProtocolError( + `SteelSeries Sensei [RAW] effect must be one of: ${Object.keys(SENSEI_RAW_EFFECT_TO_BYTE).join(", ")}.`, + ); + } + return new Uint8Array([...SENSEI_RAW_COMMAND.ledEffect, byte]); +} + +// -- Button mapping -------------------------------------------------------- + +/** + * 8 button slots with 3-byte fields. The offset order is rivalcfg's exact + * `buttons` table, where the physical buttons 6 and 7 are assigned in swap + * order (`button7` at 0x0F, `button6` at 0x12); the ids are sequential, so + * this codec keeps the rivalcfg offsets verbatim rather than "correcting" + * them. + */ +export const SENSEI_RAW_BUTTONS = { + button1: { id: 0x01, offset: 0x00 }, + button2: { id: 0x02, offset: 0x03 }, + button3: { id: 0x03, offset: 0x06 }, + button4: { id: 0x04, offset: 0x09 }, + button5: { id: 0x05, offset: 0x0c }, + button7: { id: 0x07, offset: 0x0f }, + button6: { id: 0x06, offset: 0x12 }, + button8: { id: 0x08, offset: 0x15 }, +} as const satisfies Record; + +export type SenseiRawButtonName = keyof typeof SENSEI_RAW_BUTTONS; + +const SENSEI_RAW_BUTTON_FIELD_LENGTH = 3; +const SENSEI_RAW_BUTTON_DISABLE = 0x00; +const SENSEI_RAW_BUTTON_DPI_SWITCH = 0x30; +const SENSEI_RAW_BUTTON_KEYBOARD = 0x10; +const SENSEI_RAW_BUTTON_MULTIMEDIA = 0x11; + +export type SenseiRawButtonAction = + | { type: "button"; target: SenseiRawButtonName } + | { type: "disabled" } + | { type: "dpiSwitch" } + | { 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 SenseiRawProtocolError(`${kind} codes must be integers 0–255.`); + } + return code; +} + +/** + * `31 00` + one 24-byte packet (8 slots × 3-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 steelseriesSenseiRawEncodeButtonsMapping( + mapping: Partial>, +): Uint8Array { + for (const name of Object.keys(mapping)) { + if (!Object.hasOwn(SENSEI_RAW_BUTTONS, name)) { + throw new SenseiRawProtocolError(`Unknown SteelSeries Sensei [RAW] button "${name}".`); + } + } + + const names = Object.keys(SENSEI_RAW_BUTTONS) as SenseiRawButtonName[]; + const packet = new Array(names.length * SENSEI_RAW_BUTTON_FIELD_LENGTH).fill(0x00); + + for (const [name, action] of Object.entries(mapping) as Array<[SenseiRawButtonName, SenseiRawButtonAction]>) { + const { offset } = SENSEI_RAW_BUTTONS[name]; + switch (action.type) { + case "button": { + if (!Object.hasOwn(SENSEI_RAW_BUTTONS, action.target)) { + throw new SenseiRawProtocolError(`Unknown SteelSeries Sensei [RAW] button "${action.target}".`); + } + packet[offset] = SENSEI_RAW_BUTTONS[action.target].id; + break; + } + case "disabled": { + packet[offset] = SENSEI_RAW_BUTTON_DISABLE; + break; + } + case "dpiSwitch": { + packet[offset] = SENSEI_RAW_BUTTON_DPI_SWITCH; + break; + } + case "keyboard": { + packet[offset] = SENSEI_RAW_BUTTON_KEYBOARD; + packet[offset + 1] = encodeKeyCode(action.code, "Keyboard scan"); + break; + } + case "multimedia": { + packet[offset] = SENSEI_RAW_BUTTON_MULTIMEDIA; + packet[offset + 1] = encodeKeyCode(action.code, "Multimedia key"); + break; + } + default: { + throw new SenseiRawProtocolError("Unsupported SteelSeries Sensei [RAW] button action."); + } + } + } + + return new Uint8Array([...SENSEI_RAW_COMMAND.buttonsMapping, ...packet]); +}