diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 3adb5af5..eb2bf881 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -58,6 +58,7 @@ import { SteelSeriesRival310HidClient } from "./steelseries/rival310-hid.ts"; import { SteelSeriesPrimePlusHidClient } from "./steelseries/prime-plus-hid.ts"; import { SteelSeriesPrimeMiniWirelessHidClient } from "./steelseries/prime-mini-wireless-hid.ts"; import { SteelSeriesSenseiTenHidClient } from "./steelseries/sensei-ten-hid.ts"; +import { SteelSeriesSenseiRawHidClient } from "./steelseries/sensei-raw-hid.ts"; import { GloriousHidClient } from "./glorious/hid.ts"; import { GloriousClassicHidClient } from "./glorious/classic-hid.ts"; import { GloriousCore2HidClient } from "./glorious/core2-hid.ts"; @@ -83,7 +84,7 @@ import { AjazzHidClient } from "./ajazz/hid.ts"; import { AjazzAk820HidClient } from "./ajazz/ak820-hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesSenseiRawHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient; export interface DeviceDriver { brand: string; @@ -176,6 +177,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesPrimePlusHidClient.isSupported(device), create: (device) => new SteelSeriesPrimePlusHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesPrimeMiniWirelessHidClient.isSupported(device), create: (device) => new SteelSeriesPrimeMiniWirelessHidClient(device), score: () => 6 }, { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiTenHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiTenHidClient(device), score: () => 6 }, + { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiRawHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiRawHidClient(device), score: () => 6 }, { brand: "Glorious", supports: (device) => GloriousHidClient.isSupported(device), create: (device) => new GloriousHidClient(device), score: () => 5 }, { brand: "Glorious", supports: (device) => GloriousClassicHidClient.isSupported(device), create: (device) => new GloriousClassicHidClient(device), score: () => 5 }, // Same vendor id as the classic family; disjoint by product id. diff --git a/src/drivers/steelseries/sensei-raw-hid.test.ts b/src/drivers/steelseries/sensei-raw-hid.test.ts new file mode 100644 index 00000000..0c2fe0b9 --- /dev/null +++ b/src/drivers/steelseries/sensei-raw-hid.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesSenseiRawHidClient } from "./sensei-raw-hid.ts"; + +function fakeDevice(options: { productId?: number; firmware?: number[]; activePreset?: number; answer?: boolean } = {}) { + const sent: Array<{ reportId: number; payload: number[] }> = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const reply = (bytes: number[]) => { + const response = new Uint8Array(bytes); + queueMicrotask(() => + listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + }; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x1369, + productName: "SteelSeries Sensei [RAW]", + opened: true, + collections: [], + open: async () => {}, + close: async () => {}, + sendReport: async (reportId: number, data: BufferSource) => { + const view = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : new Uint8Array(data as ArrayBuffer); + const payload = [...view]; + sent.push({ reportId, payload }); + if (options.answer === false) return; + if (payload[0] === 0x10 && payload[1] === 0x00) reply(options.firmware ?? [0x03, 0x01]); + if (payload[0] === 0x16 && payload[1] === 0x00) reply([options.activePreset ?? 0x01]); + }, + sendFeatureReport: async () => { throw new Error("the Sensei [RAW] protocol does not use feature reports"); }, + receiveFeatureReport: async () => { throw new Error("the Sensei [RAW] protocol does not use feature reports"); }, + addEventListener: (_type: string, attached: (event: HIDInputReportEvent) => void) => { listener = attached; }, + removeEventListener: () => { listener = null; }, + }; + return { device: device as unknown as HIDDevice, sent }; +} + +test("claims the Sensei [RAW] family product ids and nothing else", () => { + const { device } = fakeDevice(); + for (const productId of [0x1369, 0x1362, 0x136d, 0x136f, 0x1380, 0x1390]) { + assert.equal( + SteelSeriesSenseiRawHidClient.isSupported({ ...device, productId } as HIDDevice), + true, + `expected 0x${productId.toString(16)} to be claimed`, + ); + } + // Sensei TEN, Sensei [RAW] Optical RGB (rival-310 based) and modern families must not be claimed. + for (const productId of [0x1832, 0x1800, 0x1826, 0x1824, 0x1729]) { + assert.equal( + SteelSeriesSenseiRawHidClient.isSupported({ ...device, productId } as HIDDevice), + false, + `must not claim product id 0x${productId.toString(16)}`, + ); + } + assert.equal(SteelSeriesSenseiRawHidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus probes firmware and the active preset, reporting that preset's default", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x02 }); + const status = await new SteelSeriesSenseiRawHidClient(device).readStatus(); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x10, 0x00] }, + { reportId: 0, payload: [0x16, 0x00] }, + ]); + assert.equal(status.brand, "SteelSeries"); + assert.equal(status.name, "SteelSeries Sensei [RAW]"); + assert.deepEqual(status.firmware, ["1.3"]); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.dpi, 3240); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.ui?.valuesVerified, false); + assert.ok(status.ui?.pollingNote?.includes("active preset (2)")); +}); + +test("a silent interface fails the firmware probe loudly", async () => { + const { device } = fakeDevice({ answer: false }); + const client = new SteelSeriesSenseiRawHidClient(device); + await assert.rejects(() => client.readStatus(), /did not answer the firmware query/); +}); + +test("setDpi writes the active preset (1 by default) plus save", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x01 }); + const client = new SteelSeriesSenseiRawHidClient(device); + await client.readStatus(); + sent.length = 0; + const dpi = await client.setDpi(1620); + assert.equal(dpi, 1620); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x16, 0x00] }, + { reportId: 0, payload: [0x03, 0x01, 0x12] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); + +test("setDpi writes preset 2 when the CPI button has selected it", async () => { + const { device, sent } = fakeDevice({ activePreset: 0x02 }); + const client = new SteelSeriesSenseiRawHidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setDpi(3240); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x16, 0x00] }, + { reportId: 0, payload: [0x03, 0x02, 0x24] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); +}); + +test("setPollingRate, LED brightness/effect, and buttons mapping write plus save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesSenseiRawHidClient(device); + await client.readStatus(); + sent.length = 0; + await client.setPollingRate(500); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x04, 0x00, 0x02] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setLedBrightness("medium"); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x05, 0x01, 0x03] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setLedEffect("trigger"); + assert.deepEqual(sent, [ + { reportId: 0, payload: [0x07, 0x01, 0x05] }, + { reportId: 0, payload: [0x09, 0x00] }, + ]); + sent.length = 0; + await client.setButtonsMapping({ button8: { type: "dpiSwitch" } }); + assert.equal(sent.length, 2); + assert.equal(sent[0]?.payload[0], 0x31); + assert.equal(sent[0]?.payload.length, 2 + 8 * 3); + assert.equal(sent[0]?.payload[2 + 0x15], 0x30); + assert.deepEqual(sent[1], { reportId: 0, payload: [0x09, 0x00] }); +}); diff --git a/src/drivers/steelseries/sensei-raw-hid.ts b/src/drivers/steelseries/sensei-raw-hid.ts new file mode 100644 index 00000000..60b17448 --- /dev/null +++ b/src/drivers/steelseries/sensei-raw-hid.ts @@ -0,0 +1,244 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { + SENSEI_RAW_DEFAULT_DPI_PRESET_1, + SENSEI_RAW_DEFAULT_DPI_PRESET_2, + SENSEI_RAW_POLLING_RATES, + SENSEI_RAW_REPORT_ID, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesSenseiRawActiveCpiQuery, + steelseriesSenseiRawDecodeFirmware, + steelseriesSenseiRawDpiOptions, + steelseriesSenseiRawEncodeButtonsMapping, + steelseriesSenseiRawEncodeLedBrightness, + steelseriesSenseiRawEncodeLedEffect, + steelseriesSenseiRawEncodePollingRate, + steelseriesSenseiRawEncodeSensitivity1, + steelseriesSenseiRawEncodeSensitivity2, + steelseriesSenseiRawFirmwareQuery, + steelseriesSenseiRawSaveCommand, + type SenseiRawBrightness, + type SenseiRawButtonAction, + type SenseiRawButtonName, + type SenseiRawEffect, + type SteelSeriesSenseiRawFirmware, +} from "@openmouse/protocol/steelseries"; + +/** rivalcfg's `command_approve_delay`, shared across every SteelSeries family. */ +const COMMAND_DELAY_MS = 50; +/** rivalcfg reads input-report replies with a 200 ms timeout; allow slack. */ +const RESPONSE_TIMEOUT_MS = 500; +const DEFAULT_POLLING_HZ = 1000; + +/** + * SteelSeries Sensei [RAW] WebHID control (`1038:1369` and its Diablo III / + * Guild Wars 2 / CoD Black Ops II / World of Tanks / Heroes of the Storm + * editions, one shared vendor profile — see + * `../../steelseries/sensei-raw.ts`). + * + * DPI (two presets, `03 01`/`03 02`), polling, and the single LED are + * write-only; the two readable values are the firmware version (`10 00`) and + * the active preset index (`16 00`). This client uses the firmware query as + * its connectivity probe, matching rivalcfg's `sensei_raw.py`. + * + * Because the active preset index is readable, `setDpi` writes the + * **active** preset (so a change always takes effect, even after the CPI + * button has selected preset 2) rather than always preset 1, and + * `readStatus` reports that active preset's DPI. `setDpiPreset2` writes + * preset 2 explicitly. Both presets are tracked independently; a preset + * whose value this session has not written falls back to rivalcfg's + * documented default for that preset, with `valuesVerified: false`. + * + * The config channel is hidapi interface 0; its WebHID collection shape has + * not been captured yet, so the picker offers every interface and the wrong + * ones fail the firmware probe loudly. + */ +export class SteelSeriesSenseiRawHidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private listenerAttached = false; + private readonly inputWaiters = new Set<(payload: Uint8Array) => void>(); + /** Last DPI this session wrote to each preset, if any. */ + private readonly presetDpi: Record<1 | 2, number | null> = { 1: null, 2: null }; + private pollingRateHz: number | null = null; + + private readonly onInputReport = (event: HIDInputReportEvent): void => { + const payload = new Uint8Array( + event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength), + ); + for (const finish of [...this.inputWaiters]) finish(payload); + }; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== STEELSERIES_VENDOR_ID) return false; + return STEELSERIES_PRODUCTS.get(device.productId)?.family === "sensei-raw"; + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...SENSEI_RAW_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesSenseiRawDpiOptions(); } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + if (!this.listenerAttached) { + this.device.addEventListener("inputreport", this.onInputReport); + this.listenerAttached = true; + } + } + + async close(): Promise { + if (this.listenerAttached) { + this.device.removeEventListener("inputreport", this.onInputReport); + this.listenerAttached = false; + } + if (this.device.opened) await this.device.close(); + } + + async readStatus(): Promise { + return await this.run(async () => { + await this.open(); + const firmware = await this.probeFirmware(); + const activePreset = await this.readActivePreset(); + const product = STEELSERIES_PRODUCTS.get(this.device.productId); + const name = this.device.productName?.trim() || `SteelSeries ${product?.model ?? "Sensei [RAW]"}`; + return { + brand: "SteelSeries", + name, + ui: { + family: "steelseries-sensei-raw", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + pollingNote: `The Sensei [RAW] cannot report the DPI of a preset this app has not written; the value shown is the last written by this app for the active preset (${activePreset}), or that preset's assumed default.`, + defaultDisplayName: "SteelSeries Sensei [RAW]", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: this.dpiForPreset(activePreset), + pollingRateHz: this.pollingRateHz ?? DEFAULT_POLLING_HZ, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + connectionType: "Wired", + liftOffDistance: null, + firmware: [firmware.display], + }; + }); + } + + /** + * Writes `dpi` to the **active** preset (`03 01` or `03 02`), so the change + * takes effect even after the CPI button has switched to preset 2. The + * active index is read first; if that read fails, this falls back to + * preset 1 rather than aborting the write. + */ + async setDpi(dpi: number): Promise { + await this.run(async () => { + await this.open(); + const preset = await this.readActivePreset(); + await this.write(preset === 2 ? steelseriesSenseiRawEncodeSensitivity2(dpi) : steelseriesSenseiRawEncodeSensitivity1(dpi)); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesSenseiRawSaveCommand()); + this.presetDpi[preset] = dpi; + }); + return dpi; + } + + /** `03 02 ` — writes sensitivity preset 2 explicitly. */ + async setDpiPreset2(dpi: number): Promise { + await this.commit(steelseriesSenseiRawEncodeSensitivity2(dpi)); + this.presetDpi[2] = dpi; + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesSenseiRawEncodePollingRate(pollingRateHz)); + this.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + async setLedBrightness(brightness: SenseiRawBrightness): Promise { + await this.commit(steelseriesSenseiRawEncodeLedBrightness(brightness)); + } + + async setLedEffect(effect: SenseiRawEffect): Promise { + await this.commit(steelseriesSenseiRawEncodeLedEffect(effect)); + } + + async setButtonsMapping(mapping: Partial>): Promise { + await this.commit(steelseriesSenseiRawEncodeButtonsMapping(mapping)); + } + + /** The DPI to display for a preset: last written by this session, else that preset's rivalcfg default. */ + private dpiForPreset(preset: 1 | 2): number { + return this.presetDpi[preset] ?? (preset === 2 ? SENSEI_RAW_DEFAULT_DPI_PRESET_2 : SENSEI_RAW_DEFAULT_DPI_PRESET_1); + } + + private async commit(report: Uint8Array): Promise { + await this.run(async () => { + await this.open(); + await this.write(report); + await this.delay(COMMAND_DELAY_MS); + await this.write(steelseriesSenseiRawSaveCommand()); + }); + } + + /** + * `10 00` — two-byte firmware version. Doubles as the proof that the + * granted interface is the config channel. + */ + private async probeFirmware(): Promise { + const payload = await this.awaitResponse(steelseriesSenseiRawFirmwareQuery()); + if (!payload) { + throw new Error("The Sensei [RAW] did not answer the firmware query on this interface. Add the device again and choose another entry."); + } + return steelseriesSenseiRawDecodeFirmware(payload); + } + + /** + * `16 00` — the active preset index (1 or 2). A failed or malformed reply + * falls back to preset 1 so a DPI write is never blocked by the probe. + */ + private async readActivePreset(): Promise<1 | 2> { + const payload = await this.awaitResponse(steelseriesSenseiRawActiveCpiQuery()); + if (!payload) return 1; + const level = payload[0]; + return level === 0x02 ? 2 : 1; + } + + /** Send a query and resolve its first input report, or null on timeout. */ + private async awaitResponse(query: Uint8Array): Promise { + const response = new Promise((resolve) => { + let timer: ReturnType; + const finish = (payload: Uint8Array | null): void => { + clearTimeout(timer); + this.inputWaiters.delete(finish as (payload: Uint8Array) => void); + resolve(payload); + }; + timer = setTimeout(() => finish(null), RESPONSE_TIMEOUT_MS); + this.inputWaiters.add(finish as (payload: Uint8Array) => void); + }); + await this.write(query); + return await response; + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(SENSEI_RAW_REPORT_ID, payload.buffer as ArrayBuffer); + } + + private delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); + } + + private async run(operation: () => Promise): Promise { + const result = this.queue.then(operation, operation); + this.queue = result.then(() => undefined, () => undefined); + return await result; + } +} diff --git a/src/steelseries/devices.ts b/src/steelseries/devices.ts index 8efdb71c..b449009e 100644 --- a/src/steelseries/devices.ts +++ b/src/steelseries/devices.ts @@ -77,7 +77,8 @@ export type SteelSeriesProtocolFamily = | "aerox9-wireless" | "prime-plus" | "prime-mini-wireless" - | "sensei-ten"; + | "sensei-ten" + | "sensei-raw"; export interface SteelSeriesProduct { model: string; @@ -100,6 +101,60 @@ export interface SteelSeriesProduct { } export const STEELSERIES_PRODUCTS: ReadonlyMap = new Map([ + // sensei-raw_base.lisp (vendor GG specification): the original Sensei [RAW] + // command set — linear two-preset DPI 0x03 (90-5670), polling 0x04, single + // LED brightness/effect 0x05/0x07, eight 3-byte button slots 0x31, save + // 0x09, firmware 0x10. Shared by the base and its editions — see + // ./sensei-raw.ts. Distinct from the Sensei [RAW] Optical RGB (0x1800), + // which uses the Rival 310 command set. + [0x1369, { + model: "Sensei [RAW]", + family: "sensei-raw", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1362, { + model: "Sensei [RAW] Diablo III Edition", + family: "sensei-raw", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x136d, { + model: "Sensei [RAW] Guild Wars 2 Edition", + family: "sensei-raw", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x136f, { + model: "Sensei [RAW] Call of Duty: Black Ops II Edition", + family: "sensei-raw", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1380, { + model: "Sensei [RAW] World of Tanks Edition", + family: "sensei-raw", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], + [0x1390, { + model: "Sensei [RAW] Heroes of the Storm Edition", + family: "sensei-raw", + wireless: false, + settingsReadable: false, + hasFirmwareQuery: true, + verified: false, + }], // Pre-0.37 firmware enumeration. [0x1824, { model: "Rival 3", diff --git a/src/steelseries/index.ts b/src/steelseries/index.ts index dacc7846..0b7cfc37 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -10,4 +10,5 @@ export * from "./aerox9-wireless.js"; export * from "./prime-plus.js"; export * from "./prime-mini-wireless.js"; export * from "./sensei-ten.js"; +export * from "./sensei-raw.js"; export * from "./devices.js"; diff --git a/src/steelseries/sensei-raw.test.ts b/src/steelseries/sensei-raw.test.ts new file mode 100644 index 00000000..a92b4dd7 --- /dev/null +++ b/src/steelseries/sensei-raw.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + SENSEI_RAW_BRIGHTNESS_TO_BYTE, + SENSEI_RAW_DEFAULT_DPI_PRESET_1, + SENSEI_RAW_DEFAULT_DPI_PRESET_2, + SENSEI_RAW_DEFAULT_POLLING_RATE, + SENSEI_RAW_DPI_MAX, + SENSEI_RAW_DPI_MIN, + SENSEI_RAW_EFFECT_TO_BYTE, + SENSEI_RAW_POLLING_RATES, + SenseiRawProtocolError, + steelseriesSenseiRawActiveCpiQuery, + steelseriesSenseiRawDecodeFirmware, + steelseriesSenseiRawDpiOptions, + steelseriesSenseiRawEncodeButtonsMapping, + steelseriesSenseiRawEncodeLedBrightness, + steelseriesSenseiRawEncodeLedEffect, + steelseriesSenseiRawEncodePollingRate, + steelseriesSenseiRawEncodeSensitivity1, + steelseriesSenseiRawEncodeSensitivity2, + steelseriesSenseiRawFirmwareQuery, + steelseriesSenseiRawSaveCommand, +} from "./sensei-raw.ts"; + +test("DPI options are the linear 90..5670 range in 90 DPI steps", () => { + const options = steelseriesSenseiRawDpiOptions(); + assert.equal(options[0], SENSEI_RAW_DPI_MIN); + assert.equal(options.at(-1), SENSEI_RAW_DPI_MAX); + assert.equal(options.length, (SENSEI_RAW_DPI_MAX - SENSEI_RAW_DPI_MIN) / 90 + 1); + assert.equal(options.length, 63); + assert.equal(SENSEI_RAW_DEFAULT_DPI_PRESET_1, 1620); + assert.equal(SENSEI_RAW_DEFAULT_DPI_PRESET_2, 3240); + assert.equal(SENSEI_RAW_DEFAULT_POLLING_RATE, 1000); +}); + +test("encodes sensitivity presets with the linear+1 byte", () => { + // byte = (dpi - 90) / 90 + 1 + assert.deepEqual([...steelseriesSenseiRawEncodeSensitivity1(90)], [0x03, 0x01, 0x01]); + assert.deepEqual([...steelseriesSenseiRawEncodeSensitivity1(1620)], [0x03, 0x01, 0x12]); + assert.deepEqual([...steelseriesSenseiRawEncodeSensitivity2(3240)], [0x03, 0x02, 0x24]); + assert.deepEqual([...steelseriesSenseiRawEncodeSensitivity1(5670)], [0x03, 0x01, 0x3f]); + assert.throws(() => steelseriesSenseiRawEncodeSensitivity1(135), SenseiRawProtocolError); + assert.throws(() => steelseriesSenseiRawEncodeSensitivity2(5760), SenseiRawProtocolError); +}); + +test("polling, save, firmware, and active-CPI queries", () => { + assert.deepEqual([...steelseriesSenseiRawEncodePollingRate(1000)], [0x04, 0x00, 0x01]); + assert.deepEqual([...steelseriesSenseiRawEncodePollingRate(125)], [0x04, 0x00, 0x04]); + assert.deepEqual([...SENSEI_RAW_POLLING_RATES], [125, 250, 500, 1000]); + assert.throws(() => steelseriesSenseiRawEncodePollingRate(2000), SenseiRawProtocolError); + assert.deepEqual([...steelseriesSenseiRawSaveCommand()], [0x09, 0x00]); + assert.deepEqual([...steelseriesSenseiRawFirmwareQuery()], [0x10, 0x00]); + assert.deepEqual([...steelseriesSenseiRawActiveCpiQuery()], [0x16, 0x00]); + assert.deepEqual(steelseriesSenseiRawDecodeFirmware(new Uint8Array([0x03, 0x01])), { + bytes: [0x03, 0x01], + display: "1.3", + }); + assert.throws(() => steelseriesSenseiRawDecodeFirmware(new Uint8Array([0x03])), SenseiRawProtocolError); +}); + +test("LED brightness and effect are choice encodings on the single LED", () => { + assert.deepEqual([...steelseriesSenseiRawEncodeLedBrightness("off")], [0x05, 0x01, 0x01]); + assert.deepEqual([...steelseriesSenseiRawEncodeLedBrightness("high")], [0x05, 0x01, 0x04]); + assert.deepEqual([...steelseriesSenseiRawEncodeLedEffect("steady")], [0x07, 0x01, 0x01]); + assert.deepEqual([...steelseriesSenseiRawEncodeLedEffect("trigger")], [0x07, 0x01, 0x05]); + assert.throws(() => steelseriesSenseiRawEncodeLedBrightness("max" as never), SenseiRawProtocolError); + assert.throws(() => steelseriesSenseiRawEncodeLedEffect("pulse" as never), SenseiRawProtocolError); + assert.deepEqual(SENSEI_RAW_BRIGHTNESS_TO_BYTE, { off: 0x01, low: 0x02, medium: 0x03, high: 0x04 }); + assert.deepEqual(SENSEI_RAW_EFFECT_TO_BYTE, { steady: 0x01, breath: 0x03, trigger: 0x05 }); +}); + +test("button mapping covers eight 3-byte slots with rivalcfg's offset order", () => { + const report = steelseriesSenseiRawEncodeButtonsMapping({ + button1: { type: "button", target: "button1" }, + button6: { type: "dpiSwitch" }, + button7: { type: "keyboard", code: 0x4a }, // H + }); + assert.equal(report[0], 0x31); + assert.equal(report[1], 0x00); + assert.equal(report.length, 2 + 8 * 3); + assert.equal(report[2 + 0x12], 0x30); // button6 -> dpi switch + assert.equal(report[2 + 0x0f], 0x10); // button7 -> keyboard + assert.equal(report[2 + 0x0f + 1], 0x4a); +}); + +test("button mapping rejects unknown slots and bad codes", () => { + assert.throws( + () => steelseriesSenseiRawEncodeButtonsMapping({ ["button9" as never]: { type: "disabled" } }), + SenseiRawProtocolError, + ); + assert.throws( + () => steelseriesSenseiRawEncodeButtonsMapping({ button1: { type: "multimedia", code: 300 } }), + SenseiRawProtocolError, + ); +}); diff --git a/src/steelseries/sensei-raw.ts b/src/steelseries/sensei-raw.ts new file mode 100644 index 00000000..838a11e0 --- /dev/null +++ b/src/steelseries/sensei-raw.ts @@ -0,0 +1,291 @@ +/** + * SteelSeries Sensei [RAW] configuration protocol — pure encode/decode helpers. + * + * Reconstructed from the vendor's own software, not from a third-party + * reimplementation: SteelSeries GG 121.0.0 ships `sensei-raw_base.lisp` + * under `apps/engine/install/deviceSpecifications/`, and this module + * transcribes it. That file is OpenPGP-armored ("DESCRIPTOR" armor, + * SKESK/AES-128); the bytes below are the documented HID report payloads, + * not guesses. rivalcfg's `sensei_raw.py` (which lists the same product ids) + * independently corroborates every command byte and supplies the DPI range. + * + * This is the original **Sensei [RAW]** command set — a different device + * family from the Sensei (`./sensei-ten.ts`? no: the plain Sensei), the + * Sensei [RAW] Optical RGB (`0x1800`, which uses the Rival 310 command set), + * and the Sensei 310 (`./sensei-310.ts`). The [RAW] has a single white/red + * LED (brightness + effect only, **no color command**), eight buttons with + * 3-byte fields, and a linear two-preset DPI. + * + * Every command is an HID **output report** with report id 0x00 (33-byte + * reports — `HIDIO 33`). + * + * DPI uses two per-level commands (`03 01`/`03 02`) with a linear byte: + * rivalcfg's `input_range [90, 5670, 90]` against `output_range [0x01, + * 0x3F, 1]`, so `byte = (dpi - 90) / 90 + 1`, range 0x01–0x3F + * (90–5670 DPI in 90 DPI steps). Off-grid values are rejected. + * + * Polling (`04 00`) is write-only (1000→0x01, 500→0x02, 250→0x03, + * 125→0x04). The single LED has a brightness level (`05 01`, off/low/ + * medium/high → 0x01–0x04) and an effect (`07 01`, steady 0x01 / breath + * 0x03 / trigger 0x05). Buttons are one 24-byte packet (8 slots × 3-byte + * fields), command `31 00`; the button-6/7 slot order is odd (see + * `SENSEI_RAW_BUTTONS`). + * + * The two readable values are the firmware version (`10 00`, two bytes, + * `fw_minor` then `fw_major`) and the active preset index (`16 00`). + * Settings apply immediately; the save command (`09 00`) persists them to + * onboard flash. None of this has been verified on physical hardware by this + * project. + */ + +export const SENSEI_RAW_REPORT_ID = 0x00; + +/** Command prefixes; the payload is the prefix plus its arguments. */ +export const SENSEI_RAW_COMMAND = { + sensitivity1: [0x03, 0x01], + sensitivity2: [0x03, 0x02], + pollingRate: [0x04, 0x00], + ledBrightness: [0x05, 0x01], + ledEffect: [0x07, 0x01], + buttonsMapping: [0x31, 0x00], + save: [0x09, 0x00], + firmware: [0x10, 0x00], + activeCpiIndex: [0x16, 0x00], +} as const; + +/** `09 00` — commit the current settings to onboard flash. */ +export const SENSEI_RAW_SAVE_COMMAND = [0x09, 0x00] as const; + +export const SENSEI_RAW_FIRMWARE_RESPONSE_LENGTH = 2; +export const SENSEI_RAW_POLLING_RATES = [125, 250, 500, 1000] as const; + +const POLLING_RATE_TO_BYTE: ReadonlyMap = new Map([ + [1000, 0x01], + [500, 0x02], + [250, 0x03], + [125, 0x04], +]); + +export const SENSEI_RAW_DPI_MIN = 90; +export const SENSEI_RAW_DPI_MAX = 5670; +export const SENSEI_RAW_DPI_STEP = 90; + +/** rivalcfg `sensei_raw.py` defaults for the two presets. */ +export const SENSEI_RAW_DEFAULT_DPI_PRESET_1 = 1620; +export const SENSEI_RAW_DEFAULT_DPI_PRESET_2 = 3240; +export const SENSEI_RAW_DEFAULT_POLLING_RATE = 1000; + +export class SenseiRawProtocolError extends Error {} + +/** The 63 DPI values the linear encoding can express, ascending. */ +export function steelseriesSenseiRawDpiOptions(): number[] { + const options: number[] = []; + for (let dpi = SENSEI_RAW_DPI_MIN; dpi <= SENSEI_RAW_DPI_MAX; dpi += SENSEI_RAW_DPI_STEP) { + options.push(dpi); + } + return options; +} + +/** `input_range [90, 5670, 90]` -> `output_range [0x01, 0x3F, 1]`. */ +function encodeDpiByte(dpi: number): number { + if ( + !Number.isInteger(dpi) || + dpi < SENSEI_RAW_DPI_MIN || + dpi > SENSEI_RAW_DPI_MAX || + (dpi - SENSEI_RAW_DPI_MIN) % SENSEI_RAW_DPI_STEP !== 0 + ) { + throw new SenseiRawProtocolError( + `SteelSeries Sensei [RAW] DPI must be ${SENSEI_RAW_DPI_MIN}–${SENSEI_RAW_DPI_MAX.toLocaleString("en-US")} in ${SENSEI_RAW_DPI_STEP} DPI steps.`, + ); + } + return (dpi - SENSEI_RAW_DPI_MIN) / SENSEI_RAW_DPI_STEP + 1; +} + +/** `03 01 ` — sensitivity preset 1. */ +export function steelseriesSenseiRawEncodeSensitivity1(dpi: number): Uint8Array { + return new Uint8Array([...SENSEI_RAW_COMMAND.sensitivity1, encodeDpiByte(dpi)]); +} + +/** `03 02 ` — sensitivity preset 2. */ +export function steelseriesSenseiRawEncodeSensitivity2(dpi: number): Uint8Array { + return new Uint8Array([...SENSEI_RAW_COMMAND.sensitivity2, encodeDpiByte(dpi)]); +} + +/** `04 00 ` with 1000→0x01, 500→0x02, 250→0x03, 125→0x04. */ +export function steelseriesSenseiRawEncodePollingRate(pollingRateHz: number): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new SenseiRawProtocolError("SteelSeries Sensei [RAW] supports 125, 250, 500, or 1000 Hz polling."); + } + return new Uint8Array([...SENSEI_RAW_COMMAND.pollingRate, byte]); +} + +/** `09 00` — commit the current settings to onboard flash. */ +export function steelseriesSenseiRawSaveCommand(): Uint8Array { + return new Uint8Array(SENSEI_RAW_SAVE_COMMAND); +} + +/** `10 00` — the device answers with minor then major firmware bytes. */ +export function steelseriesSenseiRawFirmwareQuery(): Uint8Array { + return new Uint8Array(SENSEI_RAW_COMMAND.firmware); +} + +/** `16 00` — read the active preset index. */ +export function steelseriesSenseiRawActiveCpiQuery(): Uint8Array { + return new Uint8Array(SENSEI_RAW_COMMAND.activeCpiIndex); +} + +export interface SteelSeriesSenseiRawFirmware { + /** The two raw response bytes, in the order the device sent them (minor, major). */ + bytes: [number, number]; + /** The version the two bytes describe, major first, e.g. "1.3". */ + display: string; +} + +/** Decode the two-byte firmware response (`fw_minor`, `fw_major`). */ +export function steelseriesSenseiRawDecodeFirmware(payload: Uint8Array): SteelSeriesSenseiRawFirmware { + if (payload.length < SENSEI_RAW_FIRMWARE_RESPONSE_LENGTH) { + throw new SenseiRawProtocolError("SteelSeries Sensei [RAW] firmware response is shorter than two bytes."); + } + const bytes: [number, number] = [payload[0]!, payload[1]!]; + return { bytes, display: `${bytes[1]}.${bytes[0]}` }; +} + +// -- LED ------------------------------------------------------------------- + +/** `05 01 ` — LED brightness; off/low/medium/high. */ +export const SENSEI_RAW_BRIGHTNESS_TO_BYTE = { + off: 0x01, + low: 0x02, + medium: 0x03, + high: 0x04, +} as const; + +export type SenseiRawBrightness = keyof typeof SENSEI_RAW_BRIGHTNESS_TO_BYTE; + +/** `05 01 ` — LED brightness. */ +export function steelseriesSenseiRawEncodeLedBrightness(brightness: SenseiRawBrightness): Uint8Array { + const byte = SENSEI_RAW_BRIGHTNESS_TO_BYTE[brightness]; + if (byte === undefined) { + throw new SenseiRawProtocolError( + `SteelSeries Sensei [RAW] brightness must be one of: ${Object.keys(SENSEI_RAW_BRIGHTNESS_TO_BYTE).join(", ")}.`, + ); + } + return new Uint8Array([...SENSEI_RAW_COMMAND.ledBrightness, byte]); +} + +/** `07 01 ` — LED effect; steady/breath/trigger. */ +export const SENSEI_RAW_EFFECT_TO_BYTE = { + steady: 0x01, + breath: 0x03, + trigger: 0x05, +} as const; + +export type SenseiRawEffect = keyof typeof SENSEI_RAW_EFFECT_TO_BYTE; + +/** `07 01 ` — LED effect. */ +export function steelseriesSenseiRawEncodeLedEffect(effect: SenseiRawEffect): Uint8Array { + const byte = SENSEI_RAW_EFFECT_TO_BYTE[effect]; + if (byte === undefined) { + throw new SenseiRawProtocolError( + `SteelSeries Sensei [RAW] effect must be one of: ${Object.keys(SENSEI_RAW_EFFECT_TO_BYTE).join(", ")}.`, + ); + } + return new Uint8Array([...SENSEI_RAW_COMMAND.ledEffect, byte]); +} + +// -- Button mapping -------------------------------------------------------- + +/** + * 8 button slots with 3-byte fields. The offset order is rivalcfg's exact + * `buttons` table, where the physical buttons 6 and 7 are assigned in swap + * order (`button7` at 0x0F, `button6` at 0x12); the ids are sequential, so + * this codec keeps the rivalcfg offsets verbatim rather than "correcting" + * them. + */ +export const SENSEI_RAW_BUTTONS = { + button1: { id: 0x01, offset: 0x00 }, + button2: { id: 0x02, offset: 0x03 }, + button3: { id: 0x03, offset: 0x06 }, + button4: { id: 0x04, offset: 0x09 }, + button5: { id: 0x05, offset: 0x0c }, + button7: { id: 0x07, offset: 0x0f }, + button6: { id: 0x06, offset: 0x12 }, + button8: { id: 0x08, offset: 0x15 }, +} as const satisfies Record; + +export type SenseiRawButtonName = keyof typeof SENSEI_RAW_BUTTONS; + +const SENSEI_RAW_BUTTON_FIELD_LENGTH = 3; +const SENSEI_RAW_BUTTON_DISABLE = 0x00; +const SENSEI_RAW_BUTTON_DPI_SWITCH = 0x30; +const SENSEI_RAW_BUTTON_KEYBOARD = 0x10; +const SENSEI_RAW_BUTTON_MULTIMEDIA = 0x11; + +export type SenseiRawButtonAction = + | { type: "button"; target: SenseiRawButtonName } + | { type: "disabled" } + | { type: "dpiSwitch" } + | { type: "keyboard"; code: number } + | { type: "multimedia"; code: number }; + +function encodeKeyCode(code: number, kind: string): number { + if (!Number.isInteger(code) || code < 0 || code > 255) { + throw new SenseiRawProtocolError(`${kind} codes must be integers 0–255.`); + } + return code; +} + +/** + * `31 00` + one 24-byte packet (8 slots × 3-byte fields), zero-filled + * except where a mapping is given. Pass every button explicitly — unmapped + * buttons stay disabled, matching this package's no-silent-defaults policy. + */ +export function steelseriesSenseiRawEncodeButtonsMapping( + mapping: Partial>, +): Uint8Array { + for (const name of Object.keys(mapping)) { + if (!Object.hasOwn(SENSEI_RAW_BUTTONS, name)) { + throw new SenseiRawProtocolError(`Unknown SteelSeries Sensei [RAW] button "${name}".`); + } + } + + const names = Object.keys(SENSEI_RAW_BUTTONS) as SenseiRawButtonName[]; + const packet = new Array(names.length * SENSEI_RAW_BUTTON_FIELD_LENGTH).fill(0x00); + + for (const [name, action] of Object.entries(mapping) as Array<[SenseiRawButtonName, SenseiRawButtonAction]>) { + const { offset } = SENSEI_RAW_BUTTONS[name]; + switch (action.type) { + case "button": { + if (!Object.hasOwn(SENSEI_RAW_BUTTONS, action.target)) { + throw new SenseiRawProtocolError(`Unknown SteelSeries Sensei [RAW] button "${action.target}".`); + } + packet[offset] = SENSEI_RAW_BUTTONS[action.target].id; + break; + } + case "disabled": { + packet[offset] = SENSEI_RAW_BUTTON_DISABLE; + break; + } + case "dpiSwitch": { + packet[offset] = SENSEI_RAW_BUTTON_DPI_SWITCH; + break; + } + case "keyboard": { + packet[offset] = SENSEI_RAW_BUTTON_KEYBOARD; + packet[offset + 1] = encodeKeyCode(action.code, "Keyboard scan"); + break; + } + case "multimedia": { + packet[offset] = SENSEI_RAW_BUTTON_MULTIMEDIA; + packet[offset + 1] = encodeKeyCode(action.code, "Multimedia key"); + break; + } + default: { + throw new SenseiRawProtocolError("Unsupported SteelSeries Sensei [RAW] button action."); + } + } + } + + return new Uint8Array([...SENSEI_RAW_COMMAND.buttonsMapping, ...packet]); +}