Skip to content
Open
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ checklist.
| Pulsar | `@openmouse/protocol/pulsar` |
| Razer legacy/current | `@openmouse/protocol/razer` |
| Razer V4 | `@openmouse/protocol/razer-v4` |
| SteelSeries Rival 3 (Gen 1) | `@openmouse/protocol/steelseries` |
| Teevolution | `@openmouse/protocol/teevolution` |
| VGN | `@openmouse/protocol/vgn` |
| WLMouse | `@openmouse/protocol/wlmouse` |
Expand All @@ -64,3 +65,9 @@ The Ninjutso catalog and packet layouts are derived from the JavaScript shipped
by the official NinjaForce WebHID panel. They have automated transport and
codec coverage, but are not marked as hardware-verified until tested on the
corresponding Sora V2/V3 and TEN-family devices.

The SteelSeries Rival 3 Gen 1 codec and driver are derived from the public
rivalcfg project, corroborated against libratbag and OpenRGB. The device is
write-only — only the firmware version can be read back — and no entry is
marked hardware-verified yet. See
[docs/steelseries-testing.md](docs/steelseries-testing.md).
63 changes: 63 additions & 0 deletions docs/steelseries-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# SteelSeries hardware test checklist

Test in Chrome or Edge over HTTPS. **Fully quit SteelSeries GG and the
SteelSeriesEngine background service first** — they hold the configuration
interface open and the firmware probe will time out.

Supported identifiers (none hardware-verified yet):

- `1038:1824` — Rival 3, pre-0.37 firmware enumeration
- `1038:184c` — Rival 3, post-v0.37.0.0 firmware enumeration

The protocol is transcribed from the public rivalcfg project and corroborated
against libratbag's SteelSeries driver and OpenRGB's Rival 3 controller. The
config channel is hidapi interface 3; its WebHID collection shape has not been
captured, so the picker offers every interface and the driver's firmware probe
(`10 00`) is what proves the right one was chosen. A wrong interface fails
loudly — add the device again and choose another entry.

**This device is write-only.** Nothing except the firmware version can be read
back, so every verification below is physical (pointer speed, an external rate
meter), never a read. The driver reports last-written values flagged as
unverified; that is by design.

The Rival 3 Wireless (`1038:1830`, `1038:1872`) and Rival 3 Gen 2
(`1038:1870`) use different, incompatible command sets and are deliberately
not claimed by this driver.

