From 84effb7fe236b3019c204a37035c5150f92ee3e4 Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:55:11 -0600 Subject: [PATCH] fix(logitech): read Superlight 2 Lightspeed polling above 1000 Hz 0x8061's rate list is per link. A zero connection argument is the cable, so a receiver-attached Superlight 2 only advertised through 1000 Hz. Co-authored-by: Cursor --- src/drivers/logitech/hidpp.test.ts | 61 ++++++++++++++++++++++++++++++ src/drivers/logitech/hidpp.ts | 50 ++++++++++++++++++------ 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/src/drivers/logitech/hidpp.test.ts b/src/drivers/logitech/hidpp.test.ts index 63306c52..6da0902c 100644 --- a/src/drivers/logitech/hidpp.test.ts +++ b/src/drivers/logitech/hidpp.test.ts @@ -698,6 +698,67 @@ test("LightForce switch mode is refused on a HITS mouse and nothing is sent to M assert.equal(askedForModeStatus, false, "the mode-status feature is never looked up, let alone written"); }); +test("a Lightspeed Superlight 2 reads the wireless 0x8061 list, not the cable's 1 kHz list", async () => { + // Issue #224. Function 0x10 with connection 0 is the wired mask (bits 0-3, + // through 1000 Hz). Lightspeed is connection 1 and includes 8000 Hz (bit 6). + const FEATURE_INDEX = 0x0b; + const { client, device } = harness(LIGHTSPEED_RECEIVER, { 1: "mouse" }); + await resolveIndex(client); + const functions: number[] = []; + device.onRequest = (request) => { + if (request[1] !== FEATURE_INDEX) return null; + const functionId = request[2] & 0xf0; + const connection = request[3]; + functions.push(functionId); + if (functionId === 0x20) { + return successReply(request[0], FEATURE_INDEX, functionId, [connection === 0x01 ? 6 : 3]); + } + const mask = connection === 0x01 ? 0x007f : 0x000f; + return successReply(request[0], FEATURE_INDEX, functionId, [(mask >> 8) & 0xff, mask & 0xff]); + }; + const driver = client as unknown as { + wiredConnection: boolean; + readSupportedPollingRates(index: number): Promise; + readPollingRate(index: number): Promise; + }; + + driver.wiredConnection = false; + assert.deepEqual( + await driver.readSupportedPollingRates(FEATURE_INDEX), + [125, 250, 500, 1000, 2000, 4000, 8000], + ); + assert.equal(await driver.readPollingRate(FEATURE_INDEX), 8000); + driver.wiredConnection = true; + assert.deepEqual(await driver.readSupportedPollingRates(FEATURE_INDEX), [125, 250, 500, 1000]); + assert.deepEqual(functions, [0x10, 0x20, 0x10]); +}); + +test("0x8061 function 0 supplies the Lightspeed rates when function 0x10 stays at 1000 Hz", async () => { + const FEATURE_INDEX = 0x0b; + const { client, device } = harness(LIGHTSPEED_RECEIVER, { 1: "mouse" }); + await resolveIndex(client); + device.onRequest = (request) => { + if (request[1] !== FEATURE_INDEX) return null; + const functionId = request[2] & 0xf0; + if (functionId === 0x10) { + return successReply(request[0], FEATURE_INDEX, functionId, [0x00, 0x0f]); + } + if (functionId === 0x00 && request[3] === 0x01) { + return successReply(request[0], FEATURE_INDEX, functionId, [0x00, 0x7f]); + } + return new Uint8Array([request[0], 0xff, FEATURE_INDEX, request[2], 0x05]); + }; + const driver = client as unknown as { + wiredConnection: boolean; + readSupportedPollingRates(index: number): Promise; + }; + driver.wiredConnection = false; + assert.deepEqual( + await driver.readSupportedPollingRates(FEATURE_INDEX), + [125, 250, 500, 1000, 2000, 4000, 8000], + ); +}); + // --- Receiver firmware register (HID++ 1.0 register 0xF1) ------------------- // Live PRO LIGHTSPEED receiver replies stayed [1, 7, 2] and [2, 0, 17] // across a G HUB update from 14.3.19 to 14.4.20. The bytes are useful diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index a4168463..f4c61883 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -781,6 +781,10 @@ export class LogitechHidppClient { // also makes every input report unambiguous to the WebHID event handler. const name = await this.readName(nameFeature.index); const identity = await this.readIdentity(firmwareFeature.index); + // 0x8061's rate list is per link. The query below has to know which one + // before it asks, or a Lightspeed receiver is handed the wired 1 kHz list. + this.wiredConnection = isWiredHidppConnection(this.device.productId, identity.transportIds, this.isDirectConnect); + this.usbTransportId = identity.transportIds.USB ?? null; const battery = batteryFeature.index // Ordered by how directly each reports charge: 0x1004 and 0x1000 give a // percentage outright, while the voltage features only allow an estimate. @@ -848,9 +852,7 @@ export class LogitechHidppClient { }; } this.supportedLods = [...this.lodCapabilities.supportedLods]; - const wired = isWiredHidppConnection(this.device.productId, identity.transportIds, this.isDirectConnect); - this.wiredConnection = wired; - this.usbTransportId = identity.transportIds.USB ?? null; + const wired = this.wiredConnection; this.lodCapabilities = { ...this.lodCapabilities, reportRates: reportRateCapabilitiesFor(onboardProfileFormat?.id, this.usbTransportId), @@ -2309,14 +2311,14 @@ export class LogitechHidppClient { }); result.decodedValues = decodeReportRateBitmap(listReply[3] ?? 0); } else { - const listReply = await this.request(result.featureIndex, 0x10); + const connection = this.reportRateConnection(); + const listReply = await this.request(result.featureIndex, 0x10, connection); result.replies.push({ name: "getSupportedReportRates", bytes: listReply }); result.replies.push({ name: "getReportRate", - bytes: await this.request(result.featureIndex, 0x20), + bytes: await this.request(result.featureIndex, 0x20, connection), }); - const flags = ((listReply[3] ?? 0) << 8) | (listReply[4] ?? 0); - result.decodedValues = REPORT_RATE_HZ.filter((_rate, index) => (flags & (1 << index)) !== 0); + result.decodedValues = this.decodeExtendedReportRateMask(listReply); } } catch (error) { result.error = error instanceof Error ? error.message : "Could not read report-rate capabilities."; @@ -3329,19 +3331,45 @@ export class LogitechHidppClient { return { x, y, lod: reply[12] ?? 0 }; } - private async readSupportedPollingRates(featureIndex: number): Promise { - if (!featureIndex) return []; - const reply = await this.request(featureIndex, 0x10); + /** + * 0x8061 connection argument. 0 is the USB cable, 1 is Lightspeed. Omitting + * it reads the cable's list, which on a Superlight 2 stops at 1000 Hz. + */ + private reportRateConnection(): number { + return this.wiredConnection ? 0x00 : 0x01; + } + + /** Bit N of the big-endian mask is rate code N: 125 Hz through 8000 Hz. */ + private decodeExtendedReportRateMask(reply: Uint8Array): number[] { const flags = ((reply[3] ?? 0) << 8) | (reply[4] ?? 0); return REPORT_RATE_HZ.filter((_rate, index) => (flags & (1 << index)) !== 0); } + private async readSupportedPollingRates(featureIndex: number): Promise { + if (!featureIndex) return []; + const connection = this.reportRateConnection(); + // Function 0x10 is the list for the connection in the first parameter. + // A zero there is the wired list (issue #224). + const listed = this.decodeExtendedReportRateMask(await this.request(featureIndex, 0x10, connection)); + if (connection === 0x00 || listed.some((rate) => rate > 1000)) return listed; + // Some firmware keeps the capability bitmask on function 0 and answers + // function 0x10 with the wired ceiling even when asked for Lightspeed. + // An unsupported function comes back as an error, not a timeout. + try { + const capabilities = this.decodeExtendedReportRateMask(await this.request(featureIndex, 0x00, connection)); + if (capabilities.some((rate) => rate > 1000)) return capabilities; + } catch { + // Function 0 is absent on the versions that answer function 0x10. + } + return listed; + } + private async readPollingRate(featureIndex: number): Promise { if (!featureIndex) { throw new Error("This Logitech mouse does not expose extended report-rate controls."); } - const reply = await this.request(featureIndex, 0x20); + const reply = await this.request(featureIndex, 0x20, this.reportRateConnection()); const rate = REPORT_RATE_HZ[reply[3] ?? -1]; if (!rate) { throw new Error("The mouse returned an unknown report-rate value.");