diff --git a/src/control.ts b/src/control.ts index bbbe52d1..8f628e51 100644 --- a/src/control.ts +++ b/src/control.ts @@ -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; @@ -717,7 +726,10 @@ function showStatus(deviceStatus: MouseStatus): void { : "Higher rates update cursor movement more often, but use more battery.")); const pollingCard = document.querySelector("[data-rate]")?.closest(".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"]) { @@ -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("#battery-meter"); if (meter) meter.style.width = status.batteryPercent === null ? "0%" : `${status.batteryPercent}%`; diff --git a/src/devices/logitech/TESTING.md b/src/devices/logitech/TESTING.md index f6084a65..ddbbf399 100644 --- a/src/devices/logitech/TESTING.md +++ b/src/devices/logitech/TESTING.md @@ -1,9 +1,14 @@ # 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: @@ -11,6 +16,7 @@ Supported identifiers: - `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 @@ -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 diff --git a/src/devices/logitech/hidpp.test.ts b/src/devices/logitech/hidpp.test.ts index 20b8ae45..4d5c8fd2 100644 --- a/src/devices/logitech/hidpp.test.ts +++ b/src/devices/logitech/hidpp.test.ts @@ -4,9 +4,11 @@ import test from "node:test"; import { DEVICE_INDEX_DIRECT, DEVICE_INDEX_RECEIVER, + decodeBatteryLevelStatus, decodeReportRateBitmap, hidppDeviceIndex, hidppErrorMessage, + isBluetoothProduct, isDirectConnectProduct, legacyDpiFallback, withSoftwareId, @@ -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); @@ -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. diff --git a/src/devices/logitech/hidpp.ts b/src/devices/logitech/hidpp.ts index c4978187..8771274e 100644 --- a/src/devices/logitech/hidpp.ts +++ b/src/devices/logitech/hidpp.ts @@ -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, @@ -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, @@ -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 { @@ -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(); @@ -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 @@ -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 @@ -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, @@ -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); @@ -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 { + 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 { const reply = await this.request(featureIndex, 0x00); const voltageMv = ((reply[3] ?? 0) << 8) | (reply[4] ?? 0); @@ -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, diff --git a/src/devices/logitech/protocol.ts b/src/devices/logitech/protocol.ts index 95dd8565..bdf32bba 100644 --- a/src/devices/logitech/protocol.ts +++ b/src/devices/logitech/protocol.ts @@ -28,8 +28,66 @@ export function isDirectConnectProduct(productId: number): boolean { return DIRECT_PRODUCT_ID_SET.has(productId); } +/** USB HID++ control interface: vendor page 0xFF00, usage 0x0001. */ +export const HIDPP_USB_USAGE_PAGE = 0xff00; +export const HIDPP_USB_USAGE = 0x0001; +/** + * Over Bluetooth the same protocol moves to a vendor page of its own, and only + * the long report (0x11) exists — there is no 0x10 short report to send on. + */ +export const HIDPP_BLUETOOTH_USAGE_PAGE = 0xff43; + +/** + * Logitech mice paired over Bluetooth rather than through a receiver. Like the + * direct-connect USB mice they answer HID++ on device index 0xFF, but they are + * listed separately because they do not share the onboard-profile behaviour + * that makes the G402's polling rate read-only. + * + * 0xb036 is the Pebble M350s. + */ +export const LOGITECH_BLUETOOTH_PRODUCT_IDS = [0xb036] as const; + +const BLUETOOTH_PRODUCT_ID_SET: ReadonlySet = new Set(LOGITECH_BLUETOOTH_PRODUCT_IDS); + +export function isBluetoothProduct(productId: number): boolean { + return BLUETOOTH_PRODUCT_ID_SET.has(productId); +} + export function hidppDeviceIndex(productId: number): number { - return isDirectConnectProduct(productId) ? DEVICE_INDEX_DIRECT : DEVICE_INDEX_RECEIVER; + return isDirectConnectProduct(productId) || isBluetoothProduct(productId) + ? DEVICE_INDEX_DIRECT + : DEVICE_INDEX_RECEIVER; +} + +/** HID++ 2.0 battery states, shared by 0x1000 and 0x1004. */ +const BATTERY_STATES = { + 0x00: "Discharging", + 0x01: "Charging", + 0x02: "Almost full", + 0x03: "Full", + 0x04: "Charging slowly", +} as const; + +export type HidppBatteryState = (typeof BATTERY_STATES)[keyof typeof BATTERY_STATES] | "Unknown"; + +export function decodeBatteryState(status: number): HidppBatteryState { + return BATTERY_STATES[status as keyof typeof BATTERY_STATES] ?? "Unknown"; +} + +/** + * Decode Battery Level Status (0x1000), the feature Logitech's AA/AAA-powered + * mice expose instead of the rechargeable-pack features. The level is a coarse + * percentage — these mice report a handful of discrete steps, not a continuous + * reading — and a device that cannot measure at all answers 0. + */ +export function decodeBatteryLevelStatus(level: number, status: number): { + percent: number | null; + state: HidppBatteryState; +} { + return { + percent: level > 0 && level <= 100 ? level : null, + state: decodeBatteryState(status), + }; } /** HID++ 2.0 error codes, reported in byte 4 of a 0xFF error response. */ diff --git a/src/devices/vendors.ts b/src/devices/vendors.ts index 895bde8e..5c205aab 100644 --- a/src/devices/vendors.ts +++ b/src/devices/vendors.ts @@ -1,5 +1,9 @@ import { EGG_WE_HID_FILTERS } from "./endgame/egg-we-control.ts"; -import { LOGITECH_DIRECT_PRODUCT_IDS } from "./logitech/protocol.ts"; +import { + HIDPP_BLUETOOTH_USAGE_PAGE, + LOGITECH_BLUETOOTH_PRODUCT_IDS, + LOGITECH_DIRECT_PRODUCT_IDS, +} from "./logitech/protocol.ts"; export const VENDOR_ID = { pulsar: 0x3710, @@ -53,6 +57,12 @@ export const LOGITECH_RECEIVER_FILTERS: HIDDeviceFilter[] = LOGITECH_PRODUCT_IDS // Retained for existing imports; points at the first supported receiver. export const LOGITECH_RECEIVER_FILTER: HIDDeviceFilter = LOGITECH_RECEIVER_FILTERS[0]; +// Bluetooth mice carry HID++ on their own vendor page. The pointer collection +// they also expose is protected, so the browser only ever offers this one. +export const LOGITECH_BLUETOOTH_FILTERS: HIDDeviceFilter[] = LOGITECH_BLUETOOTH_PRODUCT_IDS.map( + (productId) => ({ vendorId: VENDOR_ID.logitech, productId, usagePage: HIDPP_BLUETOOTH_USAGE_PAGE }), +); + export const WLMOUSE_PRODUCTS: ReadonlyMap = new Map([ [0xa860, { name: "Beast G", wireless: true }], [0xa861, { name: "Beast G", wireless: false }], @@ -98,4 +108,5 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...RAZER_VIPER_V4_CONTROL_FILTERS, ...EGG_WE_HID_FILTERS, ...LOGITECH_RECEIVER_FILTERS, + ...LOGITECH_BLUETOOTH_FILTERS, ];