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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions build/check-bundle-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
".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
Expand Down
67 changes: 57 additions & 10 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<void> {
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<boolean> {
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;
Expand All @@ -3281,7 +3287,7 @@ export async function applyKeyboardCommand(command: KeyboardCommand): Promise<vo
}
if (command.kind === "keycode" && command.layer === 0 && client.readTrigger) {
const trigger = await client.readTrigger();
if (activeKeyboard !== client) return;
if (activeKeyboard !== client) return false;
keyboardMatrix = trigger.matrix;
keyboardKeys = trigger.keys;
}
Expand All @@ -3290,16 +3296,51 @@ export async function applyKeyboardCommand(command: KeyboardCommand): Promise<vo
: command.kind === "razerEffect" || command.kind === "logitechRgbEffect" || command.kind === "logitechSolidColor"
? st("kb.writeOnly")
: st("kb.updated");
return true;
} catch (error) {
if (activeKeyboard !== client) return;
if (activeKeyboard !== client) return false;
const recovered = client.recoveredWorkspace?.();
if (recovered) keyboardWorkspace = { ...keyboardWorkspace, ...recovered };
keyboardStatusText = error instanceof Error ? error.message : st("kb.readFailed");
return false;
} finally {
if (activeKeyboard === client) { keyboardBusy = false; emit(); }
}
}

/**
* After a keyboard reboots itself (an MCHOSE factory reset), find it again and
* switch to it. The reboot can finish before the reset call returns, and the
* disconnect handler may clear the old session meanwhile, so this polls the
* authorized devices by the ids it kept rather than waiting for an event.
*/
export async function reconnectAfterReboot(): Promise<void> {
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<void> {
const previous = activeKeyboard;
Expand All @@ -3317,6 +3358,11 @@ export async function reconnectKeyboard(): Promise<void> {
}
}

/** 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<readonly number[] | null> {
const client = activeKeyboard;
Expand Down Expand Up @@ -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<void> {
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();
}
Expand Down Expand Up @@ -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(() => {
Expand Down
4 changes: 3 additions & 1 deletion src/device/device-clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion src/device/keyboard-client.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,6 +24,8 @@ export interface KeyboardClient {
recoveredWorkspace?(): Partial<KeyboardWorkspace> | null;
/** Live key state for a tester, read outside the busy/status flow: one bit mask per matrix row. */
probeKeys?(): Promise<readonly number[]>;
/** 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<KeyboardPollingView>;
setPollingRate?(hz: number): Promise<KeyboardPollingView>;
readTrigger?(): Promise<{ matrix: readonly (readonly number[])[]; keys: KeyboardTriggerKey[] }>;
Expand Down
5 changes: 4 additions & 1 deletion src/device/keyboard-workspace.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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}.`);
Expand Down
21 changes: 21 additions & 0 deletions src/device/mchose-colors.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
58 changes: 58 additions & 0 deletions src/device/mchose-colors.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading