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
61 changes: 61 additions & 0 deletions src/drivers/logitech/hidpp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number[]>;
readPollingRate(index: number): Promise<number>;
};

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<number[]>;
};
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
Expand Down
50 changes: 39 additions & 11 deletions src/drivers/logitech/hidpp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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.";
Expand Down Expand Up @@ -3329,19 +3331,45 @@ export class LogitechHidppClient {
return { x, y, lod: reply[12] ?? 0 };
}

private async readSupportedPollingRates(featureIndex: number): Promise<number[]> {
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<number[]> {
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<number> {
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.");
Expand Down
Loading