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
9 changes: 7 additions & 2 deletions src/drivers/endgame/egg-op1-hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`,
Expand Down Expand Up @@ -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<number> {
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;
Expand Down
14 changes: 12 additions & 2 deletions src/drivers/endgame/egg-op1-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 23 additions & 5 deletions src/drivers/endgame/egg-we-hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 17 additions & 2 deletions src/drivers/endgame/egg-we-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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")),
Expand Down
46 changes: 45 additions & 1 deletion src/endgame-gear/op1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -53,6 +55,7 @@ export const EGG_DEVICE_PROFILES: ReadonlyMap<number, EggDeviceProfile> = new Ma
lodNormal: LOD_V1,
lodGlass: null,
motionSyncAt8k: false,
maxPollingHz: 8000,
}],
[0x1966, {
pid: 0x1966,
Expand All @@ -66,6 +69,7 @@ export const EGG_DEVICE_PROFILES: ReadonlyMap<number, EggDeviceProfile> = new Ma
lodNormal: LOD_V1,
lodGlass: null,
motionSyncAt8k: false,
maxPollingHz: 8000,
}],
[0x1976, {
pid: 0x1976,
Expand All @@ -79,6 +83,7 @@ export const EGG_DEVICE_PROFILES: ReadonlyMap<number, EggDeviceProfile> = new Ma
lodNormal: LOD_V1,
lodGlass: LOD_GLASS,
motionSyncAt8k: true,
maxPollingHz: 8000,
}],
[0x1978, {
pid: 0x1978,
Expand All @@ -92,6 +97,7 @@ export const EGG_DEVICE_PROFILES: ReadonlyMap<number, EggDeviceProfile> = new Ma
lodNormal: LOD_V2,
lodGlass: LOD_GLASS,
motionSyncAt8k: true,
maxPollingHz: 8000,
}],
[0x1980, {
pid: 0x1980,
Expand All @@ -105,6 +111,39 @@ export const EGG_DEVICE_PROFILES: ReadonlyMap<number, EggDeviceProfile> = 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,
}],
]);

Expand Down Expand Up @@ -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);
Expand Down