Skip to content
Closed
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
18 changes: 16 additions & 2 deletions src/control.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -676,6 +676,15 @@ function downloadDiagnostics(): void {
if (status) status.textContent = `Saved ${name}`;
}

/**
* An explicitly empty rate list means the mouse reports no polling rate at all,
* as opposed to `undefined`, which means the driver simply did not narrow the
* common set.
*/
function hasNoPollingRates(status: MouseStatus): boolean {
return Array.isArray(status.supportedPollingRates) && status.supportedPollingRates.length === 0;
}

function showStatus(deviceStatus: MouseStatus): void {
latestDeviceStatus = deviceStatus;
latestDiagnosticStatus = deviceStatus;
Expand DownExpand Up@@ -717,7 +726,10 @@ function showStatus(deviceStatus: MouseStatus): void {
: "Higher rates update cursor movement more often, but use more battery."));
const pollingCard = document.querySelector<HTMLElement>("[data-rate]")?.closest<HTMLElement>(".setting-card");
if (pollingCard) {
pollingCard.hidden = false;
// An explicitly empty rate list means the mouse reports no polling rate at
// all, so hide the card rather than leaving an empty heading behind. This
// mirrors how the sensor card is hidden for an empty lift-off list.
pollingCard.hidden = hasNoPollingRates(status);
pollingCard.style.display = "";
}
for (const selector of ["#signal-settings", "#sleep-settings"]) {
Expand DownExpand Up@@ -865,7 +877,9 @@ function showStatus(deviceStatus: MouseStatus): void {
? [battery, `${deviceStatus.dpi.toLocaleString()} DPI`, `${deviceStatus.pollingRateHz.toLocaleString()} Hz`].join(" · ")
: battery);
} else if (!hasPendingChanges()) {
setText("#read-status", `Current: ${deviceStatus.dpi.toLocaleString()} DPI · ${deviceStatus.pollingRateHz.toLocaleString()} Hz`);
const readings = [`${deviceStatus.dpi.toLocaleString()} DPI`];
if (!hasNoPollingRates(deviceStatus)) readings.push(`${deviceStatus.pollingRateHz.toLocaleString()} Hz`);
setText("#read-status", `Current: ${readings.join(" · ")}`);
}
const meter = document.querySelector<HTMLElement>("#battery-meter");
if (meter) meter.style.width = status.batteryPercent === null ? "0%" : `${status.batteryPercent}%`;
Expand Down
38 changes: 34 additions & 4 deletions src/devices/logitech/TESTING.md
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
# Logitech hardware test checklist

Test in Chrome or Edge over HTTPS. Close Logitech G HUB and Logitech Gaming
Software first — they hold the same vendor interface open and the mouse will
stop answering. Select the vendor collection (`usagePage 0xff00`, `usage
0x0001`), not the plain pointer collection.
Test in Chrome or Edge over HTTPS. Close Logitech G HUB, Logitech Gaming
Software and Logi Options+ first — they hold the same vendor interface open and
the mouse will stop answering. Select the vendor collection, not the plain
pointer collection: `usagePage 0xff00` / `usage 0x0001` over USB, or
`usagePage 0xff43` over Bluetooth.

On macOS the browser also needs Input Monitoring permission (System Settings →
Privacy & Security → Input Monitoring). Without it macOS refuses to open a mouse
HID device and every request times out.

Supported identifiers:

- `046d:c54d`, `046d:c547` — Lightspeed receivers
- `046d:c539` — HERO-era Lightspeed receiver
- `046d:c0a8` — PRO X 2 Superstrike (USB)
- `046d:c07e` — G402 / G402 Hyperion Fury (wired)
- `046d:b036` — Pebble M350s (Bluetooth)

## Receiver-attached and Superstrike devices

Expand DownExpand Up@@ -43,6 +49,30 @@ so those cards stay hidden.
switch the G402 into host-control mode.
8. Reload the page and confirm the DPI written in step 4 is still reported.

## Pebble M350s (Bluetooth, HID++ device index `0xFF`)

**Not yet confirmed on hardware.** The transport was derived from the mouse's
HID report descriptor; the feature set it answers with still needs recording.

Over Bluetooth HID++ moves to `usagePage 0xff43` and only the long report
(`0x11`) exists, so every request goes out long. Being an office mouse the
Pebble is expected to expose no DPI (`0x2201` / `0x2202`) and no report rate
(`0x8060` / `0x8061`), only device name, firmware and Battery Level Status
(`0x1000`).

