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
40 changes: 39 additions & 1 deletion src/drivers/endgame/egg-op1-hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand All @@ -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);
});
82 changes: 47 additions & 35 deletions src/drivers/endgame/egg-op1-hid.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 => {
Expand All @@ -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 {
Expand Down Expand Up @@ -143,6 +148,8 @@ export class EggOp1HidClient {

async readStatus(): Promise<EggOp1Status> {
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
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -360,7 +363,7 @@ export class EggOp1HidClient {
}

async setMotionJitterFilter(enabled: boolean): Promise<void> {
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");
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -580,6 +587,9 @@ export class EggOp1HidClient {

private updateConfig(change: (config: Uint8Array) => void): Promise<Uint8Array> {
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);
Expand Down Expand Up @@ -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<void> {
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));
Expand Down Expand Up @@ -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<string | undefined> {
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<void> {
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<void> {
Expand Down
10 changes: 6 additions & 4 deletions src/drivers/endgame/egg-op1-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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", () => {
Expand Down
57 changes: 41 additions & 16 deletions src/endgame-gear/op1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
}
Expand All @@ -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",
Expand Down Expand Up @@ -132,16 +152,19 @@ export const EGG_DEVICE_PROFILES: ReadonlyMap<number, EggDeviceProfile> = 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 = {
Expand Down Expand Up @@ -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;
Expand All @@ -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 },
Expand Down
Loading