diff --git a/src/drivers/endgame/egg-op1-hid.test.ts b/src/drivers/endgame/egg-op1-hid.test.ts index 3745a9fc..bdc01c0a 100644 --- a/src/drivers/endgame/egg-op1-hid.test.ts +++ b/src/drivers/endgame/egg-op1-hid.test.ts @@ -237,3 +237,33 @@ test("writes over the shared dongle wait until the paired mouse identifies itsel sleepy.state.pairedPid = 0x1984; assert.equal(await client.setDpi(800), 800); }); + +test("a stale handle that rejects the first write is closed, reopened and retried once", async () => { + const mouse = fake4kV2(); + const device = mouse.device as unknown as { + opened: boolean; + open(): Promise; + close(): Promise; + sendFeatureReport(reportId: number, data: BufferSource): Promise; + }; + const send = device.sendFeatureReport.bind(device); + let stale = true; + const calls: string[] = []; + device.open = async () => { calls.push("open"); device.opened = true; stale = false; }; + device.close = async () => { calls.push("close"); device.opened = false; }; + device.sendFeatureReport = async (reportId, data) => { + if (stale) throw new DOMException("Failed to write the feature report.", "NetworkError"); + return send(reportId, data); + }; + + const status = await new EggOp1HidClient(mouse.device).readStatus(); + assert.equal(status.firmware[0], "Firmware V1.07"); + assert.deepEqual(calls.slice(0, 2), ["close", "open"]); +}); + +test("a write that still fails after the reopen surfaces the error", async () => { + const mouse = fake4kV2(); + const device = mouse.device as unknown as { sendFeatureReport(): Promise }; + device.sendFeatureReport = async () => { throw new Error("Failed to write the feature report."); }; + await assert.rejects(new EggOp1HidClient(mouse.device).readStatus(), /Failed to write the feature report/); +}); diff --git a/src/drivers/endgame/egg-op1-hid.ts b/src/drivers/endgame/egg-op1-hid.ts index 6655bbca..ba8e35c8 100644 --- a/src/drivers/endgame/egg-op1-hid.ts +++ b/src/drivers/endgame/egg-op1-hid.ts @@ -637,7 +637,7 @@ export class EggOp1HidClient { let lastError: unknown; for (let attempt = 0; attempt < 4; attempt += 1) { try { - await this.device.sendFeatureReport(EGG_REPORT.config, payload); + await this.sendFeatureRecovering(EGG_REPORT.config, payload); lastError = undefined; break; } catch (error) { @@ -715,7 +715,22 @@ export class EggOp1HidClient { command.set([operation, ...header]); // Payload sits at wire offset 16, i.e. 15 once the report ID is stripped. if (payload) command.set(payload, 15); - await this.device.sendFeatureReport(EGG_REPORT.command, command); + await this.sendFeatureRecovering(EGG_REPORT.command, command); + } + + /** + * A handle left stale (sleep/resume, USB hiccup, an earlier page) rejects + * every write with "Failed to write the feature report" until the device is + * re-enumerated. Close and reopen it once and retry before giving up. + */ + private async sendFeatureRecovering(reportId: number, data: BufferSource): Promise { + try { + await this.device.sendFeatureReport(reportId, data); + } catch { + await this.device.close().catch(() => undefined); + await this.open(); + await this.device.sendFeatureReport(reportId, data); + } } private async pollCommandOk(attempts: number): Promise { diff --git a/src/drivers/endgame/egg-we-hid.ts b/src/drivers/endgame/egg-we-hid.ts index 85dede14..f44d48f2 100644 --- a/src/drivers/endgame/egg-we-hid.ts +++ b/src/drivers/endgame/egg-we-hid.ts @@ -505,6 +505,23 @@ export class EggWeHidClient { waiter.reject(error); } + /** + * A stale handle (sleep/resume, USB hiccup) rejects every write until the + * device is re-enumerated. Close the command channel, reopen and retry once. + */ + private async sendReportRecovering(channels: WeChannels, data: ArrayBuffer): Promise { + try { + await channels.cmd.sendFeatureReport(WE_REPORT_ID, data); + } catch { + await channels.cmd.close().catch(() => undefined); + await this.open(); + const fresh = this.resolvedChannels() ?? channels; + fresh.notify.removeEventListener("inputreport", this.onInputReport); + fresh.notify.addEventListener("inputreport", this.onInputReport); + await fresh.cmd.sendFeatureReport(WE_REPORT_ID, data); + } + } + private async transact( channels: WeChannels, command: number, @@ -529,7 +546,7 @@ export class EggWeHidClient { // Ensure ArrayBuffer-backed view for WebHID typings. const copy = new Uint8Array(payload); try { - await channels.cmd.sendFeatureReport(WE_REPORT_ID, copy.buffer); + await this.sendReportRecovering(channels, copy.buffer); } catch (error) { this.failWaiter(error instanceof Error ? error : new Error(String(error))); } diff --git a/src/drivers/endgame/egg-we-protocol.test.ts b/src/drivers/endgame/egg-we-protocol.test.ts index af7ac1ec..f8b4ce78 100644 --- a/src/drivers/endgame/egg-we-protocol.test.ts +++ b/src/drivers/endgame/egg-we-protocol.test.ts @@ -150,3 +150,32 @@ test("DPI patch refuses corrupt source stages", () => { const profile = new Uint8Array(WE_OFF.dpiStages + 4); assert.throws(() => wePatchActiveDpi(profile, 800, 0), /checksum is invalid/); }); + +test("a stale command channel is closed, reopened and the OP1we write retried once", async () => { + let stale = true; + const calls: string[] = []; + const device = { + vendorId: 0x3367, + productId: 0x1962, + productName: "OP1we", + opened: true, + collections: [ + { usagePage: 0xff02, usage: 0, featureReports: [{ reportId: WE_REPORT_ID, items: [] }], inputReports: [], outputReports: [], children: [] }, + { usagePage: 0xff01, usage: 0, featureReports: [], inputReports: [{ reportId: 0x09, items: [] }], outputReports: [], children: [] }, + ], + async open() { calls.push("open"); this.opened = true; stale = false; }, + async close() { calls.push("close"); this.opened = false; }, + addEventListener() {}, + removeEventListener() {}, + async sendFeatureReport() { + if (stale) throw new DOMException("Failed to write the feature report.", "NetworkError"); + calls.push("sent"); + }, + } as unknown as HIDDevice; + const client = new EggWeHidClient(device); + const send = (client as unknown as { + sendReportRecovering(channels: { cmd: HIDDevice; notify: HIDDevice }, data: ArrayBuffer): Promise; + }).sendReportRecovering.bind(client); + await send({ cmd: device, notify: device }, new ArrayBuffer(16)); + assert.deepEqual(calls, ["close", "open", "sent"]); +});