Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/drivers/keychron/launcher-mouse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
74 changes: 65 additions & 9 deletions src/drivers/keychron/launcher-mouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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];
Expand All @@ -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];
Expand All @@ -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";
}
Expand Down
17 changes: 17 additions & 0 deletions src/drivers/keychron/mouse-8k-hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 +");
});
2 changes: 1 addition & 1 deletion src/drivers/keychron/mouse-8k-hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading