From 9960ce463554370b6d9ba0bc6cb5276f94b08ecf Mon Sep 17 00:00:00 2001 From: Nick Welter Date: Tue, 11 Aug 2026 22:58:48 -0400 Subject: [PATCH] MX Master 4: button remapping (0x1B04) Adds feature 0x1B04 (REPROG CONTROLS V4): read the control table, point one control at another, and hand diverted controls back to the hardware. Codec in src/logitech/controls.ts, driver methods on LogitechHidppClient. Follow-up to #9, whose scope held button remapping back to stay reviewable. This remaps control to control only. It does not implement Options+-style actions like launching an app; Options+ does those by diverting the button and handling it in its own software, which nothing here receives. So diversion is exposed only to be cleared, never set. The device decides what is legal: every control reports a group and a mask of the groups it accepts, and the primary buttons report a mask of zero, so the firmware refuses to move them rather than any rule here. They still appear as targets for other controls, because any control in an accepted group is a legal destination even when it is not a legal source. Two decode details. The mapping flags are one 16-bit field split across two non-adjacent bytes (low at payload[2], high at payload[5], with the remap target between), so reading them as a contiguous pair mistakes a button remapped to a high control id for a diverted one. And each mapping flag is a value plus a valid bit one position higher, so a pure remap writes a zero flags byte and leaves diversion untouched; clearing diversion sets only the valid bits, so it can only ever turn diversion off. A control merges getControlIdInfo (key flags) with getControlIdReporting (the 16-bit mapping field); those are named apart (flags vs mappingFlags) so neither overwrites the other, and virtual/reprogrammable are exposed as booleans. Verified on an MX Master 4 over a Logi Bolt receiver. Codec tests cover every byte layout above; fake-HID driver tests cover read-back, refusal of illegal or absent controls, the acknowledge-but-ignore path, and restoring diversion without disturbing an existing mapping. --- docs/logitech-testing.md | 22 ++ src/drivers/logitech/buttons.test.ts | 371 +++++++++++++++++++++++++++ src/drivers/logitech/hidpp.ts | 147 +++++++++++ src/logitech/controls.test.ts | 177 +++++++++++++ src/logitech/controls.ts | 236 +++++++++++++++++ src/logitech/index.ts | 1 + 6 files changed, 954 insertions(+) create mode 100644 src/drivers/logitech/buttons.test.ts create mode 100644 src/logitech/controls.test.ts create mode 100644 src/logitech/controls.ts diff --git a/docs/logitech-testing.md b/docs/logitech-testing.md index dea98ad..15cf2f8 100644 --- a/docs/logitech-testing.md +++ b/docs/logitech-testing.md @@ -176,3 +176,25 @@ lift-off and onboard-profile paths stay inactive. 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. + +### Button remapping (`0x1B04`) + +This unit reports nine controls. Left and right click report a group mask of +zero, so the firmware itself offers no targets for them. + +Reading the table costs two round-trips per control, so it is deliberately not +part of the status refresh: read it on connect and after a write. + +1. Confirm all nine controls are listed, and that **left and right click offer + no targets at all** — that restriction comes from the device, so a mouse + that started offering them would mean the group mask is being misread. +2. Remap a button — the gesture button to middle click, say — and confirm the + physical button performs the new action. Reload and confirm it persisted. +3. Set it back to itself and confirm the original action returns. +4. Confirm a remap does not change any button's diverted state. The write + marks no flag valid, so diversion should be untouched either way. +5. With Logi Options+ **running**, confirm the buttons it has taken over read + as diverted. Close Options+ and confirm the mouse clears that itself — + Options+ uses the temporary flag, not the persistent one. +6. Use "restore to hardware control" on a mouse with nothing diverted and + confirm it writes nothing rather than rewriting every control. diff --git a/src/drivers/logitech/buttons.test.ts b/src/drivers/logitech/buttons.test.ts new file mode 100644 index 0000000..ed0677a --- /dev/null +++ b/src/drivers/logitech/buttons.test.ts @@ -0,0 +1,371 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { LogitechHidppClient } from "./hidpp.ts"; + +(globalThis as unknown as { window: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout } }).window = { + setTimeout, + clearTimeout, +}; + +const CONTROLS_INDEX = 0x0a; + +/** + * getControlIdInfo payloads captured from an MX Master 4 over a Logi Bolt + * receiver: [cid(2), tid(2), flags, pos, group, gmask, additional]. + */ +const CONTROL_TABLE = [ + [0x00, 0x50, 0x00, 0x38, 0x01, 0x00, 0x01, 0x00, 0x04], // Left click + [0x00, 0x51, 0x00, 0x39, 0x01, 0x00, 0x01, 0x00, 0x04], // Right click + [0x00, 0x52, 0x00, 0x3a, 0x31, 0x00, 0x02, 0x03, 0x05], // Middle click + [0x00, 0x53, 0x00, 0x3c, 0x31, 0x00, 0x02, 0x03, 0x05], // Back + [0x00, 0x56, 0x00, 0x3e, 0x31, 0x00, 0x02, 0x03, 0x05], // Forward + [0x00, 0xc3, 0x00, 0x9c, 0x31, 0x00, 0x02, 0x03, 0x05], // Gesture button + [0x00, 0xc4, 0x00, 0x9d, 0x31, 0x00, 0x02, 0x03, 0x05], // SmartShift button + [0x01, 0xa0, 0x01, 0x09, 0x31, 0x00, 0x02, 0x03, 0x05], // Actions Ring + [0x00, 0xd7, 0x00, 0xb4, 0xa0, 0x00, 0x03, 0x00, 0x03], // Virtual gesture button +]; + +interface ControlState { mappedTo: number; flags: number } + +interface DeviceState { + controls: Map; + /** Requests to the controls feature, so a test can assert what was NOT sent. */ + calls: Array<{ featureIndex: number; functionId: number; payload: number[] }>; + /** Every request of any kind, including the root-feature lookups. */ + allCalls: Array<{ featureIndex: number; functionId: number }>; +} + +function initialState(overrides: Record> = {}): DeviceState { + const controls = new Map(); + for (const row of CONTROL_TABLE) { + const controlId = (row[0]! << 8) | row[1]!; + controls.set(controlId, { mappedTo: controlId, flags: 0, ...overrides[controlId] }); + } + return { controls, calls: [], allCalls: [] }; +} + +/** + * A receiver exposing 0x1B04. + * + * Its write handler implements the protocol's two-bit flag semantics — a flag + * changes only when its "valid" bit, one position higher, is set — rather than + * applying whatever byte it is handed. A fake that applied the byte directly + * would let a wrong flags value pass unnoticed, which is exactly how the + * 0x2150 echo bug survived a green suite. + * + * The reply to a write deliberately carries no payload. Nothing here reads it: + * every write is confirmed by re-reading the reporting. + */ +function controlsResponder( + state: DeviceState, + options: { + present?: boolean; sticky?: boolean; resetsMappingOnClear?: boolean; + truncatedInfoAt?: number; truncatedReportingFor?: number; + } = {}, +) { + const present = options.present ?? true; + const FEATURES: Record = { 0x0003: 0x02, 0x2201: 0x14 }; + if (present) FEATURES[0x1b04] = CONTROLS_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]); + + state.allCalls.push({ featureIndex, functionId }); + + 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 === CONTROLS_INDEX) { + state.calls.push({ featureIndex, functionId, payload: [...request.slice(3)] }); + + if (functionId === 0x00) return answer([CONTROL_TABLE.length]); + + if (functionId === 0x10) { + const row = CONTROL_TABLE[request[3]!] ?? []; + // Firmware that answers short: without group and mask there is no way + // to know what the control accepts. + return answer(request[3]! === options.truncatedInfoAt ? row.slice(0, 5) : row); + } + + if (functionId === 0x20) { + const controlId = (request[3]! << 8) | request[4]!; + const control = state.controls.get(controlId); + if (!control) return answer([]); + // Short of the high flags byte, so the mapping field cannot be read. + if (controlId === options.truncatedReportingFor) { + return answer([request[3]!, request[4]!, 0x00, 0x00, 0x00]); + } + // [cid(2), flagsLow, remap(2), flagsHigh] — the remap target sits + // between the two halves of the mapping field. + return answer([ + request[3]!, request[4]!, + control.flags & 0xff, + control.mappedTo >> 8, control.mappedTo & 0xff, + (control.flags >> 8) & 0xff, + ]); + } + + if (functionId === 0x30) { + const controlId = (request[3]! << 8) | request[4]!; + const written = request[5]!; + const target = (request[6]! << 8) | request[7]!; + const control = state.controls.get(controlId); + if (control && !options.sticky) { + // A flag changes only when its valid bit is set; the value bit one + // position lower carries the new state. + for (const flag of [0x01, 0x04]) { + if ((written & (flag << 1)) !== 0) { + control.flags = (written & flag) !== 0 ? control.flags | flag : control.flags & ~flag; + } + } + // A target of zero leaves the existing mapping alone. + if (target !== 0) control.mappedTo = target; + // Firmware that treats a reporting write as a full reset, dropping + // the remap along with the diversion it was asked to clear. + else if (options.resetsMappingOnClear) control.mappedTo = controlId; + } + 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)), + }); + }); + } + } +} + +type ButtonClient = { + open(): Promise; + resolveDeviceIndex(): Promise; + readButtons(): Promise>; + setButtonMapping(controlId: number, targetControlId: number): Promise; + clearButtonDiversion(): Promise; +}; + +async function harness( + overrides: Record> = {}, + options: { + present?: boolean; sticky?: boolean; resetsMappingOnClear?: boolean; + truncatedInfoAt?: number; truncatedReportingFor?: number; + } = {}, +) { + const state = initialState(overrides); + const device = new FakeHidDevice(); + device.onRequest = controlsResponder(state, options); + const client = new LogitechHidppClient(device as unknown as HIDDevice) as unknown as ButtonClient; + await client.open(); + await client.resolveDeviceIndex(); + // Discovery probes the device index across several slots; a test asking what + // a method sent should not have to subtract that. + state.calls.length = 0; + state.allCalls.length = 0; + return { client, state }; +} + +const writes = (state: DeviceState) => state.calls.filter((call) => call.functionId === 0x30); + +test("every control is read back with its name, mapping and legal targets", async () => { + const { client } = await harness(); + const controls = await client.readButtons(); + assert.equal(controls.length, CONTROL_TABLE.length); + + const gesture = controls.find((control) => control.controlId === 0x00c3)!; + assert.equal(gesture.name, "Gesture button"); + assert.equal(gesture.reprogrammable, true); + assert.equal(gesture.mappedTo, 0x00c3); + assert.ok(gesture.remappableTo.includes(0x0052)); +}); + +test("the key flags survive the reporting merge, so virtual is read from the right field", async () => { + // Both control info and reporting carry a numeric field; the merge must keep + // the key flags, or the virtual bit reads as clear on every control. + const { client } = await harness(); + const controls = await client.readButtons(); + const virtual = controls.find((control) => control.controlId === 0x00d7)!; + assert.equal(virtual.virtual, true, "the virtual gesture button lost its key flags in the merge"); + const physical = controls.find((control) => control.controlId === 0x00c3)!; + assert.equal(physical.virtual, false); +}); + +test("the primary buttons come back with nothing to remap them to", async () => { + const { client } = await harness(); + const controls = await client.readButtons(); + for (const controlId of [0x0050, 0x0051]) { + const control = controls.find((candidate) => candidate.controlId === controlId)!; + assert.equal(control.reprogrammable, false); + assert.deepEqual(control.remappableTo, []); + } +}); + +test("the control table is read once per connection, the reporting every time", async () => { + // Two round-trips per control is the reason this is not on the refresh poll; + // re-reading the immutable half would make it three. + const { client, state } = await harness(); + await client.readButtons(); + const firstInfoReads = state.calls.filter((call) => call.functionId === 0x10).length; + const firstReportingReads = state.calls.filter((call) => call.functionId === 0x20).length; + assert.equal(firstInfoReads, CONTROL_TABLE.length); + assert.equal(firstReportingReads, CONTROL_TABLE.length); + + await client.readButtons(); + assert.equal( + state.calls.filter((call) => call.functionId === 0x10).length, + firstInfoReads, + "the control table was read again", + ); + assert.equal(state.calls.filter((call) => call.functionId === 0x20).length, firstReportingReads * 2); +}); + +test("remapping a button changes what the device reports", async () => { + const { client, state } = await harness(); + const controls = await client.setButtonMapping(0x00c3, 0x0052) as Array<{ + controlId: number; mappedTo: number; + }>; + assert.equal(state.controls.get(0x00c3)!.mappedTo, 0x0052); + assert.equal(controls.find((control) => control.controlId === 0x00c3)!.mappedTo, 0x0052); +}); + +test("a remap leaves an existing diversion exactly as it was", async () => { + // The write carries a zero flags byte, which marks no flag valid. A device + // that took the byte at face value would read it as "not diverted". + const { client, state } = await harness({ 0x00c3: { flags: 0x0001 } }); + await client.setButtonMapping(0x00c3, 0x0052); + assert.equal(state.controls.get(0x00c3)!.flags, 0x0001, "the remap cleared the diversion"); +}); + +test("an illegal target is refused before anything is written", async () => { + const { client, state } = await harness(); + // Left click's group mask is zero, so the firmware offers no targets at all. + await assert.rejects(() => client.setButtonMapping(0x0050, 0x0052), /cannot be remapped/); + assert.equal(writes(state).length, 0, "a refused remap still reached the device"); +}); + +test("a control the mouse does not have is refused before anything is written", async () => { + const { client, state } = await harness(); + await assert.rejects(() => client.setButtonMapping(0x00e5, 0x0052), /not present on this mouse/); + assert.equal(writes(state).length, 0); +}); + +test("a device that acknowledges a remap and ignores it is reported as a failure", async () => { + // The read-back is only worth doing if it can still fail. + const { client } = await harness({}, { sticky: true }); + await assert.rejects(() => client.setButtonMapping(0x00c3, 0x0052), /kept Gesture button pointing at/); +}); + +test("a diverted button is handed back to the hardware", async () => { + const { client, state } = await harness({ + 0x00c3: { flags: 0x0001 }, + 0x0053: { flags: 0x0004 }, + }); + const controls = await client.clearButtonDiversion() as Array<{ diverted: boolean }>; + assert.equal(state.controls.get(0x00c3)!.flags, 0); + assert.equal(state.controls.get(0x0053)!.flags, 0, "persistent diversion was left in place"); + assert.ok(controls.every((control) => !control.diverted)); + assert.equal(writes(state).length, 2, "only the diverted controls should be written"); +}); + +test("clearing diversion leaves a remapped button pointing where it was", async () => { + const { client, state } = await harness({ 0x00c3: { flags: 0x0001, mappedTo: 0x0052 } }); + await client.clearButtonDiversion(); + assert.equal(state.controls.get(0x00c3)!.mappedTo, 0x0052, "the clear write moved the mapping"); +}); + +test("a clear that silently drops a remap is reported, not returned as success", async () => { + // Restoring a button must not cost the user what that button was set to. + const { client } = await harness( + { 0x00c3: { flags: 0x0001, mappedTo: 0x0052 } }, + { resetsMappingOnClear: true }, + ); + await assert.rejects( + () => client.clearButtonDiversion(), + /Restoring Gesture button unexpectedly changed what it does/, + ); +}); + +test("nothing is written when no button is diverted", async () => { + const { client, state } = await harness(); + await client.clearButtonDiversion(); + assert.equal(writes(state).length, 0); +}); + +test("a button that stays diverted is reported rather than assumed cleared", async () => { + const { client } = await harness({ 0x00c3: { flags: 0x0001 } }, { sticky: true }); + await assert.rejects(() => client.clearButtonDiversion(), /kept Gesture button diverted/); +}); + +test("a control the mouse will not describe is dropped, not half-decoded", async () => { + // Its group mask is what says which targets are legal; guessing at one + // offers the user a remap the firmware will refuse. + const { client } = await harness({}, { truncatedInfoAt: 3 }); + const controls = await client.readButtons(); + assert.equal(controls.length, CONTROL_TABLE.length - 1); + assert.ok(!controls.some((control) => control.controlId === 0x0053)); + assert.ok(controls.every((control) => typeof control.name === "string")); +}); + +test("a control whose reporting will not decode is left out of the list", async () => { + // Reported with a default mapping it may not have, the card would offer to + // "restore" a button to something it was never set to. + const { client } = await harness({}, { truncatedReportingFor: 0x00c3 }); + const controls = await client.readButtons(); + assert.equal(controls.length, CONTROL_TABLE.length - 1); + assert.ok(!controls.some((control) => control.controlId === 0x00c3)); +}); + +test("a mouse without 0x1B04 is asked once and then left alone", async () => { + const { client, state } = await harness({}, { present: false }); + assert.deepEqual(await client.readButtons(), []); + // Only the root-feature lookup. Without the guard the client goes on to + // read a control count from feature index 0, which is not that feature. + assert.deepEqual(state.allCalls.map((call) => call.featureIndex), [0x00]); + await assert.rejects(() => client.setButtonMapping(0x00c3, 0x0052), /does not expose reprogrammable controls/); + await assert.rejects(() => client.clearButtonDiversion(), /does not expose reprogrammable controls/); +}); diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index a1baee8..931665b 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -12,6 +12,17 @@ import { import { LOGITECH_CHANGE_HOST, LOGITECH_FRIENDLY_NAME, + LOGITECH_KEY_FLAG, + LOGITECH_REPROG_CONTROLS, + buildControlDiversionClearWrite, + buildControlRemapWrite, + decodeControlInfo, + decodeControlReporting, + logitechControlName, + logitechTaskName, + remappableControlTargets, + type LogitechControlInfo, + type LogitechReprogrammableControl, LOGITECH_HAPTIC, LOGITECH_HAPTIC_EFFECTS, LOGITECH_HIRES_WHEEL, @@ -281,6 +292,7 @@ const FEATURE = { friendlyName: 0x0007, hostsInfo: 0x1815, changeHost: 0x1814, + reprogControls: 0x1b04, } as const; interface ResolvedFeature { @@ -801,6 +813,7 @@ export class LogitechHidppClient { // 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.controlInfoCache = undefined; this.friendlyNameCache = undefined; this.wheelCapabilityCache = undefined; this.rgbZone = null; @@ -2166,6 +2179,139 @@ export class LogitechHidppClient { } } + /** + * Reads every reprogrammable control, what it currently does, and where it + * may legally be pointed. + * + * Two round-trips per control, which is why this is not part of readStatus: + * on an MX Master 4 that is eighteen more exchanges, enough on a wireless + * link to start timing other reads out. Call it on connect and after a + * write, not on a poll. + * + * The control table itself never changes for a given device, so it is read + * once per connection; only the reporting is re-read. + */ + async readButtons(): Promise { + const feature = await this.getFeature(FEATURE.reprogControls); + if (!feature.index) return []; + + if (this.controlInfoCache === undefined) { + const count = (await this.request(feature.index, LOGITECH_REPROG_CONTROLS.count))[3] ?? 0; + const infos: LogitechControlInfo[] = []; + for (let index = 0; index < count; index += 1) { + const reply = await this.request(feature.index, LOGITECH_REPROG_CONTROLS.info, index); + const info = decodeControlInfo(reply.slice(3)); + // A control that will not describe itself is dropped rather than + // guessed at: without its group mask there is no way to know what it + // may be remapped to, and offering a target the firmware refuses is + // worse than not offering the control at all. + if (info) infos.push(info); + } + this.controlInfoCache = infos; + } + const infos = this.controlInfoCache; + + const controls: LogitechReprogrammableControl[] = []; + for (const info of infos) { + const reply = await this.request( + feature.index, + LOGITECH_REPROG_CONTROLS.reporting, + info.controlId >> 8, + info.controlId & 0xff, + ); + const reporting = decodeControlReporting(reply.slice(3)); + if (!reporting) continue; + controls.push({ + ...info, + ...reporting, + name: logitechControlName(info.controlId), + taskName: logitechTaskName(info.taskId), + reprogrammable: (info.flags & LOGITECH_KEY_FLAG.reprogrammable) !== 0, + virtual: (info.flags & LOGITECH_KEY_FLAG.virtual) !== 0, + remappableTo: remappableControlTargets(info, infos), + }); + } + return controls; + } + + /** + * Points one button at another control's action. + * + * The device is asked what is legal before anything is written, and the + * result is read back afterwards — firmware that acknowledges a remap and + * keeps its old mapping would otherwise look like a success. + */ + async setButtonMapping( + controlId: number, + targetControlId: number, + ): Promise { + const feature = await this.getFeature(FEATURE.reprogControls); + if (!feature.index) throw new Error("This mouse does not expose reprogrammable controls."); + + const before = await this.readButtons(); + const control = before.find((candidate) => candidate.controlId === controlId); + if (!control) throw new Error("That control is not present on this mouse."); + if (!control.reprogrammable || !control.remappableTo.includes(targetControlId)) { + throw new Error(`${control.name} cannot be remapped to ${logitechControlName(targetControlId)}.`); + } + + await this.requestLong( + feature.index, + LOGITECH_REPROG_CONTROLS.setReporting, + buildControlRemapWrite(controlId, targetControlId), + ); + + const after = await this.readButtons(); + const confirmed = after.find((candidate) => candidate.controlId === controlId); + if (confirmed?.mappedTo !== targetControlId) { + throw new Error( + `The mouse kept ${control.name} pointing at ` + + `${logitechControlName(confirmed?.mappedTo ?? 0)}.`, + ); + } + return after; + } + + /** + * Hands every diverted button back to the hardware. + * + * A diverted button sends HID++ notifications instead of acting, which is + * how a vendor application implements behaviours of its own. Logi Options+ + * uses the temporary flag and the mouse clears that itself once Options+ + * stops — but the persistent flag survives, so a button left that way stays + * dead until something clears it. That something has to exist somewhere. + */ + async clearButtonDiversion(): Promise { + const feature = await this.getFeature(FEATURE.reprogControls); + if (!feature.index) throw new Error("This mouse does not expose reprogrammable controls."); + + const before = await this.readButtons(); + const diverted = before.filter((control) => control.diverted); + if (!diverted.length) return before; + + for (const control of diverted) { + await this.requestLong( + feature.index, + LOGITECH_REPROG_CONTROLS.setReporting, + buildControlDiversionClearWrite(control.controlId), + ); + } + + const after = await this.readButtons(); + const stuck = after.filter((control) => control.diverted); + if (stuck.length) { + throw new Error(`The mouse kept ${stuck.map((control) => control.name).join(", ")} diverted.`); + } + // Clearing a diversion must not have disturbed where a button points. + for (const control of before) { + const now = after.find((candidate) => candidate.controlId === control.controlId); + if (now && now.mappedTo !== control.mappedTo) { + throw new Error(`Restoring ${control.name} unexpectedly changed what it does.`); + } + } + return after; + } + /** Sets haptic strength, leaving the flag byte as the device reports it. */ async setHapticIntensity(intensity: number): Promise { if (!isLogitechHapticIntensity(intensity)) { @@ -2431,6 +2577,7 @@ export class LogitechHidppClient { * back a few seconds later. */ private hostStateCache: { info: LogitechHostsInfo; paired: boolean[] } | null | undefined; + private controlInfoCache: LogitechControlInfo[] | undefined; private friendlyNameCache: { name: string; maxLength: number } | null | undefined; private wheelCapabilityCache: { supportsInvertScroll: boolean; supportsThumbWheelInvert: boolean } | undefined; diff --git a/src/logitech/controls.test.ts b/src/logitech/controls.test.ts new file mode 100644 index 0000000..6b7ca91 --- /dev/null +++ b/src/logitech/controls.test.ts @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + LOGITECH_KEY_FLAG, + LOGITECH_MAPPING_FLAG, + buildControlDiversionClearWrite, + buildControlRemapWrite, + decodeControlInfo, + decodeControlReporting, + logitechControlName, + logitechTaskName, + remappableControlTargets, +} from "./controls.js"; + +/** + * Real getControlIdInfo payloads captured from an MX Master 4 over a Logi Bolt + * receiver, so these fail if the decoding drifts from actual hardware. + */ +const MX_MASTER_4_CONTROLS = [ + [0x00, 0x50, 0x00, 0x38, 0x01, 0x00, 0x01, 0x00, 0x04], // Left click + [0x00, 0x51, 0x00, 0x39, 0x01, 0x00, 0x01, 0x00, 0x04], // Right click + [0x00, 0x52, 0x00, 0x3a, 0x31, 0x00, 0x02, 0x03, 0x05], // Middle click + [0x00, 0x53, 0x00, 0x3c, 0x31, 0x00, 0x02, 0x03, 0x05], // Back + [0x00, 0x56, 0x00, 0x3e, 0x31, 0x00, 0x02, 0x03, 0x05], // Forward + [0x00, 0xc3, 0x00, 0x9c, 0x31, 0x00, 0x02, 0x03, 0x05], // Gesture button + [0x00, 0xc4, 0x00, 0x9d, 0x31, 0x00, 0x02, 0x03, 0x05], // SmartShift button + [0x01, 0xa0, 0x01, 0x09, 0x31, 0x00, 0x02, 0x03, 0x05], // Actions Ring + [0x00, 0xd7, 0x00, 0xb4, 0xa0, 0x00, 0x03, 0x00, 0x03], // Virtual gesture button +].map((payload) => decodeControlInfo(payload)!); + +const byId = (controlId: number) => + MX_MASTER_4_CONTROLS.find((control) => control.controlId === controlId)!; + +test("control info is decoded from the wire layout", () => { + const back = byId(0x0053); + assert.equal(back.taskId, 0x003c); + assert.equal(back.group, 2); + assert.equal(back.groupMask, 0b011); + + const actionsRing = byId(0x01a0); + assert.equal(actionsRing.taskId, 0x0109); + assert.equal(logitechControlName(actionsRing.controlId), "Actions Ring"); +}); + +test("a truncated control-info payload decodes as null rather than a guess", () => { + // Group and mask live at [6] and [7]; a short read would default them to + // zero, which silently reports every control as having no legal targets. + assert.equal(decodeControlInfo([0x00, 0x53, 0x00, 0x3c, 0x31, 0x00]), null); + assert.equal(decodeControlInfo([]), null); +}); + +test("the primary buttons report themselves as not reprogrammable", () => { + for (const controlId of [0x0050, 0x0051]) { + const control = byId(controlId); + // The exact byte, not just the absence of one bit: a flags field read from + // the wrong offset also lacks the reprogrammable bit, so a bitmask check + // alone passes against broken decoding. + assert.equal(control.flags, LOGITECH_KEY_FLAG.mouseButton, "primary buttons report flags 0x01"); + assert.equal(control.flags & LOGITECH_KEY_FLAG.reprogrammable, 0); + assert.equal(control.groupMask, 0); + assert.deepEqual( + remappableControlTargets(control, MX_MASTER_4_CONTROLS), + [], + "the firmware offers no targets for a primary button, so neither may we", + ); + } +}); + +test("a virtual control is not offered as a remap target", () => { + const virtual = byId(0x00d7); + assert.notEqual(virtual.flags & LOGITECH_KEY_FLAG.virtual, 0); + // It sits in group 3, and no control's mask includes group 3. + for (const control of MX_MASTER_4_CONTROLS) { + assert.ok( + !remappableControlTargets(control, MX_MASTER_4_CONTROLS).includes(0x00d7), + `${logitechControlName(control.controlId)} offered the virtual gesture button as a target`, + ); + } +}); + +test("remap targets come from the device's group mask", () => { + const targets = remappableControlTargets(byId(0x00c3), MX_MASTER_4_CONTROLS); + assert.deepEqual(targets, [0x0050, 0x0051, 0x0052, 0x0053, 0x0056, 0x00c3, 0x00c4, 0x01a0]); + assert.ok(targets.includes(0x00c3), "a control must be able to return to its own default"); +}); + +test("a control belonging to no group is never a target", () => { + // No control on an MX Master 4 reports group 0, so this pins the contract + // for devices that do rather than restating something the capture proves. + const grouped = { controlId: 0x0052, taskId: 0x003a, flags: 0x31, group: 1, groupMask: 0xff }; + const ungrouped = { controlId: 0x00d7, taskId: 0x00b4, flags: 0xa0, group: 0, groupMask: 0x00 }; + assert.deepEqual(remappableControlTargets(grouped, [grouped, ungrouped]), [0x0052]); +}); + +test("a control whose mask is empty is offered nothing at all", () => { + const leftClick = byId(0x0050); + assert.equal(leftClick.groupMask, 0); + // Every candidate is in some group, so an empty result can only come from + // the mask — this is the rule that keeps the primary buttons where they are. + assert.ok(MX_MASTER_4_CONTROLS.every((control) => control.group > 0)); + assert.deepEqual(remappableControlTargets(leftClick, MX_MASTER_4_CONTROLS), []); +}); + +test("the mapping flags are reassembled from two non-adjacent bytes", () => { + // [cid(2), flagsLow, remap(2), flagsHigh] — the remap target sits between + // the two halves of one 16-bit field. + const reporting = decodeControlReporting([0x00, 0xc3, 0x01, 0x00, 0x52, 0x04])!; + assert.equal(reporting.controlId, 0x00c3); + assert.equal(reporting.mappedTo, 0x0052); + assert.equal(reporting.mappingFlags, 0x0401); + assert.equal(reporting.mappingFlags & LOGITECH_MAPPING_FLAG.rawWheel, LOGITECH_MAPPING_FLAG.rawWheel); + assert.equal(reporting.diverted, true); +}); + +test("a button remapped to a high control id is not mistaken for a diverted one", () => { + // Reading flags as the contiguous pair at [2..3] folds the remap target's + // high byte into them, so remapping anything to 0x01A0 — the Actions Ring — + // would set the analytics bit and, on the low half, look diverted. + const reporting = decodeControlReporting([0x00, 0xc3, 0x00, 0x01, 0xa0, 0x00])!; + assert.equal(reporting.mappedTo, 0x01a0); + assert.equal(reporting.mappingFlags, 0x0000); + assert.equal(reporting.diverted, false, "the remap target leaked into the mapping flags"); +}); + +test("persistent diversion counts as diverted, and an undiverted button does not", () => { + const persistent = decodeControlReporting([0x00, 0x53, 0x04, 0x00, 0x53, 0x00])!; + assert.equal(persistent.diverted, true); + + const plain = decodeControlReporting([0x00, 0x53, 0x00, 0x00, 0x53, 0x00])!; + assert.equal(plain.diverted, false); + + // The high byte alone carries no diversion bit, so a control reporting only + // analytics events is still the user's to press. + const analytics = decodeControlReporting([0x00, 0x53, 0x00, 0x00, 0x53, 0x01])!; + assert.equal(analytics.mappingFlags, LOGITECH_MAPPING_FLAG.analyticsKeyEvents); + assert.equal(analytics.diverted, false); +}); + +test("a truncated reporting payload decodes as null", () => { + // The high flags byte is the last one, so a short read would quietly report + // every control as carrying no high-half flags. + assert.equal(decodeControlReporting([0x00, 0xc3, 0x01, 0x00, 0x52]), null); + assert.equal(decodeControlReporting([]), null); +}); + +test("a remap write changes the mapping and no flags", () => { + const payload = buildControlRemapWrite(0x00c3, 0x0052); + assert.deepEqual(payload, [0x00, 0xc3, 0x00, 0x00, 0x52]); + // Every mapping flag needs a companion "valid" bit one position higher, so a + // zero flags byte marks nothing valid and leaves diversion exactly as it was. + assert.equal(payload[2], 0x00, "a non-zero flags byte would rewrite diversion state"); +}); + +test("a diversion-clear write can only ever turn diversion off", () => { + const payload = buildControlDiversionClearWrite(0x0053); + assert.deepEqual(payload, [0x00, 0x53, 0x0a, 0x00, 0x00]); + + const flags = payload[2]!; + // Valid bits set for diverted (0x02) and persistently diverted (0x08)... + assert.equal(flags & 0x02, 0x02); + assert.equal(flags & 0x08, 0x08); + // ...with both value bits clear, so the flags can only be turned off. + assert.equal(flags & LOGITECH_MAPPING_FLAG.diverted, 0, "the write would switch diversion on"); + assert.equal( + flags & LOGITECH_MAPPING_FLAG.persistentlyDiverted, + 0, + "the write would switch persistent diversion on", + ); + // A zero remap target leaves the button pointing where it already did. + assert.deepEqual(payload.slice(3), [0x00, 0x00]); +}); + +test("control and task ids fall back to a readable hex label", () => { + assert.equal(logitechControlName(0x0abc), "Control 0x0ABC"); + assert.equal(logitechTaskName(0x0abc), "Task 0x0ABC"); +}); diff --git a/src/logitech/controls.ts b/src/logitech/controls.ts new file mode 100644 index 0000000..776763c --- /dev/null +++ b/src/logitech/controls.ts @@ -0,0 +1,236 @@ +/** + * Feature 0x1B04 (REPROG CONTROLS V4) — which buttons a device has, what each + * one currently does, and which of them may be pointed at another. + * + * Read from an MX Master 4 (WPID B042) over a Logi Bolt receiver, which + * reports nine controls at version 6. Control and task names follow Solaar's + * special_keys tables; the ids this device actually reports were confirmed + * against it, and the rest are carried from that list unverified. + * + * The device decides what is legal here, not this code. Every control carries + * a group and a mask of the groups it accepts, and the primary buttons report + * a mask of zero — so firmware, not a rule invented here, is what refuses to + * let left click be moved. + */ + +export const LOGITECH_REPROG_CONTROLS = { + /** getCount: [count]. */ + count: 0x00, + /** getControlIdInfo(index): [cid(2), tid(2), flags, pos, group, gmask, ...]. */ + info: 0x10, + /** getControlIdReporting(cid): [cid(2), flagsLow, remap(2), flagsHigh]. */ + reporting: 0x20, + /** setControlIdReporting(cid, flags, remap(2)). */ + setReporting: 0x30, +} as const; + +/** Control ids — the physical (or virtual) buttons a device exposes. */ +export const LOGITECH_CONTROL_NAMES: Readonly> = { + 0x0050: "Left click", + 0x0051: "Right click", + 0x0052: "Middle click", + 0x0053: "Back", + 0x0054: "Back (alt)", + 0x0056: "Forward", + 0x0057: "Forward (as HID)", + 0x0059: "Button 6", + 0x005a: "Button 7", + 0x005b: "Button 8", + 0x005c: "Button 9", + 0x005d: "Button 10", + 0x005e: "Button 11", + 0x00c3: "Gesture button", + 0x00c4: "SmartShift button", + 0x00d7: "Virtual gesture button", + 0x00dc: "Back (long press)", + 0x00e0: "Mission Control / Task View", + 0x00e1: "Dashboard / Action Center", + 0x00e2: "Backlight down", + 0x00e3: "Backlight up", + 0x00e4: "Previous track", + 0x00e5: "Play / pause", + 0x00e6: "Next track", + 0x00e7: "Mute", + 0x00e8: "Volume down", + 0x00e9: "Volume up", + /** The MX Master 4's Actions Ring, the panel paired with 0x19B0 haptics. */ + 0x01a0: "Actions Ring", +}; + +/** Native task each control performs when it is neither remapped nor diverted. */ +export const LOGITECH_TASK_NAMES: Readonly> = { + 0x0038: "Left click", + 0x0039: "Right click", + 0x003a: "Middle click", + 0x003c: "Back", + 0x003e: "Forward", + 0x009c: "Gesture button", + 0x009d: "SmartShift", + 0x00b4: "Virtual gesture button", + 0x0109: "App switch / Launchpad", +}; + +/** + * Bits of the flags byte from getControlIdInfo. Confirmed against an + * MX Master 4: left click reports 0x01 (a plain mouse button, not + * reprogrammable), the gesture button 0x31, and the virtual gesture button + * 0xA0 — virtual and divertable, but not a physical key. + */ +export const LOGITECH_KEY_FLAG = { + mouseButton: 0x01, + fkey: 0x02, + hotkey: 0x04, + fnToggle: 0x08, + reprogrammable: 0x10, + divertable: 0x20, + persistentlyDivertable: 0x40, + virtual: 0x80, +} as const; + +/** + * Bits of the mapping flags from getControlIdReporting, which is one 16-bit + * field split across two non-adjacent payload bytes — low at [2], high at [5], + * with the remap target wedged between them. + */ +export const LOGITECH_MAPPING_FLAG = { + diverted: 0x0001, + persistentlyDiverted: 0x0004, + rawXy: 0x0010, + forceRawXy: 0x0040, + analyticsKeyEvents: 0x0100, + rawWheel: 0x0400, +} as const; + +export interface LogitechControlInfo { + controlId: number; + taskId: number; + /** The getControlIdInfo capability flags — LOGITECH_KEY_FLAG bits. */ + flags: number; + /** Group this control belongs to when it is used as a remap target. */ + group: number; + /** Bitmask of the groups this control may be remapped into. */ + groupMask: number; +} + +export interface LogitechControlReporting { + controlId: number; + /** + * The 16-bit mapping field, reassembled from its two payload bytes. + * Named apart from LogitechControlInfo.flags on purpose: the two are + * different fields, and a control merges both, so a shared name would have + * one silently overwrite the other. + */ + mappingFlags: number; + /** Control id this button currently acts as. */ + mappedTo: number; + /** Another application is consuming this button's events. */ + diverted: boolean; +} + +export interface LogitechReprogrammableControl extends LogitechControlInfo, LogitechControlReporting { + name: string; + taskName: string; + reprogrammable: boolean; + /** A software-side control, not a physical button anyone can press. */ + virtual: boolean; + /** Control ids this button may legally be remapped to. */ + remappableTo: number[]; +} + +export const logitechControlName = (controlId: number): string => + LOGITECH_CONTROL_NAMES[controlId] + ?? `Control 0x${controlId.toString(16).padStart(4, "0").toUpperCase()}`; + +export const logitechTaskName = (taskId: number): string => + LOGITECH_TASK_NAMES[taskId] ?? `Task 0x${taskId.toString(16).padStart(4, "0").toUpperCase()}`; + +/** Decodes the getControlIdInfo payload (function 0x10). */ +export function decodeControlInfo(payload: Uint8Array | readonly number[]): LogitechControlInfo | null { + if (payload.length < 8) return null; + const at = (index: number): number => payload[index] ?? 0; + return { + controlId: (at(0) << 8) | at(1), + taskId: (at(2) << 8) | at(3), + flags: at(4), + // Byte 5 is the physical position, which is meaningful on keyboards only. + group: at(6), + groupMask: at(7), + }; +} + +/** + * Decodes the getControlIdReporting payload (function 0x20). + * + * The mapping flags are one 16-bit field whose halves are not adjacent: the + * low byte sits at [2] and the high byte at [5], with the remap target at + * [3..4] between them. Reading the two as a contiguous pair puts the remap + * target's high byte where the flags belong, which reports a button remapped + * to anything in 0x01.. as diverted. + */ +export function decodeControlReporting( + payload: Uint8Array | readonly number[], +): LogitechControlReporting | null { + if (payload.length < 6) return null; + const at = (index: number): number => payload[index] ?? 0; + const mappingFlags = at(2) | (at(5) << 8); + const diverted = LOGITECH_MAPPING_FLAG.diverted | LOGITECH_MAPPING_FLAG.persistentlyDiverted; + return { + controlId: (at(0) << 8) | at(1), + mappingFlags, + mappedTo: (at(3) << 8) | at(4), + diverted: (mappingFlags & diverted) !== 0, + }; +} + +/** + * Which controls a given control may be remapped to. + * + * The device's group mask names the groups it accepts, and only controls + * belonging to one of those groups are legal targets. Left and right click + * report a mask of zero, so they come back with nothing. + */ +export function remappableControlTargets( + control: LogitechControlInfo, + all: readonly LogitechControlInfo[], +): number[] { + return all + // Group 0 means the control belongs to no group and is not a target. The + // mask test alone would also reject it — but only via a negative shift, + // which is an accident to rely on rather than a rule to read. + .filter((candidate) => candidate.group > 0 + && (control.groupMask & (1 << (candidate.group - 1))) !== 0) + .map((candidate) => candidate.controlId); +} + +/** + * Builds the setControlIdReporting payload for a pure remap. + * + * Each mapping flag occupies two bits — the value, and a companion "valid" bit + * one position higher — and the device ignores any flag whose valid bit is + * clear. A flags byte of zero therefore changes no flag at all, which is + * exactly what is wanted here: turning diversion on would route the button to + * HID++ notifications that nothing consumes, leaving it dead. + */ +export function buildControlRemapWrite(controlId: number, targetControlId: number): number[] { + return [controlId >> 8, controlId & 0xff, 0x00, targetControlId >> 8, targetControlId & 0xff]; +} + +/** + * Builds the payload that hands a button back to the hardware. + * + * A diverted button emits HID++ notifications instead of acting, which is how + * a vendor application implements behaviours of its own. Logi Options+ uses + * the temporary flag, which the device clears when its owner stops — but the + * persistent flag survives, and a button left that way does nothing at all + * until something clears it. + * + * Only the valid bits are set, with both value bits left clear, so this can + * only ever turn diversion off. There is deliberately no way to turn it on: + * nothing here consumes those notifications. A remap target of zero leaves the + * existing mapping untouched. + */ +export function buildControlDiversionClearWrite(controlId: number): number[] { + const clearDiverted = LOGITECH_MAPPING_FLAG.diverted << 1; + const clearPersistent = LOGITECH_MAPPING_FLAG.persistentlyDiverted << 1; + return [controlId >> 8, controlId & 0xff, clearDiverted | clearPersistent, 0x00, 0x00]; +} diff --git a/src/logitech/index.ts b/src/logitech/index.ts index 47d0df7..0c826b2 100644 --- a/src/logitech/index.ts +++ b/src/logitech/index.ts @@ -1,3 +1,4 @@ +export * from "./controls.js"; export * from "./friendly-name.js"; export * from "./haptics.js"; export * from "./hosts.js";