From 62c23436a4a6916d66179c08fac2d638d1643680 Mon Sep 17 00:00:00 2001 From: snekxs Date: Sat, 17 Oct 2026 01:53:18 -0600 Subject: [PATCH] fix(endgame): match the vendor OT handshake and interface Found by reading the vendor app (v1.6.400) against the driver: - open a session with kComSetPasswd (static vendor password), then the firmware version, before any profile query; silence is traced, not fatal - accept a lone response packet: the vendor treats the ACK as seen after every write, so a query no longer needs an ACK first - claim only the 0xFFA1:0x00A5 interface the vendor opens; other interfaces of the same PID refuse feature-report writes Refs #237 Co-Authored-By: Claude Sonnet 5.5 --- src/drivers/endgame/egg-ot-hid.test.ts | 47 ++++++++++++++++++++--- src/drivers/endgame/egg-ot-hid.ts | 53 ++++++++++++++++++-------- src/drivers/registry.test.ts | 3 ++ src/endgame-gear/ot.ts | 36 +++++++++++++++-- 4 files changed, 116 insertions(+), 23 deletions(-) diff --git a/src/drivers/endgame/egg-ot-hid.test.ts b/src/drivers/endgame/egg-ot-hid.test.ts index 0d664b58..88c6d273 100644 --- a/src/drivers/endgame/egg-ot-hid.test.ts +++ b/src/drivers/endgame/egg-ot-hid.test.ts @@ -118,8 +118,8 @@ function fakeOtMouse({ productId = 0x1905, silentSaves = false } = {}) { productName: "OT Mouse", opened: true, collections: [{ - usagePage: 0xff00, - usage: 1, + usagePage: 0xffa1, + usage: 0x00a5, children: [], inputReports: [], outputReports: [], @@ -198,8 +198,8 @@ function bareDevice(productId: number, vendorId = 0x3367, collections: HIDCollec } const feature0 = [{ - usagePage: 0xff00, - usage: 1, + usagePage: 0xffa1, + usage: 0x00a5, children: [], inputReports: [], outputReports: [], @@ -306,6 +306,43 @@ test("a mouse that never sends the response packet reports the raw tx/rx trace", const client = new EggOtHidClient(fake.device); await assert.rejects( () => client.readStatus(), - /did not answer OT profile block 0\. Trace: tx 33 [0-9a-f ]+ \| rx 00 14 [0-9a-f ]+ \| rx 00 00 [0-9a-f ]+ x\d+.*\. Collections: usage 0xff00:0x1 · feature 0x0/, + /did not answer OT profile block 0\. Trace: tx 33 [0-9a-f ]+ \| rx 00 14 [0-9a-f ]+ \| rx 00 00 [0-9a-f ]+ x\d+.*\. Collections: usage 0xffa1:0xa5 · feature 0x0/, ); }); + +test("the OT driver rejects interfaces other than the vendor 0xFFA1:0x00A5 collection", () => { + const withUsage = (usagePage: number, usage: number) => [{ + usagePage, + usage, + children: [], + inputReports: [], + outputReports: [], + featureReports: [{ reportId: 0, items: [{ reportSize: 8, reportCount: 63 }] }], + }] as unknown as HIDCollectionInfo[]; + assert.equal(EggOtHidClient.isSupported(bareDevice(0x1905, 0x3367, withUsage(0xffa1, 0x00a5))), true); + assert.equal(EggOtHidClient.isSupported(bareDevice(0x1905, 0x3367, withUsage(0x0001, 0x0002))), false); + assert.equal(EggOtHidClient.isSupported(bareDevice(0x1905, 0x3367, withUsage(0xffa1, 0x0001))), false); +}); + +test("a session opens with the vendor password, then the firmware version, before any profile query", async () => { + const fake = fakeOtMouse(); + await new EggOtHidClient(fake.device).readStatus(); + assert.match(fake.sent[0]!, /^13:/); + assert.match(fake.sent[0]!, / 47 45 47 4a 47$/, "payload starts with ASCII GEGJG"); + assert.match(fake.sent[1]!, /^11:/); + assert.match(fake.sent[2]!, /^33:/); +}); + +test("a mouse that answers queries with a lone response and no ack is still read", async () => { + const fake = fakeOtMouse(); + const device = fake.device as unknown as { receiveFeatureReport: () => Promise }; + const receive = device.receiveFeatureReport.bind(device); + device.receiveFeatureReport = async () => { + for (;;) { + const view = await receive(); + if (new Uint8Array(view.buffer, view.byteOffset, view.byteLength)[1] !== OT_COMTYPE.ack) return view; + } + }; + const status = await new EggOtHidClient(fake.device).readStatus(); + assert.equal(status.dpi, 800); +}); diff --git a/src/drivers/endgame/egg-ot-hid.ts b/src/drivers/endgame/egg-ot-hid.ts index bad4bfb5..a778c48f 100644 --- a/src/drivers/endgame/egg-ot-hid.ts +++ b/src/drivers/endgame/egg-ot-hid.ts @@ -1,6 +1,7 @@ import type { MouseStatus } from "../mouse-types.ts"; import { OT_COMTYPE, + OT_COMTYPE_OFFSET, OT_CPI_COUNT, OT_DEVICE_PROFILES, OT_PACKET_SIZE, @@ -11,6 +12,8 @@ import { OT_PROFILE_FLAG, OT_READ_RETRIES, OT_REPORT_ID, + OT_USAGE, + OT_USAGE_PAGE, OT_VENDOR_ID, OT_WRITE_RETRIES, otCpiStep, @@ -22,6 +25,7 @@ import { otEncodeFirmwareSetup, otEncodeProfilePackets, otEncodeQueryPacket, + otEncodeSetPasswdPacket, otFirmwarePackets, otIsValidCpi, otLodLabel, @@ -86,6 +90,7 @@ export class EggOtHidClient { private firmwareRaw: number[] | null | undefined; private packetPayloadLength = OT_PACKET_SIZE - 1; private trace: string[] = []; + private sessionStarted = false; readonly device: HIDDevice; readonly profile: OtDeviceProfile; @@ -95,10 +100,14 @@ export class EggOtHidClient { this.profile = otProfileForPid(device.productId); } + /** Only the vendor's 0xFFA1:0x00A5 interface; the other interfaces of the same PID refuse feature-report writes. */ static isSupported(device: HIDDevice): boolean { return device.vendorId === OT_VENDOR_ID && OT_DEVICE_PROFILES.has(device.productId) - && this.collectionHasFeatureReport(device.collections, OT_REPORT_ID); + && (device.collections ?? []).some((collection) => + collection.usagePage === OT_USAGE_PAGE + && collection.usage === OT_USAGE + && this.collectionHasFeatureReport([collection], OT_REPORT_ID)); } private static collectionHasFeatureReport(collections: readonly HIDCollectionInfo[] | undefined | null, reportId: number): boolean { @@ -110,12 +119,34 @@ export class EggOtHidClient { async open(): Promise { if (!this.device.opened) await this.device.open(); this.packetPayloadLength = this.featurePayloadLength(OT_REPORT_ID, OT_PACKET_SIZE - 1); + if (!this.sessionStarted) { + this.sessionStarted = true; + await this.startSession(); + } } async close(): Promise { + this.sessionStarted = false; if (this.device.opened) await this.device.close(); } + /** + * Vendor session start: kComSetPasswd, then the firmware version. The vendor + * carries on when the mouse stays silent here, so failures are traced, not thrown. + */ + private async startSession(): Promise { + this.trace = []; + try { + await this.exchange(otEncodeSetPasswdPacket(), false); + const query = new Uint8Array(OT_PACKET_SIZE); + query[OT_COMTYPE_OFFSET] = OT_COMTYPE.queryFirmwareVersion; + const reply = await this.exchange(query, true); + if (reply) this.firmwareRaw = Array.from(reply.subarray(OT_PAYLOAD_INDEX)); + } catch { + // Already recorded in the trace by exchange(). + } + } + describeCollections(): string { return (this.device.collections ?? []).map((collection) => { const reports = (collection.featureReports ?? []).map((report) => `0x${report.reportId.toString(16)}`); @@ -387,7 +418,7 @@ export class EggOtHidClient { return reply.subarray(OT_PAYLOAD_INDEX); } - /** Packets seen by the last exchange (tx/rx hex, newest last) so a failed handshake reports what actually came back. */ + /** Recent packets since the session started (tx/rx hex, newest last) so a failed handshake reports what actually came back. */ private describeTrace(): string { return this.trace.length > 0 ? this.trace.join(" | ") : "no packets"; } @@ -444,7 +475,6 @@ export class EggOtHidClient { await this.open(); let writeCounter = 0; let readCounter = 0; - let receivedAck = false; const payload = Uint8Array.from(packet.subarray(1, 1 + this.packetPayloadLength)); const expectResponse = packet[1] !== OT_COMTYPE.setProfileData && packet[1] !== OT_COMTYPE.firmwareDlSetup @@ -461,11 +491,8 @@ export class EggOtHidClient { const reply = await this.receivePacket(); if (reply[1] !== 0x00) { if (expectResponse) { - if (reply[1] === OT_COMTYPE.ack) { - receivedAck = true; - continue; - } - if (receivedAck) return; + if (reply[1] === OT_COMTYPE.ack) continue; + return; } else if (reply[1] === OT_COMTYPE.ack || reply[1] === OT_COMTYPE.response) { return; } @@ -479,7 +506,6 @@ export class EggOtHidClient { private async exchange(packet: Uint8Array, expectResponse: boolean): Promise { await this.open(); const payload = Uint8Array.from(packet.subarray(1, 1 + this.packetPayloadLength)); - this.trace = []; let writeCounter = 0; while (writeCounter++ < OT_WRITE_RETRIES) { this.record("tx", payload); @@ -491,7 +517,6 @@ export class EggOtHidClient { continue; } let readCounter = 0; - let receivedAck = false; while (readCounter++ < OT_READ_RETRIES) { let reply: Uint8Array; try { @@ -503,11 +528,9 @@ export class EggOtHidClient { this.record("rx", reply); if (reply[1] === 0x00) continue; if (expectResponse) { - if (reply[1] === OT_COMTYPE.ack) { - receivedAck = true; - continue; - } - if (receivedAck) return reply; + // The vendor treats the ACK as already seen after the write, so a lone response also completes. + if (reply[1] === OT_COMTYPE.ack) continue; + return reply; } else if (reply[1] === OT_COMTYPE.ack || reply[1] === OT_COMTYPE.response) { return reply; } diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index 645f588f..e21a6905 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -86,6 +86,9 @@ function collectionShapes(): HIDCollectionInfo[][] { // only when no legacy input/output control collection sits beside it, so // probe that interface feature-only. shapes.push([collection(0xffff, 0x01, { feature: REPORT_IDS })]); + // Endgame Gear OT mice are claimed only on the vendor's 0xffa1:0x00a5 + // interface, whose usage is not in USAGES. + shapes.push([collection(0xffa1, 0x00a5, { feature: [0] })]); return shapes; } diff --git a/src/endgame-gear/ot.ts b/src/endgame-gear/ot.ts index 962e9ba8..1d4fde9e 100644 --- a/src/endgame-gear/ot.ts +++ b/src/endgame-gear/ot.ts @@ -9,9 +9,12 @@ * straight from the vendor's own TypeScript sources. * * Transport: 64-byte HID feature reports, report id 0. Byte 1 is the COMTYPE - * command, byte 2 the packet id, bytes 3..63 the payload. Queries expect an - * ACK (kComAck) followed by a response; fire-and-forget commands expect an - * ACK or kComResponse. Vendor retry budget: 10 writes, 10 reads. + * command, byte 2 the packet id, bytes 3..63 the payload. The vendor sets its + * "ACK seen" flag right after every write, so a query completes on the first + * non-empty, non-ACK reply, with or without a preceding ACK. Fire-and-forget + * commands complete on an ACK or kComResponse. Vendor retry budget: 10 writes, + * 10 reads. A session starts with kComSetPasswd then kComQueryFirmwareVersion, + * on the 0xFFA1:0x00A5 interface only. * * None of this has been exercised on hardware yet: every entry is * recognition-grade, not hardware-verified. @@ -115,6 +118,33 @@ export const OT_FW_CHUNK_SIZE = 57; export const OT_WRITE_RETRIES = 10; export const OT_READ_RETRIES = 10; +/** + * The only HID interface the vendor app opens (it skips every other + * collection of the same PID). Other interfaces are standard mouse/keyboard + * collections the browser refuses feature-report writes on. + */ +export const OT_USAGE_PAGE = 0xffa1; +export const OT_USAGE = 0x00a5; + +/** + * Session password the vendor app sends (kComSetPasswd, packet id 0) before + * anything else, then reads the firmware version. A static constant shipped in + * the app, not a per-device secret. + */ +export const OT_PASSWORD = "GEGJGYIKDCGBE9DWDKB27EAA7K9Z5J1P"; +export const OT_PASSWORD_LENGTH = 32; + +/** Build the kComSetPasswd packet that opens a session. */ +export function otEncodeSetPasswdPacket(): Uint8Array { + const out = new Uint8Array(OT_PACKET_SIZE); + out[OT_COMTYPE_OFFSET] = OT_COMTYPE.setPasswd; + out[OT_PACKET_ID_OFFSET] = 0; + for (let index = 0; index < OT_PASSWORD_LENGTH; index += 1) { + out[OT_PAYLOAD_INDEX + index] = OT_PASSWORD.charCodeAt(index); + } + return out; +} + /** Profile-data block ids (kComQueryProfileData / kComSetProfileData). */ export const OT_PROFILE_BLOCK = { main: 0,