From 3fa2ed4875994340a27e9ab55c97bde38f06c531 Mon Sep 17 00:00:00 2001 From: Nick Welter Date: Sun, 9 Aug 2026 22:17:46 -0400 Subject: [PATCH 1/6] feat(logitech): haptic control for the MX Master 4 (0x19B0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MX Master 4 is the only Logitech mouse known to carry feature 0x19B0, and no public documentation describes it. Everything here was read off hardware by watching Logi Options+ talk to the mouse: a Bolt receiver broadcasts replies to every open handle, and the low nibble of the function byte carries the requester's software id, so the vendor application's traffic is visible alongside your own. The configuration is a two-byte pair behind get 0x10 / set 0x20. Byte 1 is the strength; Options+'s four presets write 25, 45, 60 and 100, and capability byte 3 reports 60 as the factory default, matching its Medium. Byte 0 is a flag bitmask: turning haptics off cleared bit 0, turning battery saving off cleared bit 1, and both returned together as 0x03. Ten writes for ten switch toggles, and only those two pairs moved anything, so Options+'s per-context switches are decided in its own software rather than in the mouse. Bits 2-7 were zero throughout and their purpose is unknown, so a write carries them through. fn 0x04 fires the motor. It accepts sixteen effect ids, 0x00-0x0E and 0x1B, answering INVALID_ARGUMENT to everything else up to 0x3F; by hand those are thirteen distinct sensations, since 0x00 and 0x01 are indistinguishable, as are 0x02, 0x03 and 0x04. Options+ plays 0x08 after a strength write and 0x00 after re-enabling haptics. Byte 1 of a play reply is a motor-busy flag rather than anything about the effect. It first read as a duration flag, which is backwards — 0x0E is two long vibrates and reports 0, while 0x0B is three quick taps and reports 1. Playing one id from idle and again on the heels of the longest effect settled it: 0 three times, then 1 three times, for the same id. Not decoded, and deliberately left alone: force sensing 0x19C0 is dormant config rather than a pressure stream, and the Actions Ring panel is an ordinary 0x1B04 control, cid 0x01A0, which Options+ diverts. Verified on hardware: MX Master 4, WPID B042, firmware LD 04.00, over a Logi Bolt receiver 046d:c548 on pairing slot 2. --- docs/logitech-testing.md | 28 +++++- src/drivers/logitech/haptics.test.ts | 118 ++++++++++++++++++++++ src/drivers/logitech/haptics.ts | 140 +++++++++++++++++++++++++++ src/drivers/logitech/hidpp.ts | 99 +++++++++++++++++++ src/drivers/mouse-types.ts | 6 ++ 5 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 src/drivers/logitech/haptics.test.ts create mode 100644 src/drivers/logitech/haptics.ts diff --git a/docs/logitech-testing.md b/docs/logitech-testing.md index 3a1716b..bf06526 100644 --- a/docs/logitech-testing.md +++ b/docs/logitech-testing.md @@ -10,7 +10,7 @@ Supported identifiers: - `046d:c54d`, `046d:c547` — Lightspeed receivers - `046d:c539` — HERO-era Lightspeed receiver - `046d:c53f`, `046d:c543` — Nano Lightspeed 1.1 / 1.2 receivers (G305) -- `046d:c548` — Logi Bolt receiver (MX Master 3S and other Bolt mice) +- `046d:c548` — Logi Bolt receiver (MX Master 3S, MX Master 4, and other Bolt mice) - `046d:c0a8` — PRO X 2 Superstrike (USB) - `046d:c07e` — G402 / G402 Hyperion Fury (wired) - `046d:c08f` — G403 HERO (wired) @@ -141,3 +141,29 @@ It exposes Adjustable DPI `0x2201` and Unified Battery `0x1004`, and has no persists after a reload. 5. If connect fails with "invalid command", the short collection alone was selected — reconnect and include usage `0x0002`. + +## MX Master 4 (Logi Bolt `046d:c548`, WPID `B042`) + +Same Bolt transport as the MX Master 3S: HID++ 2.0 feature calls use **long** +reports on a pairing slot (slot 2 on the unit this was written against), not +device index `0xFF`. Firmware `LD 04.00` / `RBM 27.00`. + +It is the only Logitech mouse known to carry Haptic `0x19B0`. It has no +`0x2202`, `0x8060`/`0x8061`, `0x8100`, `0x1001` or `0x1F20`, so the polling, +lift-off and onboard-profile paths stay inactive. + +1. Close Logi Options+. Authorize both Bolt HID++ collections if offered. +2. Confirm the sidebar shows the Bolt receiver / MX Master 4, connection + **Wireless**, battery percentage, and DPI. +3. Confirm haptic strength reads back as one of Subtle / Low / Medium / High. + A factory-default mouse reports Medium (60). +4. Change the strength and confirm the mouse buzzes at the new setting, then + reload and confirm the value persisted. +5. Turn haptic feedback off. Confirm the strength control goes inactive and + pressing the Actions Ring panel no longer buzzes. Turn it back on. +6. Toggle haptic battery saving and confirm the strength setting is unchanged + — the two share one byte, so a write that loses the other field shows up + here. +7. Cross-check against Logi Options+ by changing the strength preset **there** + and confirming OpenMouse reads the new value. Options+ caches its own view + and will not re-read a change made outside it, so verify in that direction. diff --git a/src/drivers/logitech/haptics.test.ts b/src/drivers/logitech/haptics.test.ts new file mode 100644 index 0000000..6311246 --- /dev/null +++ b/src/drivers/logitech/haptics.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + LOGITECH_HAPTIC_EFFECTS, + LOGITECH_HAPTIC_EFFECT_IDS, + LOGITECH_HAPTIC_FLAG, + LOGITECH_HAPTIC_INTENSITY_MAX, + LOGITECH_HAPTIC_PRESETS, + buildHapticConfigWrite, + decodeHapticConfig, + decodeHapticDefaultIntensity, + decodeHapticPlayReply, + encodeHapticFlags, + isLogitechHapticEffect, + isLogitechHapticIntensity, + type LogitechHapticPreset, +} from "./haptics.ts"; + +/** Config pairs read from an MX Master 4 while toggling Logi Options+. */ +const CAPTURES: ReadonlyArray<[number[], { enabled: boolean; batterySaving: boolean; intensity: number }]> = [ + [[0x03, 0x3c], { enabled: true, batterySaving: true, intensity: 60 }], + [[0x02, 0x3c], { enabled: false, batterySaving: true, intensity: 60 }], + [[0x01, 0x3c], { enabled: true, batterySaving: false, intensity: 60 }], + [[0x03, 0x19], { enabled: true, batterySaving: true, intensity: 25 }], + [[0x03, 0x64], { enabled: true, batterySaving: true, intensity: 100 }], +]; + +test("decodes the config pairs captured from hardware", () => { + for (const [payload, expected] of CAPTURES) { + const config = decodeHapticConfig(payload); + assert.ok(config); + assert.equal(config.enabled, expected.enabled); + assert.equal(config.batterySaving, expected.batterySaving); + assert.equal(config.intensity, expected.intensity); + } +}); + +test("a truncated reply decodes as null rather than as every flag set", () => { + // Reading a missing byte as -1 would report both flags on and pass as a + // successful write, which is the wrong direction to fail in. + assert.equal(decodeHapticConfig([]), null); + assert.equal(decodeHapticConfig([0x03]), null); +}); + +test("each flag toggles without disturbing the other", () => { + assert.equal(encodeHapticFlags(0x03, "enabled", false), 0x02); + assert.equal(encodeHapticFlags(0x03, "batterySaving", false), 0x01); + assert.equal(encodeHapticFlags(0x02, "enabled", true), 0x03); + assert.equal(encodeHapticFlags(0x01, "batterySaving", true), 0x03); +}); + +test("bits nobody has identified survive a flag change", () => { + // Bits 2-7 read 0 on every capture, so their meaning is unknown. Clearing + // them would discard a setting this package never displayed. + assert.equal(encodeHapticFlags(0x83, "enabled", false), 0x82); + assert.equal(encodeHapticFlags(0xfc, "enabled", true), 0xfd); +}); + +test("a write carries both bytes", () => { + assert.deepEqual(buildHapticConfigWrite(0x03, 25), [0x03, 25]); + assert.deepEqual(buildHapticConfigWrite(0x101, 300), [0x01, 0x2c]); +}); + +test("every Logi Options+ preset is a legal intensity and round trips", () => { + for (const preset of Object.keys(LOGITECH_HAPTIC_PRESETS) as LogitechHapticPreset[]) { + const value = LOGITECH_HAPTIC_PRESETS[preset]; + assert.ok(isLogitechHapticIntensity(value), `${preset} rejected`); + const written = buildHapticConfigWrite(0x03, value); + const config = decodeHapticConfig(written); + assert.equal(config?.intensity, value); + } +}); + +test("intensity outside the range the presets use is refused", () => { + assert.equal(isLogitechHapticIntensity(LOGITECH_HAPTIC_INTENSITY_MAX + 1), false); + assert.equal(isLogitechHapticIntensity(-1), false); + assert.equal(isLogitechHapticIntensity(1.5), false); + assert.equal(isLogitechHapticIntensity(Number.NaN), false); +}); + +test("only the effect ids the device accepts are treated as valid", () => { + // 0x0F to 0x1A and 0x1C upward answer INVALID_ARGUMENT on hardware. + for (const effect of LOGITECH_HAPTIC_EFFECT_IDS) { + assert.ok(isLogitechHapticEffect(effect), `0x${effect.toString(16)} rejected`); + } + for (const effect of [0x0f, 0x10, 0x1a, 0x1c, 0x20, 0xff, -1]) { + assert.equal(isLogitechHapticEffect(effect), false, `0x${effect.toString(16)} accepted`); + } +}); + +test("the effects Options+ uses are ones the device accepts", () => { + for (const effect of Object.values(LOGITECH_HAPTIC_EFFECTS)) { + assert.ok(isLogitechHapticEffect(effect)); + } +}); + +test("a play reply reports motor state, not a property of the effect", () => { + // The same id answered 0 from idle and 1 when chased by a longer effect. + assert.deepEqual(decodeHapticPlayReply([0x0b, 0x00]), { effect: 0x0b, motorWasBusy: false }); + assert.deepEqual(decodeHapticPlayReply([0x0b, 0x01]), { effect: 0x0b, motorWasBusy: true }); + assert.equal(decodeHapticPlayReply([0x0b]), null); +}); + +test("the capability reply names the factory strength", () => { + // Read from an MX Master 4: 00 01 00 3C 08 00 7F FF. + assert.equal(decodeHapticDefaultIntensity([0x00, 0x01, 0x00, 0x3c, 0x08, 0x00, 0x7f, 0xff]), 60); + assert.equal(decodeHapticDefaultIntensity([0x00, 0x01, 0x00]), null); + // The device default matches what Options+ calls Medium. + assert.equal( + decodeHapticDefaultIntensity([0x00, 0x01, 0x00, 0x3c]), + LOGITECH_HAPTIC_PRESETS.Medium, + ); +}); + +test("the flag masks do not overlap", () => { + assert.equal(LOGITECH_HAPTIC_FLAG.enabled & LOGITECH_HAPTIC_FLAG.batterySaving, 0); +}); diff --git a/src/drivers/logitech/haptics.ts b/src/drivers/logitech/haptics.ts new file mode 100644 index 0000000..bce2138 --- /dev/null +++ b/src/drivers/logitech/haptics.ts @@ -0,0 +1,140 @@ +/** + * Feature 0x19B0 (Haptic), as implemented by the MX Master 4 (WPID B042). + * + * No public documentation exists for this feature. Everything here was read + * off hardware by watching Logi Options+ talk to the mouse: a Bolt receiver + * broadcasts replies to every open handle, and the low nibble of the function + * byte carries the requester's software id, so the vendor application's own + * traffic is visible alongside your own. + * + * The configuration is a two-byte pair behind get 0x10 / set 0x20. + * + * Byte 0 is a flag bitmask. Turning haptics off in Options+ cleared bit 0; + * turning its battery-saving mode off cleared bit 1; both returned together as + * 0x03. Ten writes for ten switch toggles, and only those two pairs moved + * anything — Options+'s per-context switches (Actions Ring, Gestures, Switch + * Screens) change no device byte at all, so those are decided in its software. + * Bits 2-7 were 0 throughout and their purpose is unknown, so a write carries + * them through rather than clearing them. + * + * Byte 1 is the strength. Options+'s four presets write exactly the values in + * LOGITECH_HAPTIC_PRESETS, and capability byte 3 reports 60 as the device + * default, matching its Medium. + */ + +export const LOGITECH_HAPTIC = { + /** getCapabilities: [?, ?, ?, defaultIntensity, defaultEffect, ...]. */ + capabilities: 0x00, + get: 0x10, + set: 0x20, + /** Fires the motor once. Nothing is persisted. */ + play: 0x40, +} as const; + +/** + * Options+'s Subtle / Low / Medium / High, captured from its writes. The + * device takes any byte in range, so these are the vendor's choices rather + * than a device-imposed set. + */ +export const LOGITECH_HAPTIC_PRESETS = { + Subtle: 25, + Low: 45, + Medium: 60, + High: 100, +} as const; + +export type LogitechHapticPreset = keyof typeof LOGITECH_HAPTIC_PRESETS; + +/** The largest value any preset uses; nothing above it has been exercised. */ +export const LOGITECH_HAPTIC_INTENSITY_MAX = LOGITECH_HAPTIC_PRESETS.High; + +export const LOGITECH_HAPTIC_FLAG = { + enabled: 0x01, + batterySaving: 0x02, +} as const; + +export type LogitechHapticFlag = keyof typeof LOGITECH_HAPTIC_FLAG; + +/** + * Effect ids the MX Master 4 accepts; every other id up to 0x3F answers + * INVALID_ARGUMENT. Sixteen ids but thirteen distinct sensations by hand: + * 0x00 and 0x01 are indistinguishable, as are 0x02, 0x03 and 0x04. 0x1B sits + * apart from the contiguous block and feels like a lighter variant of another + * effect, which one is unconfirmed. + */ +export const LOGITECH_HAPTIC_EFFECT_IDS: readonly number[] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x1b, +]; + +/** + * The effects Options+ plays for each action, captured from its traffic: 0x08 + * straight after every strength write, 0x00 after re-enabling haptics. + */ +export const LOGITECH_HAPTIC_EFFECTS = { + strengthSample: 0x08, + enableConfirmation: 0x00, +} as const; + +export interface LogitechHapticConfig { + enabled: boolean; + batterySaving: boolean; + intensity: number; + /** Byte 0 as read, so a later write can carry its unknown bits through. */ + flagByte: number; +} + +export function decodeHapticConfig(payload: Uint8Array | readonly number[]): LogitechHapticConfig | null { + const flagByte = payload[0]; + const intensity = payload[1]; + if (flagByte === undefined || intensity === undefined) return null; + return { + enabled: (flagByte & LOGITECH_HAPTIC_FLAG.enabled) !== 0, + batterySaving: (flagByte & LOGITECH_HAPTIC_FLAG.batterySaving) !== 0, + intensity, + flagByte, + }; +} + +/** Sets or clears one flag, leaving every other bit of the byte alone. */ +export function encodeHapticFlags(flagByte: number, flag: LogitechHapticFlag, on: boolean): number { + const mask = LOGITECH_HAPTIC_FLAG[flag]; + return on ? flagByte | mask : flagByte & ~mask; +} + +/** + * A write carries both bytes, so a caller changing one field must supply the + * other as the device currently reports it. Passing a stale or invented flag + * byte silently discards whatever bits 2-7 hold. + */ +export function buildHapticConfigWrite(flagByte: number, intensity: number): number[] { + return [flagByte & 0xff, intensity & 0xff]; +} + +export function isLogitechHapticIntensity(value: number): boolean { + return Number.isInteger(value) && value >= 0 && value <= LOGITECH_HAPTIC_INTENSITY_MAX; +} + +export function isLogitechHapticEffect(effect: number): boolean { + return LOGITECH_HAPTIC_EFFECT_IDS.includes(effect); +} + +/** + * Byte 1 of a play reply reports that the motor was already running, not + * anything about the effect. Proven by playing one short effect from idle and + * again on the heels of the longest effect in the set: the same id answered 0 + * three times and then 1 three times. + */ +export function decodeHapticPlayReply( + payload: Uint8Array | readonly number[], +): { effect: number; motorWasBusy: boolean } | null { + const effect = payload[0]; + const busy = payload[1]; + if (effect === undefined || busy === undefined) return null; + return { effect, motorWasBusy: busy !== 0 }; +} + +/** Capability byte 3 is the factory strength; the MX Master 4 reports 60. */ +export function decodeHapticDefaultIntensity(payload: Uint8Array | readonly number[]): number | null { + return payload[3] ?? null; +} diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index fcd00d2..e9518cd 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -28,6 +28,17 @@ export { hasHidppLongCollection, hasHidppShortCollection, } from "./bolt.ts"; +import { + LOGITECH_HAPTIC, + LOGITECH_HAPTIC_EFFECTS, + buildHapticConfigWrite, + decodeHapticConfig, + encodeHapticFlags, + isLogitechHapticEffect, + isLogitechHapticIntensity, + type LogitechHapticConfig, + type LogitechHapticFlag, +} from "./haptics.ts"; import { MODE_STATUS, buildModeStatusWriteMany, @@ -225,6 +236,7 @@ const FEATURE = { reportRate: 0x8060, onboardProfiles: 0x8100, analogButtons: 0x1b0c, + haptic: 0x19b0, } as const; interface ResolvedFeature { @@ -596,6 +608,7 @@ export class LogitechHidppClient { const analogButtonTuning = analogButtonsFeature.index ? await this.readAnalogButtonTuning(analogButtonsFeature.index) : undefined; + const haptics = await this.readHapticConfig(); const modeStatusFeature = await this.getFeature(FEATURE.modeStatus); const modeStatus = modeStatusFeature.index ? await this.readModeStatus(modeStatusFeature.index) : null; // One extra request; the layout it selects is worth surfacing in diagnostics. @@ -662,6 +675,9 @@ export class LogitechHidppClient { // Surface Auto and LightForce Optical. Only expose that control bank when // the sensor positively reports the related live LOD capability; this is // deliberately conservative and avoids a product/model exception. + hapticIntensity: haptics?.intensity ?? null, + hapticEnabled: haptics?.enabled ?? null, + hapticBatterySaving: haptics?.batterySaving ?? null, gamingSurfaceMode: modeStatus === null || !hasLiveLiftOffControl ? null : decodeModeStatus(modeStatus, MODE_STATUS.gamingSurface), @@ -1693,6 +1709,89 @@ export class LogitechHidppClient { return buffer; } + /** + * Haptic configuration, or null on a device without feature 0x19B0. Only the + * MX Master 4 is known to carry it. + */ + private async readHapticConfig(): Promise { + const feature = await this.getFeature(FEATURE.haptic); + if (!feature.index) return null; + const reply = await this.request(feature.index, LOGITECH_HAPTIC.get).catch(() => null); + return reply ? decodeHapticConfig(reply.slice(3)) : null; + } + + /** + * Read-modify-write of the two-byte pair, then verify. Both fields share the + * write, so a caller changing one has to supply the other exactly as the + * device reports it, and bits 2-7 of the flag byte ride through untouched. + */ + private async writeHapticConfig(change: { + flag?: { name: LogitechHapticFlag; on: boolean }; + intensity?: number; + }): Promise { + const feature = await this.getFeature(FEATURE.haptic); + if (!feature.index) throw new Error("This mouse has no haptic feature."); + + const current = decodeHapticConfig((await this.request(feature.index, LOGITECH_HAPTIC.get)).slice(3)); + if (!current) throw new Error("The mouse gave no answer when its haptic settings were read."); + + const flagByte = change.flag + ? encodeHapticFlags(current.flagByte, change.flag.name, change.flag.on) + : current.flagByte; + const intensity = change.intensity ?? current.intensity; + + const reply = await this.request( + feature.index, + LOGITECH_HAPTIC.set, + ...buildHapticConfigWrite(flagByte, intensity), + ); + const confirmed = decodeHapticConfig(reply.slice(3)); + if (!confirmed) throw new Error("The mouse gave no answer to the haptic write."); + return confirmed; + } + + /** Sets haptic strength, leaving the flag byte as the device reports it. */ + async setHapticIntensity(intensity: number): Promise { + if (!isLogitechHapticIntensity(intensity)) { + throw new Error("Haptic strength must be a whole number between 0 and 100."); + } + const confirmed = await this.writeHapticConfig({ intensity }); + if (confirmed.intensity !== intensity) { + throw new Error(`The mouse kept a haptic strength of ${confirmed.intensity}.`); + } + return confirmed.intensity; + } + + async setHapticEnabled(enabled: boolean): Promise { + const confirmed = await this.writeHapticConfig({ flag: { name: "enabled", on: enabled } }); + if (confirmed.enabled !== enabled) { + throw new Error(`The mouse kept haptics ${confirmed.enabled ? "on" : "off"}.`); + } + return confirmed.enabled; + } + + async setHapticBatterySaving(enabled: boolean): Promise { + const confirmed = await this.writeHapticConfig({ flag: { name: "batterySaving", on: enabled } }); + if (confirmed.batterySaving !== enabled) { + throw new Error(`The mouse kept haptic battery saving ${confirmed.batterySaving ? "on" : "off"}.`); + } + return confirmed.batterySaving; + } + + /** + * Fires the motor once at whatever strength is set. Nothing persists, so + * this is safe to use as feedback. The reply reports whether the motor was + * already running, which says nothing about the effect itself. + */ + async playHapticEffect(effect: number = LOGITECH_HAPTIC_EFFECTS.strengthSample): Promise { + if (!isLogitechHapticEffect(effect)) { + throw new Error(`This mouse has no haptic effect 0x${effect.toString(16)}.`); + } + const feature = await this.getFeature(FEATURE.haptic); + if (!feature.index) throw new Error("This mouse has no haptic feature."); + await this.request(feature.index, LOGITECH_HAPTIC.play, effect); + } + async setGamingSurfaceMode(mode: GamingSurfaceMode): Promise { await this.setModeStatus({ gamingSurface: mode }); return mode; diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 817795a..ad4c944 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -196,6 +196,12 @@ export interface MouseStatus { /** False until profile-content writes were applied and restored on hardware. */ writable: boolean; } | null; + /** Logitech 0x19B0 haptic strength, 0-100. Null when the device has no haptics. */ + hapticIntensity?: number | null; + /** Logitech 0x19B0 byte 0 bit 0 — haptic feedback on or off. */ + hapticEnabled?: boolean | null; + /** Logitech 0x19B0 byte 0 bit 1 — the device's own haptic battery saver. */ + hapticBatterySaving?: boolean | null; gamingSurfaceMode?: "On" | "Off" | "Auto" | null; lightforceSwitchMode?: "Hybrid" | "Optical" | null; /** Razer lighting zones. */ From 757fea4c8d3bf4ead369f9968a79593a7483fda8 Mon Sep 17 00:00:00 2001 From: Nick Welter Date: Sun, 9 Aug 2026 22:43:45 -0400 Subject: [PATCH 2/6] docs(logitech): note that the enable buzz is the application's job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device does not buzz when haptics are switched back on. Options+ plays an effect straight after that write, and someone who has used the vendor software notices its absence — but it is feedback rather than protocol, so the driver exposes the effect and leaves the decision to the caller. --- src/drivers/logitech/haptics.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/drivers/logitech/haptics.ts b/src/drivers/logitech/haptics.ts index bce2138..93c618f 100644 --- a/src/drivers/logitech/haptics.ts +++ b/src/drivers/logitech/haptics.ts @@ -76,6 +76,14 @@ export const LOGITECH_HAPTIC_EFFECTS = { enableConfirmation: 0x00, } as const; +/* + * Note for a consuming application: the device does not buzz by itself when + * haptics are switched back on. Options+ plays enableConfirmation immediately + * after that write, and a user who has seen the vendor software notices its + * absence. Playing it is the application's job, not this driver's — it is + * feedback, not protocol. + */ + export interface LogitechHapticConfig { enabled: boolean; batterySaving: boolean; From fe2c40fa141e97a1eb9064dbec7f28a4fb9f7884 Mon Sep 17 00:00:00 2001 From: Nick Welter Date: Tue, 11 Aug 2026 09:08:48 -0400 Subject: [PATCH 3/6] refactor(logitech): move the haptic codec to the package entry point The wire format belongs in src/logitech/ and reaches consumers through the Logitech entry point; only feature discovery, WebHID requests and read-back verification stay in the driver. Following the mode-status module put it under src/drivers/ instead, which leaves the already-large HID++ driver owning the packet layout too. The codec folder uses .js extensions on relative imports and the driver folder uses .ts, so the moved file and its test follow the folder they now live in. --- src/drivers/logitech/hidpp.ts | 20 +++++++++----------- src/{drivers => }/logitech/haptics.test.ts | 2 +- src/{drivers => }/logitech/haptics.ts | 0 src/logitech/index.ts | 2 ++ 4 files changed, 12 insertions(+), 12 deletions(-) rename src/{drivers => }/logitech/haptics.test.ts (99%) rename src/{drivers => }/logitech/haptics.ts (100%) diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index e9518cd..edfecbd 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -10,6 +10,15 @@ import { resolveBoltReportDevice, } from "./bolt.ts"; import { + LOGITECH_HAPTIC, + LOGITECH_HAPTIC_EFFECTS, + buildHapticConfigWrite, + decodeHapticConfig, + encodeHapticFlags, + isLogitechHapticEffect, + isLogitechHapticIntensity, + type LogitechHapticConfig, + type LogitechHapticFlag, DEVICE_INDEX_DIRECT, decodeBatteryLevelState, decodeReportRateBitmap, @@ -28,17 +37,6 @@ export { hasHidppLongCollection, hasHidppShortCollection, } from "./bolt.ts"; -import { - LOGITECH_HAPTIC, - LOGITECH_HAPTIC_EFFECTS, - buildHapticConfigWrite, - decodeHapticConfig, - encodeHapticFlags, - isLogitechHapticEffect, - isLogitechHapticIntensity, - type LogitechHapticConfig, - type LogitechHapticFlag, -} from "./haptics.ts"; import { MODE_STATUS, buildModeStatusWriteMany, diff --git a/src/drivers/logitech/haptics.test.ts b/src/logitech/haptics.test.ts similarity index 99% rename from src/drivers/logitech/haptics.test.ts rename to src/logitech/haptics.test.ts index 6311246..7380a52 100644 --- a/src/drivers/logitech/haptics.test.ts +++ b/src/logitech/haptics.test.ts @@ -15,7 +15,7 @@ import { isLogitechHapticEffect, isLogitechHapticIntensity, type LogitechHapticPreset, -} from "./haptics.ts"; +} from "./haptics.js"; /** Config pairs read from an MX Master 4 while toggling Logi Options+. */ const CAPTURES: ReadonlyArray<[number[], { enabled: boolean; batterySaving: boolean; intensity: number }]> = [ diff --git a/src/drivers/logitech/haptics.ts b/src/logitech/haptics.ts similarity index 100% rename from src/drivers/logitech/haptics.ts rename to src/logitech/haptics.ts diff --git a/src/logitech/index.ts b/src/logitech/index.ts index 80bac4b..2b92217 100644 --- a/src/logitech/index.ts +++ b/src/logitech/index.ts @@ -1,3 +1,5 @@ +export * from "./haptics.js"; + // HID++ reserves software ID 0 for device-originated notifications. Using a // nonzero ID keeps command replies distinct from asynchronous mouse events. const SOFTWARE_ID = 0x05; From 72c72d35248f93682711cfec6a3ddef4dbac98c3 Mon Sep 17 00:00:00 2001 From: Nick Welter Date: Tue, 11 Aug 2026 09:23:59 -0400 Subject: [PATCH 4/6] feat(logitech): MX Master 4 wheel settings, friendly name and Easy-Switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the remaining MX Master 4 features alongside haptics, all read off the same hardware and all following the codec/driver split: wire format under src/logitech/, WebHID and read-back verification in the driver. 0x2111 SmartShift Enhanced carries three bytes in one write, so each setter reads the trio first. Byte 0 is the wheel's ratchet mode — the same thing the button behind the wheel toggles, and not SmartShift on/off, which is what it was first mistaken for. Byte 1 is the threshold, where 255 disables SmartShift and lower values release the ratchet on a gentler flick; proven by moving the Logi Options+ sensitivity slider and watching only that byte follow. 0x2121 capabilities are [multiplier, flags]; reading them reversed reports a multiplier of 28 on a wheel whose real multiplier is 15. Mode writes flip a single bit and carry diversion through untouched: setting it routes the wheel to HID++ and stops it scrolling, and clearing it takes that away from whatever set it. 0x2150 thumb-wheel invert support is the two-byte capability field at payload[4..5], which an MX Master 4 answers 0x0003 — reading the single byte there reports inversion unsupported on a device that demonstrably honours it. 0x0007 friendly name is validated before any bytes go out, because a device that takes half a name and rejects the rest leaves the user with neither the old one nor the new, and the name is read back afterwards: firmware that acknowledges a write and keeps its old value would otherwise look like success. Easy-Switch is named requestHostSwitch for what it can prove. A successful switch disconnects this host, so there is no state left to read back — the method resolves when the command reached the device, either acknowledged or having timed out because the mouse left before it could answer. A refusal from a mouse that is still present is surfaced rather than mistaken for departure, and the acknowledgement timeout is short so the caller is not left waiting six seconds for an answer that cannot arrive. An empty slot is refused outright: switching there leaves the mouse unreachable until someone presses the button on its underside. Fake-HID tests cover both the acknowledged and the disconnected paths, plus refusal, a report that never reaches the transport, and every rejected slot. Verified on hardware: MX Master 4, WPID B042, firmware LD 04.00, over a Logi Bolt receiver 046d:c548 on pairing slot 2. --- src/drivers/logitech/hidpp.ts | 335 +++++++++++++++++++++++ src/drivers/logitech/host-switch.test.ts | 201 ++++++++++++++ src/drivers/mouse-types.ts | 28 ++ src/logitech/friendly-name.test.ts | 71 +++++ src/logitech/friendly-name.ts | 92 +++++++ src/logitech/hosts.test.ts | 72 +++++ src/logitech/hosts.ts | 91 ++++++ src/logitech/index.ts | 3 + src/logitech/wheel.test.ts | 88 ++++++ src/logitech/wheel.ts | 159 +++++++++++ 10 files changed, 1140 insertions(+) create mode 100644 src/drivers/logitech/host-switch.test.ts create mode 100644 src/logitech/friendly-name.test.ts create mode 100644 src/logitech/friendly-name.ts create mode 100644 src/logitech/hosts.test.ts create mode 100644 src/logitech/hosts.ts create mode 100644 src/logitech/wheel.test.ts create mode 100644 src/logitech/wheel.ts diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index edfecbd..1708b0a 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -10,8 +10,36 @@ import { resolveBoltReportDevice, } from "./bolt.ts"; import { + LOGITECH_CHANGE_HOST, + LOGITECH_FRIENDLY_NAME, LOGITECH_HAPTIC, LOGITECH_HAPTIC_EFFECTS, + LOGITECH_HIRES_WHEEL, + LOGITECH_HIRES_WHEEL_BIT, + LOGITECH_HOSTS, + LOGITECH_SMART_SHIFT, + LOGITECH_SMART_SHIFT_OFF, + LOGITECH_THUMB_WHEEL, + buildFriendlyNameWrite, + buildRatchetControlWrite, + buildThumbWheelWrite, + decodeHiresWheelCapabilities, + decodeHiresWheelMode, + decodeRatchetControl, + decodeThumbWheelStatus, + decodeThumbWheelSupportsInvert, + encodeHiresWheelMode, + type LogitechRatchetControl, + type LogitechWheelMode, + buildHostSwitchWrite, + decodeFriendlyNameChunk, + decodeFriendlyNameLengths, + decodeFriendlyNameText, + decodeHostPaired, + decodeHostsInfo, + rejectFriendlyName, + rejectHostSwitch, + type LogitechHostsInfo, buildHapticConfigWrite, decodeHapticConfig, encodeHapticFlags, @@ -217,6 +245,12 @@ export function hasLiftOffControl(legacyDpi: boolean, lodByte: number | null): b const SHORT_REPORT_ID = 0x10; const LONG_REPORT_ID = 0x11; const REQUEST_TIMEOUT_MS = 6000; +/** + * A host switch succeeds by disconnecting, so the usual request timeout would + * spend six seconds waiting for an answer that cannot arrive before concluding + * the command worked. Long enough for a device that stays to acknowledge. + */ +const HOST_SWITCH_ACK_TIMEOUT_MS = 1500; const FEATURE = { deviceName: 0x0005, firmware: 0x0003, @@ -235,6 +269,12 @@ const FEATURE = { onboardProfiles: 0x8100, analogButtons: 0x1b0c, haptic: 0x19b0, + smartShift: 0x2111, + hiresWheel: 0x2121, + thumbWheel: 0x2150, + friendlyName: 0x0007, + hostsInfo: 0x1815, + changeHost: 0x1814, } as const; interface ResolvedFeature { @@ -607,6 +647,9 @@ export class LogitechHidppClient { ? await this.readAnalogButtonTuning(analogButtonsFeature.index) : undefined; const haptics = await this.readHapticConfig(); + const friendly = await this.readFriendlyName(); + const hosts = await this.readHostState(); + const wheel = await this.readWheelState(); const modeStatusFeature = await this.getFeature(FEATURE.modeStatus); const modeStatus = modeStatusFeature.index ? await this.readModeStatus(modeStatusFeature.index) : null; // One extra request; the layout it selects is worth surfacing in diagnostics. @@ -673,6 +716,12 @@ export class LogitechHidppClient { // Surface Auto and LightForce Optical. Only expose that control bank when // the sensor positively reports the related live LOD capability; this is // deliberately conservative and avoids a product/model exception. + ...wheel, + friendlyName: friendly?.name ?? null, + friendlyNameMaxLength: friendly?.maxLength ?? null, + hostCount: hosts?.info.hostCount ?? null, + currentHost: hosts?.info.currentHost ?? null, + hostSlotsPaired: hosts?.paired ?? null, hapticIntensity: haptics?.intensity ?? null, hapticEnabled: haptics?.enabled ?? null, hapticBatterySaving: haptics?.batterySaving ?? null, @@ -1748,6 +1797,292 @@ export class LogitechHidppClient { return confirmed; } + /** + * Scroll-wheel state across 0x2111, 0x2121 and 0x2150. Every field stays + * absent rather than guessed when its feature is missing, so a mouse + * without a thumb wheel does not get a control that can only fail. + */ + private async readWheelState(): Promise<{ + wheelMode: LogitechWheelMode | null; + smartShiftThreshold: number | null; + hiResScroll: boolean | null; + invertScroll: boolean | null; + supportsInvertScroll: boolean; + wheelRatchetEngaged: boolean | null; + thumbWheelInverted: boolean | null; + supportsThumbWheelInvert: boolean; + }> { + const smartShift = await this.getFeature(FEATURE.smartShift); + const ratchetReply = smartShift.index + ? await this.request(smartShift.index, LOGITECH_SMART_SHIFT.get).catch(() => null) + : null; + const ratchet = ratchetReply ? decodeRatchetControl(ratchetReply.slice(3)) : null; + + const wheel = await this.getFeature(FEATURE.hiresWheel); + let hiResScroll: boolean | null = null; + let invertScroll: boolean | null = null; + let supportsInvertScroll = false; + let wheelRatchetEngaged: boolean | null = null; + if (wheel.index) { + const capabilityReply = await this.request(wheel.index, LOGITECH_HIRES_WHEEL.capabilities).catch(() => null); + const capabilities = capabilityReply ? decodeHiresWheelCapabilities(capabilityReply.slice(3)) : null; + supportsInvertScroll = capabilities?.supportsInvert ?? false; + + const modeReply = await this.request(wheel.index, LOGITECH_HIRES_WHEEL.get).catch(() => null); + if (modeReply) { + const mode = decodeHiresWheelMode(modeReply[3] ?? 0); + hiResScroll = mode.hiRes; + invertScroll = supportsInvertScroll ? mode.inverted : null; + } + + const stateReply = await this.request(wheel.index, LOGITECH_HIRES_WHEEL.ratchetState).catch(() => null); + wheelRatchetEngaged = stateReply ? (stateReply[3] ?? 0) === 1 : null; + } + + const thumb = await this.readThumbWheelState(); + + return { + wheelMode: ratchet?.mode ?? null, + smartShiftThreshold: ratchet?.threshold ?? null, + hiResScroll, + invertScroll, + supportsInvertScroll, + wheelRatchetEngaged, + ...thumb, + }; + } + + private async readThumbWheelState(): Promise<{ + thumbWheelInverted: boolean | null; + supportsThumbWheelInvert: boolean; + }> { + const feature = await this.getFeature(FEATURE.thumbWheel); + if (!feature.index) return { thumbWheelInverted: null, supportsThumbWheelInvert: false }; + + const info = await this.request(feature.index, LOGITECH_THUMB_WHEEL.info).catch(() => null); + const supports = info ? decodeThumbWheelSupportsInvert(info.slice(3)) === true : false; + const status = await this.request(feature.index, LOGITECH_THUMB_WHEEL.get).catch(() => null); + const decoded = status ? decodeThumbWheelStatus(status.slice(3)) : null; + return { thumbWheelInverted: decoded?.inverted ?? null, supportsThumbWheelInvert: supports }; + } + + /** 0x2111 carries all three bytes, so each setter changes only its field. */ + private async writeRatchetControl( + change: { mode?: LogitechWheelMode; threshold?: number }, + ): Promise { + const feature = await this.getFeature(FEATURE.smartShift); + if (!feature.index) throw new Error("This mouse has no SmartShift feature."); + + const current = decodeRatchetControl((await this.request(feature.index, LOGITECH_SMART_SHIFT.get)).slice(3)); + if (!current) throw new Error("The mouse gave no answer when its wheel settings were read."); + + const reply = await this.request( + feature.index, + LOGITECH_SMART_SHIFT.set, + ...buildRatchetControlWrite(current, change), + ); + const confirmed = decodeRatchetControl(reply.slice(3)); + if (!confirmed) throw new Error("The mouse gave no answer to the wheel write."); + return confirmed; + } + + async setWheelMode(mode: LogitechWheelMode): Promise { + const confirmed = await this.writeRatchetControl({ mode }); + if (confirmed.mode !== mode) throw new Error(`The mouse kept the wheel in ${confirmed.mode} mode.`); + return mode; + } + + /** Passing null disables SmartShift; the ratchet mode is preserved either way. */ + async setSmartShiftThreshold(threshold: number | null): Promise { + const value = threshold === null ? LOGITECH_SMART_SHIFT_OFF : Math.round(threshold); + if (!Number.isInteger(value) || value < 0 || value > 0xff) { + throw new Error("A SmartShift threshold must be a whole number between 0 and 255."); + } + const confirmed = await this.writeRatchetControl({ threshold: value }); + if (confirmed.threshold !== value) { + throw new Error(`The mouse kept a SmartShift threshold of ${confirmed.threshold}.`); + } + return confirmed.threshold; + } + + /** + * Flips one bit of the 0x2121 mode byte. Diversion is read and carried + * through: setting it routes the wheel to HID++ and stops it scrolling, and + * clearing it would take that away from whatever set it. + */ + private async writeWheelModeBit(bit: number, on: boolean): Promise { + const feature = await this.getFeature(FEATURE.hiresWheel); + if (!feature.index) throw new Error("This mouse has no hi-resolution wheel feature."); + + const current = (await this.request(feature.index, LOGITECH_HIRES_WHEEL.get))[3] ?? 0; + const reply = await this.request( + feature.index, + LOGITECH_HIRES_WHEEL.set, + encodeHiresWheelMode(current, bit, on), + ); + return reply[3] ?? 0; + } + + async setHiResScroll(enabled: boolean): Promise { + const mode = decodeHiresWheelMode(await this.writeWheelModeBit(LOGITECH_HIRES_WHEEL_BIT.hiRes, enabled)); + if (mode.hiRes !== enabled) throw new Error("The mouse kept its previous scrolling mode."); + return mode.hiRes; + } + + async setInvertScroll(inverted: boolean): Promise { + const mode = decodeHiresWheelMode(await this.writeWheelModeBit(LOGITECH_HIRES_WHEEL_BIT.invert, inverted)); + if (mode.inverted !== inverted) throw new Error("The mouse kept its previous scroll direction."); + return mode.inverted; + } + + /** + * Inverts the thumb wheel. Logi Options+ sets the diversion bit to implement + * horizontal scrolling, so it is read and preserved rather than rewritten. + */ + async setThumbWheelInverted(inverted: boolean): Promise { + const feature = await this.getFeature(FEATURE.thumbWheel); + if (!feature.index) throw new Error("This mouse has no thumb wheel."); + + const status = decodeThumbWheelStatus((await this.request(feature.index, LOGITECH_THUMB_WHEEL.get)).slice(3)); + if (!status) throw new Error("The mouse gave no answer when its thumb wheel was read."); + + const reply = await this.request( + feature.index, + LOGITECH_THUMB_WHEEL.set, + ...buildThumbWheelWrite(status, inverted), + ); + const confirmed = decodeThumbWheelStatus(reply.slice(3)); + if (confirmed?.inverted !== inverted) { + throw new Error("The mouse kept its previous thumb-wheel direction."); + } + return confirmed.inverted; + } + + /** The editable name, or null on a device without feature 0x0007. */ + private async readFriendlyName(): Promise<{ name: string; maxLength: number } | null> { + const feature = await this.getFeature(FEATURE.friendlyName); + if (!feature.index) return null; + + const header = await this.request(feature.index, LOGITECH_FRIENDLY_NAME.lengths).catch(() => null); + const lengths = header ? decodeFriendlyNameLengths(header.slice(3)) : null; + if (!lengths) return null; + if (lengths.length === 0) return { name: "", maxLength: lengths.maxLength }; + + const characters: number[] = []; + while (characters.length < lengths.length) { + const chunk = await this.request(feature.index, LOGITECH_FRIENDLY_NAME.get, characters.length); + const decoded = decodeFriendlyNameChunk(chunk.slice(3), lengths.length - characters.length); + if (decoded.length === 0) break; + characters.push(...decoded); + } + return { name: decodeFriendlyNameText(characters), maxLength: lengths.maxLength }; + } + + /** + * Renames the device, then reads the name back. The read-back is not + * ceremony: firmware that acknowledges a write and quietly keeps its old + * value would otherwise look like a successful rename. + */ + async setFriendlyName(name: string): Promise { + const feature = await this.getFeature(FEATURE.friendlyName); + if (!feature.index) throw new Error("This mouse cannot be renamed."); + + const header = await this.request(feature.index, LOGITECH_FRIENDLY_NAME.lengths); + const lengths = decodeFriendlyNameLengths(header.slice(3)); + if (!lengths) throw new Error("This mouse did not report how long a name it accepts."); + + const rejection = rejectFriendlyName(name, lengths.maxLength); + if (rejection === "empty") throw new Error("A name cannot be empty."); + if (rejection === "non-ascii") throw new Error("A name may only contain plain ASCII characters."); + if (rejection !== null) { + throw new Error(`This mouse allows at most ${lengths.maxLength} characters.`); + } + + await this.requestLong(feature.index, LOGITECH_FRIENDLY_NAME.set, buildFriendlyNameWrite(name)); + + const confirmed = await this.readFriendlyName(); + if (confirmed?.name !== name.trim()) { + throw new Error(`The mouse kept the name "${confirmed?.name ?? ""}".`); + } + return confirmed.name; + } + + /** + * Easy-Switch slots, or null without feature 0x1815. Slot indices are + * zero-based here as they are on the wire; the button under the mouse counts + * from one, so anything user-facing has to add one. + */ + private async readHostState(): Promise<{ + info: LogitechHostsInfo; + paired: boolean[]; + } | null> { + const feature = await this.getFeature(FEATURE.hostsInfo); + if (!feature.index) return null; + + const reply = await this.request(feature.index, LOGITECH_HOSTS.info).catch(() => null); + const info = reply ? decodeHostsInfo(reply.slice(3)) : null; + if (!info) return null; + + const paired: boolean[] = []; + for (let slot = 0; slot < info.hostCount; slot += 1) { + const entry = await this.request(feature.index, LOGITECH_HOSTS.host, slot).catch(() => null); + // A slot that will not describe itself counts as empty, never as + // switchable — this has to fail towards refusing the switch. + paired.push(entry ? decodeHostPaired(entry.slice(3)) === true : false); + } + return { info, paired }; + } + + /** + * Asks the mouse to move to another Easy-Switch slot. + * + * Named for what it can prove. A successful switch disconnects this host, so + * there is no state left to read back and no way to confirm the mouse + * arrived — disconnection IS the expected success path, not a failure. + * Resolving means the command reached the device: either it acknowledged, or + * it left before an acknowledgement could be observed and the report was + * accepted by the transport. + * + * An empty slot is refused outright. Switching there leaves the mouse + * unreachable until someone presses the button on its underside. + */ + async requestHostSwitch(slot: number): Promise { + const state = await this.readHostState(); + const rejection = rejectHostSwitch(slot, state?.info ?? null, state?.paired ?? []); + if (rejection === "no-hosts") throw new Error("This mouse does not report Easy-Switch hosts."); + if (rejection === "already-current") throw new Error("The mouse is already on that computer."); + if (rejection === "empty-slot") { + throw new Error( + `Computer ${slot + 1} has nothing paired to it. Switching there would leave the mouse ` + + "unreachable until you press the button underneath it.", + ); + } + if (rejection !== null) { + throw new Error(`Computer ${slot + 1} is not one of this mouse's slots.`); + } + + const feature = await this.getFeature(FEATURE.changeHost); + if (!feature.index) throw new Error("This mouse has no 0x1814 CHANGE HOST feature."); + + try { + await this.requestWithOptions( + feature.index, + LOGITECH_CHANGE_HOST.set, + buildHostSwitchWrite(slot), + { timeoutMs: HOST_SWITCH_ACK_TIMEOUT_MS }, + ); + } catch (error) { + // The mouse leaving mid-request is the command working. Only a failure + // to hand the report to the transport means it never got there. + // A timeout here is the mouse having gone: the report reached the + // transport and no answer can arrive from a device that is no longer + // this host's. Anything else — a refusal, or sendReport failing — means + // the command did not take effect and must surface. + if (error instanceof HidppTimeoutError) return; + throw error; + } + } + /** Sets haptic strength, leaving the flag byte as the device reports it. */ async setHapticIntensity(intensity: number): Promise { if (!isLogitechHapticIntensity(intensity)) { diff --git a/src/drivers/logitech/host-switch.test.ts b/src/drivers/logitech/host-switch.test.ts new file mode 100644 index 0000000..c480a6c --- /dev/null +++ b/src/drivers/logitech/host-switch.test.ts @@ -0,0 +1,201 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { withSoftwareId } from "@openmouse/protocol/logitech"; + +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 indices this scripted receiver hands out, and the slot layout it + * reports: three hosts, slots 0 and 1 paired, sitting on slot 0 — the state an + * MX Master 4 was read in. + */ +const HOSTS_INFO_INDEX = 0x0f; +const CHANGE_HOST_INDEX = 0x0e; +const HOSTS = [true, true, false]; +const CURRENT_HOST = 0; + +const reply = (deviceIndex: number, featureIndex: number, functionId: number, data: number[] = []): Uint8Array => + new Uint8Array([deviceIndex, featureIndex, withSoftwareId(functionId), ...data]); + +interface ScriptOptions { + /** How the device behaves when told to switch. */ + onSwitch: "acknowledge" | "depart" | "refuse"; +} + +/** + * A receiver that answers enough for host switching: root lookups for 0x1815 + * and 0x1814, the hosts table, and the switch itself. + * + * "depart" returns no reply at all, which is what a mouse that has moved to + * another computer looks like from here — the request simply never answers. + */ +function hostResponder(options: ScriptOptions) { + const sent: Uint8Array[] = []; + /** Root lookups this device answers; anything else reports "not present". */ + const FEATURES: Record = { + 0x0003: 0x02, // firmware — probed while resolving the device index + 0x2201: 0x14, // a DPI feature, without which the slot is not taken as a mouse + 0x1815: HOSTS_INFO_INDEX, + 0x1814: CHANGE_HOST_INDEX, + }; + + const responder = (request: Uint8Array): Uint8Array | null => { + sent.push(request.slice()); + const deviceIndex = request[0]; + const featureIndex = request[1]; + const functionByte = request[2]; + const functionId = functionByte & 0xf0; + // Echoes the feature and function bytes exactly, which is what the + // driver matches a reply against. + const answer = (data: number[]): Uint8Array => + new Uint8Array([deviceIndex, featureIndex, functionByte, ...data]); + + if (deviceIndex !== 0x02) { + return new Uint8Array([deviceIndex, 0x8f, featureIndex, functionByte, 0x08, 0]); + } + + if (featureIndex === 0x00) { + // Root: function 0 resolves a feature id, function 1 is the ping. + if (functionId === 0x00) { + const featureId = (request[3] << 8) | request[4]; + return answer([FEATURES[featureId] ?? 0x00, 0x00, 0x02]); + } + return answer([0x04, 0x05, request[5] ?? 0]); + } + + if (featureIndex === HOSTS_INFO_INDEX) { + if (functionId === 0x00) return answer([0x13, 0x08, HOSTS.length, CURRENT_HOST]); + if (functionId === 0x10) { + const slot = request[3] ?? 0; + return answer([slot, HOSTS[slot] ? 0x01 : 0x00, 0x05, 0x01, 0x0a, 0x18]); + } + } + + if (featureIndex === CHANGE_HOST_INDEX && functionId === 0x10) { + if (options.onSwitch === "depart") return null; + if (options.onSwitch === "refuse") { + return new Uint8Array([deviceIndex, 0xff, featureIndex, functionByte, 0x02, 0]); + } + return answer([request[3] ?? 0]); + } + + return answer([0x00]); + }; + return { responder, sent }; +} + +class FakeHidDevice { + readonly productId = 0xc548; + readonly productName = "USB Receiver"; + readonly vendorId = 0x046d; + /** + * Bolt device feature traffic rides the long-report collection, and the + * driver refuses to bind without it. Both HID++ endpoints are present here, + * which is what the receiver offers once a user authorizes them. + */ + readonly collections = [ + { usagePage: 0xff00, usage: 0x0001, children: [] }, + { usagePage: 0xff00, usage: 0x0002, children: [] }, + ]; + private listeners = new Map void>(); + onRequest: (request: Uint8Array) => Uint8Array | null = () => null; + sendReportFails = false; + + 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 { + if (this.sendReportFails) throw new Error("The device is not open."); + const answer = this.onRequest(data.slice()); + if (answer) { + queueMicrotask(() => { + this.listeners.get("inputreport")?.({ + reportId, + data: new DataView(answer.buffer.slice(answer.byteOffset, answer.byteOffset + answer.byteLength)), + }); + }); + } + } +} + +/** + * Resolves the receiver slot before returning, the way a real session does: + * the panel reads status first, and until the slot is known the driver talks + * to the direct index and nothing answers. + */ +async function harness(options: ScriptOptions) { + const device = new FakeHidDevice(); + const script = hostResponder(options); + device.onRequest = script.responder; + const client = new LogitechHidppClient(device as unknown as HIDDevice) as unknown as { + open(): Promise; + resolveDeviceIndex(): Promise; + readonly resolvedDeviceIndex: number | null; + requestHostSwitch(slot: number): Promise; + }; + await client.open(); + await client.resolveDeviceIndex(); + assert.equal(client.resolvedDeviceIndex, 0x02, "the scripted receiver was not found on slot 2"); + script.sent.length = 0; + return { device, sent: script.sent, client }; +} + +const switchWrites = (sent: Uint8Array[]): Uint8Array[] => + sent.filter((request) => request[1] === CHANGE_HOST_INDEX && (request[2] & 0xf0) === 0x10); + +test("a switch the mouse acknowledges resolves", async () => { + const { client, sent } = await harness({ onSwitch: "acknowledge" }); + await client.requestHostSwitch(1); + assert.equal(switchWrites(sent).length, 1); + assert.equal(switchWrites(sent)[0]?.[3], 1, "the slot did not reach the device"); +}); + +test("a mouse that leaves before answering still counts as a switch sent", async () => { + // Disconnection is the expected success path: the command reached the + // device and no reply can arrive from a mouse that is no longer ours. + const { client, sent } = await harness({ onSwitch: "depart" }); + await client.requestHostSwitch(1); + assert.equal(switchWrites(sent).length, 1); +}); + +test("a refusal from the mouse is surfaced, not swallowed as a departure", async () => { + // An error reply means the mouse is still here and did not switch. Treating + // that as success would report a move that never happened. + const { client } = await harness({ onSwitch: "refuse" }); + await assert.rejects(() => client.requestHostSwitch(1), /invalid argument|INVALID_ARGUMENT|0x02/i); +}); + +test("a report that never reaches the transport is an error", async () => { + const { client, device } = await harness({ onSwitch: "acknowledge" }); + device.sendReportFails = true; + await assert.rejects(() => client.requestHostSwitch(1), /not open/); +}); + +test("an empty slot is refused and nothing is sent", async () => { + const { client, sent } = await harness({ onSwitch: "acknowledge" }); + await assert.rejects(() => client.requestHostSwitch(2), /nothing paired/); + assert.deepEqual(switchWrites(sent), [], "an empty slot reached the mouse"); +}); + +test("the current slot and a slot that does not exist are refused", async () => { + const { client, sent } = await harness({ onSwitch: "acknowledge" }); + await assert.rejects(() => client.requestHostSwitch(CURRENT_HOST), /already on that computer/); + await assert.rejects(() => client.requestHostSwitch(9), /not one of/); + await assert.rejects(() => client.requestHostSwitch(-1), /not one of/); + assert.deepEqual(switchWrites(sent), []); +}); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index ad4c944..74c4a66 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -197,6 +197,34 @@ export interface MouseStatus { writable: boolean; } | null; /** Logitech 0x19B0 haptic strength, 0-100. Null when the device has no haptics. */ + /** + * Logitech 0x2111 byte 0 — the wheel's ratchet mode, the same thing the + * button behind the wheel toggles. Not SmartShift on/off. + */ + wheelMode?: "Freespin" | "Ratchet" | null; + /** + * Logitech 0x2111 byte 1. 255 disables SmartShift; any lower value enables + * it and sets how gentle a flick releases the ratchet. + */ + smartShiftThreshold?: number | null; + /** Logitech 0x2121 — high-resolution (smooth) scrolling. */ + hiResScroll?: boolean | null; + invertScroll?: boolean | null; + supportsInvertScroll?: boolean; + /** Live read of whether the wheel is currently ratcheted. */ + wheelRatchetEngaged?: boolean | null; + /** Logitech 0x2150 — the horizontal thumb wheel. */ + thumbWheelInverted?: boolean | null; + supportsThumbWheelInvert?: boolean; + /** Logitech 0x0007 — the editable name, distinct from the fixed device name. */ + friendlyName?: string | null; + friendlyNameMaxLength?: number | null; + /** Logitech 0x1815 — Easy-Switch slot count, or null without the feature. */ + hostCount?: number | null; + /** Zero-based active slot; the button under the mouse counts from one. */ + currentHost?: number | null; + /** One entry per slot, true when a computer is paired to it. */ + hostSlotsPaired?: boolean[] | null; hapticIntensity?: number | null; /** Logitech 0x19B0 byte 0 bit 0 — haptic feedback on or off. */ hapticEnabled?: boolean | null; diff --git a/src/logitech/friendly-name.test.ts b/src/logitech/friendly-name.test.ts new file mode 100644 index 0000000..37dff55 --- /dev/null +++ b/src/logitech/friendly-name.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + LOGITECH_FRIENDLY_NAME_BYTES_PER_REPORT, + buildFriendlyNameWrite, + decodeFriendlyNameChunk, + decodeFriendlyNameLengths, + decodeFriendlyNameText, + encodeFriendlyName, + rejectFriendlyName, +} from "./friendly-name.js"; + +test("decodes the lengths captured from hardware", () => { + // An MX Master 4 reports 11 of a maximum 14. + assert.deepEqual(decodeFriendlyNameLengths([0x0b, 0x0e]), { length: 11, maxLength: 14 }); +}); + +test("a reply with no maximum decodes as null rather than a zero-length limit", () => { + // A maxLength of 0 would refuse every name with "at most 0 characters". + assert.equal(decodeFriendlyNameLengths([0x0b, 0x00]), null); + assert.equal(decodeFriendlyNameLengths([0x0b]), null); + assert.equal(decodeFriendlyNameLengths([]), null); +}); + +test("a chunk skips the echoed offset", () => { + // 00 4D 58 20 4D 61 73 74 65 72 20 34 — the leading 00 is the offset asked + // for. Reading from index 0 puts it in the name and shifts every character. + const payload = [0x00, ...[..."MX Master 4"].map((c) => c.charCodeAt(0))]; + const characters = decodeFriendlyNameChunk(payload, 11); + assert.equal(decodeFriendlyNameText(characters), "MX Master 4"); +}); + +test("a chunk never takes more characters than remain", () => { + const payload = [0x00, ...[..."MX Master 4XXXX"].map((c) => c.charCodeAt(0))]; + assert.equal(decodeFriendlyNameText(decodeFriendlyNameChunk(payload, 11)), "MX Master 4"); + assert.deepEqual(decodeFriendlyNameChunk(payload, 0), []); + assert.deepEqual(decodeFriendlyNameChunk(payload, -1), []); +}); + +test("trailing padding is not part of the name", () => { + assert.equal(decodeFriendlyNameText([0x4d, 0x58, 0x00, 0x00]), "MX"); + assert.equal(decodeFriendlyNameText([0x20, 0x4d, 0x58, 0x20]), "MX"); +}); + +test("a name the device can hold is accepted", () => { + assert.equal(rejectFriendlyName("Desk mouse", 14), null); + assert.equal(rejectFriendlyName("MX Master 4", 14), null); +}); + +test("a name the device cannot hold is refused before anything is written", () => { + // Validated up front: a device that takes half a name and rejects the rest + // leaves the user with neither the old one nor the new. + assert.equal(rejectFriendlyName("A".repeat(15), 14), "too-long"); + assert.equal(rejectFriendlyName("", 14), "empty"); + assert.equal(rejectFriendlyName(" ", 14), "empty"); + assert.equal(rejectFriendlyName("Maus ü", 14), "non-ascii"); + assert.equal(rejectFriendlyName("Maus — 4", 14), "non-ascii"); +}); + +test("a name too long for one report is refused rather than paged", () => { + // Reachable only on a device allowing more than fifteen characters; an + // honest refusal beats paging logic that has never run. + const long = "A".repeat(LOGITECH_FRIENDLY_NAME_BYTES_PER_REPORT + 1); + assert.equal(rejectFriendlyName(long, 32), "too-long-for-one-report"); +}); + +test("the write is offset zero followed by the characters", () => { + assert.deepEqual(buildFriendlyNameWrite("MX"), [0x00, 0x4d, 0x58]); + assert.deepEqual(encodeFriendlyName(" MX "), [0x4d, 0x58]); +}); diff --git a/src/logitech/friendly-name.ts b/src/logitech/friendly-name.ts new file mode 100644 index 0000000..226a622 --- /dev/null +++ b/src/logitech/friendly-name.ts @@ -0,0 +1,92 @@ +/** + * Feature 0x0007 (Device Friendly Name) — the editable name a device presents + * to a host, distinct from the fixed 0x0005 device name. + * + * Read from an MX Master 4 (WPID B042): fn 0x00 answers + * [currentLength, maxLength, ...], reporting 11 of a maximum 14 and holding + * "MX Master 4". fn 0x01 returns the text in chunks headed by the offset that + * was asked for, so characters begin one byte into the payload. fn 0x03 writes + * it back in the same shape. + */ + +export const LOGITECH_FRIENDLY_NAME = { + /** getFriendlyNameLengths: [current, max]. */ + lengths: 0x00, + /** getFriendlyName(offset): [offset, ...characters]. */ + get: 0x10, + /** getDefaultFriendlyName(offset), same shape as get. */ + getDefault: 0x20, + /** setFriendlyName(offset, ...characters). */ + set: 0x30, +} as const; + +/** + * A long report carries 19 bytes: three of HID++ header, then the echoed + * offset, leaving fifteen for characters. The MX Master 4 allows fourteen, so + * one write covers any name it accepts — but the limit is the device's, read + * from the hardware rather than assumed, so this is the transport ceiling. + */ +export const LOGITECH_FRIENDLY_NAME_BYTES_PER_REPORT = 15; + +export interface LogitechFriendlyNameLengths { + length: number; + maxLength: number; +} + +export function decodeFriendlyNameLengths( + payload: Uint8Array | readonly number[], +): LogitechFriendlyNameLengths | null { + const length = payload[0]; + const maxLength = payload[1]; + if (length === undefined || maxLength === undefined || maxLength === 0) return null; + return { length, maxLength }; +} + +/** + * Characters from one chunk. The reply repeats the offset it was asked for, so + * the text starts at payload[1]; reading from payload[0] yields the offset as + * a stray character and shifts the whole name. + */ +export function decodeFriendlyNameChunk( + payload: Uint8Array | readonly number[], + remaining: number, +): number[] { + const wanted = Math.min(LOGITECH_FRIENDLY_NAME_BYTES_PER_REPORT, Math.max(0, remaining)); + return [...payload].slice(1, 1 + wanted); +} + +/** Trailing NULs are padding, not part of the name. */ +export function decodeFriendlyNameText(characters: readonly number[]): string { + return new TextDecoder().decode(new Uint8Array(characters)).replace(/\0/g, "").trim(); +} + +export type LogitechFriendlyNameRejection = + | "empty" + | "too-long" + | "non-ascii" + | "too-long-for-one-report"; + +/** + * Why a name cannot be written, or null when it can. Names are validated + * before any bytes go out: a device that accepts half a name and rejects the + * rest leaves a user with neither the old one nor the new. + */ +export function rejectFriendlyName(name: string, maxLength: number): LogitechFriendlyNameRejection | null { + const bytes = encodeFriendlyName(name); + if (bytes.length === 0) return "empty"; + if (bytes.length > maxLength) return "too-long"; + if (bytes.length > LOGITECH_FRIENDLY_NAME_BYTES_PER_REPORT) return "too-long-for-one-report"; + // The wire carries bytes, but a name outside printable ASCII has never been + // exercised on hardware and multi-byte characters would break the length + // accounting the device does in characters. + if (bytes.some((byte) => byte < 0x20 || byte > 0x7e)) return "non-ascii"; + return null; +} + +export function encodeFriendlyName(name: string): number[] { + return [...new TextEncoder().encode(name.trim())]; +} + +export function buildFriendlyNameWrite(name: string): number[] { + return [0x00, ...encodeFriendlyName(name)]; +} diff --git a/src/logitech/hosts.test.ts b/src/logitech/hosts.test.ts new file mode 100644 index 0000000..d13b9d4 --- /dev/null +++ b/src/logitech/hosts.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + LOGITECH_HOST_STATUS_PAIRED, + buildHostSwitchWrite, + decodeHostPaired, + decodeHostsInfo, + rejectHostSwitch, +} from "./hosts.js"; + +/** Read from an MX Master 4: capabilities 0x1308, three slots, on slot 0. */ +const HOSTS_INFO = [0x13, 0x08, 0x03, 0x00]; + +test("decodes the hosts reply captured from hardware", () => { + const info = decodeHostsInfo(HOSTS_INFO); + assert.deepEqual(info, { hostCount: 3, currentHost: 0, capabilities: 0x1308 }); +}); + +test("the counts are read past the capability bytes", () => { + // Reading them one byte early reports eight slots on a device whose slots + // three and up refuse outright, which is how this was first got wrong. + const info = decodeHostsInfo(HOSTS_INFO); + assert.notEqual(info?.hostCount, 0x08, "the count came from a capability byte"); + assert.equal(info?.hostCount, 3); +}); + +test("a reply that cannot be trusted decodes as null", () => { + assert.equal(decodeHostsInfo([]), null); + assert.equal(decodeHostsInfo([0x13, 0x08]), null); + assert.equal(decodeHostsInfo([0x13, 0x08, 0x00, 0x00]), null, "zero slots is not a device"); + // A device claiming to sit on a slot it says it does not have is incoherent, + // and trusting it would offer a switch to a slot that cannot exist. + assert.equal(decodeHostsInfo([0x13, 0x08, 0x03, 0x05]), null); +}); + +test("slot status decodes paired against empty", () => { + assert.equal(decodeHostPaired([0x00, LOGITECH_HOST_STATUS_PAIRED, 0x05]), true); + assert.equal(decodeHostPaired([0x02, 0x00, 0x00]), false); + assert.equal(decodeHostPaired([0x02]), null); +}); + +const INFO = { hostCount: 3, currentHost: 0, capabilities: 0x1308 }; +const PAIRED = [true, true, false]; + +test("a switch to a paired slot is allowed", () => { + assert.equal(rejectHostSwitch(1, INFO, PAIRED), null); +}); + +test("a switch into an empty slot is refused", () => { + // The whole safety design rests on this: an empty slot leaves the mouse + // unreachable until someone presses the button on its underside. + assert.equal(rejectHostSwitch(2, INFO, PAIRED), "empty-slot"); +}); + +test("a switch that would go nowhere is refused", () => { + assert.equal(rejectHostSwitch(0, INFO, PAIRED), "already-current"); + assert.equal(rejectHostSwitch(3, INFO, PAIRED), "out-of-range"); + assert.equal(rejectHostSwitch(-1, INFO, PAIRED), "out-of-range"); + assert.equal(rejectHostSwitch(1.5, INFO, PAIRED), "out-of-range"); + assert.equal(rejectHostSwitch(1, null, PAIRED), "no-hosts"); +}); + +test("a slot with no status read is treated as empty, not as switchable", () => { + // A transient read failure must fail towards refusing the switch. + assert.equal(rejectHostSwitch(1, INFO, []), "empty-slot"); +}); + +test("the write carries the slot index", () => { + assert.deepEqual(buildHostSwitchWrite(1), [1]); + assert.deepEqual(buildHostSwitchWrite(0x1ff), [0xff]); +}); diff --git a/src/logitech/hosts.ts b/src/logitech/hosts.ts new file mode 100644 index 0000000..594322f --- /dev/null +++ b/src/logitech/hosts.ts @@ -0,0 +1,91 @@ +/** + * Features 0x1815 (Hosts Info) and 0x1814 (Change Host) — Easy-Switch. + * + * Read from an MX Master 4 (WPID B042) over a Logi Bolt receiver. 0x1815 + * fn 0x00 answers [capabilities(2), hostCount, currentHost]: the two leading + * capability bytes matter, because reading the counts one byte earlier reports + * eight slots on a device whose slots 3 and up refuse outright. That device + * reports three slots with two paired, itself on slot 0. + * + * Slot indices are zero-based on the wire. The button on the underside of the + * mouse and its indicator both count from one, so anything user-facing has to + * add one or it disagrees with the hardware in the user's hand. + */ + +export const LOGITECH_HOSTS = { + /** getHostsInfo: [capabilities(2), hostCount, currentHost]. */ + info: 0x00, + /** getHostInfo(slot): [slot, status, ..., nameLength, maxNameLength]. */ + host: 0x10, + /** + * getHostFriendlyName(slot, offset). NOT the host's label: on an MX Master 4 + * this answers six non-text bytes per slot regardless of the reported name + * length, which is an address rather than a name. Where the label actually + * lives is unknown, and the remaining function ids on this feature are + * reportedly set-name, move and delete-host — a blind call to the last would + * unpair one of the user's computers, so it was left alone. + */ + hostName: 0x20, +} as const; + +export const LOGITECH_CHANGE_HOST = { + /** getCurrentHost. */ + current: 0x00, + /** setCurrentHost(slot) — disconnects this host on success. */ + set: 0x10, +} as const; + +/** Status 1 means a computer is paired to the slot; 0 means it is empty. */ +export const LOGITECH_HOST_STATUS_PAIRED = 0x01; + +export interface LogitechHostsInfo { + hostCount: number; + currentHost: number; + capabilities: number; +} + +export function decodeHostsInfo(payload: Uint8Array | readonly number[]): LogitechHostsInfo | null { + const capabilityHigh = payload[0]; + const capabilityLow = payload[1]; + const hostCount = payload[2]; + const currentHost = payload[3]; + if (capabilityHigh === undefined || capabilityLow === undefined) return null; + if (hostCount === undefined || currentHost === undefined || hostCount === 0) return null; + if (currentHost >= hostCount) return null; + return { + hostCount, + currentHost, + capabilities: (capabilityHigh << 8) | capabilityLow, + }; +} + +export function decodeHostPaired(payload: Uint8Array | readonly number[]): boolean | null { + const status = payload[1]; + return status === undefined ? null : status === LOGITECH_HOST_STATUS_PAIRED; +} + +export type LogitechHostSwitchRejection = "no-hosts" | "out-of-range" | "already-current" | "empty-slot"; + +/** + * Why a host switch must not be sent, or null when it may be. + * + * Refusing an empty slot is the important one. Switching into a slot with no + * computer paired leaves the mouse unreachable until someone presses the + * button on its underside, and no warning text makes that an acceptable thing + * for a misclick to do. + */ +export function rejectHostSwitch( + slot: number, + info: LogitechHostsInfo | null, + paired: readonly boolean[], +): LogitechHostSwitchRejection | null { + if (!info) return "no-hosts"; + if (!Number.isInteger(slot) || slot < 0 || slot >= info.hostCount) return "out-of-range"; + if (slot === info.currentHost) return "already-current"; + if (paired[slot] !== true) return "empty-slot"; + return null; +} + +export function buildHostSwitchWrite(slot: number): number[] { + return [slot & 0xff]; +} diff --git a/src/logitech/index.ts b/src/logitech/index.ts index 2b92217..65e9aac 100644 --- a/src/logitech/index.ts +++ b/src/logitech/index.ts @@ -1,4 +1,7 @@ +export * from "./friendly-name.js"; export * from "./haptics.js"; +export * from "./hosts.js"; +export * from "./wheel.js"; // HID++ reserves software ID 0 for device-originated notifications. Using a // nonzero ID keeps command replies distinct from asynchronous mouse events. diff --git a/src/logitech/wheel.test.ts b/src/logitech/wheel.test.ts new file mode 100644 index 0000000..72cff5c --- /dev/null +++ b/src/logitech/wheel.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + LOGITECH_HIRES_WHEEL_BIT, + LOGITECH_SMART_SHIFT_OFF, + buildRatchetControlWrite, + buildThumbWheelWrite, + decodeHiresWheelCapabilities, + decodeHiresWheelMode, + decodeRatchetControl, + decodeThumbWheelStatus, + decodeThumbWheelSupportsInvert, + encodeHiresWheelMode, +} from "./wheel.js"; + +test("decodes the 0x2111 trio captured from hardware", () => { + // 02 FF 64 — ratcheted, SmartShift off, default threshold 100. + const control = decodeRatchetControl([0x02, 0xff, 0x64]); + assert.deepEqual(control, { mode: "Ratchet", threshold: LOGITECH_SMART_SHIFT_OFF, defaultThreshold: 0x64 }); + assert.equal(decodeRatchetControl([0x01, 0x0f, 0x64])?.mode, "Freespin"); +}); + +test("a mode byte outside the two known values decodes as null rather than a guess", () => { + assert.equal(decodeRatchetControl([0x00, 0x0f, 0x64])?.mode, null); + assert.equal(decodeRatchetControl([]), null); +}); + +test("changing the wheel mode preserves the SmartShift threshold, and the reverse", () => { + const current = { mode: "Ratchet" as const, threshold: 15, defaultThreshold: 100 }; + assert.deepEqual(buildRatchetControlWrite(current, { mode: "Freespin" }), [1, 15, 100]); + assert.deepEqual(buildRatchetControlWrite(current, { threshold: 46 }), [2, 46, 100]); + // Both fields ride in one write, so dropping either loses a setting the + // caller never asked to change. + assert.deepEqual(buildRatchetControlWrite(current, {}), [2, 15, 100]); +}); + +test("hi-res capabilities are [multiplier, flags], not the other way round", () => { + // 0F 1C — reading these reversed reports a multiplier of 28 on a wheel + // whose real multiplier is 15. + const capabilities = decodeHiresWheelCapabilities([0x0f, 0x1c]); + assert.equal(capabilities?.multiplier, 0x0f); + assert.equal(capabilities?.supportsInvert, true); + assert.equal(decodeHiresWheelCapabilities([0x0f, 0x00])?.supportsInvert, false); + assert.equal(decodeHiresWheelCapabilities([0x0f]), null); +}); + +test("the hi-res mode byte splits into its three bits", () => { + assert.deepEqual(decodeHiresWheelMode(0x06), { hiRes: true, inverted: true, diverted: false }); + assert.deepEqual(decodeHiresWheelMode(0x01), { hiRes: false, inverted: false, diverted: true }); +}); + +test("a mode write never turns diversion on and never turns it off", () => { + // Setting divert stops the wheel scrolling, because nothing consumes those + // notifications. Clearing it would take away whatever set it. + const withDivert = 0x01; + const afterHiRes = encodeHiresWheelMode(withDivert, LOGITECH_HIRES_WHEEL_BIT.hiRes, true); + assert.equal(afterHiRes & LOGITECH_HIRES_WHEEL_BIT.divert, LOGITECH_HIRES_WHEEL_BIT.divert); + + const withoutDivert = 0x02; + const afterInvert = encodeHiresWheelMode(withoutDivert, LOGITECH_HIRES_WHEEL_BIT.invert, true); + assert.equal(afterInvert & LOGITECH_HIRES_WHEEL_BIT.divert, 0); + assert.equal(afterInvert, 0x06); +}); + +test("thumb-wheel invert support is read from the two-byte capability field", () => { + // 00 14 00 78 00 03 — the capability is 0x0003 at [4..5]. Reading the single + // byte at [4] gives 0x00 and reports inversion unsupported on a device that + // demonstrably honours it. + const info = [0x00, 0x14, 0x00, 0x78, 0x00, 0x03, 0x03, 0xe8]; + assert.equal(decodeThumbWheelSupportsInvert(info), true); + assert.equal(decodeThumbWheelSupportsInvert([0x00, 0x14, 0x00, 0x78, 0x00, 0x00]), false); + assert.equal(decodeThumbWheelSupportsInvert([0x00, 0x14]), null); +}); + +test("thumb-wheel status decodes diversion and inversion separately", () => { + assert.deepEqual(decodeThumbWheelStatus([0x01, 0x01]), { diverted: true, inverted: true }); + assert.deepEqual(decodeThumbWheelStatus([0x01, 0x00]), { diverted: true, inverted: false }); + assert.equal(decodeThumbWheelStatus([0x01]), null); +}); + +test("inverting the thumb wheel preserves the diversion Options+ set", () => { + // Options+ sets diversion to implement horizontal scrolling; a write that + // clears it silently removes that. + assert.deepEqual(buildThumbWheelWrite({ diverted: true }, true), [1, 1]); + assert.deepEqual(buildThumbWheelWrite({ diverted: true }, false), [1, 0]); + assert.deepEqual(buildThumbWheelWrite({ diverted: false }, true), [0, 1]); +}); diff --git a/src/logitech/wheel.ts b/src/logitech/wheel.ts new file mode 100644 index 0000000..0fb395e --- /dev/null +++ b/src/logitech/wheel.ts @@ -0,0 +1,159 @@ +/** + * Scroll-wheel features on the MX line: 0x2111 (SmartShift Enhanced), + * 0x2121 (Hi-Res Wheel) and 0x2150 (Thumb Wheel). + * + * Byte layouts were established on an MX Master 4 by pressing the physical + * wheel-mode button and diffing dumps — exactly two bytes moved — and by + * moving Logi Options+'s own sliders and watching which value followed. + */ + +export const LOGITECH_SMART_SHIFT = { + /** getCapabilities: [?, min, max, ...] on an MX Master 4, 01 0A 4B 0E. */ + capabilities: 0x00, + /** getRatchetControlMode: [mode, threshold, defaultThreshold]. */ + get: 0x10, + /** setRatchetControlMode(mode, threshold, defaultThreshold). */ + set: 0x20, +} as const; + +/** + * 0x2111 byte 0 is the wheel's ratchet mode — the same thing the button behind + * the wheel toggles, and NOT SmartShift on/off. It was first shipped labelled + * "SmartShift / Always ratchet", which was naming a control after inferred + * behaviour rather than demonstrated behaviour. + */ +export const LOGITECH_WHEEL_MODE = { freespin: 1, ratchet: 2 } as const; + +/** + * Byte 1 is the SmartShift threshold: 255 disables it, and a lower value + * releases the ratchet on a gentler flick. Proven by moving the Options+ + * sensitivity slider — 9% wrote 46 and 75% wrote 15, with the mode byte stuck + * at 2 throughout. + */ +export const LOGITECH_SMART_SHIFT_OFF = 0xff; + +export const LOGITECH_HIRES_WHEEL = { + /** getCapabilities: [multiplier, flags, ...]. */ + capabilities: 0x00, + /** getMode / setMode, a single bitmask byte. */ + get: 0x10, + set: 0x20, + /** getRatchetSwitchState: byte 0 is 1 while the wheel is ratcheted. */ + ratchetState: 0x30, +} as const; + +/** + * Bits of the 0x2121 mode byte. + * + * NEVER set divert. It routes wheel movement to HID++ instead of ordinary HID + * scrolling, and unless something is consuming those notifications the wheel + * simply stops working. Writes read-modify-write a single bit and carry this + * one through untouched. + */ +export const LOGITECH_HIRES_WHEEL_BIT = { divert: 0x01, hiRes: 0x02, invert: 0x04 } as const; + +/** Byte 1 of getCapabilities; bit 3 is set when inversion is supported. */ +export const LOGITECH_HIRES_WHEEL_SUPPORTS_INVERT = 0x08; + +export const LOGITECH_THUMB_WHEEL = { + /** getThumbwheelInfo: [nativeRes(2), divertedRes(2), capabilities(2), ...]. */ + info: 0x00, + /** getThumbwheelStatus: [diverted, inverted]. */ + get: 0x10, + /** setThumbwheelReporting(diverted, inverted). */ + set: 0x20, +} as const; + +/** Bit 0 of the two-byte capability field at payload[4..5]. */ +export const LOGITECH_THUMB_WHEEL_SUPPORTS_INVERT = 0x01; + +export type LogitechWheelMode = "Freespin" | "Ratchet"; + +export interface LogitechRatchetControl { + mode: LogitechWheelMode | null; + threshold: number; + defaultThreshold: number; +} + +export function decodeRatchetControl(payload: Uint8Array | readonly number[]): LogitechRatchetControl | null { + const mode = payload[0]; + const threshold = payload[1]; + if (mode === undefined || threshold === undefined) return null; + return { + mode: mode === LOGITECH_WHEEL_MODE.freespin + ? "Freespin" + : mode === LOGITECH_WHEEL_MODE.ratchet ? "Ratchet" : null, + threshold, + defaultThreshold: payload[2] ?? 0, + }; +} + +/** + * A 0x2111 write carries all three bytes, so each setter reads the trio first + * and changes only its own field. A write that drops the companion silently + * discards a setting it was never asked to touch. + */ +export function buildRatchetControlWrite( + current: LogitechRatchetControl, + change: { mode?: LogitechWheelMode; threshold?: number }, +): number[] { + const mode = change.mode ?? current.mode; + const encoded = mode === "Freespin" ? LOGITECH_WHEEL_MODE.freespin : LOGITECH_WHEEL_MODE.ratchet; + return [encoded, (change.threshold ?? current.threshold) & 0xff, current.defaultThreshold & 0xff]; +} + +/** + * getCapabilities is [multiplier, flags]. Reading those two the other way + * round yields a nonsense multiplier of 28 on a wheel whose real multiplier + * is 15. + */ +export function decodeHiresWheelCapabilities( + payload: Uint8Array | readonly number[], +): { multiplier: number; supportsInvert: boolean } | null { + const multiplier = payload[0]; + const flags = payload[1]; + if (multiplier === undefined || flags === undefined) return null; + return { multiplier, supportsInvert: (flags & LOGITECH_HIRES_WHEEL_SUPPORTS_INVERT) !== 0 }; +} + +export function decodeHiresWheelMode(modeByte: number): { hiRes: boolean; inverted: boolean; diverted: boolean } { + return { + hiRes: (modeByte & LOGITECH_HIRES_WHEEL_BIT.hiRes) !== 0, + inverted: (modeByte & LOGITECH_HIRES_WHEEL_BIT.invert) !== 0, + diverted: (modeByte & LOGITECH_HIRES_WHEEL_BIT.divert) !== 0, + }; +} + +/** Sets one bit and carries every other through, diversion included. */ +export function encodeHiresWheelMode(modeByte: number, bit: number, on: boolean): number { + return (on ? modeByte | bit : modeByte & ~bit) & 0xff; +} + +/** + * Thumb-wheel capabilities are the TWO-byte field at payload[4..5] — an + * MX Master 4 answers 0x0003 there and demonstrably honours inversion, which + * reading the single byte at payload[4] would have reported as unsupported. + */ +export function decodeThumbWheelSupportsInvert(payload: Uint8Array | readonly number[]): boolean | null { + const high = payload[4]; + const low = payload[5]; + if (high === undefined || low === undefined) return null; + return (((high << 8) | low) & LOGITECH_THUMB_WHEEL_SUPPORTS_INVERT) !== 0; +} + +export function decodeThumbWheelStatus( + payload: Uint8Array | readonly number[], +): { diverted: boolean; inverted: boolean } | null { + const diverted = payload[0]; + const inverted = payload[1]; + if (diverted === undefined || inverted === undefined) return null; + return { diverted: diverted !== 0, inverted: inverted !== 0 }; +} + +/** + * Logi Options+ sets the diversion bit to implement horizontal scrolling, so a + * write that clears it takes that away. Diversion is preserved from the read. + */ +export function buildThumbWheelWrite(current: { diverted: boolean }, inverted: boolean): number[] { + return [current.diverted ? 1 : 0, inverted ? 1 : 0]; +} From fe482e28a8bd091162cbfe2ba19fc6ccdf4437f0 Mon Sep 17 00:00:00 2001 From: Nick Welter Date: Tue, 11 Aug 2026 09:32:56 -0400 Subject: [PATCH 5/6] fix(logitech): confirm a thumb-wheel write by re-reading it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0x2150 does not echo the values it was given — unlike 0x2111, 0x2121 and 0x19B0, its write reply carries nothing. Confirming from that reply read the empty payload as "not inverted", so restoring the direction appeared to succeed and inverting it appeared to fail, while the device had in fact applied both. Found on hardware, not by the suite: the fake this was first written against echoed the written values, so the test agreed with the assumption instead of with the mouse. The new fake answers an empty payload the way the device does, and three of its four tests fail against the previous implementation. --- src/drivers/logitech/hidpp.ts | 20 ++-- src/drivers/logitech/thumb-wheel.test.ts | 146 +++++++++++++++++++++++ 2 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 src/drivers/logitech/thumb-wheel.test.ts diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index 1708b0a..5d957b1 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -1946,16 +1946,20 @@ export class LogitechHidppClient { const status = decodeThumbWheelStatus((await this.request(feature.index, LOGITECH_THUMB_WHEEL.get)).slice(3)); if (!status) throw new Error("The mouse gave no answer when its thumb wheel was read."); - const reply = await this.request( - feature.index, - LOGITECH_THUMB_WHEEL.set, - ...buildThumbWheelWrite(status, inverted), - ); - const confirmed = decodeThumbWheelStatus(reply.slice(3)); - if (confirmed?.inverted !== inverted) { + await this.request(feature.index, LOGITECH_THUMB_WHEEL.set, ...buildThumbWheelWrite(status, inverted)); + + /* + * Confirmed by re-reading rather than from the write's own reply. Unlike + * 0x2111, 0x2121 and 0x19B0, this feature does not echo the values it was + * given — its reply reads as all zeros, which made a write of "not + * inverted" appear to succeed and "inverted" appear to fail while both + * had actually taken effect. + */ + const after = decodeThumbWheelStatus((await this.request(feature.index, LOGITECH_THUMB_WHEEL.get)).slice(3)); + if (after?.inverted !== inverted) { throw new Error("The mouse kept its previous thumb-wheel direction."); } - return confirmed.inverted; + return after.inverted; } /** The editable name, or null on a device without feature 0x0007. */ diff --git a/src/drivers/logitech/thumb-wheel.test.ts b/src/drivers/logitech/thumb-wheel.test.ts new file mode 100644 index 0000000..fc251ca --- /dev/null +++ b/src/drivers/logitech/thumb-wheel.test.ts @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { withSoftwareId } from "@openmouse/protocol/logitech"; + +import { LogitechHidppClient } from "./hidpp.ts"; + +(globalThis as unknown as { window: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout } }).window = { + setTimeout, + clearTimeout, +}; + +const THUMB_INDEX = 0x13; + +/** + * A receiver whose 0x2150 write reply carries nothing, which is how a real + * MX Master 4 behaves — unlike 0x2111, 0x2121 and 0x19B0, this feature does + * not echo the values it was given. + * + * Confirming from that reply read the empty payload as "not inverted", so + * setting inversion off appeared to succeed and setting it on appeared to + * fail, while the device had in fact applied both. + */ +function thumbResponder(state: { diverted: boolean; inverted: boolean }) { + const FEATURES: Record = { 0x0003: 0x02, 0x2201: 0x14, 0x2150: THUMB_INDEX }; + return (request: Uint8Array): Uint8Array | null => { + const deviceIndex = request[0]; + const featureIndex = request[1]; + const functionByte = request[2]; + const functionId = functionByte & 0xf0; + const answer = (data: number[]): Uint8Array => + new Uint8Array([deviceIndex, featureIndex, functionByte, ...data]); + + if (deviceIndex !== 0x02) { + return new Uint8Array([deviceIndex, 0x8f, featureIndex, functionByte, 0x08, 0]); + } + if (featureIndex === 0x00) { + if (functionId === 0x00) { + const featureId = (request[3] << 8) | request[4]; + return answer([FEATURES[featureId] ?? 0x00, 0x00, 0x02]); + } + return answer([0x04, 0x05, request[5] ?? 0]); + } + if (featureIndex === THUMB_INDEX) { + // getThumbwheelInfo — capability 0x0003 at [4..5], inversion supported. + if (functionId === 0x00) return answer([0x00, 0x14, 0x00, 0x78, 0x00, 0x03, 0x03, 0xe8]); + if (functionId === 0x10) return answer([state.diverted ? 1 : 0, state.inverted ? 1 : 0]); + if (functionId === 0x20) { + state.diverted = (request[3] ?? 0) !== 0; + state.inverted = (request[4] ?? 0) !== 0; + // The device applies the write and answers with an empty payload. + return answer([]); + } + } + return answer([0x00]); + }; +} + +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 answer = this.onRequest(data.slice()); + if (answer) { + queueMicrotask(() => { + this.listeners.get("inputreport")?.({ + reportId, + data: new DataView(answer.buffer.slice(answer.byteOffset, answer.byteOffset + answer.byteLength)), + }); + }); + } + } +} + +async function harness(initial: { diverted: boolean; inverted: boolean }) { + const state = { ...initial }; + const device = new FakeHidDevice(); + device.onRequest = thumbResponder(state); + const client = new LogitechHidppClient(device as unknown as HIDDevice) as unknown as { + open(): Promise; + resolveDeviceIndex(): Promise; + setThumbWheelInverted(inverted: boolean): Promise; + }; + await client.open(); + await client.resolveDeviceIndex(); + return { client, state }; +} + +test("inverting the thumb wheel is confirmed even though the write echoes nothing", async () => { + const { client, state } = await harness({ diverted: true, inverted: false }); + assert.equal(await client.setThumbWheelInverted(true), true); + assert.equal(state.inverted, true); +}); + +test("restoring the thumb wheel is confirmed the same way", async () => { + const { client, state } = await harness({ diverted: true, inverted: true }); + assert.equal(await client.setThumbWheelInverted(false), false); + assert.equal(state.inverted, false); +}); + +test("the diversion Logi Options+ set survives an inversion write", async () => { + // Options+ sets diversion to implement horizontal scrolling; clearing it + // silently takes that away. + const { client, state } = await harness({ diverted: true, inverted: false }); + await client.setThumbWheelInverted(true); + assert.equal(state.diverted, true, "diversion was cleared by an inversion write"); +}); + +test("a device that ignores the write is reported as a failure", async () => { + // The re-read is only worth doing if it can still fail. This device + // acknowledges the write and keeps its old value. + const state = { diverted: true, inverted: false }; + const responder = thumbResponder(state); + const device = new FakeHidDevice(); + device.onRequest = (request) => { + const reply = responder(request); + state.inverted = false; + return reply; + }; + const client = new LogitechHidppClient(device as unknown as HIDDevice) as unknown as { + open(): Promise; + resolveDeviceIndex(): Promise; + setThumbWheelInverted(inverted: boolean): Promise; + }; + await client.open(); + await client.resolveDeviceIndex(); + await assert.rejects(() => client.setThumbWheelInverted(true), /kept its previous/); +}); From 1c4661d41f8c3e2d889d5ff2590c3fb907ebae89 Mon Sep 17 00:00:00 2001 From: Nick Welter Date: Tue, 11 Aug 2026 10:04:35 -0400 Subject: [PATCH 6/6] perf(logitech): stop re-reading MX Master 4 state that cannot change A status refresh was making 35 round-trips, 19 of them added by the new features. On a wireless mouse that is enough for some reads to time out, and a timed-out read returns null, which surfaced as controls vanishing from the interface and reappearing a few seconds later. Easy-Switch and the friendly name are now read once per connection and cleared on close. The slot count is fixed and the current slot changing IS the connection ending, because that is what switching host does; the name only moves when something renames it, and this client drops its entry after its own write. Whether the wheel can invert is a property of the hardware, so that is read once too. 25 round-trips per refresh now, measured the same way. A failed read is deliberately left uncached so the next refresh retries, rather than one transient timeout hiding a control for the whole session. --- src/drivers/logitech/hidpp.ts | 64 +++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index 5d957b1..46745df 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -768,6 +768,11 @@ export class LogitechHidppClient { } this.listeningDevices.clear(); this.ioDevice = null; + // A device can come back on a different slot, or be a different mouse + // entirely, so nothing read this session survives the disconnect. + this.hostStateCache = undefined; + this.friendlyNameCache = undefined; + this.wheelCapabilityCache = undefined; } async setPollingRate(pollingRateHz: number): Promise { @@ -1821,12 +1826,16 @@ export class LogitechHidppClient { const wheel = await this.getFeature(FEATURE.hiresWheel); let hiResScroll: boolean | null = null; let invertScroll: boolean | null = null; - let supportsInvertScroll = false; + let supportsInvertScroll = this.wheelCapabilityCache?.supportsInvertScroll ?? false; let wheelRatchetEngaged: boolean | null = null; if (wheel.index) { - const capabilityReply = await this.request(wheel.index, LOGITECH_HIRES_WHEEL.capabilities).catch(() => null); - const capabilities = capabilityReply ? decodeHiresWheelCapabilities(capabilityReply.slice(3)) : null; - supportsInvertScroll = capabilities?.supportsInvert ?? false; + // Whether the wheel can invert is a property of the hardware, so it is + // read once rather than on every refresh. + if (this.wheelCapabilityCache === undefined) { + const capabilityReply = await this.request(wheel.index, LOGITECH_HIRES_WHEEL.capabilities).catch(() => null); + const capabilities = capabilityReply ? decodeHiresWheelCapabilities(capabilityReply.slice(3)) : null; + supportsInvertScroll = capabilities?.supportsInvert ?? false; + } const modeReply = await this.request(wheel.index, LOGITECH_HIRES_WHEEL.get).catch(() => null); if (modeReply) { @@ -1840,6 +1849,11 @@ export class LogitechHidppClient { } const thumb = await this.readThumbWheelState(); + // Seeded once both halves have been read, so a refresh never asks again. + this.wheelCapabilityCache ??= { + supportsInvertScroll, + supportsThumbWheelInvert: thumb.supportsThumbWheelInvert, + }; return { wheelMode: ratchet?.mode ?? null, @@ -1859,8 +1873,11 @@ export class LogitechHidppClient { const feature = await this.getFeature(FEATURE.thumbWheel); if (!feature.index) return { thumbWheelInverted: null, supportsThumbWheelInvert: false }; - const info = await this.request(feature.index, LOGITECH_THUMB_WHEEL.info).catch(() => null); - const supports = info ? decodeThumbWheelSupportsInvert(info.slice(3)) === true : false; + let supports = this.wheelCapabilityCache?.supportsThumbWheelInvert ?? false; + if (this.wheelCapabilityCache === undefined) { + const info = await this.request(feature.index, LOGITECH_THUMB_WHEEL.info).catch(() => null); + supports = info ? decodeThumbWheelSupportsInvert(info.slice(3)) === true : false; + } const status = await this.request(feature.index, LOGITECH_THUMB_WHEEL.get).catch(() => null); const decoded = status ? decodeThumbWheelStatus(status.slice(3)) : null; return { thumbWheelInverted: decoded?.inverted ?? null, supportsThumbWheelInvert: supports }; @@ -1964,13 +1981,15 @@ export class LogitechHidppClient { /** The editable name, or null on a device without feature 0x0007. */ private async readFriendlyName(): Promise<{ name: string; maxLength: number } | null> { + if (this.friendlyNameCache !== undefined) return this.friendlyNameCache; + const feature = await this.getFeature(FEATURE.friendlyName); - if (!feature.index) return null; + if (!feature.index) return (this.friendlyNameCache = null); const header = await this.request(feature.index, LOGITECH_FRIENDLY_NAME.lengths).catch(() => null); const lengths = header ? decodeFriendlyNameLengths(header.slice(3)) : null; if (!lengths) return null; - if (lengths.length === 0) return { name: "", maxLength: lengths.maxLength }; + if (lengths.length === 0) return (this.friendlyNameCache = { name: "", maxLength: lengths.maxLength }); const characters: number[] = []; while (characters.length < lengths.length) { @@ -1979,7 +1998,7 @@ export class LogitechHidppClient { if (decoded.length === 0) break; characters.push(...decoded); } - return { name: decodeFriendlyNameText(characters), maxLength: lengths.maxLength }; + return (this.friendlyNameCache = { name: decodeFriendlyNameText(characters), maxLength: lengths.maxLength }); } /** @@ -2004,6 +2023,9 @@ export class LogitechHidppClient { await this.requestLong(feature.index, LOGITECH_FRIENDLY_NAME.set, buildFriendlyNameWrite(name)); + // The confirmation has to reach the mouse, not the value from before this + // write — a cache answering here would confirm nothing at all. + this.friendlyNameCache = undefined; const confirmed = await this.readFriendlyName(); if (confirmed?.name !== name.trim()) { throw new Error(`The mouse kept the name "${confirmed?.name ?? ""}".`); @@ -2020,11 +2042,15 @@ export class LogitechHidppClient { info: LogitechHostsInfo; paired: boolean[]; } | null> { + if (this.hostStateCache !== undefined) return this.hostStateCache; + const feature = await this.getFeature(FEATURE.hostsInfo); - if (!feature.index) return null; + if (!feature.index) return (this.hostStateCache = null); const reply = await this.request(feature.index, LOGITECH_HOSTS.info).catch(() => null); const info = reply ? decodeHostsInfo(reply.slice(3)) : null; + // A failed read is left uncached so the next refresh tries again, rather + // than a transient timeout hiding the control for the whole session. if (!info) return null; const paired: boolean[] = []; @@ -2034,7 +2060,7 @@ export class LogitechHidppClient { // switchable — this has to fail towards refusing the switch. paired.push(entry ? decodeHostPaired(entry.slice(3)) === true : false); } - return { info, paired }; + return (this.hostStateCache = { info, paired }); } /** @@ -2339,6 +2365,22 @@ export class LogitechHidppClient { return confirmed; } + /** + * Values that cannot change while a connection is open. Easy-Switch is the + * clearest case: the slot count is fixed and the current slot changing IS + * the connection ending, because that is what switching host does. The + * friendly name only moves when something renames it, and this client drops + * the entry after its own write. + * + * Re-reading these was costing six round-trips of radio every refresh, on + * top of a feature lookup per read. On a wireless mouse that is enough for + * some reads to time out, which surfaces as controls vanishing and coming + * back a few seconds later. + */ + private hostStateCache: { info: LogitechHostsInfo; paired: boolean[] } | null | undefined; + private friendlyNameCache: { name: string; maxLength: number } | null | undefined; + private wheelCapabilityCache: { supportsInvertScroll: boolean; supportsThumbWheelInvert: boolean } | undefined; + private async getFeature(featureId: number): Promise { const reply = await this.request(0x00, 0x00, featureId >> 8, featureId & 0xff); const feature = { index: reply[3] ?? 0, version: reply[6] ?? 0 };