1. Record the OS, browser, exact VID:PID, and which picker entry connected.
The first time a unit connects, paste the `device.collections` dump into
the issue or pull request — it is the missing evidence that lets the broad
per-PID filter be narrowed to a usage-page filter.
2. Confirm the firmware version the driver reads matches what SteelSeries GG
displays (briefly reopen GG to compare, then quit it again). On a
`1038:184c` unit the version is known to be in the 0.37 family, which also
settles the two-byte order that public implementations disagree on.
3. Because nothing is readable, **record the starting configuration from GG
before changing anything**: every DPI preset, the active preset, the
polling rate, and lighting. This replaces the usual "verify every readable
value" step and is what step 8 restores.
4. Change exactly one setting at a time.
5. Write a DPI value and confirm the pointer speed physically changes. Note
that the write replaces the on-device preset table with that single preset
— the DPI button will no longer cycle the old presets. That is expected.
6. Write each polling rate (125 / 250 / 500 / 1000 Hz) and verify with an
external rate meter (for example a `pointerrawupdate` tester), not by any
read.
7. Reload OpenMouse, reconnect, and confirm the firmware still reads. Then
power-cycle/replug the mouse and confirm the written DPI and polling rate
persisted physically — that is the save command (`09 00`) doing its job.
8. Restore the original presets and settings through SteelSeries GG, and
confirm GG still controls the mouse normally after OpenMouse ran.
9. Record failures, timeouts, and any unknown behavior verbatim in the issue
or pull request. Do not attach captures containing serial numbers.
10. Only after all of the above on a given product id: set that entry's
`verified` flag to `true` in `src/steelseries/devices.ts`, add the id to
the verified list at the top of this file, and record the firmware
version in the pull request. The other product id stays unverified until
it is exercised too.

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.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@
"types": "./dist/razer/v4.d.ts",
"import": "./dist/razer/v4.js"
},
"./steelseries": {
"types": "./dist/steelseries/index.d.ts",
"import": "./dist/steelseries/index.js"
},
"./teevolution": {
"types": "./dist/teevolution/index.d.ts",
"import": "./dist/teevolution/index.js"
Expand Down
2 changes: 1 addition & 1 deletion src/drivers/mouse-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export type MouseLightingMode =
| "Breathing dual";

export interface MouseStatus {
brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK";
brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries";
name: string;
/** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */
ui?: MouseUiHints;
Expand Down
4 changes: 3 additions & 1 deletion src/drivers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ import { WLMouseHidClient } from "./wlmouse/hid.ts";
import { WootingHidClient } from "./wooting/hid.ts";
import { ZaunkoenigHidClient } from "./zaunkoenig/hid.ts";
import { GWolvesHidClient } from "./gwolves/hid.ts";
import { SteelSeriesRival3HidClient } from "./steelseries/hid.ts";

export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient;
export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient;
export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | SteelSeriesRival3HidClient;

export interface DeviceDriver {
brand: string;
Expand Down Expand Up @@ -66,6 +67,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [
{ brand: "WALLHACK", supports: (device) => WallhackMouseHidClient.isSupported(device), create: (device) => new WallhackMouseHidClient(device), score: () => 8 },
{ brand: "WALLHACK", supports: (device) => WallhackKeyboardHidClient.isSupported(device), create: (device) => new WallhackKeyboardHidClient(device), score: () => 8 },
{ brand: "G-Wolves", supports: (device) => GWolvesHidClient.isSupported(device), create: (device) => new GWolvesHidClient(device), score: () => 7 },
{ brand: "SteelSeries", supports: (device) => SteelSeriesRival3HidClient.isSupported(device), create: (device) => new SteelSeriesRival3HidClient(device), score: () => 6 },
];

function driverFor(device: HIDDevice): DeviceDriver | undefined {
Expand Down
106 changes: 106 additions & 0 deletions src/drivers/steelseries/hid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import test from "node:test";

import { SteelSeriesRival3HidClient } from "./hid.ts";

function fakeDevice(options: { productId?: number; answerFirmware?: boolean; firmware?: number[] } = {}) {
const sent: Array<{ reportId: number; payload: number[] }> = [];
let listener: ((event: HIDInputReportEvent) => void) | null = null;
const device = {
vendorId: 0x1038,
productId: options.productId ?? 0x1824,
productName: "SteelSeries Rival 3",
opened: true,
collections: [],
open: async () => {},
close: async () => {},
sendReport: async (reportId: number, data: BufferSource) => {
const view = ArrayBuffer.isView(data)
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
: new Uint8Array(data as ArrayBuffer);
const payload = [...view];
sent.push({ reportId, payload });
if (payload[0] === 0x10 && payload[1] === 0x00 && options.answerFirmware !== false) {
const response = new Uint8Array(options.firmware ?? [0x25, 0x00]);
queueMicrotask(() =>
listener?.({ reportId: 0, data: new DataView(response.buffer), device } as unknown as HIDInputReportEvent));
}
},
sendFeatureReport: async () => { throw new Error("the Rival 3 protocol does not use feature reports"); },
receiveFeatureReport: async () => { throw new Error("the Rival 3 protocol does not use feature reports"); },
addEventListener: (_type: string, attached: (event: HIDInputReportEvent) => void) => { listener = attached; },
removeEventListener: () => { listener = null; },
};
return { device: device as unknown as HIDDevice, sent };
}

test("claims only the two Rival 3 Gen 1 product ids", () => {
const { device } = fakeDevice();
assert.equal(SteelSeriesRival3HidClient.isSupported(device), true);
assert.equal(SteelSeriesRival3HidClient.isSupported({ ...device, productId: 0x184c } as HIDDevice), true);
// Documented different-protocol siblings: Rival 3 Wireless, Gen 2, Wireless Gen 2.
assert.equal(SteelSeriesRival3HidClient.isSupported({ ...device, productId: 0x1830 } as HIDDevice), false);
assert.equal(SteelSeriesRival3HidClient.isSupported({ ...device, productId: 0x1870 } as HIDDevice), false);
assert.equal(SteelSeriesRival3HidClient.isSupported({ ...device, productId: 0x1872 } as HIDDevice), false);
assert.equal(SteelSeriesRival3HidClient.isSupported({ ...device, productId: 0xffff } as HIDDevice), false);
assert.equal(SteelSeriesRival3HidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false);
});

test("readStatus probes firmware and never claims to have read settings", async () => {
const { device, sent } = fakeDevice();
const status = await new SteelSeriesRival3HidClient(device).readStatus();
assert.deepEqual(sent, [{ reportId: 0, payload: [0x10, 0x00] }]);
assert.equal(status.brand, "SteelSeries");
assert.equal(status.name, "SteelSeries Rival 3");
assert.deepEqual(status.firmware, ["37.0"]);
assert.equal(status.connectionType, "Wired");
assert.equal(status.batteryPercent, null);
// rivalcfg defaults, flagged as assumptions rather than device readings.
assert.equal(status.dpi, 800);
assert.equal(status.pollingRateHz, 1000);
assert.equal(status.ui?.valuesVerified, false);
assert.ok(status.ui?.pollingNote);
});

test("a silent interface fails the probe loudly and names SteelSeries GG", async () => {
const { device, sent } = fakeDevice({ answerFirmware: false });
await assert.rejects(new SteelSeriesRival3HidClient(device).readStatus(), /SteelSeries GG/);
// The probe was the only report sent; nothing else was attempted blind.
assert.deepEqual(sent, [{ reportId: 0, payload: [0x10, 0x00] }]);
});

test("setters write the value then the save command, all on report id 0", async () => {
const { device, sent } = fakeDevice();
const client = new SteelSeriesRival3HidClient(device);
assert.equal(await client.setDpi(1600), 1600);
assert.equal(await client.setPollingRate(500), 500);
assert.deepEqual(sent, [
{ reportId: 0, payload: [0x0b, 0x00, 0x01, 0x01, 0x24] },
{ reportId: 0, payload: [0x09, 0x00] },
{ reportId: 0, payload: [0x04, 0x00, 0x02] },
{ reportId: 0, payload: [0x09, 0x00] },
]);
const status = await client.readStatus();
assert.equal(status.dpi, 1600);
assert.equal(status.pollingRateHz, 500);
});

test("invalid values are rejected before any report reaches the mouse", async () => {
const { device, sent } = fakeDevice();
const client = new SteelSeriesRival3HidClient(device);
await assert.rejects(client.setDpi(850), /100 DPI steps/);
await assert.rejects(client.setPollingRate(2000), /125, 250, 500, or 1000 Hz/);
assert.deepEqual(sent, []);
});

test("concurrent setters never interleave their write/save pairs", async () => {
const { device, sent } = fakeDevice();
const client = new SteelSeriesRival3HidClient(device);
await Promise.all([client.setDpi(400), client.setPollingRate(125)]);
assert.deepEqual(sent.map(({ payload }) => payload), [
[0x0b, 0x00, 0x01, 0x01, 0x08],
[0x09, 0x00],
[0x04, 0x00, 0x04],
[0x09, 0x00],
]);
});
Loading