1. Confirm the sidebar and title show **Pebble M350s** and the connection reads
**Wireless / Bluetooth**.
2. Confirm the battery percentage matches what Logi Options+ reports. `0x1000`
returns a coarse level, so expect a value like 90 / 50 / 20 rather than a
continuous reading.
3. Confirm the firmware list and the HID++ device details section populate.
4. Confirm the DPI, polling-rate and sensor cards are all **hidden** and the
live status line reads the battery only. A card that appears empty means a
feature was found but returned nothing.
5. Record the feature indexes the mouse actually answers with. If it does report
`0x2201` or `0x8060`, the corresponding card should appear on its own and the
others stay hidden.

Persistent polling-rate and DPI-stage changes need a CRC-checked rewrite of the
1024-byte profile sector and are intentionally not implemented. Record the
device identifier, protocol version, and any failing setting in the issue or
Expand Down
36 changes: 36 additions & 0 deletions src/devices/logitech/hidpp.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,11 @@ import test from "node:test";
import {
DEVICE_INDEX_DIRECT,
DEVICE_INDEX_RECEIVER,
decodeBatteryLevelStatus,
decodeReportRateBitmap,
hidppDeviceIndex,
hidppErrorMessage,
isBluetoothProduct,
isDirectConnectProduct,
legacyDpiFallback,
withSoftwareId,
Expand All@@ -15,6 +17,7 @@ import {
const G402 = 0xc07e;
const LIGHTSPEED_RECEIVER = 0xc54d;
const SUPERSTRIKE_USB = 0xc0a8;
const PEBBLE_M350S = 0xb036;

test("HID++ requests use a nonzero software ID", () => {
assert.equal(withSoftwareId(0x00), 0x05);
Expand DownExpand Up@@ -62,6 +65,39 @@ test("an unrecognised HID++ error still reports its raw code", () => {
assert.match(hidppErrorMessage(0x7f), /0x7f/);
});

test("a Bluetooth mouse is addressed as the mouse itself", () => {
assert.equal(isBluetoothProduct(PEBBLE_M350S), true);
assert.equal(hidppDeviceIndex(PEBBLE_M350S), DEVICE_INDEX_DIRECT);
});

test("a Bluetooth mouse is not treated as a direct-connect USB mouse", () => {
// isDirectConnectProduct also gates the G402's read-only polling rate and its
// "close Logitech Gaming Software" timeout message, neither of which applies
// to a Bluetooth mouse.
assert.equal(isDirectConnectProduct(PEBBLE_M350S), false);
});

test("receiver product ids stay off the Bluetooth path", () => {
for (const productId of [LIGHTSPEED_RECEIVER, 0xc539, 0xc547, SUPERSTRIKE_USB, G402]) {
assert.equal(isBluetoothProduct(productId), false);
}
});

test("battery level status reports a coarse percentage and its state", () => {
assert.deepEqual(decodeBatteryLevelStatus(90, 0x00), { percent: 90, state: "Discharging" });
assert.deepEqual(decodeBatteryLevelStatus(50, 0x01), { percent: 50, state: "Charging" });
assert.deepEqual(decodeBatteryLevelStatus(100, 0x03), { percent: 100, state: "Full" });
});

test("a battery level of 0 means unmeasurable, not empty", () => {
assert.equal(decodeBatteryLevelStatus(0, 0x00).percent, null);
assert.equal(decodeBatteryLevelStatus(0x7f, 0x00).percent, null);
});

test("an unknown battery status code is reported as unknown", () => {
assert.equal(decodeBatteryLevelStatus(90, 0x07).state, "Unknown");
});

test("the legacy DPI fallback matches the grid G402 hardware advertises", () => {
// Captured from hardware: getSensorDpiList replied 00 FC | E0 54 | 0F C0,
// meaning minimum 252, range step 84, maximum 4032.
Expand Down
105 changes: 85 additions & 20 deletions src/devices/logitech/hidpp.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
import type { MouseStatus } from "../mouse-types.ts";
import {
HIDPP_BLUETOOTH_USAGE_PAGE,
HIDPP_USB_USAGE,
HIDPP_USB_USAGE_PAGE,
decodeBatteryLevelStatus,
decodeReportRateBitmap,
hidppDeviceIndex,
hidppErrorMessage,
isBluetoothProduct,
isDirectConnectProduct,
legacyDpiFallback,
withSoftwareId,
Expand All@@ -25,6 +30,8 @@ const FEATURE = {
deviceName: 0x0005,
firmware: 0x0003,
unifiedBattery: 0x1004,
// Battery Level Status, used by the AA/AAA-powered office mice.
batteryLevelStatus: 0x1000,
batteryVoltage: 0x1001,
adcMeasurement: 0x1f20,
extendedDpi: 0x2202,
Expand DownExpand Up@@ -150,21 +157,41 @@ export class LogitechHidppClient {
return isDirectConnectProduct(this.device.productId);
}

/** True when the mouse is paired over Bluetooth instead of USB. */
private get isBluetooth(): boolean {
return isBluetoothProduct(this.device.productId);
}

/**
* Bluetooth HID++ defines only the long report, so every request goes out on
* 0x11 there. Sending a 0x10 short report would be rejected outright.
*/
private get usesLongReportsOnly(): boolean {
return this.isBluetooth;
}

/** HID++ device index: the receiver's first slot, or the mouse itself. */
private get deviceIndex(): number {
return hidppDeviceIndex(this.device.productId);
}

static isSupported(device: HIDDevice): boolean {
if (device.vendorId !== LOGITECH_VENDOR_ID
|| !(LOGITECH_RECEIVER_PRODUCT_IDS.has(device.productId) || isDirectConnectProduct(device.productId))) {
if (device.vendorId !== LOGITECH_VENDOR_ID) return false;
const hasCollection = (
collections: readonly HIDCollectionInfo[],
matches: (collection: HIDCollectionInfo) => boolean,
): boolean =>
collections.some((collection) => matches(collection) || hasCollection(collection.children, matches));

if (isBluetoothProduct(device.productId)) {
return hasCollection(device.collections, (collection) =>
collection.usagePage === HIDPP_BLUETOOTH_USAGE_PAGE);
}
if (!(LOGITECH_RECEIVER_PRODUCT_IDS.has(device.productId) || isDirectConnectProduct(device.productId))) {
return false;
}
const hasHidppCollection = (collections: readonly HIDCollectionInfo[]): boolean =>
collections.some((collection) =>
(collection.usagePage === 0xff00 && collection.usage === 0x0001)
|| hasHidppCollection(collection.children));
return hasHidppCollection(device.collections);
return hasCollection(device.collections, (collection) =>
collection.usagePage === HIDPP_USB_USAGE_PAGE && collection.usage === HIDPP_USB_USAGE);
}

static async requestReceiver(): Promise<LogitechHidppClient | null> {
Expand DownExpand Up@@ -200,6 +227,9 @@ export class LogitechHidppClient {
const nameFeature = await this.getFeature(FEATURE.deviceName);
const firmwareFeature = await this.getFeature(FEATURE.firmware);
const batteryFeature = await this.getFeature(FEATURE.unifiedBattery);
const batteryLevelFeature = batteryFeature.index
? { index: 0, version: 0 }
: await this.getFeature(FEATURE.batteryLevelStatus);
const batteryVoltageFeature = await this.getFeature(FEATURE.batteryVoltage);
const adcMeasurementFeature = await this.getFeature(FEATURE.adcMeasurement);
const dpiFeature = await this.resolveDpiFeature();
Expand All@@ -213,28 +243,38 @@ export class LogitechHidppClient {
const identity = await this.readIdentity(firmwareFeature.index);
const battery = batteryFeature.index
? await this.readBattery(batteryFeature.index)
: batteryVoltageFeature.index
? await this.readBatteryVoltage(batteryVoltageFeature.index)
: adcMeasurementFeature.index
? await this.readAdcMeasurement(adcMeasurementFeature.index)
: batteryLevelFeature.index
? await this.readBatteryLevel(batteryLevelFeature.index)
: batteryVoltageFeature.index
? await this.readBatteryVoltage(batteryVoltageFeature.index)
: adcMeasurementFeature.index
? await this.readAdcMeasurement(adcMeasurementFeature.index)
: { percent: null, state: "Unknown" as const, voltageMv: null };
if (batteryVoltageFeature.index && battery.voltageMv === undefined) {
battery.voltageMv = (await this.readBatteryVoltage(batteryVoltageFeature.index)).voltageMv;
} else if (adcMeasurementFeature.index && battery.voltageMv === undefined) {
battery.voltageMv = (await this.readAdcMeasurement(adcMeasurementFeature.index)).voltageMv;
}
const dpiState = dpiFeature.legacy
? await this.readLegacyDpi(dpiFeature.index)
: await this.readDpi(dpiFeature.index);
// An office mouse exposes neither DPI nor report-rate features. Report what
// is missing instead of failing the whole read on its behalf.
const hasDpi = dpiFeature.index !== 0;
const hasReportRate = reportRateFeature.index !== 0;
const dpiState = !hasDpi
? { dpi: 0, dpiY: 0, liftOffDistance: null }
: dpiFeature.legacy
? await this.readLegacyDpi(dpiFeature.index)
: await this.readDpi(dpiFeature.index);
const supportsSeparateDpiAxes = dpiFeature.legacy
? false
: await this.readDpiCapabilities(dpiFeature.index);
const supportedPollingRates = reportRateFeature.legacy
? await this.readLegacyReportRates(reportRateFeature.index)
: await this.readSupportedPollingRates(reportRateFeature.index);
const pollingRateHz = reportRateFeature.legacy
? await this.readLegacyReportRate(reportRateFeature.index)
: await this.readPollingRate(reportRateFeature.index);
const pollingRateHz = !hasReportRate
? 0
: reportRateFeature.legacy
? await this.readLegacyReportRate(reportRateFeature.index)
: await this.readPollingRate(reportRateFeature.index);
const profileState = await this.readProfileState(profilesFeature.index);
const firmware = await this.readFirmware(firmwareFeature.index);
const analogButtonTuning = analogButtonsFeature.index
Expand All@@ -251,6 +291,9 @@ export class LogitechHidppClient {
name,
ui: {
family: "logitech-hidpp",
// Nothing in the settings grid is adjustable on a mouse that exposes
// neither DPI nor report rate, so the shell shows the status only.
settingsReady: hasDpi || hasReportRate,
// Logitech allows Lift-off Distance modification only when Gaming Surface Mode is set to "on" or "auto"
lodRequiresSurface: true,
// Direct-connect mice report their rate but keep the writable copy in
Expand DownExpand Up@@ -282,8 +325,13 @@ export class LogitechHidppClient {
supportedLiftOffDistances: dpiFeature.legacy ? [] : isSuperstrike ? ["Low", "High"] : undefined,
connectionType: wired ? "Wired" : "Wireless",
// Without this the shell falls back to its "2.4 GHz receiver" wording,
// which is wrong for a mouse plugged straight into USB.
connectionDetail: this.isDirectConnect ? "Wired USB" : undefined,
// which is wrong for a mouse plugged straight into USB or paired over
// Bluetooth.
connectionDetail: this.isDirectConnect
? "Wired USB"
: this.isBluetooth
? "Bluetooth"
: undefined,
activeProfile: profileState.activeProfile,
deviceMode: profileState.deviceMode,
unitId: identity.unitId,
Expand DownExpand Up@@ -336,7 +384,10 @@ export class LogitechHidppClient {
}
const resolved = await this.resolveDpiFeature();
if (!resolved.index) {
throw new Error("This mouse does not expose DPI controls.");
// An office mouse has no sensitivity control at all. The shell asks every
// client for its options while connecting, so answer "none" rather than
// failing the connection.
return (this.dpiOptionsCache = []);
}
if (resolved.legacy) {
const advertised = await this.readLegacyDpiList(resolved.index);
Expand DownExpand Up@@ -681,6 +732,17 @@ export class LogitechHidppClient {
return { percent: percentage <= 100 ? percentage : null, state };
}

/**
* Battery Level Status (0x1000) function 0: reply data is
* [dischargeLevel, dischargeNextLevel, status]. Mice on a replaceable cell
* report a coarse level here and have no voltage feature to fall back on.
*/
private async readBatteryLevel(featureIndex: number): Promise<BatteryReading> {
const reply = await this.request(featureIndex, 0x00);
const { percent, state } = decodeBatteryLevelStatus(reply[3] ?? 0, reply[5] ?? -1);
return { percent, state, voltageMv: null };
}

private async readBatteryVoltage(featureIndex: number): Promise<BatteryReading> {
const reply = await this.request(featureIndex, 0x00);
const voltageMv = ((reply[3] ?? 0) << 8) | (reply[4] ?? 0);
Expand DownExpand Up@@ -897,6 +959,9 @@ export class LogitechHidppClient {
if (parameters.length > 3) {
throw new Error("This WebHID client only sends short, read-only HID++ requests.");
}
if (this.usesLongReportsOnly) {
return this.requestLong(featureIndex, functionId, parameters);
}

const report = new Uint8Array([
this.deviceIndex,
Expand Down
Loading