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
2 changes: 2 additions & 0 deletions src/drivers/logitech/bolt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ test("HID++ 1.0 probe errors are treated as absent indices", () => {
classifyHidpp20Probe(new Error("The mouse rejected that setting (unsupported)."), false),
"hidpp20",
);
// sendReport itself failing is not a reply from any device index.
assert.equal(classifyHidpp20Probe(new Error("Failed to write the report."), false), "unreachable");
});

/**
Expand Down
8 changes: 6 additions & 2 deletions src/drivers/logitech/bolt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,14 @@ export function hidppIndexCandidates(
/**
* Classify a root getFeature probe: only HID++ 2.0 replies mean a mouse is on
* that index. Bolt receivers answer HID++ 1.0 errors on empty slots / 0xFF.
* Anything that is not a HID++ reply at all (sendReport itself rejected, e.g.
* on a collection that lacks the report id) is "unreachable": no index was
* asked, so it must not count as an answer, let alone a sensorless one.
*/
export function classifyHidpp20Probe(error: unknown, isTimeout: boolean): "hidpp20" | "absent" {
export function classifyHidpp20Probe(error: unknown, isTimeout: boolean): "hidpp20" | "absent" | "unreachable" {
if (isTimeout) return "absent";
if (error instanceof Error && /HID\+\+ 1\.0/.test(error.message)) return "absent";
if (!(error instanceof Error) || !/^The mouse rejected that/.test(error.message)) return "unreachable";
if (/HID\+\+ 1\.0/.test(error.message)) return "absent";
return "hidpp20";
}

Expand Down
25 changes: 25 additions & 0 deletions src/drivers/logitech/hidpp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,13 @@ class FakeHidDevice {

async close(): Promise<void> {}

/** Report ids this collection declares; sendReport rejects any other, like WebHID does. */
reportIds: number[] | null = null;

async sendReport(reportId: number, data: Uint8Array): Promise<void> {
if (this.reportIds && !this.reportIds.includes(reportId)) {
throw new Error("Failed to write the report.");
}
const request = data.slice();
this.probed.push({ reportId, data: request });
const reply = this.onRequest(request);
Expand Down Expand Up @@ -412,6 +418,25 @@ test("a direct-connect product with no sensor anywhere is reported as not a mous
assert.equal((error as Error).name, "NotAMouseError");
});

test("a long-only receiver collection is addressed on long reports", async () => {
// Chrome on some platforms hands out one HIDDevice per top-level collection,
// so a Lightspeed receiver can arrive as its usage-2 (report 0x11) half alone.
// Ticket 0143: a PRO X Superlight receiver came back "not a mouse" because
// every short probe was rejected by the host and counted as an answer.
const { client, device } = harness(0xc539, { 0x01: "mouse" }, [fakeCollection(0xff00, 0x0002)]);
device.reportIds = [0x11];
assert.equal(await resolveIndex(client), 0x01);
assert.ok(device.probed.every(({ reportId }) => reportId === 0x11));
});

test("a rejected sendReport is a transport error, not a sensorless answer", async () => {
const { client, device } = harness(0xc539, { 0x01: "mouse" });
device.reportIds = [];
const error = await resolveIndex(client).catch((reason) => reason);
assert.notEqual((error as Error).name, "NotAMouseError");
assert.match((error as Error).message, /Failed to write the report/);
});

test("a Bluetooth mouse is addressed on long reports only", async () => {
// BLE declares report 0x11 and nothing else, so the short-report path that
// Lightspeed and wired mice use would be rejected by the device outright.
Expand Down
17 changes: 12 additions & 5 deletions src/drivers/logitech/hidpp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,11 @@ export class LogitechHidppClient {
);
return "hidpp20";
} catch (error: unknown) {
return classifyHidpp20Probe(error, error instanceof HidppTimeoutError);
const outcome = classifyHidpp20Probe(error, error instanceof HidppTimeoutError);
// The request never left the host, so every other index would fail the
// same way; surface the transport error instead of a wrong "not a mouse".
if (outcome === "unreachable") throw error;
return outcome;
}
}

Expand Down Expand Up @@ -3337,10 +3341,13 @@ export class LogitechHidppClient {
): Promise<Uint8Array> {
// Bolt feature traffic only answers on long reports, and Bluetooth has no
// short report at all: its descriptor declares report 0x11 alone, so a
// sendReport(0x10) is rejected outright. Lightspeed and wired mice keep the
// short form for the three-parameter path they were verified with; longer
// payloads still go through requestLong.
if (this.isBoltReceiver || this.isBluetooth) {
// sendReport(0x10) is rejected outright. The same happens on platforms that
// split a receiver's top-level collections into separate HIDDevices (one
// per usage): the usage-2 one carries report 0x11 only, so a short request
// on it fails before reaching the receiver. Lightspeed and wired mice with
// the short collection keep the short form for the three-parameter path
// they were verified with; longer payloads still go through requestLong.
if (this.isBoltReceiver || this.isBluetooth || !hasHidppShortCollection(this.reportDevice)) {
return this.requestLong(featureIndex, functionId, parameters, options.timeoutMs);
}
if (parameters.length > 3) {
Expand Down
Loading