diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index c79ab753..ed272531 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -19,9 +19,16 @@ import { join } from "node:path"; // 2026-10-29: Razer, Logitech and SteelSeries keyboard HID clients and panels // add three real device protocols. Measured: 3,558,003 bytes across all // chunks, with the main chunk at 1,149,146 bytes (below its existing cap). +// 2026-10-30: Keychron Hall-effect panel (key map, response curve graph) adds +// styles/areas/keychron.css, about 3 kB. Measured: 164,771 bytes of CSS before the +// bump; the lazy layout tables add a 251 kB chunk of JS (main grew 6.7 kB). +// 2026-10-30: Arbiter Studio controls and Keychron Hall-effect controls together +// add two keyboard workspaces, layouts, localized strings and device artwork. +// Measured after merging both: 168,860 bytes CSS, 3,652,136 bytes JS; +// main is 1,288,217 bytes, below its existing cap. const BUDGET_BYTES: Record = { - ".css": 165_000, - ".js": 3_600_000, + ".css": 170_000, + ".js": 3_700_000, }; // main-*.js measured 1,511,619 bytes right after the split (posthog ~377 kB diff --git a/public/devices/keychron-keyboard.svg b/public/devices/keychron-keyboard.svg new file mode 100644 index 00000000..4d149bea --- /dev/null +++ b/public/devices/keychron-keyboard.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/device/controller.ts b/src/device/controller.ts index 6ba50b22..ac1255f5 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -3259,8 +3259,9 @@ export function clearKeyboardSelection(): void { export async function applyKeyboardCommand(command: KeyboardCommand): Promise { const client = activeKeyboard; if (!client || keyboardBusy) return; + const telemetry = command.kind === "keychronKeyTravel"; keyboardBusy = true; - keyboardStatusText = st("kb.applying"); + if (!telemetry) keyboardStatusText = st("kb.applying"); emit(); try { const update = await client.command(command); @@ -3282,13 +3283,15 @@ export async function applyKeyboardCommand(command: KeyboardCommand): Promise ({ + vendorId: KEYCHRON_VENDOR_ID, + productId: Number(productId), + usagePage: KEYCHRON_CONFIG_USAGE_PAGE, + usage: KEYCHRON_CONFIG_USAGE, +})); + +/** Keychron filters are offered only behind the experimental flag, read when the app loads. */ +export const HID_FILTERS: HIDDeviceFilter[] = [ + ...SUPPORTED_HID_FILTERS, ...KEYBOARD_HID_FILTERS, ...(keychronHallEffectEnabled() ? KEYCHRON_HID_FILTERS : []), + BASILISK_V3_PRO_BLUETOOTH, +]; diff --git a/src/device/keyboard-client.ts b/src/device/keyboard-client.ts index df13b7d7..0cea05b6 100644 --- a/src/device/keyboard-client.ts +++ b/src/device/keyboard-client.ts @@ -3,7 +3,7 @@ import type { KeyboardIdentityView, KeyboardPollingView, KeyboardTriggerKey, Key export interface KeyboardClient { readonly device: HIDDevice; - readonly family: "wuque" | "kochi" | "wooting" | "razer" | "logitech" | "steelseries" | "arbiter"; + readonly family: "wuque" | "kochi" | "wooting" | "razer" | "logitech" | "steelseries" | "keychron" | "arbiter"; readonly brand: string; /** Set once the session needs a reconnect before it can be used again. */ readonly faulted?: boolean; @@ -12,6 +12,8 @@ export interface KeyboardClient { close(): Promise; readIdentity(): Promise; command(command: KeyboardCommand): Promise>; + /** State to merge after `command` threw, when the failure also changed what the board holds. */ + recoveredWorkspace?(): Partial | null; readPolling?(): Promise; setPollingRate?(hz: number): Promise; readTrigger?(): Promise<{ matrix: readonly (readonly number[])[]; keys: KeyboardTriggerKey[] }>; diff --git a/src/device/keyboard-workspace.ts b/src/device/keyboard-workspace.ts index c87ed890..588832d1 100644 --- a/src/device/keyboard-workspace.ts +++ b/src/device/keyboard-workspace.ts @@ -7,6 +7,7 @@ import type { WootingAkcPayloadWrite, WootingRapidTriggerTarget, WootingRgbColor import type { RazerKeyboardCommand } from './razer-keyboard.ts'; import type { LogitechKeyboardCommand } from './logitech-keyboard.ts'; import type { SteelSeriesKeyboardCommand } from './steelseries-keyboard.ts'; +import type { KeychronKeyboardCommand, KeychronWorkspace } from './keychron-keyboard.ts'; export interface WootingRgbView { core: WootingRgbCore | null; @@ -21,6 +22,8 @@ export interface KeyboardWorkspace { razerGameModeLed?: boolean | null; /** Typed RGB core and color pages; raw `wooting.rgb` stays for diagnostics. */ wootingRgb?: WootingRgbView | null; + /** Keychron Hall-effect state; set only while the experimental flag is on. */ + keychron?: KeychronWorkspace | null; /** HB68 mode; independent of the Wuque global-settings structure. */ systemMode?: "win" | "mac"; global: WuqueGlobalState | null; @@ -37,7 +40,7 @@ export interface KeyboardWorkspace { } export const emptyKeyboardWorkspace = (): KeyboardWorkspace => ({ global: null, lighting: null, custom: [], shell: null, layer: null, styles: [], defaults: [], macro: null, higher: null, adc: null }); export type KeyboardCommand = - | RazerKeyboardCommand | LogitechKeyboardCommand | SteelSeriesKeyboardCommand + | RazerKeyboardCommand | LogitechKeyboardCommand | SteelSeriesKeyboardCommand | KeychronKeyboardCommand | { kind: 'refresh' } | { kind: 'wootingKeyActuation'; keys: number | readonly number[]; actuationMm: number | null } | { kind: 'wootingKeyRapidTrigger'; keys: number | readonly number[]; target: WootingRapidTriggerTarget | null } diff --git a/src/device/keychron-curve.test.ts b/src/device/keychron-curve.test.ts new file mode 100644 index 00000000..9acfbe97 --- /dev/null +++ b/src/device/keychron-curve.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { KeychronState } from "@openmouse/keyboard-protocol/drivers"; +import type { KeychronCurve } from "@openmouse/keyboard-protocol/keychron"; +import { CURVE_MAX_Y, clampCurve, curveInRange, curveScale, linearCurve, setCurvePoint } from "./keychron-curve.ts"; + +const base: KeychronState = { + productId: 0x040c, heVersion: 4, features: { switchAxis: true, resetSingleKey: false, dkr: false, turbo: false, gamepadDisable: false, supportMechanical: true }, + unit: 0.1, profileSize: 0, dksCount: null, snapTapCount: null, rows: 6, cols: 19, travel: { min: 0.2, max: 3.3, deadzone: 0.2 }, +}; + +test("x runs to floor(10 * (max + dead zone)) and y to 127", () => { + assert.deepEqual(curveScale(base, null), { maxX: 35, fromBoard: true, unverifiedStep: false }); + assert.equal(curveScale({ ...base, travel: { min: 0.1, max: 3.35 } }, null).maxX, 33); + assert.equal(curveScale({ ...base, travel: null }, 3.3).maxX, 33); + assert.deepEqual(curveScale({ ...base, travel: null }, null), { maxX: 255, fromBoard: false, unverifiedStep: false }); + assert.equal(curveScale({ ...base, heVersion: 5 }, null).unverifiedStep, true); +}); + +test("the linear preset is Launcher's: thirds along, ending at (max, 127)", () => { + assert.deepEqual(linearCurve(35), [[0, 0], [12, 42], [23, 85], [35, 127]]); + assert.ok(curveInRange(linearCurve(35), 35)); + assert.ok(curveInRange(linearCurve(255), 255)); +}); + +test("a point stays between its neighbours and inside the ranges", () => { + const curve = linearCurve(35); + assert.deepEqual(setCurvePoint(curve, 1, 99, 500, 35)[1], [23, 127]); + assert.deepEqual(setCurvePoint(curve, 2, -4, -9, 35)[2], [12, 0]); + assert.deepEqual(setCurvePoint(curve, 0, 8, 3, 35)[0], [8, 3]); // the first point may move right, up to the second point + assert.deepEqual(setCurvePoint(curve, 0, 40, 3, 35)[0], [12, 3]); + assert.deepEqual(setCurvePoint(curve, 3, 200, 10, 35)[3], [35, 10]); + assert.deepEqual(setCurvePoint(curve, 1, Number.NaN, Number.NaN, 35)[1], [0, 0]); +}); + +test("no sequence of edits can leave a byte out of range", () => { + let seed = 7; + const random = (): number => { seed = (seed * 48271) % 2147483647; return seed / 2147483647; }; + for (const maxX of [1, 33, 35, 255]) { + let curve: KeychronCurve = linearCurve(maxX); + for (let step = 0; step < 500; step++) { + curve = setCurvePoint(curve, Math.floor(random() * 4), (random() - 0.3) * 600, (random() - 0.3) * 400, maxX); + assert.ok(curveInRange(curve, maxX), JSON.stringify(curve)); + } + } +}); + +test("clampCurve repairs a curve outside the ranges", () => { + const repaired = clampCurve([[40, 200], [30, -3], [220, 50], [10, 127]], 35); + assert.deepEqual(repaired, [[35, CURVE_MAX_Y], [35, 0], [35, 50], [35, 127]]); + assert.ok(curveInRange(repaired, 35)); + assert.equal(curveInRange([[0, 0], [36, 0], [36, 0], [36, 0]], 35), false); +}); diff --git a/src/device/keychron-curve.ts b/src/device/keychron-curve.ts new file mode 100644 index 00000000..b6d535d0 --- /dev/null +++ b/src/device/keychron-curve.ts @@ -0,0 +1,67 @@ +import { KEYCHRON_ADVANCE_MODE, type KeychronCurve, type KeychronProfileBuffer } from "@openmouse/keyboard-protocol/keychron"; +import type { KeychronState } from "@openmouse/keyboard-protocol/drivers"; + +/** + * Ranges for the response curve, as Keychron Launcher's curve editor uses them + * (read from its code, not verified on hardware): x is key travel in 0.1 mm + * steps up to the board's maximum travel plus dead zone, y is output strength + * 0 to 127, and the four points keep x non-decreasing left to right. + */ +export const CURVE_MAX_Y = 127; +/** Millimetres per x step, as Launcher derives it (mm * 10). */ +export const CURVE_X_STEP_MM = 0.1; + +export interface CurveScale { + /** Largest x byte. */ + maxX: number; + /** False when neither the board definition nor the axis type gave a travel range, so the byte limit is used. */ + fromBoard: boolean; + /** Command v5 uses another travel step; Launcher still scales x by 10, so that scale is unverified there. */ + unverifiedStep: boolean; +} + +export function curveScale(state: KeychronState, axisMaxMm: number | null): CurveScale { + const travel = state.travel; + const mm = travel?.max !== undefined ? travel.max + (travel.deadzone ?? 0) : axisMaxMm; + const maxX = mm === null || mm <= 0 ? 255 : Math.min(255, Math.floor(mm / CURVE_X_STEP_MM + 1e-6)); + return { maxX, fromBoard: mm !== null && mm > 0, unverifiedStep: state.heVersion >= 5 }; +} + +const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, Math.round(Number.isFinite(value) ? value : low))); + +/** Move one point, keeping every byte in range and x between its neighbours. */ +export function setCurvePoint(curve: KeychronCurve, index: number, x: number, y: number, maxX: number): KeychronCurve { + const low = index === 0 ? 0 : curve[index - 1]![0]; + const high = index === curve.length - 1 ? maxX : curve[index + 1]![0]; + return curve.map((point, i) => (i === index ? [clamp(x, low, Math.max(low, high)), clamp(y, 0, CURVE_MAX_Y)] : point)) as unknown as KeychronCurve; +} + +/** Bring any curve into range, left to right. */ +export function clampCurve(curve: KeychronCurve, maxX: number): KeychronCurve { + let previous = 0; + return curve.map(([x, y]) => { + previous = clamp(x, previous, maxX); + return [previous, clamp(y, 0, CURVE_MAX_Y)]; + }) as unknown as KeychronCurve; +} + +export function curveInRange(curve: KeychronCurve, maxX: number): boolean { + let previous = 0; + return curve.every(([x, y]) => { + const ok = Number.isInteger(x) && Number.isInteger(y) && x >= previous && x <= maxX && y >= 0 && y <= CURVE_MAX_Y; + previous = x; + return ok; + }); +} + +/** Launcher's linear preset: from (0, 0) to (max, 127) with the middle points a third and two thirds along. */ +export function linearCurve(maxX: number): KeychronCurve { + const at = (fraction: number): [number, number] => [Math.round(maxX * fraction), Math.round(CURVE_MAX_Y * fraction)]; + return [[0, 0], at(1 / 3), at(2 / 3), [maxX, CURVE_MAX_Y]]; +} + +/** Keys set to the analog advanced mode; the curve shapes their output. */ +export function analogKeyCount(profile: KeychronProfileBuffer | null): number { + if (!profile) return 0; + return profile.keys.reduce((sum, row) => sum + row.filter((key) => key.advanceMode === KEYCHRON_ADVANCE_MODE.analog).length, 0); +} diff --git a/src/device/keychron-flag.ts b/src/device/keychron-flag.ts new file mode 100644 index 00000000..d229fb36 --- /dev/null +++ b/src/device/keychron-flag.ts @@ -0,0 +1,16 @@ +/** + * Keychron Hall-effect support is experimental: the protocol was derived from + * static analysis of Keychron Launcher and has not run on a physical board. + * It stays off until the flag is set, e.g. in the browser console: + * + * localStorage.setItem("openmouse.keychronHallEffect", "1"); location.reload(); + */ +export const KEYCHRON_HALL_EFFECT_FLAG = "openmouse.keychronHallEffect"; + +export function keychronHallEffectEnabled(): boolean { + try { + return globalThis.localStorage?.getItem(KEYCHRON_HALL_EFFECT_FLAG) === "1"; + } catch { + return false; + } +} diff --git a/src/device/keychron-keyboard.test.ts b/src/device/keychron-keyboard.test.ts new file mode 100644 index 00000000..1f3edd1b --- /dev/null +++ b/src/device/keychron-keyboard.test.ts @@ -0,0 +1,283 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { KEYCHRON_HE_COMMAND, KEYCHRON_HE_SUBCOMMAND as SUB } from "@openmouse/keyboard-protocol/keychron"; +import { KeychronKeyboardClient } from "./keychron-keyboard.ts"; +import { KEYCHRON_HID_FILTERS } from "./hid-filters.ts"; +import { createKeyboardClient } from "./device-clients.ts"; +import { KEYCHRON_HALL_EFFECT_FLAG } from "./keychron-flag.ts"; + +/** Keychron J3 HE ANSI: 6 x 19 keys, travel 0.2 to 3.3 mm, dead zone 0.2 mm (range 0.2 to 3.5 mm). */ +const PRODUCT_ID = 0x040c; +const ROWS = 6; +const COLS = 19; +const SIZE = 4 * (ROWS * COLS + 1) + 19 * 20 + 3 * 20 + 32; + +/** A v4 board with three profiles; the active one is slot 1. Calibration state and curve are mutable. */ +class FakeBoard { + vendorId = 0x3434; + productId = PRODUCT_ID; + productName = "Keychron J3 HE"; + opened = false; + collections = [{ usagePage: 0xff60, usage: 0x61, inputReports: [], outputReports: [], featureReports: [], children: [] }]; + listeners = new Set<(event: HIDInputReportEvent) => void>(); + sent: Uint8Array[] = []; + buffer = new Uint8Array(SIZE); + curve = [0, 0, 12, 42, 24, 85, 35, 127]; + /** Report this curve instead of the one written, to model a board that ignores the write. */ + ignoreCurveWrite = false; + calibrationState = 0; + viaSent: Uint8Array[] = []; + /** Leave VIA reads unanswered, as a board without the dynamic keymap would. */ + noKeymap = false; + noCalibrationRead = false; + get hid(): HIDDevice { return this as unknown as HIDDevice; } + async open() { this.opened = true; } + async close() { this.opened = false; } + addEventListener(_type: string, listener: (event: HIDInputReportEvent) => void) { this.listeners.add(listener); } + removeEventListener(_type: string, listener: (event: HIDInputReportEvent) => void) { this.listeners.delete(listener); } + commands(subcommand: number): Uint8Array[] { return this.sent.filter((frame) => frame[1] === subcommand); } + /** Standard VIA reads: protocol version, layer count, and a keymap whose code is its own flat index + 4. */ + private answerVia(request: Uint8Array) { + this.viaSent.push(request.slice()); + if (this.noKeymap) return; + const reply = new Uint8Array(32); + reply[0] = request[0]!; + if (request[0] === 0x01) reply[2] = 12; + else if (request[0] === 0x11) reply[1] = 2; + else { + const offset = (request[1]! << 8) | request[2]!; + reply.set(request.slice(1, 4), 1); + for (let i = 0; i < request[3]!; i += 2) { const code = offset / 2 + i / 2 + 4; reply[4 + i] = code >> 8; reply[5 + i] = code & 0xff; } + } + const event = { device: this.hid, reportId: 0, data: new DataView(reply.buffer, 0, 32) } as HIDInputReportEvent; + this.listeners.forEach((listener) => listener(event)); + } + async sendReport(_reportId: number, data: BufferSource) { + const request = data as Uint8Array; + if ([0x01, 0x11, 0x12].includes(request[0]!)) return this.answerVia(request); + assert.equal(request[0], KEYCHRON_HE_COMMAND); + this.sent.push(request.slice()); + if (request[1] === SUB.calibrate && request[2] === 4) return; // stop is never answered + if (request[1] === SUB.getCalibrateState && this.noCalibrationRead) return; + const reply = new Uint8Array(32); + reply.set(request.slice(0, 2)); + switch (request[1]) { + case SUB.getVersion: reply.set([4, 0, 0b100001], 2); break; + case SUB.getProfilesInfo: reply.set([1, 3, SIZE & 0xff, SIZE >> 8, 20, 20], 2); break; + case SUB.getProfileBuffer: { + reply.set(request.slice(2, 6), 2); + const offset = request[3]! | (request[4]! << 8); + const nameStart = SIZE - 32; + const name = new Uint8Array(32); + name.set([0, 0x50, 0, 0x30 + request[2]!]); + for (let i = 0; i < request[5]!; i++) reply[6 + i] = offset + i >= nameStart ? name[offset + i - nameStart]! : this.buffer[offset + i]!; + break; + } + case SUB.setTravel: case SUB.saveProfileBuffer: case SUB.clearProfileBuffer: case SUB.selectProfile: reply[2] = 0; break; + case SUB.getCurve: reply.set(this.curve, 2); break; + case SUB.setCurve: if (!this.ignoreCurveWrite) this.curve = [...request.slice(2, 10)]; break; + case SUB.getRealtimeTravel: reply.set([request[2]!, request[3]!, 33], 3); break; + case SUB.getAxisType: reply.set([2, 0x21, 0x00], 2); break; + case SUB.calibrate: this.calibrationState = 2; break; + case SUB.getCalibrateState: reply[3] = this.calibrationState; reply[4] = 0b101; break; + default: break; + } + const event = { device: this.hid, reportId: 0, data: new DataView(reply.buffer, 0, 32) } as HIDInputReportEvent; + this.listeners.forEach((listener) => listener(event)); + } +} + +async function ready(board = new FakeBoard()) { + const client = new KeychronKeyboardClient(board.hid); + const workspace = await client.command({ kind: "refresh" }); + return { board, client, workspace }; +} + +test("refresh reads status, profile, curve and axis type", async () => { + const { workspace } = await ready(); + const keychron = workspace.keychron!; + assert.equal(keychron.status.activeProfile, 1); + assert.deepEqual(keychron.status.profileNames, ["P0", "P1", "P2"]); + assert.equal(keychron.status.keychron?.unit, 0.1); + assert.equal(keychron.profile?.keys.length, ROWS); + assert.equal(keychron.profile?.keys[0]?.length, COLS); + assert.deepEqual(keychron.curve, [[0, 0], [12, 42], [24, 85], [35, 127]]); + assert.equal(keychron.axis?.type, 2); + assert.equal(keychron.unsaved, null); + assert.equal(keychron.layout.physical, true); + assert.equal(keychron.layout.keys.length, 100); + assert.equal(keychron.keymap?.length, ROWS); + assert.equal(keychron.keymap?.[0]?.[0], 4); + assert.equal(keychron.keymap?.[1]?.[0], 4 + COLS); + assert.equal(keychron.calibrationPhase, "idle"); +}); + +test("a board that does not answer the keymap read still returns everything else and keeps working", async () => { + const board = new FakeBoard(); + board.noKeymap = true; + const client = new KeychronKeyboardClient(board.hid); + const keychron = (await client.command({ kind: "refresh" })).keychron!; + assert.equal(keychron.keymap, null); + assert.ok(keychron.profile && keychron.curve && keychron.axis); + assert.ok(keychron.layout.physical); + assert.equal(board.viaSent.length, 1); + const travel = await client.command({ kind: "keychronKeyTravel", row: 1, col: 1 }); + assert.equal(travel.keychron?.keyTravel?.steps, 33); +}); + +test("setTravel stages the write and does not save", async () => { + const { board, client } = await ready(); + const result = await client.command({ kind: "keychronSetTravel", rapidTrigger: true, pressMm: 1.5, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.4 }); + const writes = board.commands(SUB.setTravel); + assert.equal(writes.length, 1); + // slot, work mode (quick trigger), press, rapid-trigger press and release, key mode (all keys) + assert.deepEqual([...writes[0]!.slice(2, 8)], [1, 2, 15, 3, 4, 1]); + assert.equal(board.commands(SUB.saveProfileBuffer).length, 0); + assert.deepEqual(result.keychron?.unsaved, { press: 15, sensitivePress: 3, sensitiveRelease: 4 }); +}); + +test("plain actuation uses the static work mode and sends no rapid-trigger values", async () => { + const { board, client } = await ready(); + await client.command({ kind: "keychronSetTravel", rapidTrigger: false, pressMm: 1.5, sensitivePressMm: 0, sensitiveReleaseMm: 0 }); + assert.deepEqual([...board.commands(SUB.setTravel)[0]!.slice(2, 8)], [1, 1, 15, 0, 0, 1]); +}); + +test("refresh preserves a staged travel change and its save guard", async () => { + const { client } = await ready(); + await client.command({ kind: "keychronSetTravel", rapidTrigger: false, pressMm: 1.5, sensitivePressMm: 0, sensitiveReleaseMm: 0 }); + const refreshed = await client.command({ kind: "refresh" }); + assert.equal(refreshed.keychron?.unsaved?.press, 15); + await assert.rejects(() => client.command({ kind: "keychronSelectProfile", slot: 2 }), /unsaved/); + const saved = await client.command({ kind: "keychronSave" }); + assert.equal(saved.keychron?.unsaved, null); +}); + +test("a selected key is written through the per-key bitmap", async () => { + const { board, client } = await ready(); + await client.command({ kind: "keychronSetTravel", rapidTrigger: false, keys: [{ row: 1, col: 9 }], pressMm: 1, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.3 }); + const write = board.commands(SUB.setTravel)[0]!; + assert.equal(write[7], 0); // per-key + assert.equal(write[8 + 3 * 1 + 1], 0b10); // byte 3 * row + floor(col / 8), bit col % 8 + assert.equal(write.slice(8, 32).reduce((sum, byte) => sum + byte, 0), 0b10); + await assert.rejects(() => client.command({ kind: "keychronSetTravel", rapidTrigger: false, keys: [], pressMm: 1, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.3 }), RangeError); +}); + +test("renaming writes the name, and is blocked while a travel change is unsaved", async () => { + const { board, client } = await ready(); + const result = await client.command({ kind: "keychronRename", slot: 2, name: " Work " }); + const [write] = board.commands(SUB.setProfileName); + assert.equal(write![2], 2); + assert.equal(write![3], 8); // "Work" in UTF-16BE + assert.equal(board.commands(SUB.saveProfileBuffer).length, 1); + assert.ok(result.keychron); + await assert.rejects(() => client.command({ kind: "keychronRename", slot: 2, name: "" }), RangeError); + await client.command({ kind: "keychronSetTravel", rapidTrigger: false, pressMm: 1, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.3 }); + await assert.rejects(() => client.command({ kind: "keychronRename", slot: 2, name: "Other" }), /unsaved/); + assert.equal(board.commands(SUB.setProfileName).length, 1); +}); + +test("save needs a staged change, persists it, then re-reads the profile", async () => { + const { board, client } = await ready(); + await assert.rejects(() => client.command({ kind: "keychronSave" }), /no travel change/); + await client.command({ kind: "keychronSetTravel", rapidTrigger: true, pressMm: 1.5, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.3 }); + const reads = board.commands(SUB.getProfileBuffer).length; + const result = await client.command({ kind: "keychronSave" }); + assert.equal(board.commands(SUB.saveProfileBuffer).length, 1); + assert.ok(board.commands(SUB.getProfileBuffer).length > reads); + assert.equal(result.keychron?.unsaved, null); +}); + +test("travel outside the board's declared range is rejected before anything is sent", async () => { + const { board, client } = await ready(); + for (const pressMm of [0.1, 3.6, Number.NaN]) { + await assert.rejects(() => client.command({ kind: "keychronSetTravel", rapidTrigger: true, pressMm, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.3 }), RangeError); + } + assert.equal(board.commands(SUB.setTravel).length, 0); +}); + +test("a staged change blocks switching profile", async () => { + const { board, client } = await ready(); + await client.command({ kind: "keychronSetTravel", rapidTrigger: true, pressMm: 1, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.3 }); + await assert.rejects(() => client.command({ kind: "keychronSelectProfile", slot: 2 }), /unsaved/); + assert.equal(board.commands(SUB.selectProfile).length, 0); +}); + +test("selecting a profile writes the slot and re-reads the board", async () => { + const { board, client } = await ready(); + await client.command({ kind: "keychronSelectProfile", slot: 2 }); + assert.deepEqual([...board.commands(SUB.selectProfile)[0]!.slice(2, 3)], [2]); + await assert.rejects(() => client.command({ kind: "keychronSelectProfile", slot: 3 }), RangeError); +}); + +test("clearing a profile needs the confirmation flag", async () => { + const { board, client } = await ready(); + await assert.rejects(() => client.command({ kind: "keychronClear" } as never), /confirmation/); + assert.equal(board.commands(SUB.clearProfileBuffer).length, 0); + await client.command({ kind: "keychronClear", confirmed: true }); + assert.deepEqual([...board.commands(SUB.clearProfileBuffer)[0]!.slice(2, 3)], [1]); +}); + +test("starting calibration needs the confirmation flag; stop is sent without a reply", async () => { + const { board, client } = await ready(); + await assert.rejects(() => client.command({ kind: "keychronCalibration", action: "start" } as never), /confirmation/); + assert.equal(board.commands(SUB.calibrate).length, 0); + const started = await client.command({ kind: "keychronCalibration", action: "start", confirmed: true }); + assert.equal(started.keychron?.calibration?.state, 2); + assert.equal(started.keychron?.calibration?.keys.filter(Boolean).length, 2); + const stopped = await client.command({ kind: "keychronCalibration", action: "stop" }); + assert.deepEqual(board.commands(SUB.calibrate).map((frame) => frame[2]), [2, 4]); + assert.equal(stopped.keychron?.calibration, null); +}); + +test("a failed calibration read still reports that calibration started", async () => { + const { board, client } = await ready(); + board.noCalibrationRead = true; + await assert.rejects(() => client.command({ kind: "keychronCalibration", action: "start", confirmed: true })); + assert.equal(client.recoveredWorkspace()?.keychron?.calibrationPhase, "started"); +}); + +test("a curve write is confirmed by reading the board back", async () => { + const { board, client } = await ready(); + const curve = [[1, 2], [3, 4], [5, 6], [7, 8]] as const; + const result = await client.command({ kind: "keychronWriteCurve", curve }); + assert.deepEqual(result.keychron?.curve, curve); + board.ignoreCurveWrite = true; + await assert.rejects(() => client.command({ kind: "keychronWriteCurve", curve: [[9, 9], [9, 9], [9, 9], [9, 9]] }), /different response curve/); + const writes = board.commands(SUB.setCurve).length; + for (const bad of [[[36, 0], [36, 0], [36, 0], [36, 0]], [[0, 128], [1, 0], [2, 0], [3, 0]], [[5, 0], [4, 0], [6, 0], [7, 0]]] as const) { + await assert.rejects(() => client.command({ kind: "keychronWriteCurve", curve: bad }), RangeError); + } + assert.equal(board.commands(SUB.setCurve).length, writes); +}); + +test("live key travel reads one key inside the matrix", async () => { + const { client } = await ready(); + const result = await client.command({ kind: "keychronKeyTravel", row: 2, col: 5 }); + assert.deepEqual(result.keychron?.keyTravel, { row: 2, col: 5, steps: 33 }); + await assert.rejects(() => client.command({ kind: "keychronKeyTravel", row: ROWS, col: 0 }), RangeError); +}); + +test("commands for other brands are refused", async () => { + const { client } = await ready(); + await assert.rejects(() => client.command({ kind: "system", value: 0 }), /different keyboard brand/); +}); + +test("Keychron filters cover every catalogued board's raw-HID collection", () => { + assert.ok(KEYCHRON_HID_FILTERS.length > 100); + assert.ok(KEYCHRON_HID_FILTERS.every((filter) => filter.vendorId === 0x3434 && filter.usagePage === 0xff60 && filter.usage === 0x61)); + assert.ok(KEYCHRON_HID_FILTERS.some((filter) => filter.productId === PRODUCT_ID)); +}); + +test("the keyboard factory ignores Keychron boards until the flag is set", () => { + const store = new Map(); + const storage = { getItem: (key: string) => store.get(key) ?? null }; + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); + try { + const board = new FakeBoard(); + assert.equal(createKeyboardClient(board.hid), null); + store.set(KEYCHRON_HALL_EFFECT_FLAG, "1"); + assert.ok(createKeyboardClient(board.hid) instanceof KeychronKeyboardClient); + } finally { + Reflect.deleteProperty(globalThis, "localStorage"); + } +}); diff --git a/src/device/keychron-keyboard.ts b/src/device/keychron-keyboard.ts new file mode 100644 index 00000000..69cf3c07 --- /dev/null +++ b/src/device/keychron-keyboard.ts @@ -0,0 +1,249 @@ +import { KeychronHidClient } from "@openmouse/keyboard-protocol/drivers/keychron/hid"; +import type { KeyboardStatus, KeychronState } from "@openmouse/keyboard-protocol/drivers"; +import { KEYCHRON_WORK_MODE, keychronKeyTarget } from "@openmouse/keyboard-protocol/keychron"; +import type { + KeychronAxisType, KeychronCalibrationState, KeychronCurve, KeychronKeyPosition, KeychronProfileBuffer, +} from "@openmouse/keyboard-protocol/keychron"; +import type { KeyboardClient } from "./keyboard-client.ts"; +import type { KeyboardCommand, KeyboardWorkspace } from "./keyboard-workspace.ts"; +import type { KeyboardIdentityView } from "./types.ts"; +import { travelToSteps } from "./keychron-travel.ts"; +import { curveInRange, curveScale } from "./keychron-curve.ts"; +import { loadKeychronLayout, type KeychronLayout } from "./keychron-layout.ts"; +import { loadRemap, remapKey, selectRemapLayer, type KeychronRemapState } from "./keychron-remap.ts"; + +export interface KeychronWorkspace { + status: KeyboardStatus; + /** The active profile as the board last reported it. */ + profile: KeychronProfileBuffer | null; + curve: KeychronCurve | null; + axis: KeychronAxisType | null; + calibration: KeychronCalibrationState | null; + /** + * What this app last asked calibration to do. The board's own state byte has + * no documented meaning, so the panel words the state from this, not from it. + */ + calibrationPhase: "idle" | "started" | "stopped"; + /** Physical key boxes, or a plain grid of the matrix when the board's layout is unknown. */ + layout: KeychronLayout; + /** Layer 0 keycodes by [row][col], when the board's keymap could be read; they label the keys. */ + keymap: readonly (readonly number[])[] | null; + /** Key remapping: the selected layer's keymap, the keycode context and the last result. */ + remap: KeychronRemapState; + /** Last live reading of one key, in profile steps. */ + keyTravel: { row: number; col: number; steps: number } | null; + /** + * Values written with setTravel that have not been saved yet. The board is + * not read back after setTravel, so these are what was sent, not confirmed. + */ + unsaved: { press: number; sensitivePress: number; sensitiveRelease: number } | null; +} + +export type KeychronKeyboardCommand = + | { kind: "keychronSelectProfile"; slot: number } + | { kind: "keychronRename"; slot: number; name: string } + | { + kind: "keychronSetTravel"; + /** Rapid trigger sends Launcher's quick-trigger work mode; otherwise the static one. */ + rapidTrigger: boolean; + /** Omit to apply to every key; otherwise only these keys. */ + keys?: readonly KeychronKeyPosition[]; + pressMm: number; + sensitivePressMm: number; + sensitiveReleaseMm: number; + } + | { kind: "keychronSave" } + | { kind: "keychronClear"; confirmed: true } + | { kind: "keychronWriteCurve"; curve: KeychronCurve } + | { kind: "keychronKeyTravel"; row: number; col: number } + | { kind: "keychronSelectLayer"; layer: number } + | { kind: "keychronRemap"; layer: number; row: number; col: number; code: number; confirmed?: boolean; restore?: boolean } + | { kind: "keychronDismissRemap" } + | { kind: "keychronCalibration"; action: "start"; confirmed: true } + | { kind: "keychronCalibration"; action: "stop" | "read" }; + +const isKeychronCommand = (command: KeyboardCommand): command is KeychronKeyboardCommand => + command.kind.startsWith("keychron"); + +const sameCurve = (a: KeychronCurve, b: KeychronCurve): boolean => + a.every((point, index) => point[0] === b[index]![0] && point[1] === b[index]![1]); + +/** + * Panel-side wrapper over the Keychron Hall-effect driver. The protocol is + * derived from Keychron Launcher and has not been run on a physical board, so + * nothing here reports a write as hardware-verified. Travel is staged with + * `setTravel` and only persisted by an explicit save. + */ +export class KeychronKeyboardClient implements KeyboardClient { + readonly family = "keychron" as const; + readonly brand = "Keychron"; + get name(): string { return this.device.productName || "Keychron keyboard"; } + readonly device: HIDDevice; + private readonly client: KeychronHidClient; + private state: KeychronWorkspace | null = null; + private recovered: Partial | null = null; + + constructor(device: HIDDevice) { this.device = device; this.client = new KeychronHidClient(device); } + static isSupported(device: HIDDevice): boolean { return KeychronHidClient.isSupported(device); } + open(): Promise { return this.client.open(); } + close(): Promise { return this.client.close(); } + + async readIdentity(): Promise { + const status = await this.client.readStatus(); + return { + brand: status.brand, name: status.name, boardId: null, capabilities: null, + firmwareVersion: status.firmwareVersion, appVersion: null, pcbVersion: null, serial: status.serial, + }; + } + + private current(): KeychronWorkspace { + if (!this.state) throw new Error("Read the keyboard before changing it."); + return this.state; + } + + private publish(next: KeychronWorkspace): Partial { + this.state = next; + return { keychron: next }; + } + + private keychronState(): KeychronState { + const state = this.current().status.keychron; + if (!state) throw new Error("The keyboard did not report its Hall-effect layout."); + return state; + } + + /** State to show after a command failed: the controller asks once, right after the error. */ + recoveredWorkspace(): Partial | null { + const recovered = this.recovered; + this.recovered = null; + return recovered; + } + + /** Publish a new remap state; layer 0 also labels the keys on the actuation map. */ + private publishRemap(remap: KeychronRemapState): Partial { + const current = this.current(); + return this.publish({ ...current, remap, keymap: remap.layer === 0 && remap.keymap ? remap.keymap : current.keymap }); + } + + private async refresh(): Promise> { + const status = await this.client.readStatus(); + const profile = await this.client.readProfile(); + const features = status.keychron?.features; + // A decoded absence is optional. Transport failures must surface because the driver faults the session. + const optionalRead = async (read: () => Promise): Promise => { + try { return await read(); } + catch (error) { + if (error instanceof Error && /did not report (?:its response curve|its switch axis type)/.test(error.message)) return null; + throw error; + } + }; + const curve = await optionalRead(() => this.client.readCurve()); + const axis = features?.switchAxis ? await optionalRead(() => this.client.readAxisType()) : null; + const layout = await loadKeychronLayout(this.device.productId, status.keychron?.rows ?? 0, status.keychron?.cols ?? 0); + // A board that ignores VIA leaves remapping unavailable and the keymap null; + // keys are then labelled by row and column. + const remap = await loadRemap(this.client, 0, this.state?.remap ?? null); + return this.publish({ + status, profile, curve, axis, layout, keymap: remap.keymap, remap, calibration: this.state?.calibration ?? null, + calibrationPhase: this.state?.calibrationPhase ?? "idle", keyTravel: this.state?.keyTravel ?? null, unsaved: this.state?.unsaved ?? null, + }); + } + + /** A successful write followed by a failed read leaves the previous profile unsafe to edit. */ + private async refreshAfterWrite(): Promise> { + try { return await this.refresh(); } + catch (error) { + this.state = null; + this.recovered = { keychron: null }; + throw error; + } + } + + async command(command: KeyboardCommand): Promise> { + if (command.kind === "refresh") return this.refresh(); + if (!isKeychronCommand(command)) throw new Error("This command is only for a different keyboard brand."); + switch (command.kind) { + case "keychronSelectProfile": + if (this.current().unsaved) throw new Error("Save or discard the unsaved travel change before switching profile."); + await this.client.selectProfile(command.slot); + return this.refreshAfterWrite(); + case "keychronRename": + // renameProfile saves the active profile, which would also persist a staged travel change. + if (this.current().unsaved) throw new Error("Save or discard the unsaved travel change before renaming."); + await this.client.renameProfile(command.slot, command.name); + return this.refreshAfterWrite(); + case "keychronSetTravel": { + const steps = travelToSteps(this.keychronState(), command); + const target = keychronKeyTarget(command.keys); + await this.client.setTravel({ + workMode: command.rapidTrigger ? KEYCHRON_WORK_MODE.quickTrigger : KEYCHRON_WORK_MODE.static, + press: steps.press, + ...(command.rapidTrigger ? { sensitivePress: steps.sensitivePress, sensitiveRelease: steps.sensitiveRelease } : {}), + ...target, + }); + return this.publish({ ...this.current(), unsaved: { press: steps.press, sensitivePress: steps.sensitivePress, sensitiveRelease: steps.sensitiveRelease } }); + } + case "keychronSave": { + if (!this.current().unsaved) throw new Error("There is no travel change to save."); + await this.client.saveProfile(); + this.state = { ...this.current(), unsaved: null }; + return this.refreshAfterWrite(); + } + case "keychronClear": + if (command.confirmed !== true) throw new Error("Clearing a profile needs confirmation."); + await this.client.clearProfile(); + this.state = { ...this.current(), unsaved: null }; + return this.refreshAfterWrite(); + case "keychronWriteCurve": { + const { maxX } = curveScale(this.keychronState(), this.current().axis?.maxMm ?? null); + if (!curveInRange(command.curve, maxX)) throw new RangeError(`Curve points need x from 0 to ${maxX} in rising order and y from 0 to 127.`); + await this.client.writeCurve(command.curve); + const confirmed = await this.client.readCurve(); + if (!sameCurve(confirmed, command.curve)) { + this.recovered = this.publish({ ...this.current(), curve: confirmed }); + throw new Error("The keyboard reported a different response curve after the write."); + } + return this.publish({ ...this.current(), curve: confirmed }); + } + case "keychronSelectLayer": + return this.publishRemap(await selectRemapLayer(this.client, this.current().remap, command.layer)); + case "keychronDismissRemap": + return this.publishRemap({ ...this.current().remap, warning: null }); + case "keychronRemap": { + const outcome = await remapKey(this.client, this.current().remap, command); + const published = this.publishRemap(outcome.state); + if (outcome.error) { + // The controller drops the update when a command throws, so hand it the real state separately. + this.recovered = published; + throw outcome.error; + } + return published; + } + case "keychronKeyTravel": { + const { rows, cols } = this.keychronState(); + if (!Number.isInteger(command.row) || command.row < 0 || command.row >= rows + || !Number.isInteger(command.col) || command.col < 0 || command.col >= cols) { + throw new RangeError("That key is outside the keyboard's matrix."); + } + const steps = await this.client.readKeyTravel(command.row, command.col); + return this.publish({ ...this.current(), keyTravel: { row: command.row, col: command.col, steps } }); + } + case "keychronCalibration": + if (command.action === "start") { + if (command.confirmed !== true) throw new Error("Starting calibration needs confirmation."); + await this.client.startCalibration(); + this.state = { ...this.current(), calibrationPhase: "started" }; + } else if (command.action === "stop") { + await this.client.stopCalibration(); + // The board sends no reply to stop, so the last known state is not refreshed. + return this.publish({ ...this.current(), calibration: null, calibrationPhase: "stopped" }); + } + try { + return this.publish({ ...this.current(), calibration: await this.client.readCalibration() }); + } catch (error) { + if (command.action === "start") this.recovered = { keychron: this.current() }; + throw error; + } + } + } +} diff --git a/src/device/keychron-layout-data.ts b/src/device/keychron-layout-data.ts new file mode 100644 index 00000000..63785beb --- /dev/null +++ b/src/device/keychron-layout-data.ts @@ -0,0 +1,2 @@ +// Lazy boundary for the protocol's physical layout tables (about 250 KB of source). +export { keychronLayoutFor } from "@openmouse/keyboard-protocol/keychron"; diff --git a/src/device/keychron-layout.ts b/src/device/keychron-layout.ts new file mode 100644 index 00000000..ca52bc51 --- /dev/null +++ b/src/device/keychron-layout.ts @@ -0,0 +1,26 @@ +import type { KeychronKeyShape } from "@openmouse/keyboard-protocol/keychron"; + +export type KeychronLayoutKey = KeychronKeyShape; + +export interface KeychronLayout { + keys: readonly KeychronLayoutKey[]; + width: number; + height: number; + /** False when no physical layout is known and the keys are the matrix drawn as a plain grid. */ + physical: boolean; +} + +/** + * The board's physical layout, or its matrix as a uniform grid when none is + * known. The layout tables are large, so they load on demand; the shared + * `import()` keeps them out of the main bundle. + */ +export async function loadKeychronLayout(productId: number, rows: number, cols: number): Promise { + const known = await import("./keychron-layout-data.ts").then(({ keychronLayoutFor }) => keychronLayoutFor(productId)).catch(() => null); + if (known && known.keys.length > 0) return { keys: known.keys, width: known.width, height: known.height, physical: true }; + const keys: KeychronLayoutKey[] = []; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) keys.push({ row, col, x: col, y: row, w: 1, h: 1, rotation: 0, rx: 0, ry: 0, encoder: null }); + } + return { keys, width: cols, height: rows, physical: false }; +} diff --git a/src/device/keychron-legend.ts b/src/device/keychron-legend.ts new file mode 100644 index 00000000..ea338ce3 --- /dev/null +++ b/src/device/keychron-legend.ts @@ -0,0 +1,13 @@ +import { keychronKeycodeLegend } from "@openmouse/keyboard-protocol/keychron"; +import type { KeychronKeycodeContext } from "@openmouse/keyboard-protocol/keychron"; + +/** + * Short legend and tooltip for a keycode read from the board; empty for an + * unassigned key. Pass the board's keycode context, or layer and custom keys + * are shown wrongly. `unknown` keys carry their hex value as the label. + */ +export function legendFor(code: number | null | undefined, context?: KeychronKeycodeContext): { label: string; title: string; unknown: boolean } { + if (code === null || code === undefined) return { label: "", title: "", unknown: false }; + const { label, title, unknown } = keychronKeycodeLegend(code, context); + return { label, title, unknown }; +} diff --git a/src/device/keychron-picker.test.ts b/src/device/keychron-picker.test.ts new file mode 100644 index 00000000..ee4610fc --- /dev/null +++ b/src/device/keychron-picker.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { keychronKeycodeContext, keychronKeycodeIsChoosable, keychronKeycodeLegend } from "@openmouse/keyboard-protocol/keychron"; +import { entryMatches, pickerCatalog, pickerGroups } from "./keychron-picker.ts"; + +const context = keychronKeycodeContext(0x040c, 12); +const catalog = pickerCatalog(context, 3); + +test("the catalog groups keys, limits layer keys to the board and includes its custom keys", () => { + assert.deepEqual(catalog.map((category) => category.id), [ + "letters", "numbers", "punctuation", "editing", "navigation", "function", "modifiers", "keypad", "system", "media", "layers", "special", "keychron", + ]); + const layerLabels = catalog.find((category) => category.id === "layers")!.entries.map((entry) => entry.label); + assert.ok(layerLabels.includes("MO2")); + assert.ok(!layerLabels.includes("MO3")); + assert.ok(catalog.find((category) => category.id === "keychron")!.entries.length > 0); +}); + +test("search matches the label or the title, any case, every word", () => { + const entry = { code: 0x2a, label: "Bksp", title: "Backspace" }; + assert.ok(entryMatches(entry, "bksp")); + assert.ok(entryMatches(entry, "BACK")); + assert.ok(entryMatches(entry, "back sp")); + assert.ok(!entryMatches(entry, "enter")); + assert.ok(entryMatches(entry, " ")); +}); + +test("without a search the chosen category shows; with one every category is searched", () => { + assert.deepEqual(pickerGroups(catalog, "numbers", "").map((group) => group.id), ["numbers"]); + assert.deepEqual(pickerGroups(catalog, "nonsense", "").map((group) => group.id), ["letters"]); + const hits = pickerGroups(catalog, "numbers", "escape"); + assert.ok(hits.length > 0); + assert.ok(hits.every((group) => group.entries.every((entry) => entryMatches(entry, "escape")))); + assert.deepEqual(pickerGroups(catalog, "letters", "zzzzzz"), []); +}); + +test("an unrecognised keycode is shown as hex and is never offered", () => { + const unknown = keychronKeycodeLegend(0xbeef, context); + assert.equal(unknown.unknown, true); + assert.equal(unknown.label, "0xBEEF"); + assert.equal(keychronKeycodeIsChoosable(0xbeef, context), false); + assert.ok(!catalog.some((category) => category.entries.some((entry) => entry.code === 0xbeef))); + assert.ok(catalog.every((category) => category.entries.every((entry) => keychronKeycodeIsChoosable(entry.code, context)))); +}); + +test("every key the legacy catalog offers is valid on legacy firmware", () => { + const legacy = keychronKeycodeContext(0x040c, 10); + for (const category of pickerCatalog(legacy, 3)) { + for (const entry of category.entries) assert.ok(keychronKeycodeIsChoosable(entry.code, legacy), `${category.id} ${entry.label}`); + } +}); diff --git a/src/device/keychron-picker.ts b/src/device/keychron-picker.ts new file mode 100644 index 00000000..2225d7e3 --- /dev/null +++ b/src/device/keychron-picker.ts @@ -0,0 +1,30 @@ +import { keychronKeycodeCatalog } from "@openmouse/keyboard-protocol/keychron"; +import type { KeychronCatalogCategory, KeychronCatalogEntry, KeychronKeycodeContext } from "@openmouse/keyboard-protocol/keychron"; + +export type PickerGroup = Pick & { entries: readonly KeychronCatalogEntry[] }; + +/** The keycodes this board's firmware can be set to, grouped for the picker. */ +export function pickerCatalog(context: Required, layerCount: number): KeychronCatalogCategory[] { + return keychronKeycodeCatalog({ ...context, layerCount }); +} + +/** Every search word must appear in the entry's label or title, ignoring case. */ +export function entryMatches(entry: KeychronCatalogEntry, query: string): boolean { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + const text = `${entry.label} ${entry.title}`.toLowerCase(); + return words.every((word) => text.includes(word)); +} + +/** + * What the picker lists. With a search, every category is searched and only + * those with hits are shown; without one, the chosen category is shown. + */ +export function pickerGroups(catalog: readonly KeychronCatalogCategory[], categoryId: string, query: string): PickerGroup[] { + if (query.trim() === "") { + const category = catalog.find((entry) => entry.id === categoryId) ?? catalog[0]; + return category ? [category] : []; + } + return catalog + .map((category) => ({ id: category.id, label: category.label, entries: category.entries.filter((entry) => entryMatches(entry, query)) })) + .filter((group) => group.entries.length > 0); +} diff --git a/src/device/keychron-remap.ts b/src/device/keychron-remap.ts new file mode 100644 index 00000000..773e55aa --- /dev/null +++ b/src/device/keychron-remap.ts @@ -0,0 +1,133 @@ +import type { KeychronHidClient } from "@openmouse/keyboard-protocol/drivers/keychron/hid"; +import type { KeychronKeycodeContext } from "@openmouse/keyboard-protocol/keychron"; + +export type KeychronKeymap = readonly (readonly number[])[]; + +export interface KeychronRemapWarning { + layer: number; + row: number; + col: number; + code: number; + warnings: readonly string[]; +} + +export interface KeychronRemapResult { + kind: "ok" | "failed"; + layer: number; + row: number; + col: number; + /** The keycode that was requested. */ + code: number; + /** The rejection message when it failed. */ + message: string | null; +} + +export interface KeychronRemapState { + /** Why remapping is off (the board ignores the VIA reads), or null when it works. */ + unavailable: string | null; + protocol: number | null; + /** Protocol version and the board's own custom keys, for legends and the picker. */ + context: Required; + layerCount: number; + layer: number; + /** Keycodes of the selected layer as the board last reported them. */ + keymap: KeychronKeymap | null; + /** The keycode a key had before this session changed it, by "layer:row:col". */ + originals: Readonly>; + /** Set when a write was held back until the user confirms its warnings. */ + warning: KeychronRemapWarning | null; + result: KeychronRemapResult | null; +} + +export interface KeychronRemapRequest { + layer: number; + row: number; + col: number; + code: number; + /** The user confirmed the warnings shown for this exact change. */ + confirmed?: boolean; + /** This change puts back the original code of the key. */ + restore?: boolean; +} + +export const remapKeyId = (layer: number, row: number, col: number): string => `${layer}:${row}:${col}`; + +export const emptyRemap = (unavailable: string | null): KeychronRemapState => ({ + unavailable, protocol: null, context: { protocol: 12, custom: [] }, layerCount: 0, layer: 0, keymap: null, + originals: {}, warning: null, result: null, +}); + +const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +async function attempt(read: () => Promise): Promise { + try { return await read(); } catch { return null; } +} + +/** + * Read what remapping needs: the VIA protocol version, the board's keycode + * context, the layer count and one layer's keymap. A board that ignores VIA + * leaves remapping unavailable with the reason, instead of throwing. + */ +export async function loadRemap(client: KeychronHidClient, layer: number, previous: KeychronRemapState | null): Promise { + try { + await client.readViaProtocol(); + const context = await client.keycodeContext(); + const layerCount = await client.readLayerCount(); + const target = Math.min(Math.max(0, layer), Math.max(0, layerCount - 1)); + const keymap = await client.readKeymap(target); + return { + unavailable: null, protocol: context.protocol, context, layerCount, layer: target, keymap, + originals: previous?.originals ?? {}, warning: null, result: null, + }; + } catch (error) { + return { ...emptyRemap(messageOf(error)), originals: previous?.originals ?? {} }; + } +} + +export async function selectRemapLayer(client: KeychronHidClient, state: KeychronRemapState, layer: number): Promise { + if (state.unavailable) throw new Error(state.unavailable); + if (!Number.isInteger(layer) || layer < 0 || layer >= state.layerCount) throw new RangeError("That layer does not exist on this keyboard."); + const keymap = await client.readKeymap(layer); + return { ...state, layer, keymap, warning: null, result: null }; +} + +/** + * Set one key. Warnings (for example replacing the only layer key on a layer) + * hold the write until the same request comes back confirmed. A write counts as + * done only when `setKeycode` resolved, which reads the key back; the keymap is + * then re-read. When it rejects the keymap is re-read too, so the map shows the + * board's real state, and the error is returned for the caller to raise. + */ +export async function remapKey( + client: KeychronHidClient, state: KeychronRemapState, request: KeychronRemapRequest, +): Promise<{ state: KeychronRemapState; error: Error | null }> { + if (state.unavailable) throw new Error(state.unavailable); + const { layer, row, col, code } = request; + if (layer !== state.layer) throw new RangeError("Select that layer before changing its keys."); + const previous = state.keymap?.[row]?.[col]; + if (previous === undefined) throw new RangeError("That key is not in the keymap."); + const warnings = await client.remapWarnings(layer, row, col, code); + if (warnings.length > 0 && !request.confirmed) { + return { state: { ...state, warning: { layer, row, col, code, warnings }, result: null }, error: null }; + } + try { + const confirmed = await client.setKeycode(layer, row, col, code); + const reread = await attempt(() => client.readKeymap(layer)); + // The read-back inside setKeycode proved this key even if the full re-read failed. + const keymap = reread ?? state.keymap!.map((line, r) => (r === row ? line.map((value, c) => (c === col ? confirmed : value)) : line)); + const originals = { ...state.originals }; + const id = remapKeyId(layer, row, col); + if (request.restore || originals[id] === confirmed) delete originals[id]; + else if (!(id in originals)) originals[id] = previous; + return { state: { ...state, keymap, originals, warning: null, result: { kind: "ok", layer, row, col, code: confirmed, message: null } }, error: null }; + } catch (error) { + const reread = await attempt(() => client.readKeymap(layer)); + const originals = { ...state.originals }; + const id = remapKeyId(layer, row, col); + if (reread?.[row]?.[col] === code && !(id in originals)) originals[id] = previous; + return { + state: { ...state, keymap: reread ?? state.keymap, originals, warning: null, result: { kind: "failed", layer, row, col, code, message: messageOf(error) } }, + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} diff --git a/src/device/keychron-travel.test.ts b/src/device/keychron-travel.test.ts new file mode 100644 index 00000000..d74f72d1 --- /dev/null +++ b/src/device/keychron-travel.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { KeychronState } from "@openmouse/keyboard-protocol/drivers"; +import { mmToSteps, stepsToMm, travelBounds, travelToSteps } from "./keychron-travel.ts"; + +const base: KeychronState = { + productId: 0x040c, heVersion: 4, features: { switchAxis: true, resetSingleKey: false, dkr: false, turbo: false, gamepadDisable: false, supportMechanical: true }, + unit: 0.1, profileSize: 0, dksCount: null, snapTapCount: null, rows: 6, cols: 19, travel: { min: 0.2, max: 3.3, deadzone: 0.2 }, +}; + +test("steps and millimetres round trip without float drift", () => { + assert.equal(stepsToMm(33, 0.1), 3.3); + assert.equal(mmToSteps(3.3, 0.1), 33); + assert.equal(stepsToMm(7, 0.01), 0.07); + assert.equal(mmToSteps(0.07, 0.01), 7); +}); + +test("the dead zone extends the top of the range, not the bottom", () => { + assert.deepEqual(travelBounds(base), { stepMm: 0.1, minMm: 0.2, maxMm: 3.5, deadzoneMm: 0.2, fromBoard: true }); + const deep = travelBounds({ ...base, travel: { min: 0.1, max: 3.1, deadzone: 0.4 } }); + assert.equal(deep.minMm, 0.1); + assert.equal(deep.maxMm, 3.5); + assert.equal(travelBounds({ ...base, travel: { min: 0.1, max: 3.35 } }).maxMm, 3.35); +}); + +test("a board without a declared range is limited to what a slot can store", () => { + const older = travelBounds({ ...base, travel: null }); + assert.equal(older.fromBoard, false); + assert.equal(older.maxMm, 6.3); + const v5 = travelBounds({ ...base, travel: null, heVersion: 5, unit: 0.01 }); + assert.equal(v5.maxMm, 163.83); + assert.equal(travelBounds({ ...base, travel: { min: 0.1, max: 9 } }).maxMm, 6.3); +}); + +test("requests convert to steps and out-of-range values throw", () => { + assert.deepEqual(travelToSteps(base, { pressMm: 1.5, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.4 }), { press: 15, sensitivePress: 3, sensitiveRelease: 4 }); + assert.throws(() => travelToSteps(base, { pressMm: 0.1, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.4 }), RangeError); + assert.throws(() => travelToSteps(base, { pressMm: 1, sensitivePressMm: 3.6, sensitiveReleaseMm: 0.4 }), RangeError); + assert.equal(travelToSteps(base, { pressMm: 3.5, sensitivePressMm: 0.2, sensitiveReleaseMm: 0.4 }).press, 35); +}); diff --git a/src/device/keychron-travel.ts b/src/device/keychron-travel.ts new file mode 100644 index 00000000..9dbb2c76 --- /dev/null +++ b/src/device/keychron-travel.ts @@ -0,0 +1,77 @@ +import type { KeychronState } from "@openmouse/keyboard-protocol/drivers"; + +export interface KeychronTravelBounds { + /** Millimetres per profile step. */ + stepMm: number; + minMm: number; + maxMm: number; + /** The board's dead zone from Launcher's definition; it extends the top of the range. */ + deadzoneMm: number | null; + /** False when the board's definition declares no travel range. */ + fromBoard: boolean; +} + +/** Widest value a key slot can hold: 6 bits up to command v4, 14 bits in v5. */ +const maxSteps = (heVersion: number): number => (heVersion >= 5 ? 0x3fff : 0x3f); + +const round = (value: number): number => Number(value.toFixed(4)); + +export const stepsToMm = (steps: number, unit: number): number => round(steps * unit); + +export const mmToSteps = (mm: number, unit: number): number => Math.round(mm / unit + 1e-9); + +/** + * Limits for actuation and rapid-trigger values, as Launcher shows them: from + * the board's minimum to its maximum plus its dead zone. Launcher also keeps a + * separate minimum for the rapid-trigger sliders that boards do not declare; + * it is not invented here, so the board minimum applies to all three. Without + * a declared range the only limit is what the slot can encode. + */ +export function travelBounds(state: KeychronState): KeychronTravelBounds { + const stepMm = state.unit; + const encodable = round(maxSteps(state.heVersion) * stepMm); + const travel = state.travel; + const deadzone = travel?.deadzone ?? null; + const declaredMax = travel?.max; + const declaredMin = travel?.min; + return { + stepMm, + minMm: round(Math.max(declaredMin ?? stepMm, stepMm)), + maxMm: declaredMax === undefined ? encodable : Math.min(round(declaredMax + (deadzone ?? 0)), encodable), + deadzoneMm: deadzone, + fromBoard: declaredMax !== undefined || declaredMin !== undefined, + }; +} + +export interface KeychronTravelRequest { + rapidTrigger?: boolean; + pressMm: number; + sensitivePressMm: number; + sensitiveReleaseMm: number; +} + +export interface KeychronTravelSteps { + press: number; + sensitivePress: number; + sensitiveRelease: number; +} + +/** Convert a request to profile steps; throws if any value leaves the board's range. */ +export function travelToSteps(state: KeychronState, request: KeychronTravelRequest): KeychronTravelSteps { + const bounds = travelBounds(state); + const convert = (mm: number, label: string): number => { + if (!Number.isFinite(mm) || mm < bounds.minMm - 1e-9 || mm > bounds.maxMm + 1e-9) { + throw new RangeError(`${label} must be between ${bounds.minMm} and ${bounds.maxMm} mm.`); + } + const steps = mmToSteps(mm, bounds.stepMm); + if (stepsToMm(steps, bounds.stepMm) > bounds.maxMm + 1e-9) { + throw new RangeError(`${label} must be between ${bounds.minMm} and ${bounds.maxMm} mm.`); + } + return steps; + }; + return { + press: convert(request.pressMm, "Actuation"), + sensitivePress: request.rapidTrigger === false ? 0 : convert(request.sensitivePressMm, "Rapid trigger press"), + sensitiveRelease: request.rapidTrigger === false ? 0 : convert(request.sensitiveReleaseMm, "Rapid trigger release"), + }; +} diff --git a/src/device/preview/keychron-preview.test.ts b/src/device/preview/keychron-preview.test.ts new file mode 100644 index 00000000..d6ab9a9b --- /dev/null +++ b/src/device/preview/keychron-preview.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { KeychronKeyboardClient } from "../keychron-keyboard.ts"; +import { analogKeyCount, curveInRange, curveScale } from "../keychron-curve.ts"; +import { legendFor } from "../keychron-legend.ts"; +import { pickerCatalog } from "../keychron-picker.ts"; +import { FakeKeychronBoard, keychronPreviewRequested } from "./keychron-preview.ts"; + +test("the preview is requested only by its query parameter", () => { + assert.equal(keychronPreviewRequested(""), null); + assert.deepEqual(keychronPreviewRequested("?keychronPreview=1"), { layout: "v4", protocol: 12, fault: "none" }); + assert.deepEqual(keychronPreviewRequested("?keychronPreview=v5"), { layout: "v5", protocol: 12, fault: "none" }); + assert.deepEqual(keychronPreviewRequested("?keychronPreview=legacy,reject"), { layout: "v4", protocol: 10, fault: "reject" }); + assert.deepEqual(keychronPreviewRequested("?keychronPreview=v5,novia"), { layout: "v5", protocol: 12, fault: "novia" }); +}); + +for (const [name, v5, unit] of [["v4", false, 0.1], ["v5", true, 0.01]] as const) { + test(`the ${name} preview board round-trips through the real driver`, async () => { + const client = new KeychronKeyboardClient(new FakeKeychronBoard(v5, 0) as unknown as HIDDevice); + const first = (await client.command({ kind: "refresh" })).keychron!; + assert.equal(first.status.keychron?.unit, unit); + assert.deepEqual(first.status.profileNames, ["Typing", "Gaming", "Shooter"]); + assert.equal(first.profile?.keys.length, 6); + assert.equal(first.profile?.keys[0]?.length, 19); + assert.equal(first.layout.physical, true); + assert.equal(first.layout.keys.length, 100); + assert.equal(first.keymap?.[0]?.[0], 41); // Esc + assert.equal(first.keymap?.[2]?.[1], 20); // Q + assert.equal(first.profile?.keys[3]?.[4]?.advanceMode, 3); // the demo advanced key + assert.equal(analogKeyCount(first.profile), 4); + assert.ok(curveInRange(first.curve!, curveScale(first.status.keychron!, first.axis?.maxMm ?? null).maxX)); // the fake curve obeys Launcher's ranges + + // Staged travel is invisible to reads until it is saved. + const staged = (await client.command({ kind: "keychronSetTravel", rapidTrigger: true, pressMm: 2, sensitivePressMm: 0.5, sensitiveReleaseMm: 0.6 })).keychron!; + assert.ok(staged.unsaved); + assert.notEqual(staged.profile?.keys[0]?.[0]?.distance, Math.round(2 / unit)); + const saved = (await client.command({ kind: "keychronSave" })).keychron!; + assert.equal(saved.profile?.keys[0]?.[0]?.distance, Math.round(2 / unit)); + assert.equal(saved.profile?.keys[0]?.[0]?.advancePress, Math.round(0.5 / unit)); + assert.equal(saved.profile?.keys[0]?.[0]?.advanceRelease, Math.round(0.6 / unit)); + + const renamed = (await client.command({ kind: "keychronRename", slot: 0, name: "Office" })).keychron!; + assert.equal(renamed.status.profileNames?.[0], "Office"); + + const cleared = (await client.command({ kind: "keychronClear", confirmed: true })).keychron!; + assert.equal(cleared.profile?.keys[0]?.[0]?.distance, Math.round(1.2 / unit)); + + const states = []; + for (const action of ["start", "read", "read"] as const) { + const result = await client.command(action === "start" ? { kind: "keychronCalibration", action, confirmed: true } : { kind: "keychronCalibration", action }); + states.push(result.keychron?.calibration?.state); + } + assert.deepEqual(states, [1, 2, 3]); + }); +} + +const F = 9; // KC_F +const G = 10; // KC_G +const board = (options: { protocol?: number; fault?: "none" | "reject" | "novia" } = {}) => { + const client = new KeychronKeyboardClient(new FakeKeychronBoard(false, 0, options) as unknown as HIDDevice); + return { client, load: async () => (await client.command({ kind: "refresh" })).keychron! }; +}; + +test("remapping sets the key, re-reads the keymap and remembers the original", async () => { + const { client, load } = board(); + const first = await load(); + assert.equal(first.remap.unavailable, null); + assert.equal(first.remap.protocol, 12); + assert.equal(first.remap.layerCount, 3); + assert.equal(first.remap.keymap?.[3]?.[4], F); + const changed = (await client.command({ kind: "keychronRemap", layer: 0, row: 3, col: 4, code: G })).keychron!; + assert.equal(changed.remap.keymap?.[3]?.[4], G); + assert.equal(changed.keymap?.[3]?.[4], G); // layer 0 also labels the actuation map + assert.equal(changed.remap.result?.kind, "ok"); + assert.deepEqual(changed.remap.originals, { "0:3:4": F }); + // Restoring puts the original back and forgets it. + const restored = (await client.command({ kind: "keychronRemap", layer: 0, row: 3, col: 4, code: F, restore: true })).keychron!; + assert.equal(restored.remap.keymap?.[3]?.[4], F); + assert.deepEqual(restored.remap.originals, {}); +}); + +test("replacing the only layer key waits for confirmation and writes nothing until then", async () => { + const { client, load } = board(); + const first = await load(); + const layerKey = first.remap.keymap![5]![10]!; + const held = (await client.command({ kind: "keychronRemap", layer: 0, row: 5, col: 10, code: G })).keychron!; + assert.equal(held.remap.keymap?.[5]?.[10], layerKey); + assert.ok(held.remap.warning && held.remap.warning.warnings.length > 0); + assert.equal(held.remap.result, null); + const dismissed = (await client.command({ kind: "keychronDismissRemap" })).keychron!; + assert.equal(dismissed.remap.warning, null); + assert.equal(dismissed.remap.keymap?.[5]?.[10], layerKey); + await client.command({ kind: "keychronRemap", layer: 0, row: 5, col: 10, code: G }); + const done = (await client.command({ kind: "keychronRemap", layer: 0, row: 5, col: 10, code: G, confirmed: true })).keychron!; + assert.equal(done.remap.keymap?.[5]?.[10], G); + assert.equal(done.remap.warning, null); + assert.equal(done.remap.result?.kind, "ok"); +}); + +test("a board that keeps its old key fails the write and the map shows the real key", async () => { + const { client, load } = board({ fault: "reject" }); + await load(); + await assert.rejects(() => client.command({ kind: "keychronRemap", layer: 0, row: 3, col: 4, code: G }), /did not keep the new key assignment/); + const recovered = client.recoveredWorkspace()?.keychron; + assert.equal(recovered?.remap.result?.kind, "failed"); + assert.match(recovered?.remap.result?.message ?? "", /did not keep/); + assert.equal(recovered?.remap.keymap?.[3]?.[4], F); + assert.deepEqual(recovered?.remap.originals, {}); + assert.equal(client.recoveredWorkspace(), null); // handed over once +}); + +test("the layer switcher reads that layer, and rejects layers that do not exist", async () => { + const { client, load } = board(); + await load(); + const layer1 = (await client.command({ kind: "keychronSelectLayer", layer: 1 })).keychron!; + assert.equal(layer1.remap.layer, 1); + assert.equal(layer1.remap.keymap?.[3]?.[4], 1); // transparent on the fake's upper layers + assert.equal(layer1.keymap?.[3]?.[4], F); // the actuation map keeps layer 0 legends + await assert.rejects(() => client.command({ kind: "keychronSelectLayer", layer: 3 }), RangeError); + await assert.rejects(() => client.command({ kind: "keychronRemap", layer: 0, row: 3, col: 4, code: G }), /Select that layer/); +}); + +test("legacy firmware uses its own table and refuses codes from the modern one", async () => { + const { client, load } = board({ protocol: 10 }); + const first = await load(); + assert.equal(first.remap.protocol, 10); + const catalog = pickerCatalog(first.remap.context, first.remap.layerCount); + assert.equal(catalog.find((entry) => entry.id === "layers")?.entries.find((entry) => entry.label === "MO1")?.code, 0x5101); + assert.equal(legendFor(first.remap.keymap![5]![10], first.remap.context).label, "MO1"); + await assert.rejects(() => client.command({ kind: "keychronRemap", layer: 0, row: 3, col: 4, code: 0x5221 }), /not valid for this board's firmware/); + assert.equal((await client.command({ kind: "keychronRemap", layer: 0, row: 3, col: 4, code: 0x5101 })).keychron?.remap.result?.kind, "ok"); +}); + +test("a board with custom keys shows them by name", async () => { + const { load } = board(); + const first = await load(); + assert.equal(legendFor(first.remap.keymap![5]![2]!, first.remap.context).label, "LOpt"); + assert.equal(legendFor(first.remap.keymap![5]![2]!).unknown, true); // without the context it would be hex +}); + +test("a board that ignores VIA turns remapping off with the reason, and the rest keeps working", async () => { + const { client, load } = board({ fault: "novia" }); + const first = await load(); + assert.match(first.remap.unavailable ?? "", /did not answer/); + assert.equal(first.keymap, null); + assert.ok(first.profile && first.curve); + await assert.rejects(() => client.command({ kind: "keychronRemap", layer: 0, row: 3, col: 4, code: G }), /did not answer/); + assert.equal((await client.command({ kind: "keychronKeyTravel", row: 1, col: 1 })).keychron?.keyTravel?.row, 1); +}); diff --git a/src/device/preview/keychron-preview.ts b/src/device/preview/keychron-preview.ts new file mode 100644 index 00000000..b68976a4 --- /dev/null +++ b/src/device/preview/keychron-preview.ts @@ -0,0 +1,326 @@ +/** + * Dev-only preview of the Keychron Hall-effect controls: a fake board behind + * `?keychronPreview=1` (command v4) or `?keychronPreview=v5`. It is imported + * only from `main.tsx` under `import.meta.env.DEV`, so it never reaches a + * production build. Nothing here describes real firmware: the fake answers the + * frames the driver sends, keeps staged writes apart from saved ones, and + * makes up plausible values. + */ +import { + KEYCHRON_HE_COMMAND, KEYCHRON_HE_SUBCOMMAND as SUB, keychronKeycodeCatalog, keychronKeycodeContext, +} from "@openmouse/keyboard-protocol/keychron"; +import { KEYCHRON_HALL_EFFECT_FLAG } from "../keychron-flag.ts"; + +const ROWS = 6; +const COLS = 19; // Keychron J3 HE ANSI (0x040c): travel 0.2 to 3.3 mm, dead zone 0.2 mm +const PRODUCT_ID = 0x040c; +const PROFILES = 3; +const NAMES = ["Typing", "Gaming", "Shooter"]; + +/** + * QMK keycodes by [row][col] for the J3 HE ANSI matrix, in the order the + * board's physical layout draws them. Preview data only. + */ +const KEYMAP_ROWS: Record> = { + 0: { 0: 41, 1: 58, 2: 59, 3: 60, 4: 61, 5: 62, 6: 63, 7: 64, 8: 65, 9: 66, 10: 67, 11: 68, 12: 69, 13: 70, 15: 74, 16: 77, 18: 127 }, + 1: { 0: 53, 1: 30, 2: 31, 3: 32, 4: 33, 5: 34, 6: 35, 7: 36, 8: 37, 9: 38, 10: 39, 11: 45, 12: 46, 13: 42, 14: 76, 15: 83, 16: 84, 17: 85, 18: 86 }, + 2: { 0: 43, 1: 20, 2: 26, 3: 8, 4: 21, 5: 23, 6: 28, 7: 24, 8: 12, 9: 18, 10: 19, 11: 47, 12: 48, 13: 49, 14: 75, 15: 95, 16: 96, 17: 97, 18: 87 }, + 3: { 0: 57, 1: 4, 2: 22, 3: 7, 4: 9, 5: 10, 6: 11, 7: 13, 8: 14, 9: 15, 10: 51, 11: 52, 13: 40, 15: 92, 16: 93, 17: 94 }, + 4: { 0: 225, 2: 29, 3: 27, 4: 6, 5: 25, 6: 5, 7: 17, 8: 16, 9: 54, 10: 55, 11: 56, 13: 229, 14: 82, 15: 89, 16: 90, 17: 91, 18: 88 }, + 5: { 0: 224, 1: 227, 2: 226, 6: 44, 9: 230, 10: 231, 11: 101, 12: 80, 13: 81, 14: 79, 16: 98, 17: 99 }, +}; +const LAYERS = 3; +/** Arrow keys use the analog mode in the Gaming and Shooter profiles, so the response curve card is editable there and read only in Typing. */ +const ANALOG_KEYS = new Set(["4:14", "5:12", "5:13", "5:14"]); +const DEMO_ADVANCED_KEY = { row: 3, col: 4 }; // one key with a non-default advanced mode, so the legend has all three kinds + +/** How the fake board misbehaves: it ignores key writes, or ignores every VIA command. */ +export type PreviewFault = "none" | "reject" | "novia"; + +export interface PreviewOptions { + layout: "v4" | "v5"; + /** VIA protocol version: 12 is current QMK, 10 selects the legacy keycode table. */ + protocol: 10 | 12; + fault: PreviewFault; +} + +/** + * `?keychronPreview=1` is a v4 board at VIA protocol 12. Add words separated by + * commas: `v5`, `legacy` (protocol 10), `reject` (the board ignores key writes) + * or `novia` (the board ignores VIA commands). + */ +export function keychronPreviewRequested(search: string): PreviewOptions | null { + const value = new URLSearchParams(search).get("keychronPreview"); + if (value === null) return null; + const words = value.split(",").map((word) => word.trim()); + return { + layout: words.includes("v5") ? "v5" : "v4", + protocol: words.includes("legacy") ? 10 : 12, + fault: words.includes("novia") ? "novia" : words.includes("reject") ? "reject" : "none", + }; +} + +export class FakeKeychronBoard { + readonly vendorId = 0x3434; + readonly productId = PRODUCT_ID; + readonly productName = "Keychron J3 HE ANSI (preview)"; + readonly collections = [{ usagePage: 0xff60, usage: 0x61, inputReports: [], outputReports: [], featureReports: [], children: [] }]; + opened = false; + private readonly listeners = new Set<(event: HIDInputReportEvent) => void>(); + private readonly v5: boolean; + private readonly version: number; + private readonly slotSize: number; + private readonly size: number; + private readonly unit: number; + private readonly keymap = new Uint8Array(LAYERS * ROWS * COLS * 2); + private readonly protocol: number; + private readonly fault: PreviewFault; + private readonly saved: Uint8Array[] = []; + private working: Uint8Array; + private current = 1; + /** x in 0.1 mm steps up to 35 (3.3 mm travel plus 0.2 mm dead zone), y 0 to 127. */ + private curve = [0, 0, 12, 42, 24, 85, 35, 127]; + private calibration = 0; + + private readonly replyDelayMs: number; + + constructor(v5: boolean, replyDelayMs = 15, options: { protocol?: number; fault?: PreviewFault } = {}) { + this.replyDelayMs = replyDelayMs; + this.protocol = options.protocol ?? 12; + this.fault = options.fault ?? "none"; + this.v5 = v5; + this.version = v5 ? 5 : 4; + this.slotSize = v5 ? 7 : 4; + this.unit = v5 ? 0.01 : 0.1; + this.size = this.slotSize * (ROWS * COLS + 1) + (v5 ? 21 : 19) * 20 + 3 * 20 + 32; + // Layers above 0 are transparent (KC_TRNS) except for the keys set below. + for (let at = ROWS * COLS * 2; at < this.keymap.length; at += 2) this.keymap[at + 1] = 1; + for (const [row, cols] of Object.entries(KEYMAP_ROWS)) { + for (const [col, code] of Object.entries(cols)) this.setCode(0, Number(row), Number(col), code); + } + // A layer key and a board-specific key, with the codes of this protocol's table. + const context = keychronKeycodeContext(PRODUCT_ID, this.protocol); + const catalog = keychronKeycodeCatalog({ ...context, layerCount: LAYERS }); + const find = (category: string, label: string): number | undefined => catalog.find((entry) => entry.id === category)?.entries.find((entry) => entry.label === label)?.code; + const momentary = find("layers", "MO1"); + const custom = catalog.find((entry) => entry.id === "keychron")?.entries[0]?.code; + if (momentary !== undefined) this.setCode(0, 5, 10, momentary); + if (custom !== undefined) this.setCode(0, 5, 2, custom); + for (let slot = 0; slot < PROFILES; slot++) this.saved.push(this.defaults(slot)); + this.working = this.saved[this.current]!.slice(); + } + + private steps(mm: number): number { return Math.round(mm / this.unit); } + + private writeSlot(buffer: Uint8Array, index: number, key: { distance: number; press: number; release: number; advance: number }): void { + const at = this.slotSize * index; + if (this.v5) { + const word = BigInt(key.distance) << 2n | BigInt(key.press) << 16n | BigInt(key.release) << 30n | BigInt(key.advance) << 44n; + for (let i = 0; i < 6; i++) buffer[at + i] = Number((word >> BigInt(8 * i)) & 0xffn); + buffer[at + 6] = 0; + } else { + const word = BigInt(key.distance) << 2n | BigInt(key.press) << 8n | BigInt(key.release) << 14n | BigInt(key.advance) << 20n; + for (let i = 0; i < 4; i++) buffer[at + i] = Number((word >> BigInt(8 * i)) & 0xffn); + } + } + + private writeName(buffer: Uint8Array, name: string): void { + const start = this.size - 32; + buffer.fill(0, start); + [...name].forEach((char, index) => { buffer[start + 2 * index] = char.charCodeAt(0) >> 8; buffer[start + 2 * index + 1] = char.charCodeAt(0) & 0xff; }); + } + + /** Profile 0 is plain actuation, 1 adds rapid trigger on the WASD block, 2 is shallow everywhere. */ + private defaults(slot: number): Uint8Array { + const buffer = new Uint8Array(this.size); + const base = [1.5, 1.2, 0.8][slot]!; + const rapid = slot === 0 ? 0 : [0, 0.3, 0.2][slot]!; + this.writeSlot(buffer, 0, { distance: this.steps(base), press: this.steps(rapid), release: this.steps(rapid), advance: 0 }); + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < COLS; col++) { + const wasd = (row === 3 && col >= 2 && col <= 4) || (row === 2 && col === 3); + const distance = wasd ? Math.max(0.4, base - 0.5) : base; + const demo = row === DEMO_ADVANCED_KEY.row && col === DEMO_ADVANCED_KEY.col; + this.writeSlot(buffer, 1 + row * COLS + col, { + distance: this.steps(distance), press: this.steps(wasd ? rapid : 0), release: this.steps(wasd ? rapid : 0), + advance: demo ? 3 : slot > 0 && ANALOG_KEYS.has(`${row}:${col}`) ? 4 : 0, + }); + } + } + this.writeName(buffer, NAMES[slot]!); + return buffer; + } + + private codeAt(layer: number, row: number, col: number): number { + const at = ((layer * ROWS + row) * COLS + col) * 2; + return (this.keymap[at]! << 8) | this.keymap[at + 1]!; + } + + private setCode(layer: number, row: number, col: number, code: number): void { + const at = ((layer * ROWS + row) * COLS + col) * 2; + this.keymap[at] = code >> 8; + this.keymap[at + 1] = code & 0xff; + } + + open(): Promise { this.opened = true; return Promise.resolve(); } + close(): Promise { this.opened = false; return Promise.resolve(); } + addEventListener(_type: string, listener: (event: HIDInputReportEvent) => void): void { this.listeners.add(listener); } + removeEventListener(_type: string, listener: (event: HIDInputReportEvent) => void): void { this.listeners.delete(listener); } + forgotten = false; + forget(): Promise { this.forgotten = true; return Promise.resolve(); } + + private applyTravel(request: Uint8Array): void { + const word = (at: number): number => request[at]! | (request[at + 1]! << 8); + const read = (at: number): number => (this.v5 ? word(at) : request[at]!); + const press = read(4); + const rtPress = read(this.v5 ? 6 : 5); + const rtRelease = read(this.v5 ? 8 : 6); + const modeAt = this.v5 ? 10 : 7; + const keyMode = request[modeAt]!; + const bitmap = request.slice(modeAt + 1, modeAt + 25); + const touch = (index: number): void => { + const at = this.slotSize * index; + const old = this.working.slice(at, at + this.slotSize); + const merged = this.decode(old); + merged.distance = press; + // The frame carries zeros for the rapid-trigger values when rapid trigger is off. + merged.press = rtPress; + merged.release = rtRelease; + this.writeSlot(this.working, index, merged); + }; + if (keyMode === 1) { + for (let index = 0; index <= ROWS * COLS; index++) touch(index); + return; + } + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < COLS; col++) { + if ((bitmap[3 * row + Math.floor(col / 8)]! >> (col % 8)) & 1) touch(1 + row * COLS + col); + } + } + } + + private decode(bytes: Uint8Array): { distance: number; press: number; release: number; advance: number } { + let word = 0n; + const length = this.v5 ? 6 : 4; + for (let i = length - 1; i >= 0; i--) word = (word << 8n) | BigInt(bytes[i]!); + const field = (low: number, width: number): number => Number((word >> BigInt(low)) & ((1n << BigInt(width)) - 1n)); + return this.v5 + ? { distance: field(2, 14), press: field(16, 14), release: field(30, 14), advance: field(44, 4) } + : { distance: field(2, 6), press: field(8, 6), release: field(14, 6), advance: field(20, 4) }; + } + + /** The standard VIA protocol, layer-count, keycode and keymap-buffer commands. */ + private answerVia(request: Uint8Array): void { + if (this.fault === "novia") return; + const reply = new Uint8Array(32); + reply[0] = request[0]!; + if (request[0] === 0x01) reply[2] = this.protocol; + else if (request[0] === 0x04 || request[0] === 0x05) { + const [layer, row, col] = [request[1]!, request[2]!, request[3]!]; + if (layer >= LAYERS || row >= ROWS || col >= COLS) return; + // A "reject" board takes the write command and then keeps its old key. + if (request[0] === 0x05) { if (this.fault !== "reject") this.setCode(layer, row, col, (request[4]! << 8) | request[5]!); return; } + reply.set([layer, row, col, this.codeAt(layer, row, col) >> 8, this.codeAt(layer, row, col) & 0xff], 1); + } else if (request[0] === 0x11) reply[1] = LAYERS; + else { + const offset = (request[1]! << 8) | request[2]!; + reply.set(request.slice(1, 4), 1); + for (let i = 0; i < request[3]!; i++) reply[4 + i] = this.keymap[offset + i] ?? 0; + } + this.deliver(reply); + } + + private deliver(reply: Uint8Array): void { + // Replies carry no transaction id; answer on the next task like a real report. + setTimeout(() => { + const event = { device: this, reportId: 0, data: new DataView(reply.buffer) } as unknown as HIDInputReportEvent; + this.listeners.forEach((listener) => listener(event)); + }, this.replyDelayMs); + } + + async sendReport(_reportId: number, data: BufferSource): Promise { + const request = new Uint8Array(data as ArrayBuffer); + if ([0x01, 0x04, 0x05, 0x11, 0x12].includes(request[0]!)) { this.answerVia(request); return; } + if (request[0] !== KEYCHRON_HE_COMMAND) return; + const sub = request[1]!; + if (sub === SUB.calibrate && request[2] === 4) { this.calibration = 0; return; } // stop is never answered + const reply = new Uint8Array(32); + reply.set(request.slice(0, 2)); + switch (sub) { + case SUB.getVersion: reply[2] = this.version; reply[4] = 0b100001; break; + case SUB.getProfilesInfo: reply.set([this.current, PROFILES, this.size & 0xff, this.size >> 8, 20, 20, this.v5 ? 2 : 0], 2); break; + case SUB.selectProfile: + this.current = request[2]!; + this.working = this.saved[this.current]!.slice(); + break; + case SUB.getProfileBuffer: { + reply.set(request.slice(2, 6), 2); + const offset = request[3]! | (request[4]! << 8); + const source = this.saved[request[2]!]!; + for (let i = 0; i < request[5]!; i++) reply[6 + i] = source[offset + i] ?? 0; + break; + } + case SUB.setTravel: this.applyTravel(request); break; + case SUB.setProfileName: { + const slot = request[2]!; + const chars = request.slice(4, 4 + request[3]!); + const start = this.size - 32; + this.saved[slot]!.fill(0, start); + this.saved[slot]!.set(chars, start); + if (slot === this.current) this.working.set(this.saved[slot]!.slice(start), start); + break; + } + case SUB.saveProfileBuffer: this.saved[request[2]!] = this.working.slice(); break; + case SUB.clearProfileBuffer: { + const fresh = this.defaults(request[2]!); + this.saved[request[2]!] = fresh; + this.working = fresh.slice(); + break; + } + case SUB.getCurve: reply.set(this.curve, 2); break; + case SUB.setCurve: this.curve = [...request.slice(2, 10)]; break; + case SUB.getAxisType: reply.set([2, ...(() => { const max = Math.round(3.3 / this.unit); return [max & 0xff, max >> 8]; })()], 2); break; + case SUB.getRealtimeTravel: { + const t = Date.now() / 700 + request[2]! + request[3]!; + const steps = Math.round(Math.abs(Math.sin(t)) * (3.3 / this.unit)); + reply[3] = request[2]!; + reply[4] = request[3]!; + reply[5] = steps & 0xff; + reply[6] = steps >> 8; + break; + } + case SUB.calibrate: this.calibration = 1; break; + case SUB.getCalibrateState: { + reply[3] = this.calibration; + const keys = Math.min(ROWS * COLS, this.calibration * 40); + for (let key = 0; key < keys; key++) reply[4 + (key >> 3)]! |= 1 << (key & 7); + if (this.calibration > 0) this.calibration = Math.min(this.calibration + 1, 3); + break; + } + default: break; + } + this.deliver(reply); + } +} + +/** Returns true once the fake is installed; false when the page is about to reload with the flag set. */ +export function installKeychronPreview(search: string): boolean { + const mode = keychronPreviewRequested(search); + if (mode === null) return true; + // HID filters read the flag when the app loads, so set it and load again once. + if (localStorage.getItem(KEYCHRON_HALL_EFFECT_FLAG) !== "1") { + localStorage.setItem(KEYCHRON_HALL_EFFECT_FLAG, "1"); + location.reload(); + return false; + } + const board = new FakeKeychronBoard(mode.layout === "v5", 15, { protocol: mode.protocol, fault: mode.fault }); + const hid = { + getDevices: async () => board.forgotten ? [] : [board], + requestDevice: async () => { board.forgotten = false; return [board]; }, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => true, + }; + Object.defineProperty(navigator, "hid", { value: hid, configurable: true }); + return true; +} diff --git a/src/device/types.ts b/src/device/types.ts index cdd2d5cf..f5caa272 100644 --- a/src/device/types.ts +++ b/src/device/types.ts @@ -224,7 +224,7 @@ export interface KeyboardTriggerView { } export interface KeyboardView { - family?: "wuque" | "kochi" | "wooting" | "razer" | "logitech" | "steelseries" | "arbiter"; + family?: "wuque" | "kochi" | "wooting" | "razer" | "logitech" | "steelseries" | "keychron" | "arbiter"; /** True when the driver marked the session unusable; reconnect before retrying. */ faulted?: boolean; name: string; diff --git a/src/features/devices/cards/KeyboardPanel.tsx b/src/features/devices/cards/KeyboardPanel.tsx index 936c0a83..e3d60799 100644 --- a/src/features/devices/cards/KeyboardPanel.tsx +++ b/src/features/devices/cards/KeyboardPanel.tsx @@ -8,6 +8,7 @@ import * as control from "../../../device/controller"; import type { ControlSnapshot, KeyboardTriggerKey } from "../../../device/types"; import { t, tp } from "../../../i18n/index"; import { RateSlider, StepperSlider } from "../../../shared/ui"; +const KeychronKeyboardPanel = lazy(() => import("./KeychronKeyboardPanel").then(({ KeychronKeyboardPanel }) => ({ default: KeychronKeyboardPanel }))); const RazerKeyboardPanel = lazy(() => import("./RazerKeyboardPanel").then(({ RazerKeyboardPanel }) => ({ default: RazerKeyboardPanel }))); const LogitechKeyboardPanel = lazy(() => import("./LogitechKeyboardPanel").then(({ LogitechKeyboardPanel }) => ({ default: LogitechKeyboardPanel }))); const SteelSeriesKeyboardPanel = lazy(() => import("./SteelSeriesKeyboardPanel").then(({ SteelSeriesKeyboardPanel }) => ({ default: SteelSeriesKeyboardPanel }))); @@ -29,6 +30,7 @@ export function KeyboardPanel({ snapshot }: { snapshot: ControlSnapshot }): Reac const keyboard = snapshot.keyboard; if (!keyboard) return null; if (keyboard.family === "kochi") return ; + if (keyboard.family === "keychron") return ; if (keyboard.family === "wooting") return ; if (keyboard.family === "razer") return ; if (keyboard.family === "logitech") return ; diff --git a/src/features/devices/cards/KeychronCurveGraph.tsx b/src/features/devices/cards/KeychronCurveGraph.tsx new file mode 100644 index 00000000..7c073c24 --- /dev/null +++ b/src/features/devices/cards/KeychronCurveGraph.tsx @@ -0,0 +1,76 @@ +import { useRef, type KeyboardEvent, type PointerEvent, type ReactNode } from "react"; +import type { KeychronCurve } from "@openmouse/keyboard-protocol/keychron"; +import { CURVE_MAX_Y, CURVE_X_STEP_MM, setCurvePoint } from "../../../device/keychron-curve"; +import { tp } from "../../../i18n/index"; +import type { InterfaceLocale } from "../../settings/interface-preferences"; + +const LEFT = 34; +const RIGHT = 12; +const TOP = 12; +const BOTTOM = 26; +const PLOT_W = 280; +const PLOT_H = 200; +const WIDTH = LEFT + PLOT_W + RIGHT; +const HEIGHT = TOP + PLOT_H + BOTTOM; +const Y_TICKS = [0, 32, 64, 96, CURVE_MAX_Y]; + +const fixed = (value: number): string => String(Number(value.toFixed(2))); + +/** + * The response curve: key travel in millimetres across, output 0 to 127 up. + * Points drag with a pointer and move with the arrow keys (Shift for 10 steps). + * Every move goes through `setCurvePoint`, so a byte can never leave its range. + */ +export function KeychronCurveGraph({ curve, maxX, locale, disabled, active, onActive, onChange }: { + curve: KeychronCurve; + maxX: number; + locale: InterfaceLocale; + disabled: boolean; + active: number | null; + onActive: (index: number | null) => void; + onChange: (next: KeychronCurve) => void; +}): ReactNode { + const svg = useRef(null); + const px = (x: number): number => LEFT + (x / maxX) * PLOT_W; + const py = (y: number): number => TOP + PLOT_H - (y / CURVE_MAX_Y) * PLOT_H; + const drag = (index: number, event: PointerEvent): void => { + const box = svg.current?.getBoundingClientRect(); + if (!box || disabled || !event.currentTarget.hasPointerCapture(event.pointerId)) return; + const scale = WIDTH / box.width; + const x = (((event.clientX - box.left) * scale - LEFT) / PLOT_W) * maxX; + const y = (1 - ((event.clientY - box.top) * scale - TOP) / PLOT_H) * CURVE_MAX_Y; + onChange(setCurvePoint(curve, index, x, y, maxX)); + }; + const nudge = (index: number, event: KeyboardEvent): void => { + const step = event.shiftKey ? 10 : 1; + const [x, y] = curve[index]!; + const move: Record = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] }; + const delta = move[event.key]; + if (!delta || disabled) return; + event.preventDefault(); + onChange(setCurvePoint(curve, index, x + delta[0], y + delta[1], maxX)); + }; + const mmTicks = Array.from({ length: Math.floor(maxX * CURVE_X_STEP_MM) + 1 }, (_, mm) => mm / CURVE_X_STEP_MM); + if (mmTicks[mmTicks.length - 1] !== maxX) mmTicks.push(maxX); + return ( + + {mmTicks.map((x) => )} + {Y_TICKS.map((y) => )} + {mmTicks.map((x) => {fixed(x * CURVE_X_STEP_MM)})} + {Y_TICKS.map((y) => {y})} + `${px(x)},${py(y)}`).join(" ")} /> + {curve.map(([x, y], index) => ( + { if (!disabled) event.currentTarget.setPointerCapture(event.pointerId); onActive(index); }} + onPointerMove={(event) => drag(index, event)} + onPointerEnter={() => onActive(index)} onPointerLeave={() => onActive(null)} + onFocus={() => onActive(index)} onBlur={() => onActive(null)} + onKeyDown={(event) => nudge(index, event)} /> + ))} + + ); +} diff --git a/src/features/devices/cards/KeychronKeyMap.tsx b/src/features/devices/cards/KeychronKeyMap.tsx new file mode 100644 index 00000000..c0eb0551 --- /dev/null +++ b/src/features/devices/cards/KeychronKeyMap.tsx @@ -0,0 +1,75 @@ +import type { CSSProperties, ReactNode } from "react"; +import { KEYCHRON_ADVANCE_MODE, type KeychronKeySlot, type KeychronKeycodeContext } from "@openmouse/keyboard-protocol/keychron"; +import type { KeychronWorkspace } from "../../../device/keychron-keyboard"; +import { legendFor } from "../../../device/keychron-legend"; +import { stepsToMm } from "../../../device/keychron-travel"; +import { t, tp } from "../../../i18n/index"; +import type { InterfaceLocale } from "../../settings/interface-preferences"; + +export type KeychronKeyKind = "static" | "rapid" | "advanced"; + +/** An advanced mode wins; otherwise rapid-trigger values make a key rapid. */ +export function keyKind(slot: KeychronKeySlot | undefined): KeychronKeyKind { + if (!slot) return "static"; + if (slot.advanceMode !== KEYCHRON_ADVANCE_MODE.none) return "advanced"; + return slot.advancePress > 0 || slot.advanceRelease > 0 ? "rapid" : "static"; +} + +export const keyId = (row: number, col: number): string => `${row}:${col}`; + +const fixed = (value: number): string => String(Number(value.toFixed(3))); + +/** + * The keyboard drawn at its real key positions. Each key shows its legend + * from the board's current keymap (row and column when that could not be + * read) and its actuation in millimetres. + */ +export function KeychronKeyMap({ kc, locale, keymap, context, showTravel = true, selected, disabled, onSelect }: { + kc: KeychronWorkspace; + locale: InterfaceLocale; + /** Keycodes that label the keys: a layer of the board's keymap. */ + keymap: readonly (readonly number[])[] | null; + context?: KeychronKeycodeContext; + /** Show each key's actuation in mm under its legend. */ + showTravel?: boolean; + selected: ReadonlySet; + disabled: boolean; + /** `additive` is true for Shift, Ctrl or Command clicks. */ + onSelect: (row: number, col: number, additive: boolean) => void; +}): ReactNode { + const unit = kc.status.keychron?.unit ?? 0.1; + const { layout, profile } = kc; + return ( +
+
+ {layout.keys.map((key) => { + const slot = profile?.keys[key.row]?.[key.col]; + const kind = keyKind(slot); + const mm = slot ? fixed(stepsToMm(slot.distance, unit)) : null; + const legend = keymap ? legendFor(keymap[key.row]?.[key.col], context) : null; + const place = tp(locale, "kb.keychronKeyLabel", { row: key.row, col: key.col }); + const label = legend?.label || place; + const id = keyId(key.row, key.col); + const style: CSSProperties = { + left: `${(key.x / layout.width) * 100}%`, top: `${(key.y / layout.height) * 100}%`, + width: `${(key.w / layout.width) * 100}%`, height: `${(key.h / layout.height) * 100}%`, + ...(key.rotation ? { transform: `rotate(${key.rotation}deg)`, transformOrigin: `${((key.rx - key.x) / key.w) * 100}% ${((key.ry - key.y) / key.h) * 100}%` } : {}), + }; + const className = `keychron-key${showTravel ? ` is-${kind}` : ""}${legend?.unknown ? " is-unknown" : ""}${selected.has(id) ? " is-selected" : ""}${key.encoder !== null ? " is-knob" : ""}`; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/features/devices/cards/KeychronKeyboardPanel.tsx b/src/features/devices/cards/KeychronKeyboardPanel.tsx new file mode 100644 index 00000000..02574f67 --- /dev/null +++ b/src/features/devices/cards/KeychronKeyboardPanel.tsx @@ -0,0 +1,335 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { KEYCHRON_ADVANCE_MODE } from "@openmouse/keyboard-protocol/keychron"; +import type { KeychronCurve } from "@openmouse/keyboard-protocol/keychron"; +import type { ControlSnapshot } from "../../../device/types"; +import type { KeychronWorkspace } from "../../../device/keychron-keyboard"; +import { legendFor } from "../../../device/keychron-legend"; +import { stepsToMm, travelBounds } from "../../../device/keychron-travel"; +import { CURVE_MAX_Y, CURVE_X_STEP_MM, analogKeyCount, clampCurve, curveInRange, curveScale, linearCurve, setCurvePoint } from "../../../device/keychron-curve"; +import * as control from "../../../device/controller"; +import { displayText, t, tp, type I18nKey } from "../../../i18n/index"; +import type { InterfaceLocale } from "../../settings/interface-preferences"; +import { Segmented, StepperSlider, SwitchRow } from "../../../shared/ui"; +import { KeychronCurveGraph } from "./KeychronCurveGraph"; +import { KeychronKeyMap, keyId } from "./KeychronKeyMap"; +import { KeychronRemapCard } from "./KeychronRemapCard"; + +const send = control.applyKeyboardCommand; +const millimetres = (value: number): string => `${value.toFixed(3)} mm`; +const fixed = (value: number): string => String(Number(value.toFixed(4))); + +type Locale = InterfaceLocale; +type Vars = Record; +const l = (locale: Locale, key: string, vars?: Vars): string => + vars ? tp(locale, `kb.keychron${key}` as I18nKey, vars) : t(locale, `kb.keychron${key}` as I18nKey); + +interface KeyRef { row: number; col: number } + +const ADVANCE_LABELS: ReadonlyArray = [ + [KEYCHRON_ADVANCE_MODE.none, "AdvanceNone"], + [KEYCHRON_ADVANCE_MODE.dks, "AdvanceDks"], + [KEYCHRON_ADVANCE_MODE.analog, "AdvanceAnalog"], + [KEYCHRON_ADVANCE_MODE.toggle, "AdvanceToggle"], + [KEYCHRON_ADVANCE_MODE.dkr, "AdvanceDkr"], + [KEYCHRON_ADVANCE_MODE.turbo, "AdvanceTurbo"], +]; + +/** Named only for the modes the protocol documents; anything else stays a number. */ +function advanceLabel(locale: Locale, mode: number): string { + const known = ADVANCE_LABELS.find(([value]) => value === mode); + return known ? l(locale, known[1]) : l(locale, "AdvanceRaw", { n: mode }); +} + +function Card({ title, hidden, children }: { title: string; hidden: boolean; children: ReactNode }): ReactNode { + return ; +} + +/** Two-step button, the same inline pattern the SteelSeries panel uses for overwrites. */ +function Confirmed({ label, question, yes, locale, disabled, onConfirm }: { + label: string; question: string; yes: string; locale: Locale; disabled: boolean; onConfirm: () => void; +}): ReactNode { + const [asking, setAsking] = useState(false); + if (!asking) return ; + return
+

{question}

+
+ + +
+
; +} + +function IdentityCard({ kc, locale, busy, hidden, name }: { kc: KeychronWorkspace | null; locale: Locale; busy: boolean; hidden: boolean; name: string }): ReactNode { + const state = kc?.status.keychron; + return ; +} + +function KeysCard({ kc, locale, busy, hidden, selection, primary, onSelect, onSelectAll, onClear }: { + kc: KeychronWorkspace; locale: Locale; busy: boolean; hidden: boolean; selection: ReadonlySet; primary: KeyRef | null; + onSelect: (row: number, col: number, additive: boolean) => void; onSelectAll: () => void; onClear: () => void; +}): ReactNode { + const state = kc.status.keychron; + const slot = primary ? kc.profile?.keys[primary.row]?.[primary.col] : undefined; + const legend = primary && kc.keymap ? legendFor(kc.keymap[primary.row]?.[primary.col], kc.remap.context) : null; + const unit = state?.unit ?? 0.1; + return