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/hidpp.ts b/src/drivers/logitech/hidpp.ts index fcd00d2..46745df 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -10,6 +10,43 @@ 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, + isLogitechHapticEffect, + isLogitechHapticIntensity, + type LogitechHapticConfig, + type LogitechHapticFlag, DEVICE_INDEX_DIRECT, decodeBatteryLevelState, decodeReportRateBitmap, @@ -208,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, @@ -225,6 +268,13 @@ const FEATURE = { reportRate: 0x8060, onboardProfiles: 0x8100, analogButtons: 0x1b0c, + haptic: 0x19b0, + smartShift: 0x2111, + hiresWheel: 0x2121, + thumbWheel: 0x2150, + friendlyName: 0x0007, + hostsInfo: 0x1815, + changeHost: 0x1814, } as const; interface ResolvedFeature { @@ -596,6 +646,10 @@ export class LogitechHidppClient { const analogButtonTuning = analogButtonsFeature.index ? 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. @@ -662,6 +716,15 @@ 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, gamingSurfaceMode: modeStatus === null || !hasLiveLiftOffControl ? null : decodeModeStatus(modeStatus, MODE_STATUS.gamingSurface), @@ -705,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 { @@ -1693,6 +1761,400 @@ 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; + } + + /** + * 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 = this.wheelCapabilityCache?.supportsInvertScroll ?? false; + let wheelRatchetEngaged: boolean | null = null; + if (wheel.index) { + // 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) { + 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(); + // Seeded once both halves have been read, so a refresh never asks again. + this.wheelCapabilityCache ??= { + supportsInvertScroll, + supportsThumbWheelInvert: thumb.supportsThumbWheelInvert, + }; + + 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 }; + + 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 }; + } + + /** 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."); + + 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 after.inverted; + } + + /** 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 (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 (this.friendlyNameCache = { 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 (this.friendlyNameCache = { 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)); + + // 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 ?? ""}".`); + } + 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> { + if (this.hostStateCache !== undefined) return this.hostStateCache; + + const feature = await this.getFeature(FEATURE.hostsInfo); + 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[] = []; + 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 (this.hostStateCache = { 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)) { + 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; @@ -1903,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 }; 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/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/); +}); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 817795a..74c4a66 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -196,6 +196,40 @@ 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. */ + /** + * 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; + /** 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. */ 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/haptics.test.ts b/src/logitech/haptics.test.ts new file mode 100644 index 0000000..7380a52 --- /dev/null +++ b/src/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.js"; + +/** 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/logitech/haptics.ts b/src/logitech/haptics.ts new file mode 100644 index 0000000..93c618f --- /dev/null +++ b/src/logitech/haptics.ts @@ -0,0 +1,148 @@ +/** + * 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; + +/* + * 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; + 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/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 80bac4b..65e9aac 100644 --- a/src/logitech/index.ts +++ b/src/logitech/index.ts @@ -1,3 +1,8 @@ +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. const SOFTWARE_ID = 0x05; 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]; +}