Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/steelseries-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
2 changes: 1 addition & 1 deletion src/drivers/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
4 changes: 3 additions & 1 deletion src/drivers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 },
Expand Down
218 changes: 218 additions & 0 deletions src/drivers/steelseries/aerox3-wireless-hid.test.ts
Original file line number Diff line number Diff line change
@@ -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],
]);
});
Loading
Loading