From 3f0398d781b42ac8d9849de8c913e9933003f82a Mon Sep 17 00:00:00 2001 From: Onyx Date: Tue, 20 Oct 2026 15:45:30 +0200 Subject: [PATCH] Logitech: bunny hop in Host mode and a button listener - Set bunny hop on a mouse running from software. - The button listener reports side button changes, and MouseStatus.hostButtons carries Bridge's assignments, including media keys. - MouseStatus.pollingRateWirelessHz and pollingRateWiredHz: a rate per connection for game profiles, filled in by the control panel. --- src/drivers/logitech/button-listener.test.ts | 172 +++++++++++++++++++ src/drivers/logitech/hidpp.ts | 73 ++++++++ src/drivers/logitech/host-bunny-hop.test.ts | 108 ++++++++++++ src/drivers/mouse-types.ts | 22 +++ 4 files changed, 375 insertions(+) create mode 100644 src/drivers/logitech/button-listener.test.ts create mode 100644 src/drivers/logitech/host-bunny-hop.test.ts diff --git a/src/drivers/logitech/button-listener.test.ts b/src/drivers/logitech/button-listener.test.ts new file mode 100644 index 00000000..c8a0548f --- /dev/null +++ b/src/drivers/logitech/button-listener.test.ts @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { LogitechHidppClient } from "./hidpp.ts"; + +// The driver schedules its request timeouts through window, which Node has no +// notion of. Matched to the shim the existing HID++ driver tests install. +(globalThis as unknown as { window: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout } }).window = { + setTimeout, + clearTimeout, +}; + +/** Feature index the scripted mouse hands out for 0x8110, as on a PRO X 3 Superstrike. */ +const LISTENER_INDEX = 0x22; +const BUTTONS = [1, 2, 3, 4, 5]; + +/** A receiver slot that answers root lookups and the listener's count and list, and echoes the rest. */ +function responder(features: Record) { + const sent: Uint8Array[] = []; + const answer = (request: Uint8Array): Uint8Array | null => { + sent.push(request.slice()); + const [deviceIndex, featureIndex, functionByte] = request; + const reply = (data: number[]): Uint8Array => new Uint8Array([deviceIndex, featureIndex, functionByte, ...data]); + if (deviceIndex !== 0x02) return new Uint8Array([deviceIndex, 0x8f, featureIndex, functionByte, 0x08, 0]); + if (featureIndex === 0x00 && (functionByte & 0xf0) === 0x00) { + const featureId = (request[3] << 8) | request[4]; + return reply([features[featureId] ?? 0x00, 0x00, 0x02]); + } + if (featureIndex === 0x00) return reply([0x04, 0x05, request[5] ?? 0]); + if (featureIndex === LISTENER_INDEX && (functionByte & 0xf0) === 0x00) return reply([BUTTONS.length]); + if (featureIndex === LISTENER_INDEX && (functionByte & 0xf0) === 0x30) return reply([...BUTTONS, 0x00]); + return reply([0x00]); + }; + return { answer, sent }; +} + +class FakeHidDevice { + readonly productId = 0xc548; + readonly productName = "USB Receiver"; + readonly vendorId = 0x046d; + readonly collections = [ + { usagePage: 0xff00, usage: 0x0001, children: [] }, + { usagePage: 0xff00, usage: 0x0002, children: [] }, + ]; + private listeners = new Map void>(); + onRequest: (request: Uint8Array) => Uint8Array | null = () => null; + + addEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.set(type, listener); + } + + removeEventListener(): void {} + + async open(): Promise {} + + async close(): Promise {} + + push(report: number[]): void { + const bytes = Uint8Array.from(report); + this.listeners.get("inputreport")?.({ reportId: 0x11, data: new DataView(bytes.buffer) }); + } + + async sendReport(reportId: number, data: Uint8Array): Promise { + const reply = this.onRequest(data.slice()); + if (!reply) return; + queueMicrotask(() => { + this.listeners.get("inputreport")?.({ + reportId, + data: new DataView(reply.buffer.slice(reply.byteOffset, reply.byteOffset + reply.byteLength)), + }); + }); + } +} + +interface ListenerClient { + open(): Promise; + resolveDeviceIndex(): Promise; + startButtonListener(): Promise; + stopButtonListener(): Promise; + close(): Promise; + onButtonChange(listener: (buttons: number) => void): () => void; +} + +async function harness(features: Record) { + const device = new FakeHidDevice(); + const script = responder(features); + device.onRequest = script.answer; + const client = new LogitechHidppClient(device as unknown as HIDDevice) as unknown as ListenerClient; + await client.open(); + await client.resolveDeviceIndex(); + script.sent.length = 0; + return { client, device, sent: script.sent }; +} + +const withListener = { 0x0003: 0x02, 0x2201: 0x14, 0x8110: LISTENER_INDEX }; +const listenerCalls = (sent: Uint8Array[]): Uint8Array[] => sent.filter((request) => request[1] === LISTENER_INDEX); + +test("starting the listener replays G HUB's sequence: count, list, start, then the list back", async () => { + const { client, sent } = await harness(withListener); + assert.deepEqual(await client.startButtonListener(), BUTTONS); + const calls = listenerCalls(sent); + assert.deepEqual(calls.map((request) => request[2] & 0xf0), [0x00, 0x30, 0x10, 0x40]); + assert.deepEqual([...calls[3].slice(3, 3 + BUTTONS.length)], BUTTONS, "the setList call must name every button"); +}); + +test("button events reach listeners as a bitmask, until they unsubscribe", async () => { + const { client, device } = await harness(withListener); + await client.startButtonListener(); + const seen: number[] = []; + const stop = client.onButtonChange((buttons) => seen.push(buttons)); + const event = (mask: number) => [0x02, LISTENER_INDEX, 0x00, 0x00, mask, 0x00, 0x00]; + device.push(event(0x10)); + device.push(event(0x00)); + device.push(event(0x08)); + stop(); + device.push(event(0x04)); + assert.deepEqual(seen, [0x10, 0x00, 0x08]); +}); + +test("events from another feature are not mistaken for buttons", async () => { + const { client, device } = await harness(withListener); + await client.startButtonListener(); + const seen: number[] = []; + client.onButtonChange((buttons) => seen.push(buttons)); + device.push([0x02, 0x16, 0x00, 0x00, 0x10, 0x00]); + assert.deepEqual(seen, []); +}); + +test("stopping sends function 2", async () => { + const { client, sent } = await harness(withListener); + await client.startButtonListener(); + sent.length = 0; + await client.stopButtonListener(); + assert.deepEqual(listenerCalls(sent).map((request) => request[2] & 0xf0), [0x20]); +}); + +const buttonEvent = (mask: number) => [0x02, LISTENER_INDEX, 0x00, 0x00, mask, 0x00, 0x00]; + +test("a start that fails on the last step stops the listener again and gives up the events", async () => { + const { client, device, sent } = await harness(withListener); + const answer = device.onRequest; + device.onRequest = (request) => { + if (request[1] !== LISTENER_INDEX || (request[2] & 0xf0) !== 0x40) return answer(request); + sent.push(request.slice()); + return new Uint8Array([0x02, 0xff, LISTENER_INDEX, request[2], 0x02, 0x00]); // the mouse refuses setList + }; + await assert.rejects(() => client.startButtonListener()); + assert.deepEqual(listenerCalls(sent).map((request) => request[2] & 0xf0), [0x00, 0x30, 0x10, 0x40, 0x20]); + const seen: number[] = []; + client.onButtonChange((buttons) => seen.push(buttons)); + device.push(buttonEvent(0x10)); + assert.deepEqual(seen, []); +}); + +test("after stopping or closing, button events are no longer delivered", async () => { + const { client, device } = await harness(withListener); + const seen: number[] = []; + client.onButtonChange((buttons) => seen.push(buttons)); + await client.startButtonListener(); + await client.stopButtonListener(); + device.push(buttonEvent(0x10)); + await client.startButtonListener(); + await client.close(); + device.push(buttonEvent(0x08)); + assert.deepEqual(seen, []); +}); + +test("a mouse without the feature says so and sends nothing", async () => { + const { client, sent } = await harness({ 0x0003: 0x02, 0x2201: 0x14 }); + await assert.rejects(() => client.startButtonListener(), /does not report its buttons/); + assert.deepEqual(listenerCalls(sent), []); +}); diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index f4c61883..da088a96 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -330,6 +330,8 @@ const FEATURE = { hostsInfo: 0x1815, changeHost: 0x1814, reprogControls: 0x1b04, + hostBunnyHop: 0x80e0, + buttonListener: 0x8110, } as const; /** @@ -447,6 +449,8 @@ export class LogitechHidppClient { private reportRateFeatureIndex: number | null = null; private analogButtonsFeatureIndex: number | null = null; private readonly analogPressListeners = new Set<(left: number, right: number) => void>(); + private buttonListenerFeatureIndex: number | null = null; + private readonly buttonChangeListeners = new Set<(buttons: number) => void>(); private supportedPollingRatesCache: number[] | null = null; private livePollingRateHz: number | null = null; /** Discovered by resolveDeviceIndex; null until the mouse has answered. */ @@ -511,6 +515,10 @@ export class LogitechHidppClient { if (report[0] === this.deviceIndex && report[1] === this.analogButtonsFeatureIndex && report[2] === 0x00) { this.analogPressListeners.forEach((listener) => listener(report[3] ?? 0, report[4] ?? 0)); } + // The button listener pushes the buttons held right now as a bitmask in event 0. + if (report[0] === this.deviceIndex && report[1] === this.buttonListenerFeatureIndex && report[2] === 0x00) { + this.buttonChangeListeners.forEach((listener) => listener(report[4] ?? 0)); + } const matchingIndex = this.waiters.findIndex( (waiter) => report[0] === this.deviceIndex && report[1] === waiter.featureIndex @@ -965,6 +973,7 @@ export class LogitechHidppClient { } async close(): Promise { + this.buttonListenerFeatureIndex = null; if (this.rgbClaimed) { const rgb = await this.getFeature(FEATURE.rgbEffects).catch(() => ({ index: 0, version: 0 })); if (rgb.index) await this.request(rgb.index, 0x50, 0x01, 0x00, 0x00).catch(() => undefined); @@ -1351,6 +1360,51 @@ export class LogitechHidppClient { await this.request(feature.index, 0x30, 0x00, 0x00, 0x00).catch(() => undefined); } + /** + * Pressed buttons as a bitmask, pushed on every press and release while the + * button listener is on: 0x01 left, 0x02 right, 0x04 middle, 0x08 the back side + * button, 0x10 the front side button (PRO X 3 Superstrike, from G HUB's + * capture and a hardware run). Returns the unsubscribe. + */ + onButtonChange(listener: (buttons: number) => void): () => void { + this.buttonChangeListeners.add(listener); + return () => this.buttonChangeListeners.delete(listener); + } + + /** + * Starts the button events the way G HUB does when it takes over a mouse + * running from software (Host mode): getCount (function 0), getList (3), + * start (1), then setList (4) with every button the mouse listed. The mouse + * stays silent until this has run. What function 1 does is read from its + * place in the capture, not documented; it is replayed as sent. Returns the + * button numbers being reported. + */ + async startButtonListener(): Promise { + const feature = await this.getFeature(FEATURE.buttonListener); + if (!feature.index) throw new Error("This Logitech mouse does not report its buttons."); + this.buttonListenerFeatureIndex = feature.index; + const count = (await this.request(feature.index, 0x00))[3] ?? 0; + const list = [...(await this.request(feature.index, 0x30)).slice(3, 3 + count)]; + await this.request(feature.index, 0x10); + try { + await this.requestLong(feature.index, 0x40, list); + } catch (error) { + // The mouse was already told to start, so stop it again rather than leave it half set up. + this.buttonListenerFeatureIndex = null; + await this.request(feature.index, 0x20).catch(() => undefined); + throw error; + } + return list; + } + + /** Stops the button events: function 2, as G HUB sends when it quits. */ + async stopButtonListener(): Promise { + this.buttonListenerFeatureIndex = null; + const feature = await this.getFeature(FEATURE.buttonListener); + if (!feature.index) return; + await this.request(feature.index, 0x20).catch(() => undefined); + } + private assertAnalogTuningInRange( limits: { maxActuation: number; maxRapidTrigger: number; maxHaptics: number }, tuning: { actuation: number; rapidTrigger: number; haptics: number }, @@ -1837,6 +1891,25 @@ export class LogitechHidppClient { await this.writeActiveProfile({ bunnyHoppingMs: milliseconds }); } + /** + * Sets bunny hop on a mouse running from software (Host mode): a single write + * to feature 0x80E0, function 2, whose one byte is the time in 10 ms units and + * 0 turns it off. Captured from G HUB on a PRO X 3 Superstrike (0x0a, 0x14, then + * 0x00); the reading of the byte as ms / 10 follows the onboard profile's + * encoding and is not confirmed. G HUB sends 0 when it enters Host mode, so the + * setting starts off there. No read for it has been seen, so the caller keeps + * track of the value it wrote. Nothing is stored in the mouse's profile. + */ + async setHostBunnyHopMs(milliseconds: number): Promise { + const invalid = validateBunnyHoppingMs(milliseconds); + if (invalid) throw new Error(invalid); + const feature = await this.getFeature(FEATURE.hostBunnyHop); + if (!feature.index) { + throw new Error("This mouse does not expose a Host mode bunny hop setting."); + } + await this.request(feature.index, 0x20, milliseconds / 10); + } + async setProfileDpiStages(plan: DpiStagePlan): Promise { await this.writeActiveProfile({ dpiStages: plan }); } diff --git a/src/drivers/logitech/host-bunny-hop.test.ts b/src/drivers/logitech/host-bunny-hop.test.ts new file mode 100644 index 00000000..b6c65e5f --- /dev/null +++ b/src/drivers/logitech/host-bunny-hop.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { LogitechHidppClient } from "./hidpp.ts"; + +// The driver schedules its request timeouts through window, which Node has no +// notion of. Matched to the shim the existing HID++ driver tests install. +(globalThis as unknown as { window: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout } }).window = { + setTimeout, + clearTimeout, +}; + +/** Feature index the scripted mouse hands out for 0x80E0, as on a PRO X 3 Superstrike. */ +const BUNNY_HOP_INDEX = 0x20; + +/** A receiver slot that answers root lookups and echoes every other request. */ +function responder(features: Record) { + const sent: Uint8Array[] = []; + const answer = (request: Uint8Array): Uint8Array | null => { + sent.push(request.slice()); + const [deviceIndex, featureIndex, functionByte] = request; + if (deviceIndex !== 0x02) { + return new Uint8Array([deviceIndex, 0x8f, featureIndex, functionByte, 0x08, 0]); + } + if (featureIndex === 0x00 && (functionByte & 0xf0) === 0x00) { + const featureId = (request[3] << 8) | request[4]; + return new Uint8Array([deviceIndex, featureIndex, functionByte, features[featureId] ?? 0x00, 0x00, 0x02]); + } + if (featureIndex === 0x00) return new Uint8Array([deviceIndex, featureIndex, functionByte, 0x04, 0x05, request[5] ?? 0]); + return new Uint8Array([deviceIndex, featureIndex, functionByte, 0x00]); + }; + return { answer, sent }; +} + +class FakeHidDevice { + readonly productId = 0xc548; + readonly productName = "USB Receiver"; + readonly vendorId = 0x046d; + readonly collections = [ + { usagePage: 0xff00, usage: 0x0001, children: [] }, + { usagePage: 0xff00, usage: 0x0002, children: [] }, + ]; + private listeners = new Map void>(); + onRequest: (request: Uint8Array) => Uint8Array | null = () => null; + + addEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.set(type, listener); + } + + removeEventListener(): void {} + + async open(): Promise {} + + async close(): Promise {} + + async sendReport(reportId: number, data: Uint8Array): Promise { + const reply = this.onRequest(data.slice()); + if (!reply) return; + queueMicrotask(() => { + this.listeners.get("inputreport")?.({ + reportId, + data: new DataView(reply.buffer.slice(reply.byteOffset, reply.byteOffset + reply.byteLength)), + }); + }); + } +} + +async function harness(features: Record) { + const device = new FakeHidDevice(); + const script = responder(features); + device.onRequest = script.answer; + const client = new LogitechHidppClient(device as unknown as HIDDevice) as unknown as { + open(): Promise; + resolveDeviceIndex(): Promise; + setHostBunnyHopMs(milliseconds: number): Promise; + }; + await client.open(); + await client.resolveDeviceIndex(); + script.sent.length = 0; + return { client, sent: script.sent }; +} + +const withBunnyHop = { 0x0003: 0x02, 0x2201: 0x14, 0x80e0: BUNNY_HOP_INDEX }; +const bunnyHopWrites = (sent: Uint8Array[]): Uint8Array[] => sent.filter((request) => request[1] === BUNNY_HOP_INDEX); + +test("host bunny hop writes the time in 10 ms units to feature 0x80E0, function 2", async () => { + const { client, sent } = await harness(withBunnyHop); + await client.setHostBunnyHopMs(100); + await client.setHostBunnyHopMs(200); + await client.setHostBunnyHopMs(0); + const writes = bunnyHopWrites(sent); + assert.equal(writes.length, 3); + assert.deepEqual(writes.map((request) => request[2] & 0xf0), [0x20, 0x20, 0x20]); + assert.deepEqual(writes.map((request) => request[3]), [0x0a, 0x14, 0x00], "G HUB's captured bytes"); +}); + +test("a time the mouse cannot store is refused before anything is sent", async () => { + const { client, sent } = await harness(withBunnyHop); + await assert.rejects(() => client.setHostBunnyHopMs(95), /multiple of 10/); + await assert.rejects(() => client.setHostBunnyHopMs(50), /between 100 and 1000/); + assert.deepEqual(bunnyHopWrites(sent), []); +}); + +test("a mouse without the feature says so", async () => { + const { client, sent } = await harness({ 0x0003: 0x02, 0x2201: 0x14 }); + await assert.rejects(() => client.setHostBunnyHopMs(100), /does not expose a Host mode bunny hop/); + assert.deepEqual(bunnyHopWrites(sent), []); +}); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index ffcc6d06..0bc7d4ea 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -216,6 +216,21 @@ export interface MouseStatus { dpi: number; dpiY?: number; supportsSeparateDpiAxes?: boolean; + /** + * Logitech bunny hop on a mouse running from software (Host mode), in ms, 0 for + * off. The mouse has no known read for it, so the driver never sets this: the + * control panel fills it in from the last value it wrote. + */ + hostBunnyHopMs?: number; + /** + * What a game profile makes the side buttons of a Logitech mouse running from + * software do, by the button's bit in the mouse's report (8 the back side + * button, 16 the front one): a HID keyboard usage and a modifier mask, or a + * media key by its HID consumer usage. A button with no entry keeps its normal + * Back or Forward click. The mouse holds none of this; Bridge presses the keys + * while the game runs. + */ + hostButtons?: Record; /** Hall-effect primary-button tuning exposed by Logitech's 0x1B0C HID++ feature. */ analogButtonTuning?: { maxActuation: number; @@ -237,6 +252,13 @@ export interface MouseStatus { magneticButtons?: MagneticButtonsStatus; pollingRateHz: number; supportedPollingRates?: number[]; + /** + * The report rate for one connection, for a game profile that sets wired and + * wireless apart. The mouse only has the rate of the connection it is on, so + * the driver never sets these: the control panel fills in the current link's. + */ + pollingRateWirelessHz?: number; + pollingRateWiredHz?: number; activeProfile: number | null; /** Number of firmware-managed ATK configuration banks, when readable. */ atkProfileCount?: number;