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
47 changes: 42 additions & 5 deletions src/drivers/endgame/egg-ot-hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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<DataView> };
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);
});
53 changes: 38 additions & 15 deletions src/drivers/endgame/egg-ot-hid.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -22,6 +25,7 @@ import {
otEncodeFirmwareSetup,
otEncodeProfilePackets,
otEncodeQueryPacket,
otEncodeSetPasswdPacket,
otFirmwarePackets,
otIsValidCpi,
otLodLabel,
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -110,12 +119,34 @@ export class EggOtHidClient {
async open(): Promise<void> {
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<void> {
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<void> {
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)}`);
Expand Down Expand Up @@ -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";
}
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand All @@ -479,7 +506,6 @@ export class EggOtHidClient {
private async exchange(packet: Uint8Array, expectResponse: boolean): Promise<Uint8Array | null> {
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);
Expand All @@ -491,7 +517,6 @@ export class EggOtHidClient {
continue;
}
let readCounter = 0;
let receivedAck = false;
while (readCounter++ < OT_READ_RETRIES) {
let reply: Uint8Array;
try {
Expand All @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions src/drivers/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
36 changes: 33 additions & 3 deletions src/endgame-gear/ot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Loading