From 49d3bd82beaa94115f0dceb755e07a3a0b652b94 Mon Sep 17 00:00:00 2001 From: ydw1904 Date: Wed, 30 Sep 2026 17:55:46 +0800 Subject: [PATCH 1/3] feat(keychron): drive every Launcher "8k" and "1k" mouse Keychron Launcher picks a mouse's protocol from the HID collection it exposes: 0xffc1 is the "8k" protocol the M6 speaks, usage page 0x8c is the "1k" one, 0xff0a is the 4K family. Both "8k" and "1k" are now driven fully, decoded from Launcher (main.be11320b2a72b61b.js, webpack modules 20706, 61892, 75994 and 8596). - The M6 driver becomes Keychron8kHidClient (mouse-8k-hid.ts) and claims any Keychron device with the 0xffc1 interface. It adds Launcher's feature flags (status bytes 26/53/60, or the 0x02 answer from protocol 6): the mouse's own DPI ceiling and step, rewritable polling gears, 20K FPS, X/Y DPI through 0x48/0x49, split USB and 2.4 GHz polling through 0x4a/0x4b, and lift-off levels. Button remapping (0x61/0x62 read, 0x52 write) and lighting (0x23/0x24) are new; behind a receiver the 0x03 list names the paired mouse. - Keychron1kHidClient (mouse-1k-hid.ts) speaks the same command set in feature reports 0x51 and 0x52, with no sleep, profile or angle commands, a fixed 125/500/1000 Hz table and Back/Forward codes swapped. - launcher-mouse.ts holds what both share, and KEYCHRON_LAUNCHER_MICE lists 54 models from Launcher's per-model configs and product list: DPI range, polling ceiling, lift-off heights, buttons and lighting. - The picker filters on vendor and collection instead of the M6's two product IDs, and the registry checks 0xffc1, then 0x8c, then 0xff0a, as Launcher does. Only the M6 has been on hardware. Its verified paths keep their bytes; buttons, lighting, the flagged paths and the 1k driver still need an owner test. Co-Authored-By: Claude Opus 5.5 --- src/drivers/keychron/launcher-mouse.test.ts | 116 +++ src/drivers/keychron/launcher-mouse.ts | 415 +++++++++ src/drivers/keychron/m6-hid.test.ts | 318 ------- src/drivers/keychron/m6-hid.ts | 568 ------------- src/drivers/keychron/mouse-1k-hid.test.ts | 279 ++++++ src/drivers/keychron/mouse-1k-hid.ts | 469 ++++++++++ src/drivers/keychron/mouse-8k-hid.test.ts | 702 +++++++++++++++ src/drivers/keychron/mouse-8k-hid.ts | 897 ++++++++++++++++++++ src/drivers/registry.test.ts | 4 +- src/drivers/registry.ts | 9 +- src/drivers/vendors.ts | 10 +- src/keychron/index.ts | 111 +++ 12 files changed, 3003 insertions(+), 895 deletions(-) create mode 100644 src/drivers/keychron/launcher-mouse.test.ts create mode 100644 src/drivers/keychron/launcher-mouse.ts delete mode 100644 src/drivers/keychron/m6-hid.test.ts delete mode 100644 src/drivers/keychron/m6-hid.ts create mode 100644 src/drivers/keychron/mouse-1k-hid.test.ts create mode 100644 src/drivers/keychron/mouse-1k-hid.ts create mode 100644 src/drivers/keychron/mouse-8k-hid.test.ts create mode 100644 src/drivers/keychron/mouse-8k-hid.ts diff --git a/src/drivers/keychron/launcher-mouse.test.ts b/src/drivers/keychron/launcher-mouse.test.ts new file mode 100644 index 0000000..56b2fbd --- /dev/null +++ b/src/drivers/keychron/launcher-mouse.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { KEYCHRON_4K_MICE, KEYCHRON_LAUNCHER_MICE, KEYCHRON_RECEIVERS } from "@openmouse/protocol/keychron"; +import { + keychronButtonOptions, + keychronButtons, + keychronDecodeButton, + keychronDecodeConnectedMouse, + keychronDecodeSettings, + keychronEncodeButton, + keychronEncodeDpi, + keychronEncodeLighting, + keychronEncodeSensor, + keychronLauncherMouse, + keychronLighting, + keychronLiftOff, +} from "./launcher-mouse.ts"; + +const record = (bytes: number[]): Uint8Array => new Uint8Array([0x62, 0, 0, ...bytes]); + +test("button codes follow Launcher's EFunKey and EBasicKey packing", () => { + assert.deepEqual(keychronEncodeButton("Left Click", "8k"), [1, 0x01, 0x00, 0x00]); + assert.deepEqual(keychronEncodeButton("Scroll Down", "8k"), [1, 0x00, 0xfe, 0x00]); + assert.deepEqual(keychronEncodeButton("Scroll Left", "8k"), [1, 0x00, 0x00, 0xfe]); + assert.deepEqual(keychronEncodeButton("Double Click", "8k"), [1, 0x80, 0x00, 0x00]); + assert.deepEqual(keychronEncodeButton("DPI -", "8k"), [5, 3]); + assert.deepEqual(keychronEncodeButton("Play/Pause", "8k"), [3, 0xcd, 0x00]); + assert.deepEqual(keychronEncodeButton("Disabled", "8k"), [9]); + assert.deepEqual(keychronEncodeButton("Default", "8k"), [0]); + assert.equal(keychronEncodeButton("Macro", "8k"), null); + // Module 8596 ("1k") has Back and Forward the other way round from module 75994 ("8k"). + assert.deepEqual(keychronEncodeButton("Back", "8k"), [1, 0x08, 0x00, 0x00]); + assert.deepEqual(keychronEncodeButton("Back", "1k"), [1, 0x10, 0x00, 0x00]); + assert.equal(keychronDecodeButton(record([1, 0x10, 0x00, 0x00]), "8k"), "Forward"); + assert.equal(keychronDecodeButton(record([1, 0x10, 0x00, 0x00]), "1k"), "Back"); + assert.equal(keychronDecodeButton(record([0]), "8k"), null); + assert.equal(keychronDecodeButton(record([3, 0xea, 0x00]), "8k"), "Volume Down"); + assert.equal(keychronDecodeButton(record([4, 0, 2, 0]), "8k"), "Macro"); + assert.equal(keychronDecodeButton(record([2, 0x01, 0x04, 0]), "8k"), "Custom"); + assert.equal(keychronButtonOptions("8k").length, new Set(keychronButtonOptions("8k")).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]); + assert.deepEqual(keychronDecodeSettings(bytes), { + profile: 1, + levels: [0x12, 0, 0], + dpiStages: [400, 800, 1600, 0, 0], + stageCount: 3, + lod: 1, + rippleControl: true, + angleSnapping: true, + motionSync: true, + scrollReversed: true, + debounceMs: 6, + }); + assert.deepEqual(Array.from(keychronEncodeDpi(2, [400, 800, 1600, 3200, 6400], 5)), [0x40, 2, 2, 2, 0x90, 0x01, 0x20, 0x03, 0x40, 0x06, 0x80, 0x0c, 0x00, 0x19, 5, 0, 0, 0, 0, 0]); + const sensor = keychronEncodeSensor({ lod: 3, rippleControl: false, angleSnapping: true, motionSync: false, scrollReversed: true, maxSpeed: true, lodLevel: 4 }); + assert.deepEqual(Array.from(sensor.slice(0, 12)), [0x42, 3, 2, 1, 2, 0, 2, 0, 2, 0, 0, 4]); +}); + +test("a receiver's 0x03 list gives the connected mouse", () => { + const list = new Uint8Array(20); + list.set([0x03, 2, 0x34, 0x34, 0x60, 0xd0, 0, 0x34, 0x34, 0x50, 0xd0, 1]); + assert.equal(keychronDecodeConnectedMouse(list), 0xd050); + list[11] = 0; + assert.equal(keychronDecodeConnectedMouse(list), null); +}); + +test("lift-off maps three heights to stops and more to the slider", () => { + assert.deepEqual(keychronLiftOff([[3, 0.7], [1, 1], [2, 2]], 3), { + liftOffDistance: "Low", + supportedLiftOffDistances: ["Low", "Medium", "High"], + note: "Lift-off: Low is 0.7 mm, Medium is 1 mm, High is 2 mm.", + }); + assert.equal(keychronLiftOff([[1, 1], [2, 2]], 2).liftOffDistance, "High"); + assert.deepEqual(keychronLiftOff([], 1), { liftOffDistance: null }); + const levels = keychronLauncherMouse(0xd09d)!.lodLevels!; + assert.deepEqual(keychronLiftOff(levels, 11).liftOffScale, { value: 11, min: 1, max: 11, millimetres: 1.7, minMillimetres: 0.7, maxMillimetres: 1.7 }); +}); + +test("lighting round-trips between Launcher's 0-255 sliders and the panel's steps", () => { + const offered = [1, 2, 3]; + const lighting = keychronLighting({ mode: 2, brightness: 64, speed: 255, rgb: [18, 255, 0] }, offered); + assert.deepEqual(lighting.modes, ["Off", "Static", "Breathing single", "Spectrum"]); + assert.equal(lighting.mode, "Breathing single"); + assert.equal(lighting.color, "#12ff00"); + assert.equal(lighting.brightness, 25); + assert.equal(lighting.speed, 5); + assert.deepEqual(keychronEncodeLighting({ ...lighting, mode: "Spectrum", brightness: 75, speed: 2 }, offered), { + mode: 3, + brightness: 191, + speed: 102, + rgb: [18, 255, 0], + }); + assert.throws(() => keychronEncodeLighting({ ...lighting, mode: "Wave" }, offered), /no Wave lighting/); + // Effects the panel has no name for read as unknown rather than as a wrong one. + assert.equal(keychronLighting({ mode: 6, brightness: 0, speed: 0, rgb: [0, 0, 0] }, [1, 6]).mode, null); +}); + +test("the model table leaves other drivers' mice alone and names every button", () => { + const ids = KEYCHRON_LAUNCHER_MICE.map((mouse) => mouse.productId); + assert.equal(new Set(ids).size, ids.length, "one row per product ID"); + for (const { productId } of KEYCHRON_4K_MICE) assert.equal(ids.includes(productId), false, `0x${productId.toString(16)} is a 4K mouse`); + assert.equal(ids.includes(0xd077), false, "the G3 Air speaks 8k_nordic"); + for (const id of KEYCHRON_RECEIVERS.keys()) assert.equal(ids.includes(id), false, `0x${id.toString(16)} is a receiver`); + for (const mouse of KEYCHRON_LAUNCHER_MICE) { + const buttons = keychronButtons(mouse); + assert.equal(new Set(buttons.map(({ name }) => name)).size, buttons.length, `${mouse.name} button names are unique`); + assert.equal(new Set(buttons.map(({ index }) => index)).size, buttons.length, `${mouse.name} button indexes are unique`); + assert.ok(mouse.dpi[0] < mouse.dpi[1], `${mouse.name} DPI range`); + assert.ok((mouse.lod ?? []).every(([code]) => code >= 1 && code <= 3), `${mouse.name} lift-off codes fit two bits`); + } + assert.deepEqual(keychronButtons(keychronLauncherMouse(0xd059)).map(({ name }) => name).slice(3, 7), ["Forward", "Back", "Forward 2", "Back 2"]); +}); diff --git a/src/drivers/keychron/launcher-mouse.ts b/src/drivers/keychron/launcher-mouse.ts new file mode 100644 index 0000000..1d975c3 --- /dev/null +++ b/src/drivers/keychron/launcher-mouse.ts @@ -0,0 +1,415 @@ +import type { MouseLighting, MouseLightingMode, MouseStatus } from "../mouse-types.ts"; +import { + KEYCHRON_LAUNCHER_MICE, + type KeychronButtonId, + type KeychronLauncherMouse, +} from "@openmouse/protocol/keychron"; + +/** + * The parts of Keychron Launcher's "8k" and "1k" mouse protocols that are the + * same in both: the first 18 bytes of the status report, the DPI, sensor and + * debounce packets, and the button and lighting codes. Decoded from Launcher + * (main.be11320b2a72b61b.js, webpack modules 20706, 75994 and 8596). + */ + +export const KEYCHRON_DPI_STAGE_COUNT = 5; +/** Launcher's POLLING_RATE_VALUE_SCALE; polling tables hold indexes into it. */ +export const KEYCHRON_POLLING_RATES = [125, 500, 1000, 2000, 4000, 8000] as const; +/** Ranges Launcher's descriptors give: debounce 0-20 ms, sleep 1-240 minutes. */ +export const KEYCHRON_DEBOUNCE_MAX_MS = 20; +export const KEYCHRON_SLEEP_MINUTES = [1, 3, 5, 10, 15, 30, 60, 120, 240] as const; +export const KEYCHRON_DPI_STEP = 50; +/** The M6's range, for a mouse missing from the model table. */ +export const KEYCHRON_DEFAULT_DPI: readonly [number, number] = [100, 26_000]; + +/** Command bytes the two protocols share; each sends them on its own reports. */ +export const KEYCHRON_SET = { + receiverState: 0x03, + firmware: 0x04, + dpi: 0x40, + polling: 0x41, + sensor: 0x42, + debounce: 0x43, + readButton: 0x62, + writeButton: 0x52, +} as const; + +type LiftOff = NonNullable; + +export function keychronLauncherMouse(productId: number | null | undefined): KeychronLauncherMouse | undefined { + return KEYCHRON_LAUNCHER_MICE.find((mouse) => mouse.productId === productId); +} + +/** Status bytes 1-17, laid out the same in the "8k" 0x06 and the "1k" 0x07 report. */ +export type KeychronSettings = { + /** Active onboard profile, zero-based. */ + profile: number; + /** For USB, 2.4 GHz and Bluetooth: DPI stage in the low nibble, polling gear in the high nibble. */ + levels: number[]; + /** All five hardware slots; only the first `stageCount` are in use. */ + dpiStages: number[]; + stageCount: number; + /** Bits 0-1 of byte 15: the lift-off code. */ + lod: number; + rippleControl: boolean; + angleSnapping: boolean; + motionSync: boolean; + scrollReversed: boolean; + debounceMs: number; +}; + +export function keychronDecodeSettings(bytes: Uint8Array): KeychronSettings { + const flags = bytes[15] ?? 0; + const stageCount = Math.min(bytes[16] || KEYCHRON_DPI_STAGE_COUNT, KEYCHRON_DPI_STAGE_COUNT); + return { + profile: bytes[1] ?? 0, + levels: [bytes[2] ?? 0, bytes[3] ?? 0, bytes[4] ?? 0], + dpiStages: Array.from({ length: KEYCHRON_DPI_STAGE_COUNT }, (_, stage) => readU16(bytes, 5 + stage * 2)), + stageCount, + lod: flags & 0x03, + rippleControl: (flags & 0x04) !== 0, + angleSnapping: (flags & 0x08) !== 0, + motionSync: (flags & 0x10) !== 0, + scrollReversed: (flags & 0x40) !== 0, + debounceMs: bytes[17] ?? 0, + }; +} + +/** The DPI stage of a connection (0 USB, 1 2.4 GHz, 2 Bluetooth), kept inside the stage count. */ +export function keychronActiveStage(settings: KeychronSettings, workMode: number): number { + return Math.min((settings.levels[Math.min(workMode, 2)] ?? 0) & 0x0f, settings.stageCount - 1); +} + +/** The polling gear of a connection. */ +export function keychronActiveGear(settings: KeychronSettings, workMode: number): number { + return ((settings.levels[Math.min(workMode, 2)] ?? 0) >> 4) & 0x0f; +} + +/** + * 0x40: the DPI part of the status layout shifted down a byte. The stage goes + * to all three connections, as Launcher sends it. + */ +export function keychronEncodeDpi(activeStage: number, dpiStages: readonly number[], stageCount: number): Uint8Array { + const packet = new Uint8Array(20); + packet[0] = KEYCHRON_SET.dpi; + packet.fill(activeStage, 1, 4); + dpiStages.slice(0, KEYCHRON_DPI_STAGE_COUNT).forEach((dpi, stage) => writeU16(packet, 4 + stage * 2, dpi)); + packet[14] = stageCount; + return packet; +} + +export type KeychronSensorWrite = { + /** 2-bit lift-off code, or 0 to leave it (Launcher's choice when it writes the level byte). */ + lod: number; + rippleControl: boolean; + angleSnapping: boolean; + motionSync: boolean; + scrollReversed: boolean; + /** "8k" only: the 20K FPS switch. */ + maxSpeed?: boolean; + /** "8k" only: the lift-off level byte, on firmware that flags it. */ + lodLevel?: number; +}; + +/** 0x42: every sensor option at once. Toggles are 1 = on, 2 = off; 0 would leave one alone. */ +export function keychronEncodeSensor(sensor: KeychronSensorWrite): Uint8Array { + const packet = new Uint8Array(20); + packet[0] = KEYCHRON_SET.sensor; + packet[1] = sensor.lod; + packet[2] = sensor.rippleControl ? 1 : 2; + packet[3] = sensor.angleSnapping ? 1 : 2; + packet[4] = sensor.motionSync ? 1 : 2; + packet[6] = sensor.scrollReversed ? 2 : 1; + if (sensor.maxSpeed !== undefined) packet[8] = sensor.maxSpeed ? 2 : 1; + if (sensor.lodLevel !== undefined) packet[11] = sensor.lodLevel; + return packet; +} + +/** 0x42 in its angle form: byte 9 = 2 enables tuning, byte 10 is the signed angle. */ +export function keychronEncodeAngle(degrees: number): Uint8Array { + const packet = new Uint8Array(20); + packet[0] = KEYCHRON_SET.sensor; + packet[9] = 2; + packet[10] = degrees & 0xff; + return packet; +} + +export function keychronEncodeDebounce(debounceMs: number): Uint8Array { + const packet = new Uint8Array(20); + packet[0] = KEYCHRON_SET.debounce; + packet[1] = debounceMs; + return packet; +} + +/** Launcher's 0x03 answer: [1] count, then vendor ID, product ID (both little-endian) and state (1 = connected) per mouse. */ +export function keychronDecodeConnectedMouse(bytes: Uint8Array): number | null { + const count = Math.min(bytes[1] ?? 0, Math.floor((bytes.length - 2) / 5)); + for (let entry = 0; entry < count; entry += 1) { + const offset = 2 + entry * 5; + if (bytes[offset + 4] === 1) return readU16(bytes, offset + 2); + } + return null; +} + +/** 0x04 answer: [1] is the length of the ASCII version that starts at [2]. */ +export function keychronLauncherFirmware(bytes: Uint8Array): string | null { + const length = Math.min(bytes[1] ?? 0, bytes.length - 2); + const text = Array.from(bytes.slice(2, 2 + length)) + .filter((byte) => byte >= 0x20 && byte < 0x7f) + .map((byte) => String.fromCharCode(byte)) + .join("") + .trim(); + if (!text) return null; + return text.startsWith("v") ? text : `v${text}`; +} + +// 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; + +/** + * Launcher's EBasicKey, written as three bytes high to low. The "1k" enum + * (module 8596) is the "8k" one (module 75994) with Back and Forward swapped. + */ +const MOUSE_CODE = { + left: 0x010000, + right: 0x020000, + middle: 0x040000, + back8k: 0x080000, + forward8k: 0x100000, + doubleClick: 0x800000, + scrollUp: 0x000200, + scrollDown: 0x00fe00, + scrollLeft: 0x0000fe, + scrollRight: 0x000002, +} as const; + +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[] { + const [back, forward] = protocol === "8k" + ? [MOUSE_CODE.back8k, MOUSE_CODE.forward8k] + : [MOUSE_CODE.forward8k, MOUSE_CODE.back8k]; + return [ + { label: "Left Click", type: BUTTON_TYPE.mouse, value: MOUSE_CODE.left }, + { label: "Right Click", type: BUTTON_TYPE.mouse, value: MOUSE_CODE.right }, + { label: "Middle Click", type: BUTTON_TYPE.mouse, value: MOUSE_CODE.middle }, + { label: "Back", type: BUTTON_TYPE.mouse, value: back }, + { label: "Forward", type: BUTTON_TYPE.mouse, value: forward }, + { label: "Double Click", type: BUTTON_TYPE.mouse, value: MOUSE_CODE.doubleClick }, + { label: "Scroll Up", type: BUTTON_TYPE.mouse, value: MOUSE_CODE.scrollUp }, + { label: "Scroll Down", type: BUTTON_TYPE.mouse, value: MOUSE_CODE.scrollDown }, + { label: "Scroll Left", type: BUTTON_TYPE.mouse, value: MOUSE_CODE.scrollLeft }, + { label: "Scroll Right", type: BUTTON_TYPE.mouse, value: MOUSE_CODE.scrollRight }, + { 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 }, + { 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: "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); +} + +/** + * 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). + */ +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: + 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: + return [action.type, action.value]; + default: + return [action.type]; + } +} + +/** + * Reads a 0x62 answer from byte 3. Null means the button runs its default + * function (type 0, which is all Launcher's own reads return until something + * is remapped); "Macro" and "Custom" cover codes the remapper cannot offer. + */ +export function keychronDecodeButton(bytes: Uint8Array, protocol: KeychronProtocol): string | null { + 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) + : type === BUTTON_TYPE.media ? (bytes[4] ?? 0) | ((bytes[5] ?? 0) << 8) + : type === BUTTON_TYPE.dpi ? (bytes[4] ?? 0) + : 0; + return buttonActions(protocol).find((action) => action.type === type && action.value === value)?.label ?? "Custom"; +} + +const BUTTON_NAME: Record = { + left: "Left", + right: "Right", + middle: "Middle", + backward: "Back", + forward: "Forward", + leftTilt: "Tilt Left", + rightTilt: "Tilt Right", + upScroll: "Scroll Up", + downScroll: "Scroll Down", + leftScroll: "Scroll Left", + rightScroll: "Scroll Right", + dpiLoop: "DPI", + pageUp: "Page Up", + pageDown: "Page Down", + swichLight: "Lighting", +}; + +/** What a button does before it is remapped, as a remapper label. */ +const DEFAULT_ACTION: Partial> = { + left: "Left Click", + right: "Right Click", + middle: "Middle Click", + backward: "Back", + forward: "Forward", + upScroll: "Scroll Up", + downScroll: "Scroll Down", + leftScroll: "Scroll Left", + rightScroll: "Scroll Right", + dpiLoop: "DPI Loop", +}; + +export type KeychronButton = { name: string; index: number; defaultAction: string }; + +/** A model's buttons named after their default function; repeats get a number ("Forward 2"). */ +export function keychronButtons(model: KeychronLauncherMouse | undefined): KeychronButton[] { + const seen = new Map(); + return (model?.buttons ?? []).map(([index, id]) => { + const base = BUTTON_NAME[id]; + const count = (seen.get(base) ?? 0) + 1; + seen.set(base, count); + return { name: count > 1 ? `${base} ${count}` : base, index, defaultAction: DEFAULT_ACTION[id] ?? "Default" }; + }); +} + +// Lift-off ────────────────────────────────────────────────────────────── + +/** Firmware codes on the PAW3950/3395 family: 1 = 1 mm, 2 = 2 mm, 3 = 0.7 mm. */ +export const KEYCHRON_STANDARD_LOD: ReadonlyArray = [[3, 0.7], [1, 1], [2, 2]]; + +export type KeychronLiftOff = Pick + & { note?: string }; + +/** + * Up to three choices become the Low/Medium/High stops (two become Low and + * High); more become the lift-off slider, whose labels assume even steps. + */ +export function keychronLiftOff(choices: ReadonlyArray, current: number): KeychronLiftOff { + const sorted = [...choices].sort((a, b) => a[1] - b[1]); + if (!sorted.length) return { liftOffDistance: null }; + if (sorted.length > 3) { + const byLevel = [...choices].sort((a, b) => a[0] - b[0]); + const first = byLevel[0]!; + const last = byLevel[byLevel.length - 1]!; + const value = choices.find(([code]) => code === current) ?? first; + return { + liftOffDistance: null, + liftOffScale: { + value: value[0], + min: first[0], + max: last[0], + millimetres: value[1], + minMillimetres: first[1], + maxMillimetres: last[1], + }, + }; + } + const stops = keychronLiftOffStops(sorted); + const level = stops.find(([, code]) => code === current)?.[0] ?? null; + return { + liftOffDistance: level, + supportedLiftOffDistances: stops.map(([name]) => name), + note: `Lift-off: ${stops.map(([name, , mm]) => `${name} is ${mm} mm`).join(", ")}.`, + }; +} + +/** [stop, code, millimetres] for three or fewer choices, lowest first. */ +export function keychronLiftOffStops(choices: ReadonlyArray): Array<[LiftOff, number, number]> { + const sorted = [...choices].sort((a, b) => a[1] - b[1]); + const names: LiftOff[] = sorted.length === 3 ? ["Low", "Medium", "High"] : sorted.length === 2 ? ["Low", "High"] : ["Medium"]; + return sorted.map(([code, mm], index) => [names[index]!, code, mm]); +} + +// Lighting ────────────────────────────────────────────────────────────── + +/** Launcher's light effects (json.light); 5 (one-colour flow) and 6 (flash) have no panel equivalent. */ +const LIGHT_MODE: ReadonlyArray = [ + [0, "Off"], + [1, "Static"], + [2, "Breathing single"], + [3, "Spectrum"], + [4, "Wave"], +]; +const LIGHT_BRIGHTNESS = [25, 50, 75, 100] as const; +const LIGHT_SPEEDS = [1, 2, 3, 4, 5] as const; + +export type KeychronLight = { mode: number; brightness: number; speed: number; rgb: [number, number, number] }; + +/** The panel's view of a light state; Launcher's sliders run 0-255 for brightness and speed. */ +export function keychronLighting(light: KeychronLight, offered: readonly number[]): MouseLighting { + const modes = LIGHT_MODE.filter(([code]) => code === 0 || offered.includes(code)).map(([, mode]) => mode); + const mode = LIGHT_MODE.find(([code]) => code === light.mode)?.[1] ?? null; + return { + zone: "Mouse", + modes, + mode, + color: `#${light.rgb.map((value) => value.toString(16).padStart(2, "0")).join("")}`, + color2: null, + colorModes: modes.filter((entry) => entry === "Static" || entry === "Breathing single"), + dualColorModes: [], + reactiveModes: modes.filter((entry) => entry !== "Off" && entry !== "Static"), + speeds: [...LIGHT_SPEEDS], + speed: Math.max(1, Math.round((light.speed * LIGHT_SPEEDS.length) / 255)), + brightness: nearest(LIGHT_BRIGHTNESS, Math.round((light.brightness * 100) / 255)), + brightnessLevels: [...LIGHT_BRIGHTNESS], + }; +} + +/** The panel's lighting as the mouse's codes. */ +export function keychronEncodeLighting(lighting: MouseLighting, offered: readonly number[]): KeychronLight { + const mode = LIGHT_MODE.find(([code, name]) => name === lighting.mode && (code === 0 || offered.includes(code)))?.[0]; + if (mode === undefined) throw new Error(`This Keychron mouse has no ${lighting.mode ?? "unknown"} lighting effect.`); + const hex = /^#([0-9a-f]{6})$/i.exec(lighting.color ?? "")?.[1] ?? "ffffff"; + return { + mode, + brightness: Math.round(((lighting.brightness ?? 100) * 255) / 100), + speed: Math.round(((lighting.speed ?? LIGHT_SPEEDS.length) * 255) / LIGHT_SPEEDS.length), + rgb: [0, 2, 4].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16)) as [number, number, number], + }; +} + +function nearest(values: readonly number[], target: number): number { + return values.reduce((best, value) => (Math.abs(value - target) < Math.abs(best - target) ? value : best), values[0]!); +} + +export function readU16(bytes: Uint8Array, offset: number): number { + return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8); +} + +export function writeU16(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >> 8) & 0xff; +} diff --git a/src/drivers/keychron/m6-hid.test.ts b/src/drivers/keychron/m6-hid.test.ts deleted file mode 100644 index 2db9518..0000000 --- a/src/drivers/keychron/m6-hid.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { KeychronM6HidClient } from "./m6-hid.ts"; - -if (typeof globalThis.window === "undefined") { - (globalThis as { window?: unknown }).window = globalThis; -} - -const ACK = 0xe4; - -/** - * Speaks the "8k" Keychron mouse protocol the way the M6 does: 63-byte - * queries on 0xb3 answered on 0xb4, 20-byte settings on 0xb5 acknowledged on - * 0xb6 with [0xe4, 0, command]. - */ -class FakeM6Device { - vendorId = 0x3434; - productId = 0xd060; - opened = false; - readonly sent: Array<{ reportId: number; packet: Uint8Array }> = []; - private listeners = new Map void>(); - workMode = 0; - profile = 1; - profileCount = 3; - /** Per connection (USB, 2.4 GHz, Bluetooth): DPI stage low nibble, polling high nibble. */ - levels = [0x10, 0x21, 0x32]; - dpiStages = [400, 800, 1600, 3200, 5000]; - stageCount = 3; - pollingTable = [0, 1, 2, 3, 4, 5]; - lod = 1; - ripple = false; - angleSnap = false; - motion = true; - scrollReversed = false; - maxSpeed = false; - angle = 0; - angleSupported = true; - rejectNext = false; - debounce = 8; - sleep = 10; - battery = 0x80 | 100; - readonly collections = [{ - usagePage: 0xffc1, - usage: 0x01, - outputReports: [{ reportId: 0xb3 }, { reportId: 0xb5 }], - inputReports: [{ reportId: 0xb4 }, { reportId: 0xb6 }], - }]; - - async open(): Promise { this.opened = true; } - async close(): Promise { this.opened = false; } - addEventListener(type: string, listener: (event: unknown) => void): void { this.listeners.set(type, listener); } - removeEventListener(type: string): void { this.listeners.delete(type); } - - async sendReport(reportId: number, data: ArrayBuffer): Promise { - const packet = new Uint8Array(data); - this.sent.push({ reportId, packet }); - if (reportId === 0xb3) { - if (packet[0] === 0x06) this.emit(0xb4, this.statusPacket()); - if (packet[0] === 0x04) this.emit(0xb4, new Uint8Array([0x04, 6, ...Array.from("1.0.3", (c) => c.charCodeAt(0)), 0])); - return; - } - if (reportId !== 0xb5) return; - if (this.rejectNext) { - this.rejectNext = false; - this.emit(0xb6, new Uint8Array([ACK, 7, packet[0] ?? 0])); - return; - } - switch (packet[0]) { - case 0x02: { - const reply = new Uint8Array(20); - reply.set([0x02, 5, 0, 0x34, 0x34, 0x60, 0xd0, 0x03, 0x01, this.workMode]); - this.emit(0xb6, reply); - return; - } - case 0x40: - this.levels = this.levels.map((level, index) => (level & 0xf0) | (packet[1 + index] ?? 0)); - this.stageCount = packet[14] ?? this.stageCount; - this.dpiStages = this.dpiStages.map((_, index) => (packet[4 + index * 2] ?? 0) | ((packet[5 + index * 2] ?? 0) << 8)); - break; - case 0x41: - this.levels = this.levels.map((level) => (level & 0x0f) | ((packet[1] ?? 0) << 4)); - this.pollingTable = Array.from(packet.slice(3, 3 + (packet[9] ?? 0))); - break; - case 0x42: - if (packet[9] === 2) { - this.angle = (packet[10] ?? 0) > 127 ? (packet[10] ?? 0) - 256 : (packet[10] ?? 0); - break; - } - if (packet[1]) this.lod = packet[1]; - if (packet[2]) this.ripple = packet[2] === 1; - if (packet[3]) this.angleSnap = packet[3] === 1; - if (packet[4]) this.motion = packet[4] === 1; - if (packet[6]) this.scrollReversed = packet[6] === 2; - if (packet[8]) this.maxSpeed = packet[8] === 2; - break; - case 0x43: - this.debounce = packet[1] ?? 0; - break; - case 0x0a: - if (packet[1] === 1) this.sleep = packet[2] ?? 0; - break; - case 0x0e: - this.profile = packet[1] ?? 0; - break; - default: - return; - } - this.emit(0xb6, new Uint8Array([ACK, 0, packet[0] ?? 0])); - } - - private statusPacket(): Uint8Array { - const packet = new Uint8Array(63); - packet[0] = 0x06; - packet[1] = this.profile; - packet.set(this.levels, 2); - this.dpiStages.forEach((dpi, index) => { - packet[5 + index * 2] = dpi & 0xff; - packet[6 + index * 2] = (dpi >> 8) & 0xff; - }); - packet[15] = this.lod | (this.ripple ? 0x04 : 0) | (this.angleSnap ? 0x08 : 0) | (this.motion ? 0x10 : 0) - | (this.scrollReversed ? 0x40 : 0); - packet[16] = this.stageCount; - packet[17] = this.debounce; - packet[18] = this.sleep; - packet[19] = this.battery; - packet.set(this.pollingTable, 43); - packet[49] = this.pollingTable.length; - packet[50] = this.profileCount; - packet[52] = this.maxSpeed ? 1 : 0; - packet[53] = this.angleSupported ? 0x04 : 0; - packet[55] = this.angle & 0xff; - return packet; - } - - private emit(reportId: number, bytes: Uint8Array): void { - const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - queueMicrotask(() => this.listeners.get("inputreport")?.({ reportId, data })); - } -} - -const client = (fake: FakeM6Device): KeychronM6HidClient => new KeychronM6HidClient(fake as unknown as HIDDevice); -const lastSent = (fake: FakeM6Device, command: number) => - [...fake.sent].reverse().find(({ reportId, packet }) => reportId === 0xb5 && packet[0] === command)?.packet; - -test("Keychron M6 is limited to its verified 0xffc1 control interface", () => { - const fake = new FakeM6Device(); - assert.equal(KeychronM6HidClient.isSupported(fake as unknown as HIDDevice), true); - const viaOnly = { ...fake, collections: [{ usagePage: 0xff60, usage: 0x61, outputReports: [{ reportId: 0 }], inputReports: [{ reportId: 0 }] }] }; - assert.equal(KeychronM6HidClient.isSupported(viaOnly as unknown as HIDDevice), false); -}); - -test("reads the full M6 status report", async () => { - const status = await client(new FakeM6Device()).readStatus(); - assert.equal(status.name, "Keychron M6"); - assert.equal(status.dpi, 400); - assert.deepEqual(status.dpiStages, [400, 800, 1600]); - assert.equal(status.activeDpiStage, 0); - assert.equal(status.pollingRateHz, 500); - assert.deepEqual(status.supportedPollingRates, [125, 500, 1000, 2000, 4000, 8000]); - assert.equal(status.liftOffDistance, "Medium"); - assert.deepEqual(status.supportedLiftOffDistances, ["Low", "Medium", "High"]); - assert.equal(status.motionSync, true); - assert.equal(status.angleSnapping, false); - assert.equal(status.rippleControl, false); - assert.equal(status.angleTuning, 0); - assert.equal(status.debounceMs, 8); - assert.equal(status.sleepTimeout, 600); - assert.equal(status.activeProfile, 2); - assert.equal(status.profileCount, 3); - assert.equal(status.batteryPercent, 100); - assert.equal(status.batteryState, "Charging"); - assert.deepEqual(status.firmware, ["v1.0.3"]); - assert.equal(status.ui?.hideProcessingCard, undefined); - assert.equal(status.ui?.hideSleepCard, undefined); -}); - -test("reads the DPI and polling levels of the connection in use", async () => { - const fake = new FakeM6Device(); - fake.productId = 0xd029; - fake.workMode = 1; - const status = await client(fake).readStatus(); - // 2.4 GHz slot: stage 1 (800 DPI) and polling index 2 (1000 Hz). - assert.equal(status.dpi, 800); - assert.equal(status.pollingRateHz, 1000); - assert.equal(status.connectionType, "Wireless"); -}); - -test("DPI writes follow the active stage and keep the stage count", async () => { - const fake = new FakeM6Device(); - const m6 = client(fake); - await m6.setActiveDpiStage(1); - await m6.setDpi(2400); - const status = await m6.readStatus(); - assert.deepEqual(status.dpiStages, [400, 2400, 1600]); - assert.equal(status.activeDpiStage, 1); - assert.equal(status.dpi, 2400); - assert.equal(await m6.setDpiStageValue(2, 3200), 3200); - assert.deepEqual((await m6.readStatus()).dpiStages, [400, 2400, 3200]); - assert.equal(lastSent(fake, 0x40)?.[14], 3); -}); - -test("rejects stages past the count the mouse reports", async () => { - const m6 = client(new FakeM6Device()); - await assert.rejects(m6.setActiveDpiStage(3), /between 1 and 3/); - await assert.rejects(m6.setDpiStageValue(4, 800), /between 1 and 3/); -}); - -test("the stage count grows and shrinks without disturbing the stored DPI", async () => { - const fake = new FakeM6Device(); - const m6 = client(fake); - assert.equal(await m6.setDpiStageCount(5), 5); - // Growing reveals slots the mouse was already holding, it does not invent them. - assert.deepEqual((await m6.readStatus()).dpiStages, [400, 800, 1600, 3200, 5000]); - assert.equal(lastSent(fake, 0x40)?.[14], 5); - assert.equal(await m6.setDpiStageCount(2), 2); - assert.deepEqual((await m6.readStatus()).dpiStages, [400, 800]); - // Shrinking keeps the hidden slots intact for the next time they are shown. - assert.equal(await m6.setDpiStageCount(5), 5); - assert.deepEqual((await m6.readStatus()).dpiStages, [400, 800, 1600, 3200, 5000]); -}); - -test("shrinking below the active stage pulls it back into range", async () => { - const fake = new FakeM6Device(); - const m6 = client(fake); - await m6.setActiveDpiStage(2); - assert.equal((await m6.readStatus()).activeDpiStage, 2); - await m6.setDpiStageCount(1); - const status = await m6.readStatus(); - assert.equal(status.activeDpiStage, 0); - assert.equal(status.dpi, 400); -}); - -test("rejects a stage count the mouse cannot hold", async () => { - const m6 = client(new FakeM6Device()); - for (const count of [0, 6, 2.5, Number.NaN]) { - await assert.rejects(m6.setDpiStageCount(count), /between 1 and 5/); - } -}); - -test("writes the polling rate as an index into the mouse's table", async () => { - const fake = new FakeM6Device(); - assert.equal(await client(fake).setPollingRate(4000), 4000); - const packet = lastSent(fake, 0x41)!; - assert.deepEqual(Array.from(packet.slice(0, 10)), [0x41, 4, 4, 0, 1, 2, 3, 4, 5, 6]); - await assert.rejects(client(fake).setPollingRate(250), /does not support 250 Hz/); -}); - -test("sensor options are resent together with 1 = on and 2 = off", async () => { - const fake = new FakeM6Device(); - const m6 = client(fake); - assert.equal(await m6.setLiftOffDistance("High"), "High"); - assert.equal(await m6.setMotionSync(false), false); - assert.equal(await m6.setAngleSnapping(true), true); - assert.equal(await m6.setRippleControl(true), true); - const packet = lastSent(fake, 0x42)!; - assert.deepEqual(Array.from(packet.slice(0, 9)), [0x42, 2, 1, 1, 2, 0, 1, 0, 1]); - const status = await m6.readStatus(); - assert.equal(status.liftOffDistance, "High"); - assert.equal(status.motionSync, false); - assert.equal(status.angleSnapping, true); - assert.equal(status.rippleControl, true); - assert.equal(await m6.setLiftOffDistance("Low"), "Low"); - assert.equal(fake.lod, 3); -}); - -test("angle tuning uses the dedicated 0x42 form with a signed byte", async () => { - const fake = new FakeM6Device(); - const m6 = client(fake); - assert.equal(await m6.setAngleTuning(-15), -15); - const packet = lastSent(fake, 0x42)!; - assert.equal(packet[9], 2); - assert.equal(packet[10], 0xf1); - assert.equal((await m6.readStatus()).angleTuning, -15); - await assert.rejects(m6.setAngleTuning(45), /between -30 and 30/); -}); - -test("debounce, sleep and profile round-trip through their own commands", async () => { - const fake = new FakeM6Device(); - const m6 = client(fake); - assert.equal(await m6.setDebounceTime(4), 4); - assert.deepEqual(Array.from(lastSent(fake, 0x43)!.slice(0, 2)), [0x43, 4]); - assert.equal(await m6.setSleepTimeout(300), 300); - assert.deepEqual(Array.from(lastSent(fake, 0x0a)!.slice(0, 3)), [0x0a, 1, 5]); - assert.equal(await m6.setProfile(3), 3); - assert.deepEqual(Array.from(lastSent(fake, 0x0e)!.slice(0, 2)), [0x0e, 2]); - const status = await m6.readStatus(); - assert.equal(status.debounceMs, 4); - assert.equal(status.sleepTimeout, 300); - assert.equal(status.activeProfile, 3); - await assert.rejects(m6.setProfile(4), /between 1 and 3/); - await assert.rejects(m6.setDebounceTime(21), /between 0 and 20/); - await assert.rejects(m6.setSleepTimeout(90), /must be one of/); - assert.deepEqual(m6.getSleepOptions().slice(0, 3), [60, 180, 300]); - assert.equal(m6.getDebounceOptions().length, 21); -}); - -test("angle tuning is offered only when the mouse flags support for it", async () => { - const fake = new FakeM6Device(); - fake.angleSupported = false; - const m6 = client(fake); - assert.equal((await m6.readStatus()).angleTuning, undefined); - await assert.rejects(m6.setAngleTuning(5), /does not support angle tuning/); - assert.equal(lastSent(fake, 0x42), undefined); -}); - -test("a non-zero ack code fails the write instead of a silent re-read", async () => { - const fake = new FakeM6Device(); - fake.rejectNext = true; - await assert.rejects(client(fake).setDebounceTime(3), /rejected command 0x43 \(code 7\)/); -}); - -test("a mouse that hides its profiles shows no profile card", async () => { - const fake = new FakeM6Device(); - fake.profileCount = 0; - const status = await client(fake).readStatus(); - assert.equal(status.activeProfile, null); - assert.equal(status.profileCount, undefined); -}); diff --git a/src/drivers/keychron/m6-hid.ts b/src/drivers/keychron/m6-hid.ts deleted file mode 100644 index f8268a1..0000000 --- a/src/drivers/keychron/m6-hid.ts +++ /dev/null @@ -1,568 +0,0 @@ -import type { MouseStatus } from "../mouse-types.ts"; -import { - KEYCHRON_M6_COMMAND_REPORT_ID as COMMAND_REPORT_ID, - KEYCHRON_M6_PRODUCT_ID as PRODUCT_ID, - KEYCHRON_M6_RECEIVER_PRODUCT_ID as RECEIVER_PRODUCT_ID, - KEYCHRON_M6_SETTINGS_REPORT_ID as SETTINGS_REPORT_ID, - KEYCHRON_M6_STATUS_COMMAND as STATUS_COMMAND, - KEYCHRON_M6_STATUS_PACKET_LENGTH as PACKET_LENGTH, - KEYCHRON_M6_USAGE as USAGE, - KEYCHRON_M6_USAGE_PAGE as USAGE_PAGE, - KEYCHRON_VENDOR_ID, -} from "@openmouse/protocol/keychron"; - -const QUERY_TIMEOUT_MS = 1200; -const SETTINGS_PACKET_LENGTH = 20; -const DPI_STAGE_COUNT = 5; -const DPI_MIN = 100; -const DPI_MAX = 26_000; -const DPI_STEP = 50; -/** Polling table entries index this scale (Keychron Launcher POLLING_RATE_VALUE_SCALE). */ -const POLLING_RATES = [125, 500, 1000, 2000, 4000, 8000] as const; -const ACK = 0xe4; -/** Firmware LOD codes on the PAW3950: 1 = 1 mm, 2 = 2 mm, 3 = 0.7 mm. */ -const LOD_BY_LEVEL = { Low: 3, Medium: 1, High: 2 } as const; -const SLEEP_MINUTES = [1, 3, 5, 10, 15, 30, 60, 120, 240] as const; -const DEBOUNCE_MAX_MS = 20; -const ANGLE_LIMIT = 30; -const PROFILE_MAX = 5; - -/** Commands on the 63-byte 0xb3 report (answers arrive on 0xb4). */ -const CMD = { firmware: 0x04, status: 0x06 } as const; -/** Commands on the 20-byte 0xb5 report (answers arrive on 0xb6). */ -const SET = { - version: 0x02, - sleep: 0x0a, - profile: 0x0e, - dpi: 0x40, - polling: 0x41, - sensor: 0x42, - debounce: 0x43, -} as const; - -type LiftOff = NonNullable; - -type M6Settings = { - profile: number; - profileCount: number; - activeDpiStage: number; - /** All five hardware slots; only the first `stageCount` are in use. */ - dpiStages: number[]; - stageCount: number; - pollingTable: number[]; - pollingIndex: number; - lod: number; - lodLevel: number; - rippleControl: boolean; - angleSnapping: boolean; - motionSync: boolean; - scrollReversed: boolean; - maxSpeed: boolean; - angle: number; - angleSupported: boolean; - debounceMs: number; - sleepMinutes: number; - batteryPercent: number; - charging: boolean; -}; - -type M6Identity = { firmware: string | null; workMode: number }; - -/** - * Keychron M6 client for the 0xffc1 vendor collection, the "8k" variant of - * Keychron Launcher's mouse protocol (63-byte 0xb3/0xb4 reads, 20-byte - * 0xb5/0xb6 writes). Not the VIA raw-HID protocol the Nape Pro speaks. - * - * Status report (0x06) layout, decoded from Keychron Launcher and confirmed - * on an M6 (firmware 1.0.3, USB) for every field the driver reads: - * [1] active onboard profile, zero-based; [50] profile count - * [2..4] per connection (USB, 2.4 GHz, Bluetooth): DPI stage in the low - * nibble, polling index in the high nibble - * [5..14] five DPI slots, little-endian 16-bit - * [15] bits 0-1 lift-off code, bit 2 ripple control, bit 3 angle - * snapping, bit 4 motion sync, bit 6 reversed scroll - * [16] DPI stages in use (1-5); unused tail slots keep stale values - * [17] debounce in ms; [18] sleep timeout in minutes - * [19] battery percent, bit 7 = charging - * [43..48] polling table as indexes into POLLING_RATES; [49] its length - * [52] bit 0 max-speed mode; [53] bit 2 = angle tuning supported - * [55] sensor angle as a signed byte; [61] lift-off fine level - * Settings writes are acknowledged with [0xe4, code, command]; code 0 is - * success and 7 means the command is not supported on this connection. - * The 0x40 write packet is the DPI part of this layout shifted one byte down - * (stage at [1..3], slots at [4..13], stage count at [14]). - */ -export class KeychronM6HidClient { - readonly device: HIDDevice; - private openedListener = false; - private identity: M6Identity | null = null; - private responseWaiter: { - match: (bytes: Uint8Array) => boolean; - resolve: (bytes: Uint8Array) => void; - reject: (reason: Error) => void; - } | null = null; - - private readonly onInputReport = (event: HIDInputReportEvent): void => { - if (!this.responseWaiter) return; - const bytes = new Uint8Array(event.data.buffer.slice( - event.data.byteOffset, - event.data.byteOffset + event.data.byteLength, - )); - if (!this.responseWaiter.match(bytes)) return; - const waiter = this.responseWaiter; - this.responseWaiter = null; - waiter.resolve(bytes); - }; - - constructor(device: HIDDevice) { - this.device = device; - } - - static isSupported(device: HIDDevice): boolean { - return device.vendorId === KEYCHRON_VENDOR_ID - && (device.productId === PRODUCT_ID || device.productId === RECEIVER_PRODUCT_ID) - && device.collections.some((collection) => - collection.usagePage === USAGE_PAGE - && collection.usage === USAGE - && collection.outputReports.some((report) => report.reportId === COMMAND_REPORT_ID) - && collection.inputReports.some((report) => report.reportId === COMMAND_REPORT_ID + 1)); - } - - async open(): Promise { - if (!this.device.opened) await this.device.open(); - if (!this.openedListener) { - this.device.addEventListener("inputreport", this.onInputReport); - this.openedListener = true; - } - } - - async close(): Promise { - if (this.openedListener) { - this.device.removeEventListener("inputreport", this.onInputReport); - this.openedListener = false; - } - this.responseWaiter?.reject(new Error("The Keychron M6 device was closed.")); - this.responseWaiter = null; - if (this.device.opened) await this.device.close(); - } - - getDpiOptions(): number[] { - return Array.from({ length: (DPI_MAX - DPI_MIN) / DPI_STEP + 1 }, (_, index) => DPI_MIN + index * DPI_STEP); - } - - getSleepOptions(): number[] { - return SLEEP_MINUTES.map((minutes) => minutes * 60); - } - - getDebounceOptions(): number[] { - return Array.from({ length: DEBOUNCE_MAX_MS + 1 }, (_, ms) => ms); - } - - readonly canDisableSleep = false; - - async readStatus(): Promise { - await this.open(); - const identity = await this.readIdentity(); - const settings = this.parseStatus(await this.queryStatus(), identity.workMode); - const dpi = settings.dpiStages[settings.activeDpiStage] ?? settings.dpiStages[0] ?? 800; - const pollingRateHz = POLLING_RATES[settings.pollingTable[settings.pollingIndex] ?? 2] ?? 1000; - const supportedPollingRates = settings.pollingTable - .map((value) => POLLING_RATES[value]) - .filter((value): value is (typeof POLLING_RATES)[number] => value !== undefined) - .sort((a, b) => a - b); - const liftOffDistance = (Object.keys(LOD_BY_LEVEL) as LiftOff[]) - .find((level) => LOD_BY_LEVEL[level] === settings.lod) ?? null; - - return { - brand: "Keychron", - name: "Keychron M6", - ui: { - family: "keychron-m6", - defaultDisplayName: "Keychron M6", - hideUnsupportedPollingRates: true, - forceShowBattery: true, - statusNote: "Lift-off: Low is 0.7 mm, Medium is 1 mm, High is 2 mm.", - dpiStageEditor: { - maxStages: DPI_STAGE_COUNT, - countEditable: true, - minDpi: DPI_MIN, - maxDpi: DPI_MAX, - stepDpi: DPI_STEP, - }, - }, - batteryPercent: settings.batteryPercent <= 100 ? settings.batteryPercent : null, - batteryState: settings.charging ? "Charging" : "Discharging", - dpi, - dpiStages: settings.dpiStages.slice(0, settings.stageCount), - activeDpiStage: settings.activeDpiStage, - pollingRateHz, - supportedPollingRates: supportedPollingRates.length ? supportedPollingRates : [pollingRateHz], - activeProfile: settings.profileCount > 1 ? settings.profile + 1 : null, - profileCount: settings.profileCount > 1 ? settings.profileCount : undefined, - connectionType: this.device.productId === RECEIVER_PRODUCT_ID ? "Wireless" : "Wired", - connectionDetail: this.device.productId === RECEIVER_PRODUCT_ID - ? "2.4 GHz (Keychron Link-KM)" - : "Wired USB", - liftOffDistance, - supportedLiftOffDistances: Object.keys(LOD_BY_LEVEL) as LiftOff[], - motionSync: settings.motionSync, - angleSnapping: settings.angleSnapping, - rippleControl: settings.rippleControl, - angleTuning: settings.angleSupported ? settings.angle : undefined, - debounceMs: settings.debounceMs, - sleepTimeout: settings.sleepMinutes > 0 && settings.sleepMinutes < 0xff ? settings.sleepMinutes * 60 : null, - firmware: [identity.firmware ?? "Firmware unavailable"], - }; - } - - async setDpi(dpi: number): Promise { - this.requireDpi(dpi); - const settings = await this.readSettings(); - settings.dpiStages[settings.activeDpiStage] = dpi; - await this.writeSettings(this.dpiSettingsPacket(settings)); - const confirmed = (await this.readSettings()).dpiStages[settings.activeDpiStage]; - if (confirmed !== dpi) throw new Error(`The Keychron M6 kept ${confirmed} DPI instead of ${dpi} DPI.`); - return confirmed; - } - - async setDpiStageValue(stage: number, dpi: number): Promise { - this.requireDpi(dpi); - const settings = await this.readSettings(); - this.requireStage(stage, settings.stageCount); - settings.dpiStages[stage] = dpi; - await this.writeSettings(this.dpiSettingsPacket(settings)); - const confirmed = (await this.readSettings()).dpiStages[stage]; - if (confirmed !== dpi) { - throw new Error(`The Keychron M6 kept ${confirmed} DPI on stage ${stage + 1} instead of ${dpi} DPI.`); - } - return confirmed; - } - - async setActiveDpiStage(stage: number): Promise { - const settings = await this.readSettings(); - this.requireStage(stage, settings.stageCount); - settings.activeDpiStage = stage; - await this.writeSettings(this.dpiSettingsPacket(settings)); - const confirmed = (await this.readSettings()).activeDpiStage; - if (confirmed !== stage) throw new Error(`The Keychron M6 kept DPI stage ${confirmed + 1}.`); - return confirmed; - } - - async setDpiStageCount(count: number): Promise { - if (!Number.isInteger(count) || count < 1 || count > DPI_STAGE_COUNT) { - throw new Error(`The Keychron M6 holds between 1 and ${DPI_STAGE_COUNT} DPI stages.`); - } - const settings = await this.readSettings(); - settings.stageCount = count; - // All five hardware slots keep their DPI; the count only decides how many - // the DPI button cycles through. The active stage rides along on the same - // packet, so shrinking past it would write a stage the mouse cannot hold. - if (settings.activeDpiStage >= count) settings.activeDpiStage = count - 1; - await this.writeSettings(this.dpiSettingsPacket(settings)); - const confirmed = (await this.readSettings()).stageCount; - if (confirmed !== count) { - throw new Error(`The Keychron M6 kept ${confirmed} DPI stages instead of ${count}.`); - } - return confirmed; - } - - async setPollingRate(rateHz: number): Promise { - const settings = await this.readSettings(); - const pollingIndex = settings.pollingTable.findIndex((value) => POLLING_RATES[value] === rateHz); - if (pollingIndex < 0) throw new Error(`The Keychron M6 does not support ${rateHz} Hz on this connection.`); - settings.pollingIndex = pollingIndex; - await this.writeSettings(this.pollingSettingsPacket(settings)); - const confirmed = await this.readSettings(); - const actual = POLLING_RATES[confirmed.pollingTable[confirmed.pollingIndex] ?? 2] ?? 1000; - if (actual !== rateHz) throw new Error(`The Keychron M6 kept ${actual} Hz instead of ${rateHz} Hz.`); - return actual; - } - - async setLiftOffDistance(lod: LiftOff): Promise { - const code = LOD_BY_LEVEL[lod]; - if (code === undefined) throw new Error(`The Keychron M6 has no ${lod} lift-off distance.`); - const confirmed = await this.writeSensor({ lod: code }); - if (confirmed.lod !== code) throw new Error(`The Keychron M6 kept lift-off code ${confirmed.lod}.`); - return lod; - } - - async setMotionSync(enabled: boolean): Promise { - return this.writeSensorFlag("motionSync", enabled); - } - - async setAngleSnapping(enabled: boolean): Promise { - return this.writeSensorFlag("angleSnapping", enabled); - } - - async setRippleControl(enabled: boolean): Promise { - return this.writeSensorFlag("rippleControl", enabled); - } - - async setAngleTuning(degrees: number): Promise { - if (!Number.isInteger(degrees) || Math.abs(degrees) > ANGLE_LIMIT) { - throw new Error(`Keychron M6 angle tuning must be a whole number between -${ANGLE_LIMIT} and ${ANGLE_LIMIT} degrees.`); - } - if (!(await this.readSettings()).angleSupported) { - throw new Error("This Keychron M6 firmware does not support angle tuning."); - } - const packet = new Uint8Array(SETTINGS_PACKET_LENGTH); - packet[0] = SET.sensor; - packet[9] = 2; - packet[10] = degrees & 0xff; - await this.writeSettings(packet); - const confirmed = (await this.readSettings()).angle; - if (confirmed !== degrees) throw new Error(`The Keychron M6 kept a ${confirmed}° sensor angle instead of ${degrees}°.`); - return confirmed; - } - - async setDebounceTime(debounceMs: number): Promise { - if (!Number.isInteger(debounceMs) || debounceMs < 0 || debounceMs > DEBOUNCE_MAX_MS) { - throw new Error(`Keychron M6 debounce must be between 0 and ${DEBOUNCE_MAX_MS} ms.`); - } - await this.open(); - const packet = new Uint8Array(SETTINGS_PACKET_LENGTH); - packet[0] = SET.debounce; - packet[1] = debounceMs; - await this.writeSettings(packet); - const confirmed = (await this.readSettings()).debounceMs; - if (confirmed !== debounceMs) throw new Error(`The Keychron M6 kept ${confirmed} ms debounce instead of ${debounceMs} ms.`); - return confirmed; - } - - async setSleepTimeout(seconds: number): Promise { - const minutes = Math.round(seconds / 60); - if (!SLEEP_MINUTES.includes(minutes as (typeof SLEEP_MINUTES)[number])) { - throw new Error(`Keychron M6 sleep timeout must be one of ${SLEEP_MINUTES.join(", ")} minutes.`); - } - await this.open(); - const packet = new Uint8Array(SETTINGS_PACKET_LENGTH); - packet[0] = SET.sleep; - packet[1] = 1; - packet[2] = minutes; - await this.writeSettings(packet); - const confirmed = (await this.readSettings()).sleepMinutes; - if (confirmed !== minutes) throw new Error(`The Keychron M6 kept a ${confirmed} minute sleep timeout instead of ${minutes}.`); - return confirmed * 60; - } - - /** Switch the onboard profile (1-based, as the panel numbers them). */ - async setProfile(profile: number): Promise { - const settings = await this.readSettings(); - if (!Number.isInteger(profile) || profile < 1 || profile > Math.min(settings.profileCount, PROFILE_MAX)) { - throw new Error(`Keychron M6 profile must be between 1 and ${settings.profileCount}.`); - } - const packet = new Uint8Array(SETTINGS_PACKET_LENGTH); - packet[0] = SET.profile; - packet[1] = profile - 1; - await this.writeSettings(packet); - const confirmed = (await this.readSettings()).profile + 1; - if (confirmed !== profile) throw new Error(`The Keychron M6 kept profile ${confirmed}.`); - return confirmed; - } - - private async writeSensorFlag( - flag: "motionSync" | "angleSnapping" | "rippleControl", - enabled: boolean, - ): Promise { - const confirmed = await this.writeSensor({ [flag]: enabled }); - if (confirmed[flag] !== enabled) throw new Error(`The Keychron M6 kept ${flag} ${confirmed[flag] ? "on" : "off"}.`); - return confirmed[flag]; - } - - /** - * The 0x42 packet carries every sensor option at once; each toggle byte is - * 1 = on, 2 = off, 0 = leave alone, so resend the current state with the - * requested change applied. - */ - private async writeSensor(change: Partial): Promise { - const settings = { ...(await this.readSettings()), ...change }; - const packet = new Uint8Array(SETTINGS_PACKET_LENGTH); - packet[0] = SET.sensor; - packet[1] = settings.lod; - packet[2] = settings.rippleControl ? 1 : 2; - packet[3] = settings.angleSnapping ? 1 : 2; - packet[4] = settings.motionSync ? 1 : 2; - packet[6] = settings.scrollReversed ? 2 : 1; - packet[8] = settings.maxSpeed ? 2 : 1; - packet[11] = settings.lodLevel; - await this.writeSettings(packet); - return await this.readSettings(); - } - - private requireDpi(dpi: number): void { - if (!Number.isInteger(dpi) || dpi < DPI_MIN || dpi > DPI_MAX || dpi % DPI_STEP !== 0) { - throw new Error(`Keychron M6 DPI must be a multiple of ${DPI_STEP} between ${DPI_MIN} and ${DPI_MAX}.`); - } - } - - private requireStage(stage: number, stageCount: number): void { - if (!Number.isInteger(stage) || stage < 0 || stage >= stageCount) { - throw new Error(`DPI stage must be between 1 and ${stageCount}.`); - } - } - - private async readSettings(): Promise { - await this.open(); - const identity = await this.readIdentity(); - return this.parseStatus(await this.queryStatus(), identity.workMode); - } - - /** - * Firmware string (0x04 on 0xb3) and connection mode (0x02 on 0xb5) never - * change while connected, so they are read once. Either failing leaves the - * mouse usable: the mode falls back to what the product ID implies. - */ - private async readIdentity(): Promise { - if (this.identity) return this.identity; - const fallbackMode = this.device.productId === RECEIVER_PRODUCT_ID ? 1 : 0; - const version = await this.querySettings(SET.version, [fallbackMode]).catch(() => null); - const workMode = version ? (version[9] ?? fallbackMode) & 0x07 : fallbackMode; - const firmware = await this.query(COMMAND_REPORT_ID, PACKET_LENGTH, [CMD.firmware, workMode], (bytes) => bytes[0] === CMD.firmware) - .then((bytes) => decodeFirmwareString(bytes) ?? (version ? decodeFirmwareNibbles(version) : null)) - .catch(() => (version ? decodeFirmwareNibbles(version) : null)); - this.identity = { firmware, workMode }; - return this.identity; - } - - private parseStatus(bytes: Uint8Array, workMode: number): M6Settings { - if (bytes.length < 51 || bytes[0] !== STATUS_COMMAND) { - throw new Error("The Keychron M6 returned an invalid status report."); - } - const dpiStages = Array.from({ length: DPI_STAGE_COUNT }, (_, index) => { - const offset = 5 + index * 2; - return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8); - }); - const pollingCount = Math.min(bytes[49] || 6, 6); - const stageCount = Math.min(bytes[16] || DPI_STAGE_COUNT, DPI_STAGE_COUNT); - const levels = bytes[2 + Math.min(workMode, 2)] ?? 0; - const flags = bytes[15] ?? 0; - const angle = bytes[55] ?? 0; - return { - profile: bytes[1] ?? 0, - profileCount: Math.min(bytes[50] ?? 0, PROFILE_MAX), - activeDpiStage: Math.min(levels & 0x0f, stageCount - 1), - dpiStages, - stageCount, - pollingTable: Array.from(bytes.slice(43, 43 + pollingCount)), - pollingIndex: (levels >> 4) & 0x0f, - lod: flags & 0x03, - lodLevel: bytes[61] ?? 0, - rippleControl: (flags & 0x04) !== 0, - angleSnapping: (flags & 0x08) !== 0, - motionSync: (flags & 0x10) !== 0, - scrollReversed: (flags & 0x40) !== 0, - maxSpeed: ((bytes[52] ?? 0) & 0x01) !== 0, - angle: angle > 127 ? angle - 256 : angle, - angleSupported: ((bytes[53] ?? 0) & 0x04) !== 0, - debounceMs: bytes[17] ?? 0, - sleepMinutes: bytes[18] ?? 0, - batteryPercent: (bytes[19] ?? 0) & 0x7f, - charging: ((bytes[19] ?? 0) & 0x80) !== 0, - }; - } - - private dpiSettingsPacket(settings: M6Settings): Uint8Array { - const packet = new Uint8Array(SETTINGS_PACKET_LENGTH); - packet[0] = SET.dpi; - packet[1] = settings.activeDpiStage; - packet[2] = settings.activeDpiStage; - packet[3] = settings.activeDpiStage; - settings.dpiStages.forEach((dpi, index) => { - packet[4 + index * 2] = dpi & 0xff; - packet[5 + index * 2] = (dpi >> 8) & 0xff; - }); - packet[14] = settings.stageCount; - return packet; - } - - private pollingSettingsPacket(settings: M6Settings): Uint8Array { - const packet = new Uint8Array(SETTINGS_PACKET_LENGTH); - packet[0] = SET.polling; - packet[1] = settings.pollingIndex; - packet[2] = settings.pollingIndex; - packet[9] = settings.pollingTable.length; - packet.set(settings.pollingTable.slice(0, 6), 3); - return packet; - } - - private async queryStatus(): Promise { - return await this.query(COMMAND_REPORT_ID, PACKET_LENGTH, [STATUS_COMMAND], (bytes) => bytes[0] === STATUS_COMMAND); - } - - private async querySettings(command: number, args: number[]): Promise { - return await this.query(SETTINGS_REPORT_ID, SETTINGS_PACKET_LENGTH, [command, ...args], (bytes) => bytes[0] === command); - } - - private async writeSettings(packet: Uint8Array): Promise { - const command = packet[0] ?? 0; - const reply = await this.query( - SETTINGS_REPORT_ID, - SETTINGS_PACKET_LENGTH, - Array.from(packet), - (bytes) => bytes[0] === command || (bytes[0] === ACK && bytes[2] === command), - ); - if (reply[0] === ACK && reply[1] !== 0) { - throw new Error(`The Keychron M6 rejected command 0x${command.toString(16)} (code ${reply[1]}).`); - } - } - - private async query( - reportId: number, - length: number, - payload: number[], - match: (bytes: Uint8Array) => boolean, - ): Promise { - if (this.responseWaiter) throw new Error("Another Keychron M6 request is already in progress."); - const packet = new Uint8Array(length); - packet.set(payload.slice(0, length)); - let timeout = 0; - let rejectResponse: ((reason: Error) => void) | null = null; - const response = new Promise((resolve, reject) => { - rejectResponse = reject; - timeout = window.setTimeout(() => { - this.responseWaiter = null; - reject(new Error(`The Keychron M6 did not answer command 0x${packet[0]?.toString(16)}.`)); - }, QUERY_TIMEOUT_MS); - this.responseWaiter = { - match, - resolve: (bytes) => { - window.clearTimeout(timeout); - resolve(bytes); - }, - reject: (reason) => { - window.clearTimeout(timeout); - reject(reason); - }, - }; - }); - void response.catch(() => undefined); - try { - await this.device.sendReport(reportId, packet.buffer); - } catch (error) { - this.responseWaiter = null; - const detail = error instanceof Error ? error.message : String(error); - (rejectResponse as ((reason: Error) => void) | null)?.( - new Error(`Chrome could not write Keychron M6 HID report. ${detail}`), - ); - } - return await response; - } -} - -/** 0x04 answer: [1] is the length of the ASCII version that starts at [2]. */ -function decodeFirmwareString(bytes: Uint8Array): string | null { - const length = Math.min(bytes[1] ?? 0, bytes.length - 2); - const text = Array.from(bytes.slice(2, 2 + length)) - .filter((byte) => byte >= 0x20 && byte < 0x7f) - .map((byte) => String.fromCharCode(byte)) - .join("") - .trim(); - if (!text) return null; - return text.startsWith("v") ? text : `v${text}`; -} - -/** 0x02 answer: major at [8], minor and patch in the nibbles of [7]. */ -function decodeFirmwareNibbles(bytes: Uint8Array): string | null { - if (bytes.length < 9) return null; - return `v${bytes[8]}.${(bytes[7] ?? 0) >> 4}.${(bytes[7] ?? 0) & 0x0f}`; -} diff --git a/src/drivers/keychron/mouse-1k-hid.test.ts b/src/drivers/keychron/mouse-1k-hid.test.ts new file mode 100644 index 0000000..4a88c7b --- /dev/null +++ b/src/drivers/keychron/mouse-1k-hid.test.ts @@ -0,0 +1,279 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { MouseLighting } from "../mouse-types.ts"; +import { Keychron1kHidClient } from "./mouse-1k-hid.ts"; + +/** + * Speaks Launcher's "1k" protocol: every command is a feature report (20 + * bytes on 0x51, 64 on 0x52 for buttons) and its answer is fetched with a + * feature read of the same report, which starts with the report ID. + */ +class FakeKeychron1kMouse { + vendorId = 0x3434; + productId = 0xd033; + opened = false; + readonly sent: Array<{ reportId: number; packet: Uint8Array }> = []; + private answers = new Map(); + workMode = 0; + reportedProductId = 0xd033; + paired: Array<[number, boolean]> = []; + failStatus = false; + levels = [0x20, 0x11, 0x00]; + dpiStages = [400, 800, 1600, 3200, 5000]; + stageCount = 4; + lod = 2; + ripple = true; + angleSnap = false; + motion = false; + scrollReversed = false; + debounce = 4; + battery = 80; + power = 1; + buttons = new Map(); + light = { mode: 3, brightness: 191, speed: 51, rgb: [255, 128, 0] }; + readonly collections = [{ + usagePage: 0x8c, + usage: 0x01, + featureReports: [{ reportId: 0x51 }, { reportId: 0x52 }], + inputReports: [], + outputReports: [], + }]; + + async open(): Promise { this.opened = true; } + async close(): Promise { this.opened = false; } + + async sendFeatureReport(reportId: number, data: BufferSource): Promise { + const packet = new Uint8Array(data as ArrayBuffer); + this.sent.push({ reportId, packet }); + this.answers.set(reportId, reportId === 0x52 ? this.button(packet) : this.settings(packet)); + } + + async receiveFeatureReport(reportId: number): Promise { + const answer = this.answers.get(reportId) ?? new Uint8Array(reportId === 0x52 ? 64 : 20); + const bytes = new Uint8Array(answer.length + 1); + bytes[0] = reportId; + bytes.set(answer, 1); + return new DataView(bytes.buffer); + } + + private settings(packet: Uint8Array): Uint8Array { + const answer = new Uint8Array(20); + answer[0] = packet[0] ?? 0; + switch (packet[0]) { + case 0x06: + answer.set([0x06, 2, 0, 0x34, 0x34, this.reportedProductId & 0xff, this.reportedProductId >> 8, 0x12, 0x01, this.workMode, this.battery, this.power]); + break; + case 0x04: + answer.set([0x04, 5, ...Array.from("1.1.2", (c) => c.charCodeAt(0))]); + break; + case 0x03: + answer[1] = this.paired.length; + this.paired.forEach(([pid, connected], index) => answer.set([0x34, 0x34, pid & 0xff, pid >> 8, connected ? 1 : 0], 2 + index * 5)); + break; + case 0x07: + if (this.failStatus) return new Uint8Array(20); + answer.set(this.levels, 2); + this.dpiStages.forEach((dpi, index) => answer.set([dpi & 0xff, dpi >> 8], 5 + index * 2)); + answer[15] = this.lod | (this.ripple ? 0x04 : 0) | (this.angleSnap ? 0x08 : 0) | (this.motion ? 0x10 : 0) | (this.scrollReversed ? 0x40 : 0); + answer[16] = this.stageCount; + answer[17] = this.debounce; + break; + case 0x40: + this.levels = this.levels.map((level, index) => (level & 0xf0) | (packet[1 + index] ?? 0)); + this.dpiStages = this.dpiStages.map((_, index) => (packet[4 + index * 2] ?? 0) | ((packet[5 + index * 2] ?? 0) << 8)); + this.stageCount = packet[14] ?? 0; + break; + case 0x41: + this.levels = this.levels.map((level) => (level & 0x0f) | ((packet[1] ?? 0) << 4)); + break; + case 0x42: + this.lod = packet[1] || this.lod; + this.ripple = packet[2] === 1; + this.angleSnap = packet[3] === 1; + this.motion = packet[4] === 1; + this.scrollReversed = packet[6] === 2; + break; + case 0x43: + this.debounce = packet[1] ?? 0; + break; + case 0x12: + answer[3] = this.light.mode; + break; + case 0x18: + answer.set([0x18, packet[1] ?? 0, ...this.light.rgb, this.light.brightness, this.light.speed]); + break; + case 0x22: + this.light.mode = packet[2] ?? 0; + break; + case 0x23: + this.light.brightness = packet[3] ?? 0; + break; + case 0x27: + this.light.speed = packet[3] ?? 0; + break; + case 0x28: + this.light.rgb = Array.from(packet.slice(3, 6)); + break; + default: + } + return answer; + } + + private button(packet: Uint8Array): Uint8Array { + const answer = new Uint8Array(64); + const index = packet[1] ?? 0; + if (packet[0] === 0x62) answer.set([0x62, index, 0, ...(this.buttons.get(index) ?? [0])]); + if (packet[0] === 0x52) { + const record = Array.from(packet.slice(3, 7)); + if (record[0] === 0) this.buttons.delete(index); + else this.buttons.set(index, record); + answer.set([0xe4, 0, 0x52]); + } + return answer; + } +} + +const client = (fake: FakeKeychron1kMouse): Keychron1kHidClient => new Keychron1kHidClient(fake as unknown as HIDDevice); +const lastSent = (fake: FakeKeychron1kMouse, command: number, reportId = 0x51) => + [...fake.sent].reverse().find((entry) => entry.reportId === reportId && entry.packet[0] === command)?.packet; + +test("claims usage page 0x8c only when the device has no 0xffc1 interface", () => { + const fake = new FakeKeychron1kMouse(); + assert.equal(Keychron1kHidClient.isSupported(fake as unknown as HIDDevice), true); + const both = { ...fake, collections: [...fake.collections, { usagePage: 0xffc1, usage: 1, featureReports: [], inputReports: [], outputReports: [] }] }; + assert.equal(Keychron1kHidClient.isSupported(both as unknown as HIDDevice), false); + const noReport = { ...fake, collections: [{ ...fake.collections[0]!, featureReports: [{ reportId: 0x10 }] }] }; + assert.equal(Keychron1kHidClient.isSupported(noReport as unknown as HIDDevice), false); + assert.equal(Keychron1kHidClient.isSupported({ ...fake, vendorId: 0x1d57 } as unknown as HIDDevice), false); +}); + +test("reads identity, the 0x07 status, buttons and lighting", async () => { + const fake = new FakeKeychron1kMouse(); + const status = await client(fake).readStatus(); + assert.equal(status.name, "Keychron M3"); + assert.equal(status.ui?.family, "keychron-1k"); + assert.equal(status.ui?.hideSleepCard, true); + assert.deepEqual(status.dpiStages, [400, 800, 1600, 3200]); + assert.equal(status.activeDpiStage, 0); + assert.equal(status.dpi, 400); + // USB slot: polling gear 2 of the fixed 125/500/1000 table. + assert.equal(status.pollingRateHz, 1000); + assert.deepEqual(status.supportedPollingRates, [125, 500, 1000]); + assert.equal(status.liftOffDistance, "High"); + assert.deepEqual(status.supportedLiftOffDistances, ["Low", "High"]); + assert.equal(status.rippleControl, true); + assert.equal(status.motionSync, false); + assert.equal(status.debounceMs, 4); + assert.equal(status.batteryPercent, 80); + assert.equal(status.batteryState, "Charging"); + assert.equal(status.activeProfile, null); + assert.equal(status.sleepTimeout, undefined); + assert.deepEqual(status.firmware, ["v1.1.2"]); + assert.equal(status.buttonMappings?.Lighting, "Default"); + assert.equal(status.buttonMappings?.Forward, "Forward"); + assert.equal(status.lighting?.mode, "Spectrum"); + assert.equal(status.lighting?.color, "#ff8000"); + assert.equal(status.lighting?.brightness, 75); + assert.equal(status.lighting?.speed, 1); + assert.deepEqual(Array.from(lastSent(fake, 0x62, 0x52)!.slice(0, 2)), [0x62, 14]); + assert.equal(lastSent(fake, 0x62, 0x52)!.length, 64); +}); + +test("DPI writes reuse the 0x40 layout on feature report 0x51", async () => { + const fake = new FakeKeychron1kMouse(); + const m3 = client(fake); + assert.equal(await m3.setDpiStageValue(1, 1200), 1200); + assert.deepEqual(Array.from(lastSent(fake, 0x40)!.slice(0, 15)), [0x40, 0, 0, 0, 0x90, 0x01, 0xb0, 0x04, 0x40, 0x06, 0x80, 0x0c, 0x88, 0x13, 4]); + assert.equal(lastSent(fake, 0x40)!.length, 20); + assert.equal(await m3.setActiveDpiStage(2), 2); + assert.equal(await m3.setDpiStageCount(2), 2); + const status = await m3.readStatus(); + assert.deepEqual(status.dpiStages, [400, 1200]); + assert.equal(status.activeDpiStage, 1); + await assert.rejects(m3.setDpiStageValue(0, 26_050), /between 100 and 26000/); +}); + +test("polling goes out as a gear with Launcher's fixed table", async () => { + const fake = new FakeKeychron1kMouse(); + const m3 = client(fake); + assert.equal(await m3.setPollingRate(500), 500); + assert.deepEqual(Array.from(lastSent(fake, 0x41)!.slice(0, 7)), [0x41, 1, 1, 0, 1, 2, 0]); + await assert.rejects(m3.setPollingRate(2000), /does not support 2000 Hz/); +}); + +test("sensor writes leave out the 8k-only bytes", async () => { + const fake = new FakeKeychron1kMouse(); + const m3 = client(fake); + assert.equal(await m3.setMotionSync(true), true); + assert.equal(await m3.setLiftOffDistance("Low"), "Low"); + const packet = lastSent(fake, 0x42)!; + assert.deepEqual(Array.from(packet.slice(0, 12)), [0x42, 1, 1, 2, 1, 0, 1, 0, 0, 0, 0, 0]); + assert.equal(fake.lod, 1); + assert.equal(await m3.setDebounceTime(12), 12); + assert.deepEqual(Array.from(lastSent(fake, 0x43)!.slice(0, 2)), [0x43, 12]); +}); + +test("button records go on report 0x52 with the 1k Back and Forward codes", async () => { + const fake = new FakeKeychron1kMouse(); + const m3 = client(fake); + await m3.setButtonMapping("Forward", "Back"); + // The "1k" enum swaps them: Back is 0x100000. + assert.deepEqual(Array.from(lastSent(fake, 0x52, 0x52)!.slice(0, 7)), [0x52, 3, 0, 1, 0x10, 0x00, 0x00]); + assert.equal(lastSent(fake, 0x52, 0x52)!.length, 64); + assert.equal((await m3.readStatus()).buttonMappings?.Forward, "Back"); + await m3.setButtonMapping("Lighting", "DPI +"); + assert.deepEqual(Array.from(lastSent(fake, 0x52, 0x52)!.slice(0, 5)), [0x52, 7, 0, 5, 2]); + await assert.rejects(m3.setButtonMapping("Left", "Scroll Up"), /at least one button as Left Click/); +}); + +test("lighting is written the way Launcher's 1k panel does, one field at a time", async () => { + const fake = new FakeKeychron1kMouse(); + const m3 = client(fake); + const lighting = (await m3.readStatus()).lighting!; + const next: MouseLighting = { ...lighting, mode: "Static", color: "#ff0000", brightness: 100, speed: 5 }; + const confirmed = await m3.setLighting(next); + assert.deepEqual(Array.from(lastSent(fake, 0x22)!.slice(0, 3)), [0x22, 1, 1]); + assert.deepEqual(Array.from(lastSent(fake, 0x23)!.slice(0, 4)), [0x23, 1, 1, 255]); + assert.deepEqual(Array.from(lastSent(fake, 0x27)!.slice(0, 4)), [0x27, 1, 1, 255]); + assert.deepEqual(Array.from(lastSent(fake, 0x28)!.slice(0, 6)), [0x28, 1, 1, 255, 0, 0]); + assert.equal(confirmed.mode, "Static"); + assert.equal(confirmed.color, "#ff0000"); + const writes = fake.sent.length; + await m3.setLighting({ ...confirmed, mode: "Off" }); + assert.equal(fake.light.mode, 0); + assert.equal(fake.sent.slice(writes).filter(({ packet }) => [0x23, 0x27, 0x28].includes(packet[0] ?? 0)).length, 0); +}); + +test("budget models hide the sensor toggles and lift-off", async () => { + const fake = new FakeKeychron1kMouse(); + fake.productId = 0xd058; + fake.reportedProductId = 0xd058; + const status = await client(fake).readStatus(); + assert.equal(status.name, "Keychron BM22"); + assert.equal(status.ui?.hideProcessingCard, true); + assert.equal(status.rippleControl, undefined); + assert.equal(status.liftOffDistance, null); + assert.equal(status.ui?.dpiStageEditor?.maxDpi, 2400); + assert.equal(status.buttonMappings?.DPI, "DPI Loop"); + assert.equal(status.lighting, undefined); +}); + +test("behind a receiver the 0x03 list names the connected mouse", async () => { + const fake = new FakeKeychron1kMouse(); + fake.productId = 0xd024; + fake.reportedProductId = 0xd024; + fake.workMode = 1; + fake.paired = [[0xd063, true]]; + const status = await client(fake).readStatus(); + assert.equal(status.name, "Keychron BM26"); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.connectionDetail, "2.4 GHz (CANDYSIGN Link)"); +}); + +test("a mouse that stops answering falls back to its name alone", async () => { + const fake = new FakeKeychron1kMouse(); + fake.failStatus = true; + const status = await client(fake).readStatus(); + assert.equal(status.name, "Keychron M3"); + assert.equal(status.ui?.settingsReady, false); +}); diff --git a/src/drivers/keychron/mouse-1k-hid.ts b/src/drivers/keychron/mouse-1k-hid.ts new file mode 100644 index 0000000..9b63220 --- /dev/null +++ b/src/drivers/keychron/mouse-1k-hid.ts @@ -0,0 +1,469 @@ +import type { MouseLighting, MouseStatus } from "../mouse-types.ts"; +import { + KEYCHRON_1K_BUTTON_REPORT_ID as BUTTON_REPORT_ID, + KEYCHRON_1K_REPORT_ID as REPORT_ID, + KEYCHRON_1K_USAGE as USAGE, + KEYCHRON_1K_USAGE_PAGE as USAGE_PAGE, + KEYCHRON_M6_USAGE_PAGE, + KEYCHRON_RECEIVERS, + KEYCHRON_VENDOR_ID, + type KeychronLauncherMouse, +} from "@openmouse/protocol/keychron"; +import { + KEYCHRON_DEBOUNCE_MAX_MS as DEBOUNCE_MAX_MS, + KEYCHRON_DEFAULT_DPI as DEFAULT_DPI, + KEYCHRON_DPI_STAGE_COUNT as DPI_STAGE_COUNT, + KEYCHRON_DPI_STEP as DPI_STEP, + KEYCHRON_POLLING_RATES as POLLING_RATES, + KEYCHRON_SET, + KEYCHRON_STANDARD_LOD as STANDARD_LOD, + keychronActiveGear, + keychronActiveStage, + keychronButtonOptions, + keychronButtons, + keychronDecodeButton, + keychronDecodeConnectedMouse, + keychronDecodeSettings, + keychronEncodeButton, + keychronEncodeDebounce, + keychronEncodeDpi, + keychronEncodeLighting, + keychronEncodeSensor, + keychronLauncherFirmware, + keychronLauncherMouse, + keychronLighting, + keychronLiftOff, + keychronLiftOffStops, + readU16, + type KeychronButton, + type KeychronLight, + type KeychronSettings, +} from "./launcher-mouse.ts"; + +const REPORT_LENGTH = 20; +const BUTTON_REPORT_LENGTH = 64; +/** Launcher's receiver transceiver waits this long before it fetches the answer. */ +const RECEIVER_ANSWER_DELAY_MS = 200; +const ANSWER_ATTEMPTS = 5; +const ANSWER_RETRY_MS = 50; +/** Launcher gives "1k" mice a fixed table: gears 0-2 are 125, 500 and 1000 Hz. */ +const POLLING_TABLE = [0, 1, 2] as const; + +/** Commands on feature report 0x51, except the two button ones, which use 0x52. */ +const CMD = { + receiverState: KEYCHRON_SET.receiverState, + firmware: KEYCHRON_SET.firmware, + identity: 0x06, + status: 0x07, + readLightMode: 0x12, + readLight: 0x18, + writeLightMode: 0x22, + writeLightBrightness: 0x23, + writeLightSpeed: 0x27, + writeLightColor: 0x28, + polling: KEYCHRON_SET.polling, + readButton: KEYCHRON_SET.readButton, + writeButton: KEYCHRON_SET.writeButton, +} as const; + +type LiftOff = NonNullable; +type SensorFlag = "motionSync" | "angleSnapping" | "rippleControl"; + +type Identity = { + firmware: string | null; + /** 0 USB, 1 2.4 GHz. */ + workMode: number; + productId: number | null; + batteryPercent: number; + /** Bits 0-1 of byte 11: 1 charging, 2 full. */ + powerState: number; +}; + +/** + * Keychron Launcher's "1k" mouse protocol on usage page 0x8c: the "8k" + * command set in 20-byte feature reports on 0x51 (answers fetched with a + * feature read of the same report), with buttons on 64-byte feature report + * 0x52. It has no sleep, profile, angle or 20K FPS commands and a fixed + * 125/500/1000 Hz table. Decoded from Launcher (main.be11320b2a72b61b.js, + * webpack modules 20706, 61892 and 8596), not yet confirmed on hardware, and + * Launcher does not say which models use it. + * + * 0x06 identity: [1..2] protocol version, [3..4] vendor ID, [5..6] product + * ID, [7..8] firmware, [9] low 3 bits connection, [10] battery percent, + * [11] bits 0-1 power state. 0x07 status: bytes 1-17 of the "8k" status + * report. 0x03 through a receiver lists the paired mice. + */ +export class Keychron1kHidClient { + readonly device: HIDDevice; + private identity: Identity | null = null; + private model: KeychronLauncherMouse | null | undefined; + /** Feature writes and reads come in pairs, so they run one at a time. */ + private queue: Promise = Promise.resolve(); + + constructor(device: HIDDevice) { + this.device = device; + } + + /** Launcher uses this collection only when the device has no 0xffc1 one. */ + static isSupported(device: HIDDevice): boolean { + return device.vendorId === KEYCHRON_VENDOR_ID + && device.collections.some((collection) => + collection.usagePage === USAGE_PAGE + && collection.usage === USAGE + && (collection.featureReports ?? []).some((report) => report.reportId === REPORT_ID)) + && !device.collections.some((collection) => collection.usagePage === KEYCHRON_M6_USAGE_PAGE); + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + if (this.device.opened) await this.device.close(); + } + + getDpiOptions(): number[] { + const [min, max] = this.model?.dpi ?? DEFAULT_DPI; + return Array.from({ length: Math.floor((max - min) / DPI_STEP) + 1 }, (_, index) => min + index * DPI_STEP); + } + + getDebounceOptions(): number[] { + return Array.from({ length: DEBOUNCE_MAX_MS + 1 }, (_, ms) => ms); + } + + /** Falls back to the name alone when the mouse does not answer, e.g. asleep behind its receiver. */ + async readStatus(): Promise { + const identity = await this.readIdentity(); + const model = await this.readModel(identity); + let settings: KeychronSettings; + try { + settings = await this.readSettings(); + } catch { + return this.unreachableStatus(identity); + } + const buttons = await this.readButtons().catch(() => null); + const light = model?.light ? await this.readLight().catch(() => null) : null; + const lod = keychronLiftOff(this.lodChoices(), settings.lod); + const activeStage = keychronActiveStage(settings, identity.workMode); + const [min, max] = model?.dpi ?? DEFAULT_DPI; + const sensorOptions = !model?.noSensorOptions; + const name = this.label; + + return { + brand: "Keychron", + name, + ui: { + family: "keychron-1k", + defaultDisplayName: name, + hideUnsupportedPollingRates: true, + hideSleepCard: true, + forceShowBattery: true, + ...(lod.note ? { statusNote: lod.note } : {}), + ...(sensorOptions ? {} : { hideProcessingCard: true }), + dpiStageEditor: { + maxStages: DPI_STAGE_COUNT, + countEditable: true, + minDpi: min, + maxDpi: max, + stepDpi: DPI_STEP, + }, + }, + batteryPercent: identity.batteryPercent <= 100 ? identity.batteryPercent : null, + batteryState: identity.powerState === 1 ? "Charging" : identity.powerState === 2 ? "Full" : "Discharging", + dpi: settings.dpiStages[activeStage] ?? settings.dpiStages[0] ?? 800, + dpiStages: settings.dpiStages.slice(0, settings.stageCount), + activeDpiStage: activeStage, + pollingRateHz: this.pollingRate(settings, identity), + supportedPollingRates: POLLING_TABLE.map((gear) => POLLING_RATES[gear]), + activeProfile: null, + connectionType: identity.workMode === 0 ? "Wired" : "Wireless", + connectionDetail: identity.workMode === 0 + ? "Wired USB" + : `2.4 GHz (${KEYCHRON_RECEIVERS.get(this.device.productId) ?? "Keychron receiver"})`, + liftOffDistance: lod.liftOffDistance, + ...(lod.supportedLiftOffDistances ? { supportedLiftOffDistances: lod.supportedLiftOffDistances } : {}), + ...(sensorOptions ? { + motionSync: settings.motionSync, + angleSnapping: settings.angleSnapping, + rippleControl: settings.rippleControl, + } : {}), + debounceMs: settings.debounceMs, + ...(buttons ? { + buttonMappings: Object.fromEntries(buttons.map(({ button, action }) => [button.name, action])), + buttonOptions: keychronButtonOptions("1k"), + } : {}), + ...(light && model?.light ? { lighting: keychronLighting(light, model.light) } : {}), + firmware: [identity.firmware ?? "Firmware unavailable"], + }; + } + + async setDpi(dpi: number): Promise { + const settings = await this.readSettings(); + return await this.setDpiStageValue(keychronActiveStage(settings, (await this.readIdentity()).workMode), dpi); + } + + async setDpiStageValue(stage: number, dpi: number): Promise { + this.requireDpi(dpi); + const settings = await this.readSettings(); + this.requireStage(stage, settings.stageCount); + const identity = await this.readIdentity(); + const stages = settings.dpiStages.map((value, index) => (index === stage ? dpi : value)); + await this.write(REPORT_ID, keychronEncodeDpi(keychronActiveStage(settings, identity.workMode), stages, settings.stageCount)); + const confirmed = (await this.readSettings()).dpiStages[stage]; + if (confirmed !== dpi) throw new Error(`The ${this.label} kept ${confirmed} DPI on stage ${stage + 1} instead of ${dpi} DPI.`); + return confirmed; + } + + async setActiveDpiStage(stage: number): Promise { + const settings = await this.readSettings(); + this.requireStage(stage, settings.stageCount); + await this.write(REPORT_ID, keychronEncodeDpi(stage, settings.dpiStages, settings.stageCount)); + const confirmed = keychronActiveStage(await this.readSettings(), (await this.readIdentity()).workMode); + if (confirmed !== stage) throw new Error(`The ${this.label} kept DPI stage ${confirmed + 1}.`); + return confirmed; + } + + async setDpiStageCount(count: number): Promise { + if (!Number.isInteger(count) || count < 1 || count > DPI_STAGE_COUNT) { + throw new Error(`The ${this.label} holds between 1 and ${DPI_STAGE_COUNT} DPI stages.`); + } + const settings = await this.readSettings(); + const activeStage = Math.min(keychronActiveStage(settings, (await this.readIdentity()).workMode), count - 1); + await this.write(REPORT_ID, keychronEncodeDpi(activeStage, settings.dpiStages, count)); + const confirmed = (await this.readSettings()).stageCount; + if (confirmed !== count) throw new Error(`The ${this.label} kept ${confirmed} DPI stages instead of ${count}.`); + return confirmed; + } + + /** 0x41: the gear for both connection slots, then Launcher's table. */ + async setPollingRate(rateHz: number): Promise { + const gear = POLLING_TABLE.findIndex((value) => POLLING_RATES[value] === rateHz); + if (gear < 0) throw new Error(`The ${this.label} does not support ${rateHz} Hz.`); + await this.write(REPORT_ID, [CMD.polling, gear, gear, ...POLLING_TABLE]); + const actual = this.pollingRate(await this.readSettings(), await this.readIdentity()); + if (actual !== rateHz) throw new Error(`The ${this.label} kept ${actual} Hz instead of ${rateHz} Hz.`); + return actual; + } + + async setLiftOffDistance(lod: LiftOff): Promise { + const code = keychronLiftOffStops(this.lodChoices()).find(([name]) => name === lod)?.[1]; + if (code === undefined) throw new Error(`The ${this.label} has no ${lod} lift-off distance.`); + const confirmed = await this.writeSensor({ ...(await this.readSettings()), lod: code }); + if (confirmed.lod !== code) throw new Error(`The ${this.label} kept lift-off code ${confirmed.lod}.`); + return lod; + } + + async setMotionSync(enabled: boolean): Promise { + return this.writeSensorFlag("motionSync", enabled); + } + + async setAngleSnapping(enabled: boolean): Promise { + return this.writeSensorFlag("angleSnapping", enabled); + } + + async setRippleControl(enabled: boolean): Promise { + return this.writeSensorFlag("rippleControl", enabled); + } + + async setDebounceTime(debounceMs: number): Promise { + if (!Number.isInteger(debounceMs) || debounceMs < 0 || debounceMs > DEBOUNCE_MAX_MS) { + throw new Error(`The ${this.label} debounce must be between 0 and ${DEBOUNCE_MAX_MS} ms.`); + } + await this.write(REPORT_ID, keychronEncodeDebounce(debounceMs)); + const confirmed = (await this.readSettings()).debounceMs; + if (confirmed !== debounceMs) throw new Error(`The ${this.label} kept ${confirmed} ms debounce instead of ${debounceMs} ms.`); + return confirmed; + } + + /** 0x52 on feature report 0x52: [1] button index, [3] type, then the type's data. */ + async setButtonMapping(button: string, action: string): Promise { + await this.readModel(await this.readIdentity()); + const slot = keychronButtons(this.model ?? undefined).find((entry) => entry.name === button); + if (!slot) throw new Error(`The ${this.label} has no "${button}" button.`); + const code = keychronEncodeButton(action, "1k"); + if (!code) throw new Error(`Unknown button action "${action}".`); + const expected = action === "Default" ? slot.defaultAction : action; + const after = (await this.readButtons()).map((entry) => (entry.button.index === slot.index ? expected : entry.action)); + if (!after.includes("Left Click")) throw new Error("Keep at least one button as Left Click."); + await this.write(BUTTON_REPORT_ID, [CMD.writeButton, slot.index, 0, ...code]); + const confirmed = await this.readButton(slot); + if (confirmed !== expected) throw new Error(`The ${this.label} kept ${confirmed} on ${button} instead of ${action}.`); + } + + /** Launcher writes the effect, then its brightness, speed and colour one command at a time. */ + async setLighting(lighting: MouseLighting): Promise { + await this.readModel(await this.readIdentity()); + const offered = this.model?.light; + if (!offered) throw new Error(`The ${this.label} has no lighting.`); + const light = keychronEncodeLighting(lighting, offered); + await this.write(REPORT_ID, [CMD.writeLightMode, 1, light.mode]); + if (light.mode !== 0) { + await this.write(REPORT_ID, [CMD.writeLightBrightness, 1, light.mode, light.brightness]); + await this.write(REPORT_ID, [CMD.writeLightSpeed, 1, light.mode, light.speed]); + await this.write(REPORT_ID, [CMD.writeLightColor, 1, light.mode, ...light.rgb]); + } + const confirmed = keychronLighting(await this.readLight(), offered); + if (confirmed.mode !== lighting.mode) throw new Error(`The ${this.label} kept its ${confirmed.mode ?? "previous"} lighting.`); + return confirmed; + } + + private get label(): string { + return this.model?.name ?? "Keychron mouse"; + } + + private get receiver(): boolean { + return this.identity ? this.identity.workMode === 1 : KEYCHRON_RECEIVERS.has(this.device.productId); + } + + private pollingRate(settings: KeychronSettings, identity: Identity): number { + return POLLING_RATES[POLLING_TABLE[keychronActiveGear(settings, identity.workMode)] ?? 2] ?? 1000; + } + + private lodChoices(): ReadonlyArray { + return this.model ? this.model.lod ?? [] : STANDARD_LOD; + } + + private async writeSensorFlag(flag: SensorFlag, enabled: boolean): Promise { + const confirmed = await this.writeSensor({ ...(await this.readSettings()), [flag]: enabled }); + if (confirmed[flag] !== enabled) throw new Error(`The ${this.label} kept ${flag} ${confirmed[flag] ? "on" : "off"}.`); + return confirmed[flag]; + } + + /** 0x42 with every option resent; "1k" firmware has no 20K FPS or lift-off level bytes. */ + private async writeSensor(next: KeychronSettings): Promise { + await this.write(REPORT_ID, keychronEncodeSensor(next)); + return await this.readSettings(); + } + + private unreachableStatus(identity: Identity): MouseStatus { + const name = this.label; + return { + brand: "Keychron", + name, + ui: { + family: "keychron-1k", + defaultDisplayName: name, + settingsReady: false, + statusNote: identity.workMode === 1 + ? "The mouse did not answer through the receiver. Wake it and reconnect." + : "The mouse did not answer its settings reads. Reconnect it and try again.", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: 0, + pollingRateHz: 0, + activeProfile: null, + connectionType: identity.workMode === 0 ? "Wired" : "Wireless", + liftOffDistance: null, + firmware: identity.firmware ? [identity.firmware] : [], + }; + } + + private requireDpi(dpi: number): void { + const [min, max] = this.model?.dpi ?? DEFAULT_DPI; + if (!Number.isInteger(dpi) || dpi < min || dpi > max || dpi % DPI_STEP !== 0) { + throw new Error(`The ${this.label} DPI must be a multiple of ${DPI_STEP} between ${min} and ${max}.`); + } + } + + private requireStage(stage: number, stageCount: number): void { + if (!Number.isInteger(stage) || stage < 0 || stage >= stageCount) { + throw new Error(`DPI stage must be between 1 and ${stageCount}.`); + } + } + + private async readSettings(): Promise { + await this.readModel(await this.readIdentity()); + const bytes = await this.request(REPORT_ID, [CMD.status], (answer) => answer[0] === CMD.status); + return keychronDecodeSettings(bytes); + } + + /** Read once per connection; a failed read leaves the mode the product ID implies. */ + private async readIdentity(): Promise { + if (this.identity) return this.identity; + const bytes = await this.request(REPORT_ID, [CMD.identity], (answer) => answer[0] === CMD.identity).catch(() => null); + const firmware = await this.request(REPORT_ID, [CMD.firmware], (answer) => answer[0] === CMD.firmware) + .then(keychronLauncherFirmware) + .catch(() => null); + this.identity = { + firmware: firmware ?? (bytes ? `v${bytes[8]}.${(bytes[7] ?? 0) >> 4}.${(bytes[7] ?? 0) & 0x0f}` : null), + workMode: bytes ? (bytes[9] ?? 0) & 0x07 : Number(KEYCHRON_RECEIVERS.has(this.device.productId)), + productId: bytes ? readU16(bytes, 5) : null, + batteryPercent: bytes?.[10] ?? 0xff, + powerState: (bytes?.[11] ?? 0) & 0x03, + }; + return this.identity; + } + + /** By USB product ID, then the identity's, then the connected mouse in a receiver's 0x03 list. */ + private async readModel(identity: Identity): Promise { + if (this.model !== undefined) return this.model; + let model = keychronLauncherMouse(this.device.productId) ?? keychronLauncherMouse(identity.productId); + if (!model && identity.workMode === 1) { + const list = await this.request(REPORT_ID, [CMD.receiverState], (answer) => answer[0] === CMD.receiverState).catch(() => null); + model = keychronLauncherMouse(list ? keychronDecodeConnectedMouse(list) : null); + } + this.model = model ?? null; + return this.model; + } + + private async readButtons(): Promise> { + const buttons = keychronButtons(this.model ?? undefined); + if (!buttons.length) throw new Error(`OpenMouse does not know the ${this.label} buttons.`); + const result: Array<{ button: KeychronButton; action: string }> = []; + for (const button of buttons) result.push({ button, action: await this.readButton(button) }); + return result; + } + + private async readButton(button: KeychronButton): Promise { + const bytes = await this.request( + BUTTON_REPORT_ID, + [CMD.readButton, button.index], + (answer) => answer[0] === CMD.readButton && answer[1] === button.index, + ); + return keychronDecodeButton(bytes, "1k") ?? button.defaultAction; + } + + /** 0x12 names the active effect ([3]); 0x18 then gives its RGB ([2..4]), brightness ([5]) and speed ([6]). */ + private async readLight(): Promise { + const mode = (await this.request(REPORT_ID, [CMD.readLightMode], (answer) => answer[0] === CMD.readLightMode))[3] ?? 0; + const bytes = await this.request(REPORT_ID, [CMD.readLight, mode], (answer) => answer[0] === CMD.readLight); + return { mode, brightness: bytes[5] ?? 0, speed: bytes[6] ?? 0, rgb: [bytes[2] ?? 0, bytes[3] ?? 0, bytes[4] ?? 0] }; + } + + /** A write's answer carries nothing Launcher checks, so it is only drained; the re-read confirms. */ + private async write(reportId: number, payload: ArrayLike): Promise { + await this.request(reportId, Array.from(payload), () => true).catch(() => undefined); + } + + /** + * Sends a feature report, then reads the same report back until the answer + * matches, as Launcher's feature transceivers do (behind a receiver it waits + * first). The answer starts with the report ID, which is dropped. + */ + private async request(reportId: number, payload: number[], match: (answer: Uint8Array) => boolean): Promise { + const run = this.queue.then(async () => { + await this.open(); + const packet = new Uint8Array(reportId === BUTTON_REPORT_ID ? BUTTON_REPORT_LENGTH : REPORT_LENGTH); + packet.set(payload.slice(0, packet.length)); + try { + await this.device.sendFeatureReport(reportId, packet); + } catch (error) { + throw new Error(`Chrome could not write the ${this.label} HID report. ${error instanceof Error ? error.message : String(error)}`); + } + for (let attempt = 0; attempt < ANSWER_ATTEMPTS; attempt += 1) { + if (attempt > 0 || this.receiver) await delay(attempt > 0 ? ANSWER_RETRY_MS : RECEIVER_ANSWER_DELAY_MS); + const view = await this.device.receiveFeatureReport(reportId); + const answer = new Uint8Array(view.buffer, view.byteOffset, view.byteLength).slice(1); + if (match(answer)) return answer; + } + throw new Error(`The ${this.label} did not answer command 0x${payload[0]?.toString(16)}.`); + }); + this.queue = run.catch(() => undefined); + return await run; + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/drivers/keychron/mouse-8k-hid.test.ts b/src/drivers/keychron/mouse-8k-hid.test.ts new file mode 100644 index 0000000..38097c0 --- /dev/null +++ b/src/drivers/keychron/mouse-8k-hid.test.ts @@ -0,0 +1,702 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { MouseLighting } from "../mouse-types.ts"; +import { Keychron8kHidClient } from "./mouse-8k-hid.ts"; + +const ACK = 0xe4; + +/** + * Speaks the "8k" Keychron mouse protocol the way the M6 does: 63-byte + * queries on 0xb3 answered on 0xb4, 20-byte settings on 0xb5 acknowledged on + * 0xb6 with [0xe4, 0, command]. The flag, X/Y, split polling, button and + * lighting paths follow Launcher's code for them. + */ +class FakeKeychronMouse { + vendorId = 0x3434; + productId = 0xd060; + opened = false; + readonly sent: Array<{ reportId: number; packet: Uint8Array }> = []; + private listeners = new Map void>(); + workMode = 0; + /** Protocol version in the 0x02 answer; from 6 the feature flags move there. */ + version = 5; + /** The product ID the 0x02 answer carries. */ + reportedProductId = 0xd060; + /** feature1-4: status [26], [53], [60] before protocol 6, 0x02 [11], [12], [13], [15] from it. */ + features = [0, 0x04, 0, 0]; + /** Mice behind a receiver's 0x03 list: [pid, connected]. */ + paired: Array<[number, boolean]> = []; + profile = 1; + profileCount = 3; + /** Per connection (USB, 2.4 GHz, Bluetooth): DPI stage low nibble, polling high nibble. */ + levels = [0x10, 0x21, 0x32]; + dpiStages = [400, 800, 1600, 3200, 5000]; + dpiY = [400, 800, 1600, 3200, 5000]; + xyEnable = [0, 0, 0, 0, 0, 0, 0, 0]; + stageCount = 3; + dpiMax = 0; + dpiStep = 0; + pollingTable = [0, 1, 2, 3, 4, 5]; + /** 0x4b's USB and 2.4 GHz sets. */ + pollingSets = [ + { level: 2, count: 3, table: [0, 1, 2, 0, 0, 0] }, + { level: 5, count: 6, table: [0, 1, 2, 3, 4, 5] }, + ]; + lod = 1; + lodLevel = 0; + lodCount = 0; + ripple = false; + angleSnap = false; + motion = true; + scrollReversed = false; + maxSpeed = false; + angle = 0; + rejectNext = false; + failStatus = false; + debounce = 8; + sleep = 10; + battery = 0x80 | 100; + /** 0x62 records by button index: [type, data...]; missing ones answer type 0 (default). */ + buttons = new Map(); + light = { mode: 1, brightness: 255, speed: 128, rgb: [0, 96, 255] }; + readonly collections = [{ + usagePage: 0xffc1, + usage: 0x01, + outputReports: [{ reportId: 0xb3 }, { reportId: 0xb5 }], + inputReports: [{ reportId: 0xb4 }, { reportId: 0xb6 }], + }]; + + async open(): Promise { this.opened = true; } + async close(): Promise { this.opened = false; } + addEventListener(type: string, listener: (event: unknown) => void): void { this.listeners.set(type, listener); } + removeEventListener(type: string): void { this.listeners.delete(type); } + + async sendReport(reportId: number, data: ArrayBuffer): Promise { + const packet = new Uint8Array(data); + this.sent.push({ reportId, packet }); + if (reportId === 0xb3) return this.command(packet); + if (reportId !== 0xb5) return; + if (this.rejectNext) { + this.rejectNext = false; + this.emit(0xb6, new Uint8Array([ACK, 7, packet[0] ?? 0])); + return; + } + switch (packet[0]) { + case 0x02: { + const reply = new Uint8Array(20); + reply.set([0x02, this.version, 0, 0x34, 0x34, this.reportedProductId & 0xff, this.reportedProductId >> 8, 0x03, 0x01, this.workMode]); + reply.set(this.features.slice(0, 3), 11); + reply[15] = this.features[3] ?? 0; + this.emit(0xb6, reply); + return; + } + case 0x03: { + const reply = new Uint8Array(20); + reply[0] = 0x03; + reply[1] = this.paired.length; + this.paired.forEach(([pid, connected], index) => { + reply.set([0x34, 0x34, pid & 0xff, pid >> 8, connected ? 1 : 0], 2 + index * 5); + }); + this.emit(0xb6, reply); + return; + } + case 0x23: + this.emit(0xb6, new Uint8Array([0x21, this.light.mode, this.light.brightness, this.light.speed, ...this.light.rgb])); + return; + case 0x4b: { + const reply = new Uint8Array(20); + reply[0] = 0x4b; + this.pollingSets.forEach((set, index) => { + reply[1 + index] = set.level; + reply[3 + index] = set.count; + reply.set(set.table, 5 + index * 6); + }); + this.emit(0xb6, reply); + return; + } + case 0x24: + this.light = { mode: packet[1] ?? 0, brightness: packet[2] ?? 0, speed: packet[3] ?? 0, rgb: Array.from(packet.slice(4, 7)) }; + break; + case 0x40: + this.levels = this.levels.map((level, index) => (level & 0xf0) | (packet[1 + index] ?? 0)); + this.stageCount = packet[14] ?? this.stageCount; + this.dpiStages = this.dpiStages.map((_, index) => (packet[4 + index * 2] ?? 0) | ((packet[5 + index * 2] ?? 0) << 8)); + break; + case 0x41: + this.levels = this.levels.map((level) => (level & 0x0f) | ((packet[1] ?? 0) << 4)); + this.pollingTable = Array.from(packet.slice(3, 3 + (packet[9] ?? 0))); + break; + case 0x4a: + this.pollingSets = [0, 1].map((index) => ({ + level: packet[1 + index] ?? 0, + count: packet[3 + index] ?? 0, + table: Array.from(packet.slice(5 + index * 6, 11 + index * 6)), + })); + break; + case 0x42: + if (packet[9] === 2) { + this.angle = (packet[10] ?? 0) > 127 ? (packet[10] ?? 0) - 256 : (packet[10] ?? 0); + break; + } + if (packet[1]) this.lod = packet[1]; + if (packet[2]) this.ripple = packet[2] === 1; + if (packet[3]) this.angleSnap = packet[3] === 1; + if (packet[4]) this.motion = packet[4] === 1; + if (packet[6]) this.scrollReversed = packet[6] === 2; + if (packet[8]) this.maxSpeed = packet[8] === 2; + if (packet[11]) this.lodLevel = packet[11]; + break; + case 0x43: + this.debounce = packet[1] ?? 0; + break; + case 0x0a: + if (packet[1] === 1) this.sleep = packet[2] ?? 0; + break; + case 0x0e: + this.profile = packet[1] ?? 0; + break; + default: + return; + } + this.emit(0xb6, new Uint8Array([ACK, 0, packet[0] ?? 0])); + } + + private command(packet: Uint8Array): void { + switch (packet[0]) { + case 0x06: + if (this.failStatus) throw new Error("device stalled"); + this.emit(0xb4, this.statusPacket()); + return; + case 0x04: + this.emit(0xb4, new Uint8Array([0x04, 6, ...Array.from("1.0.3", (c) => c.charCodeAt(0)), 0])); + return; + case 0x49: { + const reply = new Uint8Array(63); + reply[0] = 0x49; + this.levels.forEach((level, index) => { + const stage = level & 0x0f; + reply[1 + index] = this.version >= 6 ? stage : (stage << 4) | stage; + }); + reply[4] = this.stageCount; + this.dpiStages.forEach((dpi, index) => reply.set([dpi & 0xff, dpi >> 8], 5 + index * 2)); + this.dpiY.forEach((dpi, index) => reply.set([dpi & 0xff, dpi >> 8], 21 + index * 2)); + if (this.version >= 6) reply[37] = this.xyEnable.reduce((mask, bit, index) => mask | (bit << index), 0); + else reply.set(this.xyEnable, 37); + this.emit(0xb4, reply); + return; + } + case 0x48: + this.levels = this.levels.map((level, index) => (level & 0xf0) | ((packet[1 + index] ?? 0) & 0x0f)); + this.stageCount = packet[4] ?? this.stageCount; + this.dpiStages = this.dpiStages.map((_, index) => (packet[5 + index * 2] ?? 0) | ((packet[6 + index * 2] ?? 0) << 8)); + this.dpiY = this.dpiY.map((_, index) => (packet[21 + index * 2] ?? 0) | ((packet[22 + index * 2] ?? 0) << 8)); + this.emit(0xb4, new Uint8Array([ACK, 0, 0x48])); + return; + case 0x62: { + const index = packet[1] ?? 0; + const reply = new Uint8Array(63); + reply.set([0x62, index, 0, ...(this.buttons.get(index) ?? [0])]); + this.emit(0xb4, reply); + return; + } + case 0x52: { + const record = Array.from(packet.slice(3, 7)); + if (record[0] === 0) this.buttons.delete(packet[1] ?? 0); + else this.buttons.set(packet[1] ?? 0, record); + this.emit(0xb4, new Uint8Array([ACK, 0, 0x52])); + return; + } + default: + } + } + + private statusPacket(): Uint8Array { + const packet = new Uint8Array(63); + packet[0] = 0x06; + packet[1] = this.profile; + packet.set(this.levels, 2); + this.dpiStages.forEach((dpi, index) => { + packet[5 + index * 2] = dpi & 0xff; + packet[6 + index * 2] = (dpi >> 8) & 0xff; + }); + packet[15] = this.lod | (this.ripple ? 0x04 : 0) | (this.angleSnap ? 0x08 : 0) | (this.motion ? 0x10 : 0) + | (this.scrollReversed ? 0x40 : 0); + packet[16] = this.stageCount; + packet[17] = this.debounce; + packet[18] = this.sleep; + packet[19] = this.battery; + packet.set([this.dpiMax & 0xff, this.dpiMax >> 8, this.dpiStep], 40); + packet.set(this.pollingTable, 43); + packet[49] = this.pollingTable.length; + packet[50] = this.profileCount; + packet[52] = this.maxSpeed ? 1 : 0; + packet[55] = this.angle & 0xff; + packet[61] = (this.lodCount << 4) | this.lodLevel; + if (this.version < 6) { + packet[26] = this.features[0] ?? 0; + packet[53] = this.features[1] ?? 0; + packet[60] = this.features[2] ?? 0; + } + return packet; + } + + private emit(reportId: number, bytes: Uint8Array): void { + const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + queueMicrotask(() => this.listeners.get("inputreport")?.({ reportId, data })); + } +} + +const client = (fake: FakeKeychronMouse): Keychron8kHidClient => new Keychron8kHidClient(fake as unknown as HIDDevice); +const lastSent = (fake: FakeKeychronMouse, command: number, reportId = 0xb5) => + [...fake.sent].reverse().find((entry) => entry.reportId === reportId && entry.packet[0] === command)?.packet; + +test("claims any Keychron device with the 0xffc1 control interface", () => { + const fake = new FakeKeychronMouse(); + assert.equal(Keychron8kHidClient.isSupported(fake as unknown as HIDDevice), true); + fake.productId = 0xd050; + assert.equal(Keychron8kHidClient.isSupported(fake as unknown as HIDDevice), true); + const viaOnly = { ...fake, collections: [{ usagePage: 0xff60, usage: 0x61, outputReports: [{ reportId: 0 }], inputReports: [{ reportId: 0 }] }] }; + assert.equal(Keychron8kHidClient.isSupported(viaOnly as unknown as HIDDevice), false); + const otherVendor = { ...fake, vendorId: 0x3151 }; + assert.equal(Keychron8kHidClient.isSupported(otherVendor as unknown as HIDDevice), false); +}); + +test("reads the full M6 status report", async () => { + const status = await client(new FakeKeychronMouse()).readStatus(); + assert.equal(status.name, "Keychron M6"); + assert.equal(status.ui?.family, "keychron-8k"); + assert.equal(status.dpi, 400); + assert.deepEqual(status.dpiStages, [400, 800, 1600]); + assert.equal(status.activeDpiStage, 0); + assert.equal(status.pollingRateHz, 500); + assert.deepEqual(status.supportedPollingRates, [125, 500, 1000, 2000, 4000, 8000]); + assert.equal(status.liftOffDistance, "Medium"); + assert.deepEqual(status.supportedLiftOffDistances, ["Low", "Medium", "High"]); + assert.equal(status.ui?.statusNote, "Lift-off: Low is 0.7 mm, Medium is 1 mm, High is 2 mm."); + assert.equal(status.motionSync, true); + assert.equal(status.angleSnapping, false); + assert.equal(status.rippleControl, false); + assert.equal(status.performanceMode, undefined); + assert.equal(status.angleTuning, 0); + assert.equal(status.debounceMs, 8); + assert.equal(status.sleepTimeout, 600); + assert.equal(status.activeProfile, 2); + assert.equal(status.profileCount, 3); + assert.equal(status.batteryPercent, 100); + assert.equal(status.batteryState, "Charging"); + assert.equal(status.connectionDetail, "Wired USB"); + assert.deepEqual(status.firmware, ["v1.0.3"]); + assert.deepEqual(status.ui?.dpiStageEditor, { maxStages: 5, countEditable: true, minDpi: 100, maxDpi: 26000, stepDpi: 50 }); + assert.equal(status.ui?.hideProcessingCard, undefined); + assert.equal(status.ui?.hideSleepCard, undefined); + assert.equal(status.lighting, undefined); +}); + +test("reads the DPI and polling levels of the connection in use", async () => { + const fake = new FakeKeychronMouse(); + fake.productId = 0xd029; + fake.workMode = 1; + const status = await client(fake).readStatus(); + // 2.4 GHz slot: stage 1 (800 DPI) and polling index 2 (1000 Hz). + assert.equal(status.name, "Keychron M6"); + assert.equal(status.dpi, 800); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.connectionDetail, "2.4 GHz (Keychron Link-KM Type C)"); +}); + +test("behind a receiver that reports its own ID, the 0x03 list names the mouse", async () => { + const fake = new FakeKeychronMouse(); + fake.productId = 0xd028; + fake.reportedProductId = 0xd028; + fake.workMode = 1; + fake.paired = [[0xd060, false], [0xd050, true]]; + const status = await client(fake).readStatus(); + assert.equal(status.name, "Keychron M3 8K"); + assert.equal(status.connectionDetail, "2.4 GHz (Keychron Ultra-Link 8K)"); + assert.deepEqual(Array.from(lastSent(fake, 0x03)!.slice(0, 2)), [0x03, 0]); +}); + +test("an unknown Keychron mouse keeps the M6 defaults and hides the remapper", async () => { + const fake = new FakeKeychronMouse(); + fake.productId = 0xd0fe; + fake.reportedProductId = 0xd0fe; + const status = await client(fake).readStatus(); + assert.equal(status.name, "Keychron mouse"); + assert.deepEqual(status.supportedLiftOffDistances, ["Low", "Medium", "High"]); + assert.equal(status.buttonMappings, undefined); + assert.equal(status.ui?.dpiStageEditor?.maxDpi, 26000); +}); + +test("an 8K model takes its floor from the table and its ceiling and step from the mouse", async () => { + const fake = new FakeKeychronMouse(); + fake.productId = 0xd050; + fake.reportedProductId = 0xd050; + fake.features = [0x08, 0, 0, 0]; + fake.dpiMax = 30_000; + fake.dpiStep = 10; + const m3 = client(fake); + const status = await m3.readStatus(); + assert.equal(status.name, "Keychron M3 8K"); + assert.deepEqual(status.ui?.dpiStageEditor, { maxStages: 5, countEditable: true, minDpi: 50, maxDpi: 30000, stepDpi: 10 }); + assert.equal(m3.getDpiOptions()[0], 50); + assert.equal(m3.getDpiOptions().at(-1), 30000); + assert.equal(await m3.setDpiStageValue(0, 1210), 1210); + await assert.rejects(m3.setDpiStageValue(0, 1215), /multiple of 10 between 50 and 30000/); + assert.equal(status.angleTuning, undefined); +}); + +test("DPI writes follow the active stage and keep the stage count", async () => { + const fake = new FakeKeychronMouse(); + const m6 = client(fake); + await m6.setActiveDpiStage(1); + await m6.setDpi(2400); + const status = await m6.readStatus(); + assert.deepEqual(status.dpiStages, [400, 2400, 1600]); + assert.equal(status.activeDpiStage, 1); + assert.equal(status.dpi, 2400); + assert.equal(await m6.setDpiStageValue(2, 3200), 3200); + assert.deepEqual((await m6.readStatus()).dpiStages, [400, 2400, 3200]); + assert.equal(lastSent(fake, 0x40)?.[14], 3); +}); + +test("rejects stages past the count the mouse reports", async () => { + const m6 = client(new FakeKeychronMouse()); + await assert.rejects(m6.setActiveDpiStage(3), /between 1 and 3/); + await assert.rejects(m6.setDpiStageValue(4, 800), /between 1 and 3/); +}); + +test("the stage count grows and shrinks without disturbing the stored DPI", async () => { + const fake = new FakeKeychronMouse(); + const m6 = client(fake); + assert.equal(await m6.setDpiStageCount(5), 5); + // Growing reveals slots the mouse was already holding, it does not invent them. + assert.deepEqual((await m6.readStatus()).dpiStages, [400, 800, 1600, 3200, 5000]); + assert.equal(lastSent(fake, 0x40)?.[14], 5); + assert.equal(await m6.setDpiStageCount(2), 2); + assert.deepEqual((await m6.readStatus()).dpiStages, [400, 800]); + // Shrinking keeps the hidden slots intact for the next time they are shown. + assert.equal(await m6.setDpiStageCount(5), 5); + assert.deepEqual((await m6.readStatus()).dpiStages, [400, 800, 1600, 3200, 5000]); +}); + +test("shrinking below the active stage pulls it back into range", async () => { + const fake = new FakeKeychronMouse(); + const m6 = client(fake); + await m6.setActiveDpiStage(2); + assert.equal((await m6.readStatus()).activeDpiStage, 2); + await m6.setDpiStageCount(1); + const status = await m6.readStatus(); + assert.equal(status.activeDpiStage, 0); + assert.equal(status.dpi, 400); +}); + +test("rejects a stage count the mouse cannot hold", async () => { + const m6 = client(new FakeKeychronMouse()); + for (const count of [0, 6, 2.5, Number.NaN]) { + await assert.rejects(m6.setDpiStageCount(count), /between 1 and 5/); + } +}); + +test("separate X/Y firmware reads and writes DPI through 0x49 and 0x48", async () => { + for (const version of [5, 6]) { + const fake = new FakeKeychronMouse(); + fake.version = version; + fake.features = version >= 6 ? [0, 0x04, 0x01, 0] : [0, 0x04, 0x01, 0]; + fake.dpiY = [450, 800, 1600, 3200, 5000]; + fake.xyEnable = [1, 0, 0, 0, 0, 0, 0, 0]; + const m6 = client(fake); + assert.deepEqual((await m6.readStatus()).dpiStages, [400, 800, 1600]); + assert.equal(await m6.setDpiStageValue(1, 2000), 2000); + const packet = lastSent(fake, 0x48, 0xb3)!; + assert.equal(packet[4], 3); + // The edited stage gets X = Y; stage 1 keeps its separate Y and enable flag. + assert.deepEqual(fake.dpiStages.slice(0, 3), [400, 2000, 1600]); + assert.deepEqual(fake.dpiY.slice(0, 3), [450, 2000, 1600]); + if (version >= 6) assert.equal(packet[37], 0x01); + else assert.deepEqual(Array.from(packet.slice(37, 45)), [1, 0, 0, 0, 0, 0, 0, 0]); + assert.equal(lastSent(fake, 0x40), undefined, `protocol ${version} should not fall back to 0x40`); + assert.equal(await m6.setActiveDpiStage(2), 2); + assert.equal(lastSent(fake, 0x48, 0xb3)![1], version >= 6 ? 2 : 0x22); + } +}); + +test("writes the polling rate as an index into the mouse's table", async () => { + const fake = new FakeKeychronMouse(); + assert.equal(await client(fake).setPollingRate(4000), 4000); + const packet = lastSent(fake, 0x41)!; + assert.deepEqual(Array.from(packet.slice(0, 10)), [0x41, 4, 4, 0, 1, 2, 3, 4, 5, 6]); + await assert.rejects(client(fake).setPollingRate(250), /does not support 250 Hz/); +}); + +test("editable gears take a rate the table lacks, within the model's limit", async () => { + const fake = new FakeKeychronMouse(); + fake.productId = 0xd050; + fake.reportedProductId = 0xd050; + fake.features = [0x10, 0, 0, 0]; + fake.pollingTable = [0, 1, 2]; + const m3 = client(fake); + assert.deepEqual((await m3.readStatus()).supportedPollingRates, [125, 500, 1000, 2000, 4000, 8000]); + assert.equal(await m3.setPollingRate(8000), 8000); + // The active gear (1, 500 Hz on USB) now holds 8000 Hz. + assert.deepEqual(Array.from(lastSent(fake, 0x41)!.slice(0, 10)), [0x41, 1, 1, 0, 5, 2, 0, 0, 0, 3]); + const locked = new FakeKeychronMouse(); + locked.pollingTable = [0, 1, 2]; + await assert.rejects(client(locked).setPollingRate(8000), /does not support 8000 Hz/); +}); + +test("split polling tables are read and written as a pair", async () => { + const fake = new FakeKeychronMouse(); + fake.productId = 0xd029; + fake.workMode = 1; + fake.features = [0, 0x04, 0x02, 0]; + const m6 = client(fake); + // The 2.4 GHz set is in use: gear 5 of [125 ... 8000]. + assert.equal((await m6.readStatus()).pollingRateHz, 8000); + assert.equal(await m6.setPollingRate(1000), 1000); + const packet = lastSent(fake, 0x4a)!; + assert.deepEqual(Array.from(packet.slice(0, 17)), [0x4a, 2, 2, 3, 6, 0, 1, 2, 0, 0, 0, 0, 1, 2, 3, 4, 5]); + assert.equal(lastSent(fake, 0x41), undefined); +}); + +test("sensor options are resent together with 1 = on and 2 = off", async () => { + const fake = new FakeKeychronMouse(); + const m6 = client(fake); + assert.equal(await m6.setLiftOffDistance("High"), "High"); + assert.equal(await m6.setMotionSync(false), false); + assert.equal(await m6.setAngleSnapping(true), true); + assert.equal(await m6.setRippleControl(true), true); + const packet = lastSent(fake, 0x42)!; + assert.deepEqual(Array.from(packet.slice(0, 12)), [0x42, 2, 1, 1, 2, 0, 1, 0, 1, 0, 0, 0]); + const status = await m6.readStatus(); + assert.equal(status.liftOffDistance, "High"); + assert.equal(status.motionSync, false); + assert.equal(status.angleSnapping, true); + assert.equal(status.rippleControl, true); + assert.equal(await m6.setLiftOffDistance("Low"), "Low"); + assert.equal(fake.lod, 3); +}); + +test("a model with 1 and 2 mm only offers Low and High", async () => { + const fake = new FakeKeychronMouse(); + fake.productId = 0xd03f; + fake.reportedProductId = 0xd03f; + const m6 = client(fake); + const status = await m6.readStatus(); + assert.deepEqual(status.supportedLiftOffDistances, ["Low", "High"]); + assert.equal(status.liftOffDistance, "Low"); + assert.equal(status.ui?.statusNote, "Lift-off: Low is 1 mm, High is 2 mm."); + await assert.rejects(m6.setLiftOffDistance("Medium"), /no Medium lift-off/); + assert.equal(await m6.setLiftOffDistance("High"), "High"); + assert.equal(fake.lod, 2); +}); + +test("lift-off level firmware gets the slider and writes the level byte", async () => { + const fake = new FakeKeychronMouse(); + fake.productId = 0xd086; + fake.reportedProductId = 0xd086; + fake.version = 6; + fake.features = [0, 0, 0, 0x10]; + fake.lodLevel = 4; + fake.lodCount = 11; + const g6 = client(fake); + const status = await g6.readStatus(); + assert.equal(status.name, "Keychron G6 HE 8K"); + assert.deepEqual(status.liftOffScale, { value: 4, min: 1, max: 11, millimetres: 1, minMillimetres: 0.7, maxMillimetres: 1.7 }); + assert.equal(status.liftOffDistance, null); + assert.equal(await g6.setLiftOffScale(7), 7); + const packet = lastSent(fake, 0x42)!; + assert.equal(packet[1], 0); + assert.equal(packet[11], 7); + assert.equal(fake.lodLevel, 7); + await assert.rejects(g6.setLiftOffScale(12), /no lift-off level 12/); +}); + +test("lift-off level firmware with a three-height config writes the level byte, as Launcher does", async () => { + const fake = new FakeKeychronMouse(); + fake.productId = 0xd050; + fake.reportedProductId = 0xd050; + fake.version = 6; + fake.features = [0, 0, 0, 0x10]; + fake.lodLevel = 1; + fake.lodCount = 3; + const m3 = client(fake); + const status = await m3.readStatus(); + assert.equal(status.liftOffDistance, "Medium"); + assert.equal(await m3.setLiftOffDistance("Low"), "Low"); + const packet = lastSent(fake, 0x42)!; + assert.equal(packet[1], 0); + assert.equal(packet[11], 3); +}); + +test("the 20K FPS switch appears and writes only when flagged", async () => { + const fake = new FakeKeychronMouse(); + fake.features = [0x80, 0x04, 0, 0]; + const m6 = client(fake); + assert.equal((await m6.readStatus()).performanceMode, false); + assert.equal(await m6.setPerformanceMode(true), true); + assert.equal(lastSent(fake, 0x42)?.[8], 2); + assert.equal((await m6.readStatus()).performanceMode, true); + await assert.rejects(client(new FakeKeychronMouse()).setPerformanceMode(true), /no 20K FPS mode/); +}); + +test("angle tuning uses the dedicated 0x42 form with a signed byte", async () => { + const fake = new FakeKeychronMouse(); + const m6 = client(fake); + assert.equal(await m6.setAngleTuning(-15), -15); + const packet = lastSent(fake, 0x42)!; + assert.equal(packet[9], 2); + assert.equal(packet[10], 0xf1); + assert.equal((await m6.readStatus()).angleTuning, -15); + assert.equal(await m6.setAngleTuning(90), 90); + await assert.rejects(m6.setAngleTuning(91), /between -90 and 90/); +}); + +test("from protocol 6 the feature flags come from the 0x02 answer", async () => { + const fake = new FakeKeychronMouse(); + fake.version = 6; + fake.features = [0x80, 0, 0, 0]; + const status = await client(fake).readStatus(); + assert.equal(status.performanceMode, false); + assert.equal(status.angleTuning, undefined); +}); + +test("debounce, sleep and profile round-trip through their own commands", async () => { + const fake = new FakeKeychronMouse(); + const m6 = client(fake); + assert.equal(await m6.setDebounceTime(4), 4); + assert.deepEqual(Array.from(lastSent(fake, 0x43)!.slice(0, 2)), [0x43, 4]); + assert.equal(await m6.setSleepTimeout(300), 300); + assert.deepEqual(Array.from(lastSent(fake, 0x0a)!.slice(0, 3)), [0x0a, 1, 5]); + assert.equal(await m6.setProfile(3), 3); + assert.deepEqual(Array.from(lastSent(fake, 0x0e)!.slice(0, 2)), [0x0e, 2]); + const status = await m6.readStatus(); + assert.equal(status.debounceMs, 4); + assert.equal(status.sleepTimeout, 300); + assert.equal(status.activeProfile, 3); + await assert.rejects(m6.setProfile(4), /between 1 and 3/); + await assert.rejects(m6.setDebounceTime(21), /between 0 and 20/); + await assert.rejects(m6.setSleepTimeout(90), /must be one of/); + assert.deepEqual(m6.getSleepOptions().slice(0, 3), [60, 180, 300]); + assert.equal(m6.getDebounceOptions().length, 21); +}); + +test("angle tuning is offered only when the mouse flags support for it", async () => { + const fake = new FakeKeychronMouse(); + fake.features = [0, 0, 0, 0]; + const m6 = client(fake); + assert.equal((await m6.readStatus()).angleTuning, undefined); + await assert.rejects(m6.setAngleTuning(5), /does not support angle tuning/); + assert.equal(lastSent(fake, 0x42), undefined); +}); + +test("a non-zero ack code fails the write instead of a silent re-read", async () => { + const fake = new FakeKeychronMouse(); + fake.rejectNext = true; + await assert.rejects(client(fake).setDebounceTime(3), /rejected command 0x43 \(code 7\)/); +}); + +test("a mouse that hides its profiles shows no profile card", async () => { + const fake = new FakeKeychronMouse(); + fake.profileCount = 0; + const status = await client(fake).readStatus(); + assert.equal(status.activeProfile, null); + assert.equal(status.profileCount, undefined); +}); + +test("a mouse that stops answering falls back to its name alone", async () => { + const fake = new FakeKeychronMouse(); + fake.failStatus = true; + const status = await client(fake).readStatus(); + assert.equal(status.name, "Keychron M6"); + assert.equal(status.ui?.settingsReady, false); + assert.deepEqual(status.firmware, ["v1.0.3"]); +}); + +test("buttons read as their default until remapped, named from the model's config", async () => { + const fake = new FakeKeychronMouse(); + const status = await client(fake).readStatus(); + assert.deepEqual(status.buttonMappings, { + Left: "Left Click", + Middle: "Middle Click", + Right: "Right Click", + Back: "Back", + Forward: "Forward", + "Tilt Right": "Default", + "Tilt Left": "Default", + "Scroll Right": "Scroll Right", + "Scroll Left": "Scroll Left", + "Scroll Down": "Scroll Down", + "Scroll Up": "Scroll Up", + }); + assert.equal(status.buttonOptions?.[0], "Left Click"); + assert.ok(status.buttonOptions?.includes("Default")); + assert.deepEqual(Array.from(lastSent(fake, 0x62, 0xb3)!.slice(0, 2)), [0x62, 14]); +}); + +test("remaps write Launcher's 0x52 records and restore with type 0", async () => { + const fake = new FakeKeychronMouse(); + const m6 = client(fake); + await m6.setButtonMapping("Forward", "DPI Loop"); + assert.deepEqual(Array.from(lastSent(fake, 0x52, 0xb3)!.slice(0, 6)), [0x52, 4, 0, 5, 1, 0]); + await m6.setButtonMapping("Back", "Forward"); + // "8k" mouse codes: Back 0x080000, Forward 0x100000, high byte first. + assert.deepEqual(Array.from(lastSent(fake, 0x52, 0xb3)!.slice(0, 7)), [0x52, 3, 0, 1, 0x10, 0x00, 0x00]); + await m6.setButtonMapping("Tilt Left", "Volume Up"); + assert.deepEqual(Array.from(lastSent(fake, 0x52, 0xb3)!.slice(0, 6)), [0x52, 9, 0, 3, 0xe9, 0x00]); + let status = await m6.readStatus(); + assert.equal(status.buttonMappings?.Forward, "DPI Loop"); + assert.equal(status.buttonMappings?.Back, "Forward"); + assert.equal(status.buttonMappings?.["Tilt Left"], "Volume Up"); + await m6.setButtonMapping("Forward", "Default"); + assert.deepEqual(Array.from(lastSent(fake, 0x52, 0xb3)!.slice(0, 4)), [0x52, 4, 0, 0]); + status = await m6.readStatus(); + assert.equal(status.buttonMappings?.Forward, "Forward"); + fake.buttons.set(2, [4, 0, 1, 0]); + assert.equal((await m6.readStatus()).buttonMappings?.Right, "Macro"); + await assert.rejects(m6.setButtonMapping("Left", "Disabled"), /at least one button as Left Click/); + await assert.rejects(m6.setButtonMapping("Wheel", "Disabled"), /no "Wheel" button/); + await assert.rejects(m6.setButtonMapping("Left", "Teleport"), /Unknown button action/); +}); + +test("repeated default functions get numbered button names", async () => { + const fake = new FakeKeychronMouse(); + fake.productId = 0xd035; + fake.reportedProductId = 0xd035; + const status = await client(fake).readStatus(); + assert.equal(status.name, "Keychron M1"); + assert.deepEqual(Object.keys(status.buttonMappings ?? {}), ["Left", "Middle", "Right", "Forward", "Back", "Forward 2", "Back 2", "Scroll Down", "Scroll Up"]); +}); + +test("budget sensors hide the processing toggles and lift-off", async () => { + const fake = new FakeKeychronMouse(); + fake.productId = 0xd064; + fake.reportedProductId = 0xd064; + const status = await client(fake).readStatus(); + assert.equal(status.ui?.hideProcessingCard, true); + assert.equal(status.motionSync, undefined); + assert.equal(status.liftOffDistance, null); + assert.equal(status.supportedLiftOffDistances, undefined); + assert.equal(status.ui?.dpiStageEditor?.maxDpi, 12000); +}); + +test("lit models read and write Launcher's 0x23/0x24 lighting", async () => { + const fake = new FakeKeychronMouse(); + fake.productId = 0xd033; + fake.reportedProductId = 0xd033; + const m3 = client(fake); + const lighting = (await m3.readStatus()).lighting!; + assert.equal(lighting.mode, "Static"); + assert.equal(lighting.color, "#0060ff"); + assert.equal(lighting.brightness, 100); + assert.equal(lighting.speed, 3); + assert.deepEqual(lighting.modes, ["Off", "Static", "Breathing single", "Spectrum", "Wave"]); + const next: MouseLighting = { ...lighting, mode: "Breathing single", color: "#ff0000", brightness: 50, speed: 5 }; + const confirmed = await m3.setLighting(next); + assert.deepEqual(Array.from(lastSent(fake, 0x24)!.slice(0, 7)), [0x24, 2, 128, 255, 255, 0, 0]); + assert.equal(confirmed.mode, "Breathing single"); + assert.equal(confirmed.color, "#ff0000"); + await m3.setLighting({ ...confirmed, mode: "Off" }); + assert.equal(fake.light.mode, 0); + await assert.rejects(client(new FakeKeychronMouse()).setLighting(next), /has no lighting/); +}); diff --git a/src/drivers/keychron/mouse-8k-hid.ts b/src/drivers/keychron/mouse-8k-hid.ts new file mode 100644 index 0000000..b25f9ac --- /dev/null +++ b/src/drivers/keychron/mouse-8k-hid.ts @@ -0,0 +1,897 @@ +import type { MouseLighting, MouseStatus } from "../mouse-types.ts"; +import { + KEYCHRON_M6_COMMAND_REPORT_ID as COMMAND_REPORT_ID, + KEYCHRON_M6_SETTINGS_REPORT_ID as SETTINGS_REPORT_ID, + KEYCHRON_M6_STATUS_COMMAND as STATUS_COMMAND, + KEYCHRON_M6_STATUS_PACKET_LENGTH as PACKET_LENGTH, + KEYCHRON_M6_USAGE as USAGE, + KEYCHRON_M6_USAGE_PAGE as USAGE_PAGE, + KEYCHRON_RECEIVERS, + KEYCHRON_VENDOR_ID, + type KeychronLauncherMouse, +} from "@openmouse/protocol/keychron"; +import { + KEYCHRON_DEBOUNCE_MAX_MS as DEBOUNCE_MAX_MS, + KEYCHRON_DEFAULT_DPI as DEFAULT_DPI, + KEYCHRON_DPI_STAGE_COUNT as DPI_STAGE_COUNT, + KEYCHRON_DPI_STEP as DEFAULT_DPI_STEP, + KEYCHRON_POLLING_RATES as POLLING_RATES, + KEYCHRON_SET, + KEYCHRON_SLEEP_MINUTES as SLEEP_MINUTES, + KEYCHRON_STANDARD_LOD as STANDARD_LOD, + keychronActiveGear, + keychronActiveStage, + keychronButtonOptions, + keychronButtons, + keychronDecodeButton, + keychronDecodeConnectedMouse, + keychronDecodeSettings, + keychronEncodeAngle, + keychronEncodeButton, + keychronEncodeDebounce, + keychronEncodeDpi, + keychronEncodeLighting, + keychronEncodeSensor, + keychronLauncherFirmware, + keychronLauncherMouse, + keychronLighting, + keychronLiftOff, + keychronLiftOffStops, + readU16, + writeU16, + type KeychronButton, + type KeychronLight, + type KeychronSettings, +} from "./launcher-mouse.ts"; + +const QUERY_TIMEOUT_MS = 1200; +const SETTINGS_PACKET_LENGTH = 20; +const ACK = 0xe4; +/** Launcher's angle slider; the byte is signed and Launcher reads anything past +90 as negative. */ +const ANGLE_LIMIT = 90; +const PROFILE_MAX = 5; +const POLLING_GEARS = 6; +/** From this protocol version the feature flags sit in the 0x02 answer instead of the status report. */ +const FLAGS_IN_VERSION_REPLY = 6; + +/** Commands on the 63-byte 0xb3 report (answers arrive on 0xb4). */ +const CMD = { + firmware: KEYCHRON_SET.firmware, + status: STATUS_COMMAND, + writeXy: 0x48, + readXy: 0x49, + writeButton: KEYCHRON_SET.writeButton, + readButton: KEYCHRON_SET.readButton, +} as const; +/** Commands on the 20-byte 0xb5 report (answers arrive on 0xb6). */ +const SET = { + version: 0x02, + receiverState: KEYCHRON_SET.receiverState, + sleep: 0x0a, + profile: 0x0e, + lightingAnswer: 0x21, + readLighting: 0x23, + writeLighting: 0x24, + polling: KEYCHRON_SET.polling, + writePollingSets: 0x4a, + readPollingSets: 0x4b, +} as const; + +type LiftOff = NonNullable; +type SensorFlag = "motionSync" | "angleSnapping" | "rippleControl" | "maxSpeed"; + +/** Launcher's support flags (feature1-4), from the status report or, from protocol 6, the 0x02 answer. */ +type Features = { + /** Status bytes 40-42 carry the DPI ceiling and step. */ + dpiLimits: boolean; + /** Gear values can be rewritten, not only picked. */ + pollingGears: boolean; + /** The 20K FPS switch. */ + fps20k: boolean; + angle: boolean; + /** Separate X/Y DPI through 0x48/0x49. */ + separateDpi: boolean; + /** USB and 2.4 GHz polling tables through 0x4a/0x4b. */ + separatePolling: boolean; + /** Lift-off through the level byte instead of the 2-bit code. */ + lodLevel: boolean; +}; + +type Status = KeychronSettings & { + profileCount: number; + /** Gear table as indexes into POLLING_RATES; only the first `pollingCount` are live. */ + pollingTable: number[]; + pollingCount: number; + dpiMax: number; + dpiStep: number; + maxSpeed: boolean; + angle: number; + lodLevel: number; + lodCount: number; + sleepMinutes: number; + batteryPercent: number; + charging: boolean; + features: Features; +}; + +type Identity = { + firmware: string | null; + /** 0 USB, 1 2.4 GHz, 2 Bluetooth. */ + workMode: number; + version: number; + productId: number | null; + /** Feature bytes from the 0x02 answer, used from protocol 6. */ + flags: [number, number, number, number]; +}; + +/** DPI as the panel sees it; `xy` holds the 0x49 block when the mouse keeps X and Y apart. */ +type Dpi = { + activeStage: number; + stages: number[]; + stageCount: number; + xy: { y: number[]; enable: number[] } | null; +}; + +type PollingSet = { level: number; count: number; table: number[] }; +/** The gear set in use, plus both sets when the mouse keeps USB and 2.4 GHz apart. */ +type Polling = PollingSet & { sets: [PollingSet, PollingSet] | null; set: 0 | 1 }; + +type LiftOffChoices = { kind: "code" | "level"; choices: ReadonlyArray }; + +/** + * Keychron Launcher's "8k" mouse protocol on the 0xffc1 collection (63-byte + * 0xb3/0xb4 reads, 20-byte 0xb5/0xb6 writes), which the M6 and most other + * Keychron mice speak. Not the VIA raw-HID protocol the Nape Pro speaks. + * Decoded from Launcher (main.be11320b2a72b61b.js, webpack module 20706); + * the M6 (firmware 1.0.3, USB and Link-KM) confirmed every field it reads and + * every write except angle tuning, buttons, lighting and the flagged paths. + * + * Status report (0x06) layout: + * [1] active onboard profile, zero-based; [50] profile count + * [2..4] per connection (USB, 2.4 GHz, Bluetooth): DPI stage in the low + * nibble, polling gear in the high nibble + * [5..14] five DPI slots, little-endian 16-bit + * [15] bits 0-1 lift-off code, bit 2 ripple control, bit 3 angle + * snapping, bit 4 motion sync, bit 6 reversed scroll + * [16] DPI stages in use (1-5); unused tail slots keep stale values + * [17] debounce in ms; [18] sleep timeout in minutes + * [19] battery percent, bit 7 = charging + * [26] [53] [60] feature flags before protocol 6 (from 6, 0x02 bytes 11-13 and 15) + * [40..41] DPI ceiling; [42] DPI step (when flagged) + * [43..48] polling gears as indexes into POLLING_RATES; [49] gears in use + * [52] bit 0 20K FPS; [55] sensor angle, signed + * [61] lift-off level in the low nibble, level count in the high nibble + * Settings writes are acknowledged with [0xe4, code, command]; code 0 is + * success and 7 means the command is not supported on this connection. + * The 0x40 write packet is the DPI part of this layout shifted one byte down + * (stage at [1..3], slots at [4..13], stage count at [14]). + */ +export class Keychron8kHidClient { + readonly device: HIDDevice; + private openedListener = false; + private identity: Identity | null = null; + private model: KeychronLauncherMouse | null | undefined; + /** Floor, ceiling and step from the last status report, for getDpiOptions(). */ + private dpiLimits: { min: number; max: number; step: number } | null = null; + private responseWaiter: { + match: (bytes: Uint8Array) => boolean; + resolve: (bytes: Uint8Array) => void; + reject: (reason: Error) => void; + } | null = null; + + private readonly onInputReport = (event: HIDInputReportEvent): void => { + if (!this.responseWaiter) return; + const bytes = new Uint8Array(event.data.buffer.slice( + event.data.byteOffset, + event.data.byteOffset + event.data.byteLength, + )); + if (!this.responseWaiter.match(bytes)) return; + const waiter = this.responseWaiter; + this.responseWaiter = null; + waiter.resolve(bytes); + }; + + constructor(device: HIDDevice) { + this.device = device; + } + + /** Launcher treats any Keychron device with this collection as an "8k" mouse, so this does too. */ + static isSupported(device: HIDDevice): boolean { + return device.vendorId === KEYCHRON_VENDOR_ID + && device.collections.some((collection) => + collection.usagePage === USAGE_PAGE + && collection.usage === USAGE + && collection.outputReports.some((report) => report.reportId === COMMAND_REPORT_ID) + && collection.inputReports.some((report) => report.reportId === COMMAND_REPORT_ID + 1)); + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + if (!this.openedListener) { + this.device.addEventListener("inputreport", this.onInputReport); + this.openedListener = true; + } + } + + async close(): Promise { + if (this.openedListener) { + this.device.removeEventListener("inputreport", this.onInputReport); + this.openedListener = false; + } + this.responseWaiter?.reject(new Error(`The ${this.label} was closed.`)); + this.responseWaiter = null; + if (this.device.opened) await this.device.close(); + } + + getDpiOptions(): number[] { + const { min, max, step } = this.dpiLimits ?? this.modelDpiLimits(null); + return Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, index) => min + index * step); + } + + getSleepOptions(): number[] { + return SLEEP_MINUTES.map((minutes) => minutes * 60); + } + + getDebounceOptions(): number[] { + return Array.from({ length: DEBOUNCE_MAX_MS + 1 }, (_, ms) => ms); + } + + readonly canDisableSleep = false; + + /** Falls back to the name alone when the mouse does not answer, e.g. asleep behind its receiver. */ + async readStatus(): Promise { + await this.open(); + const identity = await this.readIdentity(); + const model = await this.readModel(identity); + let status: Status; + let dpi: Dpi; + let polling: Polling; + try { + status = await this.readSettings(); + dpi = await this.readDpi(status, identity); + polling = await this.readPolling(status, identity); + } catch { + return this.unreachableStatus(identity); + } + const buttons = await this.readButtons().catch(() => null); + const light = model?.light ? await this.readLight().catch(() => null) : null; + const pollingRateHz = POLLING_RATES[polling.table[polling.level] ?? 2] ?? 1000; + const liftOff = this.liftOffChoices(status); + const lod = keychronLiftOff(liftOff.choices, liftOff.kind === "level" ? status.lodLevel : status.lod); + const { min, max, step } = this.modelDpiLimits(status); + const sensorOptions = !model?.noSensorOptions; + const name = this.label; + + return { + brand: "Keychron", + name, + ui: { + family: "keychron-8k", + defaultDisplayName: name, + hideUnsupportedPollingRates: true, + forceShowBattery: true, + ...(lod.note ? { statusNote: lod.note } : {}), + ...(sensorOptions ? {} : { hideProcessingCard: true }), + dpiStageEditor: { + maxStages: DPI_STAGE_COUNT, + countEditable: true, + minDpi: min, + maxDpi: max, + stepDpi: step, + }, + }, + batteryPercent: status.batteryPercent <= 100 ? status.batteryPercent : null, + batteryState: status.charging ? "Charging" : "Discharging", + dpi: dpi.stages[dpi.activeStage] ?? dpi.stages[0] ?? 800, + dpiStages: dpi.stages.slice(0, dpi.stageCount), + activeDpiStage: dpi.activeStage, + pollingRateHz, + supportedPollingRates: this.supportedRates(status, polling), + activeProfile: status.profileCount > 1 ? status.profile + 1 : null, + profileCount: status.profileCount > 1 ? status.profileCount : undefined, + connectionType: identity.workMode === 0 ? "Wired" : "Wireless", + connectionDetail: this.connectionDetail(identity), + liftOffDistance: lod.liftOffDistance, + ...(lod.supportedLiftOffDistances ? { supportedLiftOffDistances: lod.supportedLiftOffDistances } : {}), + ...(lod.liftOffScale ? { liftOffScale: lod.liftOffScale } : {}), + ...(sensorOptions ? { + motionSync: status.motionSync, + angleSnapping: status.angleSnapping, + rippleControl: status.rippleControl, + } : {}), + ...(status.features.fps20k ? { performanceMode: status.maxSpeed } : {}), + angleTuning: status.features.angle ? status.angle : undefined, + debounceMs: status.debounceMs, + 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"), + } : {}), + ...(light && model?.light ? { lighting: keychronLighting(light, model.light) } : {}), + firmware: [identity.firmware ?? "Firmware unavailable"], + }; + } + + async setDpi(dpi: number): Promise { + const current = await this.readDpi(await this.readSettings(), await this.readIdentity()); + return await this.setDpiStageValue(current.activeStage, dpi); + } + + async setDpiStageValue(stage: number, dpi: number): Promise { + const status = await this.readSettings(); + this.requireDpi(dpi, status); + const identity = await this.readIdentity(); + const current = await this.readDpi(status, identity); + this.requireStage(stage, current.stageCount); + const stages = current.stages.map((value, index) => (index === stage ? dpi : value)); + const confirmed = (await this.writeDpi(current, { ...current, stages }, identity)).stages[stage]; + if (confirmed !== dpi) { + throw new Error(`The ${this.label} kept ${confirmed} DPI on stage ${stage + 1} instead of ${dpi} DPI.`); + } + return confirmed; + } + + async setActiveDpiStage(stage: number): Promise { + const identity = await this.readIdentity(); + const current = await this.readDpi(await this.readSettings(), identity); + this.requireStage(stage, current.stageCount); + const confirmed = (await this.writeDpi(current, { ...current, activeStage: stage }, identity)).activeStage; + if (confirmed !== stage) throw new Error(`The ${this.label} kept DPI stage ${confirmed + 1}.`); + return confirmed; + } + + async setDpiStageCount(count: number): Promise { + if (!Number.isInteger(count) || count < 1 || count > DPI_STAGE_COUNT) { + throw new Error(`The ${this.label} holds between 1 and ${DPI_STAGE_COUNT} DPI stages.`); + } + const identity = await this.readIdentity(); + const current = await this.readDpi(await this.readSettings(), identity); + // All five hardware slots keep their DPI; the count only decides how many + // the DPI button cycles through. The active stage rides along on the same + // packet, so shrinking past it would write a stage the mouse cannot hold. + const activeStage = Math.min(current.activeStage, count - 1); + const confirmed = (await this.writeDpi(current, { ...current, stageCount: count, activeStage }, identity)).stageCount; + if (confirmed !== count) { + throw new Error(`The ${this.label} kept ${confirmed} DPI stages instead of ${count}.`); + } + return confirmed; + } + + /** + * Picks the gear that already holds the rate. Firmware that flags editable + * gears gets the rate written into the active gear instead, when the model + * offers it. + */ + async setPollingRate(rateHz: number): Promise { + const identity = await this.readIdentity(); + const status = await this.readSettings(); + const polling = await this.readPolling(status, identity); + const rate = POLLING_RATES.indexOf(rateHz as (typeof POLLING_RATES)[number]); + const gear = polling.table.slice(0, polling.count).indexOf(rate); + let next: PollingSet; + if (rate >= 0 && gear >= 0) next = { ...polling, level: gear }; + else if (rate >= 0 && status.features.pollingGears && this.supportedRates(status, polling).includes(rateHz)) { + next = { ...polling, table: polling.table.map((value, index) => (index === polling.level ? rate : value)) }; + } else { + throw new Error(`The ${this.label} does not support ${rateHz} Hz on this connection.`); + } + await this.writeSettings(this.pollingPacket(polling, next)); + const confirmed = await this.readPolling(await this.readSettings(), identity); + const actual = POLLING_RATES[confirmed.table[confirmed.level] ?? 2] ?? 1000; + if (actual !== rateHz) throw new Error(`The ${this.label} kept ${actual} Hz instead of ${rateHz} Hz.`); + return actual; + } + + async setLiftOffDistance(lod: LiftOff): Promise { + const status = await this.readSettings(); + const liftOff = this.liftOffChoices(status); + const code = liftOff.choices.length <= 3 + ? keychronLiftOffStops(liftOff.choices).find(([name]) => name === lod)?.[1] + : undefined; + if (code === undefined) throw new Error(`The ${this.label} has no ${lod} lift-off distance.`); + await this.writeLiftOff(status, liftOff.kind, code); + return lod; + } + + /** Lift-off on models with more than three heights; the code is the level Launcher's config lists. */ + async setLiftOffScale(code: number): Promise { + const status = await this.readSettings(); + const liftOff = this.liftOffChoices(status); + if (!liftOff.choices.some(([value]) => value === code)) throw new Error(`The ${this.label} has no lift-off level ${code}.`); + await this.writeLiftOff(status, liftOff.kind, code); + return code; + } + + async setMotionSync(enabled: boolean): Promise { + return this.writeSensorFlag("motionSync", enabled); + } + + async setAngleSnapping(enabled: boolean): Promise { + return this.writeSensorFlag("angleSnapping", enabled); + } + + async setRippleControl(enabled: boolean): Promise { + return this.writeSensorFlag("rippleControl", enabled); + } + + /** Launcher's "20K FPS" switch, on firmware that flags it. */ + async setPerformanceMode(enabled: boolean): Promise { + if (!(await this.readSettings()).features.fps20k) throw new Error(`The ${this.label} has no 20K FPS mode.`); + return this.writeSensorFlag("maxSpeed", enabled); + } + + async setAngleTuning(degrees: number): Promise { + if (!Number.isInteger(degrees) || Math.abs(degrees) > ANGLE_LIMIT) { + throw new Error(`The ${this.label} angle must be a whole number between -${ANGLE_LIMIT} and ${ANGLE_LIMIT} degrees.`); + } + if (!(await this.readSettings()).features.angle) { + throw new Error(`This ${this.label} firmware does not support angle tuning.`); + } + await this.writeSettings(keychronEncodeAngle(degrees)); + const confirmed = (await this.readSettings()).angle; + if (confirmed !== degrees) throw new Error(`The ${this.label} kept a ${confirmed}° sensor angle instead of ${degrees}°.`); + return confirmed; + } + + async setDebounceTime(debounceMs: number): Promise { + if (!Number.isInteger(debounceMs) || debounceMs < 0 || debounceMs > DEBOUNCE_MAX_MS) { + throw new Error(`The ${this.label} debounce must be between 0 and ${DEBOUNCE_MAX_MS} ms.`); + } + await this.open(); + await this.writeSettings(keychronEncodeDebounce(debounceMs)); + const confirmed = (await this.readSettings()).debounceMs; + if (confirmed !== debounceMs) throw new Error(`The ${this.label} kept ${confirmed} ms debounce instead of ${debounceMs} ms.`); + return confirmed; + } + + async setSleepTimeout(seconds: number): Promise { + const minutes = Math.round(seconds / 60); + if (!SLEEP_MINUTES.includes(minutes as (typeof SLEEP_MINUTES)[number])) { + throw new Error(`The ${this.label} sleep timeout must be one of ${SLEEP_MINUTES.join(", ")} minutes.`); + } + await this.open(); + const packet = new Uint8Array(SETTINGS_PACKET_LENGTH); + packet[0] = SET.sleep; + packet[1] = 1; + packet[2] = minutes; + await this.writeSettings(packet); + const confirmed = (await this.readSettings()).sleepMinutes; + if (confirmed !== minutes) throw new Error(`The ${this.label} kept a ${confirmed} minute sleep timeout instead of ${minutes}.`); + return confirmed * 60; + } + + /** Switch the onboard profile (1-based, as the panel numbers them). */ + async setProfile(profile: number): Promise { + const status = await this.readSettings(); + if (!Number.isInteger(profile) || profile < 1 || profile > Math.min(status.profileCount, PROFILE_MAX)) { + throw new Error(`The ${this.label} profile must be between 1 and ${status.profileCount}.`); + } + const packet = new Uint8Array(SETTINGS_PACKET_LENGTH); + packet[0] = SET.profile; + packet[1] = profile - 1; + await this.writeSettings(packet); + const confirmed = (await this.readSettings()).profile + 1; + if (confirmed !== profile) throw new Error(`The ${this.label} kept profile ${confirmed}.`); + return confirmed; + } + + /** 0x52 on the 63-byte report: [1] button index, [3] type, then the type's data. */ + async setButtonMapping(button: string, action: string): Promise { + await this.readModel(await this.readIdentity()); + const buttons = keychronButtons(this.model ?? undefined); + const slot = buttons.find((entry) => entry.name === button); + if (!slot) throw new Error(`The ${this.label} has no "${button}" button.`); + const code = keychronEncodeButton(action, "8k"); + if (!code) throw new Error(`Unknown button action "${action}".`); + const expected = action === "Default" ? slot.defaultAction : action; + const after = (await this.readButtons()).map((entry) => (entry.button.index === slot.index ? expected : entry.action)); + if (!after.includes("Left Click")) throw new Error("Keep at least one button as Left Click."); + const packet = new Uint8Array(PACKET_LENGTH); + packet[0] = CMD.writeButton; + packet[1] = slot.index; + packet.set(code, 3); + const reply = await this.query(COMMAND_REPORT_ID, PACKET_LENGTH, Array.from(packet), (bytes) => bytes[0] === ACK && bytes[2] === CMD.writeButton); + if (reply[1] !== 0) throw new Error(`The ${this.label} rejected the ${button} button (code ${reply[1]}).`); + const confirmed = await this.readButton(slot); + if (confirmed !== expected) throw new Error(`The ${this.label} kept ${confirmed} on ${button} instead of ${action}.`); + } + + async setLighting(lighting: MouseLighting): Promise { + await this.readModel(await this.readIdentity()); + const offered = this.model?.light; + if (!offered) throw new Error(`The ${this.label} has no lighting.`); + const light = keychronEncodeLighting(lighting, offered); + const packet = new Uint8Array(SETTINGS_PACKET_LENGTH); + packet[0] = SET.writeLighting; + packet.set([light.mode, light.brightness, light.speed, ...light.rgb], 1); + await this.writeSettings(packet); + const confirmed = keychronLighting(await this.readLight(), offered); + if (confirmed.mode !== lighting.mode) throw new Error(`The ${this.label} kept its ${confirmed.mode ?? "previous"} lighting.`); + return confirmed; + } + + private get label(): string { + return this.model?.name ?? "Keychron mouse"; + } + + private async writeSensorFlag(flag: SensorFlag, enabled: boolean): Promise { + const confirmed = await this.writeSensor({ ...(await this.readSettings()), [flag]: enabled }); + if (confirmed[flag] !== enabled) throw new Error(`The ${this.label} kept ${flag} ${confirmed[flag] ? "on" : "off"}.`); + return confirmed[flag]; + } + + /** + * The 0x42 packet carries every sensor option at once, so resend the + * current state with the requested change applied. Lift-off goes in the + * code or the level byte, whichever the firmware uses, with the other one + * 0 the way Launcher sends it. + */ + private async writeSensor(next: Status): Promise { + const levelMode = this.liftOffChoices(next).kind === "level"; + await this.writeSettings(keychronEncodeSensor({ + ...next, + lod: levelMode ? 0 : next.lod, + lodLevel: levelMode ? next.lodLevel : 0, + })); + return await this.readSettings(); + } + + private async writeLiftOff(status: Status, kind: LiftOffChoices["kind"], code: number): Promise { + const confirmed = await this.writeSensor(kind === "level" ? { ...status, lodLevel: code } : { ...status, lod: code }); + const actual = kind === "level" ? confirmed.lodLevel : confirmed.lod; + if (actual !== code) throw new Error(`The ${this.label} kept lift-off ${kind} ${actual}.`); + } + + /** + * The 2-bit codes, or on firmware that flags lift-off levels the level byte, + * which Launcher fills from the same config list sorted by index and cut to + * the count the mouse reports. + */ + private liftOffChoices(status: Status): LiftOffChoices { + const model = this.model; + const codes = model ? model.lod ?? [] : STANDARD_LOD; + if (!status.features.lodLevel) return { kind: "code", choices: codes }; + const levels = [...(model?.lodLevels?.length ? model.lodLevels : codes)].sort((a, b) => a[0] - b[0]); + return { kind: "level", choices: status.lodCount > 0 ? levels.slice(0, status.lodCount) : levels }; + } + + /** The model's rates when the gears can be rewritten, otherwise the rates the gears hold. */ + private supportedRates(status: Status, polling: PollingSet): number[] { + const model = this.model; + const rates = status.features.pollingGears && model + ? POLLING_RATES.filter((rate) => rate <= model.maxPollingHz) + : polling.table.slice(0, polling.count).map((value) => POLLING_RATES[value]).filter((rate) => rate !== undefined); + const unique = [...new Set(rates)].sort((a, b) => a - b); + return unique.length ? unique : [POLLING_RATES[polling.table[polling.level] ?? 2] ?? 1000]; + } + + private connectionDetail(identity: Identity): string { + if (identity.workMode === 2) return "Bluetooth"; + if (identity.workMode === 0) return "Wired USB"; + const receiver = KEYCHRON_RECEIVERS.get(this.device.productId); + return receiver ? `2.4 GHz (${receiver})` : "2.4 GHz receiver"; + } + + private unreachableStatus(identity: Identity): MouseStatus { + const name = this.label; + return { + brand: "Keychron", + name, + ui: { + family: "keychron-8k", + defaultDisplayName: name, + settingsReady: false, + statusNote: identity.workMode === 1 + ? "The mouse did not answer through the receiver. Wake it and reconnect." + : "The mouse did not answer its settings reads. Reconnect it and try again.", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: 0, + pollingRateHz: 0, + activeProfile: null, + connectionType: identity.workMode === 0 ? "Wired" : "Wireless", + liftOffDistance: null, + firmware: identity.firmware ? [identity.firmware] : [], + }; + } + + private requireDpi(dpi: number, status: Status): void { + const { min, max, step } = this.modelDpiLimits(status); + if (!Number.isInteger(dpi) || dpi < min || dpi > max || dpi % step !== 0) { + throw new Error(`The ${this.label} DPI must be a multiple of ${step} between ${min} and ${max}.`); + } + } + + private requireStage(stage: number, stageCount: number): void { + if (!Number.isInteger(stage) || stage < 0 || stage >= stageCount) { + throw new Error(`DPI stage must be between 1 and ${stageCount}.`); + } + } + + /** The model's floor, and the mouse's own ceiling and step when it flags them. */ + private modelDpiLimits(status: Status | null): { min: number; max: number; step: number } { + const [min, max] = this.model?.dpi ?? DEFAULT_DPI; + return { + min, + max: status?.features.dpiLimits && status.dpiMax > min ? status.dpiMax : max, + step: status?.features.dpiLimits && status.dpiStep > 0 ? status.dpiStep : DEFAULT_DPI_STEP, + }; + } + + private async readSettings(): Promise { + await this.open(); + const identity = await this.readIdentity(); + await this.readModel(identity); + const status = this.parseStatus(await this.queryStatus(), identity); + this.dpiLimits = this.modelDpiLimits(status); + return status; + } + + /** + * Firmware string (0x04 on 0xb3) and connection mode (0x02 on 0xb5) never + * change while connected, so they are read once. Either failing leaves the + * mouse usable: the mode falls back to what the product ID implies. + */ + private async readIdentity(): Promise { + if (this.identity) return this.identity; + await this.open(); + const fallbackMode = KEYCHRON_RECEIVERS.has(this.device.productId) ? 1 : 0; + const version = await this.querySettings(SET.version, [fallbackMode]).catch(() => null); + const workMode = version ? (version[9] ?? fallbackMode) & 0x07 : fallbackMode; + const firmware = await this.query(COMMAND_REPORT_ID, PACKET_LENGTH, [CMD.firmware, workMode], (bytes) => bytes[0] === CMD.firmware) + .then((bytes) => keychronLauncherFirmware(bytes) ?? (version ? decodeFirmwareNibbles(version) : null)) + .catch(() => (version ? decodeFirmwareNibbles(version) : null)); + this.identity = { + firmware, + workMode, + version: version ? readU16(version, 1) : 0, + productId: version ? readU16(version, 5) : null, + flags: version ? [version[11] ?? 0, version[12] ?? 0, version[13] ?? 0, version[15] ?? 0] : [0, 0, 0, 0], + }; + return this.identity; + } + + /** + * The model table row: by USB product ID, then by the ID in the 0x02 + * answer, then, behind a receiver, by the connected mouse in its 0x03 list. + */ + private async readModel(identity: Identity): Promise { + if (this.model !== undefined) return this.model; + let model = keychronLauncherMouse(this.device.productId) ?? keychronLauncherMouse(identity.productId); + if (!model && identity.workMode === 1) { + const list = await this.querySettings(SET.receiverState, []).catch(() => null); + model = keychronLauncherMouse(list ? keychronDecodeConnectedMouse(list) : null); + } + this.model = model ?? null; + return this.model; + } + + private parseStatus(bytes: Uint8Array, identity: Identity): Status { + if (bytes.length < 51 || bytes[0] !== STATUS_COMMAND) { + throw new Error(`The ${this.label} returned an invalid status report.`); + } + const flags = identity.version >= FLAGS_IN_VERSION_REPLY + ? identity.flags + : [bytes[26] ?? 0, bytes[53] ?? 0, bytes[60] ?? 0, 0]; + const angle = bytes[55] ?? 0; + return { + ...keychronDecodeSettings(bytes), + profileCount: Math.min(bytes[50] ?? 0, PROFILE_MAX), + pollingTable: Array.from(bytes.slice(43, 43 + POLLING_GEARS)), + pollingCount: Math.min(bytes[49] || POLLING_GEARS, POLLING_GEARS), + dpiMax: readU16(bytes, 40), + dpiStep: bytes[42] ?? 0, + maxSpeed: ((bytes[52] ?? 0) & 0x01) !== 0, + angle: angle > ANGLE_LIMIT ? angle - 256 : angle, + lodLevel: (bytes[61] ?? 0) & 0x0f, + lodCount: (bytes[61] ?? 0) >> 4, + sleepMinutes: bytes[18] ?? 0, + batteryPercent: (bytes[19] ?? 0) & 0x7f, + charging: ((bytes[19] ?? 0) & 0x80) !== 0, + features: decodeFeatures(flags[0] ?? 0, flags[1] ?? 0, flags[2] ?? 0, flags[3] ?? 0), + }; + } + + /** Stages from the status report, or from 0x49 on firmware that keeps X and Y apart. */ + private async readDpi(status: Status, identity: Identity): Promise { + if (!status.features.separateDpi) { + return { + activeStage: keychronActiveStage(status, identity.workMode), + stages: status.dpiStages, + stageCount: status.stageCount, + xy: null, + }; + } + const bytes = await this.query(COMMAND_REPORT_ID, PACKET_LENGTH, [CMD.readXy], (reply) => reply[0] === CMD.readXy); + const stageCount = Math.min(bytes[4] || DPI_STAGE_COUNT, DPI_STAGE_COUNT); + const levels = bytes[1 + Math.min(identity.workMode, 2)] ?? 0; + return { + activeStage: Math.min(levels & 0x0f, stageCount - 1), + stages: Array.from({ length: DPI_STAGE_COUNT }, (_, stage) => readU16(bytes, 5 + stage * 2)), + stageCount, + xy: { + y: Array.from({ length: DPI_STAGE_COUNT }, (_, stage) => readU16(bytes, 21 + stage * 2)), + // Protocol 6 packs the per-stage flags into one byte; older firmware gives each a byte. + enable: identity.version >= FLAGS_IN_VERSION_REPLY + ? Array.from({ length: 8 }, (_, bit) => ((bytes[37] ?? 0) >> bit) & 1) + : Array.from(bytes.slice(37, 45)), + }, + }; + } + + /** + * 0x40, or 0x48 when X and Y are kept apart. The panel edits one axis, so + * a changed stage gets X = Y; the other stages keep their pairs. + */ + private async writeDpi(current: Dpi, next: Dpi, identity: Identity): Promise { + if (!current.xy) { + await this.writeSettings(keychronEncodeDpi(next.activeStage, next.stages, next.stageCount)); + return await this.readDpi(await this.readSettings(), identity); + } + const { y, enable } = current.xy; + const v6 = identity.version >= FLAGS_IN_VERSION_REPLY; + const packet = new Uint8Array(PACKET_LENGTH); + packet[0] = CMD.writeXy; + // Before protocol 6 each connection byte also carries the Y stage in its high nibble. + packet.fill(v6 ? next.activeStage : (next.activeStage << 4) | next.activeStage, 1, 4); + packet[4] = next.stageCount; + next.stages.forEach((x, stage) => { + writeU16(packet, 5 + stage * 2, x); + writeU16(packet, 21 + stage * 2, x === current.stages[stage] ? y[stage] ?? x : x); + }); + if (v6) packet[37] = enable.reduce((mask, bit, index) => mask | ((bit & 1) << index), 0); + else packet.set(enable.slice(0, 8), 37); + const reply = await this.query(COMMAND_REPORT_ID, PACKET_LENGTH, Array.from(packet), (bytes) => bytes[0] === ACK && bytes[2] === CMD.writeXy); + if (reply[1] !== 0) throw new Error(`The ${this.label} rejected command 0x48 (code ${reply[1]}).`); + return await this.readDpi(await this.readSettings(), identity); + } + + /** The gear set of this connection: the status table, or 0x4b's USB and 2.4 GHz sets. */ + private async readPolling(status: Status, identity: Identity): Promise { + if (!status.features.separatePolling) { + return { + level: keychronActiveGear(status, identity.workMode), + count: status.pollingCount, + table: status.pollingTable, + sets: null, + set: 0, + }; + } + const bytes = await this.querySettings(SET.readPollingSets, []); + const sets = [0, 1].map((set): PollingSet => ({ + level: bytes[1 + set] ?? 0, + count: Math.min(bytes[3 + set] || POLLING_GEARS, POLLING_GEARS), + table: Array.from(bytes.slice(5 + set * POLLING_GEARS, 5 + (set + 1) * POLLING_GEARS)), + })) as [PollingSet, PollingSet]; + const set = identity.workMode === 1 ? 1 : 0; + return { ...sets[set], sets, set }; + } + + /** 0x41 carries this connection's gears; 0x4a carries both sets. */ + private pollingPacket(polling: Polling, next: PollingSet): Uint8Array { + const packet = new Uint8Array(SETTINGS_PACKET_LENGTH); + if (!polling.sets) { + packet[0] = SET.polling; + packet[1] = next.level; + packet[2] = next.level; + packet.set(next.table.slice(0, next.count), 3); + packet[9] = next.count; + return packet; + } + packet[0] = SET.writePollingSets; + polling.sets.map((set, index) => (index === polling.set ? next : set)).forEach((set, index) => { + packet[1 + index] = set.level; + packet[3 + index] = set.count; + packet.set(set.table.slice(0, POLLING_GEARS), 5 + index * POLLING_GEARS); + }); + return packet; + } + + private async readButtons(): Promise> { + const buttons = keychronButtons(this.model ?? undefined); + if (!buttons.length) throw new Error(`OpenMouse does not know the ${this.label} buttons.`); + const result: Array<{ button: KeychronButton; action: string }> = []; + for (const button of buttons) result.push({ button, action: await this.readButton(button) }); + return result; + } + + /** 0x62 answers [0x62, index, 0, type, data...]. */ + private async readButton(button: KeychronButton): Promise { + const bytes = await this.query( + COMMAND_REPORT_ID, + PACKET_LENGTH, + [CMD.readButton, button.index], + (reply) => reply[0] === CMD.readButton && reply[1] === button.index, + ); + return keychronDecodeButton(bytes, "8k") ?? button.defaultAction; + } + + /** 0x23 answers on 0x21: mode, brightness, speed, then RGB. */ + private async readLight(): Promise { + const bytes = await this.query(SETTINGS_REPORT_ID, SETTINGS_PACKET_LENGTH, [SET.readLighting], (reply) => reply[0] === SET.lightingAnswer); + return { mode: bytes[1] ?? 0, brightness: bytes[2] ?? 0, speed: bytes[3] ?? 0, rgb: [bytes[4] ?? 0, bytes[5] ?? 0, bytes[6] ?? 0] }; + } + + private async queryStatus(): Promise { + return await this.query(COMMAND_REPORT_ID, PACKET_LENGTH, [STATUS_COMMAND], (bytes) => bytes[0] === STATUS_COMMAND); + } + + private async querySettings(command: number, args: number[]): Promise { + return await this.query(SETTINGS_REPORT_ID, SETTINGS_PACKET_LENGTH, [command, ...args], (bytes) => bytes[0] === command); + } + + private async writeSettings(packet: Uint8Array): Promise { + const command = packet[0] ?? 0; + const reply = await this.query( + SETTINGS_REPORT_ID, + SETTINGS_PACKET_LENGTH, + Array.from(packet), + (bytes) => bytes[0] === command || (bytes[0] === ACK && bytes[2] === command), + ); + if (reply[0] === ACK && reply[1] !== 0) { + throw new Error(`The ${this.label} rejected command 0x${command.toString(16)} (code ${reply[1]}).`); + } + } + + private async query( + reportId: number, + length: number, + payload: number[], + match: (bytes: Uint8Array) => boolean, + ): Promise { + if (this.responseWaiter) throw new Error(`Another ${this.label} request is already in progress.`); + const packet = new Uint8Array(length); + packet.set(payload.slice(0, length)); + let timeout: ReturnType | undefined; + let rejectResponse: ((reason: Error) => void) | null = null; + const response = new Promise((resolve, reject) => { + rejectResponse = reject; + timeout = setTimeout(() => { + this.responseWaiter = null; + reject(new Error(`The ${this.label} did not answer command 0x${packet[0]?.toString(16)}.`)); + }, QUERY_TIMEOUT_MS); + this.responseWaiter = { + match, + resolve: (bytes) => { + clearTimeout(timeout); + resolve(bytes); + }, + reject: (reason) => { + clearTimeout(timeout); + reject(reason); + }, + }; + }); + void response.catch(() => undefined); + try { + await this.device.sendReport(reportId, packet.buffer); + } catch (error) { + this.responseWaiter = null; + clearTimeout(timeout); + const detail = error instanceof Error ? error.message : String(error); + (rejectResponse as ((reason: Error) => void) | null)?.( + new Error(`Chrome could not write the ${this.label} HID report. ${detail}`), + ); + } + return await response; + } +} + +function decodeFeatures(feature1: number, feature2: number, feature3: number, feature4: number): Features { + return { + dpiLimits: (feature1 & 0x08) !== 0, + pollingGears: (feature1 & 0x10) !== 0, + fps20k: (feature1 & 0x80) !== 0, + angle: (feature2 & 0x04) !== 0, + separateDpi: (feature3 & 0x01) !== 0, + separatePolling: (feature3 & 0x02) !== 0, + lodLevel: (feature4 & 0x10) !== 0, + }; +} + +/** 0x02 answer: major at [8], minor and patch in the nibbles of [7]. */ +function decodeFirmwareNibbles(bytes: Uint8Array): string | null { + if (bytes.length < 9) return null; + return `v${bytes[8]}.${(bytes[7] ?? 0) >> 4}.${(bytes[7] ?? 0) & 0x0f}`; +} diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index c07a94e..ae828b7 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -17,8 +17,8 @@ import { const DEVICES_DIR = dirname(fileURLToPath(import.meta.url)); -const REPORT_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0x09, 0x0e, 0x0f, 0x10, 0x11, 0x20, 0xa1, 0xb3, 0xb4, 0xb5]; -const USAGE_PAGES = [0x01, 0x0a, 0x0c, 0xFF07, 0xff, 0xff00, 0xff01, 0xff02, 0xff05, 0xff0a, 0xff1c, 0xff43, 0xff55, 0xff60, 0xffa0, 0xffc1, 0xffc2, 0xffff]; +const REPORT_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0x09, 0x0e, 0x0f, 0x10, 0x11, 0x20, 0x51, 0xa1, 0xb3, 0xb4, 0xb5]; +const USAGE_PAGES = [0x01, 0x0a, 0x0c, 0x8c, 0xFF07, 0xff, 0xff00, 0xff01, 0xff02, 0xff05, 0xff0a, 0xff1c, 0xff43, 0xff55, 0xff60, 0xffa0, 0xffc1, 0xffc2, 0xffff]; // Usage 4 is the Corsair config collection; 0x61 is VIA raw HID; 0xc7 is Ryunix telemetry. const USAGES = [0, 1, 0x0212, 2, 4, 0x10, 0x61, 0xc7]; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index b18abc1..0c6de2f 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -8,8 +8,9 @@ import { FantechHidClient } from "./fantech/hid.ts"; import { GearHubHidClient } from "./gearhub/hid.ts"; import { eggWeCreate, eggWeIsSupported, eggWeSupportScore, isEggWeClient, type EggWeHidClient } from "./endgame/egg-we-control.ts"; import { FinalmouseHidClient } from "./finalmouse/hid.ts"; +import { Keychron1kHidClient } from "./keychron/mouse-1k-hid.ts"; import { Keychron4kHidClient } from "./keychron/mouse-4k-hid.ts"; -import { KeychronM6HidClient } from "./keychron/m6-hid.ts"; +import { Keychron8kHidClient } from "./keychron/mouse-8k-hid.ts"; import { KeychronNapeHidClient } from "./keychron/nape-hid.ts"; import { LamzuAtlantisHidClient } from "./lamzu-atlantis/hid.ts"; import { LamzuHidClient } from "./lamzu/hid.ts"; @@ -68,7 +69,7 @@ import { KyuProMx1Client } from "./ryunix/kyu-pro-mx1-hid.ts"; import { BytechHidClient } from "./bytech/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | KeychronM6HidClient | Keychron4kHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient; export interface DeviceDriver { brand: string; @@ -116,7 +117,9 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "Delux", supports: (device) => DeluxHidClient.isSupported(device), create: (device) => new DeluxHidClient(device), score: () => 6 }, { brand: "Attack Shark", supports: (device) => AttackSharkHidClient.isSupported(device), create: (device) => new AttackSharkHidClient(device), score: () => 5 }, { brand: "Razer", supports: (device) => RazerViperV4ProHidClient.isSupported(device), create: (device) => new RazerViperV4ProHidClient(device), score: () => 7 }, - { brand: "Keychron", supports: (device) => KeychronM6HidClient.isSupported(device), create: (device) => new KeychronM6HidClient(device), score: () => 7 }, + // Launcher's order: the 0xffc1 collection, then 0x8c, then the 4K family's 0xff0a. + { brand: "Keychron", supports: (device) => Keychron8kHidClient.isSupported(device), create: (device) => new Keychron8kHidClient(device), score: () => 7 }, + { brand: "Keychron", supports: (device) => Keychron1kHidClient.isSupported(device), create: (device) => new Keychron1kHidClient(device), score: () => 7 }, { brand: "Keychron", supports: (device) => Keychron4kHidClient.isSupported(device), create: (device) => new Keychron4kHidClient(device), score: () => 7 }, { brand: "Keychron", supports: (device) => KeychronNapeHidClient.isSupported(device), create: (device) => new KeychronNapeHidClient(device), score: () => 6 }, // Ahead of Fantech: GearHub-V5 mice (Lingbao M5 Pro, Attack Shark R2, …) diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 9f4fbf4..99ebda3 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -318,9 +318,11 @@ export const KEYCHRON_NAPE_HID_FILTERS: HIDDeviceFilter[] = KEYCHRON_NAPE_PRODUC (productId) => ({ vendorId: VENDOR_ID.keychron, productId, usagePage: 0xff60, usage: 0x61 }), ); -export const KEYCHRON_M6_HID_FILTERS: HIDDeviceFilter[] = [ - { vendorId: VENDOR_ID.keychron, productId: 0xd060, usagePage: 0xffc1, usage: 0x01 }, - { vendorId: VENDOR_ID.keychron, productId: 0xd029, usagePage: 0xffc1, usage: 0x01 }, +// Keychron mice and receivers on Launcher's "8k" (0xffc1) and "1k" (0x8c) +// protocols. No product IDs: Launcher picks the protocol by collection alone. +export const KEYCHRON_LAUNCHER_HID_FILTERS: HIDDeviceFilter[] = [ + { vendorId: VENDOR_ID.keychron, usagePage: 0xffc1, usage: 0x01 }, + { vendorId: VENDOR_ID.keychron, usagePage: 0x8c, usage: 0x01 }, ]; // Keychron 4K mice and their receiver. No product ID: the receiver's is unknown, @@ -770,7 +772,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...RAZER_DEATHADDER_ESSENTIAL_FILTERS, ...RAZER_COBRA_FILTERS, ...KEYCHRON_NAPE_HID_FILTERS, - ...KEYCHRON_M6_HID_FILTERS, + ...KEYCHRON_LAUNCHER_HID_FILTERS, ...KEYCHRON_4K_HID_FILTERS, ...RAZER_REGISTRY_FILTERS, ...RAZER_DEATHADDER_V2_FILTERS, diff --git a/src/keychron/index.ts b/src/keychron/index.ts index a333b35..16340a5 100644 --- a/src/keychron/index.ts +++ b/src/keychron/index.ts @@ -16,6 +16,117 @@ export const KEYCHRON_M6_SETTINGS_REPORT_ID = 0xb5; export const KEYCHRON_M6_SETTINGS_RESPONSE_REPORT_ID = 0xb6; export const KEYCHRON_M6_STATUS_COMMAND = 0x06; export const KEYCHRON_M6_STATUS_PACKET_LENGTH = 63; +/** + * The M6's protocol is Launcher's "8k" one, which every Keychron mouse with a + * 0xffc1 collection speaks. Launcher's "1k" protocol is the same command set + * on usage page 0x8c: 20-byte feature report 0x51 for settings and 64-byte + * feature report 0x52 for buttons. Launcher prefers 0xffc1, then 0x8c, then + * the 4K family's 0xff0a when a device offers more than one. + */ +export const KEYCHRON_1K_USAGE_PAGE = 0x8c; +export const KEYCHRON_1K_USAGE = 0x01; +export const KEYCHRON_1K_REPORT_ID = 0x51; +export const KEYCHRON_1K_BUTTON_REPORT_ID = 0x52; +/** Default function Launcher's config gives a button; it also names the button. */ +export type KeychronButtonId = + | "left" | "right" | "middle" | "backward" | "forward" + | "leftTilt" | "rightTilt" | "upScroll" | "downScroll" | "leftScroll" | "rightScroll" + | "dpiLoop" | "pageUp" | "pageDown" | "swichLight"; +/** + * One row per product ID from Launcher's per-model config + * (launcher.keychron.com/static/device//json/v3.json), named + * as Launcher's product list names it. Neither says which of the two + * protocols a model speaks; the collection it exposes decides that. + */ +export interface KeychronLauncherMouse { + productId: number; + name: string; + /** dpi.limit */ + dpi: readonly [min: number, max: number]; + /** The highest rate in dpi.reportRate. */ + maxPollingHz: number; + /** sys.lod stops written as the 2-bit lift-off code: [code, millimetres]. */ + lod?: ReadonlyArray; + /** sys.lod stops written as the lift-off level byte, on firmware that flags it: [level, millimetres]. */ + lodLevels?: ReadonlyArray; + /** keys: button index and its default function. */ + buttons: ReadonlyArray; + /** light: the effect codes the model offers. */ + light?: readonly number[]; + /** sys.disSensor: Launcher hides ripple control, angle snapping and motion sync. */ + noSensorOptions?: true; +} +/** + * The M6 (0xd060) is the only row confirmed on hardware; its config lists 1 + * and 2 mm, but lift-off code 3 (0.7 mm) round-tripped on the mouse too. + */ +export const KEYCHRON_LAUNCHER_MICE: readonly KeychronLauncherMouse[] = [ + { productId: 0xd033, name: "Keychron M3", dpi: [100, 26000], maxPollingHz: 1000, lod: [[1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [7, "swichLight"], [13, "downScroll"], [14, "upScroll"]], light: [1, 2, 3, 4, 5, 6] }, + { productId: 0xd035, name: "Keychron M1", dpi: [100, 26000], maxPollingHz: 1000, lod: [[1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [5, "forward"], [6, "backward"], [13, "downScroll"], [14, "upScroll"]], light: [1, 2, 3, 4, 5, 6] }, + { productId: 0xd036, name: "Keychron M3 Mini", dpi: [100, 26000], maxPollingHz: 1000, lod: [[1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd03b, name: "Keychron M2", dpi: [100, 26000], maxPollingHz: 1000, lod: [[1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd03d, name: "Keychron M2 Mini", dpi: [100, 26000], maxPollingHz: 1000, lod: [[1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd03f, name: "Keychron M6", dpi: [100, 26000], maxPollingHz: 1000, lod: [[1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [8, "leftTilt"], [9, "rightTilt"], [10, "rightScroll"], [11, "leftScroll"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd043, name: "Keychron M4", dpi: [100, 26000], maxPollingHz: 1000, lod: [[1, 1], [2, 2]], buttons: [[0, "left"], [1, "right"], [2, "middle"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd044, name: "Keychron M7", dpi: [100, 26000], maxPollingHz: 1000, lod: [[1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [7, "pageDown"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd048, name: "Keychron M5 8K", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [8, "downScroll"], [9, "upScroll"], [10, "rightScroll"], [11, "leftScroll"]] }, + { productId: 0xd049, name: "Keychron M6 8K", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [8, "rightTilt"], [9, "leftTilt"], [10, "rightScroll"], [11, "leftScroll"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd04a, name: "Keychron M3 KM", dpi: [50, 26000], maxPollingHz: 1000, lod: [[1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [5, "swichLight"], [6, "upScroll"], [7, "downScroll"]], light: [1, 2, 3, 4, 5, 6] }, + { productId: 0xd04c, name: "Keychron M3 Combo", dpi: [50, 12000], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [5, "pageDown"], [6, "upScroll"], [7, "downScroll"]], noSensorOptions: true }, + { productId: 0xd04d, name: "Keychron M2 Mini Combo", dpi: [50, 26000], maxPollingHz: 1000, lod: [[1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [6, "upScroll"], [7, "downScroll"]] }, + { productId: 0xd04e, name: "Keychron M3 Combo", dpi: [50, 12000], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [5, "pageDown"], [6, "upScroll"], [7, "downScroll"]], noSensorOptions: true }, + { productId: 0xd04f, name: "Keychron M3 Mini", dpi: [50, 12000], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [6, "upScroll"], [7, "downScroll"]], noSensorOptions: true }, + { productId: 0xd050, name: "Keychron M3 8K", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "pageDown"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd051, name: "Keychron M3 Mini 8K", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd052, name: "Keychron M2 8K", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd053, name: "Keychron M4 8K", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd054, name: "Keychron M1 8K", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [5, "pageUp"], [6, "pageDown"], [13, "upScroll"], [14, "downScroll"]] }, + { productId: 0xd055, name: "Keychron M2 Mini 8K", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd056, name: "Keychron M7 8K", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "pageDown"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd058, name: "Keychron BM22", dpi: [100, 2400], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [5, "dpiLoop"], [6, "upScroll"], [7, "downScroll"]], noSensorOptions: true }, + { productId: 0xd059, name: "Keychron M1", dpi: [100, 26000], maxPollingHz: 1000, lod: [[1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [5, "forward"], [6, "backward"], [13, "downScroll"], [14, "upScroll"]], light: [1, 2, 3, 4, 5, 6] }, + { productId: 0xd060, name: "Keychron M6", dpi: [100, 26000], maxPollingHz: 1000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [8, "rightTilt"], [9, "leftTilt"], [10, "rightScroll"], [11, "leftScroll"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd061, name: "Keychron BM24", dpi: [100, 2400], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [5, "dpiLoop"], [6, "upScroll"], [7, "downScroll"]], noSensorOptions: true }, + { productId: 0xd062, name: "Keychron BM25", dpi: [100, 2400], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [5, "dpiLoop"], [6, "upScroll"], [7, "downScroll"]], noSensorOptions: true }, + { productId: 0xd063, name: "Keychron BM26", dpi: [100, 2400], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [6, "upScroll"], [7, "downScroll"]], noSensorOptions: true }, + { productId: 0xd064, name: "Keychron M6", dpi: [50, 12000], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [8, "rightTilt"], [9, "leftTilt"], [10, "rightScroll"], [11, "leftScroll"], [13, "downScroll"], [14, "upScroll"]], noSensorOptions: true }, + { productId: 0xd067, name: "Keychron M6 SE", dpi: [50, 12000], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [8, "rightTilt"], [9, "leftTilt"], [10, "rightScroll"], [11, "leftScroll"], [13, "downScroll"], [14, "upScroll"]], noSensorOptions: true }, + { productId: 0xd068, name: "Keychron M5", dpi: [50, 12000], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [10, "rightScroll"], [11, "leftScroll"], [13, "upScroll"], [14, "downScroll"]], noSensorOptions: true }, + { productId: 0xd069, name: "Keychron M7", dpi: [50, 12000], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [7, "pageDown"], [13, "upScroll"], [14, "downScroll"]], noSensorOptions: true }, + { productId: 0xd06b, name: "Keychron LM7", dpi: [50, 26000], maxPollingHz: 8000, lod: [[1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd06c, name: "Keychron LM7 Ultra", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd06d, name: "Keychron G4", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd06e, name: "Keychron G3", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd06f, name: "Keychron G5", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd070, name: "Keychron BM27", dpi: [100, 2400], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [7, "dpiLoop"], [13, "upScroll"], [14, "downScroll"]], noSensorOptions: true }, + { productId: 0xd073, name: "Keychron M5 SE", dpi: [100, 2400], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [7, "dpiLoop"], [13, "upScroll"], [14, "downScroll"]], noSensorOptions: true }, + { productId: 0xd074, name: "Keychron G3 HE", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd075, name: "Keychron M8", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd076, name: "Keychron M3 V2", dpi: [50, 26000], maxPollingHz: 8000, lod: [[1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [5, "upScroll"], [6, "downScroll"]], light: [1, 2, 3] }, + { productId: 0xd079, name: "Keychron BM28", dpi: [100, 2400], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [7, "dpiLoop"], [13, "upScroll"], [14, "downScroll"]], noSensorOptions: true }, + { productId: 0xd080, name: "Keychron G4 HE", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd082, name: "Keychron BM28 8K", dpi: [50, 6000], maxPollingHz: 8000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [5, "dpiLoop"], [10, "rightScroll"], [11, "leftScroll"], [13, "upScroll"], [14, "downScroll"]], noSensorOptions: true }, + { productId: 0xd083, name: "Keychron G3 HE", dpi: [50, 40000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], lodLevels: [[2, 0.9], [3, 1.2], [4, 1.4], [5, 1.6]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd084, name: "Keychron T1 HE", dpi: [100, 2400], maxPollingHz: 1000, buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [7, "dpiLoop"], [13, "upScroll"], [14, "downScroll"]], noSensorOptions: true }, + { productId: 0xd086, name: "Keychron G6 HE 8K", dpi: [50, 40000], maxPollingHz: 8000, lodLevels: [[1, 0.7], [2, 0.8], [3, 0.9], [4, 1], [5, 1.1], [6, 1.2], [7, 1.3], [8, 1.4], [9, 1.5], [10, 1.6], [11, 1.7]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [13, "upScroll"], [14, "downScroll"]] }, + { productId: 0xd087, name: "Keychron G5 HE", dpi: [50, 40000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd08a, name: "Keychron M6 HE 8K", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [8, "rightTilt"], [9, "leftTilt"], [10, "rightScroll"], [11, "leftScroll"], [13, "downScroll"], [14, "upScroll"]] }, + { productId: 0xd08c, name: "Keychron G10 HE", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd091, name: "Keychron M8 HE", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd092, name: "Keychron G9 HE 8K", dpi: [50, 30000], maxPollingHz: 8000, lod: [[3, 0.7], [1, 1], [2, 2]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "backward"], [4, "forward"], [7, "upScroll"], [8, "downScroll"]] }, + { productId: 0xd09d, name: "Keychron G6 HE 8K", dpi: [50, 40000], maxPollingHz: 8000, lodLevels: [[1, 0.7], [2, 0.8], [3, 0.9], [4, 1], [5, 1.1], [6, 1.2], [7, 1.3], [8, 1.4], [9, 1.5], [10, 1.6], [11, 1.7]], buttons: [[0, "left"], [1, "middle"], [2, "right"], [3, "forward"], [4, "backward"], [13, "upScroll"], [14, "downScroll"]] }, +]; +/** Receivers in Launcher's product list (category "Bridge"). */ +export const KEYCHRON_RECEIVERS = new Map([ + [0xd024, "CANDYSIGN Link"], + [0xd026, "Keychron Link-KM"], + [0xd027, "Keychron Receiver"], + [0xd028, "Keychron Ultra-Link 8K"], + [0xd029, "Keychron Link-KM Type C"], + [0xd030, "Keychron Link Type A"], + [0xd031, "Keychron Link Type C"], + [0xd05a, "Keychron TurboLink 8K"], +]); /** * Keychron's 4K mice speak Launcher's "4k" protocol on this collection: the * Nordic DMS v1 framing the Orbital driver also uses (64-byte report 0, 0xA1 From b2a71e6dd65f9dc065f88a89772729745c704e96 Mon Sep 17 00:00:00 2001 From: ydw1904 Date: Wed, 30 Sep 2026 18:12:10 +0800 Subject: [PATCH 2/3] fix(keychron): refresh the 1k battery on every status read The "1k" battery sits in the 0x06 identity answer, which the driver read once per connection, so the percentage never moved after connecting. Launcher re-reads 0x06 with every status read; so does this now. Co-Authored-By: Claude Opus 5.5 --- src/drivers/keychron/mouse-1k-hid.test.ts | 11 +++++++++++ src/drivers/keychron/mouse-1k-hid.ts | 11 +++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/drivers/keychron/mouse-1k-hid.test.ts b/src/drivers/keychron/mouse-1k-hid.test.ts index 4a88c7b..6b20aac 100644 --- a/src/drivers/keychron/mouse-1k-hid.test.ts +++ b/src/drivers/keychron/mouse-1k-hid.test.ts @@ -179,6 +179,17 @@ test("reads identity, the 0x07 status, buttons and lighting", async () => { assert.equal(lastSent(fake, 0x62, 0x52)!.length, 64); }); +test("the battery is read again on every status read", async () => { + const fake = new FakeKeychron1kMouse(); + const m3 = client(fake); + assert.equal((await m3.readStatus()).batteryPercent, 80); + fake.battery = 79; + fake.power = 0; + const status = await m3.readStatus(); + assert.equal(status.batteryPercent, 79); + assert.equal(status.batteryState, "Discharging"); +}); + test("DPI writes reuse the 0x40 layout on feature report 0x51", async () => { const fake = new FakeKeychron1kMouse(); const m3 = client(fake); diff --git a/src/drivers/keychron/mouse-1k-hid.ts b/src/drivers/keychron/mouse-1k-hid.ts index 9b63220..327d710 100644 --- a/src/drivers/keychron/mouse-1k-hid.ts +++ b/src/drivers/keychron/mouse-1k-hid.ts @@ -141,6 +141,7 @@ export class Keychron1kHidClient { } catch { return this.unreachableStatus(identity); } + const power = await this.readPower().catch(() => identity); const buttons = await this.readButtons().catch(() => null); const light = model?.light ? await this.readLight().catch(() => null) : null; const lod = keychronLiftOff(this.lodChoices(), settings.lod); @@ -168,8 +169,8 @@ export class Keychron1kHidClient { stepDpi: DPI_STEP, }, }, - batteryPercent: identity.batteryPercent <= 100 ? identity.batteryPercent : null, - batteryState: identity.powerState === 1 ? "Charging" : identity.powerState === 2 ? "Full" : "Discharging", + batteryPercent: power.batteryPercent <= 100 ? power.batteryPercent : null, + batteryState: power.powerState === 1 ? "Charging" : power.powerState === 2 ? "Full" : "Discharging", dpi: settings.dpiStages[activeStage] ?? settings.dpiStages[0] ?? 800, dpiStages: settings.dpiStages.slice(0, settings.stageCount), activeDpiStage: activeStage, @@ -395,6 +396,12 @@ export class Keychron1kHidClient { return this.identity; } + /** 0x06 again: the battery sits in the identity answer, which is otherwise read once. */ + private async readPower(): Promise> { + const bytes = await this.request(REPORT_ID, [CMD.identity], (answer) => answer[0] === CMD.identity); + return { batteryPercent: bytes[10] ?? 0xff, powerState: (bytes[11] ?? 0) & 0x03 }; + } + /** By USB product ID, then the identity's, then the connected mouse in a receiver's 0x03 list. */ private async readModel(identity: Identity): Promise { if (this.model !== undefined) return this.model; From b7e38f4433658b4363b4d6721d4b530eb84093ba Mon Sep 17 00:00:00 2001 From: snekxs Date: Wed, 30 Sep 2026 16:05:49 -0600 Subject: [PATCH 3/3] fix(keychron): stop the 8k driver shadowing the 8K Nordic G3 Air --- src/drivers/keychron/mouse-8k-hid.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/drivers/keychron/mouse-8k-hid.ts b/src/drivers/keychron/mouse-8k-hid.ts index b25f9ac..3c9cf08 100644 --- a/src/drivers/keychron/mouse-8k-hid.ts +++ b/src/drivers/keychron/mouse-8k-hid.ts @@ -1,5 +1,6 @@ import type { MouseLighting, MouseStatus } from "../mouse-types.ts"; import { + KEYCHRON_8K_NORDIC_PRODUCT_IDS, KEYCHRON_M6_COMMAND_REPORT_ID as COMMAND_REPORT_ID, KEYCHRON_M6_SETTINGS_REPORT_ID as SETTINGS_REPORT_ID, KEYCHRON_M6_STATUS_COMMAND as STATUS_COMMAND, @@ -197,6 +198,11 @@ export class Keychron8kHidClient { /** Launcher treats any Keychron device with this collection as an "8k" mouse, so this does too. */ static isSupported(device: HIDDevice): boolean { + // The G3 Air and its Ultra-Link receivers speak the 8K Nordic protocol + // instead, and their own driver claims them by product ID. A G3 Air can + // still expose a 0xffc1 collection, so exclude those IDs here or the + // collection match would shadow the Nordic driver. + if (KEYCHRON_8K_NORDIC_PRODUCT_IDS.includes(device.productId)) return false; return device.vendorId === KEYCHRON_VENDOR_ID && device.collections.some((collection) => collection.usagePage === USAGE_PAGE