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..6b20aac --- /dev/null +++ b/src/drivers/keychron/mouse-1k-hid.test.ts @@ -0,0 +1,290 @@ +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("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); + 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..327d710 --- /dev/null +++ b/src/drivers/keychron/mouse-1k-hid.ts @@ -0,0 +1,476 @@ +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 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); + 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: 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, + 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; + } + + /** 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; + 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..3c9cf08 --- /dev/null +++ b/src/drivers/keychron/mouse-8k-hid.ts @@ -0,0 +1,903 @@ +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, + 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 { + // 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 + && 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 e6969b5..02141d6 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -18,8 +18,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; 0x0e is Rapoo's 0xBA configuration channel. const USAGES = [0, 1, 0x0212, 2, 4, 0x0e, 0x10, 0x61, 0xc7]; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index ada92e7..74d92d4 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -8,9 +8,10 @@ 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 { Keychron8kHidClient } from "./keychron/mouse-8k-hid.ts"; import { Keychron8kNordicHidClient } from "./keychron/mouse-8k-nordic-hid.ts"; -import { KeychronM6HidClient } from "./keychron/m6-hid.ts"; import { KeychronNapeHidClient } from "./keychron/nape-hid.ts"; import { LamzuAtlantisHidClient } from "./lamzu-atlantis/hid.ts"; import { LamzuHidClient } from "./lamzu/hid.ts"; @@ -71,7 +72,7 @@ import { BytechHidClient } from "./bytech/hid.ts"; import { RapooHidClient } from "./rapoo/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 | Keychron8kNordicHidClient | 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 | RapooHidClient; +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 | Keychron8kNordicHidClient | 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 | RapooHidClient; export interface DeviceDriver { brand: string; @@ -119,7 +120,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) => Keychron8kNordicHidClient.isSupported(device), create: (device) => new Keychron8kNordicHidClient(device), score: () => 7 }, { brand: "Keychron", supports: (device) => KeychronNapeHidClient.isSupported(device), create: (device) => new KeychronNapeHidClient(device), score: () => 6 }, diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index a631804..3969115 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -325,9 +325,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, @@ -786,7 +788,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 2eb7794..7e86a05 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