diff --git a/src/drivers/keychron/launcher-mouse.test.ts b/src/drivers/keychron/launcher-mouse.test.ts index 56b2fbd..2b2f99c 100644 --- a/src/drivers/keychron/launcher-mouse.test.ts +++ b/src/drivers/keychron/launcher-mouse.test.ts @@ -40,6 +40,23 @@ test("button codes follow Launcher's EFunKey and EBasicKey packing", () => { assert.equal(keychronButtonOptions("8k").length, new Set(keychronButtonOptions("8k")).size); }); +test("shortcut presets and polling-rate keys follow Launcher's EShortcutKey and ELoopKey", () => { + assert.deepEqual(keychronEncodeButton("Copy (Ctrl+C)", "8k"), [8, 0x07, 0x01, 0x06]); + assert.deepEqual(keychronEncodeButton("Screenshot (Cmd+Shift+4)", "1k"), [8, 0x07, 0x0a, 0x21]); + assert.deepEqual(keychronEncodeButton("Mission Control", "8k"), [8, 0x0c, 0x9f, 0x02]); + assert.deepEqual(keychronEncodeButton("Brightness Up", "8k"), [8, 0x0c, 0x6f, 0x00]); + assert.deepEqual(keychronEncodeButton("Polling +", "8k"), [13, 2]); + assert.equal(keychronDecodeButton(record([8, 0x07, 0x08, 0x06]), "8k"), "Copy (Cmd+C)"); + assert.equal(keychronDecodeButton(record([8, 0x07, 0x01, 0x2d]), "1k"), "Zoom Out (Ctrl+-)"); + assert.equal(keychronDecodeButton(record([8, 0x07, 0x01, 0x04]), "8k"), "Custom"); + assert.equal(keychronDecodeButton(record([13, 1]), "8k"), "Polling Loop"); + assert.ok(!keychronButtonOptions("8k").includes("Polling Loop")); + assert.ok(keychronButtonOptions("8k", true).includes("Polling -")); + assert.ok(keychronButtonOptions("8k").includes("Launchpad")); + const options = keychronButtonOptions("8k", true); + assert.equal(options.length, new Set(options).size); +}); + test("status bytes 1-17 decode the same for both protocols", () => { const bytes = new Uint8Array(20); bytes.set([0x07, 1, 0x12, 0x00, 0x00, 0x90, 0x01, 0x20, 0x03, 0x40, 0x06, 0, 0, 0, 0, 0x5d, 3, 6]); diff --git a/src/drivers/keychron/launcher-mouse.ts b/src/drivers/keychron/launcher-mouse.ts index 1d975c3..0f4ea69 100644 --- a/src/drivers/keychron/launcher-mouse.ts +++ b/src/drivers/keychron/launcher-mouse.ts @@ -166,7 +166,33 @@ export function keychronLauncherFirmware(bytes: Uint8Array): string | null { // Buttons ─────────────────────────────────────────────────────────────── /** Launcher's EFunKey: byte 3 of a button record. */ -const BUTTON_TYPE = { default: 0, mouse: 1, media: 3, macro: 4, dpi: 5, disabled: 9 } as const; +const BUTTON_TYPE = { default: 0, mouse: 1, media: 3, macro: 4, dpi: 5, shortcut: 8, disabled: 9, pollingRate: 13 } as const; + +/** + * Launcher's EShortcutKey, the presets its "Shortcuts" group offers for mice, + * written three bytes high to low like the mouse codes. Kind 0x07 carries a + * modifier mask (ctrl 1, shift 2, alt 4, win/cmd 8) and a HID keycode; kind + * 0x0c carries a consumer usage, low byte first. + */ +const SHORTCUT_CODE = { + copy: 0x070106, + cut: 0x07011b, + paste: 0x070119, + screenshot: 0x070a16, + showDesktop: 0x070807, + zoomIn: 0x07012e, + zoomOut: 0x07012d, + copyMac: 0x070806, + cutMac: 0x07081b, + pasteMac: 0x070819, + screenshotMac: 0x070a21, + launchpad: 0x0ca002, + missionControl: 0x0c9f02, + zoomInMac: 0x07082e, + zoomOutMac: 0x07082d, + brightnessUp: 0x0c6f00, + brightnessDown: 0x0c7000, +} as const; /** * Launcher's EBasicKey, written as three bytes high to low. The "1k" enum @@ -189,8 +215,12 @@ export type KeychronProtocol = "8k" | "1k"; type ButtonAction = { label: string; type: number; value: number }; -/** Every action the remapper offers, in display order. Media codes are HID consumer usages. */ -function buttonActions(protocol: KeychronProtocol): ButtonAction[] { +/** + * Every action the remapper offers, in display order. Media codes are HID + * consumer usages. Launcher only shows the polling-rate keys when feature + * byte 1 bit 4 (rewritable polling gears) is set, so they are opt-in. + */ +function buttonActions(protocol: KeychronProtocol, pollingKeys = true): ButtonAction[] { const [back, forward] = protocol === "8k" ? [MOUSE_CODE.back8k, MOUSE_CODE.forward8k] : [MOUSE_CODE.forward8k, MOUSE_CODE.back8k]; @@ -208,36 +238,61 @@ function buttonActions(protocol: KeychronProtocol): ButtonAction[] { { label: "DPI Loop", type: BUTTON_TYPE.dpi, value: 1 }, { label: "DPI +", type: BUTTON_TYPE.dpi, value: 2 }, { label: "DPI -", type: BUTTON_TYPE.dpi, value: 3 }, + ...(pollingKeys ? [ + { label: "Polling Loop", type: BUTTON_TYPE.pollingRate, value: 1 }, + { label: "Polling +", type: BUTTON_TYPE.pollingRate, value: 2 }, + { label: "Polling -", type: BUTTON_TYPE.pollingRate, value: 3 }, + ] : []), { label: "Volume Up", type: BUTTON_TYPE.media, value: 0xe9 }, { label: "Volume Down", type: BUTTON_TYPE.media, value: 0xea }, { label: "Mute", type: BUTTON_TYPE.media, value: 0xe2 }, { label: "Play/Pause", type: BUTTON_TYPE.media, value: 0xcd }, { label: "Next Track", type: BUTTON_TYPE.media, value: 0xb5 }, { label: "Previous Track", type: BUTTON_TYPE.media, value: 0xb6 }, + { label: "Copy (Ctrl+C)", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.copy }, + { label: "Cut (Ctrl+X)", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.cut }, + { label: "Paste (Ctrl+V)", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.paste }, + { label: "Screenshot (Win+Shift+S)", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.screenshot }, + { label: "Show Desktop (Win+D)", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.showDesktop }, + { label: "Zoom In (Ctrl+=)", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.zoomIn }, + { label: "Zoom Out (Ctrl+-)", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.zoomOut }, + { label: "Copy (Cmd+C)", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.copyMac }, + { label: "Cut (Cmd+X)", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.cutMac }, + { label: "Paste (Cmd+V)", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.pasteMac }, + { label: "Screenshot (Cmd+Shift+4)", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.screenshotMac }, + { label: "Launchpad", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.launchpad }, + { label: "Mission Control", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.missionControl }, + { label: "Zoom In (Cmd+=)", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.zoomInMac }, + { label: "Zoom Out (Cmd+-)", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.zoomOutMac }, + { label: "Brightness Up", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.brightnessUp }, + { label: "Brightness Down", type: BUTTON_TYPE.shortcut, value: SHORTCUT_CODE.brightnessDown }, { label: "Disabled", type: BUTTON_TYPE.disabled, value: 0 }, // Launcher's "restore": the firmware's own function for that button. { label: "Default", type: BUTTON_TYPE.default, value: 0 }, ]; } -export function keychronButtonOptions(protocol: KeychronProtocol): string[] { - return buttonActions(protocol).map(({ label }) => label); +/** `pollingKeys` is the 8k feature flag; the 1k protocol never has it. */ +export function keychronButtonOptions(protocol: KeychronProtocol, pollingKeys = false): string[] { + return buttonActions(protocol, pollingKeys).map(({ label }) => label); } /** * Bytes 3 onward of a 0x52 write: the type, then the data the way Launcher - * packs it (mouse codes three bytes high to low, media codes low byte first, - * the DPI key as one byte). + * packs it (mouse and shortcut codes three bytes high to low, media codes + * low byte first, the DPI and polling-rate keys as one byte). */ export function keychronEncodeButton(label: string, protocol: KeychronProtocol): number[] | null { const action = buttonActions(protocol).find((entry) => entry.label === label); if (!action) return null; switch (action.type) { case BUTTON_TYPE.mouse: + case BUTTON_TYPE.shortcut: return [action.type, (action.value >> 16) & 0xff, (action.value >> 8) & 0xff, action.value & 0xff]; case BUTTON_TYPE.media: return [action.type, action.value & 0xff, (action.value >> 8) & 0xff]; case BUTTON_TYPE.dpi: + case BUTTON_TYPE.pollingRate: return [action.type, action.value]; default: return [action.type]; @@ -253,9 +308,10 @@ export function keychronDecodeButton(bytes: Uint8Array, protocol: KeychronProtoc const type = bytes[3] ?? 0; if (type === BUTTON_TYPE.default) return null; if (type === BUTTON_TYPE.macro) return "Macro"; - const value = type === BUTTON_TYPE.mouse ? ((bytes[4] ?? 0) << 16) | ((bytes[5] ?? 0) << 8) | (bytes[6] ?? 0) + const value = type === BUTTON_TYPE.mouse || type === BUTTON_TYPE.shortcut + ? ((bytes[4] ?? 0) << 16) | ((bytes[5] ?? 0) << 8) | (bytes[6] ?? 0) : type === BUTTON_TYPE.media ? (bytes[4] ?? 0) | ((bytes[5] ?? 0) << 8) - : type === BUTTON_TYPE.dpi ? (bytes[4] ?? 0) + : type === BUTTON_TYPE.dpi || type === BUTTON_TYPE.pollingRate ? (bytes[4] ?? 0) : 0; return buttonActions(protocol).find((action) => action.type === type && action.value === value)?.label ?? "Custom"; } diff --git a/src/drivers/keychron/mouse-8k-hid.test.ts b/src/drivers/keychron/mouse-8k-hid.test.ts index 38097c0..f81079f 100644 --- a/src/drivers/keychron/mouse-8k-hid.test.ts +++ b/src/drivers/keychron/mouse-8k-hid.test.ts @@ -700,3 +700,20 @@ test("lit models read and write Launcher's 0x23/0x24 lighting", async () => { assert.equal(fake.light.mode, 0); await assert.rejects(client(new FakeKeychronMouse()).setLighting(next), /has no lighting/); }); + +test("shortcut presets remap like Launcher and polling keys follow feature1 bit 4", async () => { + const fake = new FakeKeychronMouse(); + const m6 = client(fake); + let status = await m6.readStatus(); + assert.ok(status.buttonOptions?.includes("Copy (Ctrl+C)")); + assert.ok(!status.buttonOptions?.includes("Polling Loop")); + await m6.setButtonMapping("Tilt Left", "Copy (Ctrl+C)"); + assert.deepEqual(Array.from(lastSent(fake, 0x52, 0xb3)!.slice(0, 7)), [0x52, 9, 0, 8, 0x07, 0x01, 0x06]); + fake.features = [0x10, 0, 0, 0]; + await m6.setButtonMapping("Tilt Right", "Polling +"); + assert.deepEqual(Array.from(lastSent(fake, 0x52, 0xb3)!.slice(0, 6)), [0x52, 8, 0, 13, 2, 0]); + status = await m6.readStatus(); + assert.ok(status.buttonOptions?.includes("Polling Loop")); + assert.equal(status.buttonMappings?.["Tilt Left"], "Copy (Ctrl+C)"); + assert.equal(status.buttonMappings?.["Tilt Right"], "Polling +"); +}); diff --git a/src/drivers/keychron/mouse-8k-hid.ts b/src/drivers/keychron/mouse-8k-hid.ts index 3c9cf08..c0705f9 100644 --- a/src/drivers/keychron/mouse-8k-hid.ts +++ b/src/drivers/keychron/mouse-8k-hid.ts @@ -311,7 +311,7 @@ export class Keychron8kHidClient { sleepTimeout: status.sleepMinutes > 0 && status.sleepMinutes < 0xff ? status.sleepMinutes * 60 : null, ...(buttons ? { buttonMappings: Object.fromEntries(buttons.map(({ button, action }) => [button.name, action])), - buttonOptions: keychronButtonOptions("8k"), + buttonOptions: keychronButtonOptions("8k", status.features.pollingGears), } : {}), ...(light && model?.light ? { lighting: keychronLighting(light, model.light) } : {}), firmware: [identity.firmware ?? "Firmware unavailable"],