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..3e7fc7e --- /dev/null +++ b/src/drivers/steelseries/aerox3-wireless-hid.test.ts @@ -0,0 +1,218 @@ +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"]); + + // 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 () => { + 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..2cf1fe4 --- /dev/null +++ b/src/drivers/steelseries/aerox3-wireless-hid.ts @@ -0,0 +1,504 @@ +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": { + // 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: { ...previous } }); + 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";