diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index e7cf441f..51e8dc4f 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -30,9 +30,36 @@ import { join } from "node:path"; // 2026-10-31: VIA keyboard support adds the keycode tables, the definition // parser, macro/encoder/lighting editors and their strings, about 82 kB of JS // (3,734,304 bytes measured); it is lazy-loaded, main stays under its cap. +// +// 2026-10-31: MCHOSE Ace 68 Air support (keyboard-protocol codec and driver, +// per-key travel, keymap, macros, DKS/mod-tap/toggle, lighting, calibration +// panels and their strings in 12 languages), about 209 kB of JS (3,947,820 +// bytes measured) and the keyboard deck, macro step and DKS grid styles, +// about 6 kB of CSS (174,587 bytes measured). The panel and the HID driver +// are lazy-loaded; the main chunk grew 20 kB, mostly English strings. +// +// 2026-10-31: MCHOSE follow-ups: media keycap icons (nine lucide icons), Mac +// layer labels, keyboard-function names and a shared slider with a typed +// number field (Wooting's millimetre field, now reused), about 5 kB of JS +// (3,953,032 bytes measured). +// +// 2026-10-31: MCHOSE parity with the vendor app's trigger, key and advanced +// tabs: per-direction rapid-trigger precision, travel view and selection +// tools, ready-made shortcuts, layer restore, live key depth, binding list, +// and 18 new strings in 12 languages, about 14 kB of JS (3,969,768 bytes +// measured). +// +// 2026-10-31: MCHOSE profile save/load to a file, official setups in the +// profile card and their strings in 12 languages, about 14 kB of JS +// (3,983,809 bytes measured). +// +// 2026-10-31: MCHOSE gradient editor and key-colour reset, factory reset +// (one profile or the whole keyboard), and their strings in 12 languages: +// about 21 kB of JS (4,004,460 bytes measured) and the gradient bar's styles, +// about 0.5 kB of CSS (175,264 bytes measured). const BUDGET_BYTES: Record = { - ".css": 170_000, - ".js": 3_800_000, + ".css": 176_000, + ".js": 4_010_000, }; // main-*.js measured 1,511,619 bytes right after the split (posthog ~377 kB diff --git a/src/device/controller.ts b/src/device/controller.ts index f11405d4..24f658d5 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -1,6 +1,7 @@ import { emptyKeyboardWorkspace } from "./keyboard-workspace.ts"; import { track, deviceProps, scrubError } from "../platform/analytics"; import type { KeyboardCommand } from "./keyboard-workspace.ts"; +import type { KeyboardKeyReport } from "./keyboard-client.ts"; import { cachedBatterySamples, estimateBatteryTime, estimateFromRatedLife, ratedBattery, recordBatterySample, type BatteryMode } from "../features/devices/battery-history"; import { bridgeEmptyResultMessage, isBridgeHidActive } from "../features/bridge/bridge-hid"; import { applyBridgeNativeSettings } from "../features/bridge/bridge"; @@ -3259,20 +3260,25 @@ export function clearKeyboardSelection(): void { /** Serialize keyboard reads and writes and reject results after a device switch. */ export async function applyKeyboardCommand(command: KeyboardCommand): Promise { + await runKeyboardCommand(command); +} + +/** Like applyKeyboardCommand, but true when the command went through and false when it failed or could not start (the status says why). */ +export async function runKeyboardCommand(command: KeyboardCommand): Promise { const client = activeKeyboard; - if (!client || keyboardBusy) return; + if (!client || keyboardBusy) return false; const telemetry = command.kind === "keychronKeyTravel"; keyboardBusy = true; if (!telemetry) keyboardStatusText = st("kb.applying"); emit(); try { const update = await client.command(command); - if (activeKeyboard !== client) return; + if (activeKeyboard !== client) return false; keyboardWorkspace = { ...keyboardWorkspace, ...update }; if (command.kind === "slot" && client.readPolling && client.readTrigger) { keyboardPolling = await client.readPolling(); const trigger = await client.readTrigger(); - if (activeKeyboard !== client) return; + if (activeKeyboard !== client) return false; keyboardMatrix = trigger.matrix; keyboardKeys = trigger.keys; keyboardSelected = null; @@ -3281,7 +3287,7 @@ export async function applyKeyboardCommand(command: KeyboardCommand): Promise { + const previous = activeKeyboard; + if (previewMode || !previous) return; + const { vendorId, productId } = previous.device; + for (let tries = 0; tries < 24; tries += 1) { + await wait(500); + // Stop once another keyboard is active; a cleared session is expected during the reboot. + if (activeKeyboard && activeKeyboard !== previous) return; + // A rebooted keyboard normally comes back as a new device object; accept the old one too after a few tries. + const devices = ((await navigator.hid?.getDevices()) ?? []) + .filter((entry) => entry.vendorId === vendorId && entry.productId === productId && (entry !== previous.device || tries >= 6)); + const candidates = devices.map((device) => createKeyboardClient(device)).filter((client): client is KeyboardClient => client !== null); + // Two identical keyboards: there is no telling which one rebooted, so leave the choice to the user. + if (candidates.length > 1) return; + const next = candidates[0]; + if (!next) continue; + try { + await previous.close().catch(() => undefined); + if (activeKeyboard === previous) await activateClient(next, false, previous); + else if (!activeKeyboard) await activateClient(next); + return; + } catch { + await next.close().catch(() => undefined); + } + } +} + /** Reopen the active keyboard after its driver marked the session unusable. */ export async function reconnectKeyboard(): Promise { const previous = activeKeyboard; @@ -3317,6 +3358,11 @@ export async function reconnectKeyboard(): Promise { } } +/** Follow keys as the active keyboard reports them; a no-op where it cannot. Returns a function that stops. */ +export function watchKeyboardKeys(listener: (report: KeyboardKeyReport) => void, depth = false): () => void { + return activeKeyboard?.watchKeys?.(listener, depth) ?? (() => undefined); +} + /** The keys held down right now, or null when nothing can be read. Deliberately silent: no busy flag, status text or redraw. */ export async function probeKeyboardKeys(): Promise { const client = activeKeyboard; @@ -7541,17 +7587,18 @@ function previewShelfEntry(index: number, name: string, selected: boolean): Side return { index, name, detail: "", selected, vendorId: 0, productId: 0, kind: "mouse", transport: "webhid" }; } -/** Show an Arbiter keyboard from invented data; nothing is read from or written to a device. */ +/** Show a keyboard from invented data; nothing is read from or written to a device. */ async function showKeyboardPreview(name: KeyboardPreviewMode): Promise { - const { ArbiterPreviewClient, arbiterPreviewLabel } = await import("./preview/arbiter-preview"); - const client = new ArbiterPreviewClient(name); + const { client, label } = name === "mchose-ace68-air" + ? await import("./preview/mchose-preview").then(({ MchosePreviewClient, MCHOSE_PREVIEW_LABEL }) => ({ client: new MchosePreviewClient(), label: MCHOSE_PREVIEW_LABEL })) + : await import("./preview/arbiter-preview").then(({ ArbiterPreviewClient, arbiterPreviewLabel }) => ({ client: new ArbiterPreviewClient(name), label: arbiterPreviewLabel(name) })); activeDevice = client.device; activeKeyboard = client; keyboardIdentity = await client.readIdentity(); keyboardWorkspace = { ...keyboardWorkspace, ...await client.command({ kind: "refresh" }) }; sidebarDevices = [{ index: 0, name: client.name, detail: "", selected: true, vendorId: client.device.vendorId, productId: client.device.productId, kind: "keyboard", transport: "webhid" }]; setConnectionButtons(true, "Preview mode"); - setReadStatus(st("ctl.previewLabel", { label: displayText(interfacePreferences.locale, arbiterPreviewLabel(name)) })); + setReadStatus(st("ctl.previewLabel", { label: displayText(interfacePreferences.locale, label) })); deviceListView = "device"; emit(); } @@ -7631,7 +7678,7 @@ export function start(): void { showSlotsPreview(); deviceListView = "device"; emit(); - } else if (previewMode === "arbiter-polar-65-pro" || previewMode === "arbiter-polar-65") { + } else if (previewMode === "arbiter-polar-65-pro" || previewMode === "arbiter-polar-65" || previewMode === "mchose-ace68-air") { void showKeyboardPreview(previewMode); } else if (previewMode !== null && previewMode !== "list") { void showFixturePreview(previewMode).then(() => { diff --git a/src/device/device-clients.ts b/src/device/device-clients.ts index f7d3defa..915a93c4 100644 --- a/src/device/device-clients.ts +++ b/src/device/device-clients.ts @@ -9,6 +9,7 @@ import { SteelSeriesKeyboardClient } from "./steelseries-keyboard.ts"; import { KeychronKeyboardClient } from "./keychron-keyboard.ts"; import { keychronHallEffectEnabled } from "./keychron-flag.ts"; import { ViaKeyboardClient } from "./via-keyboard.ts"; +import { MchoseKeyboardClient } from "./mchose-keyboard.ts"; import type { PulsarClient, SupportedClient } from "@openmouse/protocol/drivers/registry"; export { describeHidDevice } from "./hid-diagnostics.ts"; export { type PulsarClient, type SupportedClient }; @@ -103,7 +104,7 @@ export type { KeyboardClient }; export function isKeyboardClient(client: SupportedClient | KeyboardClient): client is KeyboardClient { return client instanceof WuqueKeyboardClient || client instanceof KochiKeyboardClient || client instanceof WootingKeyboardClient || client instanceof RazerKeyboardClient || client instanceof LogitechKeyboardClient || client instanceof SteelSeriesKeyboardClient - || client instanceof KeychronKeyboardClient || client instanceof ArbiterKeyboardClient + || client instanceof KeychronKeyboardClient || client instanceof ArbiterKeyboardClient || client instanceof MchoseKeyboardClient || client instanceof ViaKeyboardClient; } @@ -120,6 +121,7 @@ export function createKeyboardClient(device: HIDDevice): KeyboardClient | null { if (LogitechKeyboardClient.isSupported(device)) return new LogitechKeyboardClient(device); if (SteelSeriesKeyboardClient.isSupported(device)) return new SteelSeriesKeyboardClient(device); if (ArbiterKeyboardClient.isSupported(device)) return new ArbiterKeyboardClient(device); + if (MchoseKeyboardClient.isSupported(device)) return new MchoseKeyboardClient(device); if (keychronHallEffectEnabled() && KeychronKeyboardClient.isSupported(device)) return new KeychronKeyboardClient(device); if (ViaKeyboardClient.isSupported(device)) return new ViaKeyboardClient(device); return null; diff --git a/src/device/keyboard-client.ts b/src/device/keyboard-client.ts index 86ed2cb7..10fbe80f 100644 --- a/src/device/keyboard-client.ts +++ b/src/device/keyboard-client.ts @@ -1,9 +1,17 @@ import type { KeyboardCommand, KeyboardWorkspace } from "./keyboard-workspace.ts"; import type { KeyboardIdentityView, KeyboardPollingView, KeyboardTriggerKey, KeyboardTriggerPatch, KeyboardTriggerSettings } from "./types.ts"; +export interface KeyboardKeyReport { + index: number; + /** Raw travel units; the key is down above 10. */ + travel: number; + /** A key needs two full presses; "pending" is after the first. */ + calibration: "none" | "pending" | "done"; +} + export interface KeyboardClient { readonly device: HIDDevice; - readonly family: "wuque" | "kochi" | "wooting" | "razer" | "logitech" | "steelseries" | "keychron" | "arbiter" | "via"; + readonly family: "wuque" | "kochi" | "wooting" | "razer" | "logitech" | "steelseries" | "keychron" | "arbiter" | "mchose" | "via"; readonly brand: string; /** Set once the session needs a reconnect before it can be used again. */ readonly faulted?: boolean; @@ -16,6 +24,8 @@ export interface KeyboardClient { recoveredWorkspace?(): Partial | null; /** Live key state for a tester, read outside the busy/status flow: one bit mask per matrix row. */ probeKeys?(): Promise; + /** Keys reported as they move (MCHOSE debug mode), by matrix index. Returns a function that stops watching. */ + watchKeys?(listener: (report: KeyboardKeyReport) => void, depth?: boolean): () => void; 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 55f279b3..30cb7a0f 100644 --- a/src/device/keyboard-workspace.ts +++ b/src/device/keyboard-workspace.ts @@ -1,4 +1,5 @@ import type { ArbiterCommand, ArbiterWorkspace } from './arbiter-workspace.ts'; +import type { MchoseCommand, MchoseWorkspace } from './mchose-workspace.ts'; import type { WuqueGlobalState, WuqueKeyState, WuqueLightingState } from '@openmouse/keyboard-protocol/drivers'; import type { KeyboardStatus } from '@openmouse/keyboard-protocol/drivers'; import type { WuqueMacroRead, WuqueHidClient } from '@openmouse/keyboard-protocol/drivers/wuque/hid'; @@ -39,6 +40,7 @@ export interface KeyboardWorkspace { higher: WuqueHigherKey | null; adc: WuqueAxisReading | null; arbiter?: ArbiterWorkspace | null; + mchose?: MchoseWorkspace | null; } export const emptyKeyboardWorkspace = (): KeyboardWorkspace => ({ global: null, lighting: null, custom: [], shell: null, layer: null, styles: [], defaults: [], macro: null, higher: null, adc: null }); export type KeyboardCommand = @@ -67,7 +69,8 @@ export type KeyboardCommand = | { kind: 'higherRead'; row: number; col: number } | { kind: 'higherWrite'; write: WuqueHigherKeyWrite } | { kind: 'adc'; row: number } - | ArbiterCommand; + | ArbiterCommand + | MchoseCommand; function integer(value: number, max: number): void { if (!Number.isInteger(value) || value < 0 || value > max) throw new RangeError(`Expected a whole number from 0 to ${max}.`); diff --git a/src/device/mchose-colors.test.ts b/src/device/mchose-colors.test.ts new file mode 100644 index 00000000..ef16829e --- /dev/null +++ b/src/device/mchose-colors.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mchoseGradientColor, mchoseGradientKeys } from "./mchose-colors.ts"; + +const stops = [{ at: 1, color: "#0000ff" }, { at: 0, color: "#ff0000" }]; + +test("a gradient blends between its nearest stops and holds past the ends", () => { + assert.deepEqual(mchoseGradientColor(stops, 0), { r: 255, g: 0, b: 0 }); + assert.deepEqual(mchoseGradientColor(stops, 0.5), { r: 128, g: 0, b: 128 }); + assert.deepEqual(mchoseGradientColor(stops, 1), { r: 0, g: 0, b: 255 }); + assert.deepEqual(mchoseGradientColor([{ at: 0.3, color: "#00ff00" }, { at: 0.6, color: "#ffffff" }], 0.1), { r: 0, g: 255, b: 0 }); + assert.deepEqual(mchoseGradientColor([{ at: 0.3, color: "#00ff00" }, { at: 0.6, color: "#ffffff" }], 0.9), { r: 255, g: 255, b: 255 }); +}); + +test("keys get colours along the chosen direction", () => { + const keys = [{ index: 1, row: 0, x: 0, width: 1 }, { index: 2, row: 0, x: 4, width: 1 }, { index: 3, row: 2, x: 0, width: 1 }]; + const across = mchoseGradientKeys(keys, stops, 0); + assert.deepEqual(across.map((key) => key.color.r), [255, 0, 255], "left to right: the right key is blue, both left keys red"); + const down = mchoseGradientKeys(keys, stops, 90); + assert.deepEqual(down.map((key) => key.color.b), [0, 0, 255], "top to bottom: only the lower row is blue"); +}); diff --git a/src/device/mchose-colors.ts b/src/device/mchose-colors.ts new file mode 100644 index 00000000..3ac0a225 --- /dev/null +++ b/src/device/mchose-colors.ts @@ -0,0 +1,58 @@ +import type { MchoseRgb } from "@openmouse/keyboard-protocol/mchose"; + +/** Per-key colour data as the colour picker's hex string, and back. */ +export function mchoseRgbToHex({ r, g, b }: MchoseRgb): string { + return `#${[r, g, b].map((value) => value.toString(16).padStart(2, "0")).join("")}`; +} + +export function mchoseHexToRgb(hex: string): MchoseRgb { + return { r: parseInt(hex.slice(1, 3), 16) || 0, g: parseInt(hex.slice(3, 5), 16) || 0, b: parseInt(hex.slice(5, 7), 16) || 0 }; +} + +/** Starting colour for the per-key colour picker. */ +export const MCHOSE_DEFAULT_KEY_COLOR = mchoseRgbToHex({ r: 255, g: 255, b: 255 }); + +/** One colour stop of a gradient: `at` runs 0 to 1 along the bar. */ +export interface MchoseGradientStop { + at: number; + color: string; +} + +/** The gradient's colour at `t` (0 to 1), blending the two stops around it. */ +export function mchoseGradientColor(stops: readonly MchoseGradientStop[], t: number): MchoseRgb { + const sorted = [...stops].sort((a, b) => a.at - b.at); + const after = sorted.findIndex((stop) => stop.at >= t); + if (after <= 0) return mchoseHexToRgb((after === 0 ? sorted[0] : sorted[sorted.length - 1])!.color); + const from = sorted[after - 1]!; + const to = sorted[after]!; + const mix = to.at === from.at ? 0 : (t - from.at) / (to.at - from.at); + const a = mchoseHexToRgb(from.color); + const b = mchoseHexToRgb(to.color); + return { r: Math.round(a.r + (b.r - a.r) * mix), g: Math.round(a.g + (b.g - a.g) * mix), b: Math.round(a.b + (b.b - a.b) * mix) }; +} + +/** + * Paint a gradient across keys at `angle` degrees (0 runs left to right, 90 + * top to bottom), stretched so the first and last keys get the end stops. + */ +export function mchoseGradientKeys( + keys: readonly { index: number; row: number; x: number; width: number }[], + stops: readonly MchoseGradientStop[], + angle: number, +): { index: number; color: MchoseRgb }[] { + const rad = (angle * Math.PI) / 180; + // Key units on both axes, so a diagonal follows the keyboard's real shape. + const along = keys.map((key) => (key.x + key.width / 2) * Math.cos(rad) + (key.row + 0.5) * Math.sin(rad)); + const min = Math.min(...along); + const span = Math.max(...along) - min || 1; + return keys.map((key, n) => ({ index: key.index, color: mchoseGradientColor(stops, (along[n]! - min) / span) })); +} + +/** Starting stops for the gradient editor. */ +export const MCHOSE_DEFAULT_GRADIENT: readonly MchoseGradientStop[] = [{ at: 0, color: "#ff2d6f" }, { at: 1, color: "#2dc8ff" }]; + +/** A key's custom colour when it is reset: off, as the keyboard ships. */ +export const MCHOSE_KEY_COLOR_OFF: MchoseRgb = { r: 0, g: 0, b: 0 }; + +/** The keyboard flashes a key blue between its two calibration presses; the panel's dot matches that LED colour. */ +export const MCHOSE_CALIBRATION_PENDING = "#3b82f6"; diff --git a/src/device/mchose-keyboard.ts b/src/device/mchose-keyboard.ts new file mode 100644 index 00000000..917c8467 --- /dev/null +++ b/src/device/mchose-keyboard.ts @@ -0,0 +1,481 @@ +import type { MchoseKeyboardHidClient } from "@openmouse/keyboard-protocol/drivers/mchose/hid"; +import { MCHOSE_CONFIG_USAGE, MCHOSE_CONFIG_USAGE_PAGE, mchoseProduct } from "@openmouse/keyboard-protocol/mchose"; +import type { MchoseAction, MchoseAdvancedTable, MchoseProduct } from "@openmouse/keyboard-protocol/mchose"; +import type { MchoseAdvancedBinding, MchoseWorkspace } from "./mchose-workspace.ts"; +import type { KeyboardClient, KeyboardKeyReport } from "./keyboard-client.ts"; +import type { KeyboardCommand, KeyboardWorkspace } from "./keyboard-workspace.ts"; +import type { KeyboardIdentityView } from "./types.ts"; +import { mchoseKeymapChanges, mchoseTravelRestore } from "./mchose-profile-file.ts"; + +/* + * Only the product catalog is imported up front: this module sits in the main + * bundle for device detection, and everything else loads with the driver. + * These mirror MCHOSE_ACTION_TYPE, ADVANCED_SLOTS and LAYER_COUNT. + */ +const ADVANCED_SLOTS = 32; +const LAYER_COUNT = 4; + +/** Keymap action type and slot table of each advanced-key kind. */ +const ADVANCED: Record = { + dks: { type: 0x90, table: "dks" }, + modTap: { type: 0x92, table: "modTap" }, + toggle: { type: 0x91, table: "toggle" }, +}; + +/** Keymap types that point into each slot table; rapid snap, SOCD and layer tap share the mod-tap slots. */ +const TABLE_TYPES: Record = { + dks: [0x90], + toggle: [0x91], + modTap: [0x92, 0x93, 0x94, 0x95], +}; +/** Rapid snap, SOCD and layer tap: two keys, each pointing at the other in `code2`. */ +const PAIR_TYPES: readonly number[] = [0x93, 0x94, 0x95]; +const PAIR_TYPE = { snapTap: 0x93, socd: 0x94 } as const; +const SOCD_TYPE = 0x94; +const NO_ACTION: MchoseAction = { type: 0, code1: 0, code2: 0 }; + +/** + * Panel-side wrapper around the MCHOSE HID driver. Every write is confirmed + * by the driver's read-back. A timeout or a write the keyboard did not keep + * leaves this client `faulted`, and the panel asks for a reconnect; a + * rejected argument never reaches the keyboard and does not. + */ +export class MchoseKeyboardClient implements KeyboardClient { + readonly family = "mchose"; + readonly brand = "MCHOSE"; + readonly device: HIDDevice; + faulted = false; + /** The HID driver loads only once an MCHOSE keyboard is in use, so it stays out of the main bundle. */ + private loaded: Promise | null = null; + private readonly product: MchoseProduct | undefined; + private current: MchoseWorkspace | null = null; + /** Factory keymap of every layer, for clearing a key back to its default. */ + private factory: MchoseAction[][] = []; + private calibrating = false; + /** Profile whose debug mode (key reports) is on, and how many hold it: a calibration and each live-depth view. */ + private debug: { profile: number; users: number } | null = null; + + constructor(device: HIDDevice) { + this.device = device; + this.product = MchoseKeyboardClient.productFor(device); + } + + /** Same test as the driver's: a catalogued product on its usage-page-1, usage-0 collection. */ + private static productFor(device: HIDDevice): MchoseProduct | undefined { + const product = mchoseProduct(device.vendorId, device.productId); + return product && device.collections.some((c) => c.usagePage === MCHOSE_CONFIG_USAGE_PAGE && c.usage === MCHOSE_CONFIG_USAGE) ? product : undefined; + } + + private driver(): Promise { + this.loaded ??= import("@openmouse/keyboard-protocol/drivers/mchose/hid").then(({ MchoseKeyboardHidClient }) => new MchoseKeyboardHidClient(this.device)); + return this.loaded; + } + + get name(): string { return this.product?.name ?? (this.device.productName || "MCHOSE keyboard"); } + + static isSupported(device: HIDDevice): boolean { return MchoseKeyboardClient.productFor(device) !== undefined; } + async open(): Promise { await (await this.driver()).open(); } + async close(): Promise { + if (!this.loaded) return; + const driver = await this.loaded; + // Never leave the keyboard calibrating, or streaming key reports, after the page lets go of it. + if (!this.faulted) { + if (this.calibrating) await driver.calibrate("end").catch(() => undefined); + if (this.debug) await driver.setDebugMode(this.debug.profile, false).catch(() => undefined); + } + this.calibrating = false; + this.debug = null; + await driver.close(); + } + + /** Turn debug mode on for the first holder and off after the last; it belongs to the profile active at the time. */ + private async holdDebug(on: boolean): Promise { + const driver = await this.driver(); + if (on) { + if (this.debug) { this.debug.users += 1; return; } + this.debug = { profile: this.current?.profile ?? 0, users: 1 }; + await driver.setDebugMode(this.debug.profile, true); + } else if (this.debug && --this.debug.users === 0) { + const { profile } = this.debug; + this.debug = null; + await driver.setDebugMode(profile, false); + } + } + + /** + * Key reports matched to matrix slots through the factory keymap, as the + * vendor app does. With `depth`, debug mode stays on while watching, so + * every key reports its travel as it moves (the vendor's key-travel page). + */ + watchKeys(listener: (report: KeyboardKeyReport) => void, depth = false): () => void { + let stop: (() => void) | null = null; + let stopped = false; + void this.driver().then((driver) => { + if (stopped) return; + stop = driver.onKeyReport((report) => { + const index = (this.factory[0] ?? []).findIndex((a) => a.type === report.type && a.code1 === report.code1 && a.code2 === report.code2); + if (index >= 0) listener({ index, travel: report.travel, calibration: report.calibration }); + }); + if (depth) void this.holdDebug(true).catch(() => undefined); + }); + return () => { + if (!stopped && stop && depth) void this.holdDebug(false).catch(() => undefined); + stopped = true; + stop?.(); + }; + } + + /** What the keyboard is known to hold after a command failed part-way (each write is recorded as it lands). */ + recoveredWorkspace(): Partial | null { + return this.current ? { mchose: this.current } : null; + } + + async readIdentity(): Promise { + const status = await this.guard(async () => (await this.driver()).readStatus()); + const state = status.mchose; + if (state && this.product) { + this.current = { + product: this.product, + info: state.info, + profile: state.base?.activeProfile ?? 0, + profileCount: state.base?.profileCount ?? 1, + profileNames: state.profileNames ?? [], + layout: state.layout ?? (await import("@openmouse/keyboard-protocol/mchose")).mchoseModel(this.product.productId)!.layout, + performance: state.performance, + settings: state.settings ?? null, + lighting: state.lighting ?? null, + travel: state.travel ?? [], + keymap: [], + defaults: [], + advanced: null, + macros: null, + colors: null, + }; + } + return { + brand: status.brand, name: status.name, boardId: null, capabilities: null, + firmwareVersion: status.firmwareVersion, appVersion: null, pcbVersion: null, serial: null, + }; + } + + /** Everything the identity read leaves out: keymaps, advanced keys, macros and colours. */ + private async readTables(profile: number): Promise> { + const keymap: MchoseAction[][] = []; + for (let layer = 0; layer < LAYER_COUNT; layer += 1) keymap.push(await (await this.driver()).readKeymap(profile, layer)); + if (this.factory.length === 0) { + for (let layer = 0; layer < LAYER_COUNT; layer += 1) this.factory.push(await (await this.driver()).readDefaultKeymap(layer)); + } + return { + keymap, + defaults: this.factory, + advanced: await (await this.driver()).readAdvancedKeys(profile), + macros: await (await this.driver()).readMacros(), + colors: await (await this.driver()).readKeyColors(profile), + }; + } + + async command(command: KeyboardCommand): Promise> { + return this.guard(async () => { + switch (command.kind) { + case "refresh": { + // The identity read walked the profile settings on connect; a manual refresh reads them again. + if (this.current?.keymap.length) await this.readIdentity(); + if (!this.current) await this.readIdentity(); + return { mchose: this.patch(await this.readTables(this.require().profile)) }; + } + case "mchoseReset": { + const { profile, profileCount } = this.require(); + const driver = await this.driver(); + // Names live in the custom area, which the reset keeps; the vendor app clears them, so do that first. + const names = command.scope === "all" ? Array.from({ length: profileCount }, (_, n) => n) : [profile]; + for (const n of names) if (this.current?.profileNames[n]) this.patch({ profileNames: await driver.setProfileName(n, "") }); + await driver.factoryReset(command.scope === "all" ? "all" : profile); + // The keyboard reboots and comes back as a new device; the controller reconnects it. + this.debug = null; + this.calibrating = false; + this.faulted = true; + return {}; + } + case "mchoseTravel": { + const { profile } = this.require(); + return { mchose: this.patch({ travel: await (await this.driver()).setKeyTravel(profile, command.updates) }) }; + } + case "mchosePolling": { + const { profile } = this.require(); + return { mchose: this.patch({ performance: await (await this.driver()).setPollingRate(profile, command.hz) }) }; + } + case "mchoseWinLock": { + const { profile, settings } = this.require(); + const performance = await (await this.driver()).setWinLock(profile, command.locked); + return { mchose: this.patch({ performance, settings: settings && { ...settings, lockWin: performance.lockWin } }) }; + } + case "mchoseSettings": { + const { profile } = this.require(); + const settings = await (await this.driver()).setSettings(profile, command.settings); + return { mchose: this.patch({ settings, performance: await (await this.driver()).readPerformance(profile) }) }; + } + case "mchoseLighting": { + const { profile } = this.require(); + return { mchose: this.patch({ lighting: await (await this.driver()).setLighting(profile, command.lighting) }) }; + } + case "mchoseKeyColors": { + const { profile } = this.require(); + return { mchose: this.patch({ colors: await (await this.driver()).setKeyColors(profile, command.updates) }) }; + } + case "mchoseImport": { + const { file } = command; + const state = this.require(); + const { profile } = state; + const driver = await this.driver(); + if (file.productId !== state.product.productId) throw new RangeError("This profile file is for another keyboard."); + // Settings and lighting first, then travel, then the advanced-key slots the new keymap points at, then the keymap. + // Each write is recorded as it lands, so a failure part-way leaves the panel showing what the keyboard holds. + this.patch({ settings: await driver.setSettings(profile, file.settings) }); + if (file.pollingHz && state.product.pollingRatesHz.includes(file.pollingHz)) this.patch({ performance: await driver.setPollingRate(profile, file.pollingHz) }); + this.patch({ lighting: await driver.setLighting(profile, file.lighting) }); + this.patch({ travel: await driver.setKeyTravel(profile, state.layout.map(({ index }) => mchoseTravelRestore(index, file.travel[index]!))) }); + const slots = (types: readonly number[]): Set => new Set(file.keymap.flat().filter((action) => types.includes(action.type)).map((action) => action.code1)); + for (const slot of slots(TABLE_TYPES.dks)) if (file.advanced.dks[slot]) await driver.setDks(profile, slot, file.advanced.dks[slot]!); + for (const slot of slots(TABLE_TYPES.modTap)) if (file.advanced.modTap[slot]) await driver.setModTap(profile, slot, file.advanced.modTap[slot]!); + for (const slot of slots(TABLE_TYPES.toggle)) if (file.advanced.toggle[slot]) await driver.setToggle(profile, slot, file.advanced.toggle[slot]!); + const before = state.keymap; + const changes = mchoseKeymapChanges(before, file.keymap); + for (let layer = 0; layer < changes.length; layer += 1) { + if (changes[layer]!.length) this.patch({ keymap: this.withLayer(layer, await driver.setKeymap(profile, layer, changes[layer]!)) }); + } + this.patch({ performance: await driver.readPerformance(profile) }); + await this.releaseUnusedSlots(before); + const colors = state.colors ?? []; + const colorUpdates = file.colors.flatMap((color, index) => { + const now = colors[index]; + return now && now.r === color.r && now.g === color.g && now.b === color.b ? [] : [{ index, color }]; + }); + this.patch({ + advanced: await driver.readAdvancedKeys(profile), + ...(colorUpdates.length ? { colors: await driver.setKeyColors(profile, colorUpdates) } : {}), + ...(file.name && file.name !== state.profileNames[profile] ? { profileNames: await driver.setProfileName(profile, file.name) } : {}), + }); + return { mchose: this.require() }; + } + case "mchoseKeymap": { + const { profile } = this.require(); + const before = this.require().keymap; + // Replacing one key of a pair also returns its partner to its factory action. + const updates = this.withPartnerRestores(command.layer, command.updates); + await this.resetSocdPriority(command.layer, updates.map((update) => update.index)); + const layer = await (await this.driver()).setKeymap(profile, command.layer, updates); + const keymap = this.withLayer(command.layer, layer); + this.patch({ keymap }); + await this.releaseUnusedSlots(before); + return { mchose: this.require() }; + } + case "mchoseMacros": + return { mchose: this.patch({ macros: await (await this.driver()).setMacros(command.macros) }) }; + case "mchoseAdvanced": + return { mchose: await this.bindAdvanced(command.layer, command.index, command.binding) }; + case "mchosePair": + return { mchose: await this.bindPair(command.layer, command.first, command.second, command.pair, command.priority ?? 0) }; + case "mchoseAdvancedClear": { + const action = this.factoryAction(command.layer, command.index); + return this.command({ kind: "mchoseKeymap", layer: command.layer, updates: [{ index: command.index, action }] }); + } + case "mchoseProfile": { + const base = await (await this.driver()).setActiveProfile(command.profile); + const profile = base.activeProfile; + // Debug mode lives in each profile's config: move it along so key reports keep coming. + if (this.debug && this.debug.profile !== profile) { + await (await this.driver()).setDebugMode(this.debug.profile, false); + this.debug.profile = profile; + await (await this.driver()).setDebugMode(profile, true); + } + this.patch({ + profile, + profileCount: base.profileCount, + performance: await (await this.driver()).readPerformance(profile), + settings: await (await this.driver()).readSettings(profile), + lighting: await (await this.driver()).readLighting(profile), + travel: await (await this.driver()).readKeyTravel(profile), + }); + return { mchose: this.patch(await this.readTables(profile)) }; + } + case "mchoseProfileName": + return { mchose: this.patch({ profileNames: await (await this.driver()).setProfileName(command.profile, command.name) }) }; + case "mchoseCalibrate": { + // Like the vendor app, calibration runs with debug mode on so each key reports its progress. + const driver = await this.driver(); + if (command.step === "start") { + if (!this.calibrating) await this.holdDebug(true); + this.calibrating = true; + await driver.calibrate("start"); + } else if (command.step === "end") { + await driver.calibrate("end"); + if (this.calibrating) await this.holdDebug(false); + this.calibrating = false; + } else await driver.calibrate("restart"); + return {}; + } + default: + throw new Error("This command is not available on MCHOSE keyboards."); + } + }); + } + + private withLayer(layer: number, actions: readonly MchoseAction[]): MchoseAction[][] { + return this.require().keymap.map((entries, index) => (index === layer ? [...actions] : [...entries])); + } + + private factoryAction(layer: number, index: number): MchoseAction { + return this.factory[layer]?.[index] ?? NO_ACTION; + } + + /** Slots of `table` some key of this profile still points at. */ + private usedSlots(table: MchoseAdvancedTable, keymap: readonly (readonly MchoseAction[])[] = this.require().keymap): Set { + const types = TABLE_TYPES[table]; + return new Set(keymap.flat().filter((action) => types.includes(action.type)).map((action) => action.code1)); + } + + /** `updates` plus a factory restore for the partner of every pair key they replace. */ + private withPartnerRestores(layer: number, updates: readonly { index: number; action: MchoseAction }[]): { index: number; action: MchoseAction }[] { + const actions = this.require().keymap[layer] ?? []; + const next = [...updates]; + for (const { index } of updates) { + const current = actions[index]; + if (!current || !PAIR_TYPES.includes(current.type)) continue; + const partner = current.code2; + const partnerAction = actions[partner]; + if (partnerAction && partnerAction.type === current.type && partnerAction.code2 === index && !next.some((u) => u.index === partner)) { + next.push({ index: partner, action: this.factoryAction(layer, partner) }); + } + } + return next; + } + + /** SOCD keys keep their rule in the travel priority; clear it when they stop being SOCD keys. */ + private async resetSocdPriority(layer: number, indexes: readonly number[]): Promise { + const { profile, keymap, travel } = this.require(); + const socd = indexes.filter((index) => keymap[layer]?.[index]?.type === SOCD_TYPE && (travel[index]?.priority ?? 0) !== 0); + if (socd.length === 0) return; + this.patch({ travel: await (await this.driver()).setKeyTravel(profile, socd.map((index) => ({ index, priority: 0 }))) }); + } + + /** What a key sends inside a pair: its own mapping when it is a plain action, else its factory action. */ + private sendAction(layer: number, index: number): MchoseAction { + const state = this.require(); + const current = state.keymap[layer]?.[index]; + if (current && current.type !== 0 && current.type < 0x90) return current; + // A key already in a pair keeps the tap stored in its mod-tap slot. + const stored = current && PAIR_TYPES.includes(current.type) ? state.advanced?.modTap[current.code1]?.tap : undefined; + if (stored && stored.type !== 0) return stored; + const factory = this.factoryAction(layer, index); + return factory.type !== 0 ? factory : this.factoryAction(0, index); + } + + /** + * Pair two keys as rapid snap or SOCD. Each key gets a mod-tap slot holding + * both keys' actions (its own first) and points at its partner. SOCD keys + * also carry the rule in their travel priority, mirrored on the second key. + */ + private async bindPair(layer: number, first: number, second: number, pair: "snapTap" | "socd", priority: number): Promise { + const state = this.require(); + const actions = state.keymap[layer] ?? []; + if (first === second || !actions[first] || !actions[second]) throw new RangeError("Choose two different keys."); + if (pair === "socd" && (!Number.isInteger(priority) || priority < 0 || priority > 3)) throw new RangeError("SOCD rule must be 0-3."); + const type = PAIR_TYPE[pair]; + const key1 = this.sendAction(layer, first); + const key2 = this.sendAction(layer, second); + // Keys that leave an older pair free their partners; a pair rebound as itself keeps its slots. + const updates = this.withPartnerRestores(layer, [{ index: first, action: NO_ACTION }, { index: second, action: NO_ACTION }]); + const samePair = actions[first]!.type === type && actions[first]!.code2 === second && actions[second]!.type === type && actions[second]!.code2 === first; + const freed = new Set(updates.map((u) => u.index)); + const remaining = state.keymap.map((entries, n) => (n === layer ? entries.map((a, i) => (freed.has(i) ? NO_ACTION : a)) : entries)); + const used = this.usedSlots("modTap", remaining); + const free = Array.from({ length: ADVANCED_SLOTS }, (_, n) => n).filter((n) => !used.has(n)); + const [slotA, slotB] = samePair ? [actions[first]!.code1, actions[second]!.code1] : [free[0], free[1]]; + if (slotA === undefined || slotB === undefined) throw new RangeError(`Rapid snap, SOCD and mod-tap share ${ADVANCED_SLOTS} slots; two free slots are needed.`); + const driver = await this.driver(); + // Partners of older pairs lose their SOCD rule before their keymap entry changes. + await this.resetSocdPriority(layer, updates.map((u) => u.index).filter((i) => i !== first && i !== second)); + await driver.setModTap(state.profile, slotA, { tap: key1, hold: key2 }); + await driver.setModTap(state.profile, slotB, { tap: key2, hold: key1 }); + const before = state.keymap; + const pairUpdates = updates.map((u) => (u.index === first ? { index: first, action: { type, code1: slotA, code2: second } } + : u.index === second ? { index: second, action: { type, code1: slotB, code2: first } } : u)); + const layerActions = await driver.setKeymap(state.profile, layer, pairUpdates); + this.patch({ keymap: this.withLayer(layer, layerActions) }); + const travel = this.require().travel; + const priorities = pair === "socd" + ? [{ index: first, priority }, { index: second, priority: priority === 1 ? 2 : priority === 2 ? 1 : priority }] + : [first, second].filter((i) => (travel[i]?.priority ?? 0) !== 0 && before[layer]?.[i]?.type === SOCD_TYPE).map((index) => ({ index, priority: 0 })); + if (priorities.length > 0) this.patch({ travel: await driver.setKeyTravel(state.profile, priorities) }); + await this.releaseUnusedSlots(before); + return this.patch({ advanced: await driver.readAdvancedKeys(state.profile) }); + } + + /** + * Store the advanced key in a slot, then point the key at it. A key that + * already uses this kind keeps its slot; otherwise the first slot no key + * uses is taken. A slot the key leaves behind is cleared once nothing uses it. + */ + private async bindAdvanced(layer: number, index: number, binding: MchoseAdvancedBinding): Promise { + const state = this.require(); + const { type, table } = ADVANCED[binding.kind]; + const currentAction = state.keymap[layer]?.[index]; + if (!currentAction) throw new RangeError(`Key ${index} on layer ${layer} is outside the keymap.`); + const used = this.usedSlots(table); + // Keep the key's own slot only when it is valid and no other key points at it too. + const sharers = state.keymap.flat().filter((action) => TABLE_TYPES[table].includes(action.type) && action.code1 === currentAction.code1).length; + let slot = currentAction.type === type && currentAction.code1 < ADVANCED_SLOTS && sharers === 1 ? currentAction.code1 : -1; + if (slot < 0) slot = Array.from({ length: ADVANCED_SLOTS }, (_, n) => n).find((n) => !used.has(n)) ?? -1; + if (slot < 0) throw new RangeError(`All ${ADVANCED_SLOTS} ${table} slots are in use.`); + let code2 = 0; + if (binding.kind === "dks") await (await this.driver()).setDks(state.profile, slot, binding.dks); + if (binding.kind === "toggle") await (await this.driver()).setToggle(state.profile, slot, binding.action); + if (binding.kind === "modTap") { + code2 = Math.round(binding.holdMs / 10); + if (code2 < 1 || code2 > 255) throw new RangeError("Hold time must be 10-2550 ms."); + await (await this.driver()).setModTap(state.profile, slot, binding.entry); + } + const before = state.keymap; + const updates = this.withPartnerRestores(layer, [{ index, action: { type, code1: slot, code2 } }]); + await this.resetSocdPriority(layer, updates.map((u) => u.index)); + const layerActions = await (await this.driver()).setKeymap(state.profile, layer, updates); + this.patch({ keymap: this.withLayer(layer, layerActions) }); + await this.releaseUnusedSlots(before); + return this.patch({ advanced: await (await this.driver()).readAdvancedKeys(state.profile) }); + } + + /** Clear advanced-key slots that `before` used and the current keymap no longer does. */ + private async releaseUnusedSlots(before: readonly (readonly MchoseAction[])[]): Promise { + const { profile } = this.require(); + let changed = false; + for (const table of Object.keys(TABLE_TYPES) as MchoseAdvancedTable[]) { + const now = this.usedSlots(table); + for (const slot of this.usedSlots(table, before)) { + if (now.has(slot) || slot >= ADVANCED_SLOTS) continue; + await (await this.driver()).clearSlot(profile, table, slot); + changed = true; + } + } + if (changed) this.patch({ advanced: await (await this.driver()).readAdvancedKeys(profile) }); + } + + private require(): MchoseWorkspace { + if (!this.current) throw new Error("Read the keyboard before changing it."); + return this.current; + } + + private patch(update: Partial): MchoseWorkspace { + this.current = { ...this.require(), ...update }; + return this.current; + } + + private async guard(run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (!(error instanceof RangeError)) this.faulted = true; + throw error; + } + } +} + diff --git a/src/device/mchose-profile-file.test.ts b/src/device/mchose-profile-file.test.ts new file mode 100644 index 00000000..c8153150 --- /dev/null +++ b/src/device/mchose-profile-file.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { MCHOSE_ACE68_LAYOUT, MCHOSE_PRODUCTS, decodeMchoseDks, decodeMchoseKeyTravel } from "@openmouse/keyboard-protocol/mchose"; +import type { MchoseWorkspace } from "./mchose-workspace.ts"; +import { mchoseKeymapChanges, mchoseProfileFile, mchoseProfileFileName, mchoseTravelRestore, parseMchoseProfileFile } from "./mchose-profile-file.ts"; + +const none = { type: 0, code1: 0, code2: 0 }; +const workspace = (): MchoseWorkspace => ({ + product: MCHOSE_PRODUCTS[0]!, + info: { firmwareVersion: "116", buildDate: "", chip: "" }, + profile: 1, + profileCount: 3, + profileNames: [null, "Games", null], + layout: MCHOSE_ACE68_LAYOUT, + performance: null, + settings: { + macMode: false, lockWin: false, lockAltTab: false, lockAltF4: false, scanRateRaw: 0, tachyonMode: false, + bottomRapidTrigger: true, travelCheck: true, debounce: false, rtSmart: 5, sleepTime: 12, sleepEnabled: true, + }, + lighting: { + main: { effect: 6, brightness: 80, speed: 2, direction: 0, rainbow: true, color: { r: 0, g: 200, b: 160 } }, + side: { effect: 2, brightness: 100, speed: 2, rainbow: false, color: { r: 255, g: 255, b: 255 } }, + }, + travel: Array.from({ length: 128 }, () => decodeMchoseKeyTravel([0xa0, 0x01, 0x1e, 0x00, 0x09, 0x1e, 0x0c, 0x1e])), + keymap: Array.from({ length: 4 }, () => Array.from({ length: 128 }, () => ({ ...none }))), + defaults: [], + advanced: { + dks: Array.from({ length: 32 }, () => decodeMchoseDks(new Array(24).fill(0))), + modTap: Array.from({ length: 32 }, () => ({ tap: { ...none }, hold: { ...none } })), + toggle: Array.from({ length: 32 }, () => ({ ...none })), + }, + macros: null, + colors: Array.from({ length: 128 }, () => ({ r: 1, g: 2, b: 3 })), +}); + +test("a saved profile reads back for the same keyboard only", () => { + const file = mchoseProfileFile(workspace())!; + assert.equal(file.name, "Games"); + assert.equal(mchoseProfileFileName(file, "Profile 2"), "ace-68-air-iii-games.json"); + const text = JSON.stringify(file); + assert.deepEqual(parseMchoseProfileFile(text, file.productId), file); + assert.throws(() => parseMchoseProfileFile(text, 0x1234), { message: "other-product" }); + assert.throws(() => parseMchoseProfileFile("{}", file.productId), { message: "not-profile" }); + assert.throws(() => parseMchoseProfileFile("not json", file.productId), { message: "not-profile" }); + assert.throws(() => parseMchoseProfileFile(JSON.stringify({ ...file, travel: [] }), file.productId), { message: "not-profile" }); + assert.equal(mchoseProfileFile({ ...workspace(), settings: null }), null, "nothing to save before the profile is read"); +}); + +test("restoring writes only what differs and every travel field", () => { + const current = workspace().keymap; + const target = current.map((layer) => layer.map((action) => ({ ...action }))); + target[2]![5] = { type: 0x10, code1: 1, code2: 6 }; + assert.deepEqual(mchoseKeymapChanges(current, target), [[], [], [{ index: 5, action: { type: 0x10, code1: 1, code2: 6 } }], []]); + const key = workspace().travel[0]!; + assert.deepEqual(mchoseTravelRestore(7, key), { + index: 7, pressPrecision: key.pressPrecision, releasePrecision: key.releasePrecision, actuation: key.actuation, rapidTrigger: key.keyMode === 1, + rapidTriggerPress: key.rapidTriggerPress, rapidTriggerRelease: key.rapidTriggerRelease, pressDeadzone: key.pressDeadzone, + releaseDeadzone: key.releaseDeadzone, switchType: key.switchType, priority: key.priority, + }); +}); + +test("only complete profiles are saved, and damaged entries are refused before writing", () => { + const short = workspace(); + assert.equal(mchoseProfileFile({ ...short, travel: short.travel.slice(0, 64) }), null); + assert.equal(mchoseProfileFile({ ...short, keymap: short.keymap.map((layer) => layer.slice(0, 64)) }), null); + const file = mchoseProfileFile(workspace())!; + const damaged = (patch: (copy: any) => void): string => { const copy = JSON.parse(JSON.stringify(file)); patch(copy); return JSON.stringify(copy); }; + assert.throws(() => parseMchoseProfileFile(damaged((copy) => { copy.keymap[1][3] = null; }), file.productId), { message: "not-profile" }); + assert.throws(() => parseMchoseProfileFile(damaged((copy) => { copy.keymap[0][0] = { type: "16", code1: 0, code2: 4 }; }), file.productId), { message: "not-profile" }); + assert.throws(() => parseMchoseProfileFile(damaged((copy) => { copy.travel[7] = null; }), file.productId), { message: "not-profile" }); + assert.throws(() => parseMchoseProfileFile(damaged((copy) => { copy.colors[2] = null; }), file.productId), { message: "not-profile" }); + assert.throws(() => parseMchoseProfileFile(damaged((copy) => { copy.travel[7] = {}; }), file.productId), { message: "not-profile" }); + assert.throws(() => parseMchoseProfileFile(damaged((copy) => { copy.keymap[0][5] = { type: 0x90, code1: 2, code2: 0 }; copy.advanced.dks[2] = null; }), file.productId), { message: "not-profile" }); + assert.equal(parseMchoseProfileFile(damaged((copy) => { copy.advanced.dks[2] = null; }), file.productId).advanced.dks[2], null, "an empty slot nothing points at is fine"); + assert.throws(() => parseMchoseProfileFile(damaged((copy) => { copy.keymap[0][5] = { type: 0x92, code1: 1, code2: 20 }; copy.advanced.modTap[1] = {}; }), file.productId), { message: "not-profile" }); + assert.throws(() => parseMchoseProfileFile(damaged((copy) => { copy.keymap[0][5] = { type: 0x92, code1: 1, code2: 20 }; copy.advanced.modTap[1] = { tap: { type: 0x10, code1: 0, code2: 4 } }; }), file.productId), { message: "not-profile" }, "hold missing"); + assert.throws(() => parseMchoseProfileFile(damaged((copy) => { copy.keymap[0][5] = { type: 0x92, code1: 1, code2: 20 }; copy.advanced.modTap[1] = { tap: { type: "16" }, hold: { type: 0x10, code1: 2, code2: 0 } }; }), file.productId), { message: "not-profile" }, "tap malformed"); + assert.throws(() => parseMchoseProfileFile(damaged((copy) => { copy.keymap[0][5] = { type: 0x91, code1: 0, code2: 0 }; copy.advanced.toggle[0] = {}; }), file.productId), { message: "not-profile" }); + assert.equal(mchoseProfileFile({ ...workspace(), advanced: { ...workspace().advanced!, dks: [] } }), null, "advanced tables must be complete to save"); +}); diff --git a/src/device/mchose-profile-file.ts b/src/device/mchose-profile-file.ts new file mode 100644 index 00000000..ee51846b --- /dev/null +++ b/src/device/mchose-profile-file.ts @@ -0,0 +1,132 @@ +import type { + MchoseAction, + MchoseAdvancedKeys, + MchoseKeyTravel, + MchoseKeyTravelUpdate, + MchoseLighting, + MchoseRgb, + MchoseSettings, +} from "@openmouse/keyboard-protocol/mchose"; +import type { MchoseWorkspace } from "./mchose-workspace.ts"; + +/** + * One MCHOSE onboard profile saved to a file: everything the profile holds on + * the keyboard. Macros are shared by every profile, so they are left out. + */ +export interface MchoseProfileFile { + format: typeof MCHOSE_PROFILE_FORMAT; + version: 1; + product: string; + productId: number; + firmware: string; + name: string | null; + pollingHz: number | null; + settings: MchoseSettings; + lighting: MchoseLighting; + /** Key-travel records by matrix slot, in the firmware's own steps. */ + travel: MchoseKeyTravel[]; + /** Four layers of 128 matrix slots. */ + keymap: MchoseAction[][]; + advanced: MchoseAdvancedKeys; + colors: MchoseRgb[]; +} + +export const MCHOSE_PROFILE_FORMAT = "openmouse.mchose-profile"; + +/** The file for the workspace's active profile, or null while part of it is still unread. */ +export function mchoseProfileFile(state: MchoseWorkspace): MchoseProfileFile | null { + const { settings, lighting, advanced, colors } = state; + // The same table sizes the parser accepts, so a saved file always loads back. + const complete = state.travel.length === 128 && colors?.length === 128 && state.keymap.length === 4 && state.keymap.every((layer) => layer.length === 128) + && advanced?.dks.length === 32 && advanced.modTap.length === 32 && advanced.toggle.length === 32; + if (!settings || !lighting || !advanced || !colors || !complete) return null; + return { + format: MCHOSE_PROFILE_FORMAT, + version: 1, + product: state.product.name, + productId: state.product.productId, + firmware: state.info.firmwareVersion, + name: state.profileNames[state.profile] ?? null, + pollingHz: state.performance?.pollingHz ?? null, + settings, + lighting, + travel: [...state.travel], + keymap: state.keymap.map((layer) => [...layer]), + advanced, + colors: [...colors], + }; +} + +/** + * Parse a profile file for this product. Only the shape is checked here; the + * values go through the protocol's own range checks when they are written. + */ +export function parseMchoseProfileFile(text: string, productId: number): MchoseProfileFile { + let data: unknown; + try { + data = JSON.parse(text); + } catch { + throw new RangeError("not-profile"); + } + const file = data as Partial | null; + if (!file || file.format !== MCHOSE_PROFILE_FORMAT || file.version !== 1) throw new RangeError("not-profile"); + if (file.productId !== productId) throw new RangeError("other-product"); + const isList = (value: unknown, length: number): boolean => Array.isArray(value) && value.length === length; + const isObject = (value: unknown): boolean => typeof value === "object" && value !== null; + const isAction = (value: unknown): boolean => isObject(value) + && ["type", "code1", "code2"].every((field) => typeof (value as Record)[field] === "number"); + const isNumbers = (value: unknown, fields: readonly string[]): boolean => isObject(value) + && fields.every((field) => typeof (value as Record)[field] === "number"); + const TRAVEL_FIELDS = ["pressPrecision", "releasePrecision", "actuation", "keyMode", "rapidTriggerPress", "rapidTriggerRelease", "pressDeadzone", "releaseDeadzone", "switchType", "priority"]; + const tables = file.advanced; + // Every advanced-key slot the keymap points at must be in the file, or the key would lose its binding. + const bound = (action: MchoseAction): boolean => { + const table = action.type === 0x90 ? tables?.dks : action.type === 0x91 ? tables?.toggle : action.type >= 0x92 && action.type <= 0x95 ? tables?.modTap : null; + const entry = table?.[action.code1] as Record | null | undefined; + if (!table) return true; + // The entry the key points at must be complete for its kind, not just present. + if (action.type === 0x90) return isObject(entry) && Array.isArray(entry!.points) && Array.isArray(entry!.actions); + if (action.type === 0x91) return isAction(entry); + return isObject(entry) && isAction(entry!.tap) && isAction(entry!.hold); + }; + const complete = typeof file.settings === "object" && file.settings !== null + && typeof file.lighting === "object" && file.lighting !== null + && isList(file.travel, 128) && isList(file.colors, 128) + && isList(file.keymap, 4) && file.keymap!.every((layer) => isList(layer, 128)) + && !!tables && isList(tables.dks, 32) && isList(tables.modTap, 32) && isList(tables.toggle, 32) + // Entries too, so a damaged file is refused before the first write rather than part-way through. + && file.keymap!.every((layer) => layer.every((action) => isAction(action) && bound(action))) + && file.travel!.every((key) => isNumbers(key, TRAVEL_FIELDS)) && file.colors!.every((color) => isNumbers(color, ["r", "g", "b"])); + if (!complete) throw new RangeError("not-profile"); + return file as MchoseProfileFile; +} + +/** A travel record as the update that writes it back. */ +export function mchoseTravelRestore(index: number, key: MchoseKeyTravel): MchoseKeyTravelUpdate { + return { + index, + pressPrecision: key.pressPrecision, + releasePrecision: key.releasePrecision, + actuation: key.actuation, + rapidTrigger: key.keyMode === 1, + rapidTriggerPress: key.rapidTriggerPress, + rapidTriggerRelease: key.rapidTriggerRelease, + pressDeadzone: key.pressDeadzone, + releaseDeadzone: key.releaseDeadzone, + switchType: key.switchType, + priority: key.priority, + }; +} + +const sameAction = (a: MchoseAction | undefined, b: MchoseAction): boolean => !!a && a.type === b.type && a.code1 === b.code1 && a.code2 === b.code2; + +/** Keymap updates per layer that turn `current` into `target`, skipping slots that already match. */ +export function mchoseKeymapChanges(current: readonly (readonly MchoseAction[])[], target: readonly (readonly MchoseAction[])[]): { index: number; action: MchoseAction }[][] { + return target.map((layer, n) => layer.flatMap((action, index) => (sameAction(current[n]?.[index], action) ? [] : [{ index, action }]))); +} + +/** A name for the downloaded file, from the product and profile names. */ +export function mchoseProfileFileName(file: MchoseProfileFile, fallback: string): string { + const slug = (text: string): string => text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + return `${slug(file.product) || "mchose"}-${slug(file.name ?? "") || slug(fallback) || "profile"}.json`; +} diff --git a/src/device/mchose-workspace.ts b/src/device/mchose-workspace.ts new file mode 100644 index 00000000..b426c8d7 --- /dev/null +++ b/src/device/mchose-workspace.ts @@ -0,0 +1,70 @@ +import type { + MchoseAction, + MchoseAdvancedKeys, + MchoseDks, + MchoseInfo, + MchoseKeyTravel, + MchoseKeyTravelUpdate, + MchoseLayoutKey, + MchoseLighting, + MchoseMacro, + MchoseModTap, + MchosePerformance, + MchoseProduct, + MchoseRgb, + MchoseSettings, +} from "@openmouse/keyboard-protocol/mchose"; +import type { MchoseProfileFile } from "./mchose-profile-file.ts"; + +/** Everything the MCHOSE panel shows, as the keyboard last confirmed it. */ +export interface MchoseWorkspace { + product: MchoseProduct; + info: MchoseInfo; + /** Active on-board profile; everything per-profile below belongs to it. */ + profile: number; + profileCount: number; + /** Names as stored by the vendor app, by slot. */ + profileNames: readonly (string | null)[]; + /** Keys with matrix slots from this keyboard's default keymap. */ + layout: readonly MchoseLayoutKey[]; + performance: MchosePerformance | null; + settings: MchoseSettings | null; + lighting: MchoseLighting | null; + /** Key-travel records by matrix slot (128), in the firmware's own steps. */ + travel: readonly MchoseKeyTravel[]; + /** Keymap by layer (4), then matrix slot. */ + keymap: readonly (readonly MchoseAction[])[]; + /** Factory keymap by layer, for "restore" and keycap labels. */ + defaults: readonly (readonly MchoseAction[])[]; + advanced: MchoseAdvancedKeys | null; + /** Shared by every profile. */ + macros: readonly MchoseMacro[] | null; + /** Per-key colours of the Custom effect, by matrix slot. */ + colors: readonly MchoseRgb[] | null; +} + +export type MchoseAdvancedBinding = + | { kind: "dks"; dks: MchoseDks } + | { kind: "modTap"; entry: MchoseModTap; holdMs: number } + | { kind: "toggle"; action: MchoseAction }; + +export type MchoseCommand = + | { kind: "mchoseTravel"; updates: readonly MchoseKeyTravelUpdate[] } + | { kind: "mchosePolling"; hz: number } + | { kind: "mchoseWinLock"; locked: boolean } + | { kind: "mchoseProfile"; profile: number } + | { kind: "mchoseSettings"; settings: MchoseSettings } + | { kind: "mchoseLighting"; lighting: MchoseLighting } + | { kind: "mchoseKeyColors"; updates: readonly { index: number; color: MchoseRgb }[] } + | { kind: "mchoseKeymap"; layer: number; updates: readonly { index: number; action: MchoseAction }[] } + | { kind: "mchoseMacros"; macros: readonly MchoseMacro[] } + | { kind: "mchoseAdvanced"; layer: number; index: number; binding: MchoseAdvancedBinding } + | { kind: "mchoseAdvancedClear"; layer: number; index: number } + /** Pair two keys as rapid snap or SOCD; `priority` is the SOCD rule (0-3). */ + | { kind: "mchosePair"; layer: number; first: number; second: number; pair: "snapTap" | "socd"; priority?: number } + | { kind: "mchoseProfileName"; profile: number; name: string } + /** Replace everything the active profile holds with a saved profile file. */ + | { kind: "mchoseImport"; file: MchoseProfileFile } + /** The vendor app's factory reset: the active profile, or the whole keyboard. */ + | { kind: "mchoseReset"; scope: "profile" | "all" } + | { kind: "mchoseCalibrate"; step: "start" | "end" | "restart" }; diff --git a/src/device/preview/mchose-preview.ts b/src/device/preview/mchose-preview.ts new file mode 100644 index 00000000..28bf0333 --- /dev/null +++ b/src/device/preview/mchose-preview.ts @@ -0,0 +1,193 @@ +import { + MCHOSE_ACE68_LAYOUT, + MCHOSE_ACTION_TYPE, + MCHOSE_LAYER_COUNT, + MCHOSE_PRODUCTS, + applyMchoseKeyTravelUpdate, + decodeMchoseKeyTravel, + decodeMchoseDks, +} from "@openmouse/keyboard-protocol/mchose"; +import type { MchoseAction, MchoseKeyTravel } from "@openmouse/keyboard-protocol/mchose"; +import type { MchoseWorkspace } from "../mchose-workspace.ts"; +import type { KeyboardClient } from "../keyboard-client.ts"; +import type { KeyboardCommand, KeyboardWorkspace } from "../keyboard-workspace.ts"; +import type { KeyboardIdentityView } from "../types.ts"; + +/** + * Preview stand-in for the MCHOSE Ace 68 Air. Everything here is invented to + * show the controls: no keyboard was read, and nothing is ever written to a device. + */ +export const MCHOSE_PREVIEW_LABEL = "Ace 68 Air III (preview data)"; + +function travelTable(): MchoseKeyTravel[] { + // Factory-looking records: 1.20 mm actuation, rapid trigger off. + const base = decodeMchoseKeyTravel([0xa0, 0x00, 119, 0x00, 0x09, 0x00, 0x09, 0x00]); + const travel = Array.from({ length: 128 }, () => ({ ...base })); + for (const key of MCHOSE_ACE68_LAYOUT) { + if (["W", "A", "S", "D"].includes(key.label)) travel[key.index] = { ...base, keyMode: 1, actuation: 30, rapidTriggerPress: 10, rapidTriggerRelease: 13, pressDeadzone: 15, releaseDeadzone: 15 }; + if (key.label === "Caps") travel[key.index] = { ...base, actuation: 250 }; + } + return travel; +} + +/** Each key sends its own usage on the base layers; Fn layers start empty. */ +function keymap(): MchoseAction[][] { + const base: MchoseAction[] = Array.from({ length: 128 }, () => ({ type: 0, code1: 0, code2: 0 })); + for (const key of MCHOSE_ACE68_LAYOUT) { + base[key.index] = key.usage === 0xff ? { type: MCHOSE_ACTION_TYPE.special, code1: 0xff, code2: 1 } + : key.usage >= 0xe0 ? { type: MCHOSE_ACTION_TYPE.key, code1: 1 << (key.usage - 0xe0), code2: 0 } + : { type: MCHOSE_ACTION_TYPE.key, code1: 0, code2: key.usage }; + } + // The Mac layers swap Win and Alt, as on the keyboard. + const mac = base.map((a) => (a.type === MCHOSE_ACTION_TYPE.key && (a.code1 & 0xcc) ? { ...a, code1: a.code1 & 0x44 ? a.code1 << 1 : a.code1 >> 1 } : { ...a })); + return Array.from({ length: MCHOSE_LAYER_COUNT }, (_, layer) => (layer % 2 === 1 ? base.map(() => ({ type: 0, code1: 0, code2: 0 })) : layer ? mac : base.map((a) => ({ ...a })))); +} + +const emptyTables = () => ({ + dks: Array.from({ length: 32 }, () => decodeMchoseDks(new Array(24).fill(0))), + modTap: Array.from({ length: 32 }, () => ({ tap: { type: 0, code1: 0, code2: 0 }, hold: { type: 0, code1: 0, code2: 0 } })), + toggle: Array.from({ length: 32 }, () => ({ type: 0, code1: 0, code2: 0 })), +}); + +/** A keyboard client that answers from memory, so every control can be driven. */ +export class MchosePreviewClient implements KeyboardClient { + readonly family = "mchose"; + readonly brand = "MCHOSE"; + readonly device: HIDDevice; + readonly name = MCHOSE_PREVIEW_LABEL; + private state: MchoseWorkspace; + + constructor() { + const product = MCHOSE_PRODUCTS[0]!; + this.device = { vendorId: 0x41e4, productId: product.productId, productName: this.name, collections: [] } as unknown as HIDDevice; + const defaults = keymap(); + this.state = { + product, + info: { firmwareVersion: "116", buildDate: "Nov 8 2025,16:25:57", chip: "GL8KH-PRO" }, + profile: 0, + profileCount: 3, + profileNames: ["Everyday", null, null, null], + layout: MCHOSE_ACE68_LAYOUT, + performance: { + layerMode: 0, pollingRaw: 1, pollingHz: 8000, scanRateRaw: 0, lockWin: false, lockAltTab: false, lockAltF4: false, + tachyonMode: false, bottomRapidTrigger: true, debounceLevel: 0, sleepTime: 12, + }, + settings: { + macMode: false, lockWin: false, lockAltTab: false, lockAltF4: false, scanRateRaw: 0, tachyonMode: false, + bottomRapidTrigger: true, travelCheck: true, debounce: false, rtSmart: 5, sleepTime: 12, sleepEnabled: true, + }, + lighting: { + main: { effect: 6, brightness: 80, speed: 2, direction: 0, rainbow: true, color: { r: 0, g: 200, b: 160 } }, + side: { effect: 2, brightness: 100, speed: 2, rainbow: false, color: { r: 255, g: 255, b: 255 } }, + }, + travel: travelTable(), + keymap: defaults, + defaults: keymap(), + advanced: emptyTables(), + macros: Array.from({ length: 16 }, (_, n) => (n === 0 + ? { mode: 1, steps: [{ kind: "key" as const, code: 0x0b, down: true, delayMs: 20 }, { kind: "key" as const, code: 0x0b, down: false, delayMs: 0 }] } + : { mode: 0, steps: [] })), + colors: Array.from({ length: 128 }, () => ({ r: 0, g: 0, b: 0 })), + }; + } + + async open(): Promise {} + async close(): Promise {} + + async readIdentity(): Promise { + return { brand: this.brand, name: this.name, boardId: null, capabilities: null, firmwareVersion: "116", appVersion: null, pcbVersion: null, serial: null }; + } + + private patch(update: Partial): Partial { + this.state = { ...this.state, ...update }; + return { mchose: this.state }; + } + + async command(command: KeyboardCommand): Promise> { + const state = this.state; + switch (command.kind) { + case "refresh": return { mchose: state }; + case "mchoseTravel": { + const travel = state.travel.slice(); + for (const update of command.updates) { + const key = travel[update.index]; + if (!key) throw new RangeError(`Key ${update.index} is outside the table.`); + travel[update.index] = applyMchoseKeyTravelUpdate(key, update); + } + return this.patch({ travel }); + } + case "mchosePolling": return this.patch({ performance: state.performance && { ...state.performance, pollingHz: command.hz } }); + case "mchoseWinLock": return this.patch({ settings: state.settings && { ...state.settings, lockWin: command.locked } }); + case "mchoseSettings": return this.patch({ settings: command.settings }); + case "mchoseLighting": return this.patch({ lighting: command.lighting }); + case "mchoseKeyColors": { + const colors = (state.colors ?? []).slice(); + for (const { index, color } of command.updates) colors[index] = color; + return this.patch({ colors }); + } + case "mchoseKeymap": { + const keymap: MchoseAction[][] = state.keymap.map((layer) => layer.slice()); + for (const { index, action } of command.updates) keymap[command.layer]![index] = action; + return this.patch({ keymap }); + } + case "mchoseMacros": return this.patch({ macros: command.macros }); + case "mchoseAdvanced": { + const type = command.binding.kind === "dks" ? MCHOSE_ACTION_TYPE.dks : command.binding.kind === "modTap" ? MCHOSE_ACTION_TYPE.modTap : MCHOSE_ACTION_TYPE.toggle; + const advanced = { dks: [...state.advanced!.dks], modTap: [...state.advanced!.modTap], toggle: [...state.advanced!.toggle] }; + const used = new Set(state.keymap.flat().filter((a) => a.type === type).map((a) => a.code1)); + const current = state.keymap[command.layer]![command.index]!; + const slot = current.type === type ? current.code1 : [...Array(32).keys()].find((n) => !used.has(n)) ?? 0; + let code2 = 0; + if (command.binding.kind === "dks") advanced.dks[slot] = command.binding.dks; + if (command.binding.kind === "toggle") advanced.toggle[slot] = command.binding.action; + if (command.binding.kind === "modTap") { advanced.modTap[slot] = command.binding.entry; code2 = Math.round(command.binding.holdMs / 10); } + const keymap: MchoseAction[][] = state.keymap.map((layer) => layer.slice()); + keymap[command.layer]![command.index] = { type, code1: slot, code2 }; + return this.patch({ advanced, keymap }); + } + case "mchosePair": { + const type = command.pair === "socd" ? MCHOSE_ACTION_TYPE.socd : MCHOSE_ACTION_TYPE.snapTap; + const keymap: MchoseAction[][] = state.keymap.map((layer) => layer.slice()); + const used = new Set(state.keymap.flat().filter((a) => a.type >= MCHOSE_ACTION_TYPE.modTap && a.type <= MCHOSE_ACTION_TYPE.layerTap).map((a) => a.code1)); + const free = [...Array(32).keys()].filter((n) => !used.has(n)); + keymap[command.layer]![command.first] = { type, code1: free[0] ?? 0, code2: command.second }; + keymap[command.layer]![command.second] = { type, code1: free[1] ?? 1, code2: command.first }; + const travel = state.travel.slice(); + if (command.pair === "socd") { + const p = command.priority ?? 0; + travel[command.first] = { ...travel[command.first]!, priority: p }; + travel[command.second] = { ...travel[command.second]!, priority: p === 1 ? 2 : p === 2 ? 1 : p }; + } + return this.patch({ keymap, travel }); + } + case "mchoseAdvancedClear": { + const keymap: MchoseAction[][] = state.keymap.map((layer) => layer.slice()); + keymap[command.layer]![command.index] = { ...state.defaults[command.layer]![command.index]! }; + return this.patch({ keymap }); + } + case "mchoseProfile": return this.patch({ profile: command.profile }); + case "mchoseReset": { + // The preview has no factory image; it goes back to the data it started with. + const fresh = new MchosePreviewClient().state; + return this.patch(command.scope === "all" ? { ...fresh } : { ...fresh, profile: state.profile, profileNames: state.profileNames.map((name, n) => (n === state.profile ? null : name)), macros: state.macros }); + } + case "mchoseImport": { + const { file } = command; + const profileNames = state.profileNames.slice(); + if (file.name) profileNames[state.profile] = file.name; + return this.patch({ + settings: file.settings, lighting: file.lighting, colors: file.colors, advanced: file.advanced, profileNames, + travel: state.travel.map((key, index) => (state.layout.some((entry) => entry.index === index) ? { ...file.travel[index]! } : key)), + keymap: file.keymap.map((layer) => [...layer]), + }); + } + case "mchoseProfileName": { + const profileNames = state.profileNames.slice(); + profileNames[command.profile] = command.name || null; + return this.patch({ profileNames }); + } + case "mchoseCalibrate": return {}; + default: throw new Error("This command is not available on MCHOSE keyboards."); + } + } +} diff --git a/src/device/preview/preview-modes.ts b/src/device/preview/preview-modes.ts index cea4d4d5..0b5d29a3 100644 --- a/src/device/preview/preview-modes.ts +++ b/src/device/preview/preview-modes.ts @@ -45,11 +45,12 @@ export const PREVIEW_KEYS = [ "steelseries-gen2", "arbiter-polar-65-pro", "arbiter-polar-65", + "mchose-ace68-air", ] as const; export type PreviewMode = typeof PREVIEW_KEYS[number]; /** Keyboard previews have no mouse status; they run a stand-in keyboard client instead. */ -export type KeyboardPreviewMode = "arbiter-polar-65-pro" | "arbiter-polar-65"; +export type KeyboardPreviewMode = "arbiter-polar-65-pro" | "arbiter-polar-65" | "mchose-ace68-air"; export type FixturePreviewMode = Exclude; export function previewsEnabled(buildChannel: string, viteDev: boolean): boolean { @@ -101,6 +102,7 @@ export function parsePreviewMode(value: string | null): PreviewMode | null { case "steelseries-gen2": return "steelseries-gen2"; case "arbiter-polar-65-pro": return "arbiter-polar-65-pro"; case "arbiter-polar-65": return "arbiter-polar-65"; + case "mchose-ace68-air": return "mchose-ace68-air"; default: return null; } } diff --git a/src/device/types.ts b/src/device/types.ts index 82d46e10..7a2d6f33 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" | "keychron" | "arbiter" | "via"; + family?: "wuque" | "kochi" | "wooting" | "razer" | "logitech" | "steelseries" | "keychron" | "arbiter" | "mchose" | "via"; /** True when the driver marked the session unusable; reconnect before retrying. */ faulted?: boolean; name: string; diff --git a/src/features/devices/DeviceHero.tsx b/src/features/devices/DeviceHero.tsx index 37e080fd..c2e325fc 100644 --- a/src/features/devices/DeviceHero.tsx +++ b/src/features/devices/DeviceHero.tsx @@ -31,8 +31,8 @@ export function DeviceHero({ const active = control.getActiveDevice(); const activeEntry = snapshot.devices.find((device) => device.selected) ?? null; const name = status?.name ?? keyboard?.name ?? activeEntry?.name ?? ""; - // Arbiter keyboards have no product render; draw a keyboard rather than the unknown-device mouse. - const keyboardArt = keyboard?.family === "arbiter" && !snapshot.deviceArtwork; + // Arbiter and MCHOSE keyboards have no product render; draw a keyboard rather than the unknown-device mouse. + const keyboardArt = (keyboard?.family === "arbiter" || keyboard?.family === "mchose") && !snapshot.deviceArtwork; const image = snapshot.deviceArtwork ?? (keyboard && !keyboardArt ? deviceImage({ vendorId: keyboard.vendorId, productId: keyboard.productId } as HIDDevice, keyboard.name) : null); const needsArtwork = Boolean(status && active && (imageFailed || isUnknownDevice( diff --git a/src/features/devices/cards/KeyboardPanel.tsx b/src/features/devices/cards/KeyboardPanel.tsx index 1723c999..a13f9ff0 100644 --- a/src/features/devices/cards/KeyboardPanel.tsx +++ b/src/features/devices/cards/KeyboardPanel.tsx @@ -11,6 +11,7 @@ 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 MchoseKeyboardPanel = lazy(() => import("./MchoseKeyboardPanel").then(({ MchoseKeyboardPanel }) => ({ default: MchoseKeyboardPanel }))); const ViaKeyboardPanel = lazy(() => import("./ViaKeyboardPanel").then(({ ViaKeyboardPanel }) => ({ default: ViaKeyboardPanel }))); const SteelSeriesKeyboardPanel = lazy(() => import("./SteelSeriesKeyboardPanel").then(({ SteelSeriesKeyboardPanel }) => ({ default: SteelSeriesKeyboardPanel }))); @@ -37,6 +38,7 @@ export function KeyboardPanel({ snapshot }: { snapshot: ControlSnapshot }): Reac if (keyboard.family === "logitech") return ; if (keyboard.family === "steelseries") return ; if (keyboard.family === "arbiter") return ; + if (keyboard.family === "mchose") return ; if (keyboard.family === "via") return ; const locale = snapshot.preferences.locale; const { identity, polling, trigger } = keyboard; diff --git a/src/features/devices/cards/MchoseEditors.tsx b/src/features/devices/cards/MchoseEditors.tsx new file mode 100644 index 00000000..65d9a9f6 --- /dev/null +++ b/src/features/devices/cards/MchoseEditors.tsx @@ -0,0 +1,923 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import { Lightbulb, Play, SkipBack, SkipForward, Square, Sun, SunDim, Volume1, Volume2, VolumeX, type LucideIcon } from "lucide-react"; +import { + MCHOSE_ACTION_TYPE, + MCHOSE_BRIGHTNESS_MAX, + MCHOSE_DKS_DEFAULT_POINTS, + MCHOSE_MACRO_COUNT, + MCHOSE_MACRO_MODE, + MCHOSE_MEDIA_NAMES, + MCHOSE_MOD_TAP_HOLD_RANGE_MS, + MCHOSE_MODIFIER, + MCHOSE_MOUSE_NAMES, + MCHOSE_SPECIAL_NAMES, + MCHOSE_SPEED_MAX, + MCHOSE_SYSTEM_NAMES, + MCHOSE_USAGE_NAMES, + mchoseActionLabel, + mchoseActionsEqual, + mchoseMediaAction, + mchoseModel, +} from "@openmouse/keyboard-protocol/mchose"; +import type { + MchoseAction, + MchoseDks, + MchoseDksAction, + MchoseEffect, + MchoseLayoutKey, + MchoseLightZone, + MchoseMacro, + MchoseMacroStep, + MchoseRgb, +} from "@openmouse/keyboard-protocol/mchose"; +import * as control from "../../../device/controller"; +import type { MchoseWorkspace } from "../../../device/mchose-workspace"; +import { + MCHOSE_DEFAULT_GRADIENT, MCHOSE_DEFAULT_KEY_COLOR, MCHOSE_KEY_COLOR_OFF, mchoseGradientColor, mchoseGradientKeys, mchoseHexToRgb, mchoseRgbToHex, + type MchoseGradientStop, +} from "../../../device/mchose-colors"; +import type { InterfaceLocale } from "../../settings/interface-preferences"; +import { displayText, t, tp } from "../../../i18n/index"; +import { ColorPicker } from "../../../shared/ColorPicker"; +import { OptionMenu, Segmented, SliderField, StepperSlider, SwitchRow } from "../../../shared/ui"; +import { hidKeyForCode } from "../../profiles/hid-keys"; + +const NONE: MchoseAction = { type: 0, code1: 0, code2: 0 }; + +export function keyRows(layout: readonly MchoseLayoutKey[]): MchoseLayoutKey[][] { + // As many rows as the model has: a board with an F-row or numpad brings its own. + const rows = [...new Set(layout.map((key) => key.row))].sort((a, b) => a - b); + return rows.map((row) => layout.filter((key) => key.row === row).sort((a, b) => a.x - b.x)); +} + +/** Short keycap names, so a 1u key never wraps its label. */ +const KEYCAP: Readonly> = { + Backspace: "Bksp", Insert: "Ins", Delete: "Del", "Page Up": "PgUp", "Page Down": "PgDn", PgUp: "PgUp", PgDn: "PgDn", + "Caps Lock": "Caps", "Print Screen": "PrtSc", "Scroll Lock": "ScrLk", "Num Lock": "NumLk", Pause: "Pause", + Up: "\u2191", Down: "\u2193", Left: "\u2190", Right: "\u2192", + "Right Shift": "RShift", "Right Ctrl": "RCtrl", "Right Alt": "RAlt", "Right Win": "RWin", + LShift: "Shift", LCtrl: "Ctrl", LAlt: "Alt", Disabled: "\u2013", + Command: "\u2318", Option: "\u2325", "Right Command": "R\u2318", "Right Option": "R\u2325", +}; + +/** Mac layers (2 and 3) name Win and Alt as Command and Option, as the vendor app does. */ +const macName = (text: string): string => text.replace(/\bWin\b/g, "Command").replace(/\b[LR]?Alt\b/g, "Option"); + +/** Media keys draw as icons on a keycap, as in the vendor app; the name stays in the tooltip. */ +const KEYCAP_ICON: Readonly> = { + "Previous track": SkipBack, "Next track": SkipForward, "Play / pause": Play, Stop: Square, + Mute: VolumeX, "Volume down": Volume1, "Volume up": Volume2, "Lighting on / off": Lightbulb, + "Screen brightness up": Sun, "Screen brightness down": SunDim, +}; + +/** Modifier symbols for combinations on a keycap: "Ctrl+Z" shows as "⌃Z". */ +const MODIFIER_SYMBOL: readonly [string, string][] = [["Ctrl+", "⌃"], ["Shift+", "⇧"], ["Alt+", "⌥"], ["Win+", "⊞"], ["Option+", "⌥"], ["Command+", "⌘"]]; + +export function keycapLabel(text: string): string { + if (KEYCAP[text]) return KEYCAP[text]!; + if (!text.includes("+") || text.endsWith("+")) return text; + const parts = text.split("+"); + const key = parts.pop()!; + const combo = parts.map((part) => MODIFIER_SYMBOL.find(([name]) => name === `${part}+`)?.[1] ?? part).join(""); + return combo + (KEYCAP[key] ?? key); +} + +export function KeyDeck({ locale, layout, label, sub, swatch, depth, muted, selected, onToggle, disabled, ariaLabel }: { + locale: InterfaceLocale; + layout: readonly MchoseLayoutKey[]; + label: (key: MchoseLayoutKey) => string; + sub?: (key: MchoseLayoutKey) => string | null; + /** A colour (hex) to show as a dot on the keycap. */ + swatch?: (key: MchoseLayoutKey) => string | null; + /** How far the key is pressed right now, 0 to 1, drawn as a fill from the bottom. */ + depth?: (key: MchoseLayoutKey) => number; + muted?: (key: MchoseLayoutKey) => boolean; + selected: ReadonlySet; + onToggle: (index: number) => void; + disabled: boolean; + ariaLabel: string; +}): ReactNode { + const rows = useMemo(() => keyRows(layout), [layout]); + return ( +
+
+ {rows.map((keys, row) => ( +
+ {keys.map((key) => { + const isSelected = selected.has(key.index); + const shown = label(key); + const detail = sub?.(key) ?? null; + const dot = swatch?.(key) ?? null; + const Icon = KEYCAP_ICON[shown]; + return ( +