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
4 changes: 3 additions & 1 deletion src/drivers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { SteelSeriesRival310HidClient } from "./steelseries/rival310-hid.ts";
import { SteelSeriesPrimePlusHidClient } from "./steelseries/prime-plus-hid.ts";
import { SteelSeriesPrimeMiniWirelessHidClient } from "./steelseries/prime-mini-wireless-hid.ts";
import { SteelSeriesSenseiTenHidClient } from "./steelseries/sensei-ten-hid.ts";
import { SteelSeriesSenseiRawHidClient } from "./steelseries/sensei-raw-hid.ts";
import { GloriousHidClient } from "./glorious/hid.ts";
import { GloriousClassicHidClient } from "./glorious/classic-hid.ts";
import { GloriousCore2HidClient } from "./glorious/core2-hid.ts";
Expand All @@ -83,7 +84,7 @@ import { AjazzHidClient } from "./ajazz/hid.ts";
import { AjazzAk820HidClient } from "./ajazz/ak820-hid.ts";

export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient;
export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient;
export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesSenseiRawHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient;

export interface DeviceDriver {
brand: string;
Expand Down Expand Up @@ -176,6 +177,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [
{ brand: "SteelSeries", supports: (device) => SteelSeriesPrimePlusHidClient.isSupported(device), create: (device) => new SteelSeriesPrimePlusHidClient(device), score: () => 6 },
{ brand: "SteelSeries", supports: (device) => SteelSeriesPrimeMiniWirelessHidClient.isSupported(device), create: (device) => new SteelSeriesPrimeMiniWirelessHidClient(device), score: () => 6 },
{ brand: "SteelSeries", supports: (device) => SteelSeriesSenseiTenHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiTenHidClient(device), score: () => 6 },
{ brand: "SteelSeries", supports: (device) => SteelSeriesSenseiRawHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiRawHidClient(device), score: () => 6 },
{ brand: "Glorious", supports: (device) => GloriousHidClient.isSupported(device), create: (device) => new GloriousHidClient(device), score: () => 5 },
{ brand: "Glorious", supports: (device) => GloriousClassicHidClient.isSupported(device), create: (device) => new GloriousClassicHidClient(device), score: () => 5 },
// Same vendor id as the classic family; disjoint by product id.
Expand Down
139 changes: 139 additions & 0 deletions src/drivers/steelseries/sensei-raw-hid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import test from "node:test";

import { SteelSeriesSenseiRawHidClient } from "./sensei-raw-hid.ts";

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

test("claims the Sensei [RAW] family product ids and nothing else", () => {
const { device } = fakeDevice();
for (const productId of [0x1369, 0x1362, 0x136d, 0x136f, 0x1380, 0x1390]) {
assert.equal(
SteelSeriesSenseiRawHidClient.isSupported({ ...device, productId } as HIDDevice),
true,
`expected 0x${productId.toString(16)} to be claimed`,
);
}
// Sensei TEN, Sensei [RAW] Optical RGB (rival-310 based) and modern families must not be claimed.
for (const productId of [0x1832, 0x1800, 0x1826, 0x1824, 0x1729]) {
assert.equal(
SteelSeriesSenseiRawHidClient.isSupported({ ...device, productId } as HIDDevice),
false,
`must not claim product id 0x${productId.toString(16)}`,
);
}
assert.equal(SteelSeriesSenseiRawHidClient.isSupported({ ...device, vendorId: 0x1532 } as HIDDevice), false);
});

test("readStatus probes firmware and the active preset, reporting that preset's default", async () => {
const { device, sent } = fakeDevice({ activePreset: 0x02 });
const status = await new SteelSeriesSenseiRawHidClient(device).readStatus();
assert.deepEqual(sent, [
{ reportId: 0, payload: [0x10, 0x00] },
{ reportId: 0, payload: [0x16, 0x00] },
]);
assert.equal(status.brand, "SteelSeries");
assert.equal(status.name, "SteelSeries Sensei [RAW]");
assert.deepEqual(status.firmware, ["1.3"]);
assert.equal(status.connectionType, "Wired");
assert.equal(status.dpi, 3240);
assert.equal(status.pollingRateHz, 1000);
assert.equal(status.ui?.valuesVerified, false);
assert.ok(status.ui?.pollingNote?.includes("active preset (2)"));
});

test("a silent interface fails the firmware probe loudly", async () => {
const { device } = fakeDevice({ answer: false });
const client = new SteelSeriesSenseiRawHidClient(device);
await assert.rejects(() => client.readStatus(), /did not answer the firmware query/);
});

test("setDpi writes the active preset (1 by default) plus save", async () => {
const { device, sent } = fakeDevice({ activePreset: 0x01 });
const client = new SteelSeriesSenseiRawHidClient(device);
await client.readStatus();
sent.length = 0;
const dpi = await client.setDpi(1620);
assert.equal(dpi, 1620);
assert.deepEqual(sent, [
{ reportId: 0, payload: [0x16, 0x00] },
{ reportId: 0, payload: [0x03, 0x01, 0x12] },
{ reportId: 0, payload: [0x09, 0x00] },
]);
});

test("setDpi writes preset 2 when the CPI button has selected it", async () => {
const { device, sent } = fakeDevice({ activePreset: 0x02 });
const client = new SteelSeriesSenseiRawHidClient(device);
await client.readStatus();
sent.length = 0;
await client.setDpi(3240);
assert.deepEqual(sent, [
{ reportId: 0, payload: [0x16, 0x00] },
{ reportId: 0, payload: [0x03, 0x02, 0x24] },
{ reportId: 0, payload: [0x09, 0x00] },
]);
});

test("setPollingRate, LED brightness/effect, and buttons mapping write plus save", async () => {
const { device, sent } = fakeDevice();
const client = new SteelSeriesSenseiRawHidClient(device);
await client.readStatus();
sent.length = 0;
await client.setPollingRate(500);
assert.deepEqual(sent, [
{ reportId: 0, payload: [0x04, 0x00, 0x02] },
{ reportId: 0, payload: [0x09, 0x00] },
]);
sent.length = 0;
await client.setLedBrightness("medium");
assert.deepEqual(sent, [
{ reportId: 0, payload: [0x05, 0x01, 0x03] },
{ reportId: 0, payload: [0x09, 0x00] },
]);
sent.length = 0;
await client.setLedEffect("trigger");
assert.deepEqual(sent, [
{ reportId: 0, payload: [0x07, 0x01, 0x05] },
{ reportId: 0, payload: [0x09, 0x00] },
]);
sent.length = 0;
await client.setButtonsMapping({ button8: { type: "dpiSwitch" } });
assert.equal(sent.length, 2);
assert.equal(sent[0]?.payload[0], 0x31);
assert.equal(sent[0]?.payload.length, 2 + 8 * 3);
assert.equal(sent[0]?.payload[2 + 0x15], 0x30);
assert.deepEqual(sent[1], { reportId: 0, payload: [0x09, 0x00] });
});
Loading
Loading