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";