diff --git a/src/drivers/endgame/egg-op1-hid.test.ts b/src/drivers/endgame/egg-op1-hid.test.ts index 4302013..3745a9f 100644 --- a/src/drivers/endgame/egg-op1-hid.test.ts +++ b/src/drivers/endgame/egg-op1-hid.test.ts @@ -189,7 +189,7 @@ test("over the shared 0x1970 dongle, the mouse-info reply names the paired 4K v2 // Asleep: keep the neutral name, then pick up the model once the mouse answers. const sleepy = fake4kV2({ productId: 0x1970, pairedPid: null }); const client = new EggOp1HidClient(sleepy.device); - assert.equal((await client.readStatus()).name, "Endgame Gear OP1w/XM2w 4K v2"); + assert.equal((await client.readStatus()).name, "Endgame Gear OP1w/XM2w 4K"); sleepy.state.pairedPid = 0x1984; assert.equal((await client.readStatus()).name, "Endgame Gear OP1w 4K v2"); }); @@ -199,3 +199,41 @@ test("a mouse-info reply that lags behind the dongle's held response still names const lagging = fake4kV2({ productId: 0x1970, pairedPid: 0x1982, relayLagReads: 2 }); assert.equal((await new EggOp1HidClient(lagging.device).readStatus()).name, "Endgame Gear XM2w 4K v2"); }); + +test("an OP1w 4K v1 behind the shared dongle switches to the v1 encodings", async () => { + // Ticket #0126: an original OP1w 4K was read and written as a v2 (re/PROTOCOL.md section 12). + const v1 = fake4kV2({ productId: 0x1970, pairedPid: 0x1972 }); + v1.blob[0x09] = 2; // LOD 2 mm: v1 stores whole millimetres + v1.blob[0x6f] = 1; // v2's glass-mode byte, which the v1 power block must not carry + const client = new EggOp1HidClient(v1.device); + + const status = await client.readStatus(); + assert.equal(status.name, "Endgame Gear OP1w 4K"); + assert.deepEqual(status.eggLodOptions, ["1 mm", "2 mm"]); + assert.equal(status.liftOffDistance, "High"); + assert.equal(status.eggSupportsGlassMode, false); + assert.equal(status.eggSupportsV2SensorControls, false); + assert.equal(status.motionJitterFilter, false); + assert.deepEqual(status.supportedPollingRates, [1000, 2000, 4000]); + assert.equal(status.eggCpiMax, 26_000); + + await client.setLiftOffDistance("Medium"); + assert.equal(v1.blob[0x09], 1); + v1.writes.length = 0; + await client.setPollingRate(2000); + assert.deepEqual(v1.writes, ["15 0f 0a 00 00 00 | 00 04 21 01 f0 f0 08 08 08 03 00"]); + await client.setMotionJitterFilter(true); + assert.equal(v1.blob[0x06] & 0x10, 0x10); + await assert.rejects(client.setGlassMode(true), /Glass Mode/); + await assert.rejects(client.setPollingRate(125), /Unsupported/); + assert.equal(EggOp1HidClient.isSupported(fake4kV2({ productId: 0x1972 }).device), true); +}); + +test("writes over the shared dongle wait until the paired mouse identifies itself", async () => { + const sleepy = fake4kV2({ productId: 0x1970, pairedPid: null }); + const client = new EggOp1HidClient(sleepy.device); + await assert.rejects(client.setDpi(800), /not identified itself/); + assert.deepEqual(sleepy.writes, []); + sleepy.state.pairedPid = 0x1984; + assert.equal(await client.setDpi(800), 800); +}); diff --git a/src/drivers/endgame/egg-op1-hid.ts b/src/drivers/endgame/egg-op1-hid.ts index 2c21452..6655bbc 100644 --- a/src/drivers/endgame/egg-op1-hid.ts +++ b/src/drivers/endgame/egg-op1-hid.ts @@ -1,7 +1,9 @@ import type { MouseStatus } from "../mouse-types.ts"; import { + EGG_4K_DONGLE_PID, EGG_4K_OFFSET, EGG_4K_POLLING_RATES, + EGG_4K_V1_POLLING_RATES, EGG_BUTTON_ACTION_OPTIONS, EGG_COMMAND_SIZE, EGG_CONFIG_SIZE, @@ -88,9 +90,11 @@ export class EggOp1HidClient { private configPayloadLength = EGG_CONFIG_SIZE - 1; private commandPayloadLength = EGG_COMMAND_SIZE - 1; private firmwareVersion: string | null | undefined; - private pairedName: string | undefined; + /** False on the shared 4K dongle until the mouse-info reply says which mouse is paired. */ + private modelKnown: boolean; - readonly profile: EggDeviceProfile; + /** On the shared 4K dongle, switches to the paired mouse's profile once it is identified. */ + profile: EggDeviceProfile; onDeviceChange?: () => void; private readonly onInputReport = (event: HIDInputReportEvent): void => { @@ -104,6 +108,7 @@ export class EggOp1HidClient { constructor(device: HIDDevice) { this.device = device; this.profile = eggProfileForPid(device.productId); + this.modelKnown = device.productId !== EGG_4K_DONGLE_PID; } static isSupported(device: HIDDevice): boolean { @@ -143,6 +148,8 @@ export class EggOp1HidClient { async readStatus(): Promise { const config = await this.readConfig(); + // Retried on each read until the mouse answers: over the dongle it sleeps quickly. + if (!this.modelKnown) await this.run(() => this.identifyPairedMouse()).catch(() => undefined); const cpiLevels = Math.min(Math.max(config[EGG_OFFSET.cpiLevels], 1), 4); // Firmware does not persist the currently selected runtime stage. Stage 1 // is the stable representative for the generic DPI readout; the complete @@ -155,10 +162,6 @@ export class EggOp1HidClient { if (this.firmwareVersion === undefined) { this.firmwareVersion = await this.readFirmware().catch(() => null); } - // Retried on each read until the mouse answers: over the dongle it sleeps quickly. - if (this.profile.wireless4k && this.pairedName === undefined) { - this.pairedName = await this.readPairedName().catch(() => undefined); - } const glassMode = this.profile.lodGlass !== null && config[EGG_OFFSET.glassMode] !== 0; const lodOptions = eggLodOptions(this.profile, glassMode); const lodIndex = config[EGG_OFFSET.lod] - this.lodBase(glassMode); @@ -168,7 +171,7 @@ export class EggOp1HidClient { this.decodePhysicalButtonAction(config, index as EggButtonIndex, leftHanded)); return { brand: "Endgame Gear", - name: this.pairedName ?? this.profile.name, + name: this.profile.name, batteryPercent: null, batteryState: "Unknown", dpi, @@ -184,7 +187,7 @@ export class EggOp1HidClient { rippleControl: config[EGG_OFFSET.rippleControl] !== 0, slamclickFilter: (config[EGG_OFFSET.filterFlags] & FILTER.slamclick) !== 0, // Bit 4 is the 4K v1's jitter filter; the 4K v2 tool never writes it. - motionJitterFilter: this.profile.wireless4k ? null : (config[EGG_OFFSET.filterFlags] & FILTER.motionJitter) !== 0, + motionJitterFilter: this.is4kV2() ? null : (config[EGG_OFFSET.filterFlags] & FILTER.motionJitter) !== 0, leftSpdtMode: this.decodeSpdtMode(config[EGG_OFFSET.firstButton]), rightSpdtMode: this.decodeSpdtMode(config[EGG_OFFSET.firstButton + BUTTON_CONFIG_SIZE]), eggCpiLevels: cpiLevels, @@ -248,7 +251,7 @@ export class EggOp1HidClient { /** The 4K v2 takes only its vendor enum values; wired 8K models use any 8000 / rate divider. */ supportedPollingRates(): number[] { - if (this.profile.wireless4k) return [...EGG_4K_POLLING_RATES]; + if (this.profile.wireless4k) return [...(this.is4kV2() ? EGG_4K_POLLING_RATES : EGG_4K_V1_POLLING_RATES)]; return EGG_POLLING_RATES.filter((rate) => rate <= this.profile.maxPollingHz); } @@ -360,7 +363,7 @@ export class EggOp1HidClient { } async setMotionJitterFilter(enabled: boolean): Promise { - if (this.profile.wireless4k) throw new Error(`${this.profile.name} has no motion-jitter filter.`); + if (this.is4kV2()) throw new Error(`${this.profile.name} has no motion-jitter filter.`); await this.setFilterFlag(FILTER.motionJitter, enabled, "motion-jitter filter"); } @@ -493,9 +496,13 @@ export class EggOp1HidClient { return this.profile.wireless4k ? EGG_4K_OFFSET.angleTuning : EGG_OFFSET.angleTuning; } - /** 4K v2 glass-mode LOD is stored as whole millimetres, so option 0 (1.0 mm) is wire value 1. */ + /** 4K v1 LOD, and 4K v2 glass-mode LOD, is stored as whole millimetres, so option 0 (1 mm) is wire value 1. */ private lodBase(glassMode: boolean): number { - return glassMode && this.profile.wireless4k ? 1 : 0; + return this.profile.wireless4k && (glassMode || !this.is4kV2()) ? 1 : 0; + } + + private is4kV2(): boolean { + return this.profile.wireless4k === true && this.profile.configFamily === "v2"; } private decodeInt8(value: number): number { @@ -580,6 +587,9 @@ export class EggOp1HidClient { private updateConfig(change: (config: Uint8Array) => void): Promise { return this.run(async () => { + // v1 and v2 share the dongle but encode lift-off and polling differently: never write blind. + if (!this.modelKnown) await this.identifyPairedMouse(); + if (!this.modelKnown) throw new Error("The mouse behind the dongle has not identified itself yet. Move it to wake it, then try again."); const config = await this.readConfigRaw(); const before = config.slice(); change(config); @@ -641,10 +651,11 @@ export class EggOp1HidClient { if (!acknowledged) throw new Error("The EGG mouse did not acknowledge the configuration write."); } - /** 4K v2: send only the blocks that changed, like the vendor tool. Both button chunks go together. */ + /** 4K v1 and v2: send only the blocks that changed, like the vendor tools. Both button chunks go together. */ private async writeConfigBlocks(before: Uint8Array, after: Uint8Array): Promise { - const old = eggBlockWrites(before); - const writes = eggBlockWrites(after); + const glass = this.profile.lodGlass !== null; + const old = eggBlockWrites(before, glass); + const writes = eggBlockWrites(after, glass); const changed = new Set(writes .filter((write, index) => write.payload.some((byte, i) => byte !== old[index].payload[i])) .map((write) => write.command)); @@ -674,28 +685,29 @@ export class EggOp1HidClient { } /** - * The dongle's USB IDs are the same whichever 4K v2 is paired; the mouse-info - * reply carries the mouse's own VID/PID at payload +0/+2. Undefined while the - * mouse does not answer, so the caller can retry. + * The dongle's USB IDs are the same whichever 4K mouse is paired; the + * mouse-info reply carries the mouse's own VID/PID at payload +0/+2, and the + * profile follows it because v1 and v2 encode lift-off and polling + * differently (re/PROTOCOL.md section 12). An unlisted PID keeps the neutral + * profile. Leaves modelKnown false while the mouse does not answer. */ - private readPairedName(): Promise { - return this.run(async () => { - await this.open(); - await this.sendCommand(EGG_OPERATION.mouseInfo); - // The vendor tool's settle and busy back-off (PROTOCOL.md section 2). - await this.delay(150); - for (let attempt = 0; attempt < 3; attempt += 1) { - const reply = (await this.receiveFeature(EGG_REPORT.command, EGG_COMMAND_SIZE, this.commandPayloadLength)).bytes; - if (reply[1] === STATUS_OK && eggReadUint16LE(reply, 16) === EGG_VENDOR_ID) { - const paired = EGG_DEVICE_PROFILES.get(eggReadUint16LE(reply, 18)); - return paired?.wireless4k ? paired.name : this.profile.name; - } - // An OK without our VID is the previous command's held reply: the dongle has not relayed the mouse yet. - if (reply[1] !== STATUS_BUSY && reply[1] !== STATUS_OK) return undefined; - await this.delay(200 * (attempt + 1)); + private async identifyPairedMouse(): Promise { + await this.open(); + await this.sendCommand(EGG_OPERATION.mouseInfo); + // The vendor tool's settle and busy back-off (PROTOCOL.md section 2). + await this.delay(150); + for (let attempt = 0; attempt < 3; attempt += 1) { + const reply = (await this.receiveFeature(EGG_REPORT.command, EGG_COMMAND_SIZE, this.commandPayloadLength)).bytes; + if (reply[1] === STATUS_OK && eggReadUint16LE(reply, 16) === EGG_VENDOR_ID) { + const paired = EGG_DEVICE_PROFILES.get(eggReadUint16LE(reply, 18)); + if (paired?.wireless4k) this.profile = paired; + this.modelKnown = true; + return; } - return undefined; - }); + // An OK without our VID is the previous command's held reply: the dongle has not relayed the mouse yet. + if (reply[1] !== STATUS_BUSY && reply[1] !== STATUS_OK) return; + await this.delay(200 * (attempt + 1)); + } } private async sendCommand(operation: number, header: number[] = [], payload?: Uint8Array): Promise { diff --git a/src/drivers/endgame/egg-op1-protocol.test.ts b/src/drivers/endgame/egg-op1-protocol.test.ts index e236431..1ddd67d 100644 --- a/src/drivers/endgame/egg-op1-protocol.test.ts +++ b/src/drivers/endgame/egg-op1-protocol.test.ts @@ -23,10 +23,10 @@ const op1 = EGG_DEVICE_PROFILES.get(0x1964)!; const purple = EGG_DEVICE_PROFILES.get(0x1976)!; const op1v2 = EGG_DEVICE_PROFILES.get(0x1978)!; -test("all eight Endgame Gear OP1-protocol devices have explicit capability profiles", () => { +test("all ten Endgame Gear OP1-protocol devices have explicit capability profiles", () => { assert.deepEqual( [...EGG_DEVICE_PROFILES.keys()], - [0x1964, 0x1966, 0x1976, 0x1978, 0x1980, 0x1984, 0x1982, 0x1970], + [0x1964, 0x1966, 0x1976, 0x1978, 0x1980, 0x1972, 0x1968, 0x1984, 0x1982, 0x1970], ); assert.equal(op1.motionSyncAt8k, false); assert.equal(EGG_DEVICE_PROFILES.get(0x1966)!.motionSyncAt8k, false); @@ -42,12 +42,14 @@ test("OP1w 4K v2 wireless models are capped at 4000 Hz while wired 8K models kee assert.equal(EGG_DEVICE_PROFILES.get(0x1970)!.maxPollingHz, 4000); }); -test("cabled 4K v2 PIDs name their model; the shared dongle stays neutral until the mouse reports its PID", () => { +test("cabled 4K PIDs name their model; the shared dongle stays neutral until the mouse reports its PID", () => { // An XM2w 4K v2 behind the dongle reports the receiver's fixed USB name // (confirmed on hardware), so 0x1970 alone cannot say which mouse is paired. + assert.equal(eggProfileForPid(0x1972).name, "Endgame Gear OP1w 4K"); + assert.equal(eggProfileForPid(0x1968).name, "Endgame Gear XM2w 4K"); assert.equal(eggProfileForPid(0x1984).name, "Endgame Gear OP1w 4K v2"); assert.equal(eggProfileForPid(0x1982).name, "Endgame Gear XM2w 4K v2"); - assert.equal(eggProfileForPid(0x1970).name, "Endgame Gear OP1w/XM2w 4K v2"); + assert.equal(eggProfileForPid(0x1970).name, "Endgame Gear OP1w/XM2w 4K"); }); test("CPI ranges and quantization follow each sensor generation", () => { diff --git a/src/endgame-gear/op1.ts b/src/endgame-gear/op1.ts index b26366c..d823ebb 100644 --- a/src/endgame-gear/op1.ts +++ b/src/endgame-gear/op1.ts @@ -15,6 +15,8 @@ export interface EggButtonAction { } export const EGG_VENDOR_ID = 0x3367; +/** The 2.4 GHz dongle shared by every OP1w/XM2w 4K, v1 and v2. */ +export const EGG_4K_DONGLE_PID = 0x1970; export type EggSensorFamily = "paw3395" | "paw3950"; export type EggConfigFamily = "v1" | "v2"; @@ -34,9 +36,11 @@ export interface EggDeviceProfile { /** Wired 8K models top out at 8000 Hz; wireless dongles are RF-limited to 4000 Hz. */ maxPollingHz: number; /** - * OP1w/XM2w 4K v2 firmware: refuses the whole-blob store (status 0x07) and - * takes block writes instead (eggBlockWrites), keeps angle tuning and force - * max FPS at their own offsets, and stores glass-mode LOD in whole millimetres. + * OP1w/XM2w 4K firmware, both generations: refuses the whole-blob store + * (status 0x07) and takes block writes instead (eggBlockWrites). On v2 + * (configFamily "v2") angle tuning and force max FPS sit at their own + * offsets and glass-mode LOD is whole millimetres; v1 stores every LOD in + * whole millimetres and has no glass mode. */ wireless4k?: true; } @@ -47,6 +51,22 @@ const LOD_V2 = [ "1.3 mm", "1.4 mm", "1.5 mm", "1.6 mm", "1.7 mm", ] as const; const LOD_GLASS = ["1.0 mm", "2.0 mm"] as const; +/** The 4K v1 tool offers only these, stored as wire values 1 and 2 (re/PROTOCOL.md section 12). */ +const LOD_4K_V1 = ["1 mm", "2 mm"] as const; +/** OP1w 4K / XM2w 4K, the first generation: PAW3395 limits, no glass mode, no v2 sensor controls. */ +const WIRELESS_4K_V1 = { + configFamily: "v1", + sensorFamily: "paw3395", + cpiMin: 50, + cpiMax: 26_000, + cpiStepLow: 50, + cpiStepHigh: 50, + lodNormal: LOD_4K_V1, + lodGlass: null, + motionSyncAt8k: true, + maxPollingHz: 4000, + wireless4k: true, +} as const; const WIRELESS_4K_V2 = { configFamily: "v2", sensorFamily: "paw3950", @@ -132,16 +152,19 @@ export const EGG_DEVICE_PROFILES: ReadonlyMap = new Ma motionSyncAt8k: true, maxPollingHz: 8000, }], - // OP1w/XM2w 4K v2: first wireless models on the OP1-8K v2 config protocol. - // Cabled, each mouse has its own PID (OP1w 0x1984, XM2w 0x1982, the latter - // from the XM2w vendor tool's binary only). The 2.4 GHz dongle is 0x1970 for - // both, reused from the older, unrelated OP1we (see egg-we-hid.ts, which - // uses the descriptor to keep the two drivers apart, issue #107), and its - // USB name is fixed whatever mouse is paired. Its profile name stays neutral - // until the mouse-info command (EGG_OPERATION.mouseInfo) reports which one. + // OP1w/XM2w 4K, v1 and v2: the wireless models on the OP1-8K config protocol. + // Cabled, each mouse has its own PID (OP1w 4K 0x1972, XM2w 4K 0x1968, OP1w + // 4K v2 0x1984, XM2w 4K v2 0x1982; both XM2w from the vendor binaries only, + // re/PROTOCOL.md section 10). The 2.4 GHz dongle is 0x1970 for all four, + // reused from the older, unrelated OP1we (see egg-we-hid.ts, which uses the + // descriptor to keep the two drivers apart, issue #107), and its USB name is + // fixed whatever mouse is paired. Its profile stays neutral until the + // mouse-info command (EGG_OPERATION.mouseInfo) reports which one. + [0x1972, { pid: 0x1972, name: "Endgame Gear OP1w 4K", ...WIRELESS_4K_V1 }], + [0x1968, { pid: 0x1968, name: "Endgame Gear XM2w 4K", ...WIRELESS_4K_V1 }], [0x1984, { pid: 0x1984, name: "Endgame Gear OP1w 4K v2", ...WIRELESS_4K_V2 }], [0x1982, { pid: 0x1982, name: "Endgame Gear XM2w 4K v2", ...WIRELESS_4K_V2 }], - [0x1970, { pid: 0x1970, name: "Endgame Gear OP1w/XM2w 4K v2", ...WIRELESS_4K_V2 }], + [EGG_4K_DONGLE_PID, { pid: EGG_4K_DONGLE_PID, name: "Endgame Gear OP1w/XM2w 4K", ...WIRELESS_4K_V2 }], ]); export const EGG_REPORT = { @@ -191,6 +214,8 @@ export const EGG_4K_OFFSET = { /** The only rates the 4K v2 vendor tool writes; 125 Hz is its "Office Mode" (0x40). */ export const EGG_4K_POLLING_RATES = [125, 1000, 2000, 4000] as const; +/** The 4K v1 tool writes only 0x08 / 0x04 / 0x02; Office Mode and power saving came with v2. */ +export const EGG_4K_V1_POLLING_RATES = [1000, 2000, 4000] as const; export interface EggBlockWrite { command: number; @@ -201,19 +226,19 @@ export interface EggBlockWrite { } /** - * The three block writes the OP1w/XM2w 4K v2 vendor tool uses instead of the + * The three block writes the OP1w/XM2w 4K vendor tools use instead of the * whole-blob store, built from a config buffer. Layout decoded from the vendor * tool and USB captures in johanneszab/endgame-op1w (re/PROTOCOL.md section 4); - * its blob offsets sit 16 bytes into our buffer. The power block carries 11 - * bytes but declares 10, exactly like the vendor tool: byte 11 is glass mode. + * its blob offsets sit 16 bytes into our buffer. The power block declares 10 + * bytes; the v2 tool appends an 11th, glass mode, which the v1 never sends. */ -export function eggBlockWrites(config: Uint8Array): EggBlockWrite[] { +export function eggBlockWrites(config: Uint8Array, hasGlassMode = true): EggBlockWrite[] { const at = (offset: number): number => config[16 + offset]; const sensor = new Uint8Array(28); sensor.set([at(0x07), at(0x08), at(0x09), at(0x0a), at(0x0b), at(0x01), at(0x0e), at(0x0d)]); sensor.set(config.subarray(16 + 0x23, 16 + 0x37), 8); const filters = [0, 1, 2, 3, 4].map((button) => at(0x3d + button * 7)); - const power = Uint8Array.of(at(0x0c), at(0x05), at(0x06), at(0x04), ...filters, at(0x03), at(0x6f)); + const power = Uint8Array.of(at(0x0c), at(0x05), at(0x06), at(0x04), ...filters, at(0x03), ...(hasGlassMode ? [at(0x6f)] : [])); const buttons = config.slice(16 + 0x37, 16 + 0x6f); return [ { command: EGG_OPERATION.writeSensor, declaredLength: 28, chunk: 0, payload: sensor },