diff --git a/src/drivers/endgame/egg-we-hid.ts b/src/drivers/endgame/egg-we-hid.ts index 163e5e6..e3fd8ce 100644 --- a/src/drivers/endgame/egg-we-hid.ts +++ b/src/drivers/endgame/egg-we-hid.ts @@ -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; @@ -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 []; @@ -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. @@ -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, }, }; } @@ -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 { diff --git a/src/drivers/endgame/egg-we-protocol.test.ts b/src/drivers/endgame/egg-we-protocol.test.ts index 91f350e..415ba18 100644 --- a/src/drivers/endgame/egg-we-protocol.test.ts +++ b/src/drivers/endgame/egg-we-protocol.test.ts @@ -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, @@ -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);