From a4a08f8aaf38f5d072620990b8e5359c273ca1fe Mon Sep 17 00:00:00 2001 From: Mikkel ALMONTE--RINGAUD Date: Mon, 5 Oct 2026 02:15:00 +0200 Subject: [PATCH 1/2] feat: add SteelSeries Aerox 3 Wireless support --- docs/steelseries-testing.md | 60 +++ src/drivers/registry.test.ts | 2 +- src/drivers/registry.ts | 4 +- .../steelseries/aerox3-wireless-hid.test.ts | 208 ++++++++ .../steelseries/aerox3-wireless-hid.ts | 500 ++++++++++++++++++ src/steelseries/aerox3-wireless.test.ts | 139 +++++ src/steelseries/aerox3-wireless.ts | 383 ++++++++++++++ src/steelseries/devices.ts | 42 ++ src/steelseries/index.ts | 1 + 9 files changed, 1337 insertions(+), 2 deletions(-) create mode 100644 src/drivers/steelseries/aerox3-wireless-hid.test.ts create mode 100644 src/drivers/steelseries/aerox3-wireless-hid.ts create mode 100644 src/steelseries/aerox3-wireless.test.ts create mode 100644 src/steelseries/aerox3-wireless.ts diff --git a/docs/steelseries-testing.md b/docs/steelseries-testing.md index 4127d21..dfe6bfa 100644 --- a/docs/steelseries-testing.md +++ b/docs/steelseries-testing.md @@ -61,3 +61,63 @@ Do not test firmware flashing, factory reset, lighting, or button remapping. The driver implements none of them, and the lighting/button commands are documented in `src/steelseries/rival3.ts` as known-but-withheld until there is hardware evidence and a reason to ship them. + +## Aerox 3 Wireless + +Supported identifiers: + +- `1038:183a`: Aerox 3 Wireless over the USB cable, hardware-verified +- `1038:1838`: Aerox 3 Wireless over the 2.4 GHz dongle, hardware-verified +- `1038:187a`: CS2 Dragon Lore Edition over the USB cable, unverified +- `1038:1878`: CS2 Dragon Lore Edition over the 2.4 GHz dongle, unverified + +The codec is transcribed from rivalcfg's `aerox3_wireless_wired.py` and +`aerox3_wireless_wireless.py`. Over the dongle every command byte has `0x40` +ORed in. Bluetooth mode is not configurable through this protocol. + +### Observed on hardware + +On macOS, with the bytes produced by `src/steelseries/aerox3-wireless.ts`: + +- The config channel is the usage page `0xFFC0` usage `0x01` collection, which + is interface 3 over the cable. The dongle exposes the same collection. +- Every write is acked by a 64-byte input report that echoes the command byte + followed by `00`, for example `2b 00` then `11 00`. Over the dongle the third + byte is `01`, for example `69 00 01`. +- The battery query answers `92 95` over the cable (charging, 100%) and + `d2 15` over the dongle (discharging, 100%). +- Polling rate: 125 Hz and 1000 Hz both measured on the mouse input collection, + over the cable and over the dongle, with median report gaps of 8.00 ms and + 1.00 ms. +- DPI presets: a 400/1600 table cycled exactly those two stages with the DPI + button and survived a replug. The 5-stage default table written over the + dongle cycled 5 stages. +- Zone colors land on the top, middle and bottom LEDs in that order, but are + not kept across a power cycle. The mouse boots into its default lighting. +- Default lighting `rainbow` (`27 01 00`) makes the mouse boot into a rainbow + cycle. +- rivalcfg's runtime rainbow `22 FF` is acked but leaves the strip dark, and + `22 07` does too. The driver does not offer a runtime rainbow. +- Reactive color flashes the strip on click. While it is on, the strip stays + dark between clicks. +- Dim timer: 0 keeps the LEDs on, 5 seconds dims them after 5 idle seconds. +- Sleep timer: with 1 minute set over the dongle, the mouse stopped answering + between 60 and 70 idle seconds. While it sleeps the dongle answers every + query with an unsolicited `40 ff 01` report. The same report also arrives + once right after a polling rate change over the dongle. +- Button mapping: the DPI button remapped to the `A` key typed `a`, and every + other button kept its default action. + +### Checklist for the remaining product ids + +1. Confirm the battery level and charging state match SteelSeries GG. +2. Edit each DPI stage, change the active stage, and change the stage count. + Confirm with the DPI button that the mouse cycles exactly the written table. +3. Write each polling rate and verify it with an external rate meter. +4. Set each strip zone color, Off, a reactive click color, and the default + lighting, then power-cycle to check the default lighting. +5. Set the sleep and dim timers and confirm the mouse sleeps and dims. +6. Remap the DPI button to a key and back, and confirm scroll still works. +7. Power-cycle the mouse and confirm DPI, polling, timers and buttons persisted. +8. Only then set `verified: true` on the exercised product id in + `src/steelseries/devices.ts`. diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index 90ced93..6924f47 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -20,7 +20,7 @@ import { COOLERMASTER_PRODUCT_IDS } from "@openmouse/protocol/coolermaster"; const DEVICES_DIR = dirname(fileURLToPath(import.meta.url)); const REPORT_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0x09, 0x0e, 0x0f, 0x10, 0x11, 0x20, 0x51, 0xa1, 0xb3, 0xb4, 0xb5]; -const USAGE_PAGES = [0x01, 0x0a, 0x0c, 0x8c, 0xFF07, 0xff, 0xff00, 0xff01, 0xff02, 0xff05, 0xff0a, 0xff1c, 0xff43, 0xff55, 0xff60, 0xffa0, 0xffc1, 0xffc2, 0xffff]; +const USAGE_PAGES = [0x01, 0x0a, 0x0c, 0x8c, 0xFF07, 0xff, 0xff00, 0xff01, 0xff02, 0xff05, 0xff0a, 0xff1c, 0xff43, 0xff55, 0xff60, 0xffa0, 0xffc0, 0xffc1, 0xffc2, 0xffff]; // Usage 4 is the Corsair config collection; 0x61 is VIA raw HID; 0xc7 is // Ryunix telemetry; 0x0e is Rapoo's 0xBA configuration channel. const USAGES = [0, 1, 0x0212, 2, 4, 0x0e, 0x10, 0x61, 0xc7]; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 24e4c71..4f430be 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -44,6 +44,7 @@ import { GWolvesHidClient } from "./gwolves/hid.ts"; import { GWolvesXviHidClient } from "./gwolves/xvi-hid.ts"; import { SteelSeriesRival3HidClient } from "./steelseries/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"; @@ -74,7 +75,7 @@ import { RapooHidClient } from "./rapoo/hid.ts"; import { CoolerMasterHidClient } from "./coolermaster/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | 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 | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | 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 | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient; export interface DeviceDriver { brand: string; @@ -147,6 +148,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "G-Wolves", supports: (device) => GWolvesXviHidClient.isSupported(device), create: (device) => new GWolvesXviHidClient(device), score: () => 7 }, { brand: "SteelSeries", supports: (device) => SteelSeriesRival3HidClient.isSupported(device), create: (device) => new SteelSeriesRival3HidClient(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 }, diff --git a/src/drivers/steelseries/aerox3-wireless-hid.test.ts b/src/drivers/steelseries/aerox3-wireless-hid.test.ts new file mode 100644 index 0000000..6c84488 --- /dev/null +++ b/src/drivers/steelseries/aerox3-wireless-hid.test.ts @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SteelSeriesAerox3WirelessHidClient } from "./aerox3-wireless-hid.ts"; + +/** usage page `0xFFC0` as read from the `1038:1838` dongle's report descriptor. */ +const CONFIG_COLLECTION = { + usagePage: 0xffc0, + usage: 0x01, + children: [], + inputReports: [{ reportId: 0 }], + outputReports: [{ reportId: 0 }], + featureReports: [], +} as unknown as HIDCollectionInfo; + +function fakeDevice(options: { + productId?: number; + answerBattery?: boolean; + battery?: number[]; + readback?: boolean; + /** unsolicited reports the dongle pushes before the battery reply. */ + events?: number[][]; +} = {}) { + const sent: number[][] = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const emit = (bytes: number[]) => { + const response = new Uint8Array(64); + response.set(bytes); + queueMicrotask(() => listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent)); + }; + const device = { + vendorId: 0x1038, + productId: options.productId ?? 0x183a, + productName: "SteelSeries Aerox 3 Wireless", + opened: true, + collections: [CONFIG_COLLECTION], + open: async () => {}, + close: async () => {}, + sendReport: async (reportId: number, data: BufferSource) => { + assert.equal(reportId, 0); + const payload = [...new Uint8Array(data as ArrayBuffer)]; + sent.push(payload); + if (payload[0] === 0x92 || payload[0] === 0xd2) { + for (const event of options.events ?? []) emit(event); + if (options.answerBattery !== false) emit(options.battery ?? [payload[0], 0x95]); + } else if (options.readback) { + emit([payload[0]!]); + } + }, + addEventListener: (_type: string, attached: (event: HIDInputReportEvent) => void) => { listener = attached; }, + removeEventListener: () => { listener = null; }, + }; + return { device: device as unknown as HIDDevice, sent }; +} + +const DEFAULT_BUTTONS = (() => { + const packet = new Array(40).fill(0x00); + [0x01, 0x02, 0x03, 0x04, 0x05, 0x30, 0x31, 0x32].forEach((id, index) => { packet[index * 5] = id; }); + return packet; +})(); + +test("claims the four Aerox 3 Wireless pids and not the wired Aerox 3", () => { + const { device } = fakeDevice(); + for (const productId of [0x183a, 0x187a, 0x1838, 0x1878]) { + assert.equal(SteelSeriesAerox3WirelessHidClient.isSupported({ ...device, productId } as HIDDevice), true); + } + assert.equal(SteelSeriesAerox3WirelessHidClient.isSupported({ ...device, productId: 0x1836 } as HIDDevice), false); + // the mouse, keyboard, consumer and 0xFFC1 collections refuse output reports. + for (const usagePage of [0x01, 0x0c, 0xffc1]) { + const collections = [{ usagePage, usage: 0x01, children: [] }] as unknown as HIDCollectionInfo[]; + assert.equal(SteelSeriesAerox3WirelessHidClient.isSupported({ ...device, collections } as HIDDevice), false); + } + assert.equal(SteelSeriesAerox3WirelessHidClient.isSupported({ ...device, productId: 0x1854 } as HIDDevice), false); + assert.equal(SteelSeriesAerox3WirelessHidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false); +}); + +test("readStatus decodes the captured battery reply and reports cached defaults", async () => { + const { device, sent } = fakeDevice(); + const status = await new SteelSeriesAerox3WirelessHidClient(device).readStatus(); + assert.deepEqual(sent, [[0x92]]); + assert.equal(status.batteryPercent, 100); + assert.equal(status.batteryState, "Charging"); + assert.equal(status.connectionType, "Wired"); + assert.deepEqual(status.dpiStages, [400, 800, 1200, 2400, 3200]); + assert.equal(status.dpi, 400); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.sleepTimeout, 300); + assert.equal(status.ui?.valuesVerified, false); + assert.equal(status.buttonMappings?.DPI, "DPI Cycle"); + assert.equal(status.buttonMappings?.["Scroll Up"], "Scroll Up"); + assert.deepEqual(status.lightingZones?.map(({ zone, mode, color }) => [zone, mode, color]), [ + ["Top", "Static", "#ff0000"], + ["Middle", "Static", "#00ff00"], + ["Bottom", "Static", "#0000ff"], + ["Click reaction", "Off", "#ffffff"], + ]); +}); + +test("the battery probe ignores replies that do not echo its command", async () => { + const { device } = fakeDevice({ battery: [0x2b, 0x00] }); + await assert.rejects(new SteelSeriesAerox3WirelessHidClient(device).readStatus(), /SteelSeries GG/); +}); + +test("the dongle battery probe skips unsolicited events and decodes the captured reply", async () => { + const { device, sent } = fakeDevice({ productId: 0x1838, events: [[0x40, 0xff, 0x01]], battery: [0xd2, 0x15] }); + const status = await new SteelSeriesAerox3WirelessHidClient(device).readStatus(); + assert.deepEqual(sent, [[0xd2]]); + assert.equal(status.batteryPercent, 100); + assert.equal(status.batteryState, "Discharging"); +}); + +test("a sleeping mouse behind the dongle gets its own error, not the SteelSeries GG hint", async () => { + const { device } = fakeDevice({ productId: 0x1838, events: [[0x40, 0xff, 0x01]], answerBattery: false }); + await assert.rejects(new SteelSeriesAerox3WirelessHidClient(device).readStatus(), /asleep or out of range/); +}); + +test("2.4 GHz mode flags every command", async () => { + const { device, sent } = fakeDevice({ productId: 0x1838, readback: true }); + const client = new SteelSeriesAerox3WirelessHidClient(device); + const status = await client.readStatus(); + assert.equal(status.connectionType, "Wireless"); + await client.setPollingRate(500); + assert.deepEqual(sent, [[0xd2], [0x6b, 0x01], [0x51, 0x00]]); +}); + +test("dpi stage edits rewrite the whole preset table then save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesAerox3WirelessHidClient(device); + await client.setDpiStageValue(1, 1600); + await client.setActiveDpiStage(1); + await client.setDpiStageCount(2); + assert.deepEqual(sent, [ + [0x2d, 0x05, 0x00, 0x04, 0x12, 0x0d, 0x1b, 0x26], + [0x11, 0x00], + [0x2d, 0x05, 0x01, 0x04, 0x12, 0x0d, 0x1b, 0x26], + [0x11, 0x00], + [0x2d, 0x02, 0x01, 0x04, 0x12], + [0x11, 0x00], + ]); + const status = await client.readStatus(); + assert.deepEqual(status.dpiStages, [400, 1600]); + assert.equal(status.dpi, 1600); +}); + +test("setSleepTimeout takes seconds and writes whole minutes", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesAerox3WirelessHidClient(device); + assert.equal(await client.setSleepTimeout(600), 600); + assert.deepEqual(sent[0], [0x29, 0xc0, 0x27, 0x09]); + await assert.rejects(client.setSleepTimeout(90), /whole minutes/); +}); + +test("lighting zones map to zone colors, rainbow and reactive color", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesAerox3WirelessHidClient(device); + const [top, , , reactive] = (await client.readStatus()).lightingZones!; + sent.length = 0; + await client.setLighting({ ...top!, mode: "Static", color: "#123456" }); + await assert.rejects(client.setLighting({ ...top!, mode: "Spectrum" }), /does not support Spectrum/); + await client.setLighting({ ...reactive!, mode: "Reactive", color: "#00ff00" }); + await client.setLighting({ ...top!, mode: "Off" }); + assert.deepEqual(sent, [ + [0x21, 0x01, 0x00, 0x12, 0x34, 0x56], + [0x11, 0x00], + [0x26, 0x01, 0x00, 0x00, 0xff, 0x00], + [0x11, 0x00], + [0x21, 0x01, 0x00, 0x00, 0x00, 0x00], + [0x11, 0x00], + ]); + const zones = (await client.readStatus()).lightingZones!; + assert.deepEqual(zones.map(({ mode }) => mode), ["Off", "Static", "Static", "Reactive"]); + assert.deepEqual(zones[0]!.modes, ["Static", "Off"]); +}); + +test("remapping one button keeps the rest of the default layout", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesAerox3WirelessHidClient(device); + await client.setButtonMapping("DPI", "Play/Pause"); + const expected = [...DEFAULT_BUTTONS]; + expected[0x19] = 0x61; + expected[0x1a] = 0xcd; + assert.deepEqual(sent, [[0x2a, ...expected], [0x11, 0x00]]); + assert.equal((await client.readStatus()).buttonMappings?.DPI, "Play/Pause"); + await assert.rejects(client.setButtonMapping("Left", "Disabled"), /Left Click/); + await assert.rejects(client.setButtonMapping("Wheel", "Disabled"), /no "Wheel" button/); +}); + +test("invalid values never reach the mouse", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesAerox3WirelessHidClient(device); + await assert.rejects(client.setDpi(150), /100 DPI steps/); + await assert.rejects(client.setPollingRate(2000), /1000 Hz/); + await assert.rejects(client.setDpiStageValue(5, 800), /stages 1 to 5/); + await assert.rejects(client.setDimTimer(5000), /0 to 1200/); + assert.deepEqual(sent, []); +}); + +test("concurrent setters never interleave their write and save", async () => { + const { device, sent } = fakeDevice(); + const client = new SteelSeriesAerox3WirelessHidClient(device); + await Promise.all([client.setPollingRate(125), client.setDimTimer(0)]); + assert.deepEqual(sent, [ + [0x2b, 0x03], + [0x11, 0x00], + [0x23, 0x0f, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x11, 0x00], + ]); +}); diff --git a/src/drivers/steelseries/aerox3-wireless-hid.ts b/src/drivers/steelseries/aerox3-wireless-hid.ts new file mode 100644 index 0000000..7c5dbc2 --- /dev/null +++ b/src/drivers/steelseries/aerox3-wireless-hid.ts @@ -0,0 +1,500 @@ +import type { MouseLighting, MouseLightingMode, MouseStatus } from "../mouse-types.js"; +import { + AEROX3_WIRELESS_DEFAULT_BUTTONS, + AEROX3_WIRELESS_DEFAULT_DIM_TIMER_SECONDS, + AEROX3_WIRELESS_DEFAULT_DPI_PRESETS, + AEROX3_WIRELESS_DEFAULT_POLLING_RATE, + AEROX3_WIRELESS_DEFAULT_SLEEP_TIMER_MINUTES, + AEROX3_WIRELESS_DEFAULT_ZONE_COLORS, + AEROX3_WIRELESS_DPI_MAX, + AEROX3_WIRELESS_DPI_MIN, + AEROX3_WIRELESS_DPI_STEP, + AEROX3_WIRELESS_MAX_DPI_PRESETS, + AEROX3_WIRELESS_MULTIMEDIA_KEYS, + AEROX3_WIRELESS_POLLING_RATES, + AEROX3_WIRELESS_REPORT_ID, + AEROX3_WIRELESS_SLEEP_TIMER_MAX_MINUTES, + AEROX3_WIRELESS_ZONES, + STEELSERIES_PRODUCTS, + STEELSERIES_VENDOR_ID, + steelseriesAerox3WirelessBatteryQuery, + steelseriesAerox3WirelessDecodeBattery, + steelseriesAerox3WirelessDpiOptions, + steelseriesAerox3WirelessEncodeButtonsMapping, + steelseriesAerox3WirelessEncodeDefaultLighting, + steelseriesAerox3WirelessEncodeDimTimer, + steelseriesAerox3WirelessEncodeDpiPresets, + steelseriesAerox3WirelessEncodePollingRate, + steelseriesAerox3WirelessEncodeReactiveColor, + steelseriesAerox3WirelessEncodeSleepTimer, + steelseriesAerox3WirelessEncodeZoneColor, + steelseriesAerox3WirelessSaveCommand, + type Aerox3WirelessBattery, + type Aerox3WirelessButtonAction, + type Aerox3WirelessButtonName, + type Aerox3WirelessDefaultLighting, + type Aerox3WirelessRgb, + type Aerox3WirelessZone, +} from "@openmouse/protocol/steelseries"; + +/** rivalcfg's `command_approve_delay`. */ +const COMMAND_DELAY_MS = 50; +const RESPONSE_TIMEOUT_MS = 500; +/** rivalcfg waits this long for the 2.4 ghz readback after every write. */ +const WIRELESS_READBACK_TIMEOUT_MS = 200; + +const CONFIG_USAGE_PAGE = 0xffc0; +const CONFIG_USAGE = 0x01; + +/** the only collection with a 64-byte output report, the others refuse writes. */ +function hasConfigCollection(collections: readonly HIDCollectionInfo[]): boolean { + return collections.some((collection) => + (collection.usagePage === CONFIG_USAGE_PAGE && collection.usage === CONFIG_USAGE) + || hasConfigCollection(collection.children ?? [])); +} + +/** + * the dongle answers `40 FF 01` in place of the mouse while the mouse sleeps or + * is out of range. it also sends it once right after a polling rate change. + */ +function isMouseUnreachableEvent(payload: Uint8Array): boolean { + return payload[0] === 0x40 && payload[1] === 0xff; +} + +/** the 2.4 ghz dongle pids, every other aerox 3 wireless pid is the usb cable. */ +const WIRELESS_MODE_PRODUCT_IDS = new Set([0x1838, 0x1878]); + +const ZONE_LABELS: Record = { 1: "Top", 2: "Middle", 3: "Bottom" }; +const REACTIVE_ZONE = "Click reaction"; +const ZONE_MODES: readonly MouseLightingMode[] = ["Static", "Off"]; +const REACTIVE_MODES: readonly MouseLightingMode[] = ["Reactive", "Off"]; + +const BUTTON_LABELS: Record = { + button1: "Left", + button2: "Right", + button3: "Middle", + button4: "Back", + button5: "Forward", + button6: "DPI", + scrollUp: "Scroll Up", + scrollDown: "Scroll Down", +}; + +const ACTION_LABELS: ReadonlyArray = [ + ["Left Click", { type: "button", target: "button1" }], + ["Right Click", { type: "button", target: "button2" }], + ["Middle Click", { type: "button", target: "button3" }], + ["Back", { type: "button", target: "button4" }], + ["Forward", { type: "button", target: "button5" }], + ["Scroll Up", { type: "button", target: "scrollUp" }], + ["Scroll Down", { type: "button", target: "scrollDown" }], + ["DPI Cycle", { type: "dpiSwitch" }], + ["Disabled", { type: "disabled" }], + ["Mute", { type: "multimedia", code: AEROX3_WIRELESS_MULTIMEDIA_KEYS.mute }], + ["Play/Pause", { type: "multimedia", code: AEROX3_WIRELESS_MULTIMEDIA_KEYS.playPause }], + ["Next Track", { type: "multimedia", code: AEROX3_WIRELESS_MULTIMEDIA_KEYS.next }], + ["Previous Track", { type: "multimedia", code: AEROX3_WIRELESS_MULTIMEDIA_KEYS.previous }], + ["Volume Up", { type: "multimedia", code: AEROX3_WIRELESS_MULTIMEDIA_KEYS.volumeUp }], + ["Volume Down", { type: "multimedia", code: AEROX3_WIRELESS_MULTIMEDIA_KEYS.volumeDown }], + // keyboard codes are hid usage ids, as in rivalcfg `layout_qwerty.py`. + ...Array.from({ length: 26 }, (_, i) => [`Key ${String.fromCharCode(65 + i)}`, { type: "keyboard", code: 0x04 + i }] as const), + ...Array.from({ length: 10 }, (_, i) => [`Key ${(i + 1) % 10}`, { type: "keyboard", code: 0x1e + i }] as const), + ["Key Enter", { type: "keyboard", code: 0x28 }], + ["Key Escape", { type: "keyboard", code: 0x29 }], + ["Key Tab", { type: "keyboard", code: 0x2b }], + ["Key Space", { type: "keyboard", code: 0x2c }], + ...Array.from({ length: 12 }, (_, i) => [`Key F${i + 1}`, { type: "keyboard", code: 0x3a + i }] as const), + ...Array.from({ length: 12 }, (_, i) => [`Key F${i + 13}`, { type: "keyboard", code: 0x68 + i }] as const), +]; + +function actionKey(action: Aerox3WirelessButtonAction): string { + if (action.type === "button") return `button:${action.target}`; + if (action.type === "keyboard" || action.type === "multimedia") return `${action.type}:${action.code}`; + return action.type; +} + +function actionLabel(action: Aerox3WirelessButtonAction): string { + const key = actionKey(action); + return ACTION_LABELS.find(([, candidate]) => actionKey(candidate) === key)?.[0] ?? "Unknown"; +} + +function toHex({ r, g, b }: Aerox3WirelessRgb): string { + return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; +} + +function fromHex(color: string | null): Aerox3WirelessRgb { + const match = /^#?([0-9a-f]{6})$/i.exec(color ?? ""); + if (!match) throw new Error("Choose a #rrggbb color first."); + const value = Number.parseInt(match[1]!, 16); + return { r: (value >> 16) & 0xff, g: (value >> 8) & 0xff, b: value & 0xff }; +} + +interface ZoneState { + mode: "Static" | "Off"; + color: Aerox3WirelessRgb; +} + +/** + * steelseries aerox 3 wireless webhid control, over the usb cable or the 2.4 ghz dongle. + * + * both transports share one command set, the dongle pids get `0x40` ored into + * every command byte. nothing but the battery can be read back, so dpi stages, + * polling, lighting, timers and buttons come from a cache seeded with + * rivalcfg's defaults and updated after each successful write. every write is + * followed by the save command. dpi, polling, timers and buttons survive a + * power cycle, zone colors do not: the mouse boots into its startup lighting. + * + * the config channel is the usage page `0xFFC0` collection, interface 3 over the + * cable. chrome grants every collection of the mouse at once, so only that one + * is claimed. the battery reply echoes the query byte, `92` over the cable and `D2` + * over the dongle, which tells it apart from readbacks and the dongle's + * unsolicited `40 FF 01` events. + * + * rivalcfg's runtime rainbow `22 FF` is acked by a `1038:183A` unit but leaves + * the strip dark, so it is not offered. rainbow works as startup lighting. + */ +export class SteelSeriesAerox3WirelessHidClient { + readonly device: HIDDevice; + private readonly wireless: boolean; + private queue: Promise = Promise.resolve(); + private listenerAttached = false; + private readonly inputWaiters = new Set<(payload: Uint8Array) => void>(); + private dpiStages: number[] = [...AEROX3_WIRELESS_DEFAULT_DPI_PRESETS]; + private activeDpiStage = 0; + private pollingRateHz: number = AEROX3_WIRELESS_DEFAULT_POLLING_RATE; + private sleepTimerMinutes: number = AEROX3_WIRELESS_DEFAULT_SLEEP_TIMER_MINUTES; + private dimTimerSeconds: number = AEROX3_WIRELESS_DEFAULT_DIM_TIMER_SECONDS; + private readonly zones = new Map( + AEROX3_WIRELESS_ZONES.map((zone) => [zone, { mode: "Static", color: { ...AEROX3_WIRELESS_DEFAULT_ZONE_COLORS[zone] } }]), + ); + private reactiveColor: Aerox3WirelessRgb | null = null; + private buttons: Record = { ...AEROX3_WIRELESS_DEFAULT_BUTTONS }; + + 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 = WIRELESS_MODE_PRODUCT_IDS.has(device.productId); + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== STEELSERIES_VENDOR_ID) return false; + return STEELSERIES_PRODUCTS.get(device.productId)?.family === "aerox3-wireless" + && hasConfigCollection(device.collections); + } + + get pollIntervalMs(): number { return 30_000; } + + get supportedPollingRates(): number[] { return [...AEROX3_WIRELESS_POLLING_RATES]; } + + getDpiOptions(): number[] { return steelseriesAerox3WirelessDpiOptions(); } + + 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 product = STEELSERIES_PRODUCTS.get(this.device.productId); + const lightingZones = this.lightingZones(); + return { + brand: "SteelSeries", + name: this.device.productName?.trim() || `SteelSeries ${product?.model ?? "Aerox 3 Wireless"}`, + ui: { + family: "steelseries-aerox3-wireless", + settingsReady: true, + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + hideSignalCard: true, + showAdvancedSection: true, + pollingNote: "The Aerox 3 Wireless cannot report its settings; values shown are the last written by this app, or SteelSeries defaults.", + defaultDisplayName: "SteelSeries Aerox 3 Wireless", + dpiStageEditor: { + maxStages: AEROX3_WIRELESS_MAX_DPI_PRESETS, + countEditable: true, + minDpi: AEROX3_WIRELESS_DPI_MIN, + maxDpi: AEROX3_WIRELESS_DPI_MAX, + stepDpi: AEROX3_WIRELESS_DPI_STEP, + }, + }, + batteryPercent: battery.level, + batteryState: battery.isCharging ? "Charging" : "Discharging", + dpi: this.dpiStages[this.activeDpiStage]!, + dpiStages: [...this.dpiStages], + activeDpiStage: this.activeDpiStage, + pollingRateHz: this.pollingRateHz, + supportedPollingRates: this.supportedPollingRates, + sleepTimeout: this.sleepTimerMinutes * 60, + activeProfile: null, + buttonMappings: Object.fromEntries( + (Object.keys(BUTTON_LABELS) as Aerox3WirelessButtonName[]).map((name) => [BUTTON_LABELS[name], actionLabel(this.buttons[name])]), + ), + buttonOptions: ACTION_LABELS.map(([label]) => label), + lighting: lightingZones[0], + lightingZones, + connectionType: this.wireless ? "Wireless" : "Wired", + connectionDetail: this.wireless ? "2.4 GHz dongle" : "USB cable", + liftOffDistance: null, + 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; + } + + /** new stages repeat the last one, the user then edits them. */ + async setDpiStageCount(count: number): Promise { + if (!Number.isInteger(count) || count < 1 || count > AEROX3_WIRELESS_MAX_DPI_PRESETS) { + throw new Error(`The Aerox 3 Wireless holds between 1 and ${AEROX3_WIRELESS_MAX_DPI_PRESETS} DPI stages.`); + } + const stages = Array.from({ length: count }, (_, index) => this.dpiStages[index] ?? this.dpiStages.at(-1)!); + await this.writePresets(stages, Math.min(this.activeDpiStage, count - 1)); + return count; + } + + async setDpiStages(stages: readonly number[], activeStage = 0): Promise { + await this.writePresets(stages, activeStage); + } + + async setPollingRate(pollingRateHz: number): Promise { + await this.commit(steelseriesAerox3WirelessEncodePollingRate(pollingRateHz, this.wireless)); + this.pollingRateHz = pollingRateHz; + return pollingRateHz; + } + + /** the firmware counts whole minutes, 0 disables sleep. */ + async setSleepTimeout(seconds: number): Promise { + if (!Number.isInteger(seconds) || seconds % 60 !== 0 || seconds < 0 || seconds > AEROX3_WIRELESS_SLEEP_TIMER_MAX_MINUTES * 60) { + throw new Error(`The Aerox 3 Wireless sleep timeout must be whole minutes up to ${AEROX3_WIRELESS_SLEEP_TIMER_MAX_MINUTES} minutes, or 0 to disable it.`); + } + await this.setSleepTimer(seconds / 60); + return seconds; + } + + async setSleepTimer(minutes: number): Promise { + await this.commit(steelseriesAerox3WirelessEncodeSleepTimer(minutes, this.wireless)); + this.sleepTimerMinutes = minutes; + } + + async setDimTimer(seconds: number): Promise { + await this.commit(steelseriesAerox3WirelessEncodeDimTimer(seconds, this.wireless)); + this.dimTimerSeconds = seconds; + } + + get dimTimer(): number { return this.dimTimerSeconds; } + + async setZoneColor(zone: Aerox3WirelessZone, color: Aerox3WirelessRgb): Promise { + await this.commit(steelseriesAerox3WirelessEncodeZoneColor(zone, color, this.wireless)); + this.zones.set(zone, { mode: "Static", color: { ...color } }); + } + + async setReactiveColor(color: Aerox3WirelessRgb | null): Promise { + await this.commit(steelseriesAerox3WirelessEncodeReactiveColor(color, this.wireless)); + this.reactiveColor = color && { ...color }; + } + + /** the lighting the mouse boots into, the only lighting that persists. */ + async setDefaultLighting(mode: Aerox3WirelessDefaultLighting): Promise { + await this.commit(steelseriesAerox3WirelessEncodeDefaultLighting(mode, this.wireless)); + } + + async setLighting(lighting: MouseLighting): Promise { + if (lighting.zone === REACTIVE_ZONE) { + if (lighting.mode !== "Reactive" && lighting.mode !== "Off") throw new Error("Choose Reactive or Off."); + await this.setReactiveColor(lighting.mode === "Off" ? null : fromHex(lighting.color)); + return this.lightingZones().find(({ zone }) => zone === REACTIVE_ZONE)!; + } + const zone = AEROX3_WIRELESS_ZONES.find((candidate) => ZONE_LABELS[candidate] === lighting.zone); + if (zone === undefined) throw new Error(`The Aerox 3 Wireless has no "${lighting.zone}" lighting zone.`); + switch (lighting.mode) { + case "Static": + await this.setZoneColor(zone, fromHex(lighting.color)); + break; + case "Off": + await this.setZoneColor(zone, { r: 0, g: 0, b: 0 }); + this.zones.set(zone, { mode: "Off", color: this.zones.get(zone)!.color }); + break; + default: + throw new Error(`The Aerox 3 Wireless does not support ${lighting.mode ?? "that"} lighting.`); + } + return this.lightingZones().find(({ zone: label }) => label === lighting.zone)!; + } + + async setButtonMapping(button: string, action: string): Promise { + const name = (Object.keys(BUTTON_LABELS) as Aerox3WirelessButtonName[]).find((candidate) => BUTTON_LABELS[candidate] === button); + if (!name) throw new Error(`The Aerox 3 Wireless has no "${button}" button.`); + const resolved = action === "Default" + ? AEROX3_WIRELESS_DEFAULT_BUTTONS[name] + : ACTION_LABELS.find(([label]) => label === action)?.[1]; + if (!resolved) throw new Error(`Unknown button action "${action}".`); + const next = { ...this.buttons, [name]: resolved }; + if (!Object.values(next).some((candidate) => candidate.type === "button" && candidate.target === "button1")) { + throw new Error("Keep at least one button as Left Click."); + } + await this.setButtonsMapping(next); + } + + async setButtonsMapping(mapping: Partial>): Promise { + const next = { ...AEROX3_WIRELESS_DEFAULT_BUTTONS, ...mapping }; + await this.commit(steelseriesAerox3WirelessEncodeButtonsMapping(next, this.wireless)); + this.buttons = next; + } + + private lightingZones(): MouseLighting[] { + const base = { + color2: null, + dualColorModes: [], + reactiveModes: [], + speeds: [], + speed: null, + writeOnly: true, + } as const; + return [ + ...AEROX3_WIRELESS_ZONES.map((zone): MouseLighting => { + const state = this.zones.get(zone)!; + return { + ...base, + zone: ZONE_LABELS[zone], + group: "Strip", + modes: ZONE_MODES, + mode: state.mode, + color: toHex(state.color), + colorModes: ["Static"], + }; + }), + { + ...base, + zone: REACTIVE_ZONE, + modes: REACTIVE_MODES, + mode: this.reactiveColor ? "Reactive" : "Off", + color: this.reactiveColor ? toHex(this.reactiveColor) : "#ffffff", + colorModes: ["Reactive"], + }, + ]; + } + + private requireStage(stage: number): void { + if (!Number.isInteger(stage) || stage < 0 || stage >= this.dpiStages.length) { + throw new Error(`The Aerox 3 Wireless has DPI stages 1 to ${this.dpiStages.length}.`); + } + } + + private async writePresets(stages: readonly number[], activeStage: number): Promise { + await this.commit(steelseriesAerox3WirelessEncodeDpiPresets(stages, activeStage, this.wireless)); + this.dpiStages = [...stages]; + this.activeDpiStage = activeStage; + } + + /** sends one setting, then the save command, as a single queued transaction. */ + private async commit(report: Uint8Array): Promise { + await this.run(async () => { + await this.open(); + await this.send(report); + await this.delay(COMMAND_DELAY_MS); + await this.send(steelseriesAerox3WirelessSaveCommand(this.wireless)); + }); + } + + /** over the dongle rivalcfg drains a readback after each write, a missing one is not an error. */ + private async send(report: Uint8Array): Promise { + if (!this.wireless) { + await this.write(report); + return; + } + await this.awaitResponse(report, () => true, WIRELESS_READBACK_TIMEOUT_MS); + } + + /** `92` doubles as the connectivity probe. */ + private async probeBattery(): Promise { + const query = steelseriesAerox3WirelessBatteryQuery(this.wireless); + let unreachable = false; + const payload = await this.awaitResponse(query, (reply) => { + if (isMouseUnreachableEvent(reply)) unreachable = true; + return reply[0] === query[0]; + }, RESPONSE_TIMEOUT_MS); + if (!payload && unreachable) { + throw new Error("The Aerox 3 Wireless is asleep or out of range of its dongle. Move or click it to wake it, then try again."); + } + if (!payload) { + throw new Error( + "The Aerox 3 Wireless 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 steelseriesAerox3WirelessDecodeBattery(payload); + } + + private async awaitResponse( + query: Uint8Array, + accepts: (payload: Uint8Array) => boolean, + timeoutMs: number, + ): Promise { + const response = new Promise((resolve) => { + let timer: ReturnType; + const finish = (payload: Uint8Array | null): void => { + if (payload && !accepts(payload)) return; + clearTimeout(timer); + this.inputWaiters.delete(finish as (payload: Uint8Array) => void); + resolve(payload); + }; + timer = setTimeout(() => finish(null), timeoutMs); + this.inputWaiters.add(finish as (payload: Uint8Array) => void); + }); + try { + await this.write(query); + } catch (error) { + this.inputWaiters.clear(); + throw error; + } + return await response; + } + + private async write(payload: Uint8Array): Promise { + await this.device.sendReport(AEROX3_WIRELESS_REPORT_ID, payload.buffer as ArrayBuffer); + } + + private delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); + } + + private async run(operation: () => Promise): Promise { + const result = this.queue.then(operation, operation); + this.queue = result.then(() => undefined, () => undefined); + return await result; + } +} diff --git a/src/steelseries/aerox3-wireless.test.ts b/src/steelseries/aerox3-wireless.test.ts new file mode 100644 index 0000000..f0e7eac --- /dev/null +++ b/src/steelseries/aerox3-wireless.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + AEROX3_WIRELESS_DEFAULT_BUTTONS, + AEROX3_WIRELESS_DPI_MAX, + AEROX3_WIRELESS_DPI_MIN, + Aerox3WirelessProtocolError, + applyAerox3WirelessFlag, + steelseriesAerox3WirelessBatteryQuery, + steelseriesAerox3WirelessDecodeBattery, + steelseriesAerox3WirelessDpiOptions, + steelseriesAerox3WirelessEncodeButtonsMapping, + steelseriesAerox3WirelessEncodeDefaultLighting, + steelseriesAerox3WirelessEncodeDimTimer, + steelseriesAerox3WirelessEncodeDpiPresets, + steelseriesAerox3WirelessEncodePollingRate, + steelseriesAerox3WirelessEncodeRainbowEffect, + steelseriesAerox3WirelessEncodeReactiveColor, + steelseriesAerox3WirelessEncodeSleepTimer, + steelseriesAerox3WirelessEncodeZoneColor, + steelseriesAerox3WirelessSaveCommand, +} from "./aerox3-wireless.ts"; + +test("the wireless flag only touches byte 0", () => { + assert.deepEqual(applyAerox3WirelessFlag([0x23, 0x0f, 0x01]), [0x63, 0x0f, 0x01]); + assert.deepEqual(applyAerox3WirelessFlag([0x92]), [0xd2]); + assert.throws(() => applyAerox3WirelessFlag([]), Aerox3WirelessProtocolError); +}); + +test("dpi options span the truemove air table", () => { + const options = steelseriesAerox3WirelessDpiOptions(); + assert.equal(options[0], AEROX3_WIRELESS_DPI_MIN); + assert.equal(options.at(-1), AEROX3_WIRELESS_DPI_MAX); +}); + +test("encodes the rivalcfg default dpi presets", () => { + assert.deepEqual( + [...steelseriesAerox3WirelessEncodeDpiPresets([400, 800, 1200, 2400, 3200], 0, false)], + [0x2d, 0x05, 0x00, 0x04, 0x09, 0x0d, 0x1b, 0x26], + ); + assert.deepEqual([...steelseriesAerox3WirelessEncodeDpiPresets([1600], 0, true)], [0x6d, 0x01, 0x00, 0x12]); + assert.throws(() => steelseriesAerox3WirelessEncodeDpiPresets([150], 0, false), /100 DPI steps/); + assert.throws(() => steelseriesAerox3WirelessEncodeDpiPresets([400, 400, 400, 400, 400, 400], 0, false), /1 to 5/); + assert.throws(() => steelseriesAerox3WirelessEncodeDpiPresets([400], 1, false), Aerox3WirelessProtocolError); +}); + +test("encodes polling rates", () => { + assert.deepEqual([...steelseriesAerox3WirelessEncodePollingRate(1000, false)], [0x2b, 0x00]); + assert.deepEqual([...steelseriesAerox3WirelessEncodePollingRate(125, true)], [0x6b, 0x03]); + assert.throws(() => steelseriesAerox3WirelessEncodePollingRate(2000, false), Aerox3WirelessProtocolError); +}); + +test("encodes zone, reactive and rainbow lighting", () => { + assert.deepEqual([...steelseriesAerox3WirelessEncodeZoneColor(3, { r: 1, g: 2, b: 3 }, false)], [0x21, 0x01, 0x02, 1, 2, 3]); + assert.deepEqual([...steelseriesAerox3WirelessEncodeZoneColor(1, { r: 255, g: 0, b: 0 }, true)], [0x61, 0x01, 0x00, 255, 0, 0]); + assert.throws(() => steelseriesAerox3WirelessEncodeZoneColor(4 as never, { r: 0, g: 0, b: 0 }, false), Aerox3WirelessProtocolError); + assert.throws(() => steelseriesAerox3WirelessEncodeZoneColor(1, { r: 256, g: 0, b: 0 }, false), Aerox3WirelessProtocolError); + assert.deepEqual([...steelseriesAerox3WirelessEncodeReactiveColor(null, false)], [0x26, 0, 0, 0, 0, 0]); + assert.deepEqual([...steelseriesAerox3WirelessEncodeReactiveColor({ r: 9, g: 8, b: 7 }, true)], [0x66, 0x01, 0x00, 9, 8, 7]); + assert.deepEqual([...steelseriesAerox3WirelessEncodeRainbowEffect(false)], [0x22, 0xff]); + assert.deepEqual([...steelseriesAerox3WirelessEncodeDefaultLighting("reactive-rainbow", false)], [0x27, 0x01, 0x01]); + assert.throws(() => steelseriesAerox3WirelessEncodeDefaultLighting("toString" as never, false), Aerox3WirelessProtocolError); +}); + +test("encodes timers as little-endian milliseconds", () => { + assert.deepEqual([...steelseriesAerox3WirelessEncodeSleepTimer(5, false)], [0x29, 0xe0, 0x93, 0x04]); + assert.deepEqual([...steelseriesAerox3WirelessEncodeDimTimer(30, true)], [0x63, 0x0f, 0x01, 0x00, 0x00, 0x30, 0x75, 0x00]); + assert.throws(() => steelseriesAerox3WirelessEncodeSleepTimer(21, false), /0 to 20/); + assert.throws(() => steelseriesAerox3WirelessEncodeDimTimer(1201, false), /0 to 1200/); +}); + +test("save and battery commands", () => { + assert.deepEqual([...steelseriesAerox3WirelessSaveCommand(false)], [0x11, 0x00]); + assert.deepEqual([...steelseriesAerox3WirelessSaveCommand(true)], [0x51, 0x00]); + assert.deepEqual([...steelseriesAerox3WirelessBatteryQuery(false)], [0x92]); + assert.deepEqual([...steelseriesAerox3WirelessBatteryQuery(true)], [0xd2]); +}); + +test("decodes the battery reply captured from a 1038:183A unit", () => { + const reply = new Uint8Array(64); + reply[0] = 0x92; + reply[1] = 0x95; + assert.deepEqual(steelseriesAerox3WirelessDecodeBattery(reply), { level: 100, isCharging: true }); + assert.deepEqual(steelseriesAerox3WirelessDecodeBattery(new Uint8Array([0x92, 0x0b])), { level: 50, isCharging: false }); + assert.deepEqual(steelseriesAerox3WirelessDecodeBattery(new Uint8Array([0x92, 0x00])), { level: 0, isCharging: false }); + assert.throws(() => steelseriesAerox3WirelessDecodeBattery(new Uint8Array([0x92])), Aerox3WirelessProtocolError); +}); + +function defaultButtonsPacket(): number[] { + const packet = new Array(40).fill(0x00); + packet[0x00] = 0x01; + packet[0x05] = 0x02; + packet[0x0a] = 0x03; + packet[0x0f] = 0x04; + packet[0x14] = 0x05; + packet[0x19] = 0x30; + packet[0x1e] = 0x31; + packet[0x23] = 0x32; + return packet; +} + +test("an empty mapping encodes rivalcfg's default 40-byte button packet", () => { + assert.deepEqual([...steelseriesAerox3WirelessEncodeButtonsMapping({}, false)], [0x2a, ...defaultButtonsPacket()]); + assert.deepEqual( + [...steelseriesAerox3WirelessEncodeButtonsMapping(AEROX3_WIRELESS_DEFAULT_BUTTONS, true)], + [0x6a, ...defaultButtonsPacket()], + ); +}); + +test("remapping one button keeps the others at their defaults", () => { + const expected = defaultButtonsPacket(); + expected[0x14] = 0x61; + expected[0x15] = 0xcd; + expected[0x19] = 0x51; + expected[0x1a] = 0x68; + assert.deepEqual( + [...steelseriesAerox3WirelessEncodeButtonsMapping({ + button5: { type: "multimedia", code: 0xcd }, + button6: { type: "keyboard", code: 0x68 }, + }, false)], + [0x2a, ...expected], + ); +}); + +test("rejects unknown buttons, targets and codes", () => { + assert.throws( + () => steelseriesAerox3WirelessEncodeButtonsMapping({ ["button7" as never]: { type: "disabled" } }, false), + Aerox3WirelessProtocolError, + ); + assert.throws( + () => steelseriesAerox3WirelessEncodeButtonsMapping({ button1: { type: "button", target: "button9" as never } }, false), + Aerox3WirelessProtocolError, + ); + assert.throws( + () => steelseriesAerox3WirelessEncodeButtonsMapping({ button1: { type: "keyboard", code: 256 } }, false), + Aerox3WirelessProtocolError, + ); +}); diff --git a/src/steelseries/aerox3-wireless.ts b/src/steelseries/aerox3-wireless.ts new file mode 100644 index 0000000..56bbc0e --- /dev/null +++ b/src/steelseries/aerox3-wireless.ts @@ -0,0 +1,383 @@ +/** + * steelseries aerox 3 wireless configuration protocol, pure encode/decode helpers. + * + * transcribed from rivalcfg `rivalcfg/devices/aerox3_wireless_wired.py` for the + * usb-cabled mode, `1038:183A` and the cs2 dragon lore edition `1038:187A`, and + * `aerox3_wireless_wireless.py` for the 2.4 ghz dongle mode, `1038:1838` and + * `1038:1878`. the dongle profile is the wired settings dict passed through a + * `_patch_command` helper that ors `0b01000000` into byte 0 of every command, + * implemented here as `applyAerox3WirelessFlag`. + * + * the settings dict is byte-identical to `aerox5_wireless_wired.py` except for + * `buttons_mapping`: the aerox 3 wireless has 6 buttons plus scroll up/down, so + * its packet is 40 bytes with the scroll fields at `0x1E`/`0x23`, where the + * aerox 5 wireless has 9 buttons and a 55-byte packet. it is also unrelated to + * the wired-only aerox 3 in `./aerox3.ts`, whose polling bytes, zone packing, + * rainbow command and dpi preset base all differ. + * + * every command is an hid output report with report id `0x00` on the usage page + * `0xFFC0` collection. settings are write-only and the mouse acks each write by + * echoing its command byte followed by `00`. battery level is readable through + * `92`. the save command `11 00` persists dpi, polling, timers and buttons, but + * zone colors are lost at power-off and the mouse boots into its default + * lighting, as rivalcfg's docs warn for newer steelseries mice. + */ + +import { TRUEMOVE_AIR_DPI_TO_BYTE } from "./rival3-wireless.js"; + +export const AEROX3_WIRELESS_REPORT_ID = 0x00; + +const WIRELESS_FLAG = 0b01000000; + +/** wired-mode command bytes, see `applyAerox3WirelessFlag` for 2.4 ghz mode. */ +export const AEROX3_WIRELESS_COMMAND = { + dpiPresets: [0x2d], + pollingRate: [0x2b], + zoneColor: [0x21, 0x01], + reactiveColor: [0x26], + sleepTimer: [0x29], + dimTimer: [0x23, 0x0f, 0x01, 0x00, 0x00], + buttonsMapping: [0x2a], + rainbowEffect: [0x22, 0xff], + defaultLighting: [0x27], + batteryLevel: [0x92], +} as const; + +export const AEROX3_WIRELESS_SAVE_COMMAND = [0x11, 0x00] as const; + +/** rivalcfg `_patch_command`: ors `0x40` into the first byte. */ +export function applyAerox3WirelessFlag(command: readonly number[]): number[] { + if (command.length === 0) { + throw new Aerox3WirelessProtocolError("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 ? applyAerox3WirelessFlag(command) : [...command]; +} + +export const AEROX3_WIRELESS_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 AEROX3_WIRELESS_DPI_MIN = 100; +export const AEROX3_WIRELESS_DPI_MAX = 18000; +export const AEROX3_WIRELESS_DPI_STEP = 100; +export const AEROX3_WIRELESS_MAX_DPI_PRESETS = 5; +export const AEROX3_WIRELESS_SLEEP_TIMER_MAX_MINUTES = 20; +export const AEROX3_WIRELESS_DIM_TIMER_MAX_SECONDS = 1200; +export const AEROX3_WIRELESS_BATTERY_RESPONSE_LENGTH = 2; + +/** rivalcfg profile defaults. */ +export const AEROX3_WIRELESS_DEFAULT_DPI_PRESETS = [400, 800, 1200, 2400, 3200] as const; +export const AEROX3_WIRELESS_DEFAULT_POLLING_RATE = 1000; +export const AEROX3_WIRELESS_DEFAULT_SLEEP_TIMER_MINUTES = 5; +export const AEROX3_WIRELESS_DEFAULT_DIM_TIMER_SECONDS = 30; + +const BATTERY_CHARGING_FLAG = 0b10000000; + +export class Aerox3WirelessProtocolError extends Error {} + +/** every dpi the truemove air table can express, ascending. */ +export function steelseriesAerox3WirelessDpiOptions(): number[] { + return [...TRUEMOVE_AIR_DPI_TO_BYTE.keys()].sort((a, b) => a - b); +} + +/** `2D ...`, one byte per dpi, `selectedIndex` is 0-based on the wire. */ +export function steelseriesAerox3WirelessEncodeDpiPresets( + presets: readonly number[], + selectedIndex: number, + wireless: boolean, +): Uint8Array { + if (presets.length < 1 || presets.length > AEROX3_WIRELESS_MAX_DPI_PRESETS) { + throw new Aerox3WirelessProtocolError( + `SteelSeries Aerox 3 Wireless supports 1 to ${AEROX3_WIRELESS_MAX_DPI_PRESETS} DPI presets.`, + ); + } + if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= presets.length) { + throw new Aerox3WirelessProtocolError( + `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 Aerox3WirelessProtocolError( + `SteelSeries Aerox 3 Wireless DPI must be ${AEROX3_WIRELESS_DPI_MIN} to ${AEROX3_WIRELESS_DPI_MAX.toLocaleString("en-US")} in ${AEROX3_WIRELESS_DPI_STEP} DPI steps.`, + ); + } + return byte; + }); + return new Uint8Array([...frame(AEROX3_WIRELESS_COMMAND.dpiPresets, wireless), presets.length, selectedIndex, ...encoded]); +} + +/** `2B ` with 1000 as `0x00` down to 125 as `0x03`. */ +export function steelseriesAerox3WirelessEncodePollingRate(pollingRateHz: number, wireless: boolean): Uint8Array { + const byte = POLLING_RATE_TO_BYTE.get(pollingRateHz); + if (byte === undefined) { + throw new Aerox3WirelessProtocolError( + "SteelSeries Aerox 3 Wireless supports 125, 250, 500, or 1000 Hz polling.", + ); + } + return new Uint8Array([...frame(AEROX3_WIRELESS_COMMAND.pollingRate, wireless), byte]); +} + +export interface Aerox3WirelessRgb { + r: number; + g: number; + b: number; +} + +function encodeRgb({ r, g, b }: Aerox3WirelessRgb): [number, number, number] { + for (const channel of [r, g, b]) { + if (!Number.isInteger(channel) || channel < 0 || channel > 255) { + throw new Aerox3WirelessProtocolError("RGB channels must be integers from 0 to 255."); + } + } + return [r, g, b]; +} + +/** zone 1 is the top led of the strip, 2 the middle one, 3 the bottom one. */ +export type Aerox3WirelessZone = 1 | 2 | 3; + +export const AEROX3_WIRELESS_ZONES = [1, 2, 3] as const satisfies readonly Aerox3WirelessZone[]; + +/** rivalcfg defaults are red, lime and blue. */ +export const AEROX3_WIRELESS_DEFAULT_ZONE_COLORS: Readonly> = { + 1: { r: 0xff, g: 0x00, b: 0x00 }, + 2: { r: 0x00, g: 0xff, b: 0x00 }, + 3: { r: 0x00, g: 0x00, b: 0xff }, +}; + +/** `21 01 `. */ +export function steelseriesAerox3WirelessEncodeZoneColor( + zone: Aerox3WirelessZone, + color: Aerox3WirelessRgb, + wireless: boolean, +): Uint8Array { + if (!AEROX3_WIRELESS_ZONES.includes(zone)) { + throw new Aerox3WirelessProtocolError( + "SteelSeries Aerox 3 Wireless has zones 1 (top), 2 (middle), and 3 (bottom) only.", + ); + } + return new Uint8Array([...frame(AEROX3_WIRELESS_COMMAND.zoneColor, wireless), zone - 1, ...encodeRgb(color)]); +} + +/** `26 01 00 ` when enabled, `26 00 00 00 00 00` when `color` is null. */ +export function steelseriesAerox3WirelessEncodeReactiveColor( + color: Aerox3WirelessRgb | null, + wireless: boolean, +): Uint8Array { + const command = frame(AEROX3_WIRELESS_COMMAND.reactiveColor, wireless); + if (color === null) { + return new Uint8Array([...command, 0x00, 0x00, 0x00, 0x00, 0x00]); + } + return new Uint8Array([...command, 0x01, 0x00, ...encodeRgb(color)]); +} + +function encodeMillisecondsLE24(value: number, max: number, unit: string, msPerUnit: number): number[] { + if (!Number.isInteger(value) || value < 0 || value > max) { + throw new Aerox3WirelessProtocolError(`SteelSeries Aerox 3 Wireless ${unit} must be an integer from 0 to ${max}.`); + } + const ms = value * msPerUnit; + return [ms & 0xff, (ms >> 8) & 0xff, (ms >> 16) & 0xff]; +} + +/** `29 `, idle minutes before sleep, 0 disables it. */ +export function steelseriesAerox3WirelessEncodeSleepTimer(minutes: number, wireless: boolean): Uint8Array { + const bytes = encodeMillisecondsLE24(minutes, AEROX3_WIRELESS_SLEEP_TIMER_MAX_MINUTES, "sleep timer minutes", 60_000); + return new Uint8Array([...frame(AEROX3_WIRELESS_COMMAND.sleepTimer, wireless), ...bytes]); +} + +/** `23 0F 01 00 00 `, idle seconds before the leds dim, 0 disables it. */ +export function steelseriesAerox3WirelessEncodeDimTimer(seconds: number, wireless: boolean): Uint8Array { + const bytes = encodeMillisecondsLE24(seconds, AEROX3_WIRELESS_DIM_TIMER_MAX_SECONDS, "dim timer seconds", 1_000); + return new Uint8Array([...frame(AEROX3_WIRELESS_COMMAND.dimTimer, wireless), ...bytes]); +} + +/** + * `22 FF`, rivalcfg's rainbow on every zone. a `1038:183A` unit acks it but the + * strip stays dark, use the `rainbow` default lighting instead. + */ +export function steelseriesAerox3WirelessEncodeRainbowEffect(wireless: boolean): Uint8Array { + return new Uint8Array(frame(AEROX3_WIRELESS_COMMAND.rainbowEffect, wireless)); +} + +export const AEROX3_WIRELESS_DEFAULT_LIGHTING = { + off: [0x00, 0x00], + reactive: [0x00, 0x01], + rainbow: [0x01, 0x00], + "reactive-rainbow": [0x01, 0x01], +} as const; + +export type Aerox3WirelessDefaultLighting = keyof typeof AEROX3_WIRELESS_DEFAULT_LIGHTING; + +/** `27 `, the lighting shown before a host sends any color. */ +export function steelseriesAerox3WirelessEncodeDefaultLighting( + mode: Aerox3WirelessDefaultLighting, + wireless: boolean, +): Uint8Array { + const bytes = Object.hasOwn(AEROX3_WIRELESS_DEFAULT_LIGHTING, mode) ? AEROX3_WIRELESS_DEFAULT_LIGHTING[mode] : undefined; + if (bytes === undefined) { + throw new Aerox3WirelessProtocolError( + `SteelSeries Aerox 3 Wireless default lighting must be one of: ${Object.keys(AEROX3_WIRELESS_DEFAULT_LIGHTING).join(", ")}.`, + ); + } + return new Uint8Array([...frame(AEROX3_WIRELESS_COMMAND.defaultLighting, wireless), ...bytes]); +} + +/** `11 00`, commits the current settings to onboard flash. */ +export function steelseriesAerox3WirelessSaveCommand(wireless: boolean): Uint8Array { + return new Uint8Array(frame(AEROX3_WIRELESS_SAVE_COMMAND, wireless)); +} + +/** `92`, answered by a two-byte input report. */ +export function steelseriesAerox3WirelessBatteryQuery(wireless: boolean): Uint8Array { + return new Uint8Array(frame(AEROX3_WIRELESS_COMMAND.batteryLevel, wireless)); +} + +export interface Aerox3WirelessBattery { + /** 0 to 100 in steps of 5. */ + level: number; + isCharging: boolean; +} + +/** `data[1]` holds the charging flag in its top bit and a 1-based level in steps of 5 below it. */ +export function steelseriesAerox3WirelessDecodeBattery(payload: Uint8Array): Aerox3WirelessBattery { + if (payload.length < AEROX3_WIRELESS_BATTERY_RESPONSE_LENGTH) { + throw new Aerox3WirelessProtocolError( + "SteelSeries Aerox 3 Wireless battery response is shorter than two bytes.", + ); + } + const statusByte = payload[1]!; + const raw = statusByte & ~BATTERY_CHARGING_FLAG; + return { + level: Math.min(100, Math.max(0, (raw - 1) * 5)), + isCharging: (statusByte & BATTERY_CHARGING_FLAG) !== 0, + }; +} + +// -- button mapping --------------------------------------------------------- + +/** `buttons_mapping.buttons` from `aerox3_wireless_wired.py`. */ +export const AEROX3_WIRELESS_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 Aerox3WirelessButtonName = keyof typeof AEROX3_WIRELESS_BUTTONS; + +const BUTTON_FIELD_LENGTH = 5; +const BUTTON_DISABLE = 0x00; +const BUTTON_DPI_SWITCH = 0x30; +const BUTTON_KEYBOARD = 0x51; +const BUTTON_MULTIMEDIA = 0x61; + +/** + * a button's target. any physical button, scroll included, is a valid target: + * rivalcfg resolves `scrollup` through the buttons table, which is how its + * default profile keeps the wheel working. + */ +export type Aerox3WirelessButtonAction = + | { type: "button"; target: Aerox3WirelessButtonName } + | { type: "disabled" } + | { type: "dpiSwitch" } + | { type: "keyboard"; code: number } + | { type: "multimedia"; code: number }; + +/** rivalcfg's default mapping, button 6 being the dpi cycle button behind the wheel. */ +export const AEROX3_WIRELESS_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" }, +}; + +/** rivalcfg `layout_multimedia.py`, the low byte of each consumer usage. */ +export const AEROX3_WIRELESS_MULTIMEDIA_KEYS = { + mute: 0xe2, + next: 0xb5, + playPause: 0xcd, + previous: 0xb6, + volumeUp: 0xe9, + volumeDown: 0xea, +} as const; + +function encodeKeyCode(code: number, kind: string): number { + if (!Number.isInteger(code) || code < 0 || code > 255) { + throw new Aerox3WirelessProtocolError(`${kind} codes must be integers from 0 to 255.`); + } + return code; +} + +/** + * `2A` followed by a 40-byte packet, 5 bytes per button. like rivalcfg, any + * button missing from `mapping` gets its default action, so remapping one + * button never disables the others. + */ +export function steelseriesAerox3WirelessEncodeButtonsMapping( + mapping: Partial>, + wireless: boolean, +): Uint8Array { + for (const name of Object.keys(mapping)) { + if (!Object.hasOwn(AEROX3_WIRELESS_BUTTONS, name)) { + throw new Aerox3WirelessProtocolError(`Unknown SteelSeries Aerox 3 Wireless button "${name}".`); + } + } + + const names = Object.keys(AEROX3_WIRELESS_BUTTONS) as Aerox3WirelessButtonName[]; + const packet = new Array(names.length * BUTTON_FIELD_LENGTH).fill(0x00); + + for (const name of names) { + const action = mapping[name] ?? AEROX3_WIRELESS_DEFAULT_BUTTONS[name]; + const { offset } = AEROX3_WIRELESS_BUTTONS[name]; + switch (action.type) { + case "button": { + if (!Object.hasOwn(AEROX3_WIRELESS_BUTTONS, action.target)) { + throw new Aerox3WirelessProtocolError(`Unknown SteelSeries Aerox 3 Wireless button "${action.target}".`); + } + packet[offset] = AEROX3_WIRELESS_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 Aerox3WirelessProtocolError("Unsupported SteelSeries Aerox 3 Wireless button action."); + } + } + } + + return new Uint8Array([...frame(AEROX3_WIRELESS_COMMAND.buttonsMapping, wireless), ...packet]); +} diff --git a/src/steelseries/devices.ts b/src/steelseries/devices.ts index 6703ced..8efdb71 100644 --- a/src/steelseries/devices.ts +++ b/src/steelseries/devices.ts @@ -25,6 +25,11 @@ * here: 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 + * the wired Aerox 3 beyond the name: its command set is Aerox 5 Wireless's, + * with a shorter 6-button mapping packet. + * * The Aerox 5 is split across **two** families despite one product line and * mostly-shared command bytes: `"aerox5"` (`0x1850`, the plain wired mouse — * see `./aerox5.ts`) and `"aerox5-wireless"` (the separately-sold Aerox 5 @@ -64,6 +69,7 @@ export type SteelSeriesProtocolFamily = | "rival3" | "rival310" | "aerox3" + | "aerox3-wireless" | "rival3-wireless" | "aerox5" | "aerox5-wireless" @@ -122,6 +128,42 @@ export const STEELSERIES_PRODUCTS: ReadonlyMap = new hasFirmwareQuery: false, verified: false, }], + // aerox3_wireless_wired.py: usb-cabled mode, interface 3, usage page 0xFFC0. + // every setting exercised on hardware, see docs/steelseries-testing.md. + [0x183a, { + model: "Aerox 3 Wireless (wired mode)", + family: "aerox3-wireless", + wireless: true, + settingsReadable: false, + hasFirmwareQuery: false, + verified: true, + }], + [0x187a, { + model: "Aerox 3 Wireless CS2 Dragon Lore Edition (wired mode)", + family: "aerox3-wireless", + wireless: true, + settingsReadable: false, + hasFirmwareQuery: false, + verified: false, + }], + // aerox3_wireless_wireless.py: same mouse over the 2.4 GHz dongle, every + // command byte ORed with 0x40, see ./aerox3-wireless.ts. + [0x1838, { + model: "Aerox 3 Wireless (2.4 GHz mode)", + family: "aerox3-wireless", + wireless: true, + settingsReadable: false, + hasFirmwareQuery: false, + verified: true, + }], + [0x1878, { + model: "Aerox 3 Wireless CS2 Dragon Lore Edition (2.4 GHz mode)", + family: "aerox3-wireless", + wireless: true, + settingsReadable: false, + hasFirmwareQuery: false, + verified: false, + }], // rival3_wireless.py: "2.4 GHz mode", endpoint 3. Own command set (0x20/ // 0x17/0x19), own DPI table (TrueMove Air), plus a battery_level read // (0xAA 0x01) neither Rival 3 Gen 1 nor Aerox 3 has. See ./rival3-wireless.ts. diff --git a/src/steelseries/index.ts b/src/steelseries/index.ts index f98bc8d..dacc784 100644 --- a/src/steelseries/index.ts +++ b/src/steelseries/index.ts @@ -1,6 +1,7 @@ export * from "./rival3.js"; export * from "./rival310.js"; export * from "./aerox3.js"; +export * from "./aerox3-wireless.js"; export * from "./rival3-wireless.js"; export * from "./aerox5.js"; export * from "./aerox5-wireless.js"; From 8d1524ec1585afc4b91bfd80afafacfd52e30b77 Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:44:00 -0600 Subject: [PATCH 2/2] fix(steelseries): keep a zone's colour when it is turned off setZoneColor caches the colour it writes, so the Off branch read back the black it had just written: the zone reported #000000 and switching it back to Static wrote black instead of the colour the user had chosen. Capture the colour before the Off write and cache that, and assert the read-back and the Static restore in the zone test. --- src/drivers/steelseries/aerox3-wireless-hid.test.ts | 10 ++++++++++ src/drivers/steelseries/aerox3-wireless-hid.ts | 8 ++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/drivers/steelseries/aerox3-wireless-hid.test.ts b/src/drivers/steelseries/aerox3-wireless-hid.test.ts index 6c84488..3e7fc7e 100644 --- a/src/drivers/steelseries/aerox3-wireless-hid.test.ts +++ b/src/drivers/steelseries/aerox3-wireless-hid.test.ts @@ -170,6 +170,16 @@ test("lighting zones map to zone colors, rainbow and reactive color", async () = const zones = (await client.readStatus()).lightingZones!; assert.deepEqual(zones.map(({ mode }) => mode), ["Off", "Static", "Static", "Reactive"]); assert.deepEqual(zones[0]!.modes, ["Static", "Off"]); + + // Turning a zone off must not forget its colour: the zone reports the colour it + // was off at, and switching back to Static restores it instead of writing black. + assert.equal(zones[0]!.color, "#123456"); + sent.length = 0; + await client.setLighting({ ...zones[0]!, mode: "Static" }); + assert.deepEqual(sent, [ + [0x21, 0x01, 0x00, 0x12, 0x34, 0x56], + [0x11, 0x00], + ]); }); test("remapping one button keeps the rest of the default layout", async () => { diff --git a/src/drivers/steelseries/aerox3-wireless-hid.ts b/src/drivers/steelseries/aerox3-wireless-hid.ts index 7c5dbc2..2cf1fe4 100644 --- a/src/drivers/steelseries/aerox3-wireless-hid.ts +++ b/src/drivers/steelseries/aerox3-wireless-hid.ts @@ -346,10 +346,14 @@ export class SteelSeriesAerox3WirelessHidClient { case "Static": await this.setZoneColor(zone, fromHex(lighting.color)); break; - case "Off": + case "Off": { + // setZoneColor caches the colour it writes, so read the previous one first: + // the zone must report (and later restore) its colour, not the black it is off at. + const previous = this.zones.get(zone)!.color; await this.setZoneColor(zone, { r: 0, g: 0, b: 0 }); - this.zones.set(zone, { mode: "Off", color: this.zones.get(zone)!.color }); + this.zones.set(zone, { mode: "Off", color: { ...previous } }); break; + } default: throw new Error(`The Aerox 3 Wireless does not support ${lighting.mode ?? "that"} lighting.`); }