diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..e7384d0f 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -47,17 +47,29 @@ 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 { SteelSeriesRival110HidClient } from "./steelseries/rival-110-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"; 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"; 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 { SteelSeriesSenseiRivalProHidClient } from "./steelseries/sensei-rival-pro-hid.ts"; +import { SteelSeriesSensei310HidClient } from "./steelseries/sensei-310-hid.ts"; +import { SteelSeriesRival5HidClient } from "./steelseries/rival-5-hid.ts"; +import { SteelSeriesRival3Gen2HidClient } from "./steelseries/rival-3-v2-hid.ts"; +import { SteelSeriesRival3WirelessV2HidClient } from "./steelseries/rival-3-wireless-v2-hid.ts"; +import { SteelSeriesAerox3WirelessGen2HidClient } from "./steelseries/aerox-3-wireless-gen2-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,8 +95,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 | SteelSeriesSensei310HidClient | SteelSeriesPrimeMiniHidClient | SteelSeriesRival5HidClient | SteelSeriesRival3Gen2HidClient | SteelSeriesRival3WirelessV2HidClient | SteelSeriesAerox3WirelessGen2HidClient | SteelSeriesRival600HidClient | SteelSeriesRival100HidClient | SteelSeriesRival110HidClient | SteelSeriesRival300HidClient | 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; supports(device: HIDDevice): boolean; @@ -165,17 +176,29 @@ 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) => SteelSeriesRival110HidClient.isSupported(device), create: (device) => new SteelSeriesRival110HidClient(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 }, { 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 }, { 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: "SteelSeries", supports: (device) => SteelSeriesSenseiRivalProHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiRivalProHidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesSensei310HidClient.isSupported(device), create: (device) => new SteelSeriesSensei310HidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesRival5HidClient.isSupported(device), create: (device) => new SteelSeriesRival5HidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesRival3Gen2HidClient.isSupported(device), create: (device) => new SteelSeriesRival3Gen2HidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesRival3WirelessV2HidClient.isSupported(device), create: (device) => new SteelSeriesRival3WirelessV2HidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesAerox3WirelessGen2HidClient.isSupported(device), create: (device) => new SteelSeriesAerox3WirelessGen2HidClient(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/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/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/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/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/drivers/steelseries/rival-110-hid.test.ts b/src/drivers/steelseries/rival-110-hid.test.ts new file mode 100644 index 00000000..75f0751d --- /dev/null +++ b/src/drivers/steelseries/rival-110-hid.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesRival110HidClient } from "./rival-110-hid.ts"; + +function fakeDevice(options: { productId?: number; firmware?: number[]; activePreset?: number; answer?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const reply = (bytes: number[]) => { + const response = new Uint8Array(bytes); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + }; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x1729, + productName: "SteelSeries Rival 110", + opened: true, + collections: [], + open: async () => {}, + close: async () => {}, + sendReport: async (reportId: number, data: BufferSource) => { + const view = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : new Uint8Array(data as ArrayBuffer); + const payload = [...view]; + sent.push({ reportId, payload }); + if (options.answer === false) return; + if (payload[0] === 0x10 && payload[1] === 0x00) reply(options.firmware ?? [0x05, 0x02]); + if (payload[0] === 0x16 && payload[1] === 0x00) reply([options.activePreset ?? 0x01]); + }, + sendFeatureReport: async () => { throw new Error("the Rival 110 protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Rival 110 protocol does not use feature reports"); }, + addEventListener: (_type: string, attached: (event: HIDInputReportEvent) => void) => { listener = attached; }, + removeEventListener: () => { listener = null; }, + }; + return { device: device as unknown as HIDDevice, sent }; +} + +test("claims the Rival 110 family product ids and nothing else", () => { + const { device } = fakeDevice(); + for (const productId of [0x1729, 0x1816, 0x1810]) { + assert.equal( + SteelSeriesRival110HidClient.isSupported({ ...device, productId } as HIDDevice), + true, + `expected 0x${productId.toString(16)} to be claimed`, + ); + } + for (const productId of [0x1702, 0x1814, 0x1824, 0x1836]) { + assert.equal( + SteelSeriesRival110HidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesRival110HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus probes firmware and the active preset, reporting that preset's default", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x02 }); + const status = await new SteelSeriesRival110HidClient(device).readStatus(); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x10, 0x00] }, + { reportId: 0, payload: [0x16, 0x00] }, + ]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Rival 110"); + assert.deepEqual(status.firmware, ["2.5"]); + assert.equal(status.connectionType, "Wired"); + // Active preset is 2, which this session has not written, so its default is shown. + assert.equal(status.dpi, 1600); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.ui?.valuesVerified, false); + assert.ok(status.ui?.pollingNote?.includes("active preset (2)")); +}); + +test("a silent interface fails the firmware probe loudly", async () => { + const { device } = fakeDevice({ answer: false }); + const client = new SteelSeriesRival110HidClient(device); + await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); +}); + +test("setDpi writes the active preset (1 by default) plus save", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x01 }); + const client = new SteelSeriesRival110HidClient(device); + await client.readStatus(); + sent.length = 0; + const dpi = await client.setDpi(800); + assert.equal(dpi, 800); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x16, 0x00] }, + { reportId: 0, payload: [0x03, 0x01, 0x12] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); + +test("setDpi writes preset 2 when the CPI button has selected it", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x02 }); + const client = new SteelSeriesRival110HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setDpi(3200); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x16, 0x00] }, + { reportId: 0, payload: [0x03, 0x02, 0x49] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + // readStatus now reports the value written to the active preset. + sent.length = 0; + const status = await client.readStatus(); + assert.equal(status.dpi, 3200); +}); + +test("setDpiPreset2 writes and records preset 2", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x01 }); + const client = new SteelSeriesRival110HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setDpiPreset2(1600); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x03, 0x02, 0x24] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); + +test("setPollingRate, setLedColor, setLightEffect, and setButton6Mode write plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival110HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setPollingRate(500); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x04, 0x00, 0x02] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setLedColor(0xff, 0x18, 0x00); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x05, 0x00, 0xff, 0x18, 0x00] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setLightEffect("breath"); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x07, 0x00, 0x03] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setButton6Mode("os"); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x0b, 0x01] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); diff --git a/src/drivers/steelseries/rival-110-hid.ts b/src/drivers/steelseries/rival-110-hid.ts new file mode 100644 index 00000000..91bad516 --- /dev/null +++ b/src/drivers/steelseries/rival-110-hid.ts @@ -0,0 +1,249 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + RIVAL110_DEFAULT_DPI_PRESET_1, + RIVAL110_DEFAULT_DPI_PRESET_2, + RIVAL110_POLLING_RATES, + RIVAL110_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesRival110ActiveCpiQuery, + steelseriesRival110DecodeActivePreset, + steelseriesRival110DecodeFirmware, + steelseriesRival110DpiOptions, + steelseriesRival110EncodeButton6Mode, + steelseriesRival110EncodeLedColor, + steelseriesRival110EncodeLightEffect, + steelseriesRival110EncodePollingRate, + steelseriesRival110EncodeSensitivity1, + steelseriesRival110EncodeSensitivity2, + steelseriesRival110FirmwareQuery, + steelseriesRival110SaveCommand, + type Rival110Button6Mode, + type Rival110LightEffect, + type SteelSeriesRival110Firmware, +} from "@openmouse/protocol/steelseries"; + +/** rivalcfg's `command_approve_delay`, shared across every SteelSeries family. */ +const COMMAND_DELAY_MS = 50; +/** rivalcfg reads input-report replies with a 200 ms timeout; allow slack. */ +const RESPONSE_TIMEOUT_MS = 500; +const DEFAULT_POLLING_HZ = 1000; + +/** + * SteelSeries Rival 110 WebHID control (Rival 110 `1038:1729`, + * Rival 106 `1038:1816`, Rival 300S `1038:1810` — one shared vendor profile; + * see `../../steelseries/rival-110.ts`). + * + * DPI (two presets, `03 01`/`03 02`), polling, and the LED are write-only; + * the two readable values are the firmware version (`10 00`) and the active + * preset index (`16 00`). This client uses the firmware query as its + * connectivity probe, matching rivalcfg's `rival110.py`. + * + * Because the active preset index is readable, `setDpi` writes the + * **active** preset (so a change always takes effect, even after the CPI + * button has selected preset 2) rather than always preset 1, and + * `readStatus` reports that active preset's DPI. `setDpiPreset2` writes + * preset 2 explicitly. Both presets are tracked independently; a preset + * whose value this session has not written falls back to rivalcfg's + * documented default for that preset, with `valuesVerified: false`. + * + * This mouse has **no button remapping at all** — the codec exposes only the + * button-6 (CPI) mode toggle. Do not confuse this family with the Rival 100 / + * Rival 105, which uses a different (eight-step) DPI table. The config + * channel is hidapi interface 0; its WebHID collection shape has not been + * captured yet, so the picker offers every interface and the wrong ones fail + * the firmware probe loudly. + */ +export class SteelSeriesRival110HidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private listenerAttached = false; + private readonly inputWaiters = new Set<(payload: Uint8Array) => void>(); + /** Last DPI this session wrote to each preset, if any. */ + private readonly presetDpi: Record<1 | 2, number | null> = { 1: null, 2: null }; + private pollingRateHz: number | null = null; + + private readonly onInputReport = (event: HIDInputReportEvent): void => { + const payload = new Uint8Array( + event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength), + ); + for (const finish of [...this.inputWaiters]) finish(payload); + }; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== STEELSERIES_VENDOR_ID) return false; + return STEELSERIES_PRODUCTS.get(device.productId)?.family === "rival-110"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...RIVAL110_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesRival110DpiOptions(); } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + if (!this.listenerAttached) { + this.device.addEventListener("inputreport", this.onInputReport); + this.listenerAttached = true; + } + } + + async close(): Promise { + if (this.listenerAttached) { + this.device.removeEventListener("inputreport", this.onInputReport); + this.listenerAttached = false; + } + if (this.device.opened) await this.device.close(); + } + + async readStatus(): Promise { + return await this.run(async () => { + await this.open(); + const firmware = await this.probeFirmware(); + const activePreset = await this.readActivePreset(); + const product = STEELSERIES_PRODUCTS.get(this.device.productId); + const name = this.device.productName?.trim() || `SteelSeries ${product?.model ?? "Rival 110"}`; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-rival-110", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: `The Rival 110 cannot report the DPI of a preset this app has not written; the value shown is the last written by this app for the active preset (${activePreset}), or that preset's assumed default.`, + defaultDisplayName: "SteelSeries Rival 110", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: this.dpiForPreset(activePreset), + pollingRateHz: this.pollingRateHz ?? DEFAULT_POLLING_HZ, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + connectionType: "Wired", + liftOffDistance: null, + firmware: [firmware.display], + }; + }); + } + + /** + * Writes `dpi` to the **active** preset (`03 01` or `03 02`), so the change + * takes effect even after the CPI button has switched to preset 2. The + * active index is read first; if that read fails, this falls back to + * preset 1 (the pre-existing behavior) rather than aborting the write. + */ + async setDpi(dpi: number): Promise { + await this.run(async () => { + await this.open(); + const preset = await this.readActivePreset(); + await this.write(preset === 2 ? steelseriesRival110EncodeSensitivity2(dpi) : steelseriesRival110EncodeSensitivity1(dpi)); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival110SaveCommand()); + this.presetDpi[preset] = dpi; + }); + return dpi; + } + + /** `03 02 ` — writes sensitivity preset 2 explicitly. */ + async setDpiPreset2(dpi: number): Promise { + await this.commit(steelseriesRival110EncodeSensitivity2(dpi)); + this.presetDpi[2] = dpi; + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesRival110EncodePollingRate(pollingRateHz)); + this.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + async setLedColor(r: number, g: number, b: number): Promise { + await this.commit(steelseriesRival110EncodeLedColor(r, g, b)); + } + + async setLightEffect(effect: Rival110LightEffect): Promise { + await this.commit(steelseriesRival110EncodeLightEffect(effect)); + } + + /** `0B ` — CPI button mode. */ + async setButton6Mode(mode: Rival110Button6Mode): Promise { + await this.commit(steelseriesRival110EncodeButton6Mode(mode)); + } + + private async commit(report: Uint8Array): Promise { + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival110SaveCommand()); + }); + } + + /** The DPI to display for a preset: last written by this session, else that preset's rivalcfg default. */ + private dpiForPreset(preset: 1 | 2): number { + return this.presetDpi[preset] ?? (preset === 2 ? RIVAL110_DEFAULT_DPI_PRESET_2 : RIVAL110_DEFAULT_DPI_PRESET_1); + } + + /** + * `10 00` — two-byte firmware version. Doubles as the proof that the + * granted interface is the config channel. + */ + private async probeFirmware(): Promise { + const payload = await this.awaitResponse(steelseriesRival110FirmwareQuery()); + if (!payload) { + throw new Error("The Rival 110 did not answer the firmware query on this interface. Add the device again and choose another entry."); + } + return steelseriesRival110DecodeFirmware(payload); + } + + /** + * `16 00` — the active preset index (1 or 2). A failed or malformed reply + * falls back to preset 1 so a DPI write is never blocked by the probe. + */ + private async readActivePreset(): Promise<1 | 2> { + const payload = await this.awaitResponse(steelseriesRival110ActiveCpiQuery()); + if (!payload) return 1; + try { + return steelseriesRival110DecodeActivePreset(payload); + } catch { + return 1; + } + } + + /** Send a query and resolve its first input report, or null on timeout. */ + private async awaitResponse(query: Uint8Array): Promise { + const response = new Promise((resolve) => { + let timer: ReturnType; + const finish = (payload: Uint8Array | null): void => { + clearTimeout(timer); + this.inputWaiters.delete(finish as (payload: Uint8Array) => void); + resolve(payload); + }; + timer = setTimeout(() => finish(null), RESPONSE_TIMEOUT_MS); + this.inputWaiters.add(finish as (payload: Uint8Array) => void); + }); + await this.write(query); + return await response; + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(RIVAL110_REPORT_ID, payload.buffer as ArrayBuffer); + } + + private delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); + } + + private async run(operation: () => Promise): Promise { + const result = this.queue.then(operation, operation); + this.queue = result.then(() => undefined, () => undefined); + return await result; + } +} diff --git a/src/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/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/drivers/steelseries/rival-300-hid.test.ts b/src/drivers/steelseries/rival-300-hid.test.ts new file mode 100644 index 00000000..06772c8e --- /dev/null +++ b/src/drivers/steelseries/rival-300-hid.test.ts @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesRival300HidClient } from "./rival-300-hid.ts"; + +function fakeDevice(options: { productId?: number; firmware?: number[]; activePreset?: number; answer?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const reply = (bytes: number[]) => { + const response = new Uint8Array(bytes); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + }; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x1710, + productName: "SteelSeries Rival 300", + opened: true, + collections: [], + open: async () => {}, + close: async () => {}, + sendReport: async (reportId: number, data: BufferSource) => { + const view = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : new Uint8Array(data as ArrayBuffer); + const payload = [...view]; + sent.push({ reportId, payload }); + if (options.answer === false) return; + if (payload[0] === 0x10 && payload[1] === 0x00) reply(options.firmware ?? [0x03, 0x01]); + if (payload[0] === 0x16 && payload[1] === 0x00) reply([options.activePreset ?? 0x01]); + }, + sendFeatureReport: async () => { throw new Error("the Rival 300 protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Rival 300 protocol does not use feature reports"); }, + addEventListener: (_type: string, attached: (event: HIDInputReportEvent) => void) => { listener = attached; }, + removeEventListener: () => { listener = null; }, + }; + return { device: device as unknown as HIDDevice, sent }; +} + +test("claims the Rival 300 family product ids and nothing else", () => { + const { device } = fakeDevice(); + for (const productId of [0x1710, 0x1392, 0x1394, 0x1712, 0x1714, 0x1716, 0x1718, 0x171a, 0x171c]) { + assert.equal( + SteelSeriesRival300HidClient.isSupported({ ...device, productId } as HIDDevice), + true, + `expected 0x${productId.toString(16)} to be claimed`, + ); + } + for (const productId of [0x1729, 0x1702, 0x1814, 0x1824, 0x1836]) { + assert.equal( + SteelSeriesRival300HidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesRival300HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus probes firmware and the active preset, reporting that preset's default", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x02 }); + const status = await new SteelSeriesRival300HidClient(device).readStatus(); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x10, 0x00] }, + { reportId: 0, payload: [0x16, 0x00] }, + ]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Rival 300"); + assert.deepEqual(status.firmware, ["1.3"]); + assert.equal(status.connectionType, "Wired"); + // Active preset is 2, which this session has not written, so its default is shown. + assert.equal(status.dpi, 1600); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.ui?.valuesVerified, false); + assert.ok(status.ui?.pollingNote?.includes("active preset (2)")); +}); + +test("a silent interface fails the firmware probe loudly", async () => { + const { device } = fakeDevice({ answer: false }); + const client = new SteelSeriesRival300HidClient(device); + await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); +}); + +test("setDpi writes the active preset (1 by default) plus save", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x01 }); + const client = new SteelSeriesRival300HidClient(device); + await client.readStatus(); + sent.length = 0; + const dpi = await client.setDpi(800); + assert.equal(dpi, 800); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x16, 0x00] }, + { reportId: 0, payload: [0x03, 0x01, 0x10] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); + +test("setDpi writes preset 2 when the CPI button has selected it", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x02 }); + const client = new SteelSeriesRival300HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setDpi(1600); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x16, 0x00] }, + { reportId: 0, payload: [0x03, 0x02, 0x20] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + // readStatus now reports the value written to the active preset. + sent.length = 0; + const status = await client.readStatus(); + assert.equal(status.dpi, 1600); +}); + +test("setDpiPreset2 writes and records preset 2", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x01 }); + const client = new SteelSeriesRival300HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setDpiPreset2(1600); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x03, 0x02, 0x20] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); + +test("setPollingRate, LED color/effect, and buttons mapping write plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesRival300HidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setPollingRate(500); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x04, 0x00, 0x02] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setLedColor("wheel", 0, 0, 0xff); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x08, 0x02, 0x00, 0x00, 0xff] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setLedEffect("logo", "breath"); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x07, 0x01, 0x03] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setButtonsMapping({ button6: { type: "dpiSwitch" } }); + assert.equal(sent.length, 2); + assert.equal(sent[0]?.payload[0], 0x31); + assert.equal(sent[0]?.payload.length, 2 + 6 * 5); + assert.equal(sent[0]?.payload[2 + 5 * 5], 0x30); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x09, 0x00] }); +}); diff --git a/src/drivers/steelseries/rival-300-hid.ts b/src/drivers/steelseries/rival-300-hid.ts new file mode 100644 index 00000000..38b3227f --- /dev/null +++ b/src/drivers/steelseries/rival-300-hid.ts @@ -0,0 +1,251 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + RIVAL300_DEFAULT_DPI_PRESET_1, + RIVAL300_DEFAULT_DPI_PRESET_2, + RIVAL300_POLLING_RATES, + RIVAL300_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesRival300ActiveCpiQuery, + steelseriesRival300DecodeActivePreset, + steelseriesRival300DecodeFirmware, + steelseriesRival300DpiOptions, + steelseriesRival300EncodeButtonsMapping, + steelseriesRival300EncodeLedColor, + steelseriesRival300EncodeLedEffect, + steelseriesRival300EncodePollingRate, + steelseriesRival300EncodeSensitivity1, + steelseriesRival300EncodeSensitivity2, + steelseriesRival300FirmwareQuery, + steelseriesRival300SaveCommand, + type Rival300ButtonAction, + type Rival300ButtonName, + type Rival300Led, + type Rival300LightEffect, + type SteelSeriesRival300Firmware, +} from "@openmouse/protocol/steelseries"; + +/** rivalcfg's `command_approve_delay`, shared across every SteelSeries family. */ +const COMMAND_DELAY_MS = 50; +/** rivalcfg reads input-report replies with a 200 ms timeout; allow slack. */ +const RESPONSE_TIMEOUT_MS = 500; +const DEFAULT_POLLING_HZ = 1000; + +/** + * SteelSeries Rival 300 WebHID control (Rival 300 `1038:1710` and its + * editions Fallout 4 `1038:1712`, Acer Predator `1038:1714`, CS:GO Fade + * `1038:1394` / stm32 `1038:1716`, HP OMEN `1038:1718`, CS:GO Hyper Beast + * `1038:171A`, Evil Geniuses `1038:171C`, plus the original Rival Dota 2 + * `1038:1392` — one shared `rival_base` vendor profile; see + * `../../steelseries/rival-300.ts`). + * + * DPI (two presets, `03 01`/`03 02`), polling, both LED zones, and the six + * buttons are write-only; the two readable values are the firmware version + * (`10 00`) and the active preset index (`16 00`). This client uses the + * firmware query as its connectivity probe, matching rivalcfg's + * `rival300.py`. + * + * Because the active preset index is readable, `setDpi` writes the + * **active** preset (so a change always takes effect, even after the CPI + * button has selected preset 2) rather than always preset 1, and + * `readStatus` reports that active preset's DPI. `setDpiPreset2` writes + * preset 2 explicitly. Both presets are tracked independently; a preset + * whose value this session has not written falls back to rivalcfg's + * documented default for that preset, with `valuesVerified: false`. + * + * The config channel is hidapi interface 0; its WebHID collection shape has + * not been captured yet, so the picker offers every interface and the wrong + * ones fail the firmware probe loudly. + */ +export class SteelSeriesRival300HidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private listenerAttached = false; + private readonly inputWaiters = new Set<(payload: Uint8Array) => void>(); + /** Last DPI this session wrote to each preset, if any. */ + private readonly presetDpi: Record<1 | 2, number | null> = { 1: null, 2: null }; + private pollingRateHz: number | null = null; + + private readonly onInputReport = (event: HIDInputReportEvent): void => { + const payload = new Uint8Array( + event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength), + ); + for (const finish of [...this.inputWaiters]) finish(payload); + }; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== STEELSERIES_VENDOR_ID) return false; + return STEELSERIES_PRODUCTS.get(device.productId)?.family === "rival-300"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...RIVAL300_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesRival300DpiOptions(); } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + if (!this.listenerAttached) { + this.device.addEventListener("inputreport", this.onInputReport); + this.listenerAttached = true; + } + } + + async close(): Promise { + if (this.listenerAttached) { + this.device.removeEventListener("inputreport", this.onInputReport); + this.listenerAttached = false; + } + if (this.device.opened) await this.device.close(); + } + + async readStatus(): Promise { + return await this.run(async () => { + await this.open(); + const firmware = await this.probeFirmware(); + const activePreset = await this.readActivePreset(); + const product = STEELSERIES_PRODUCTS.get(this.device.productId); + const name = this.device.productName?.trim() || `SteelSeries ${product?.model ?? "Rival 300"}`; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-rival-300", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: `The Rival 300 cannot report the DPI of a preset this app has not written; the value shown is the last written by this app for the active preset (${activePreset}), or that preset's assumed default.`, + defaultDisplayName: "SteelSeries Rival 300", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: this.dpiForPreset(activePreset), + pollingRateHz: this.pollingRateHz ?? DEFAULT_POLLING_HZ, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + connectionType: "Wired", + liftOffDistance: null, + firmware: [firmware.display], + }; + }); + } + + /** + * Writes `dpi` to the **active** preset (`03 01` or `03 02`), so the change + * takes effect even after the CPI button has switched to preset 2. The + * active index is read first; if that read fails, this falls back to + * preset 1 (the pre-existing behavior) rather than aborting the write. + */ + async setDpi(dpi: number): Promise { + await this.run(async () => { + await this.open(); + const preset = await this.readActivePreset(); + await this.write(preset === 2 ? steelseriesRival300EncodeSensitivity2(dpi) : steelseriesRival300EncodeSensitivity1(dpi)); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival300SaveCommand()); + this.presetDpi[preset] = dpi; + }); + return dpi; + } + + /** `03 02 ` — writes sensitivity preset 2 explicitly. */ + async setDpiPreset2(dpi: number): Promise { + await this.commit(steelseriesRival300EncodeSensitivity2(dpi)); + this.presetDpi[2] = dpi; + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesRival300EncodePollingRate(pollingRateHz)); + this.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + async setLedColor(led: Rival300Led, r: number, g: number, b: number): Promise { + await this.commit(steelseriesRival300EncodeLedColor(led, r, g, b)); + } + + async setLedEffect(led: Rival300Led, effect: Rival300LightEffect): Promise { + await this.commit(steelseriesRival300EncodeLedEffect(led, effect)); + } + + async setButtonsMapping(mapping: Partial>): Promise { + await this.commit(steelseriesRival300EncodeButtonsMapping(mapping)); + } + + private async commit(report: Uint8Array): Promise { + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesRival300SaveCommand()); + }); + } + + /** The DPI to display for a preset: last written by this session, else that preset's rivalcfg default. */ + private dpiForPreset(preset: 1 | 2): number { + return this.presetDpi[preset] ?? (preset === 2 ? RIVAL300_DEFAULT_DPI_PRESET_2 : RIVAL300_DEFAULT_DPI_PRESET_1); + } + + /** + * `10 00` — two-byte firmware version. Doubles as the proof that the + * granted interface is the config channel. + */ + private async probeFirmware(): Promise { + const payload = await this.awaitResponse(steelseriesRival300FirmwareQuery()); + if (!payload) { + throw new Error("The Rival 300 did not answer the firmware query on this interface. Add the device again and choose another entry."); + } + return steelseriesRival300DecodeFirmware(payload); + } + + /** + * `16 00` — the active preset index (1 or 2). A failed or malformed reply + * falls back to preset 1 so a DPI write is never blocked by the probe. + */ + private async readActivePreset(): Promise<1 | 2> { + const payload = await this.awaitResponse(steelseriesRival300ActiveCpiQuery()); + if (!payload) return 1; + try { + return steelseriesRival300DecodeActivePreset(payload); + } catch { + return 1; + } + } + + /** Send a query and resolve its first input report, or null on timeout. */ + private async awaitResponse(query: Uint8Array): Promise { + const response = new Promise((resolve) => { + let timer: ReturnType; + const finish = (payload: Uint8Array | null): void => { + clearTimeout(timer); + this.inputWaiters.delete(finish as (payload: Uint8Array) => void); + resolve(payload); + }; + timer = setTimeout(() => finish(null), RESPONSE_TIMEOUT_MS); + this.inputWaiters.add(finish as (payload: Uint8Array) => void); + }); + await this.write(query); + return await response; + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(RIVAL300_REPORT_ID, payload.buffer as ArrayBuffer); + } + + private delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); + } + + private async run(operation: () => Promise): Promise { + const result = this.queue.then(operation, operation); + this.queue = result.then(() => undefined, () => undefined); + return await result; + } +} diff --git a/src/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/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/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/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/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/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/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..7482927d 100644 --- a/src/steelseries/devices.ts +++ b/src/steelseries/devices.ts @@ -9,10 +9,18 @@ * 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 + * 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 +29,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 @@ -50,23 +58,37 @@ * 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. + * + * 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; export type SteelSeriesProtocolFamily = | "rival3" + | "rival3-v2" + | "aerox3-wireless-gen2" + | "rival-100" + | "rival-110" + | "rival-300" | "rival310" | "aerox3" | "aerox3-wireless" @@ -74,10 +96,17 @@ export type SteelSeriesProtocolFamily = | "aerox5" | "aerox5-wireless" | "rival650" + | "rival-600" | "aerox9-wireless" | "prime-plus" | "prime-mini-wireless" - | "sensei-ten"; + | "sensei-ten" + | "sensei-rival-pro" + | "sensei-310" + | "prime-mini" + | "rival-5" + | "rival3-wireless-v2" + | "sensei-raw"; export interface SteelSeriesProduct { model: string; @@ -100,6 +129,223 @@ 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, + }], + // 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, + }], + // 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, + }], + [0x1362, { + model: "Sensei [RAW] Diablo III Edition", + family: "sensei-raw", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + // 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, + }], + [0x136d, { + model: "Sensei [RAW] Guild Wars 2 Edition", + family: "sensei-raw", + 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, + }], + [0x136f, { + model: "Sensei [RAW] Call of Duty: Black Ops II Edition", + family: "sensei-raw", + 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, + }], + [0x1380, { + model: "Sensei [RAW] World of Tanks Edition", + family: "sensei-raw", + 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, + }], + [0x1816, { + model: "Rival 106", + family: "rival-110", + 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, + }], + [0x170a, { + model: "Rival 100 Dell China Edition", + family: "rival-100", + 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, + }], + [0x170b, { + model: "Rival 100 Dota 2 Edition (Retail)", + family: "rival-100", + 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, + }], + [0x170c, { + model: "Rival 100 Dota 2 Edition (Lenovo)", + family: "rival-100", + 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, + }], + // 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, + }], + [0x171a, { + model: "Rival 300 CS:GO Hyper Beast Edition", + family: "rival-300", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1810, { + model: "Rival 300S", + family: "rival-110", + 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, + }], + [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", @@ -119,6 +365,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", @@ -156,6 +414,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", @@ -175,6 +453,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", @@ -261,6 +551,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 @@ -311,6 +622,47 @@ 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, + }], + // 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 @@ -362,6 +714,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 @@ -383,4 +756,47 @@ 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, + }], + // 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, + }], + // 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..59724c8a 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -1,4 +1,7 @@ export * from "./rival3.js"; +export * from "./rival-100.js"; +export * from "./rival-110.js"; +export * from "./rival-300.js"; export * from "./rival310.js"; export * from "./aerox3.js"; export * from "./aerox3-wireless.js"; @@ -6,8 +9,17 @@ 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"; export * from "./sensei-ten.js"; +export * from "./sensei-rival-pro.js"; +export * from "./sensei-310.js"; +export * from "./prime-mini.js"; +export * from "./rival-5.js"; +export * from "./rival-3-v2.js"; +export * from "./rival-3-wireless-v2.js"; +export * from "./aerox-3-wireless-gen2.js"; +export * from "./sensei-raw.js"; export * from "./devices.js"; 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-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]); +} 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 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; diff --git a/src/steelseries/rival-110.test.ts b/src/steelseries/rival-110.test.ts new file mode 100644 index 00000000..6d2eecbc --- /dev/null +++ b/src/steelseries/rival-110.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RIVAL110_BUTTON6_MODE_TO_BYTE, + RIVAL110_DEFAULT_DPI_PRESET_1, + RIVAL110_DEFAULT_DPI_PRESET_2, + RIVAL110_DEFAULT_POLLING_RATE, + RIVAL110_DPI_MAX, + RIVAL110_DPI_MIN, + RIVAL110_DPI_TO_BYTE, + RIVAL110_FIRMWARE_RESPONSE_LENGTH, + RIVAL110_POLLING_RATES, + Rival110ProtocolError, + steelseriesRival110ActiveCpiQuery, + steelseriesRival110DecodeActivePreset, + steelseriesRival110DecodeFirmware, + steelseriesRival110DpiOptions, + steelseriesRival110EncodeButton6Mode, + steelseriesRival110EncodeLedColor, + steelseriesRival110EncodeLightEffect, + steelseriesRival110EncodePollingRate, + steelseriesRival110EncodeSensitivity1, + steelseriesRival110EncodeSensitivity2, + steelseriesRival110FirmwareQuery, + steelseriesRival110SaveCommand, +} from "./rival-110.ts"; + +test("DPI table is the PMW3326 curve, including 0x49 for 3200", () => { + const options = steelseriesRival110DpiOptions(); + assert.equal(options[0], RIVAL110_DPI_MIN); + assert.equal(options.at(-1), RIVAL110_DPI_MAX); + assert.equal(options.length, (RIVAL110_DPI_MAX - RIVAL110_DPI_MIN) / 100 + 1); + assert.equal(RIVAL110_DPI_TO_BYTE.get(400), 0x08); + assert.equal(RIVAL110_DPI_TO_BYTE.get(800), 0x12); + assert.equal(RIVAL110_DPI_TO_BYTE.get(3200), 0x49); + assert.equal(RIVAL110_DPI_TO_BYTE.get(7200), 0xa7); + assert.equal(RIVAL110_DEFAULT_DPI_PRESET_1, 800); + assert.equal(RIVAL110_DEFAULT_DPI_PRESET_2, 1600); + assert.equal(RIVAL110_DEFAULT_POLLING_RATE, 1000); +}); + +test("encodes both sensitivity presets with the PMW3326 byte", () => { + assert.deepEqual([...steelseriesRival110EncodeSensitivity1(200)], [0x03, 0x01, 0x04]); + assert.deepEqual([...steelseriesRival110EncodeSensitivity1(800)], [0x03, 0x01, 0x12]); + assert.deepEqual([...steelseriesRival110EncodeSensitivity2(1600)], [0x03, 0x02, 0x24]); + assert.deepEqual([...steelseriesRival110EncodeSensitivity2(7200)], [0x03, 0x02, 0xa7]); + // Off-table values are rejected (no 250 step, unlike the Rival 100 family). + assert.throws(() => steelseriesRival110EncodeSensitivity1(250), Rival110ProtocolError); + assert.throws(() => steelseriesRival110EncodeSensitivity2(7300), Rival110ProtocolError); +}); + +test("encodes polling rates with 1000->0x01 down to 125->0x04", () => { + assert.deepEqual([...steelseriesRival110EncodePollingRate(1000)], [0x04, 0x00, 0x01]); + assert.deepEqual([...steelseriesRival110EncodePollingRate(500)], [0x04, 0x00, 0x02]); + assert.deepEqual([...steelseriesRival110EncodePollingRate(250)], [0x04, 0x00, 0x03]); + assert.deepEqual([...steelseriesRival110EncodePollingRate(125)], [0x04, 0x00, 0x04]); + assert.deepEqual([...RIVAL110_POLLING_RATES], [125, 250, 500, 1000]); + assert.throws(() => steelseriesRival110EncodePollingRate(2000), Rival110ProtocolError); +}); + +test("save, firmware, and active-CPI queries", () => { + assert.deepEqual([...steelseriesRival110SaveCommand()], [0x09, 0x00]); + assert.deepEqual([...steelseriesRival110FirmwareQuery()], [0x10, 0x00]); + assert.deepEqual([...steelseriesRival110ActiveCpiQuery()], [0x16, 0x00]); + assert.equal(RIVAL110_FIRMWARE_RESPONSE_LENGTH, 2); + assert.deepEqual(steelseriesRival110DecodeFirmware(new Uint8Array([0x05, 0x02])), { + bytes: [0x05, 0x02], + display: "2.5", + }); + assert.throws(() => steelseriesRival110DecodeFirmware(new Uint8Array([0x05])), Rival110ProtocolError); + // Active-preset reply: report-id stripped, so the first byte is the level. + assert.equal(steelseriesRival110DecodeActivePreset(new Uint8Array([0x01, 0x00])), 1); + assert.equal(steelseriesRival110DecodeActivePreset(new Uint8Array([0x02, 0x00])), 2); + assert.throws(() => steelseriesRival110DecodeActivePreset(new Uint8Array([0x03])), Rival110ProtocolError); + assert.throws(() => steelseriesRival110DecodeActivePreset(new Uint8Array([])), Rival110ProtocolError); +}); + +test("LED color, effect, and button-6 mode", () => { + assert.deepEqual([...steelseriesRival110EncodeLedColor(0xff, 0x18, 0x00)], [0x05, 0x00, 0xff, 0x18, 0x00]); + assert.throws(() => steelseriesRival110EncodeLedColor(0, -1, 0), Rival110ProtocolError); + assert.deepEqual([...steelseriesRival110EncodeLightEffect("steady")], [0x07, 0x00, 0x01]); + assert.deepEqual([...steelseriesRival110EncodeLightEffect("breath")], [0x07, 0x00, 0x03]); + assert.throws(() => steelseriesRival110EncodeLightEffect("rainbow" as never), Rival110ProtocolError); + assert.deepEqual([...steelseriesRival110EncodeButton6Mode("dpi")], [0x0b, 0x00]); + assert.deepEqual([...steelseriesRival110EncodeButton6Mode("os")], [0x0b, 0x01]); + assert.throws(() => steelseriesRival110EncodeButton6Mode("x" as never), Rival110ProtocolError); + assert.deepEqual(RIVAL110_BUTTON6_MODE_TO_BYTE, { dpi: 0x00, os: 0x01 }); +}); diff --git a/src/steelseries/rival-110.ts b/src/steelseries/rival-110.ts new file mode 100644 index 00000000..b6b0bcf7 --- /dev/null +++ b/src/steelseries/rival-110.ts @@ -0,0 +1,249 @@ +/** + * SteelSeries Rival 110 configuration protocol — pure encode/decode helpers. + * + * Reconstructed from the vendor's own software, not from a third-party + * reimplementation: SteelSeries GG 121.0.0 ships `rival-110.lisp`, + * `rival-106.lisp` and `rival-300s.lisp`, each of which `include "rival-100"` + * (so the command bytes are Rival 100's) and override only the firmware + * struct. This module transcribes that command set. Those vendor files are + * OpenPGP-armored ("DESCRIPTOR" armor, SKESK/AES-128). rivalcfg groups these + * three product ids under `rival110.py` (with `rival300s.py` reusing it) and + * independently corroborates every command byte. + * + * ## Why this is a separate family from `./rival-100.ts` + * + * The Rival 110/106/300S share the Rival 100's command bytes but use the + * **PixArt PMW3326** sensor, not the Rival 100's eight-step sensor. The + * vendor files state the DPI byte but no table, so rivalcfg's + * `dpi/pmw3326.py` supplies it (200–7200 in 100 DPI steps, the "linear-like" + * sensor curve — notably `0x49`→3200, matching the vendor TrueMove Core + * table in `./rival-3-v2.ts`). Off-table values are rejected. The Rival 100 + * module's eight-value choice table does not apply here and vice versa, + * which is exactly why these are two modules. + * + * ## Protocol + * + * Every command is an HID **output report** with report id 0x00. The vendor + * file says 33-byte reports (`HIDIO 33`); rivalcfg sets a 32-byte + * `packet_length` for these models. That padding difference is a transport + * detail (the driver sends the command bytes), noted here rather than + * encoded. + * + * DPI uses two per-level commands (`03 01`/`03 02`). Polling (`04 00`) is + * write-only (1000→0x01, 500→0x02, 250→0x03, 125→0x04). Lighting is a single + * zone: color (`05 00` + RGB) and effect (`07 00` + steady/breath), with the + * color-sequencing template leaving the brightness byte zero. Button 6 (the + * CPI button under the wheel) has a mode toggle (`0B`): hardware DPI switch + * or an OS-bound button. There is **no button remapping** on this family. + * + * The two readable values are the firmware version (`10 00`, two bytes, + * `fw_minor` then `fw_major`) and the active preset index (`16 00`). + * + * Settings apply immediately; the save command (`09 00`) persists them to + * onboard flash. None of this has been verified on physical hardware by this + * project. + */ + +export const RIVAL110_REPORT_ID = 0x00; + +/** Command prefixes; the payload is the prefix plus its arguments. */ +export const RIVAL110_COMMAND = { + sensitivity1: [0x03, 0x01], + sensitivity2: [0x03, 0x02], + pollingRate: [0x04, 0x00], + ledColor: [0x05, 0x00], + ledPulsation: [0x07, 0x00], + button6Mode: [0x0b], + save: [0x09, 0x00], + firmware: [0x10, 0x00], + activeCpiIndex: [0x16, 0x00], +} as const; + +/** `09 00` — commit the current settings to onboard flash. */ +export const RIVAL110_SAVE_COMMAND = [0x09, 0x00] as const; + +export const RIVAL110_FIRMWARE_RESPONSE_LENGTH = 2; + +export const RIVAL110_POLLING_RATES = [125, 250, 500, 1000] as const; + +const POLLING_RATE_TO_BYTE: ReadonlyMap = new Map([ + [1000, 0x01], + [500, 0x02], + [250, 0x03], + [125, 0x04], +]); + +/** + * The PixArt PMW3326 DPI curve, from rivalcfg's `dpi/pmw3326.py` (the vendor + * files give no table). 200–7200 in 100 DPI steps. + */ +export const RIVAL110_DPI_TO_BYTE: ReadonlyMap = new Map([ + [200, 0x04], [300, 0x06], [400, 0x08], [500, 0x0b], [600, 0x0d], [700, 0x0f], + [800, 0x12], [900, 0x14], [1000, 0x16], [1100, 0x19], [1200, 0x1b], [1300, 0x1d], + [1400, 0x20], [1500, 0x22], [1600, 0x24], [1700, 0x27], [1800, 0x29], [1900, 0x2b], + [2000, 0x2e], [2100, 0x30], [2200, 0x32], [2300, 0x34], [2400, 0x37], [2500, 0x39], + [2600, 0x3b], [2700, 0x3e], [2800, 0x40], [2900, 0x42], [3000, 0x45], [3100, 0x47], + [3200, 0x49], [3300, 0x4c], [3400, 0x4e], [3500, 0x50], [3600, 0x53], [3700, 0x55], + [3800, 0x57], [3900, 0x5a], [4000, 0x5c], [4100, 0x5e], [4200, 0x61], [4300, 0x63], + [4400, 0x65], [4500, 0x68], [4600, 0x6a], [4700, 0x6c], [4800, 0x6f], [4900, 0x71], + [5000, 0x73], [5100, 0x76], [5200, 0x78], [5300, 0x7a], [5400, 0x7d], [5500, 0x7f], + [5600, 0x81], [5700, 0x84], [5800, 0x86], [5900, 0x88], [6000, 0x8b], [6100, 0x8d], + [6200, 0x8f], [6300, 0x92], [6400, 0x94], [6500, 0x96], [6600, 0x99], [6700, 0x9b], + [6800, 0x9d], [6900, 0xa0], [7000, 0xa2], [7100, 0xa4], [7200, 0xa7], +]); + +/** rivalcfg `rival110.py` defaults for the two presets. */ +export const RIVAL110_DEFAULT_DPI_PRESET_1 = 800; +export const RIVAL110_DEFAULT_DPI_PRESET_2 = 1600; +export const RIVAL110_DEFAULT_POLLING_RATE = 1000; + +export class Rival110ProtocolError extends Error {} + +/** The PMW3326 DPI curve supports 200–7200 in 100 DPI steps. */ +export const RIVAL110_DPI_MIN = 200; +export const RIVAL110_DPI_MAX = 7200; + +/** The 72 supported DPI values, ascending. */ +export function steelseriesRival110DpiOptions(): number[] { + return [...RIVAL110_DPI_TO_BYTE.keys()].sort((a, b) => a - b); +} + +function encodeDpiByte(dpi: number): number { + const byte = RIVAL110_DPI_TO_BYTE.get(dpi); + if (byte === undefined) { + throw new Rival110ProtocolError( + `SteelSeries Rival 110 DPI must be ${RIVAL110_DPI_MIN}–${RIVAL110_DPI_MAX.toLocaleString("en-US")} in 100 DPI steps.`, + ); + } + return byte; +} + +/** `03 01 ` — sensitivity preset 1. */ +export function steelseriesRival110EncodeSensitivity1(dpi: number): Uint8Array { + return new Uint8Array([...RIVAL110_COMMAND.sensitivity1, encodeDpiByte(dpi)]); +} + +/** `03 02 ` — sensitivity preset 2. */ +export function steelseriesRival110EncodeSensitivity2(dpi: number): Uint8Array { + return new Uint8Array([...RIVAL110_COMMAND.sensitivity2, encodeDpiByte(dpi)]); +} + +/** `04 00 ` with 1000→0x01, 500→0x02, 250→0x03, 125→0x04. */ +export function steelseriesRival110EncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new Rival110ProtocolError("SteelSeries Rival 110 supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...RIVAL110_COMMAND.pollingRate, byte]); +} + +/** `09 00` — commit the current settings to onboard flash. */ +export function steelseriesRival110SaveCommand(): Uint8Array { + return new Uint8Array(RIVAL110_SAVE_COMMAND); +} + +/** `10 00` — the device answers with minor then major firmware bytes. */ +export function steelseriesRival110FirmwareQuery(): Uint8Array { + return new Uint8Array(RIVAL110_COMMAND.firmware); +} + +/** `16 00` — read the active preset index. */ +export function steelseriesRival110ActiveCpiQuery(): Uint8Array { + return new Uint8Array(RIVAL110_COMMAND.activeCpiIndex); +} + +/** + * Decode the active-preset reply. The specification's `resolution_level` + * incoming struct is `report_id` then `level`, and the reply's report id is + * not surfaced by WebHID, so the level is the first byte. The device reports + * it 1-based (values 0x01/0x02); anything else is rejected rather than + * guessed at. + */ +export function steelseriesRival110DecodeActivePreset(payload: Uint8Array): 1 | 2 { + const level = payload[0]; + if (level !== 0x01 && level !== 0x02) { + throw new Rival110ProtocolError( + `SteelSeries Rival 110 active preset reply was not 1 or 2 (got ${level === undefined ? "nothing" : `0x${level.toString(16)}`}).`, + ); + } + return level; +} + +export interface SteelSeriesRival110Firmware { + /** The two raw response bytes, in the order the device sent them (minor, major). */ + bytes: [number, number]; + /** The version the two bytes describe, major first, e.g. "1.3". */ + display: string; +} + +/** + * Decode the two-byte firmware response. The specification's incoming + * struct orders the fields `fw_minor` then `fw_major`, so the display string + * puts them back in major.minor order. + */ +export function steelseriesRival110DecodeFirmware(payload: Uint8Array): SteelSeriesRival110Firmware { + if (payload.length < RIVAL110_FIRMWARE_RESPONSE_LENGTH) { + throw new Rival110ProtocolError("SteelSeries Rival 110 firmware response is shorter than two bytes."); + } + const bytes: [number, number] = [payload[0]!, payload[1]!]; + return { bytes, display: `${bytes[1]}.${bytes[0]}` }; +} + +// -- LED ------------------------------------------------------------------- + +function encodeRgb(r: number, g: number, b: number): [number, number, number] { + for (const channel of [r, g, b]) { + if (!Number.isInteger(channel) || channel < 0 || channel > 255) { + throw new Rival110ProtocolError("RGB channels must be integers 0–255."); + } + } + return [r, g, b]; +} + +/** + * `05 00 ` — the single LED zone color. The vendor struct also + * carries a `brightness` byte, but both the vendor's own color-sequencing + * template and rivalcfg's `rgbcolor` leave it zero, so this codec matches + * them rather than inventing a brightness semantic. + */ +export function steelseriesRival110EncodeLedColor(r: number, g: number, b: number): Uint8Array { + return new Uint8Array([...RIVAL110_COMMAND.ledColor, ...encodeRgb(r, g, b)]); +} + +/** `07 00 ` — lighting effect. */ +export const RIVAL110_LIGHT_EFFECT_TO_BYTE = { + steady: 0x01, + breath: 0x03, +} as const; + +export type Rival110LightEffect = keyof typeof RIVAL110_LIGHT_EFFECT_TO_BYTE; + +/** `07 00 ` — steady or breath. */ +export function steelseriesRival110EncodeLightEffect(effect: Rival110LightEffect): Uint8Array { + const byte = RIVAL110_LIGHT_EFFECT_TO_BYTE[effect]; + if (byte === undefined) { + throw new Rival110ProtocolError( + `SteelSeries Rival 110 light effect must be one of: ${Object.keys(RIVAL110_LIGHT_EFFECT_TO_BYTE).join(", ")}.`, + ); + } + return new Uint8Array([...RIVAL110_COMMAND.ledPulsation, byte]); +} + +/** `0B ` — the button-6 (CPI) button mode. */ +export const RIVAL110_BUTTON6_MODE_TO_BYTE = { + dpi: 0x00, + os: 0x01, +} as const; + +export type Rival110Button6Mode = keyof typeof RIVAL110_BUTTON6_MODE_TO_BYTE; + +/** `0B ` — dpi or os. */ +export function steelseriesRival110EncodeButton6Mode(mode: Rival110Button6Mode): Uint8Array { + const byte = RIVAL110_BUTTON6_MODE_TO_BYTE[mode]; + if (byte === undefined) { + throw new Rival110ProtocolError( + `SteelSeries Rival 110 button-6 mode must be one of: ${Object.keys(RIVAL110_BUTTON6_MODE_TO_BYTE).join(", ")}.`, + ); + } + return new Uint8Array([...RIVAL110_COMMAND.button6Mode, byte]); +} 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]); +} 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]); +} diff --git a/src/steelseries/rival-300.test.ts b/src/steelseries/rival-300.test.ts new file mode 100644 index 00000000..b434b003 --- /dev/null +++ b/src/steelseries/rival-300.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RIVAL300_DEFAULT_DPI_PRESET_1, + RIVAL300_DEFAULT_DPI_PRESET_2, + RIVAL300_DEFAULT_POLLING_RATE, + RIVAL300_DPI_MAX, + RIVAL300_DPI_MIN, + RIVAL300_POLLING_RATES, + Rival300ProtocolError, + steelseriesRival300ActiveCpiQuery, + steelseriesRival300DecodeActivePreset, + steelseriesRival300DecodeFirmware, + steelseriesRival300DpiOptions, + steelseriesRival300EncodeButtonsMapping, + steelseriesRival300EncodeLedColor, + steelseriesRival300EncodeLedEffect, + steelseriesRival300EncodePollingRate, + steelseriesRival300EncodeSensitivity1, + steelseriesRival300EncodeSensitivity2, + steelseriesRival300FirmwareQuery, + steelseriesRival300SaveCommand, +} from "./rival-300.ts"; + +test("DPI options are the linear 50..6500 range in 50 DPI steps", () => { + const options = steelseriesRival300DpiOptions(); + assert.equal(options[0], RIVAL300_DPI_MIN); + assert.equal(options.at(-1), RIVAL300_DPI_MAX); + assert.equal(options.length, (RIVAL300_DPI_MAX - RIVAL300_DPI_MIN) / 50 + 1); + assert.equal(RIVAL300_DEFAULT_DPI_PRESET_1, 800); + assert.equal(RIVAL300_DEFAULT_DPI_PRESET_2, 1600); + assert.equal(RIVAL300_DEFAULT_POLLING_RATE, 1000); +}); + +test("encodes sensitivity presets with the linear+1 byte", () => { + // byte = (dpi - 50) / 50 + 1 + assert.deepEqual([...steelseriesRival300EncodeSensitivity1(50)], [0x03, 0x01, 0x01]); + assert.deepEqual([...steelseriesRival300EncodeSensitivity1(800)], [0x03, 0x01, 0x10]); + assert.deepEqual([...steelseriesRival300EncodeSensitivity2(1600)], [0x03, 0x02, 0x20]); + assert.deepEqual([...steelseriesRival300EncodeSensitivity1(6500)], [0x03, 0x01, 0x82]); + assert.throws(() => steelseriesRival300EncodeSensitivity1(75), Rival300ProtocolError); + assert.throws(() => steelseriesRival300EncodeSensitivity2(6550), Rival300ProtocolError); +}); + +test("polling, save, firmware, and active-CPI queries", () => { + assert.deepEqual([...steelseriesRival300EncodePollingRate(1000)], [0x04, 0x00, 0x01]); + assert.deepEqual([...steelseriesRival300EncodePollingRate(125)], [0x04, 0x00, 0x04]); + assert.deepEqual([...RIVAL300_POLLING_RATES], [125, 250, 500, 1000]); + assert.throws(() => steelseriesRival300EncodePollingRate(2000), Rival300ProtocolError); + assert.deepEqual([...steelseriesRival300SaveCommand()], [0x09, 0x00]); + assert.deepEqual([...steelseriesRival300FirmwareQuery()], [0x10, 0x00]); + assert.deepEqual([...steelseriesRival300ActiveCpiQuery()], [0x16, 0x00]); + assert.deepEqual(steelseriesRival300DecodeFirmware(new Uint8Array([0x03, 0x01])), { + bytes: [0x03, 0x01], + display: "1.3", + }); + assert.throws(() => steelseriesRival300DecodeFirmware(new Uint8Array([0x03])), Rival300ProtocolError); + // Active-preset reply: report-id stripped, so the first byte is the level. + assert.equal(steelseriesRival300DecodeActivePreset(new Uint8Array([0x01, 0x00])), 1); + assert.equal(steelseriesRival300DecodeActivePreset(new Uint8Array([0x02, 0x00])), 2); + assert.throws(() => steelseriesRival300DecodeActivePreset(new Uint8Array([0x03])), Rival300ProtocolError); + assert.throws(() => steelseriesRival300DecodeActivePreset(new Uint8Array([])), Rival300ProtocolError); +}); + +test("LED color and effect address logo (0x01) and wheel (0x02) zones", () => { + assert.deepEqual([...steelseriesRival300EncodeLedColor("logo", 0xff, 0x18, 0x00)], [0x08, 0x01, 0xff, 0x18, 0x00]); + assert.deepEqual([...steelseriesRival300EncodeLedColor("wheel", 0x00, 0x00, 0xff)], [0x08, 0x02, 0x00, 0x00, 0xff]); + assert.deepEqual([...steelseriesRival300EncodeLedEffect("logo", "steady")], [0x07, 0x01, 0x01]); + assert.deepEqual([...steelseriesRival300EncodeLedEffect("wheel", "breath")], [0x07, 0x02, 0x03]); + assert.throws(() => steelseriesRival300EncodeLedColor("strip" as never, 0, 0, 0), Rival300ProtocolError); + assert.throws(() => steelseriesRival300EncodeLedEffect("logo", "pulse" as never), Rival300ProtocolError); +}); + +test("button mapping covers six slots", () => { + const report = steelseriesRival300EncodeButtonsMapping({ + button1: { type: "button", target: "button1" }, + button6: { type: "dpiSwitch" }, + }); + assert.equal(report[0], 0x31); + assert.equal(report[1], 0x00); + assert.equal(report.length, 2 + 6 * 5); + assert.equal(report[2], 0x01); + assert.equal(report[2 + 5 * 5], 0x30); +}); + +test("button mapping rejects unknown slots and bad codes", () => { + assert.throws( + () => steelseriesRival300EncodeButtonsMapping({ ["button7" as never]: { type: "disabled" } }), + Rival300ProtocolError, + ); + assert.throws( + () => steelseriesRival300EncodeButtonsMapping({ button1: { type: "multimedia", code: 300 } }), + Rival300ProtocolError, + ); +}); diff --git a/src/steelseries/rival-300.ts b/src/steelseries/rival-300.ts new file mode 100644 index 00000000..94cb3b3b --- /dev/null +++ b/src/steelseries/rival-300.ts @@ -0,0 +1,331 @@ +/** + * SteelSeries Rival 300 configuration protocol — pure encode/decode helpers. + * + * Reconstructed from the vendor's own software, not from a third-party + * reimplementation: SteelSeries GG 121.0.0 ships `rival_base.lisp` (the + * shared profile) plus `rival_300.lisp`, `dota2-rival-mouse.lisp`, + * `predator-mouse.lisp`, `csgo-rival-300.lisp`, `csgo-rival-300-stm32.lisp`, + * `fallout_4_rival_300.lisp`, `hp_omen_rival_300.lisp`, + * `csgo-hyperbeast-rival-300.lisp` and `eg-rival-300.lisp`, all of which + * `include "rival_base"`. This module transcribes that command set. Those + * files are OpenPGP-armored ("DESCRIPTOR" armor, SKESK/AES-128). rivalcfg's + * `rival300.py` (which lists the same product ids) independently + * corroborates every command byte. + * + * This is the original Rival / Rival 300 command set, distinct from Rival + * 100 (`./rival-100.ts`) and Rival 110 (`./rival-110.ts`): the DPI byte is a + * linear range (not a choice table), the two LED zones have independent + * color and effect commands, and the six buttons are remappable. + * + * Every command is an HID **output report** with report id 0x00 (33-byte + * reports — `HIDIO 33`). + * + * DPI uses two per-level commands (`03 01`/`03 02`) with a linear byte: + * rivalcfg's `input_range [50, 6500, 50]` against `output_range [0x01, + * 0x82, 1]`, so `byte = (dpi - 50) / 50 + 1`, range 0x01–0x82 + * (50–6500 DPI in 50 DPI steps). The vendor struct carries a trailing + * `dcpi` byte (`0x00` or `0xAA`) that rivalcfg leaves at 0; this codec + * matches rivalcfg and does not emit it. Off-grid values are rejected. + * + * Polling (`04 00`) is write-only (1000→0x01, 500→0x02, 250→0x03, + * 125→0x04). Lighting is two zones — logo (`08 01`) and wheel (`08 02`) — + * each with its own color and effect (`07 01`/`07 02`, steady 0x01 / breath + * 0x03). Buttons are one 30-byte packet (6 slots × 5-byte fields), + * command `31 00`, as an output report. + * + * The two readable values are the firmware version (`10 00`, two bytes, + * `fw_minor` then `fw_major`) and the active preset index (`16 00`). + * Settings apply immediately; the save command (`09 00`) persists them to + * onboard flash. None of this has been verified on physical hardware by this + * project. + */ + +export const RIVAL300_REPORT_ID = 0x00; + +/** Command prefixes; the payload is the prefix plus its arguments. */ +export const RIVAL300_COMMAND = { + sensitivity1: [0x03, 0x01], + sensitivity2: [0x03, 0x02], + pollingRate: [0x04, 0x00], + logoColor: [0x08, 0x01], + wheelColor: [0x08, 0x02], + logoEffect: [0x07, 0x01], + wheelEffect: [0x07, 0x02], + buttonsMapping: [0x31, 0x00], + save: [0x09, 0x00], + firmware: [0x10, 0x00], + activeCpiIndex: [0x16, 0x00], +} as const; + +/** `09 00` — commit the current settings to onboard flash. */ +export const RIVAL300_SAVE_COMMAND = [0x09, 0x00] as const; + +export const RIVAL300_FIRMWARE_RESPONSE_LENGTH = 2; +export const RIVAL300_POLLING_RATES = [125, 250, 500, 1000] as const; + +const POLLING_RATE_TO_BYTE: ReadonlyMap = new Map([ + [1000, 0x01], + [500, 0x02], + [250, 0x03], + [125, 0x04], +]); + +export const RIVAL300_DPI_MIN = 50; +export const RIVAL300_DPI_MAX = 6500; +export const RIVAL300_DPI_STEP = 50; + +/** rivalcfg `rival300.py` defaults for the two presets. */ +export const RIVAL300_DEFAULT_DPI_PRESET_1 = 800; +export const RIVAL300_DEFAULT_DPI_PRESET_2 = 1600; +export const RIVAL300_DEFAULT_POLLING_RATE = 1000; + +export class Rival300ProtocolError extends Error {} + +/** The 130 DPI values the linear encoding can express, ascending. */ +export function steelseriesRival300DpiOptions(): number[] { + const options: number[] = []; + for (let dpi = RIVAL300_DPI_MIN; dpi <= RIVAL300_DPI_MAX; dpi += RIVAL300_DPI_STEP) { + options.push(dpi); + } + return options; +} + +/** + * `input_range [50, 6500, 50]` -> `output_range [0x01, 0x82, 1]`: + * `byte = (dpi - 50) / 50 + 1`. Off-grid values are rejected. + */ +function encodeDpiByte(dpi: number): number { + if ( + !Number.isInteger(dpi) || + dpi < RIVAL300_DPI_MIN || + dpi > RIVAL300_DPI_MAX || + (dpi - RIVAL300_DPI_MIN) % RIVAL300_DPI_STEP !== 0 + ) { + throw new Rival300ProtocolError( + `SteelSeries Rival 300 DPI must be ${RIVAL300_DPI_MIN}–${RIVAL300_DPI_MAX.toLocaleString("en-US")} in ${RIVAL300_DPI_STEP} DPI steps.`, + ); + } + return (dpi - RIVAL300_DPI_MIN) / RIVAL300_DPI_STEP + 1; +} + +/** `03 01 ` — sensitivity preset 1. */ +export function steelseriesRival300EncodeSensitivity1(dpi: number): Uint8Array { + return new Uint8Array([...RIVAL300_COMMAND.sensitivity1, encodeDpiByte(dpi)]); +} + +/** `03 02 ` — sensitivity preset 2. */ +export function steelseriesRival300EncodeSensitivity2(dpi: number): Uint8Array { + return new Uint8Array([...RIVAL300_COMMAND.sensitivity2, encodeDpiByte(dpi)]); +} + +/** `04 00 ` with 1000→0x01, 500→0x02, 250→0x03, 125→0x04. */ +export function steelseriesRival300EncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new Rival300ProtocolError("SteelSeries Rival 300 supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...RIVAL300_COMMAND.pollingRate, byte]); +} + +/** `09 00` — commit the current settings to onboard flash. */ +export function steelseriesRival300SaveCommand(): Uint8Array { + return new Uint8Array(RIVAL300_SAVE_COMMAND); +} + +/** `10 00` — the device answers with minor then major firmware bytes. */ +export function steelseriesRival300FirmwareQuery(): Uint8Array { + return new Uint8Array(RIVAL300_COMMAND.firmware); +} + +/** `16 00` — read the active preset index. */ +export function steelseriesRival300ActiveCpiQuery(): Uint8Array { + return new Uint8Array(RIVAL300_COMMAND.activeCpiIndex); +} + +/** + * Decode the active-preset reply. The specification's `resolution_level` + * incoming struct is `report_id` then `level`, and the reply's report id is + * not surfaced by WebHID, so the level is the first byte. The device reports + * it 1-based (values 0x01/0x02); anything else is rejected rather than + * guessed at. + */ +export function steelseriesRival300DecodeActivePreset(payload: Uint8Array): 1 | 2 { + const level = payload[0]; + if (level !== 0x01 && level !== 0x02) { + throw new Rival300ProtocolError( + `SteelSeries Rival 300 active preset reply was not 1 or 2 (got ${level === undefined ? "nothing" : `0x${level.toString(16)}`}).`, + ); + } + return level; +} + +export interface SteelSeriesRival300Firmware { + /** The two raw response bytes, in the order the device sent them (minor, major). */ + bytes: [number, number]; + /** The version the two bytes describe, major first, e.g. "1.3". */ + display: string; +} + +/** Decode the two-byte firmware response (`fw_minor`, `fw_major`). */ +export function steelseriesRival300DecodeFirmware(payload: Uint8Array): SteelSeriesRival300Firmware { + if (payload.length < RIVAL300_FIRMWARE_RESPONSE_LENGTH) { + throw new Rival300ProtocolError("SteelSeries Rival 300 firmware response is shorter than two bytes."); + } + const bytes: [number, number] = [payload[0]!, payload[1]!]; + return { bytes, display: `${bytes[1]}.${bytes[0]}` }; +} + +// -- LED ------------------------------------------------------------------- + +/** The two independently controlled LED zones. */ +export type Rival300Led = "logo" | "wheel"; + +const LED_COLOR_COMMAND: Record = { + logo: RIVAL300_COMMAND.logoColor, + wheel: RIVAL300_COMMAND.wheelColor, +}; + +const LED_EFFECT_COMMAND: Record = { + logo: RIVAL300_COMMAND.logoEffect, + wheel: RIVAL300_COMMAND.wheelEffect, +}; + +function encodeRgb(r: number, g: number, b: number): [number, number, number] { + for (const channel of [r, g, b]) { + if (!Number.isInteger(channel) || channel < 0 || channel > 255) { + throw new Rival300ProtocolError("RGB channels must be integers 0–255."); + } + } + return [r, g, b]; +} + +/** `08 ` — one LED zone's color (`logo` = 0x01, `wheel` = 0x02). */ +export function steelseriesRival300EncodeLedColor(led: Rival300Led, r: number, g: number, b: number): Uint8Array { + const command = LED_COLOR_COMMAND[led]; + if (!command) { + throw new Rival300ProtocolError('SteelSeries Rival 300 LEDs are "logo" and "wheel" only.'); + } + return new Uint8Array([...command, ...encodeRgb(r, g, b)]); +} + +export const RIVAL300_LIGHT_EFFECT_TO_BYTE = { + steady: 0x01, + breath: 0x03, +} as const; + +export type Rival300LightEffect = keyof typeof RIVAL300_LIGHT_EFFECT_TO_BYTE; + +/** `07 ` — one LED zone's effect (steady/breath). */ +export function steelseriesRival300EncodeLedEffect(led: Rival300Led, effect: Rival300LightEffect): Uint8Array { + const command = LED_EFFECT_COMMAND[led]; + if (!command) { + throw new Rival300ProtocolError('SteelSeries Rival 300 LEDs are "logo" and "wheel" only.'); + } + const byte = RIVAL300_LIGHT_EFFECT_TO_BYTE[effect]; + if (byte === undefined) { + throw new Rival300ProtocolError( + `SteelSeries Rival 300 light effect must be one of: ${Object.keys(RIVAL300_LIGHT_EFFECT_TO_BYTE).join(", ")}.`, + ); + } + return new Uint8Array([...command, byte]); +} + +// -- Button mapping -------------------------------------------------------- + +/** 6 button slots, 5-byte fields. */ +export const RIVAL300_BUTTONS = { + button1: { id: 0x01, offset: 0x00 }, + button2: { id: 0x02, offset: 0x05 }, + button3: { id: 0x03, offset: 0x0a }, + button4: { id: 0x04, offset: 0x0f }, + button5: { id: 0x05, offset: 0x14 }, + button6: { id: 0x06, offset: 0x19 }, +} as const satisfies Record; + +export type Rival300ButtonName = keyof typeof RIVAL300_BUTTONS; + +const RIVAL300_BUTTON_FIELD_LENGTH = 5; +const RIVAL300_BUTTON_DISABLE = 0x00; +const RIVAL300_BUTTON_DPI_SWITCH = 0x30; +const RIVAL300_BUTTON_SCROLL_UP = 0x31; +const RIVAL300_BUTTON_SCROLL_DOWN = 0x32; +const RIVAL300_BUTTON_KEYBOARD = 0x51; +const RIVAL300_BUTTON_MULTIMEDIA = 0x61; + +export type Rival300ButtonAction = + | { type: "button"; target: Rival300ButtonName } + | { type: "disabled" } + | { type: "dpiSwitch" } + | { type: "scrollUp" } + | { type: "scrollDown" } + | { type: "keyboard"; code: number } + | { type: "multimedia"; code: number }; + +function encodeKeyCode(code: number, kind: string): number { + if (!Number.isInteger(code) || code < 0 || code > 255) { + throw new Rival300ProtocolError(`${kind} codes must be integers 0–255.`); + } + return code; +} + +/** + * `31 00` + one 30-byte packet (6 slots × 5-byte fields), zero-filled + * except where a mapping is given. Pass every button explicitly — unmapped + * buttons stay disabled, matching this package's no-silent-defaults policy. + */ +export function steelseriesRival300EncodeButtonsMapping( + mapping: Partial>, +): Uint8Array { + for (const name of Object.keys(mapping)) { + if (!Object.hasOwn(RIVAL300_BUTTONS, name)) { + throw new Rival300ProtocolError(`Unknown SteelSeries Rival 300 button "${name}".`); + } + } + + const names = Object.keys(RIVAL300_BUTTONS) as Rival300ButtonName[]; + const packet = new Array(names.length * RIVAL300_BUTTON_FIELD_LENGTH).fill(0x00); + + for (const [name, action] of Object.entries(mapping) as Array<[Rival300ButtonName, Rival300ButtonAction]>) { + const { offset } = RIVAL300_BUTTONS[name]; + switch (action.type) { + case "button": { + if (!Object.hasOwn(RIVAL300_BUTTONS, action.target)) { + throw new Rival300ProtocolError(`Unknown SteelSeries Rival 300 button "${action.target}".`); + } + packet[offset] = RIVAL300_BUTTONS[action.target].id; + break; + } + case "disabled": { + packet[offset] = RIVAL300_BUTTON_DISABLE; + break; + } + case "dpiSwitch": { + packet[offset] = RIVAL300_BUTTON_DPI_SWITCH; + break; + } + case "scrollUp": { + packet[offset] = RIVAL300_BUTTON_SCROLL_UP; + break; + } + case "scrollDown": { + packet[offset] = RIVAL300_BUTTON_SCROLL_DOWN; + break; + } + case "keyboard": { + packet[offset] = RIVAL300_BUTTON_KEYBOARD; + packet[offset + 1] = encodeKeyCode(action.code, "Keyboard scan"); + break; + } + case "multimedia": { + packet[offset] = RIVAL300_BUTTON_MULTIMEDIA; + packet[offset + 1] = encodeKeyCode(action.code, "Multimedia key"); + break; + } + default: { + throw new Rival300ProtocolError("Unsupported SteelSeries Rival 300 button action."); + } + } + } + + return new Uint8Array([...RIVAL300_COMMAND.buttonsMapping, ...packet]); +} 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]); +} 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]); +} 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]); +} 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]); +} 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]); +}