diff --git a/src/drivers/endgame/egg-op1-hid.ts b/src/drivers/endgame/egg-op1-hid.ts index 5a382ab..5c3081d 100644 --- a/src/drivers/endgame/egg-op1-hid.ts +++ b/src/drivers/endgame/egg-op1-hid.ts @@ -167,7 +167,7 @@ export class EggOp1HidClient { dpiY, supportsSeparateDpiAxes: true, pollingRateHz: this.decodePollingRate(config[EGG_OFFSET.pollingDivider]), - supportedPollingRates: [...EGG_POLLING_RATES], + supportedPollingRates: this.supportedPollingRates(), activeProfile: null, connectionType: "Wired", connectionDetail: `Wired USB - PID 0x${this.device.productId.toString(16).toUpperCase()} - ${this.profile.sensorFamily.toUpperCase()}`, @@ -234,8 +234,13 @@ export class EggOp1HidClient { return dpi; } + /** RF dongles (e.g. OP1w 4K v2) are capped below the 8000 Hz wired ceiling. */ + supportedPollingRates(): number[] { + return EGG_POLLING_RATES.filter((rate) => rate <= this.profile.maxPollingHz); + } + async setPollingRate(rate: number): Promise { - if (!EGG_POLLING_RATES.includes(rate as (typeof EGG_POLLING_RATES)[number])) { + if (!this.supportedPollingRates().includes(rate)) { throw new Error("Unsupported Endgame Gear 8K polling rate."); } const divider = 8000 / rate; diff --git a/src/drivers/endgame/egg-op1-protocol.test.ts b/src/drivers/endgame/egg-op1-protocol.test.ts index 5efc585..72718da 100644 --- a/src/drivers/endgame/egg-op1-protocol.test.ts +++ b/src/drivers/endgame/egg-op1-protocol.test.ts @@ -22,14 +22,24 @@ const op1 = EGG_DEVICE_PROFILES.get(0x1964)!; const purple = EGG_DEVICE_PROFILES.get(0x1976)!; const op1v2 = EGG_DEVICE_PROFILES.get(0x1978)!; -test("all five Endgame Gear 8K devices have explicit capability profiles", () => { - assert.deepEqual([...EGG_DEVICE_PROFILES.keys()], [0x1964, 0x1966, 0x1976, 0x1978, 0x1980]); +test("all seven Endgame Gear 8K devices have explicit capability profiles", () => { + assert.deepEqual( + [...EGG_DEVICE_PROFILES.keys()], + [0x1964, 0x1966, 0x1976, 0x1978, 0x1980, 0x1984, 0x1970], + ); assert.equal(op1.motionSyncAt8k, false); assert.equal(EGG_DEVICE_PROFILES.get(0x1966)!.motionSyncAt8k, false); assert.equal(purple.motionSyncAt8k, true); assert.equal(op1v2.motionSyncAt8k, true); }); +test("OP1w 4K v2 wireless models are capped at 4000 Hz while wired 8K models keep 8000 Hz", () => { + assert.equal(op1.maxPollingHz, 8000); + assert.equal(op1v2.maxPollingHz, 8000); + assert.equal(EGG_DEVICE_PROFILES.get(0x1984)!.maxPollingHz, 4000); + assert.equal(EGG_DEVICE_PROFILES.get(0x1970)!.maxPollingHz, 4000); +}); + test("CPI ranges and quantization follow each sensor generation", () => { assert.equal(eggClampCpi(op1, 30_000), 26_000); assert.equal(eggClampCpi(op1, 31_000), 26_000); diff --git a/src/drivers/endgame/egg-we-hid.ts b/src/drivers/endgame/egg-we-hid.ts index e3fd8ce..f3d0896 100644 --- a/src/drivers/endgame/egg-we-hid.ts +++ b/src/drivers/endgame/egg-we-hid.ts @@ -38,7 +38,16 @@ 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 EGG_8K_PRODUCT_IDS = new Set([0x1964, 0x1966, 0x1976, 0x1978, 0x1980]); +/** + * Wire report ID of the OP1-8K config protocol's command feature report + * (EGG_REPORT.command in endgame-gear/op1.ts). The OP1w 4K v2 wireless + * receiver reuses PID 0x1970 from the older, unrelated OP1we dongle + * (OP1WE_RECEIVER_PIDS below), so PID alone cannot tell them apart — see + * issue #107. Any device exposing this feature report speaks the OP1-8K + * protocol and must be left to EggOp1HidClient regardless of its PID. + */ +const EGG_OP1_COMMAND_REPORT_ID = 0xa1; const USAGE_PAGE_CMD = 0xff02; const USAGE_PAGE_NOTIFY = 0xff01; @@ -103,10 +112,19 @@ 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) - || XM2WE_RECEIVER_PIDS.has(device.productId)); + if (EGG_8K_PRODUCT_IDS.has(device.productId)) return false; + // PID-based exclusion above only covers the wired OP1-8K family. A + // receiver PID here (e.g. 0x1970) can also belong to a newer OP1-8K-v2 + // wireless model that happens to reuse it; the descriptor is the only + // reliable signal in that case. + if (this.hasOp1EightKCommandReport(device)) return false; + return OP1WE_CABLE_PIDS.has(device.productId) + || OP1WE_RECEIVER_PIDS.has(device.productId) + || XM2WE_RECEIVER_PIDS.has(device.productId); + } + + private static hasOp1EightKCommandReport(device: HIDDevice): boolean { + return this.listFeatureReports(device).some((report) => report.reportId === EGG_OP1_COMMAND_REPORT_ID); } static isReceiverDevice(device: HIDDevice): boolean { diff --git a/src/drivers/endgame/egg-we-protocol.test.ts b/src/drivers/endgame/egg-we-protocol.test.ts index 415ba18..2860e33 100644 --- a/src/drivers/endgame/egg-we-protocol.test.ts +++ b/src/drivers/endgame/egg-we-protocol.test.ts @@ -18,12 +18,19 @@ import { weUnpackScalarPair, } from "@openmouse/protocol/endgame-gear-we"; -function hidDevice(productId: number, productName = ""): HIDDevice { +function hidDevice(productId: number, productName = "", featureReportIds: number[] = []): HIDDevice { return { vendorId: 0x3367, productId, productName, - collections: [], + collections: featureReportIds.length === 0 ? [] : [{ + usagePage: 0xff02, + usage: 0, + featureReports: featureReportIds.map((reportId) => ({ reportId, items: [] })), + inputReports: [], + outputReports: [], + children: [], + }], } as unknown as HIDDevice; } @@ -37,6 +44,14 @@ test("XM2we receiver revisions are supported and keep their model identity", () } }); +test("a 0x1970 receiver exposing the OP1-8K command report is left for EggOp1HidClient (issue #107)", () => { + const legacyWeDongle = hidDevice(0x1970); + assert.equal(EggWeHidClient.isSupported(legacyWeDongle), true); + + const op1w4kV2Dongle = hidDevice(0x1970, "", [0xa1]); + assert.equal(EggWeHidClient.isSupported(op1w4kV2Dongle), false); +}); + test("WE model names can fall back to the USB product string", () => { assert.equal( EggWeHidClient.displayNameForDevice(hidDevice(0x1962, "XM2we")), diff --git a/src/endgame-gear/op1.ts b/src/endgame-gear/op1.ts index 0d10c88..19d5ad5 100644 --- a/src/endgame-gear/op1.ts +++ b/src/endgame-gear/op1.ts @@ -31,6 +31,8 @@ export interface EggDeviceProfile { lodNormal: readonly string[]; lodGlass: readonly string[] | null; motionSyncAt8k: boolean; + /** Wired 8K models top out at 8000 Hz; wireless dongles are RF-limited to 4000 Hz. */ + maxPollingHz: number; } const LOD_V1 = ["0.7 mm", "1 mm", "2 mm"] as const; @@ -53,6 +55,7 @@ export const EGG_DEVICE_PROFILES: ReadonlyMap = new Ma lodNormal: LOD_V1, lodGlass: null, motionSyncAt8k: false, + maxPollingHz: 8000, }], [0x1966, { pid: 0x1966, @@ -66,6 +69,7 @@ export const EGG_DEVICE_PROFILES: ReadonlyMap = new Ma lodNormal: LOD_V1, lodGlass: null, motionSyncAt8k: false, + maxPollingHz: 8000, }], [0x1976, { pid: 0x1976, @@ -79,6 +83,7 @@ export const EGG_DEVICE_PROFILES: ReadonlyMap = new Ma lodNormal: LOD_V1, lodGlass: LOD_GLASS, motionSyncAt8k: true, + maxPollingHz: 8000, }], [0x1978, { pid: 0x1978, @@ -92,6 +97,7 @@ export const EGG_DEVICE_PROFILES: ReadonlyMap = new Ma lodNormal: LOD_V2, lodGlass: LOD_GLASS, motionSyncAt8k: true, + maxPollingHz: 8000, }], [0x1980, { pid: 0x1980, @@ -105,6 +111,39 @@ export const EGG_DEVICE_PROFILES: ReadonlyMap = new Ma lodNormal: LOD_V2, lodGlass: LOD_GLASS, motionSyncAt8k: true, + maxPollingHz: 8000, + }], + // OP1w 4K v2: first wireless model on the OP1-8K v2 config protocol. The + // dongle's own USB PID (0x1970) is reused from the older, unrelated OP1we + // (see egg-we-hid.ts) — descriptor-based detection there keeps the two + // drivers from both claiming it. See issue #107. + [0x1984, { + pid: 0x1984, + name: "Endgame Gear OP1w 4K v2", + configFamily: "v2", + sensorFamily: "paw3950", + cpiMin: 10, + cpiMax: 30_000, + cpiStepLow: 10, + cpiStepHigh: 50, + lodNormal: LOD_V2, + lodGlass: null, + motionSyncAt8k: true, + maxPollingHz: 4000, + }], + [0x1970, { + pid: 0x1970, + name: "Endgame Gear OP1w 4K v2", + configFamily: "v2", + sensorFamily: "paw3950", + cpiMin: 10, + cpiMax: 30_000, + cpiStepLow: 10, + cpiStepHigh: 50, + lodNormal: LOD_V2, + lodGlass: null, + motionSyncAt8k: true, + maxPollingHz: 4000, }], ]); @@ -153,10 +192,15 @@ export function eggNormalizeFeatureReport( // config size. On Windows, command replies then retain their A0/A1 wire // header as the first payload byte. Treat that byte as framing instead of // prepending a duplicate report ID and shifting the status/version fields. + // Status byte values observed on the wire (issue #107): 0x01 OK, 0x03 busy, + // 0x07 rejected, 0x08 mouse unreachable (RF sleep). All four are valid + // framing markers, not just OK/busy — treating only 0x01/0x03 as framing + // left rejected and RF-sleep replies on the OP1w 4K v2 falling through to + // the byte-shifting path below, corrupting the payload length calculation. const hasWireHeader = raw.length === payloadLength && raw.length >= 2 && (raw[0] === 0 || raw[0] === EGG_REPORT.config || raw[0] === EGG_REPORT.command) - && (raw[1] === 0x01 || raw[1] === 0x03); + && (raw[1] === 0x01 || raw[1] === 0x03 || raw[1] === 0x07 || raw[1] === 0x08); if (hasWireHeader) { const result = new Uint8Array(Math.max(expectedTotal, raw.length)); result.set(raw);