From 2e26ece94b3fbe6299c2034245f7ba3acecf8adb Mon Sep 17 00:00:00 2001 From: NeedlerCR <246850044+NeedlerCR@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:57:26 +0100 Subject: [PATCH] Add Logitech Pebble M350s support over Bluetooth The Logitech driver only reached mice through a USB vendor interface. Over Bluetooth the same HID++ protocol moves to usage page 0xff43 and drops the 0x10 short report, so a Bluetooth mouse was never offered by the picker and would not have answered if it had been. - Detect the Bluetooth control interface and address it on device index 0xFF, kept separate from the direct-connect USB list so the Pebble does not inherit the G402's read-only polling rate. - Send every request as a long report when the transport has no short report. - Read Battery Level Status (0x1000), which is what mice on a replaceable cell expose instead of the rechargeable-pack features. - Treat a missing DPI or report-rate feature as "not present" rather than an error. An office mouse has neither, and the read used to fail on its behalf. The shell now hides the polling card when a driver reports an explicitly empty rate list, matching how an empty lift-off list already hides the sensor card. --- src/control.ts | 18 ++++- src/devices/logitech/TESTING.md | 38 +++++++++-- src/devices/logitech/hidpp.test.ts | 36 ++++++++++ src/devices/logitech/hidpp.ts | 105 +++++++++++++++++++++++------ src/devices/logitech/protocol.ts | 60 ++++++++++++++++- src/devices/vendors.ts | 13 +++- 6 files changed, 242 insertions(+), 28 deletions(-) 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, ];