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
31 changes: 25 additions & 6 deletions src/drivers/endgame/egg-we-hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import {
const EGG_VENDOR_ID = 0x3367;
const OP1WE_CABLE_PIDS = new Set([0x1962, 0x1972]);
const OP1WE_RECEIVER_PIDS = new Set([0x1961, 0x1970]);
/** XM2we receiver revisions observed in WebHID hardware reports. */
const XM2WE_RECEIVER_PIDS = new Set([0x1960, 0x1968, 0x1982]);
const EGG_8K_PRODUCT_IDS = new Set([0x1964, 0x1966, 0x1976, 0x1978]);

const USAGE_PAGE_CMD = 0xff02;
Expand Down Expand Up @@ -102,15 +104,24 @@ export class EggWeHidClient {
static isSupported(device: HIDDevice): boolean {
if (device.vendorId !== EGG_VENDOR_ID) return false;
return !EGG_8K_PRODUCT_IDS.has(device.productId)
&& (OP1WE_CABLE_PIDS.has(device.productId) || OP1WE_RECEIVER_PIDS.has(device.productId));
&& (OP1WE_CABLE_PIDS.has(device.productId)
|| OP1WE_RECEIVER_PIDS.has(device.productId)
|| XM2WE_RECEIVER_PIDS.has(device.productId));
}

static isReceiverDevice(device: HIDDevice): boolean {
if (OP1WE_RECEIVER_PIDS.has(device.productId)) return true;
if (OP1WE_RECEIVER_PIDS.has(device.productId) || XM2WE_RECEIVER_PIDS.has(device.productId)) return true;
const name = (device.productName || "").toLowerCase();
return name.includes("receiver") || name.includes("dongle");
}

static displayNameForDevice(device: HIDDevice): string {
const name = (device.productName || "").toLowerCase();
return XM2WE_RECEIVER_PIDS.has(device.productId) || name.includes("xm2")
? "Endgame Gear XM2we"
: "Endgame Gear OP1we";
}

static pickDevices(devices: readonly HIDDevice[]): HIDDevice[] {
const we = devices.filter((device) => this.isSupported(device));
if (we.length === 0) return [];
Expand Down Expand Up @@ -154,6 +165,15 @@ export class EggWeHidClient {
|| device === this.notifyDevice;
}

displayName(): string {
const devices = [this.device, this.cmdDevice, this.notifyDevice].filter(
(device): device is HIDDevice => device !== null,
);
return devices.some((device) => EggWeHidClient.displayNameForDevice(device).includes("XM2we"))
? "Endgame Gear XM2we"
: "Endgame Gear OP1we";
}

/**
* Pair FF02 (cmd) + FF01 (notify) for this transport only.
* Cable primary → cable peers; receiver primary → receiver peers.
Expand Down Expand Up @@ -263,8 +283,8 @@ export class EggWeHidClient {
hideUnsupportedPollingRates: true,
hideProcessingCard: true,
forceShowBattery: true,
pollingNote: "OP1we supports up to 1,000 Hz (125 / 250 / 500 / 1000).",
defaultDisplayName: "Endgame Gear OP1we",
pollingNote: `${meta.name.replace("Endgame Gear ", "")} supports up to 1,000 Hz (125 / 250 / 500 / 1000).`,
defaultDisplayName: meta.name,
},
};
}
Expand Down Expand Up @@ -394,9 +414,8 @@ export class EggWeHidClient {
// ---------------------------------------------------------------------------

private productMeta(): { name: string; wired: boolean; viaReceiver: boolean } {
// isSupported only accepts OP1we cable/receiver PIDs.
const viaReceiver = EggWeHidClient.isReceiverDevice(this.device);
return { name: "Endgame Gear OP1we", wired: !viaReceiver, viaReceiver };
return { name: this.displayName(), wired: !viaReceiver, viaReceiver };
}

private requireChannels(): WeChannels {
Expand Down
32 changes: 32 additions & 0 deletions src/drivers/endgame/egg-we-protocol.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import test from "node:test";

import { EggWeHidClient } from "./egg-we-hid.ts";

import {
WE_OFF,
WE_REPORT_ID,
Expand All @@ -16,6 +18,36 @@ import {
weUnpackScalarPair,
} from "@openmouse/protocol/endgame-gear-we";

function hidDevice(productId: number, productName = ""): HIDDevice {
return {
vendorId: 0x3367,
productId,
productName,
collections: [],
} as unknown as HIDDevice;
}

test("XM2we receiver revisions are supported and keep their model identity", () => {
for (const productId of [0x1960, 0x1968, 0x1982]) {
const device = hidDevice(productId);
assert.equal(EggWeHidClient.isSupported(device), true);
assert.equal(EggWeHidClient.isReceiverDevice(device), true);
assert.equal(EggWeHidClient.displayNameForDevice(device), "Endgame Gear XM2we");
assert.equal(new EggWeHidClient(device).displayName(), "Endgame Gear XM2we");
}
});

test("WE model names can fall back to the USB product string", () => {
assert.equal(
EggWeHidClient.displayNameForDevice(hidDevice(0x1962, "XM2we")),
"Endgame Gear XM2we",
);
assert.equal(
EggWeHidClient.displayNameForDevice(hidDevice(0x1962, "OP1we")),
"Endgame Gear OP1we",
);
});

test("commands produce a valid report checksum", () => {
for (const payload of [weBuildReadEepromPayload(0x1234, 8), weBuildWriteEepromPayload(0x12, [1, 2, 3, 4])]) {
assert.equal(payload.length, 16);
Expand Down