From 731d9cafda5ee63eeedaf07f0a705f5840438133 Mon Sep 17 00:00:00 2001 From: Onyx Date: Fri, 30 Oct 2026 07:52:04 +0100 Subject: [PATCH] feat: add VIA keyboard support Generic driver for boards running VIA/QMK firmware, found by the raw HID collection (usage page 0xFF60, usage 0x61) instead of a vendor list. Driver (drivers/via): protocol version, firmware and keycodes version, keymap read/write by buffer (protocol 8+) or single key, layer count, encoders, macro memory, custom-menu values, and layout options. Writes are read back and the board's reply is returned, since boards clamp or round custom values. Codec (src/via): 32-byte command frames, served v3 definition parser with layout-option decoding, keycode names for both QMK numbering generations, a macro action codec that keeps bytes it does not understand, VIA's macro text syntax with QMK key names, and a parser that turns a definition's menus into range, toggle, dropdown, colour and button controls with their showIf conditions. EEPROM reset, bootloader jump and macro reset are never sent. Exercised on one board (Anvil Native, protocol 11); see docs/via-support.md. --- README.md | 7 + docs/via-support.md | 70 ++ package.json | 5 + src/drivers/keyboard-types.ts | 15 + src/drivers/registry.test.ts | 18 +- src/drivers/registry.ts | 6 +- src/drivers/vendors.ts | 8 + src/drivers/via/hid.test.ts | 221 +++++ src/drivers/via/hid.ts | 380 +++++++++ src/index.ts | 1 + src/via/index.ts | 1492 +++++++++++++++++++++++++++++++++ src/via/protocol.test.ts | 625 ++++++++++++++ 12 files changed, 2845 insertions(+), 3 deletions(-) create mode 100644 docs/via-support.md create mode 100644 src/drivers/via/hid.test.ts create mode 100644 src/drivers/via/hid.ts create mode 100644 src/via/index.ts create mode 100644 src/via/protocol.test.ts diff --git a/README.md b/README.md index 7926e8b..9f128e6 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,13 @@ filters, the driver registry, and shared status types live under | SteelSeries | `@openmouse/keyboard-protocol/steelseries` | | Keychron (Hall-effect) | `@openmouse/keyboard-protocol/keychron` | | Arbiter Studio | `@openmouse/keyboard-protocol/arbiter` | +| VIA (QMK/VIA firmware, any vendor) | `@openmouse/keyboard-protocol/via` | + +The VIA driver matches the raw-HID collection (`0xFF60:0x61`) on any board and +covers keymaps, keycode names, encoders, macros and custom-menu lighting. Layout +and menu meaning come from a VIA definition parsed with `parseViaDefinition`. +It has been exercised on one board (Anvil Native, VIA protocol 11); see +[the VIA ledger](docs/via-support.md). Kochi Hummingbird 68 (`1CA2:0901`) supports reading and changing Windows/Mac mode on its `FFA0:1` USB configuration collection. Both directions and mode diff --git a/docs/via-support.md b/docs/via-support.md new file mode 100644 index 0000000..e164158 --- /dev/null +++ b/docs/via-support.md @@ -0,0 +1,70 @@ +# VIA support + +`@openmouse/keyboard-protocol/via` speaks the [VIA](https://github.com/the-via/app) +configuration protocol used by QMK/VIA firmware. It is written from the public +protocol and the VIA definition format. + +## Hardware verification + +Exercised through the OpenMouse UI on one board: + +| Board | Id | VIA protocol | Verified | +| --- | --- | --- | --- | +| Anvil Native | `7470:0002` | 11 | identity, layout from its VIA definition, keymap read and write, RGB-matrix controls (brightness, effect, speed, colour), macro write and playback | + +Everything else is implemented from the protocol and covered by tests against a +fake board only. Other boards, and VIA protocols other than 11, are unverified. + +## Transport + +- One raw-HID collection: usage page `0xFF60`, usage `0x61`, 32-byte reports. +- The driver matches the collection only, not vendor or product ids, so any + VIA board is recognised. Brand drivers in the registry take precedence. +- There is no transaction id. Replies are paired by command id plus the echoed + addressing bytes, and a timeout faults the session until reconnect. + +## Implemented + +| Area | Detail | +| --- | --- | +| Identity | protocol version, firmware version (0 is reported as unknown), keycodes version, layout options, uptime | +| Keymap | layer count, whole-keymap buffer read/write (protocol 8+), single key read/write (protocol 7) | +| Keycode names | basic keys, modifier combinations, mod-tap, layer-tap, layer keys (MO/TG/TO/TT/OSL/DF), one-shot modifiers and macro keys, for both QMK numbering generations (legacy up to protocol 10, modern from 11). Lighting keycodes (BL_, RGB_, RM_ and UG_, numbered per protocol generation and offered per the definition's keycode modules), a definition's custom keycodes, and a composer for layer-tap, mod-tap and modifier+key (`viaComposeKeycode`). Unknown codes are shown as hex. | +| Keycode names for files and hosts | `viaKeycodeToLayoutName` / `viaKeycodeFromLayoutName` use the names VIA writes in a `.layout.json` (`KC_A`, `MO(1)`, `LT(1,KC_A)`, `LCTL(KC_A)`, `MT(MOD_LCTL,KC_A)`, hex when unnamed). `viaKeycodeFromEventCode` / `viaEventCodeFromKeycode` map `KeyboardEvent.code` to basic keycodes, for macro recording and for showing the OS layout's legends | +| Encoders | get/set per layer and direction, with read-back | +| Switch matrix | `readSwitchMatrix(rows, cols)` returns one bit mask per row (key tester); the matrix must fit one report (30 bytes) | +| Macros | count, buffer size, raw buffer read/write, and a codec for the action format: text, key tap/down/up and delays (delays need protocol 11). Bytes the codec does not understand round-trip untouched. A text syntax like VIA's macro editor (`{KC_A}`, `{KC_LCTL,KC_C}`, `{+KC_LSFT}` / `{-KC_LSFT}`, `{500}`, `\{`) is read and written by `parseViaMacroExpression` and `viaMacroToExpression`, using the QMK names from (`viaKeycodeFromName`, `viaKeycodeName`). Differences from VIA's editor: `\\` types a backslash, and bytes it cannot name are written as `{raw:...}`. | +| Custom menu | get/set/save by `[channel, valueId, ...]` (lighting and other board settings), and a parser that turns a definition's menus into range, toggle, dropdown, colour and button controls with their `showIf` conditions | +| Definitions | `parseViaDefinition` reads the served v3 form and the older v2 form (v2 names its lighting, for example `qmk_rgblight`, or extends a preset with an object, instead of listing menus; those are expanded to the same controls, v2 `customMenus` are kept, and old firmware addresses them by single value id; Wilba Tech `wt_*` lighting is not modelled) (`https://usevia.app/definitions/v3/.json`; v2 lives under `/definitions/v2/`), which already has key positions and menus spelled out; the raw KLE files in `the-via/keyboards` are not read. `viaLayoutChoices` decodes the board's layout options, `viaPackLayoutChoices` encodes them, and `viaActiveKeys` selects the matching keys | +| Layout options | read and write (`setLayoutOptions`, a 32-bit keyboard value), with the board's reply returned | + +Writes are read back. `setCustomValue` returns what the board reports, which can +differ from the request: boards clamp or round custom values (the Anvil Native +reads one lower near the top of its brightness range), so callers should show the +returned value. + +## Not exposed + +- EEPROM reset, bootloader jump and macro reset. +- The device-indication value and per-key RGB. +- Macro keycodes above 8 bits. Protocol 11 macros store basic keycodes in one + byte, so only basic keys can be tapped, held or released. +- Audio and LED-matrix custom channels, whose ids are not confirmed. + +## Application-level features + +Kept in OpenMouse, not in this package, because they are about files and UI: +saving and restoring a keymap file (keymap, macros, encoders and layout options; +lighting is not included), saving and loading VIA's own `.layout.json`, the rotary +encoder editor, the live key tester, macro recording from the host keyboard, and +showing the OS keyboard layout's legends on keys. + +## Definitions + +The wire protocol does not carry the key layout or the custom-menu meaning. +Those come from a VIA v3 definition (published from +[`the-via/keyboards`](https://github.com/the-via/keyboards), GPL-3.0). This +package only parses a definition object; fetching and caching it, and matching a +board to one by vendor and product id (`viaVendorProductId`), is left to the +consuming application. `usevia.app` answers a missing definition with an HTML +page and status 200, so treat a body that is not a valid definition as "none". diff --git a/package.json b/package.json index c5ba19e..9fdd269 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "docs/steelseries-support.md", "docs/keychron-support.md", "docs/arbiter-support.md", + "docs/via-support.md", "README.md", "CONTRIBUTING.md" ], @@ -58,6 +59,10 @@ "./arbiter": { "types": "./dist/arbiter/index.d.ts", "import": "./dist/arbiter/index.js" + }, + "./via": { + "types": "./dist/via/index.d.ts", + "import": "./dist/via/index.js" } }, "scripts": { diff --git a/src/drivers/keyboard-types.ts b/src/drivers/keyboard-types.ts index 55ebd0d..7ec5054 100644 --- a/src/drivers/keyboard-types.ts +++ b/src/drivers/keyboard-types.ts @@ -188,6 +188,19 @@ export interface KeychronState { travel: { min?: number; max?: number; deadzone?: number } | null; } +/** Identity read from a VIA board; `null` where the firmware lacks that command. */ +export interface ViaStatus { + protocolVersion: number; + layerCount: number | null; + firmwareVersion: number | null; + keycodesVersion: number | null; + /** Layout-option bitfield, the choice per definition `labels` group. */ + layoutOptions: number | null; + uptimeMs: number | null; + macroCount: number | null; + macroBufferSize: number | null; +} + export interface KeyboardStatus { /** HB68 Windows/Mac mode, verified through both mode queries. */ kochi?: { systemMode: import("../kochi/index.js").KochiSystemMode }; @@ -243,4 +256,6 @@ export interface KeyboardStatus { steelseries?: { productId: number; profile: SteelSeriesApexProfile | null; verified: boolean } | null; /** Arbiter Studio keyboard state (travel, light effect, SuperTap). */ arbiter?: ArbiterKeyboardState | null; + /** VIA/QMK board identity; keymap, macros and lighting are read on demand. */ + via?: ViaStatus | null; } diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index 4141699..ab24f26 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -8,6 +8,7 @@ import { LogitechKeyboardHidClient } from "./logitech/hid.ts"; import { SteelSeriesKeyboardHidClient } from "./steelseries/hid.ts"; import { KeychronHidClient } from "./keychron/hid.ts"; import { ArbiterKeyboardHidClient } from "./arbiter/hid.ts"; +import { ViaHidClient } from "./via/hid.ts"; import { DEVICE_DRIVERS, clientSupportScore, createSupportedClient, deviceBrand } from "./registry.ts"; import { ARBITER_HID_FILTERS, @@ -17,6 +18,7 @@ import { LOGITECH_HID_FILTERS, RAZER_HID_FILTERS, STEELSERIES_HID_FILTERS, + VIA_HID_FILTERS, VENDOR_ID, WOOTING_HID_FILTERS, WUQUE_HID_FILTERS, @@ -85,10 +87,21 @@ const LOGITECH_RECEIVER_DEVICE = (): HIDDevice => test("DEVICE_DRIVERS lists one branded entry per supported protocol", () => { assert.deepEqual( DEVICE_DRIVERS.map((driver) => driver.brand), - ["Kochi", "Wooting", "Wuque Studio", "Razer", "Logitech", "SteelSeries", "Keychron", "Arbiter Studio"], + ["Kochi", "Wooting", "Wuque Studio", "Razer", "Logitech", "SteelSeries", "Keychron", "Arbiter Studio", "VIA"], ); }); +test("VIA matches any vendor's raw HID collection but yields to a brand driver", () => { + const via = fakeDevice({ vendorId: 0xfeed, productId: 1, collections: [collection(0xff60, 0x61)] }); + assert.ok(createSupportedClient(via) instanceof ViaHidClient); + assert.equal(deviceBrand(createSupportedClient(via)!), "VIA"); + const nested = fakeDevice({ vendorId: 0xfeed, productId: 1, collections: [collection(0x0001, 0x0006, [collection(0xff60, 0x61)])] }); + assert.ok(createSupportedClient(nested) instanceof ViaHidClient); + assert.equal(createSupportedClient(fakeDevice({ vendorId: 0xfeed, productId: 1, collections: [collection(0xff61, 0x61)] })), null); + const wooting = fakeDevice({ vendorId: WOOTING_VENDOR_ID, productId: 0x1322, collections: [collection(0xff55, 1), collection(0xff60, 0x61)] }); + assert.ok(createSupportedClient(wooting) instanceof WootingHidClient); +}); + test("createSupportedClient returns the matching driver client", () => { assert.ok(createSupportedClient(WOOTING_DEVICE()) instanceof WootingHidClient); assert.ok(createSupportedClient(WUQUE_DEVICE()) instanceof WuqueHidClient); @@ -102,7 +115,7 @@ test("createSupportedClient returns the matching driver client", () => { test("Keychron claims only catalogued HE boards on the raw-HID collection", () => { assert.equal(deviceBrand(createSupportedClient(KEYCHRON_DEVICE())!), "Keychron"); - assert.equal(createSupportedClient(KEYCHRON_DEVICE({ productId: 0x0001 })), null); + assert.equal(deviceBrand(createSupportedClient(KEYCHRON_DEVICE({ productId: 0x0001 }))!), "VIA"); assert.equal(createSupportedClient(KEYCHRON_DEVICE({ collections: [collection(0xff60, 0x62)] })), null); assert.equal(KEYCHRON_HID_FILTERS.length, KEYCHRON_HE_PRODUCT_IDS.length); assert.ok(KEYCHRON_HID_FILTERS.every((filter) => filter.vendorId === VENDOR_ID.keychron)); @@ -211,6 +224,7 @@ test("SUPPORTED_HID_FILTERS concatenates the vendor filter sets with no duplicat ...STEELSERIES_HID_FILTERS, ...KEYCHRON_HID_FILTERS, ...ARBITER_HID_FILTERS, + ...VIA_HID_FILTERS, ]); const seen = new Set(SUPPORTED_HID_FILTERS.map((f) => `${f.vendorId}:${f.productId}:${f.usagePage}:${f.usage}`)); assert.equal(seen.size, SUPPORTED_HID_FILTERS.length); diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index da4a406..818eb1d 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -6,6 +6,7 @@ import { LogitechKeyboardHidClient } from "./logitech/hid.ts"; import { SteelSeriesKeyboardHidClient } from "./steelseries/hid.ts"; import { KeychronHidClient } from "./keychron/hid.ts"; import { ArbiterKeyboardHidClient } from "./arbiter/hid.ts"; +import { ViaHidClient } from "./via/hid.ts"; export type SupportedClient = | WootingHidClient @@ -15,7 +16,8 @@ export type SupportedClient = | LogitechKeyboardHidClient | SteelSeriesKeyboardHidClient | KeychronHidClient - | ArbiterKeyboardHidClient; + | ArbiterKeyboardHidClient + | ViaHidClient; export interface DeviceDriver { brand: string; @@ -33,6 +35,8 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesKeyboardHidClient.isSupported(device), create: (device) => new SteelSeriesKeyboardHidClient(device), score: () => 6 }, { brand: "Keychron", supports: (device) => KeychronHidClient.isSupported(device), create: (device) => new KeychronHidClient(device), score: () => 6 }, { brand: "Arbiter Studio", supports: (device) => ArbiterKeyboardHidClient.isSupported(device), create: (device) => new ArbiterKeyboardHidClient(device), score: () => 6 }, + // Last: VIA matches any board with a raw-HID collection, so brand drivers win. + { brand: "VIA", supports: (device) => ViaHidClient.isSupported(device), create: (device) => new ViaHidClient(device), score: () => 5 }, ]; function driverFor(device: HIDDevice): DeviceDriver | undefined { diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 6aefec6..f0f9621 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -42,6 +42,7 @@ import { STEELSERIES_KEYBOARD_PRODUCT_IDS, STEELSERIES_VENDOR_ID, } from "@openmouse/keyboard-protocol/steelseries"; +import { VIA_USAGE, VIA_USAGE_PAGE } from "@openmouse/keyboard-protocol/via"; export const VENDOR_ID = { kochi: KOCHI_VENDOR_ID, @@ -145,6 +146,12 @@ export const ARBITER_HID_FILTERS: HIDDeviceFilter[] = ARBITER_PRODUCTS.map((entr usage: ARBITER_CONFIG_USAGE, })); +/** + * VIA/QMK raw HID (0xFF60:0x61). Any vendor can ship VIA firmware, so this + * filter has no vendor or product id; it matches the collection only. + */ +export const VIA_HID_FILTERS: HIDDeviceFilter[] = [{ usagePage: VIA_USAGE_PAGE, usage: VIA_USAGE }]; + export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...KOCHI_HID_FILTERS, ...WOOTING_HID_FILTERS, @@ -154,4 +161,5 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...STEELSERIES_HID_FILTERS, ...KEYCHRON_HID_FILTERS, ...ARBITER_HID_FILTERS, + ...VIA_HID_FILTERS, ]; diff --git a/src/drivers/via/hid.test.ts b/src/drivers/via/hid.test.ts new file mode 100644 index 0000000..93af6d4 --- /dev/null +++ b/src/drivers/via/hid.test.ts @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ViaHidClient, ViaUnhandledError } from "./hid.ts"; +import { VIA_COMMAND, VIA_REPORT_LENGTH, decodeViaKeymap, encodeViaKeymap } from "@openmouse/keyboard-protocol/via"; + +const MATRIX = { rows: 2, cols: 3 }; + +class FakeViaBoard { + vendorId = 0xfeed; + productId = 0x0001; + productName = "Test VIA Board"; + opened = false; + collections = [{ usagePage: 0xff60, usage: 0x61, inputReports: [], outputReports: [], featureReports: [], children: [] }]; + protocol = 9; + firmware = [0, 0, 1, 2]; + layoutOptions = [0, 0, 0, 3]; + customMax = 255; + keymap = encodeViaKeymap([[4, 5, 6, 7, 8, 9], [0x5100, 0x5101, 0x5102, 0x5103, 0x5104, 0x5105]]); + macros = [0x61, 0x62, 0, 0x63, 0, ...new Array(11).fill(0)]; + custom = new Map([["3,1", [128]], ["3,2", [5]], ["3,4", [10, 200]]]); + unhandled = new Set([0x06]); + corruptKeymapWrites = false; + silent = false; + sent: number[][] = []; + private listeners = new Set<(event: HIDInputReportEvent) => void>(); + + async open(): Promise { this.opened = true; } + async close(): Promise { this.opened = false; } + addEventListener(_type: "inputreport", listener: (event: HIDInputReportEvent) => void): void { this.listeners.add(listener); } + removeEventListener(_type: "inputreport", listener: (event: HIDInputReportEvent) => void): void { this.listeners.delete(listener); } + + async sendReport(_reportId: number, source: BufferSource): Promise { + const request = new Uint8Array(source instanceof ArrayBuffer ? source : source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength)); + this.sent.push([...request]); + if (this.silent) return; + const reply = this.respond(request); + const event = { reportId: 0, data: new DataView(reply.buffer), device: this } as unknown as HIDInputReportEvent; + for (const listener of this.listeners) listener(event); + } + + private respond(request: Uint8Array): Uint8Array { + const reply = new Uint8Array(VIA_REPORT_LENGTH); + reply.set(request); + const [command, a = 0, b = 0, c = 0] = request; + const offset = (a << 8) | b; + const keyAt = (a * 6 + b * 3 + c) * 2; + switch (command) { + case VIA_COMMAND.getProtocolVersion: reply.set([this.protocol >> 8, this.protocol & 0xff], 1); break; + case VIA_COMMAND.getKeyboardValue: + if (this.unhandled.has(a)) reply[0] = 0xff; + else if (a === 0x04) reply.set(this.firmware, 2); + else if (a === 0x02) reply.set(this.layoutOptions, 2); + else if (a === 0x01) reply.set([0, 0, 0x03, 0xe8], 2); + else if (a === 0x03) reply.set(this.protocol >= 13 ? [request[2]!, 0x00, 0x05, 0x01, 0x00] : [0x00, 0x05, 0x01, 0x00], 2); + break; + case VIA_COMMAND.setKeyboardValue: if (a === 0x02) this.layoutOptions = [...request.slice(2, 6)]; break; + case VIA_COMMAND.getLayerCount: reply[1] = 2; break; + case VIA_COMMAND.getKeymapBuffer: reply.set(this.keymap.slice(offset, offset + c), 4); break; + case VIA_COMMAND.setKeymapBuffer: { + const data = request.slice(4, 4 + c); + if (this.corruptKeymapWrites) data[0] = (data[0]! + 1) & 0xff; + this.keymap.splice(offset, c, ...data); + break; + } + case VIA_COMMAND.getKeycode: reply.set(this.keymap.slice(keyAt, keyAt + 2), 4); break; + case VIA_COMMAND.setKeycode: this.keymap.splice(keyAt, 2, request[4]!, request[5]!); break; + case VIA_COMMAND.macroGetCount: reply[1] = 3; break; + case VIA_COMMAND.macroGetBufferSize: reply.set([0, this.macros.length], 1); break; + case VIA_COMMAND.macroGetBuffer: reply.set(this.macros.slice(offset, offset + c), 4); break; + case VIA_COMMAND.macroSetBuffer: this.macros.splice(offset, c, ...request.slice(4, 4 + c)); break; + case VIA_COMMAND.customMenuGet: { + const value = this.custom.get(`${a},${b}`); + if (value) reply.set(value, 3); else reply[0] = 0xff; + break; + } + case VIA_COMMAND.customMenuSet: { + const key = `${a},${b}`; + this.custom.set(key, [...request.slice(3, 3 + (this.custom.get(key)?.length ?? 1))].map((byte) => Math.min(byte, this.customMax))); + break; + } + default: reply[0] = 0xff; + } + return reply; + } +} + +const client = (board: FakeViaBoard) => new ViaHidClient(board as unknown as HIDDevice); + +test("the VIA client recognises the raw HID collection, nested or not", () => { + const board = new FakeViaBoard(); + assert.ok(ViaHidClient.isSupported(board as unknown as HIDDevice)); + board.collections = [{ usagePage: 1, usage: 6, inputReports: [], outputReports: [], featureReports: [], children: board.collections }]; + assert.ok(ViaHidClient.isSupported(board as unknown as HIDDevice)); + board.collections = []; + assert.equal(ViaHidClient.isSupported(board as unknown as HIDDevice), false); +}); + +test("readStatus reports identity and leaves unsupported values null", async () => { + const status = await client(new FakeViaBoard()).readStatus(); + assert.equal(status.brand, "VIA"); + assert.equal(status.name, "Test VIA Board"); + assert.deepEqual(status.via, { + protocolVersion: 9, + layerCount: 2, + firmwareVersion: 0x0102, + keycodesVersion: null, + layoutOptions: 3, + uptimeMs: 1000, + macroCount: 3, + macroBufferSize: 16, + }); + assert.deepEqual(status.firmware, ["VIA protocol 9", "Firmware 258"]); +}); + +test("a firmware version of 0 means the maker never set one and is reported as unknown", async () => { + const board = new FakeViaBoard(); + board.firmware = [0, 0, 0, 0]; + const status = await client(board).readStatus(); + assert.equal(status.firmwareVersion, null); + assert.deepEqual(status.firmware, ["VIA protocol 9"]); +}); + +test("layout options are written as a 32-bit value and read back", async () => { + const board = new FakeViaBoard(); + const via = client(board); + assert.equal(await via.setLayoutOptions(0x00000105), 0x105); + assert.deepEqual(board.layoutOptions, [0, 0, 1, 5]); + assert.equal((await via.readStatus()).via?.layoutOptions, 0x105); +}); + +test("the keymap reads back as one array per layer", async () => { + const keymap = await client(new FakeViaBoard()).readKeymap(MATRIX); + assert.deepEqual(keymap, [[4, 5, 6, 7, 8, 9], [0x5100, 0x5101, 0x5102, 0x5103, 0x5104, 0x5105]]); +}); + +test("writeKeymap sends buffer chunks and confirms by read-back", async () => { + const board = new FakeViaBoard(); + const next = [[10, 11, 12, 13, 14, 15], [16, 17, 18, 19, 20, 21]]; + await client(board).writeKeymap(next, MATRIX); + assert.deepEqual(decodeViaKeymap([...board.keymap], 2, 2, 3), next); + assert.equal(board.sent.filter((frame) => frame[0] === VIA_COMMAND.setKeymapBuffer).length, 1); +}); + +test("writeKeymap rejects a board whose read-back differs and a wrong-sized layer", async () => { + const board = new FakeViaBoard(); + board.corruptKeymapWrites = true; + await assert.rejects(client(board).writeKeymap([[1, 2, 3, 4, 5, 6]], MATRIX), /read-back/); + await assert.rejects(client(new FakeViaBoard()).writeKeymap([[1, 2]], MATRIX), RangeError); +}); + +test("setKeycode confirms a single key", async () => { + const board = new FakeViaBoard(); + assert.equal(await client(board).setKeycode(1, 1, 2, 0x1234), 0x1234); + assert.equal(await client(board).readKeycode(1, 1, 2), 0x1234); +}); + +test("macros read as raw bytes per macro and rewrite with read-back", async () => { + const board = new FakeViaBoard(); + const via = client(board); + assert.deepEqual((await via.readMacros()).macros, [[0x61, 0x62], [0x63], []]); + await via.writeMacros([[0x41], [], [0x42, 0x43]]); + assert.deepEqual((await via.readMacros()).macros, [[0x41], [], [0x42, 0x43]]); + await assert.rejects(via.writeMacros([[1]]), RangeError); +}); + +test("custom values are read and written through the custom menu channel", async () => { + const via = client(new FakeViaBoard()); + assert.deepEqual(await via.readCustomValue([3, 4], 2), [10, 200]); + await via.setCustomValue([3, 1], [64]); + assert.deepEqual(await via.readCustomValue([3, 1], 1), [64]); + await assert.rejects(via.readCustomValue([9, 9], 1), ViaUnhandledError); +}); + +test("a custom value the board clamps is reported as the board holds it, not as an error", async () => { + const board = new FakeViaBoard(); + board.customMax = 100; + const via = client(board); + assert.deepEqual(await via.setCustomValue([3, 1], [200]), [100]); + assert.deepEqual(await via.readCustomValue([3, 1], 1), [100]); + assert.deepEqual(await via.setCustomValue([3, 1], [50]), [50]); + assert.deepEqual(await via.setCustomValue([3, 1], [200], false), [200]); +}); + +test("a silent board times out and the session refuses further commands", async () => { + const board = new FakeViaBoard(); + board.silent = true; + const via = client(board); + await assert.rejects(via.readKeycode(0, 0, 0), /did not answer/); + board.silent = false; + await assert.rejects(via.readKeycode(0, 0, 0), /Reconnect/); +}); + +test("the switch matrix reads as one big-endian bit mask per row", async () => { + const board = new FakeViaBoard(); + const client = new ViaHidClient(board as unknown as HIDDevice); + await client.open(); + assert.deepEqual(await client.readSwitchMatrix(2, 9), [5, 256]); + await assert.rejects(client.readSwitchMatrix(40, 8), RangeError); +}); + +test("protocol 13 pages the switch matrix: the request names the first row and the reply echoes it", async () => { + const board = new FakeViaBoard(); + board.protocol = 13; + const client = new ViaHidClient(board as unknown as HIDDevice); + await client.open(); + assert.deepEqual(await client.readSwitchMatrix(2, 9), [5, 256]); + const request = board.sent.filter((frame) => frame[0] === VIA_COMMAND.getKeyboardValue && frame[1] === 0x03).at(-1)!; + assert.equal(request[2], 0); +}); + +test("a macro upload holds playback with a non-zero last byte and releases it only when every chunk is in", async () => { + const board = new FakeViaBoard(); + const via = client(board); + await via.writeMacros([[0x41], [], [0x42, 0x43]]); + const writes = board.sent.filter((frame) => frame[0] === VIA_COMMAND.macroSetBuffer); + const last = board.macros.length - 1; + assert.deepEqual([writes[0]!.slice(1, 5)], [[last >> 8, last & 0xff, 1, 0xff]]); + assert.deepEqual(writes.at(-1)!.slice(1, 5), [last >> 8, last & 0xff, 1, 0]); + assert.equal(board.macros[last], 0); +}); diff --git a/src/drivers/via/hid.ts b/src/drivers/via/hid.ts new file mode 100644 index 0000000..da5474a --- /dev/null +++ b/src/drivers/via/hid.ts @@ -0,0 +1,380 @@ +import type { KeyboardStatus, ViaStatus } from "../keyboard-types.ts"; +import { + VIA_REPORT_LENGTH, + VIA_KEYBOARD_VALUE, + VIA_LEGACY_LAYER_COUNT, + VIA_PROTOCOL_BETA, + VIA_REPORT_ID, + VIA_USAGE, + VIA_USAGE_PAGE, + decodeViaBuffer, + decodeViaCustomValue, + decodeViaEncoder, + decodeViaKeyboardValue, + decodeViaKeycode, + decodeViaKeymap, + decodeViaLayerCount, + decodeViaMacroBufferSize, + decodeViaMacroCount, + decodeViaProtocolVersion, + encodeViaCustomGet, + encodeViaCustomSave, + encodeViaCustomSet, + encodeViaGetEncoder, + encodeViaGetKeyboardValue, + encodeViaGetKeycode, + encodeViaGetKeymapBuffer, + encodeViaGetLayerCount, + encodeViaGetProtocolVersion, + encodeViaKeymap, + encodeViaMacroGetBuffer, + encodeViaMacroGetBufferSize, + encodeViaMacroGetCount, + encodeViaMacroSetBuffer, + encodeViaSetEncoder, + encodeViaSetKeyboardValue, + encodeViaSetKeycode, + encodeViaSetKeymapBuffer, + joinViaMacros, + splitViaMacros, + viaBufferChunks, + viaIsUnhandled, + viaKeymapByteLength, + viaReplyMatches, + viaUint32, + viaUint32Bytes, +} from "@openmouse/keyboard-protocol/via"; + +const REPLY_TIMEOUT_MS = 1500; + +export class ViaUnhandledError extends Error { + constructor(command: number) { + super(`The keyboard does not implement VIA command 0x${command.toString(16)}.`); + this.name = "ViaUnhandledError"; + } +} + +export interface ViaMatrixSize { + rows: number; + cols: number; +} + +export interface ViaMacros { + count: number; + bufferSize: number; + macros: number[][]; +} + +/** + * Any board running VIA/QMK raw HID (usage page 0xFF60, usage 0x61). Key + * layout and custom-menu meaning come from a VIA definition, not the board, so + * matrix sizes are passed in by the caller. EEPROM reset, bootloader jump and + * macro reset are deliberately not exposed. + */ +export class ViaHidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private listening = false; + private faulted = false; + private protocol: number | null = null; + private pending: { + request: Uint8Array; + headerLength: number; + resolve: (frame: Uint8Array) => void; + reject: (error: Error) => void; + } | null = null; + + constructor(device: HIDDevice) { this.device = device; } + + static isSupported(device: HIDDevice): boolean { + return ViaHidClient.configCollection(device.collections ?? []) !== null; + } + + private static configCollection(collections: readonly HIDCollectionInfo[]): HIDCollectionInfo | null { + for (const collection of collections) { + if (collection.usagePage === VIA_USAGE_PAGE && collection.usage === VIA_USAGE) return collection; + const nested = ViaHidClient.configCollection(collection.children ?? []); + if (nested) return nested; + } + return null; + } + + async open(): Promise { + if (!ViaHidClient.isSupported(this.device)) throw new Error("Not a VIA raw HID interface."); + if (this.faulted) throw new Error("Reconnect the keyboard after a failed transaction."); + if (!this.device.opened) await this.device.open(); + if (this.faulted) { + if (this.device.opened) await this.device.close(); + throw new Error("Reconnect the keyboard after a failed transaction."); + } + if (!this.listening) { + this.device.addEventListener("inputreport", this.onReport); + this.listening = true; + } + } + + async close(): Promise { + this.faulted = true; + this.pending?.reject(new Error("Keyboard disconnected.")); + if (this.listening) this.device.removeEventListener("inputreport", this.onReport); + this.listening = false; + if (this.device.opened) await this.device.close(); + } + + private readonly onReport = (event: HIDInputReportEvent): void => { + if (event.device !== this.device || !this.pending) return; + const frame = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength).slice(); + const { request, headerLength, resolve, reject } = this.pending; + if (viaIsUnhandled(frame)) reject(new ViaUnhandledError(request[0]!)); + else if (viaReplyMatches(frame, request, headerLength)) resolve(frame); + }; + + private serialized(operation: () => Promise): Promise { + const next = this.queue.then(operation); + this.queue = next.catch(() => undefined); + return next; + } + + private async call(request: Uint8Array, headerLength: number): Promise { + await this.open(); + let timer: ReturnType | undefined; + const reply = new Promise((resolve, reject) => { + this.pending = { request, headerLength, resolve, reject }; + timer = setTimeout(() => reject(new Error(`Keyboard did not answer VIA command 0x${request[0]!.toString(16)}.`)), REPLY_TIMEOUT_MS); + }); + try { + const [frame] = await Promise.all([reply, this.device.sendReport(VIA_REPORT_ID, request)]); + return frame; + } catch (error) { + if (!(error instanceof ViaUnhandledError)) this.faulted = true; + throw error; + } finally { + clearTimeout(timer); + this.pending = null; + } + } + + private async optional(read: () => Promise): Promise { + try { + return await read(); + } catch (error) { + if (error instanceof ViaUnhandledError) return null; + throw error; + } + } + + private async protocolVersion(): Promise { + this.protocol ??= decodeViaProtocolVersion(await this.call(encodeViaGetProtocolVersion(), 0)); + return this.protocol; + } + + private async keyboardValue(id: number, resultLength: number): Promise { + return decodeViaKeyboardValue(await this.call(encodeViaGetKeyboardValue(id), 1), 0, resultLength); + } + + private async layerCount(): Promise { + if ((await this.protocolVersion()) < VIA_PROTOCOL_BETA) return VIA_LEGACY_LAYER_COUNT; + return decodeViaLayerCount(await this.call(encodeViaGetLayerCount(), 0)); + } + + private async readBuffer( + total: number, + encode: (offset: number, size: number) => Uint8Array, + ): Promise { + const bytes: number[] = []; + for (const { offset, size } of viaBufferChunks(total)) { + bytes.push(...decodeViaBuffer(await this.call(encode(offset, size), 3), size)); + } + return bytes; + } + + private async keymapBytes(layers: number, matrix: ViaMatrixSize): Promise { + return this.readBuffer(viaKeymapByteLength(layers, matrix.rows, matrix.cols), encodeViaGetKeymapBuffer); + } + + private async readKeymapNow(matrix: ViaMatrixSize, layers: number): Promise { + if ((await this.protocolVersion()) >= VIA_PROTOCOL_BETA) { + return decodeViaKeymap(await this.keymapBytes(layers, matrix), layers, matrix.rows, matrix.cols); + } + const keymap: number[][] = []; + for (let layer = 0; layer < layers; layer += 1) { + const keycodes: number[] = []; + for (let row = 0; row < matrix.rows; row += 1) { + for (let col = 0; col < matrix.cols; col += 1) { + keycodes.push(decodeViaKeycode(await this.call(encodeViaGetKeycode(layer, row, col), 3))); + } + } + keymap.push(keycodes); + } + return keymap; + } + + readStatus(): Promise { + return this.serialized(async () => { + const protocolVersion = await this.protocolVersion(); + const layerCount = await this.optional(() => this.layerCount()); + const firmwareVersion = await this.optional(async () => viaUint32(await this.keyboardValue(VIA_KEYBOARD_VALUE.firmwareVersion, 4))); + const keycodesVersion = await this.optional(async () => viaUint32(await this.keyboardValue(VIA_KEYBOARD_VALUE.keycodesVersion, 4))); + const layoutOptions = await this.optional(async () => viaUint32(await this.keyboardValue(VIA_KEYBOARD_VALUE.layoutOptions, 4))); + const uptimeMs = await this.optional(async () => viaUint32(await this.keyboardValue(VIA_KEYBOARD_VALUE.uptime, 4))); + const macroCount = await this.optional(async () => decodeViaMacroCount(await this.call(encodeViaMacroGetCount(), 0))); + const macroBufferSize = await this.optional(async () => decodeViaMacroBufferSize(await this.call(encodeViaMacroGetBufferSize(), 0))); + const via: ViaStatus = { + protocolVersion, layerCount, firmwareVersion, keycodesVersion, layoutOptions, uptimeMs, macroCount, macroBufferSize, + }; + const name = this.device.productName || "VIA keyboard"; + return { + brand: "VIA", + name, + connectionType: "Unknown", + connectionDetail: "VIA raw HID", + firmware: [`VIA protocol ${protocolVersion}`, ...(firmwareVersion ? [`Firmware ${firmwareVersion}`] : [])], + firmwareVersion: firmwareVersion ? String(firmwareVersion) : null, + layout: null, + serial: null, + activeProfile: null, + profileCount: null, + profileNames: [], + ui: { + family: "via", + settingsReady: false, + defaultDisplayName: name, + statusNote: "VIA support is written from the public protocol; this board has not been tested through OpenMouse.", + }, + via, + }; + }); + } + + readKeymap(matrix: ViaMatrixSize, layers?: number): Promise { + return this.serialized(async () => this.readKeymapNow(matrix, layers ?? (await this.layerCount()))); + } + + writeKeymap(keymap: readonly (readonly number[])[], matrix: ViaMatrixSize): Promise { + return this.serialized(async () => { + const perLayer = matrix.rows * matrix.cols; + if (keymap.some((layer) => layer.length !== perLayer)) throw new RangeError("Every layer must hold rows * cols keycodes."); + if (keymap.length > (await this.layerCount())) throw new RangeError("The keymap has more layers than the board."); + const bytes = encodeViaKeymap(keymap); + if ((await this.protocolVersion()) >= VIA_PROTOCOL_BETA) { + for (const { offset, size } of viaBufferChunks(bytes.length)) { + await this.call(encodeViaSetKeymapBuffer(offset, bytes.slice(offset, offset + size)), 3); + } + } else { + for (const [layer, keycodes] of keymap.entries()) { + for (const [index, keycode] of keycodes.entries()) { + await this.call(encodeViaSetKeycode(layer, Math.floor(index / matrix.cols), index % matrix.cols, keycode), 3); + } + } + } + const confirmed = await this.readKeymapNow(matrix, keymap.length); + if (confirmed.some((layer, index) => layer.some((keycode, key) => keycode !== keymap[index]![key]))) { + throw new Error("Keymap read-back did not match what was written."); + } + }); + } + + /** Writes the packed layout options and returns what the board reports afterwards. */ + setLayoutOptions(bitfield: number): Promise { + return this.serialized(async () => { + await this.call(encodeViaSetKeyboardValue(VIA_KEYBOARD_VALUE.layoutOptions, viaUint32Bytes(bitfield)), 1); + return viaUint32(await this.keyboardValue(VIA_KEYBOARD_VALUE.layoutOptions, 4)); + }); + } + + readKeycode(layer: number, row: number, col: number): Promise { + return this.serialized(async () => decodeViaKeycode(await this.call(encodeViaGetKeycode(layer, row, col), 3))); + } + + setKeycode(layer: number, row: number, col: number, keycode: number): Promise { + return this.serialized(async () => { + await this.call(encodeViaSetKeycode(layer, row, col, keycode), 5); + const confirmed = decodeViaKeycode(await this.call(encodeViaGetKeycode(layer, row, col), 3)); + if (confirmed !== keycode) throw new Error("Key read-back did not match the request."); + return confirmed; + }); + } + + /** Which switches are down, as one bit mask per matrix row (bit 0 is column 0). Fits one report: at most 30 bytes. */ + readSwitchMatrix(rows: number, cols: number): Promise { + const bytesPerRow = Math.ceil(cols / 8); + // Protocol 13 pages the matrix: the request names the first row and the reply echoes it before up to 28 bytes of rows. + const pageRows = Math.floor((VIA_REPORT_LENGTH - 4) / bytesPerRow); + if (rows < 1 || bytesPerRow < 1 || pageRows < 1) throw new RangeError("This matrix is too large to read."); + return this.serialized(async () => { + const paged = (await this.protocolVersion()) >= 13; + if (!paged && rows * bytesPerRow > VIA_REPORT_LENGTH - 2) throw new RangeError("This matrix is too large to read in one report."); + const bytes: number[] = []; + for (let first = 0; first < rows; first += paged ? pageRows : rows) { + const count = paged ? Math.min(pageRows, rows - first) : rows; + bytes.push(...(paged + ? decodeViaKeyboardValue(await this.call(encodeViaGetKeyboardValue(VIA_KEYBOARD_VALUE.switchMatrixState, [first]), 2), 1, count * bytesPerRow) + : await this.keyboardValue(VIA_KEYBOARD_VALUE.switchMatrixState, count * bytesPerRow))); + } + // Each row is big-endian: its first byte holds the highest columns. + return Array.from({ length: rows }, (_, row) => bytes.slice(row * bytesPerRow, (row + 1) * bytesPerRow).reduce((mask, byte) => mask * 256 + byte, 0)); + }); + } + + readEncoder(layer: number, id: number, clockwise: boolean): Promise { + return this.serialized(async () => decodeViaEncoder(await this.call(encodeViaGetEncoder(layer, id, clockwise), 3))); + } + + setEncoder(layer: number, id: number, clockwise: boolean, keycode: number): Promise { + return this.serialized(async () => { + await this.call(encodeViaSetEncoder(layer, id, clockwise, keycode), 5); + const confirmed = decodeViaEncoder(await this.call(encodeViaGetEncoder(layer, id, clockwise), 3)); + if (confirmed !== keycode) throw new Error("Encoder read-back did not match the request."); + return confirmed; + }); + } + + private async macroLayout(): Promise<{ count: number; bufferSize: number }> { + const count = decodeViaMacroCount(await this.call(encodeViaMacroGetCount(), 0)); + const bufferSize = decodeViaMacroBufferSize(await this.call(encodeViaMacroGetBufferSize(), 0)); + return { count, bufferSize }; + } + + readMacros(): Promise { + return this.serialized(async () => { + const { count, bufferSize } = await this.macroLayout(); + const buffer = await this.readBuffer(bufferSize, encodeViaMacroGetBuffer); + return { count, bufferSize, macros: splitViaMacros(buffer, count) }; + }); + } + + writeMacros(macros: readonly (readonly number[])[]): Promise { + return this.serialized(async () => { + const { count, bufferSize } = await this.macroLayout(); + if (macros.length !== count) throw new RangeError(`The board holds ${count} macros; got ${macros.length}.`); + const bytes = joinViaMacros(macros, bufferSize); + // QMK will not play a macro while the buffer's last byte is non-zero, so mark it for the whole upload and + // clear it only once every chunk is in. A failed upload leaves macros paused instead of half old, half new. + const last = bytes.length - 1; + await this.call(encodeViaMacroSetBuffer(last, [0xff]), 3); + for (const { offset, size } of viaBufferChunks(last)) { + await this.call(encodeViaMacroSetBuffer(offset, bytes.slice(offset, offset + size)), 3); + } + await this.call(encodeViaMacroSetBuffer(last, [bytes[last]!]), 3); + const confirmed = await this.readBuffer(bufferSize, encodeViaMacroGetBuffer); + if (confirmed.some((byte, index) => byte !== bytes[index])) throw new Error("Macro read-back did not match what was written."); + }); + } + + readCustomValue(ids: readonly number[], resultLength: number): Promise { + return this.serialized(async () => decodeViaCustomValue(await this.call(encodeViaCustomGet(ids), ids.length), ids.length, resultLength)); + } + + setCustomValue(ids: readonly number[], value: readonly number[], verify = true): Promise { + return this.serialized(async () => { + await this.call(encodeViaCustomSet(ids, value), ids.length); + if (!verify) return [...value]; + return decodeViaCustomValue(await this.call(encodeViaCustomGet(ids), ids.length), ids.length, value.length); + }); + } + + /** `legacy` is the old lighting save, which takes no channel. */ + saveCustomChannel(channel: number, legacy = false): Promise { + return this.serialized(async () => { await this.call(encodeViaCustomSave(legacy ? null : channel), legacy ? 0 : 1); }); + } +} diff --git a/src/index.ts b/src/index.ts index ac81405..6e733fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,3 +5,4 @@ export * as logitech from "./logitech/index.js"; export * as steelseries from "./steelseries/index.js"; export * as keychron from "./keychron/index.js"; export * as arbiter from "./arbiter/index.js"; +export * as via from "./via/index.js"; diff --git a/src/via/index.ts b/src/via/index.ts new file mode 100644 index 0000000..60814b4 --- /dev/null +++ b/src/via/index.ts @@ -0,0 +1,1492 @@ +export interface ViaBasicKeycode { + code: number; + /** Readable name for tooltips and menus. */ + title: string; + /** Short legend for a key cap. */ + label: string; + /** QMK names from https://docs.qmk.fm/keycodes_basic. The first is written out; the rest are accepted when reading. */ + names: readonly string[]; +} + +/** + * The basic keycodes (one byte, the same in every QMK version). Mouse keys and + * quantum keys are left out because their numbers depend on the QMK version. + */ +const ROWS: ViaBasicKeycode[] = []; +const add = (code: number, title: string, names: readonly string[], label = title): void => { ROWS.push({ code, title, label, names }); }; + +for (let index = 0; index < 26; index += 1) { + const letter = String.fromCharCode(65 + index); + add(0x04 + index, letter, [`KC_${letter}`]); +} +for (let index = 1; index <= 9; index += 1) add(0x1d + index, String(index), [`KC_${index}`]); +add(0x27, "0", ["KC_0"]); +add(0x28, "Enter", ["KC_ENT", "KC_ENTER"]); +add(0x29, "Esc", ["KC_ESC", "KC_ESCAPE"]); +add(0x2a, "Backspace", ["KC_BSPC", "KC_BACKSPACE"], "Bksp"); +add(0x2b, "Tab", ["KC_TAB"]); +add(0x2c, "Space", ["KC_SPC", "KC_SPACE"]); +add(0x2d, "-", ["KC_MINS", "KC_MINUS"]); +add(0x2e, "=", ["KC_EQL", "KC_EQUAL"]); +add(0x2f, "[", ["KC_LBRC", "KC_LEFT_BRACKET"]); +add(0x30, "]", ["KC_RBRC", "KC_RIGHT_BRACKET"]); +add(0x31, "\\", ["KC_BSLS", "KC_BACKSLASH"]); +add(0x32, "# (ISO)", ["KC_NUHS", "KC_NONUS_HASH"]); +add(0x33, ";", ["KC_SCLN", "KC_SEMICOLON"]); +add(0x34, "'", ["KC_QUOT", "KC_QUOTE"]); +add(0x35, "`", ["KC_GRV", "KC_GRAVE"]); +add(0x36, ",", ["KC_COMM", "KC_COMMA"]); +add(0x37, ".", ["KC_DOT"]); +add(0x38, "/", ["KC_SLSH", "KC_SLASH"]); +add(0x39, "Caps Lock", ["KC_CAPS", "KC_CAPS_LOCK"], "Caps"); +for (let index = 1; index <= 12; index += 1) add(0x39 + index, `F${index}`, [`KC_F${index}`]); +add(0x46, "Print Screen", ["KC_PSCR", "KC_PRINT_SCREEN"], "PrtSc"); +add(0x47, "Scroll Lock", ["KC_SCRL", "KC_SCROLL_LOCK", "KC_SLCK", "KC_BRMD"], "ScrLk"); +add(0x48, "Pause", ["KC_PAUS", "KC_PAUSE", "KC_BRK", "KC_BRMU"]); +add(0x49, "Insert", ["KC_INS", "KC_INSERT"], "Ins"); +add(0x4a, "Home", ["KC_HOME"]); +add(0x4b, "Page Up", ["KC_PGUP", "KC_PAGE_UP"], "PgUp"); +add(0x4c, "Delete", ["KC_DEL", "KC_DELETE"], "Del"); +add(0x4d, "End", ["KC_END"]); +add(0x4e, "Page Down", ["KC_PGDN", "KC_PAGE_DOWN"], "PgDn"); +add(0x4f, "Right", ["KC_RGHT", "KC_RIGHT"]); +add(0x50, "Left", ["KC_LEFT"]); +add(0x51, "Down", ["KC_DOWN"]); +add(0x52, "Up", ["KC_UP"]); +add(0x53, "Num Lock", ["KC_NUM", "KC_NUM_LOCK", "KC_NLCK"], "NumLk"); +add(0x54, "KP /", ["KC_PSLS", "KC_KP_SLASH"]); +add(0x55, "KP *", ["KC_PAST", "KC_KP_ASTERISK"]); +add(0x56, "KP -", ["KC_PMNS", "KC_KP_MINUS"]); +add(0x57, "KP +", ["KC_PPLS", "KC_KP_PLUS"]); +add(0x58, "KP Enter", ["KC_PENT", "KC_KP_ENTER"], "KP Ent"); +for (let index = 1; index <= 9; index += 1) add(0x58 + index, `KP ${index}`, [`KC_P${index}`, `KC_KP_${index}`]); +add(0x62, "KP 0", ["KC_P0", "KC_KP_0"]); +add(0x63, "KP .", ["KC_PDOT", "KC_KP_DOT"]); +add(0x64, "\\ (ISO)", ["KC_NUBS", "KC_NONUS_BACKSLASH"]); +add(0x65, "Menu", ["KC_APP", "KC_APPLICATION"]); +add(0x66, "Power (keyboard)", ["KC_KB_POWER"]); +add(0x67, "KP =", ["KC_PEQL", "KC_KP_EQUAL"]); +for (let index = 13; index <= 24; index += 1) add(0x5b + index, `F${index}`, [`KC_F${index}`]); +add(0x74, "Execute", ["KC_EXEC", "KC_EXECUTE"]); +add(0x75, "Help", ["KC_HELP"]); +add(0x76, "Menu key", ["KC_MENU"]); +add(0x77, "Select", ["KC_SLCT", "KC_SELECT"]); +add(0x78, "Stop", ["KC_STOP"]); +add(0x79, "Again", ["KC_AGIN", "KC_AGAIN"]); +add(0x7a, "Undo", ["KC_UNDO"]); +add(0x7b, "Cut", ["KC_CUT"]); +add(0x7c, "Copy", ["KC_COPY"]); +add(0x7d, "Paste", ["KC_PSTE", "KC_PASTE"]); +add(0x7e, "Find", ["KC_FIND"]); +add(0x7f, "Mute", ["KC_KB_MUTE"]); +add(0x80, "Volume up", ["KC_KB_VOLUME_UP"], "Vol+"); +add(0x81, "Volume down", ["KC_KB_VOLUME_DOWN"], "Vol-"); +add(0x82, "Locking Caps Lock", ["KC_LCAP", "KC_LOCKING_CAPS_LOCK"]); +add(0x83, "Locking Num Lock", ["KC_LNUM", "KC_LOCKING_NUM_LOCK"]); +add(0x84, "Locking Scroll Lock", ["KC_LSCR", "KC_LOCKING_SCROLL_LOCK"]); +add(0x85, "KP ,", ["KC_PCMM", "KC_KP_COMMA"]); +add(0x86, "KP = (AS/400)", ["KC_KP_EQUAL_AS400"]); +for (let index = 1; index <= 9; index += 1) add(0x86 + index, `International ${index}`, [`KC_INT${index}`, `KC_INTERNATIONAL_${index}`, ...[["KC_RO", "KC_KANA", "KC_JYEN", "KC_HENK", "KC_MHEN"][index - 1]].filter((alias): alias is string => alias !== undefined)]); +for (let index = 1; index <= 9; index += 1) add(0x8f + index, `Language ${index}`, [`KC_LNG${index}`, `KC_LANGUAGE_${index}`, `KC_LANG${index}`]); +add(0x99, "Alternate Erase", ["KC_ERAS", "KC_ALTERNATE_ERASE"]); +add(0x9a, "SysReq", ["KC_SYRQ", "KC_SYSTEM_REQUEST"]); +add(0x9b, "Cancel", ["KC_CNCL", "KC_CANCEL"]); +add(0x9c, "Clear", ["KC_CLR", "KC_CLEAR"]); +add(0x9d, "Prior", ["KC_PRIR", "KC_PRIOR"]); +add(0x9e, "Return", ["KC_RETN", "KC_RETURN"]); +add(0x9f, "Separator", ["KC_SEPR", "KC_SEPARATOR"]); +add(0xa0, "Out", ["KC_OUT"]); +add(0xa1, "Oper", ["KC_OPER"]); +add(0xa2, "Clear/Again", ["KC_CLAG", "KC_CLEAR_AGAIN"]); +add(0xa3, "CrSel", ["KC_CRSL", "KC_CRSEL"]); +add(0xa4, "ExSel", ["KC_EXSL", "KC_EXSEL"]); +add(0xa5, "Power", ["KC_PWR", "KC_SYSTEM_POWER"]); +add(0xa6, "Sleep", ["KC_SLEP", "KC_SYSTEM_SLEEP"]); +add(0xa7, "Wake", ["KC_WAKE", "KC_SYSTEM_WAKE"]); +add(0xa8, "Mute", ["KC_MUTE", "KC_AUDIO_MUTE"]); +add(0xa9, "Volume up", ["KC_VOLU", "KC_AUDIO_VOL_UP"], "Vol+"); +add(0xaa, "Volume down", ["KC_VOLD", "KC_AUDIO_VOL_DOWN"], "Vol-"); +add(0xab, "Next track", ["KC_MNXT", "KC_MEDIA_NEXT_TRACK"], "Next"); +add(0xac, "Previous track", ["KC_MPRV", "KC_MEDIA_PREV_TRACK"], "Prev"); +add(0xad, "Stop", ["KC_MSTP", "KC_MEDIA_STOP"]); +add(0xae, "Play/pause", ["KC_MPLY", "KC_MEDIA_PLAY_PAUSE"], "Play"); +add(0xaf, "Media select", ["KC_MSEL", "KC_MEDIA_SELECT"]); +add(0xb0, "Eject", ["KC_EJCT", "KC_MEDIA_EJECT"]); +add(0xb1, "Mail", ["KC_MAIL"]); +add(0xb2, "Calculator", ["KC_CALC", "KC_CALCULATOR"]); +add(0xb3, "My computer", ["KC_MYCM", "KC_MY_COMPUTER"]); +add(0xb4, "Browser search", ["KC_WSCH", "KC_WWW_SEARCH"]); +add(0xb5, "Browser home", ["KC_WHOM", "KC_WWW_HOME"]); +add(0xb6, "Browser back", ["KC_WBAK", "KC_WWW_BACK"]); +add(0xb7, "Browser forward", ["KC_WFWD", "KC_WWW_FORWARD"]); +add(0xb8, "Browser stop", ["KC_WSTP", "KC_WWW_STOP"]); +add(0xb9, "Browser refresh", ["KC_WREF", "KC_WWW_REFRESH"]); +add(0xba, "Browser favorites", ["KC_WFAV", "KC_WWW_FAVORITES"]); +add(0xbb, "Fast forward", ["KC_MFFD", "KC_MEDIA_FAST_FORWARD"]); +add(0xbc, "Rewind", ["KC_MRWD", "KC_MEDIA_REWIND"]); +add(0xbd, "Brightness up", ["KC_BRIU", "KC_BRIGHTNESS_UP"]); +add(0xbe, "Brightness down", ["KC_BRID", "KC_BRIGHTNESS_DOWN"]); +add(0xbf, "Control panel", ["KC_CPNL", "KC_CONTROL_PANEL"]); +add(0xc0, "Assistant", ["KC_ASST", "KC_ASSISTANT"]); +add(0xc1, "Mission Control", ["KC_MCTL", "KC_MISSION_CONTROL"]); +add(0xc2, "Launchpad", ["KC_LPAD", "KC_LAUNCHPAD"]); +add(0xe0, "Left Ctrl", ["KC_LCTL", "KC_LEFT_CTRL"], "LCtrl"); +add(0xe1, "Left Shift", ["KC_LSFT", "KC_LEFT_SHIFT"], "LShift"); +add(0xe2, "Left Alt", ["KC_LALT", "KC_LEFT_ALT", "KC_LOPT"], "LAlt"); +add(0xe3, "Left Win", ["KC_LGUI", "KC_LEFT_GUI", "KC_LCMD", "KC_LWIN"], "LWin"); +add(0xe4, "Right Ctrl", ["KC_RCTL", "KC_RIGHT_CTRL"], "RCtrl"); +add(0xe5, "Right Shift", ["KC_RSFT", "KC_RIGHT_SHIFT"], "RShift"); +add(0xe6, "Right Alt", ["KC_RALT", "KC_RIGHT_ALT", "KC_ROPT", "KC_ALGR"], "RAlt"); +add(0xe7, "Right Win", ["KC_RGUI", "KC_RIGHT_GUI", "KC_RCMD", "KC_RWIN"], "RWin"); + +export const VIA_BASIC_KEYCODES: readonly ViaBasicKeycode[] = ROWS; + +const BY_CODE = new Map(ROWS.map((row) => [row.code, row])); +const BY_NAME = new Map(ROWS.flatMap((row) => row.names.map((name): [string, number] => [name, row.code]))); + +/** The table row for a basic keycode, or null when it has none. */ +export function viaBasicKeycode(code: number): ViaBasicKeycode | null { + return BY_CODE.get(code) ?? null; +} + +/** The short QMK name of a basic keycode, or null when it has none. */ +export function viaKeycodeName(code: number): string | null { + return BY_CODE.get(code)?.names[0] ?? null; +} + +/** The keycode for a QMK name or alias (any letter case), or null when the name is unknown. */ +export function viaKeycodeFromName(name: string): number | null { + return BY_NAME.get(name.trim().toUpperCase()) ?? null; +} + +export type ViaKeycodeGroupId = + | "letters" | "numbers" | "symbols" | "function" | "navigation" | "keypad" | "modifiers" | "media" | "special" | "layers" | "macros" | "lighting" | "custom"; + +export interface ViaKeycodeGroup { + id: ViaKeycodeGroupId; + keycodes: number[]; +} + +export interface ViaKeycodeLabel { + /** Short legend for a key cap. */ + label: string; + /** Longer description for a tooltip. */ + title: string; +} + +interface LayerBases { + modTap: number; + to: number; + momentary: number; + defaultLayer: number; + toggle: number; + oneShotLayer: number; + oneShotMod: number; + layerTapToggle: number; + /** Keycode of macro 0; the 16 macro keys follow it. */ + macro: number; +} + +const MACRO_KEYS = 16; + +const LEGACY_BASES: LayerBases = { + modTap: 0x6000, to: 0x5010, momentary: 0x5100, defaultLayer: 0x5200, toggle: 0x5300, + oneShotLayer: 0x5400, oneShotMod: 0x5500, layerTapToggle: 0x5800, macro: 0x5f12, +}; +const MODERN_BASES: LayerBases = { + modTap: 0x2000, to: 0x5200, momentary: 0x5220, defaultLayer: 0x5240, toggle: 0x5260, + oneShotLayer: 0x5280, oneShotMod: 0x52a0, layerTapToggle: 0x52c0, macro: 0x7702, +}; +const MODERN_BASES_12: LayerBases = { ...MODERN_BASES, macro: 0x7700 }; + +/** QMK renumbered its quantum keycodes at VIA protocol 11; protocol 10 only moved the TO range; protocol 12 moved the macro keys to 0x7700. */ +/** TO only has 16 slots (0x5010 to 0x501f) before protocol 10 moved it; every other layer action holds 32. */ +const layerSlots = (base: number, known: LayerBases, protocolVersion: number): number => (base === known.to && protocolVersion < 10 ? 16 : 32); + +function bases(protocolVersion: number): LayerBases { + if (protocolVersion >= 12) return MODERN_BASES_12; + if (protocolVersion >= 11) return MODERN_BASES; + return protocolVersion === 10 ? { ...LEGACY_BASES, to: 0x5000 } : LEGACY_BASES; +} + +const LAYER_TAP = 0x4000; +const MODS_MIN = 0x0100; +const MODS_MAX = 0x1fff; + +function modifierNames(mask: number): string { + const right = (mask & 0x10) !== 0; + const names = [[0x01, "Ctrl"], [0x02, "Shift"], [0x04, "Alt"], [0x08, "Win"]] as const; + return names.filter(([bit]) => mask & bit).map(([, label]) => `${right ? "R" : ""}${label}`).join("+"); +} + +const SPECIAL: Readonly> = { + 0x0000: { label: "None", title: "No key" }, + 0x0001: { label: "▽", title: "Transparent: falls through to the layer below" }, +}; + +function basic(code: number): ViaKeycodeLabel | undefined { + const known = SPECIAL[code] ?? viaBasicKeycode(code); + return known && { label: known.label, title: known.title }; +} + +const hex = (code: number): string => `0x${code.toString(16).toUpperCase().padStart(4, "0")}`; + +/** Names a 16-bit VIA keycode. Anything unrecognised comes back as hex rather than a guess. */ +export function describeViaKeycode(code: number, protocolVersion: number, custom: readonly ViaCustomKeycode[] = []): ViaKeycodeLabel { + if (code <= 0xff) return basic(code) ?? { label: hex(code), title: `Keycode ${hex(code)}` }; + const known = bases(protocolVersion); + if (code >= MODS_MIN && code <= MODS_MAX) { + const key = basic(code & 0xff); + const mods = modifierNames((code >> 8) & 0x1f); + if (key && mods) return { label: `${mods}+${key.label}`, title: `${mods} + ${key.title}` }; + } else if (code >= known.modTap && code <= known.modTap + 0x1fff) { + const key = basic(code & 0xff); + const mods = modifierNames((code >> 8) & 0x1f); + if (key && mods) return { label: `${key.label} · ${mods}`, title: `Mod-tap: tap ${key.title}, hold ${mods}` }; + } else if (code >= LAYER_TAP && code <= 0x4fff) { + const key = basic(code & 0xff); + const layer = (code >> 8) & 0x0f; + if (key) return { label: `LT(${layer}) ${key.label}`, title: `Layer-tap: tap ${key.title}, hold layer ${layer}` }; + } else { + const layered: readonly (readonly [number, string, string])[] = [ + [known.to, "TO", "Switch to layer"], [known.momentary, "MO", "Hold for layer"], + [known.defaultLayer, "DF", "Set default layer"], [known.toggle, "TG", "Toggle layer"], + [known.oneShotLayer, "OSL", "One-shot layer"], [known.layerTapToggle, "TT", "Tap-toggle layer"], + ]; + for (const [base, abbreviation, description] of layered) { + if (code >= base && code < base + layerSlots(base, known, protocolVersion)) { + const layer = code - base; + return { label: `${abbreviation}(${layer})`, title: `${description} ${layer}` }; + } + } + if (code >= known.macro && code < known.macro + macroKeyLimit(protocolVersion)) { + return { label: `M${code - known.macro}`, title: `Macro ${code - known.macro}` }; + } + if (code >= known.oneShotMod && code <= known.oneShotMod + (protocolVersion >= 11 ? 0x1f : 0xff)) { + const mods = modifierNames(code - known.oneShotMod); + if (mods) return { label: `OSM ${mods}`, title: `One-shot modifier ${mods}` }; + } + } + const lighting = lightingRows(protocolVersion).find((row) => row.code === code); + if (lighting) return { label: lighting.name.replace(/^(BL|RGB|UG|RM)_/, "$1 "), title: lighting.title }; + const base = customKeycodeBase(protocolVersion); + const own = code >= base ? custom[code - base] : undefined; + if (own) return { label: own.shortName ?? own.name, title: own.title ?? own.name }; + return { label: hex(code), title: `Keycode ${hex(code)}` }; +} + +export interface ViaCustomKeycode { + name: string; + title?: string; + shortName?: string; +} + +type LightingFamily = "backlight" | "rgblight" | "matrix"; +interface LightingRow { code: number; name: string; title: string; family: LightingFamily } + +// Names and titles in code order for the legacy numbering; the modern backlight list reorders the first five (see `lightingRows`). +const BACKLIGHT_KEYS: readonly (readonly [string, string])[] = [ + ["BL_ON", "Backlight on"], ["BL_OFF", "Backlight off"], ["BL_DEC", "Backlight down"], ["BL_INC", "Backlight up"], + ["BL_TOGG", "Backlight toggle"], ["BL_STEP", "Backlight step"], ["BL_BRTG", "Backlight breathing"], +]; +const RGB_KEYS: readonly (readonly [string, string])[] = [ + ["RGB_TOG", "RGB toggle"], ["RGB_MOD", "RGB next mode"], ["RGB_RMOD", "RGB previous mode"], ["RGB_HUI", "RGB hue up"], + ["RGB_HUD", "RGB hue down"], ["RGB_SAI", "RGB saturation up"], ["RGB_SAD", "RGB saturation down"], ["RGB_VAI", "RGB brightness up"], + ["RGB_VAD", "RGB brightness down"], ["RGB_SPI", "RGB speed up"], ["RGB_SPD", "RGB speed down"], ["RGB_M_P", "RGB plain"], + ["RGB_M_B", "RGB breathe"], ["RGB_M_R", "RGB rainbow"], ["RGB_M_SW", "RGB swirl"], ["RGB_M_SN", "RGB snake"], + ["RGB_M_K", "RGB knight"], ["RGB_M_X", "RGB Xmas"], ["RGB_M_G", "RGB gradient"], +]; +const UNDERGLOW_KEYS: readonly (readonly [string, string])[] = [ + ["UG_TOGG", "Underglow toggle"], ["UG_NEXT", "Underglow next"], ["UG_PREV", "Underglow previous"], ["UG_HUEU", "Underglow hue up"], + ["UG_HUED", "Underglow hue down"], ["UG_SATU", "Underglow saturation up"], ["UG_SATD", "Underglow saturation down"], + ["UG_VALU", "Underglow brightness up"], ["UG_VALD", "Underglow brightness down"], ["UG_SPDU", "Underglow speed up"], ["UG_SPDD", "Underglow speed down"], +]; +const MATRIX_KEYS: readonly (readonly [string, string])[] = [ + ["RM_ON", "Matrix on"], ["RM_OFF", "Matrix off"], ["RM_TOGG", "Matrix toggle"], ["RM_NEXT", "Matrix next effect"], ["RM_PREV", "Matrix previous effect"], + ["RM_HUEU", "Matrix hue up"], ["RM_HUED", "Matrix hue down"], ["RM_SATU", "Matrix saturation up"], ["RM_SATD", "Matrix saturation down"], + ["RM_VALU", "Matrix brightness up"], ["RM_VALD", "Matrix brightness down"], ["RM_SPDU", "Matrix speed up"], ["RM_SPDD", "Matrix speed down"], +]; + +const lightingCache = new Map(); + +/** The lighting keycodes of a protocol generation: legacy (to 10), 11, 12 (adds RM_), 13 (RGB_ becomes UG_). */ +function lightingRows(protocolVersion: number): LightingRow[] { + const generation = protocolVersion >= 13 ? 13 : protocolVersion >= 12 ? 12 : protocolVersion >= 11 ? 11 : 0; + const cached = lightingCache.get(generation); + if (cached) return cached; + const rows: LightingRow[] = []; + const push = (family: LightingFamily, base: number, list: readonly (readonly [string, string])[]): void => + list.forEach(([name, title], index) => rows.push({ code: base + index, name, title, family })); + const [on, off, dec, inc, toggle, step, breathing] = BACKLIGHT_KEYS; + push("backlight", generation ? 0x7800 : 0x5cbb, generation ? [on!, off!, toggle!, dec!, inc!, step!, breathing!] : BACKLIGHT_KEYS); + if (generation === 13) { + push("rgblight", 0x7820, UNDERGLOW_KEYS); + } else { + push("rgblight", generation ? 0x7820 : 0x5cc2, generation ? [...RGB_KEYS, ["RGB_MODE_RGBTEST", "RGB test"]] : RGB_KEYS); + } + if (generation >= 12) push("matrix", 0x7840, MATRIX_KEYS); + lightingCache.set(generation, rows); + return rows; +} + +/** Lighting keycodes worth offering for the keycode modules a definition lists. */ +export function viaLightingKeycodes(protocolVersion: number, modules: readonly string[]): number[] { + const has = (...names: string[]): boolean => names.some((name) => modules.includes(name)); + const families = new Set(); + if (has("qmk_lighting", "qmk_backlight_keycodes", "qmk_backlight_rgblight_keycodes")) families.add("backlight"); + if (has("qmk_lighting", "qmk_rgblight_keycodes", "qmk_backlight_rgblight_keycodes")) families.add("rgblight"); + // VIA offers the shared RGB_ codes for matrix boards up to protocol 12; the RM_ family is its own only from 13. + if (has("qmk_rgb_matrix_keycodes")) families.add(protocolVersion >= 13 ? "matrix" : "rgblight"); + return lightingRows(protocolVersion).filter((row) => families.has(row.family)).map((row) => row.code); +} + +/** Where a keyboard's own custom keycodes start. */ +/** How many macro keycodes the protocol generation reserves (the board's own count may be smaller). */ +const macroKeyLimit = (protocolVersion: number): number => (protocolVersion >= 12 ? 0x80 : MACRO_KEYS); +/** How many custom keycodes the protocol generation reserves. */ +const customKeycodeLimit = (protocolVersion: number): number => (protocolVersion >= 11 ? 0x100 : 0x80); + +const customKeycodeBase = (protocolVersion: number): number => (protocolVersion >= 12 ? 0x7e00 : protocolVersion === 11 ? 0x7f00 : 0x5f80); + +/** Modifier bits for `viaComposeKeycode`; `right` selects the right-hand set. */ +export const VIA_MOD = { ctrl: 0x01, shift: 0x02, alt: 0x04, gui: 0x08, right: 0x10 } as const; + +export type ViaCompose = + | { kind: "layerTap"; layer: number; key: number } + | { kind: "modTap"; mods: number; key: number } + | { kind: "mods"; mods: number; key: number }; + +/** Builds a layer-tap, mod-tap or modifier+key keycode, which the picker cannot list one by one. */ +export function viaComposeKeycode(compose: ViaCompose, protocolVersion: number): number { + if (!Number.isInteger(compose.key) || compose.key < 1 || compose.key > 0xff) throw new RangeError("The key must be a basic keycode (1 to 255)."); + if (compose.kind === "layerTap") { + if (!Number.isInteger(compose.layer) || compose.layer < 0 || compose.layer > 15) throw new RangeError("A layer-tap layer must be 0 to 15."); + return LAYER_TAP | (compose.layer << 8) | compose.key; + } + if (!Number.isInteger(compose.mods) || (compose.mods & 0x0f) === 0 || compose.mods > 0x1f) throw new RangeError("Pick at least one modifier."); + return (compose.kind === "modTap" ? bases(protocolVersion).modTap : 0) | (compose.mods << 8) | compose.key; +} + +// KeyboardEvent.code values for the basic keycodes, so a host keystroke can become a keycode and a keycode can borrow the OS layout's legend. +const EVENT_CODES: Record = { + Enter: 0x28, Escape: 0x29, Backspace: 0x2a, Tab: 0x2b, Space: 0x2c, Minus: 0x2d, Equal: 0x2e, BracketLeft: 0x2f, BracketRight: 0x30, + Backslash: 0x31, Semicolon: 0x33, Quote: 0x34, Backquote: 0x35, Comma: 0x36, Period: 0x37, Slash: 0x38, CapsLock: 0x39, + PrintScreen: 0x46, ScrollLock: 0x47, Pause: 0x48, Insert: 0x49, Home: 0x4a, PageUp: 0x4b, Delete: 0x4c, End: 0x4d, PageDown: 0x4e, + ArrowRight: 0x4f, ArrowLeft: 0x50, ArrowDown: 0x51, ArrowUp: 0x52, NumLock: 0x53, NumpadDivide: 0x54, NumpadMultiply: 0x55, + NumpadSubtract: 0x56, NumpadAdd: 0x57, NumpadEnter: 0x58, NumpadDecimal: 0x63, IntlBackslash: 0x64, ContextMenu: 0x65, + ControlLeft: 0xe0, ShiftLeft: 0xe1, AltLeft: 0xe2, MetaLeft: 0xe3, ControlRight: 0xe4, ShiftRight: 0xe5, AltRight: 0xe6, MetaRight: 0xe7, +}; +for (let i = 0; i < 26; i++) EVENT_CODES[`Key${String.fromCharCode(65 + i)}`] = 0x04 + i; +for (let i = 1; i <= 9; i++) { EVENT_CODES[`Digit${i}`] = 0x1d + i; EVENT_CODES[`Numpad${i}`] = 0x58 + i; } +EVENT_CODES.Digit0 = 0x27; +EVENT_CODES.Numpad0 = 0x62; +for (let i = 1; i <= 12; i++) EVENT_CODES[`F${i}`] = 0x39 + i; +for (let i = 13; i <= 24; i++) EVENT_CODES[`F${i}`] = 0x5b + i; +const EVENT_CODE_BY_KEYCODE = new Map(Object.entries(EVENT_CODES).map(([event, keycode]) => [keycode, event])); + +/** The basic keycode for a `KeyboardEvent.code`, or null for keys VIA has no basic keycode for. */ +export const viaKeycodeFromEventCode = (eventCode: string): number | null => EVENT_CODES[eventCode] ?? null; +/** The `KeyboardEvent.code` of a basic keycode, which `navigator.keyboard.getLayoutMap()` can turn into the user's legend. */ +export const viaEventCodeFromKeycode = (keycode: number): string | null => EVENT_CODE_BY_KEYCODE.get(keycode) ?? null; + +const HAND_MODS = [["CTL", 0x01], ["SFT", 0x02], ["ALT", 0x04], ["GUI", 0x08]] as const; + +/** A keycode as VIA writes it in a `.layout.json` file: KC_A, MO(1), LT(1,KC_A), LCTL(KC_A), MT(MOD_LCTL,KC_A), or hex when VIA has no name. */ +export function viaKeycodeToLayoutName(code: number, protocolVersion: number): string { + const known = bases(protocolVersion); + if (code === 0) return "KC_NO"; + if (code === 1) return "KC_TRNS"; + const basicName = code <= 0xff ? viaKeycodeName(code) : null; + if (basicName) return basicName; + if (code >= known.macro && code < known.macro + macroKeyLimit(protocolVersion)) return `MACRO(${code - known.macro})`; + const key = viaKeycodeName(code & 0xff); + const mods = (code >> 8) & 0x1f; + if (key && code >= MODS_MIN && code <= MODS_MAX && mods) { + const side = mods & 0x10 ? "R" : "L"; + return HAND_MODS.filter(([, bit]) => mods & bit).reduceRight((inner, [name]) => `${side}${name}(${inner})`, key); + } + if (key && code >= known.modTap && code <= known.modTap + 0x1fff && mods) { + const side = mods & 0x10 ? "R" : "L"; + return `MT(${HAND_MODS.filter(([, bit]) => mods & bit).map(([name]) => `MOD_${side}${name}`).join(" | ")},${key})`; + } + if (key && code >= LAYER_TAP && code <= 0x4fff) return `LT(${(code >> 8) & 0x0f},${key})`; + if (code >= known.oneShotMod && code <= known.oneShotMod + 0x1f && (code & 0x0f) !== 0) return `OSM(${modMaskName(code & 0x1f)})`; + for (const [base, name] of layerFunctions(known)) if (code >= base && code < base + layerSlots(base, known, protocolVersion)) return `${name}(${code - base})`; + const lighting = lightingRows(protocolVersion).find((row) => row.code === code); + return lighting ? lighting.name : `0x${code.toString(16)}`; +} + +/** `MOD_LCTL | MOD_LSFT` for a 5-bit modifier mask (bit 4 is the right-hand side). */ +function modMaskName(mods: number): string { + const side = mods & 0x10 ? "R" : "L"; + return HAND_MODS.filter(([, bit]) => mods & bit).map(([name]) => `MOD_${side}${name}`).join(" | "); +} + +/** The inverse of `modMaskName`; null when a part is not a MOD_ name. */ +function parseModMask(text: string): number | null { + const mask = text.split("|").reduce((sum, part) => { + const match = /^MOD_([LR])(CTL|SFT|ALT|GUI)$/.exec(part.trim().toUpperCase()); + const bit = HAND_MODS.find(([hand]) => hand === match?.[2])?.[1]; + return sum === null || !match || bit === undefined ? null : sum | bit | (match[1] === "R" ? 0x10 : 0); + }, 0); + return mask === null || mask === 0 ? null : mask; +} + +const layerFunctions = (known: LayerBases): readonly (readonly [number, string])[] => [ + [known.to, "TO"], [known.momentary, "MO"], [known.defaultLayer, "DF"], [known.toggle, "TG"], [known.oneShotLayer, "OSL"], [known.layerTapToggle, "TT"], +]; + +/** The inverse of `viaKeycodeToLayoutName`; null when the name is not understood. */ +export function viaKeycodeFromLayoutName(name: string, protocolVersion: number): number | null { + const text = name.trim(); + const upper = text.toUpperCase(); + if (upper === "KC_NO" || upper === "XXXXXXX") return 0; + if (upper === "KC_TRNS" || upper === "KC_TRANSPARENT" || upper === "_______") return 1; + const direct = viaKeycodeFromName(text); + if (direct !== null) return direct; + const numbered = /^(MACRO|CUSTOM)\((\d+)\)$/.exec(upper); + if (numbered) { + const n = Number(numbered[2]); + if (numbered[1] === "MACRO") return n < macroKeyLimit(protocolVersion) ? bases(protocolVersion).macro + n : null; + return n < customKeycodeLimit(protocolVersion) ? customKeycodeBase(protocolVersion) + n : null; + } + const hexMatch = /^0x([0-9a-f]{1,4})$/i.exec(text); + if (hexMatch) return Number.parseInt(hexMatch[1]!, 16); + const lighting = lightingRows(protocolVersion).find((row) => row.name === text.toUpperCase()); + if (lighting) return lighting.code; + const known = bases(protocolVersion); + const layered = /^([A-Z]+)\((\d+)\)$/.exec(text.toUpperCase()); + if (layered) { + const base = layerFunctions(known).find(([, functionName]) => functionName === layered[1])?.[0]; + const layer = Number(layered[2]); + return base !== undefined && layer < layerSlots(base, known, protocolVersion) ? base + layer : null; + } + const layerTap = /^LT\(\s*(\d+)\s*,\s*([^)]+)\)$/i.exec(text); + if (layerTap) { + const key = viaKeycodeFromName(layerTap[2]!); + return key !== null && key <= 0xff && Number(layerTap[1]) < 16 ? LAYER_TAP | (Number(layerTap[1]) << 8) | key : null; + } + const modTap = /^MT\(\s*([^,]+),\s*([^)]+)\)$/i.exec(text); + if (modTap) { + const key = viaKeycodeFromName(modTap[2]!); + const mods = modTap[1]!.split("|").reduce((sum, part) => { + const match = /^MOD_([LR])(CTL|SFT|ALT|GUI)$/.exec(part.trim().toUpperCase()); + const bit = HAND_MODS.find(([hand]) => hand === match?.[2])?.[1]; + return sum === null || !match || bit === undefined ? null : sum | bit | (match[1] === "R" ? 0x10 : 0); + }, 0); + return key !== null && key <= 0xff && mods ? known.modTap | (mods << 8) | key : null; + } + const oneShot = /^OSM\(\s*([^)]+)\)$/i.exec(text); + if (oneShot) { + const mods = parseModMask(oneShot[1]!); + return mods === null ? null : known.oneShotMod | mods; + } + // LCTL(..) style wrappers plus QMK's short forms: C S A G (left hand), MEH (ctrl shift alt) and HYPR (all four). + const wrapped = /^((?:[LR](?:CTL|SFT|ALT|GUI))|C|S|A|G|MEH|HYPR)\((.+)\)$/i.exec(text); + if (wrapped) { + const inner = viaKeycodeFromLayoutName(wrapped[2]!, protocolVersion); + const modifier = wrapped[1]!.toUpperCase(); + const long = /^([LR])(CTL|SFT|ALT|GUI)$/.exec(modifier); + const bit = long + ? HAND_MODS.find(([hand]) => hand === long[2])?.[1] + : ({ C: 0x01, S: 0x02, A: 0x04, G: 0x08, MEH: 0x07, HYPR: 0x0f } as Record)[modifier]; + if (inner === null || bit === undefined || inner > 0x1fff || (inner >= 0x100 && inner < MODS_MIN)) return null; + return inner | ((bit | (long?.[1] === "R" ? 0x10 : 0)) << 8); + } + return null; +} + +const range = (from: number, to: number): number[] => Array.from({ length: to - from + 1 }, (_, index) => from + index); + +/** Keycodes the editor offers, grouped. Layer keys are generated for the board's real layer count. */ +export function viaKeycodePicker( + protocolVersion: number, + layerCount: number, + macroCount = 0, + options: { modules?: readonly string[]; custom?: readonly ViaCustomKeycode[] } = {}, +): ViaKeycodeGroup[] { + const known = bases(protocolVersion); + const layers = range(0, Math.min(Math.max(layerCount, 0), 32) - 1); + const macros = range(0, Math.min(Math.max(macroCount, 0), macroKeyLimit(protocolVersion)) - 1); + const groups: ViaKeycodeGroup[] = [ + { id: "letters", keycodes: range(0x04, 0x1d) }, + { id: "numbers", keycodes: range(0x1e, 0x27) }, + { id: "symbols", keycodes: [...range(0x28, 0x39), 0x64, 0x65] }, + { id: "function", keycodes: [...range(0x3a, 0x45), ...range(0x68, 0x73)] }, + { id: "navigation", keycodes: range(0x46, 0x52) }, + { id: "keypad", keycodes: range(0x53, 0x63) }, + { id: "modifiers", keycodes: range(0xe0, 0xe7) }, + { id: "media", keycodes: [...range(0xa8, 0xbe), 0x7f, 0x80, 0x81] }, + { id: "special", keycodes: [0x0000, 0x0001] }, + { + id: "layers", + keycodes: layers.flatMap((layer) => [known.momentary, known.toggle, known.to, known.layerTapToggle, known.oneShotLayer].filter((base) => layer < layerSlots(base, known, protocolVersion)).map((base) => base + layer)), + }, + { id: "macros", keycodes: macros.map((macro) => known.macro + macro) }, + { id: "lighting", keycodes: viaLightingKeycodes(protocolVersion, options.modules ?? []) }, + { id: "custom", keycodes: (options.custom ?? []).map((_, index) => customKeycodeBase(protocolVersion) + index) }, + ]; + return groups.filter((group) => group.keycodes.length > 0 || (group.id !== "lighting" && group.id !== "custom")); +} + +export type ViaMacroAction = + | { type: "text"; text: string } + | { type: "tap" | "down" | "up"; keycode: number } + | { type: "delay"; ms: number } + /** Bytes this codec does not understand, kept exactly so a rewrite never damages them. */ + | { type: "raw"; bytes: number[] }; + +/** Delays arrived with VIA protocol 11. */ +export const VIA_MACRO_DELAY_PROTOCOL = 11; +export const VIA_MACRO_MAX_DELAY_MS = 9999; + +const PREFIX = 0x01; +const TAP = 0x01; +const DOWN = 0x02; +const UP = 0x03; +const DELAY = 0x04; +const DELAY_END = 0x7c; +const KEY_ACTION = { [TAP]: "tap", [DOWN]: "down", [UP]: "up" } as const; + +const isTextByte = (byte: number): boolean => byte === 0x09 || byte === 0x0a || (byte >= 0x20 && byte <= 0x7e); + +/** Protocol 10 and older have no prefix byte: a key action is `01|02|03 keycode`, and there are no delays. */ +function decodeLegacyMacro(bytes: readonly number[]): ViaMacroAction[] { + const actions: ViaMacroAction[] = []; + let text = ""; + const flush = (): void => { + if (text) actions.push({ type: "text", text }); + text = ""; + }; + let index = 0; + while (index < bytes.length) { + const byte = bytes[index]!; + const argument = bytes[index + 1]; + if ((byte === TAP || byte === DOWN || byte === UP) && argument !== undefined && argument !== 0) { + flush(); + actions.push({ type: KEY_ACTION[byte], keycode: argument }); + index += 2; + } else if (isTextByte(byte)) { + text += String.fromCharCode(byte); + index += 1; + } else { + flush(); + actions.push({ type: "raw", bytes: bytes.slice(index) }); + return actions; + } + } + flush(); + return actions; +} + +/** + * One macro's bytes (without the 0x00 that ends it) as editable actions. Text is + * plain ASCII, key actions are `01 01|02|03 keycode`, and a delay is + * `01 04 |`. Anything else is returned as `raw`, so + * `encodeViaMacro(decodeViaMacro(bytes))` gives back exactly `bytes`. + */ +export function decodeViaMacro(bytes: readonly number[], protocolVersion: number = VIA_MACRO_DELAY_PROTOCOL): ViaMacroAction[] { + if (protocolVersion < VIA_MACRO_DELAY_PROTOCOL) return decodeLegacyMacro(bytes); + const actions: ViaMacroAction[] = []; + let text = ""; + const flush = (): void => { + if (text) actions.push({ type: "text", text }); + text = ""; + }; + let index = 0; + while (index < bytes.length) { + const byte = bytes[index]!; + if (byte === PREFIX) { + const kind = bytes[index + 1]; + const argument = bytes[index + 2]; + if ((kind === TAP || kind === DOWN || kind === UP) && argument !== undefined && argument !== 0) { + flush(); + actions.push({ type: KEY_ACTION[kind], keycode: argument }); + index += 3; + continue; + } + if (kind === DELAY) { + const end = bytes.indexOf(DELAY_END, index + 2); + const digits = end > index + 2 ? String.fromCharCode(...bytes.slice(index + 2, end)) : ""; + if (/^(?:0|[1-9]\d{0,3})$/.test(digits)) { + flush(); + actions.push({ type: "delay", ms: Number(digits) }); + index = end + 1; + continue; + } + } + flush(); + actions.push({ type: "raw", bytes: bytes.slice(index) }); + return actions; + } + if (isTextByte(byte)) { + text += String.fromCharCode(byte); + index += 1; + continue; + } + flush(); + let end = index; + while (end < bytes.length && bytes[end] !== PREFIX && !isTextByte(bytes[end]!)) end += 1; + actions.push({ type: "raw", bytes: bytes.slice(index, end) }); + index = end; + } + flush(); + return actions; +} + +/** The inverse of `decodeViaMacro`; throws a RangeError for anything the board could not store. */ +export function encodeViaMacro(actions: readonly ViaMacroAction[], protocolVersion: number): number[] { + return actions.flatMap((action): number[] => { + switch (action.type) { + case "text": { + const codes = [...action.text].map((character) => character.charCodeAt(0)); + if (codes.some((code) => !isTextByte(code))) throw new RangeError("Macro text can only hold plain ASCII characters."); + return codes; + } + case "tap": + case "down": + case "up": { + if (!Number.isInteger(action.keycode) || action.keycode < 1 || action.keycode > 0xff) throw new RangeError("A macro key must be a basic keycode (1 to 255)."); + const kind = { tap: TAP, down: DOWN, up: UP }[action.type]; + return protocolVersion < VIA_MACRO_DELAY_PROTOCOL ? [kind, action.keycode] : [PREFIX, kind, action.keycode]; + } + case "delay": { + if (protocolVersion < VIA_MACRO_DELAY_PROTOCOL) throw new RangeError("Delays need VIA protocol 11 or newer."); + if (!Number.isInteger(action.ms) || action.ms < 0 || action.ms > VIA_MACRO_MAX_DELAY_MS) throw new RangeError(`A delay must be 0 to ${VIA_MACRO_MAX_DELAY_MS} ms.`); + return [PREFIX, DELAY, ...[...String(action.ms)].map((digit) => digit.charCodeAt(0)), DELAY_END]; + } + case "raw": { + if (action.bytes.some((byte) => !Number.isInteger(byte) || byte < 1 || byte > 0xff)) throw new RangeError("Raw macro bytes must be 1 to 255."); + return [...action.bytes]; + } + } + }); +} + +/** + * VIA's macro text syntax. Plain text is typed as written. A block in braces is + * one action: `{KC_A}` presses a key, `{KC_LCTL,KC_C}` presses a chord (hold in + * order, release in reverse), `{+KC_LSFT}` holds a key, `{-KC_LSFT}` releases it + * and `{500}` waits 500 ms. `\{` types a brace and `\\` a backslash. A block + * `{raw:1,5,4}` keeps bytes this editor does not understand. + */ +export class ViaMacroExpressionError extends Error { + readonly index: number; + constructor(message: string, index: number) { + super(message); + this.name = "ViaMacroExpressionError"; + this.index = index; + } +} + +const isTextCharacter = (code: number): boolean => code === 0x09 || code === 0x0a || (code >= 0x20 && code <= 0x7e); +const KEY_KIND = { tap: 1, down: 2, up: 3 } as const; + +function lookup(name: string, index: number): number { + const code = viaKeycodeFromName(name); + if (code === null) throw new ViaMacroExpressionError(`Unknown keycode "${name}". Use QMK names such as KC_A or KC_ENT.`, index); + return code; +} + +function block(body: string, index: number, protocolVersion: number): ViaMacroAction[] { + if (/^\d+$/.test(body)) { + const ms = Number(body); + if (protocolVersion < VIA_MACRO_DELAY_PROTOCOL) throw new ViaMacroExpressionError("Waits need VIA protocol 11 or newer.", index); + if (ms > VIA_MACRO_MAX_DELAY_MS) throw new ViaMacroExpressionError(`A wait must be 0 to ${VIA_MACRO_MAX_DELAY_MS} ms.`, index); + return [{ type: "delay", ms }]; + } + if (/^raw:/i.test(body)) { + const bytes = body.slice(4).split(",").map(Number); + if (bytes.some((byte) => !Number.isInteger(byte) || byte < 1 || byte > 0xff)) throw new ViaMacroExpressionError("Raw bytes must be whole numbers from 1 to 255.", index); + return [{ type: "raw", bytes }]; + } + if (body.startsWith("+")) return [{ type: "down", keycode: lookup(body.slice(1), index) }]; + if (body.startsWith("-")) return [{ type: "up", keycode: lookup(body.slice(1), index) }]; + const codes = body.split(",").map((name) => lookup(name, index)); + if (codes.length === 1) return [{ type: "tap", keycode: codes[0]! }]; + return [ + ...codes.map((keycode): ViaMacroAction => ({ type: "down", keycode })), + ...[...codes].reverse().map((keycode): ViaMacroAction => ({ type: "up", keycode })), + ]; +} + +/** Reads a macro written in VIA's text syntax; throws a `ViaMacroExpressionError` that says where it went wrong. */ +export function parseViaMacroExpression(expression: string, protocolVersion: number): ViaMacroAction[] { + const actions: ViaMacroAction[] = []; + let text = ""; + let textStart = 0; + const flush = (): void => { + if (!text) return; + const bad = [...text].findIndex((character) => !isTextCharacter(character.charCodeAt(0))); + if (bad !== -1) throw new ViaMacroExpressionError("Macro text can only hold plain ASCII characters.", textStart + bad); + actions.push({ type: "text", text }); + text = ""; + }; + let index = 0; + while (index < expression.length) { + const character = expression[index]!; + if (character === "\\" && (expression[index + 1] === "{" || expression[index + 1] === "\\")) { + if (!text) textStart = index; + text += expression[index + 1]; + index += 2; + continue; + } + if (character === "{") { + const end = expression.indexOf("}", index + 1); + if (end === -1) throw new ViaMacroExpressionError("This { has no closing }.", index); + const body = expression.slice(index + 1, end).replace(/\s+/g, ""); + if (!body) throw new ViaMacroExpressionError("An empty {} does nothing. Put a key inside, or write \\{ for a literal brace.", index); + flush(); + actions.push(...block(body, index, protocolVersion)); + index = end + 1; + continue; + } + if (!text) textStart = index; + text += character; + index += 1; + } + flush(); + return actions; +} + +const escapeText = (text: string): string => text.replace(/\\/g, "\\\\").replace(/\{/g, "\\{"); + +/** Writes actions in VIA's text syntax; a key with no QMK name is kept as a `{raw:...}` block. */ +export function viaMacroToExpression(actions: readonly ViaMacroAction[], protocolVersion: number = VIA_MACRO_DELAY_PROTOCOL): string { + const rawKey = (type: keyof typeof KEY_KIND, keycode: number): string => + `{raw:${protocolVersion < VIA_MACRO_DELAY_PROTOCOL ? "" : "1,"}${KEY_KIND[type]},${keycode}}`; + const parts: string[] = []; + let index = 0; + while (index < actions.length) { + const action = actions[index]!; + if (action.type === "text") parts.push(escapeText(action.text)); + else if (action.type === "delay") parts.push(`{${action.ms}}`); + else if (action.type === "raw") parts.push(`{raw:${action.bytes.join(",")}}`); + else if (action.type === "tap") { + const name = viaKeycodeName(action.keycode); + parts.push(name ? `{${name}}` : rawKey("tap", action.keycode)); + } else if (action.type === "up") { + const name = viaKeycodeName(action.keycode); + parts.push(name ? `{-${name}}` : rawKey("up", action.keycode)); + } else { + let downs = 0; + while (actions[index + downs]?.type === "down") downs += 1; + const pressed = actions.slice(index, index + downs).map((entry) => (entry as { keycode: number }).keycode); + const released = actions.slice(index + downs, index + 2 * downs); + const chord = released.length === downs + && released.every((entry, at) => entry.type === "up" && entry.keycode === pressed[downs - 1 - at]) + && pressed.every((code) => viaKeycodeName(code) !== null); + if (chord) { + parts.push(`{${pressed.map((code) => viaKeycodeName(code)).join(",")}}`); + index += 2 * downs; + continue; + } + const name = viaKeycodeName(action.keycode); + parts.push(name ? `{+${name}}` : rawKey("down", action.keycode)); + } + index += 1; + } + return parts.join(""); +} + +export type ViaControl = + | { type: "range"; min: number; max: number; unit?: string } + | { type: "toggle"; off: number; on: number } + | { type: "dropdown"; options: { label: string; value: number }[] } + | { type: "color" } + | { type: "button"; value: number }; + +export interface ViaMenuItem { + label: string; + /** The definition's name for this value, used by `showIf` expressions. */ + token: string; + /** `[channel, valueId, ...]`, the ids the custom-menu commands take. */ + ids: number[]; + /** + * The single value id old firmware (protocol 10 and older) expects instead of `ids`, for the lighting a v2 + * definition names; those boards take `id` followed straight by the data, with no channel. + */ + legacyId?: number; + /** Width of the value on the wire, 1 to 4 bytes. */ + bytes: number; + showIf?: string; + control: ViaControl; +} + +export interface ViaMenuSection { + label: string; + items: ViaMenuItem[]; +} + +export interface ViaMenu { + label: string; + showIf?: string; + sections: ViaMenuSection[]; +} + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +const text = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined); +const join = (outer?: string, inner?: string): string | undefined => (outer && inner ? `(${outer}) && (${inner})` : outer ?? inner); +/** A toggle's off/on value: a number, or a byte array such as [0, 1] packed big-endian. */ +const toggleValue = (value: unknown, fallback: number): number => + Array.isArray(value) && value.length > 1 && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 0xff) + ? value.reduce((sum, byte: number) => sum * 256 + byte, 0) + : firstNumber(value, fallback); + +const firstNumber = (value: unknown, fallback: number): number => { + const candidate = Array.isArray(value) ? value[0] : value; + return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : fallback; +}; + +function toControl(entry: Record): ViaControl | null { + const options = entry.options; + switch (entry.type) { + case "range": { + if (!Array.isArray(options) || typeof options[0] !== "number" || typeof options[1] !== "number") return null; + const unit = text(entry.unit); + return { type: "range", min: options[0], max: options[1], ...(unit ? { unit } : {}) }; + } + case "toggle": + return { type: "toggle", off: toggleValue(Array.isArray(options) ? options[0] : undefined, 0), on: toggleValue(Array.isArray(options) ? options[1] : undefined, 1) }; + case "dropdown": { + if (!Array.isArray(options)) return null; + const choices = options.flatMap((option, index) => { + if (typeof option === "string") return [{ label: option, value: index }]; + if (Array.isArray(option) && typeof option[0] === "string") return [{ label: option[0], value: firstNumber(option[1], index) }]; + return []; + }); + return choices.length > 0 ? { type: "dropdown", options: choices } : null; + } + case "color": return { type: "color" }; + case "button": return { type: "button", value: firstNumber(options, 1) }; + default: return null; + } +} + +function toItem(entry: Record, showIf: string | undefined): ViaMenuItem | null { + const content = entry.content; + if (!Array.isArray(content) || typeof content[0] !== "string") return null; + const ids = content.slice(1); + if (ids.length < 2 || ids.some((id) => !Number.isInteger(id) || id < 0 || id > 0xff)) return null; + const control = toControl(entry); + if (!control) return null; + // Without an explicit width, size the value to the largest number the control can carry. + const largest = control.type === "range" ? control.max : control.type === "button" ? control.value : control.type === "toggle" ? Math.max(control.off, control.on) : 0; + const arrayWidth = control.type === "toggle" && Array.isArray(entry.options) ? Math.max(1, ...entry.options.map((option) => (Array.isArray(option) ? option.length : 1))) : 1; + const inferred = Math.min(4, Math.max(arrayWidth, largest > 0xffffff ? 4 : largest > 0xffff ? 3 : largest > 0xff ? 2 : 1)); + const bytes = typeof entry.bytes === "number" && entry.bytes >= 1 && entry.bytes <= 4 ? Math.floor(entry.bytes) : inferred; + const legacyId = Number.isInteger(entry.legacyId) && (entry.legacyId as number) >= 0 && (entry.legacyId as number) <= 0xff ? (entry.legacyId as number) : undefined; + return { label: text(entry.label) ?? "", token: content[0], ids: ids as number[], ...(legacyId !== undefined ? { legacyId } : {}), bytes, ...(showIf ? { showIf } : {}), control }; +} + +function collectItems(content: unknown, inherited?: string): ViaMenuItem[] { + if (!Array.isArray(content)) return []; + return content.flatMap((entry) => { + if (!isRecord(entry)) return []; + const showIf = join(inherited, text(entry.showIf)); + if (typeof entry.type === "string") { + const item = toItem(entry, showIf); + return item ? [item] : []; + } + return collectItems(entry.content, showIf); + }); +} + +/** + * Normalises a definition's `menus` into controls the app can draw. Items the + * app cannot drive (static text, keycode pickers, palettes) are dropped, and + * groups are flattened with their `showIf` folded into each item. + */ +export function parseViaMenus(menus: readonly unknown[]): ViaMenu[] { + return menus.flatMap((menu) => { + if (!isRecord(menu) || !Array.isArray(menu.content)) return []; + const menuCondition = text(menu.showIf); + const loose = collectItems(menu.content.filter((entry) => isRecord(entry) && typeof entry.type === "string"), menuCondition); + const sections: ViaMenuSection[] = [ + ...(loose.length > 0 ? [{ label: "", items: loose }] : []), + ...menu.content.flatMap((entry) => { + if (!isRecord(entry) || typeof entry.type === "string") return []; + const items = collectItems(entry.content, join(menuCondition, text(entry.showIf))); + return items.length > 0 ? [{ label: text(entry.label) ?? "", items }] : []; + }), + ]; + return sections.length > 0 ? [{ label: text(menu.label) ?? "", ...(menuCondition ? { showIf: menuCondition } : {}), sections }] : []; + }); +} + +/** Unsigned big-endian value of a custom-menu reply. */ +export function viaValueToNumber(bytes: readonly number[]): number { + return bytes.reduce((total, byte) => total * 256 + byte, 0); +} + +export function viaNumberToBytes(value: number, length: number): number[] { + if (!Number.isInteger(value) || value < 0 || value >= 256 ** length) throw new RangeError("VIA value does not fit its byte width."); + return Array.from({ length }, (_, index) => Math.floor(value / 256 ** (length - 1 - index)) % 256); +} + +const TOKEN = /\s*(\{[^}]+\}|\d+(?:\.\d+)?|==|!=|<=|>=|&&|\|\||[<>()!])/y; + +/** + * Evaluates a `showIf` expression such as `{id_effect} != 0 && {id_effect} != 24`. + * `lookup` maps a token to its current value. An expression that cannot be + * parsed shows the control rather than hiding it. + */ +export function evaluateViaCondition(expression: string, lookup: (token: string) => number | undefined): boolean { + const tokens: string[] = []; + TOKEN.lastIndex = 0; + let consumed = 0; + for (let match = TOKEN.exec(expression); match; match = TOKEN.exec(expression)) { + tokens.push(match[1]!); + consumed = TOKEN.lastIndex; + } + if (expression.slice(consumed).trim() !== "") return true; + let position = 0; + const peek = (): string | undefined => tokens[position]; + const take = (): string => tokens[position++]!; + + function primary(): number { + const token = take(); + if (token === undefined) throw new Error("unexpected end"); + if (token === "(") { + const inner = or(); + if (take() !== ")") throw new Error("missing )"); + return inner; + } + if (token === "!") return primary() === 0 ? 1 : 0; + if (token.startsWith("{")) return lookup(token.slice(1, -1).trim()) ?? 0; + const number = Number(token); + if (Number.isNaN(number)) throw new Error("unexpected token"); + return number; + } + function comparison(): number { + const left = primary(); + const operator = peek(); + if (operator !== "==" && operator !== "!=" && operator !== "<" && operator !== "<=" && operator !== ">" && operator !== ">=") return left; + take(); + const right = primary(); + const result = { "==": left === right, "!=": left !== right, "<": left < right, "<=": left <= right, ">": left > right, ">=": left >= right }[operator]; + return result ? 1 : 0; + } + function and(): number { + let value = comparison(); + while (peek() === "&&") { take(); const right = comparison(); value = value !== 0 && right !== 0 ? 1 : 0; } + return value; + } + function or(): number { + let value = and(); + while (peek() === "||") { take(); const right = and(); value = value !== 0 || right !== 0 ? 1 : 0; } + return value; + } + try { + const result = or(); + return position === tokens.length ? result !== 0 : true; + } catch { + return true; + } +} + +export interface ViaMatrix { + rows: number; + cols: number; +} + +export interface ViaKey { + row: number; + col: number; + x: number; + y: number; + w: number; + h: number; + r: number; + rx: number; + ry: number; + x2?: number; + y2?: number; + w2?: number; + h2?: number; + encoder?: number; + led?: number; +} + +export type ViaOptionKeys = Record>; + +export interface ViaDefinition { + name: string; + vendorId: number; + productId: number; + firmwareVersion: number | null; + matrix: ViaMatrix; + keys: ViaKey[]; + optionKeys: ViaOptionKeys; + width: number; + height: number; + labels: unknown[]; + menus: unknown[]; + keycodeModules: string[]; + customKeycodes: ViaCustomKeycode[]; +} + +export function viaVendorProductId(vendorId: number, productId: number): number { + return vendorId * 65536 + productId; +} + +function rotate(x: number, y: number, ox: number, oy: number, degrees: number): { x: number; y: number } { + const rad = (degrees * Math.PI) / 180; + const dx = x - ox; + const dy = y - oy; + return { x: ox + dx * Math.cos(rad) - dy * Math.sin(rad), y: oy + dx * Math.sin(rad) + dy * Math.cos(rad) }; +} + +function bounds(key: ViaKey): { x0: number; y0: number; x1: number; y1: number } { + const x2 = key.x2 ?? 0; + const y2 = key.y2 ?? 0; + const w2 = key.w2 ?? key.w; + const h2 = key.h2 ?? key.h; + const x0 = Math.min(key.x, key.x + x2); + const y0 = Math.min(key.y, key.y + y2); + const x1 = Math.max(key.x + key.w, key.x + x2 + w2); + const y1 = Math.max(key.y + key.h, key.y + y2 + h2); + const corners = [[x0, y0], [x1, y0], [x0, y1], [x1, y1]].map(([x, y]) => rotate(x!, y!, key.rx, key.ry, key.r)); + return { + x0: Math.min(...corners.map((c) => c.x)), + y0: Math.min(...corners.map((c) => c.y)), + x1: Math.max(...corners.map((c) => c.x)), + y1: Math.max(...corners.map((c) => c.y)), + }; +} + +function finite(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function servedKey(raw: unknown, matrix: ViaMatrix): ViaKey | null { + if (typeof raw !== "object" || raw === null) return null; + const k = raw as Record; + if (k.d === true) return null; + const key: ViaKey = { + row: finite(k.row, -1), col: finite(k.col, -1), + x: finite(k.x), y: finite(k.y), w: finite(k.w, 1), h: finite(k.h, 1), + r: finite(k.r), rx: finite(k.rx), ry: finite(k.ry), + }; + for (const name of ["x2", "y2", "w2", "h2"] as const) if (typeof k[name] === "number") key[name] = k[name] as number; + if (typeof k.ei === "number") key.encoder = k.ei; + if (typeof k.li === "number") key.led = k.li; + if (key.row < 0 && key.col < 0) return key.encoder === undefined ? null : key; + if (key.row >= matrix.rows || key.col >= matrix.cols || key.row < 0 || key.col < 0) { + throw new Error(`VIA definition key ${key.row},${key.col} is outside the ${matrix.rows}x${matrix.cols} matrix.`); + } + return key; +} + +const RGBLIGHT_EFFECTS = ["All Off", "Solid Color", "Breathing 1", "Breathing 2", "Breathing 3", "Breathing 4", "Rainbow Mood 1", "Rainbow Mood 2", "Rainbow Mood 3", "Rainbow Swirl 1", "Rainbow Swirl 2", "Rainbow Swirl 3", "Rainbow Swirl 4", "Rainbow Swirl 5", "Rainbow Swirl 6", "Snake 1", "Snake 2", "Snake 3", "Snake 4", "Snake 5", "Snake 6", "Knight 1", "Knight 2", "Knight 3", "Christmas", "Gradient 1", "Gradient 2", "Gradient 3", "Gradient 4", "Gradient 5", "Gradient 6", "Gradient 7", "Gradient 8", "Gradient 9", "Gradient 10", "RGB Test", "Alternating", "Twinkle 1", "Twinkle 2", "Twinkle 3", "Twinkle 4", "Twinkle 5", "Twinkle 6"]; +const BACKLIGHT_ITEMS = [ + { label: "Backlight Brightness", type: "range", options: [0, 255], content: ["id_qmk_backlight_brightness", 1, 1], legacyId: 0x09 }, + { label: "Backlight Effect", type: "dropdown", options: [["Off", 0], ["Breathing", 1]], content: ["id_qmk_backlight_effect", 1, 2], legacyId: 0x0a }, +]; +const UNDERGLOW_ITEMS = [ + { label: "Brightness", type: "range", options: [0, 255], content: ["id_qmk_rgblight_brightness", 2, 1], legacyId: 0x80 }, + { label: "Effect", type: "dropdown", options: RGBLIGHT_EFFECTS, content: ["id_qmk_rgblight_effect", 2, 2], legacyId: 0x81 }, + { showIf: "{id_qmk_rgblight_effect} != 0", label: "Effect Speed", type: "range", options: [0, 255], content: ["id_qmk_rgblight_effect_speed", 2, 3], legacyId: 0x82 }, + { showIf: "{id_qmk_rgblight_effect} != 0 && {id_qmk_rgblight_effect} != 35", label: "Color", type: "color", content: ["id_qmk_rgblight_color", 2, 4], legacyId: 0x83 }, +]; + +/** The menus behind the `lighting` name a VIA v2 definition gives instead of a menu list (the QMK ones; Wilba Tech lighting uses other commands). */ +const LEGACY_LIGHTING: Readonly> = { + qmk_backlight: [{ label: "Lighting", content: [{ label: "Backlight", content: BACKLIGHT_ITEMS }] }], + qmk_rgblight: [{ label: "Lighting", content: [{ label: "Underglow", content: UNDERGLOW_ITEMS }] }], + qmk_backlight_rgblight: [{ label: "Lighting", content: [{ label: "Backlight", content: BACKLIGHT_ITEMS }, { label: "Underglow", content: UNDERGLOW_ITEMS }] }], +}; + +const effectNames = (list: unknown): string[] | null => + Array.isArray(list) ? list.flatMap((entry) => (Array.isArray(entry) && typeof entry[0] === "string" ? [entry[0]] : [])) : null; + +/** + * Lighting menus for a v2 definition: a preset name, or an object that `extends` a preset and may replace its + * effect lists and the lighting values it supports (the same overrides VIA's own reader applies). + */ +function lightingMenus(lighting: unknown): unknown[] { + if (typeof lighting === "string") return [...(LEGACY_LIGHTING[lighting] ?? [])]; + if (!isRecord(lighting) || typeof lighting.extends !== "string" || !LEGACY_LIGHTING[lighting.extends]) return []; + const supported = Array.isArray(lighting.supportedLightingValues) ? lighting.supportedLightingValues.filter((value): value is number => typeof value === "number") : null; + const keep = (item: { legacyId: number }): boolean => supported === null || supported.includes(item.legacyId); + const backlightEffects = effectNames(lighting.effects); + const underglowEffects = effectNames(lighting.underglowEffects); + const dark = Array.isArray(lighting.underglowEffects) + ? [0, ...lighting.underglowEffects.flatMap((entry, index) => (Array.isArray(entry) && entry[1] === 0 ? [index] : []))] + : null; + const backlight = BACKLIGHT_ITEMS.filter(keep).map((item) => (backlightEffects && item.legacyId === 0x0a ? { ...item, options: backlightEffects } : item)); + const underglow = UNDERGLOW_ITEMS.filter(keep).map((item) => { + if (underglowEffects && item.legacyId === 0x81) return { ...item, options: underglowEffects }; + if (dark && item.legacyId === 0x83) return { ...item, showIf: [...new Set(dark)].map((index) => `{id_qmk_rgblight_effect} != ${index}`).join(" && ") }; + return item; + }); + const sections = [ + ...(lighting.extends.includes("backlight") && backlight.length > 0 ? [{ label: "Backlight", content: backlight }] : []), + ...(lighting.extends.includes("rgblight") && underglow.length > 0 ? [{ label: "Underglow", content: underglow }] : []), + ]; + return sections.length > 0 ? [{ label: "Lighting", content: sections }] : []; +} + +function parseServedDefinition(def: Record, matrix: ViaMatrix): ViaDefinition { + const layouts = def.layouts as { keys: unknown[]; optionKeys?: unknown; labels?: unknown; width?: unknown; height?: unknown }; + const keys = layouts.keys.flatMap((raw) => servedKey(raw, matrix) ?? []); + const optionKeys: ViaOptionKeys = {}; + for (const [group, options] of Object.entries((layouts.optionKeys ?? {}) as Record>)) { + optionKeys[Number(group)] = {}; + for (const [option, members] of Object.entries(options ?? {})) { + optionKeys[Number(group)]![Number(option)] = (members ?? []).flatMap((raw) => servedKey(raw, matrix) ?? []); + } + } + // A board whose only keys sit in layout options still has a keymap, drawn from those keys. + const optionMembers = Object.values(optionKeys).flatMap((options) => Object.values(options).flat()); + if (keys.length === 0 && optionMembers.length === 0) throw new Error("VIA definition keymap has no keys."); + const boxes = [...keys, ...optionMembers].map(bounds); + const vendorProductId = finite(def.vendorProductId, Number.NaN); + if (!Number.isInteger(vendorProductId) || vendorProductId < 0) throw new Error("VIA definition vendorProductId must be a number."); + const firmware = def.firmwareVersion; + return { + name: typeof def.name === "string" ? def.name : "VIA keyboard", + vendorId: Math.floor(vendorProductId / 65536), + productId: vendorProductId % 65536, + firmwareVersion: typeof firmware === "number" ? firmware : null, + matrix, + keys, + optionKeys, + width: finite(layouts.width, Math.max(...boxes.map((b) => b.x1))), + height: finite(layouts.height, Math.max(...boxes.map((b) => b.y1))), + labels: Array.isArray(layouts.labels) ? layouts.labels : [], + menus: Array.isArray(def.menus) ? def.menus : [...lightingMenus(def.lighting), ...(Array.isArray(def.customMenus) ? def.customMenus : [])], + keycodeModules: Array.isArray(def.keycodes) ? def.keycodes.filter((id): id is string => typeof id === "string") : [], + customKeycodes: Array.isArray(def.customKeycodes) + ? def.customKeycodes.map((entry: { name?: unknown; title?: unknown; shortName?: unknown }) => ({ + name: String(entry?.name ?? ""), + ...(typeof entry?.title === "string" ? { title: entry.title } : {}), + ...(typeof entry?.shortName === "string" ? { shortName: entry.shortName } : {}), + })) + : [], + }; +} + +function choiceCount(label: unknown): number { + return Array.isArray(label) ? Math.max(1, label.length - 1) : 2; +} + +function choiceBits(count: number): number { + return Math.max(1, Math.ceil(Math.log2(count))); +} + +/** The choice VIA stored for each layout-option label, unpacked from the board's 32-bit value. */ +export function viaLayoutChoices(labels: readonly unknown[], bitfield: number): number[] { + const choices: number[] = []; + let bits = bitfield >>> 0; + for (let index = labels.length - 1; index >= 0; index -= 1) { + const count = choiceCount(labels[index]); + const size = choiceBits(count); + const choice = bits & ((1 << size) - 1); + bits >>>= size; + choices.unshift(choice < count ? choice : 0); + } + return choices; +} + +/** The inverse of `viaLayoutChoices`: one choice per label packed into the 32-bit value the board stores. */ +export function viaPackLayoutChoices(labels: readonly unknown[], choices: readonly number[]): number { + let bits = 0; + labels.forEach((label, index) => { + const count = choiceCount(label); + const choice = Math.min(Math.max(Math.trunc(choices[index] ?? 0), 0), count - 1); + bits = (bits << choiceBits(count)) | choice; + }); + return bits >>> 0; +} + +/** The keys on screen for a layout-option selection: always-present keys plus the chosen option groups. */ +export function viaActiveKeys(definition: ViaDefinition, choices: readonly number[]): ViaKey[] { + return [ + ...definition.keys, + ...Object.entries(definition.optionKeys).flatMap(([group, options]) => options[choices[Number(group)] ?? 0] ?? []), + ]; +} + +export function parseViaDefinition(source: unknown): ViaDefinition { + if (typeof source !== "object" || source === null) throw new Error("VIA definition must be an object."); + const def = source as Record; + const matrix = def.matrix as Partial | undefined; + if (!matrix || !Number.isInteger(matrix.rows) || !Number.isInteger(matrix.cols) || matrix.rows! < 1 || matrix.cols! < 1) { + throw new Error("VIA definition matrix needs positive integer rows and cols."); + } + if (!Array.isArray((def.layouts as { keys?: unknown } | undefined)?.keys)) throw new Error("VIA definition layouts.keys must be an array."); + return parseServedDefinition(def, { rows: matrix.rows!, cols: matrix.cols! }); +} + +export const VIA_USAGE_PAGE = 0xff60; +export const VIA_USAGE = 0x61; +export const VIA_REPORT_ID = 0; +export const VIA_REPORT_LENGTH = 32; +export const VIA_UNHANDLED = 0xff; +export const VIA_PROTOCOL_ALPHA = 7; +export const VIA_PROTOCOL_BETA = 8; +export const VIA_BUFFER_CHUNK = 28; +export const VIA_LEGACY_LAYER_COUNT = 4; + +/** + * Commands the codec can build. EEPROM reset (0x0A), bootloader jump (0x0B) + * and macro reset (0x10) exist in the protocol but are never encoded here. + */ +export const VIA_COMMAND = { + getProtocolVersion: 0x01, + getKeyboardValue: 0x02, + setKeyboardValue: 0x03, + getKeycode: 0x04, + setKeycode: 0x05, + customMenuSet: 0x07, + customMenuGet: 0x08, + customMenuSave: 0x09, + macroGetCount: 0x0c, + macroGetBufferSize: 0x0d, + macroGetBuffer: 0x0e, + macroSetBuffer: 0x0f, + getLayerCount: 0x11, + getKeymapBuffer: 0x12, + setKeymapBuffer: 0x13, + getEncoder: 0x14, + setEncoder: 0x15, +} as const; + +export type ViaCommand = (typeof VIA_COMMAND)[keyof typeof VIA_COMMAND]; + +export const VIA_KEYBOARD_VALUE = { + uptime: 0x01, + layoutOptions: 0x02, + switchMatrixState: 0x03, + firmwareVersion: 0x04, + deviceIndication: 0x05, + keycodesVersion: 0x06, +} as const; + +function byteArray(values: readonly number[], what: string): number[] { + for (const value of values) { + if (!Number.isInteger(value) || value < 0 || value > 0xff) { + throw new RangeError(`VIA ${what} byte out of range: ${value}`); + } + } + return [...values]; +} + +export function encodeViaCommand(command: ViaCommand, args: readonly number[] = []): Uint8Array { + if (args.length > VIA_REPORT_LENGTH - 1) throw new RangeError("VIA command arguments exceed one report."); + const frame = new Uint8Array(VIA_REPORT_LENGTH); + frame[0] = command; + frame.set(byteArray(args, "argument"), 1); + return frame; +} + +export function viaIsUnhandled(reply: Uint8Array): boolean { + return reply[0] === VIA_UNHANDLED; +} + +/** + * True when `reply` answers `request`: same command id and the same + * addressing bytes. VIA has no transaction id, so this echo is the only + * pairing the wire offers. `headerLength` is how many bytes after the command + * the firmware echoes back (layer/row/col, buffer offset and size, value ids). + */ +export function viaReplyMatches(reply: Uint8Array, request: Uint8Array, headerLength: number): boolean { + if (reply.length < 1 + headerLength || reply[0] !== request[0]) return false; + for (let index = 1; index <= headerLength; index += 1) { + if (reply[index] !== request[index]) return false; + } + return true; +} + +export const shiftViaTo16Bit = (hi: number, lo: number): number => ((hi & 0xff) << 8) | (lo & 0xff); + +export function viaUint32(bytes: readonly number[]): number { + if (bytes.length < 4) throw new RangeError("VIA value is shorter than 4 bytes."); + return ((bytes[0]! << 24) | (bytes[1]! << 16) | (bytes[2]! << 8) | bytes[3]!) >>> 0; +} + +export function encodeViaGetProtocolVersion(): Uint8Array { + return encodeViaCommand(VIA_COMMAND.getProtocolVersion); +} + +export function decodeViaProtocolVersion(reply: Uint8Array): number { + return shiftViaTo16Bit(reply[1] ?? 0, reply[2] ?? 0); +} + +export function viaUint32Bytes(value: number): number[] { + return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]; +} + +export function encodeViaSetKeyboardValue(id: number, bytes: readonly number[]): Uint8Array { + return encodeViaCommand(VIA_COMMAND.setKeyboardValue, [id, ...bytes]); +} + +export function encodeViaGetKeyboardValue(id: number, params: readonly number[] = []): Uint8Array { + return encodeViaCommand(VIA_COMMAND.getKeyboardValue, [id, ...params]); +} + +export function decodeViaKeyboardValue(reply: Uint8Array, paramsLength: number, resultLength: number): number[] { + const start = 2 + paramsLength; + return [...reply.slice(start, start + resultLength)]; +} + +function assertKeycode(keycode: number): void { + if (!Number.isInteger(keycode) || keycode < 0 || keycode > 0xffff) { + throw new RangeError(`VIA keycode out of range: ${keycode}`); + } +} + +export function encodeViaGetKeycode(layer: number, row: number, col: number): Uint8Array { + return encodeViaCommand(VIA_COMMAND.getKeycode, [layer, row, col]); +} + +export function decodeViaKeycode(reply: Uint8Array): number { + return shiftViaTo16Bit(reply[4] ?? 0, reply[5] ?? 0); +} + +export function encodeViaSetKeycode(layer: number, row: number, col: number, keycode: number): Uint8Array { + assertKeycode(keycode); + return encodeViaCommand(VIA_COMMAND.setKeycode, [layer, row, col, keycode >> 8, keycode & 0xff]); +} + +export function encodeViaGetLayerCount(): Uint8Array { + return encodeViaCommand(VIA_COMMAND.getLayerCount); +} + +export function decodeViaLayerCount(reply: Uint8Array): number { + return reply[1] ?? 0; +} + +function assertBufferRange(offset: number, size: number): void { + if (!Number.isInteger(offset) || offset < 0 || offset > 0xffff) throw new RangeError("VIA buffer offset out of range."); + if (!Number.isInteger(size) || size < 0 || size > VIA_BUFFER_CHUNK) { + throw new RangeError(`VIA buffer transfers are at most ${VIA_BUFFER_CHUNK} bytes.`); + } +} + +export function encodeViaGetKeymapBuffer(offset: number, size: number): Uint8Array { + assertBufferRange(offset, size); + return encodeViaCommand(VIA_COMMAND.getKeymapBuffer, [offset >> 8, offset & 0xff, size]); +} + +export function encodeViaSetKeymapBuffer(offset: number, data: readonly number[]): Uint8Array { + assertBufferRange(offset, data.length); + return encodeViaCommand(VIA_COMMAND.setKeymapBuffer, [offset >> 8, offset & 0xff, data.length, ...data]); +} + +export function decodeViaBuffer(reply: Uint8Array, size: number): number[] { + return [...reply.slice(4, 4 + size)]; +} + +export function encodeViaGetEncoder(layer: number, id: number, clockwise: boolean): Uint8Array { + return encodeViaCommand(VIA_COMMAND.getEncoder, [layer, id, clockwise ? 1 : 0]); +} + +export function decodeViaEncoder(reply: Uint8Array): number { + return shiftViaTo16Bit(reply[4] ?? 0, reply[5] ?? 0); +} + +export function encodeViaSetEncoder(layer: number, id: number, clockwise: boolean, keycode: number): Uint8Array { + assertKeycode(keycode); + return encodeViaCommand(VIA_COMMAND.setEncoder, [layer, id, clockwise ? 1 : 0, keycode >> 8, keycode & 0xff]); +} + +export function encodeViaMacroGetCount(): Uint8Array { + return encodeViaCommand(VIA_COMMAND.macroGetCount); +} + +export function decodeViaMacroCount(reply: Uint8Array): number { + return reply[1] ?? 0; +} + +export function encodeViaMacroGetBufferSize(): Uint8Array { + return encodeViaCommand(VIA_COMMAND.macroGetBufferSize); +} + +export function decodeViaMacroBufferSize(reply: Uint8Array): number { + return shiftViaTo16Bit(reply[1] ?? 0, reply[2] ?? 0); +} + +export function encodeViaMacroGetBuffer(offset: number, size: number): Uint8Array { + assertBufferRange(offset, size); + return encodeViaCommand(VIA_COMMAND.macroGetBuffer, [offset >> 8, offset & 0xff, size]); +} + +export function encodeViaMacroSetBuffer(offset: number, data: readonly number[]): Uint8Array { + assertBufferRange(offset, data.length); + return encodeViaCommand(VIA_COMMAND.macroSetBuffer, [offset >> 8, offset & 0xff, data.length, ...data]); +} + +export function encodeViaCustomGet(ids: readonly number[]): Uint8Array { + return encodeViaCommand(VIA_COMMAND.customMenuGet, ids); +} + +export function encodeViaCustomSet(ids: readonly number[], value: readonly number[]): Uint8Array { + return encodeViaCommand(VIA_COMMAND.customMenuSet, [...ids, ...value]); +} + +/** Saves a custom-menu channel; `null` is the old argument-less save of protocol 10 and older lighting. */ +export function encodeViaCustomSave(channel: number | null): Uint8Array { + return encodeViaCommand(VIA_COMMAND.customMenuSave, channel === null ? [] : [channel]); +} + +export function decodeViaCustomValue(reply: Uint8Array, idsLength: number, resultLength: number): number[] { + const start = 1 + idsLength; + return [...reply.slice(start, start + resultLength)]; +} + +export function viaKeymapByteLength(layers: number, rows: number, cols: number): number { + return layers * rows * cols * 2; +} + +export function viaBufferChunks(total: number): { offset: number; size: number }[] { + const chunks: { offset: number; size: number }[] = []; + for (let offset = 0; offset < total; offset += VIA_BUFFER_CHUNK) { + chunks.push({ offset, size: Math.min(VIA_BUFFER_CHUNK, total - offset) }); + } + return chunks; +} + +export function decodeViaKeymap(bytes: readonly number[], layers: number, rows: number, cols: number): number[][] { + const perLayer = rows * cols; + if (bytes.length < viaKeymapByteLength(layers, rows, cols)) throw new RangeError("VIA keymap buffer is too short."); + return Array.from({ length: layers }, (_, layer) => + Array.from({ length: perLayer }, (_, index) => { + const at = (layer * perLayer + index) * 2; + return shiftViaTo16Bit(bytes[at]!, bytes[at + 1]!); + }), + ); +} + +export function encodeViaKeymap(layers: readonly (readonly number[])[]): number[] { + return layers.flatMap((layer) => layer.flatMap((keycode) => { + assertKeycode(keycode); + return [keycode >> 8, keycode & 0xff]; + })); +} + +/** + * Splits the macro buffer into its macros (each ends in a 0x00 byte). The + * action encoding inside a macro differs by protocol version, so the bytes are + * kept raw; this only finds the boundaries. Missing macros come back empty. + */ +export function splitViaMacros(buffer: readonly number[], count: number): number[][] { + const macros: number[][] = Array.from({ length: count }, () => []); + let current = 0; + for (const byte of buffer) { + if (current >= count) break; + if (byte === 0) current += 1; + else macros[current]!.push(byte); + } + return macros; +} + +export function joinViaMacros(macros: readonly (readonly number[])[], bufferSize: number): number[] { + const joined = macros.flatMap((macro) => { + if (macro.includes(0)) throw new RangeError("A VIA macro cannot contain a 0x00 byte."); + return [...macro, 0]; + }); + if (joined.length > bufferSize) throw new RangeError("VIA macros do not fit the board's macro buffer."); + return [...joined, ...new Array(bufferSize - joined.length).fill(0)]; +} diff --git a/src/via/protocol.test.ts b/src/via/protocol.test.ts new file mode 100644 index 0000000..213dd0b --- /dev/null +++ b/src/via/protocol.test.ts @@ -0,0 +1,625 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + viaEventCodeFromKeycode, + viaKeycodeFromEventCode, + viaKeycodeFromLayoutName, + viaKeycodeToLayoutName, + VIA_MOD, + viaComposeKeycode, + viaLightingKeycodes, + VIA_BASIC_KEYCODES, + VIA_COMMAND, + VIA_KEYBOARD_VALUE, + VIA_REPORT_LENGTH, + ViaMacroExpressionError, + decodeViaKeyboardValue, + decodeViaKeymap, + decodeViaMacro, + decodeViaProtocolVersion, + describeViaKeycode, + encodeViaCommand, + encodeViaCustomSet, + encodeViaGetKeyboardValue, + encodeViaGetKeymapBuffer, + encodeViaKeymap, + encodeViaMacro, + encodeViaSetKeycode, + evaluateViaCondition, + joinViaMacros, + parseViaDefinition, + parseViaMacroExpression, + parseViaMenus, + splitViaMacros, + viaActiveKeys, + viaBufferChunks, + viaIsUnhandled, + viaKeycodeFromName, + viaKeycodeName, + viaKeycodePicker, + viaLayoutChoices, + viaMacroToExpression, + viaNumberToBytes, + viaPackLayoutChoices, + viaReplyMatches, + viaUint32, + viaUint32Bytes, + viaValueToNumber, + viaVendorProductId, +} from "@openmouse/keyboard-protocol/via"; + +test("commands are 32-byte zero padded frames and reject bad bytes", () => { + const frame = encodeViaCommand(VIA_COMMAND.getKeycode, [1, 2, 3]); + assert.equal(frame.length, VIA_REPORT_LENGTH); + assert.deepEqual([...frame.slice(0, 6)], [0x04, 1, 2, 3, 0, 0]); + assert.throws(() => encodeViaCommand(VIA_COMMAND.getKeycode, [256]), RangeError); + assert.throws(() => encodeViaCommand(VIA_COMMAND.getKeycode, new Array(32).fill(0)), RangeError); + assert.throws(() => encodeViaSetKeycode(0, 0, 0, 0x10000), RangeError); +}); + +test("keycodes are big-endian and keymaps round-trip through the byte buffer", () => { + assert.deepEqual([...encodeViaSetKeycode(1, 2, 3, 0x1234).slice(0, 6)], [0x05, 1, 2, 3, 0x12, 0x34]); + const layers = [[0x0004, 0x0005, 0x0006, 0x0007], [0x5100, 0x0000, 0x00ff, 0x7c00]]; + const bytes = encodeViaKeymap(layers); + assert.equal(bytes.length, 16); + assert.deepEqual(decodeViaKeymap(bytes, 2, 2, 2), layers); + assert.throws(() => decodeViaKeymap(bytes.slice(0, 15), 2, 2, 2), RangeError); +}); + +test("buffer transfers are chunked 28 bytes at a time", () => { + assert.deepEqual(viaBufferChunks(60), [{ offset: 0, size: 28 }, { offset: 28, size: 28 }, { offset: 56, size: 4 }]); + assert.deepEqual([...encodeViaGetKeymapBuffer(0x0102, 28).slice(0, 4)], [0x12, 0x01, 0x02, 28]); + assert.throws(() => encodeViaGetKeymapBuffer(0, 29), RangeError); +}); + +test("replies pair with requests by command and echoed header, and flag unknown commands", () => { + const request = encodeViaGetKeyboardValue(VIA_KEYBOARD_VALUE.firmwareVersion); + const reply = new Uint8Array(VIA_REPORT_LENGTH); + reply.set([0x02, VIA_KEYBOARD_VALUE.firmwareVersion, 0x00, 0x00, 0x01, 0x02]); + assert.ok(viaReplyMatches(reply, request, 1)); + assert.deepEqual(decodeViaKeyboardValue(reply, 0, 4), [0, 0, 1, 2]); + assert.equal(viaUint32([0xff, 0xff, 0xff, 0xff]), 0xffffffff); + reply[1] = 0x06; + assert.equal(viaReplyMatches(reply, request, 1), false); + assert.equal(viaIsUnhandled(Uint8Array.of(0xff, 0x04)), true); + assert.equal(decodeViaProtocolVersion(Uint8Array.of(0x01, 0x00, 0x09)), 9); +}); + +test("custom values carry the channel and value id ahead of the payload", () => { + assert.deepEqual([...encodeViaCustomSet([3, 4], [10, 200]).slice(0, 5)], [0x07, 3, 4, 10, 200]); +}); + +test("macros split on 0x00 and rejoin zero padded to the buffer size", () => { + const buffer = [0x61, 0x62, 0, 0x63, 0, 0, 0, 0]; + assert.deepEqual(splitViaMacros(buffer, 3), [[0x61, 0x62], [0x63], []]); + assert.deepEqual(joinViaMacros([[0x61, 0x62], [0x63], []], 8), buffer); + assert.throws(() => joinViaMacros([[1, 2, 3]], 3), RangeError); + assert.throws(() => joinViaMacros([[1, 0, 2]], 8), RangeError); +}); + +const SERVED = { + name: "Served Board", + vendorProductId: 0xfeed0001, + firmwareVersion: 0, + menus: [], + keycodes: ["qmk_rgb_matrix_keycodes"], + matrix: { rows: 2, cols: 3 }, + layouts: { + labels: ["Split"], + width: 3, + height: 2, + optionKeys: { + 0: { + 0: [{ row: 1, col: 2, x: 2, y: 1, r: 0, rx: 0, ry: 0, d: false, h: 1, w: 1 }], + 1: [{ row: 1, col: 2, x: 2, y: 1, r: 0, rx: 0, ry: 0, d: false, h: 1, w: 2 }], + }, + }, + keys: [ + { row: 0, col: 0, x: 0, y: 0, r: 0, rx: 0, ry: 0, d: false, h: 1, w: 1 }, + { row: 0, col: 1, x: 1, y: 0, r: 0, rx: 0, ry: 0, d: false, h: 1, w: 1 }, + { row: -1, col: -1, x: 0, y: 1, r: 0, rx: 0, ry: 0, d: true, h: 1, w: 1 }, + ], + }, +}; + +test("a served (processed) definition parses and its layout options select keys", () => { + const def = parseViaDefinition(SERVED); + assert.deepEqual([def.vendorId, def.productId], [0xfeed, 1]); + assert.equal(def.keys.length, 2); + assert.deepEqual(def.keycodeModules, ["qmk_rgb_matrix_keycodes"]); + assert.equal(viaActiveKeys(def, [0]).at(-1)!.w, 1); + assert.equal(viaActiveKeys(def, [1]).at(-1)!.w, 2); + assert.equal(viaActiveKeys(def, []).length, 3); + assert.throws(() => parseViaDefinition({ ...SERVED, matrix: { rows: 1, cols: 1 } }), /outside/); +}); + +test("layout choices unpack from the board's bitfield, last label in the low bits", () => { + assert.deepEqual(viaLayoutChoices(["Split"], 1), [1]); + assert.deepEqual(viaLayoutChoices(["A", ["Layer", "x", "y", "z"]], 0b110), [1, 2]); + assert.deepEqual(viaLayoutChoices(["A", ["Layer", "x", "y", "z"]], 0b011), [0, 0]); +}); + +test("layout choices pack back into the bitfield and round-trip", () => { + const labels = ["A", ["Layer", "x", "y", "z"], "C"]; + assert.equal(viaPackLayoutChoices(["Split"], [1]), 1); + for (const choices of [[0, 0, 0], [1, 2, 1], [1, 1, 0], [0, 2, 1]]) { + assert.deepEqual(viaLayoutChoices(labels, viaPackLayoutChoices(labels, choices)), choices); + } + assert.equal(viaPackLayoutChoices(["A", ["Layer", "x", "y", "z"]], [99, 99]), 0b110); + assert.equal(viaPackLayoutChoices(["A"], []), 0); + assert.deepEqual(viaUint32Bytes(0x01020304), [1, 2, 3, 4]); +}); + +test("a v2 definition names its lighting instead of listing menus, and the QMK names become controls", () => { + const v2 = (lighting: unknown) => parseViaDefinition({ ...SERVED, menus: undefined, lighting }); + const glow = parseViaMenus(v2("qmk_rgblight").menus)[0]!.sections[0]!.items; + assert.deepEqual(glow.map((item) => item.ids), [[2, 1], [2, 2], [2, 3], [2, 4]]); + assert.equal(glow[1]!.control.type === "dropdown" && glow[1]!.control.options.length, 43); + assert.deepEqual(parseViaMenus(v2("qmk_backlight").menus)[0]!.sections.map((section) => section.label), ["Backlight"]); + assert.deepEqual(parseViaMenus(v2("qmk_backlight_rgblight").menus)[0]!.sections.map((section) => section.label), ["Backlight", "Underglow"]); + for (const lighting of ["none", "wt_rgb_backlight", { extends: "none" }, undefined]) assert.deepEqual(v2(lighting).menus, []); +}); + +test("a definition keeps encoders and rotated keys, and malformed ones are rejected", () => { + const encoder = { row: -1, col: -1, ei: 0, x: 3, y: 0, r: 15, rx: 3, ry: 0, d: false, h: 1, w: 1 }; + const def = parseViaDefinition({ ...SERVED, layouts: { ...SERVED.layouts, keys: [...SERVED.layouts.keys, encoder] } }); + assert.equal(def.keys.length, 3); + assert.deepEqual(def.keys.find((key) => key.encoder === 0), { row: -1, col: -1, x: 3, y: 0, w: 1, h: 1, r: 15, rx: 3, ry: 0, encoder: 0 }); + assert.throws(() => parseViaDefinition("nope"), /must be an object/); + assert.throws(() => parseViaDefinition({ ...SERVED, vendorProductId: "0xFEED" }), /vendorProductId/); + assert.throws(() => parseViaDefinition({ ...SERVED, layouts: {} }), /layouts\.keys/); + assert.throws(() => parseViaDefinition({ ...SERVED, layouts: { ...SERVED.layouts, keys: [], optionKeys: {} } }), /no keys/); + assert.throws(() => parseViaDefinition({ ...SERVED, matrix: { rows: 0, cols: 3 } }), /matrix/); +}); + +const label = (code: number, protocol: number): string => describeViaKeycode(code, protocol).label; + +test("basic keycodes keep their names across protocol generations", () => { + for (const protocol of [9, 12]) { + assert.equal(label(0x04, protocol), "A"); + assert.equal(label(0x27, protocol), "0"); + assert.equal(label(0x28, protocol), "Enter"); + assert.equal(label(0xe1, protocol), "LShift"); + assert.equal(label(0x0001, protocol), "▽"); + assert.equal(label(0x00f0, protocol), "0x00F0"); + } +}); + +test("modifier combinations and mod-tap follow each generation's base", () => { + assert.equal(label(0x0204, 9), "Shift+A"); + assert.equal(label(0x1104, 12), "RCtrl+A"); + assert.equal(label(0x6104, 9), "A · Ctrl"); + assert.equal(label(0x2104, 12), "A · Ctrl"); + assert.equal(label(0x2104, 9), "0x2104"); + assert.equal(label(0x4204, 9), "LT(2) A"); +}); + +test("layer keys resolve only in the generation that defines them", () => { + assert.equal(label(0x5102, 9), "MO(2)"); + assert.equal(label(0x5222, 12), "MO(2)"); + assert.equal(label(0x5222, 9), "0x5222"); + assert.equal(label(0x5012, 9), "TO(2)"); + assert.equal(label(0x5002, 10), "TO(2)"); + assert.equal(label(0x5002, 9), "0x5002"); + assert.equal(label(0x5262, 12), "TG(2)"); + assert.equal(label(0x52a2, 12), "OSM Shift"); +}); + +test("macro keys sit at each generation's own base and the picker offers one per macro slot", () => { + assert.equal(label(0x7702, 11), "M0"); + assert.equal(label(0x7700, 12), "M0"); + assert.equal(label(0x7711, 11), "M15"); + assert.equal(label(0x770f, 12), "M15"); + assert.equal(label(0x7712, 11), "0x7712"); + assert.equal(label(0x5f12, 9), "M0"); + assert.equal(label(0x5f12, 12), "0x5F12"); + const macros = viaKeycodePicker(12, 3, 4).find((group) => group.id === "macros")!; + assert.deepEqual(macros.keycodes, [0x7700, 0x7701, 0x7702, 0x7703]); + assert.deepEqual(viaKeycodePicker(11, 3, 2).find((group) => group.id === "macros")!.keycodes, [0x7702, 0x7703]); + assert.equal(viaKeycodePicker(9, 3, 40).find((group) => group.id === "macros")!.keycodes.length, 16); + assert.equal(viaKeycodePicker(12, 3).find((group) => group.id === "macros")!.keycodes.length, 0); +}); + +test("every picker keycode has a real name and layer keys follow the layer count", () => { + for (const protocol of [9, 12]) { + const groups = viaKeycodePicker(protocol, 3); + for (const group of groups) { + for (const code of group.keycodes) { + assert.ok(!describeViaKeycode(code, protocol).label.startsWith("0x"), `${group.id} 0x${code.toString(16)} at protocol ${protocol}`); + } + } + assert.equal(groups.find((group) => group.id === "letters")!.keycodes.length, 26); + assert.equal(groups.find((group) => group.id === "layers")!.keycodes.length, 15); + } + assert.equal(viaKeycodePicker(12, 0).find((group) => group.id === "layers")!.keycodes.length, 0); + assert.equal(viaKeycodePicker(12, 40).find((group) => group.id === "layers")!.keycodes.length, 160); +}); + +const bytes = (text: string): number[] => [...text].map((character) => character.charCodeAt(0)); + +test("a macro decodes into text, key actions and delays", () => { + const stream = [...bytes("hi"), 1, 1, 0x28, 1, 2, 0xe1, ...bytes("a"), 1, 3, 0xe1, 1, 4, ...bytes("100"), 0x7c]; + assert.deepEqual(decodeViaMacro(stream), [ + { type: "text", text: "hi" }, + { type: "tap", keycode: 0x28 }, + { type: "down", keycode: 0xe1 }, + { type: "text", text: "a" }, + { type: "up", keycode: 0xe1 }, + { type: "delay", ms: 100 }, + ]); +}); + +test("encoding gives back the same bytes, and anything unknown survives untouched", () => { + const streams = [ + [...bytes("hello world"), 1, 1, 0x28], + [1, 4, ...bytes("0"), 0x7c, ...bytes("x")], + [...bytes("ab"), 0x80, 0x81, ...bytes("cd")], + [1, 5, 0x04, 0x07, ...bytes("tail")], + [1, 4, ...bytes("0100"), 0x7c], + [1, 4, ...bytes("12"), ...bytes("ab")], + [], + ]; + for (const stream of streams) assert.deepEqual(encodeViaMacro(decodeViaMacro(stream), 11), stream); + assert.deepEqual(decodeViaMacro([1, 5, 4, 3, 7]), [{ type: "raw", bytes: [1, 5, 4, 3, 7] }]); +}); + +test("encoding validates what the board can store", () => { + assert.deepEqual(encodeViaMacro([{ type: "delay", ms: 250 }], 11), [1, 4, 0x32, 0x35, 0x30, 0x7c]); + assert.throws(() => encodeViaMacro([{ type: "delay", ms: 250 }], 10), /protocol 11/); + assert.throws(() => encodeViaMacro([{ type: "delay", ms: 10000 }], 11), RangeError); + assert.throws(() => encodeViaMacro([{ type: "delay", ms: -1 }], 11), RangeError); + assert.throws(() => encodeViaMacro([{ type: "tap", keycode: 0x100 }], 11), RangeError); + assert.throws(() => encodeViaMacro([{ type: "tap", keycode: 0 }], 11), RangeError); + assert.throws(() => encodeViaMacro([{ type: "text", text: "café" }], 11), /ASCII/); + assert.throws(() => encodeViaMacro([{ type: "raw", bytes: [0] }], 11), RangeError); + assert.deepEqual(encodeViaMacro([{ type: "text", text: "a\tb\n" }], 9), [0x61, 9, 0x62, 10]); + assert.deepEqual(encodeViaMacro([{ type: "tap", keycode: 4 }], 9), [1, 4]); +}); + +test("QMK names map to keycodes, with aliases and any letter case", () => { + assert.equal(viaKeycodeFromName("KC_A"), 0x04); + assert.equal(viaKeycodeFromName("kc_0"), 0x27); + assert.equal(viaKeycodeFromName("KC_ENT"), 0x28); + assert.equal(viaKeycodeFromName("KC_ENTER"), 0x28); + assert.equal(viaKeycodeFromName("KC_F24"), 0x73); + assert.equal(viaKeycodeFromName("KC_LCTL"), 0xe0); + assert.equal(viaKeycodeFromName("KC_LOPT"), 0xe2); + assert.equal(viaKeycodeFromName("KC_MUTE"), 0xa8); + assert.equal(viaKeycodeFromName("KC_P0"), 0x62); + assert.equal(viaKeycodeFromName("KC_PEQL"), 0x67); + assert.equal(viaKeycodeFromName("KC_INT9"), 0x8f); + assert.equal(viaKeycodeFromName("KC_LNG1"), 0x90); + assert.equal(viaKeycodeFromName("KC_NOPE"), null); + assert.equal(viaKeycodeName(0x28), "KC_ENT"); + assert.equal(viaKeycodeName(0xf0), null); +}); + +test("every keycode row has a unique code and names, all within one byte", () => { + const codes = new Set(); + const names = new Set(); + for (const { code, title, label, names: spellings } of VIA_BASIC_KEYCODES) { + assert.ok(code >= 1 && code <= 0xff, `code ${code}`); + assert.ok(!codes.has(code), `duplicate code ${code}`); + assert.ok(title && label, `code ${code} needs a title and a label`); + codes.add(code); + for (const name of spellings) { + assert.ok(!names.has(name), `duplicate name ${name}`); + names.add(name); + assert.equal(viaKeycodeFromName(name), code); + } + } + assert.equal(codes.size, VIA_BASIC_KEYCODES.length); +}); + +test("an expression reads as text, key presses, chords, holds and waits", () => { + assert.deepEqual(parseViaMacroExpression("hi{KC_ENT}", 11), [{ type: "text", text: "hi" }, { type: "tap", keycode: 0x28 }]); + assert.deepEqual(parseViaMacroExpression("{KC_LCTL,KC_C}", 11), [ + { type: "down", keycode: 0xe0 }, { type: "down", keycode: 0x06 }, { type: "up", keycode: 0x06 }, { type: "up", keycode: 0xe0 }, + ]); + assert.deepEqual(parseViaMacroExpression("{+KC_LSFT}ab{-KC_LSFT}{ 250 }", 11), [ + { type: "down", keycode: 0xe1 }, { type: "text", text: "ab" }, { type: "up", keycode: 0xe1 }, { type: "delay", ms: 250 }, + ]); + assert.deepEqual(parseViaMacroExpression("a\\{b\\\\c}d", 11), [{ type: "text", text: "a{b\\c}d" }]); + assert.deepEqual(parseViaMacroExpression("{raw:1,5,4}", 11), [{ type: "raw", bytes: [1, 5, 4] }]); + assert.deepEqual(encodeViaMacro(parseViaMacroExpression("hi{KC_ENT}", 11), 11), [0x68, 0x69, 1, 1, 0x28]); +}); + +test("mistakes in an expression name what is wrong and where", () => { + const problem = (expression: string, protocol = 11): ViaMacroExpressionError => { + try { parseViaMacroExpression(expression, protocol); } catch (error) { return error as ViaMacroExpressionError; } + throw new Error(`"${expression}" was accepted`); + }; + assert.match(problem("ab{KC_FOO}").message, /Unknown keycode "KC_FOO"/); + assert.equal(problem("ab{KC_FOO}").index, 2); + assert.match(problem("ab{KC_A").message, /no closing/); + assert.match(problem("{}").message, /empty/); + assert.match(problem("{10000}").message, /0 to 9999/); + assert.match(problem("{100}", 10).message, /protocol 11/); + assert.match(problem("café").message, /ASCII/); + assert.equal(problem("café").index, 3); + assert.match(problem("{raw:0}").message, /1 to 255/); +}); + +test("actions written as an expression read back to the same actions", () => { + const sequences = [ + parseViaMacroExpression("Hello, world!{KC_ENT}", 11), + parseViaMacroExpression("{KC_LCTL,KC_LSFT,KC_ESC}", 11), + parseViaMacroExpression("{+KC_LSFT}abc{-KC_LSFT}{500}{KC_TAB}", 11), + parseViaMacroExpression("brace \\{ and slash \\\\ and \\n", 11), + parseViaMacroExpression("{raw:1,5,4,7}text", 11), + ]; + for (const actions of sequences) assert.deepEqual(parseViaMacroExpression(viaMacroToExpression(actions), 11), actions); + assert.equal(viaMacroToExpression([{ type: "down", keycode: 0x04 }, { type: "up", keycode: 0x04 }]), "{KC_A}"); + assert.equal(viaMacroToExpression([{ type: "tap", keycode: 0xf0 }]), "{raw:1,1,240}"); + assert.equal(viaMacroToExpression([{ type: "down", keycode: 0xe0 }, { type: "down", keycode: 0x06 }, { type: "up", keycode: 0xe0 }]), "{+KC_LCTL}{+KC_C}{-KC_LCTL}"); + assert.equal(viaMacroToExpression([{ type: "text", text: "a{b\\" }]), "a\\{b\\\\"); +}); + +const MENUS = [ + { + label: "Lighting", + content: [ + { + label: "Backlight", + content: [ + { label: "Brightness", type: "range", options: [0, 255], content: ["id_brightness", 3, 1] }, + { label: "Effect", type: "dropdown", options: ["All Off", "Solid", ["Wave", 5]], content: ["id_effect", 3, 2] }, + { label: "Speed", type: "range", showIf: "{id_effect} != 0", content: ["id_speed", 3, 3] , options: [0, 255] }, + { label: "Color", type: "color", showIf: "{id_effect} != 0 && {id_effect} != 24", content: ["id_color", 3, 4] }, + { label: "Static text", content: "just words" }, + { showIf: "{id_effect} == 1", content: [{ label: "Pulse", type: "toggle", content: ["id_pulse", 3, 5] }] }, + { label: "Reset", type: "button", options: [7], content: ["id_reset", 3, 6] }, + { label: "Pick", type: "keycode", content: ["id_pick", 3, 7] }, + ], + }, + ], + }, + { label: "Empty", content: [{ label: "Nothing", content: [{ label: "Words", content: "x" }] }] }, +]; + +test("menus parse into typed controls, folding group conditions and dropping what cannot be driven", () => { + const menus = parseViaMenus(MENUS); + assert.equal(menus.length, 1); + const [section] = menus[0]!.sections; + assert.equal(section!.label, "Backlight"); + assert.deepEqual(section!.items.map((item) => [item.label, item.control.type]), [ + ["Brightness", "range"], ["Effect", "dropdown"], ["Speed", "range"], ["Color", "color"], ["Pulse", "toggle"], ["Reset", "button"], + ]); + const effect = section!.items[1]!; + assert.deepEqual(effect.ids, [3, 2]); + assert.deepEqual(effect.control, { type: "dropdown", options: [{ label: "All Off", value: 0 }, { label: "Solid", value: 1 }, { label: "Wave", value: 5 }] }); + assert.equal(section!.items[4]!.showIf, "{id_effect} == 1"); + assert.deepEqual(section!.items[4]!.control, { type: "toggle", off: 0, on: 1 }); + assert.deepEqual(section!.items[5]!.control, { type: "button", value: 7 }); +}); + +test("showIf expressions follow the definition's current values", () => { + const values = (effect: number) => (token: string): number | undefined => (token === "id_effect" ? effect : undefined); + assert.equal(evaluateViaCondition("{id_effect} != 0", values(0)), false); + assert.equal(evaluateViaCondition("{id_effect} != 0", values(3)), true); + assert.equal(evaluateViaCondition("{id_effect} != 0 && {id_effect} != 24", values(24)), false); + assert.equal(evaluateViaCondition("({id_effect} == 1 || {id_effect} == 2) && !({id_effect} == 2)", values(1)), true); + assert.equal(evaluateViaCondition("{id_effect} >= 2", values(1)), false); + assert.equal(evaluateViaCondition("{unknown} == 0", values(1)), true); +}); + +test("an expression that cannot be parsed shows the control", () => { + assert.equal(evaluateViaCondition("{a} ===", () => 1), true); + assert.equal(evaluateViaCondition("{a} != 0 )", () => 0), true); + assert.equal(evaluateViaCondition("window.alert(1)", () => 0), true); +}); + +test("values convert between bytes and numbers big-endian", () => { + assert.equal(viaValueToNumber([1, 2]), 258); + assert.deepEqual(viaNumberToBytes(258, 2), [1, 2]); + assert.deepEqual(viaNumberToBytes(7, 1), [7]); + assert.throws(() => viaNumberToBytes(256, 1), RangeError); +}); + +test("lighting keycodes follow the protocol generation and the definition modules", () => { + assert.deepEqual(viaLightingKeycodes(11, ["qmk_rgblight_keycodes"]).slice(0, 2), [0x7820, 0x7821]); + assert.equal(describeViaKeycode(0x7820, 11).title, "RGB toggle"); + assert.equal(describeViaKeycode(0x5cc2, 9).title, "RGB toggle"); + assert.equal(describeViaKeycode(0x7840, 12).title, "Matrix on"); + assert.equal(describeViaKeycode(0x7820, 13).title, "Underglow toggle"); + assert.equal(describeViaKeycode(0x7802, 11).title, "Backlight toggle"); + assert.deepEqual(viaLightingKeycodes(11, ["qmk_rgb_matrix_keycodes"]).length, 20); + assert.equal(viaLightingKeycodes(12, ["qmk_rgb_matrix_keycodes"]).length, 20); + assert.equal(viaLightingKeycodes(13, ["qmk_rgb_matrix_keycodes"]).length, 13); + assert.deepEqual(viaLightingKeycodes(11, []), []); +}); + +test("custom keycodes start where the protocol puts them and show in the picker", () => { + const custom = [{ name: "MY_KEY", title: "Does a thing", shortName: "MK" }]; + assert.equal(describeViaKeycode(0x7f00, 11, custom).label, "MK"); + assert.equal(describeViaKeycode(0x7e00, 12, custom).title, "Does a thing"); + assert.equal(describeViaKeycode(0x5f80, 9, custom).label, "MK"); + assert.equal(describeViaKeycode(0x7f01, 11, custom).label, "0x7F01"); + const ids = viaKeycodePicker(11, 4, 0, { custom }).map((group) => group.id); + assert.ok(ids.includes("custom")); + assert.ok(!viaKeycodePicker(11, 4).some((group) => group.id === "custom" || group.id === "lighting")); +}); + +test("composed keycodes round-trip through the describer", () => { + assert.equal(viaComposeKeycode({ kind: "layerTap", layer: 2, key: 0x2c }, 11), 0x422c); + assert.equal(viaComposeKeycode({ kind: "modTap", mods: VIA_MOD.ctrl, key: 0x04 }, 11), 0x2104); + assert.equal(viaComposeKeycode({ kind: "modTap", mods: VIA_MOD.ctrl, key: 0x04 }, 9), 0x6104); + assert.equal(viaComposeKeycode({ kind: "mods", mods: VIA_MOD.ctrl | VIA_MOD.shift, key: 0x06 }, 11), 0x0306); + assert.equal(describeViaKeycode(0x422c, 11).label, "LT(2) Space"); + assert.throws(() => viaComposeKeycode({ kind: "mods", mods: 0, key: 4 }, 11), RangeError); + assert.throws(() => viaComposeKeycode({ kind: "layerTap", layer: 16, key: 4 }, 11), RangeError); +}); + +test("host key events map to basic keycodes and back", () => { + assert.equal(viaKeycodeFromEventCode("KeyA"), 0x04); + assert.equal(viaKeycodeFromEventCode("Digit0"), 0x27); + assert.equal(viaKeycodeFromEventCode("Numpad1"), 0x59); + assert.equal(viaKeycodeFromEventCode("F12"), 0x45); + assert.equal(viaKeycodeFromEventCode("F13"), 0x68); + assert.equal(viaKeycodeFromEventCode("MetaRight"), 0xe7); + assert.equal(viaKeycodeFromEventCode("MediaPlay"), null); + assert.equal(viaEventCodeFromKeycode(0x2c), "Space"); +}); + +test("layout-file names round-trip for every keycode family", () => { + for (const [protocol, code] of [[11, 0x04], [11, 0x5220 + 2], [11, 0x422c], [11, 0x2104], [9, 0x6104], [11, 0x0306], [11, 0x7820], [11, 0x7f01], [11, 0x0000], [11, 0x0a04]] as const) { + const name = viaKeycodeToLayoutName(code, protocol); + assert.equal(viaKeycodeFromLayoutName(name, protocol), code, name); + } + assert.equal(viaKeycodeToLayoutName(0x5222, 11), "MO(2)"); + assert.equal(viaKeycodeToLayoutName(0x422c, 11), "LT(2,KC_SPC)"); + assert.equal(viaKeycodeToLayoutName(0x0306, 11), "LCTL(LSFT(KC_C))"); + assert.equal(viaKeycodeFromLayoutName("MT(MOD_LCTL | MOD_LSFT,KC_A)", 11), 0x2304); + assert.equal(viaKeycodeFromLayoutName("NOT_A_KEY", 11), null); +}); + +test("matrix boards get the shared RGB codes up to protocol 12 and their own RM codes from 13", () => { + const modules = ["qmk_rgb_matrix_keycodes"]; + assert.ok(viaLightingKeycodes(12, modules).includes(0x7820)); + assert.ok(!viaLightingKeycodes(12, modules).includes(0x7842)); + assert.ok(viaLightingKeycodes(13, modules).includes(0x7842)); +}); + +test("the macro keys start at 0x7702 on protocol 11 and at 0x7700 from protocol 12", () => { + assert.equal(viaKeycodeFromLayoutName("MACRO(2)", 11), 0x7704); + assert.equal(viaKeycodeFromLayoutName("MACRO(2)", 12), 0x7702); + assert.equal(viaKeycodeFromLayoutName("MACRO(16)", 11), null); + assert.equal(viaKeycodeToLayoutName(0x7700, 12), "MACRO(0)"); + assert.equal(describeViaKeycode(0x7700, 12).label, "M0"); +}); + +test("layout names cover no-op, transparent, macro and custom keys the way VIA files write them", () => { + for (const protocol of [10, 11, 12]) { + assert.equal(viaKeycodeFromLayoutName("KC_NO", protocol), 0); + assert.equal(viaKeycodeFromLayoutName("KC_TRNS", protocol), 1); + assert.equal(viaKeycodeToLayoutName(0, protocol), "KC_NO"); + assert.equal(viaKeycodeToLayoutName(1, protocol), "KC_TRNS"); + assert.equal(viaKeycodeFromLayoutName(viaKeycodeToLayoutName(viaKeycodeFromLayoutName("MACRO(3)", protocol)!, protocol), protocol), viaKeycodeFromLayoutName("MACRO(3)", protocol)); + } + assert.equal(viaKeycodeFromLayoutName("CUSTOM(2)", 11), 0x7f02); + assert.equal(viaKeycodeFromLayoutName("CUSTOM(2)", 12), 0x7e02); + assert.equal(viaKeycodeFromLayoutName("CUSTOM(2)", 9), 0x5f82); +}); + +test("protocol 10 and older macros store a key action as two bytes without the prefix", () => { + const actions = [{ type: "tap", keycode: 4 }, { type: "down", keycode: 0xe1 }, { type: "up", keycode: 0xe1 }] as const; + assert.deepEqual(encodeViaMacro(actions, 10), [1, 4, 2, 0xe1, 3, 0xe1]); + assert.deepEqual(decodeViaMacro([1, 4, 2, 0xe1, 3, 0xe1], 10), actions); + assert.deepEqual(decodeViaMacro([0x68, 0x69, 1, 4], 10), [{ type: "text", text: "hi" }, { type: "tap", keycode: 4 }]); + assert.deepEqual(encodeViaMacro(actions, 11), [1, 1, 4, 1, 2, 0xe1, 1, 3, 0xe1]); + assert.equal(viaMacroToExpression([{ type: "tap", keycode: 0xfe }], 10), "{raw:1,254}"); + assert.equal(viaMacroToExpression([{ type: "tap", keycode: 0xfe }]), "{raw:1,1,254}"); +}); + +test("a range or button wider than a byte is read as two bytes when the definition omits the width", () => { + const item = (entry: Record) => parseViaMenus([{ label: "M", content: [{ label: "X", content: ["id_x", 3, 1], ...entry }] }])[0]!.sections[0]!.items[0]!; + assert.equal(item({ type: "range", options: [0, 255] }).bytes, 1); + assert.equal(item({ type: "range", options: [0, 1000] }).bytes, 2); + assert.equal(item({ type: "range", options: [0, 1000], bytes: 1 }).bytes, 1); + assert.equal(item({ type: "button", options: 300 }).bytes, 2); +}); + +test("layout files from older QMK spell some keys differently and shorten the modifier wrappers", () => { + assert.equal(viaKeycodeFromLayoutName("KC_SLCK", 11), 0x47); + assert.equal(viaKeycodeFromLayoutName("KC_NLCK", 11), 0x53); + assert.deepEqual(["KC_RO", "KC_KANA", "KC_JYEN", "KC_HENK", "KC_MHEN"].map((name) => viaKeycodeFromLayoutName(name, 11)), [0x87, 0x88, 0x89, 0x8a, 0x8b]); + assert.equal(viaKeycodeFromLayoutName("KC_LANG1", 11), 0x90); + assert.equal(viaKeycodeToLayoutName(0x87, 11), "KC_INT1"); + assert.equal(viaKeycodeFromLayoutName("C(KC_A)", 11), 0x0104); + assert.equal(viaKeycodeFromLayoutName("S(KC_1)", 11), 0x021e); + assert.equal(viaKeycodeFromLayoutName("MEH(KC_A)", 11), 0x0704); + assert.equal(viaKeycodeFromLayoutName("HYPR(KC_A)", 11), 0x0f04); + assert.equal(viaKeycodeFromLayoutName("LCTL(KC_A)", 11), 0x0104); + assert.equal(viaKeycodeFromLayoutName("RALT(KC_A)", 11), 0x1404); +}); + +test("v2 lighting items carry the single value id old firmware expects", () => { + const items = parseViaMenus(parseViaDefinition({ ...SERVED, menus: undefined, lighting: "qmk_backlight_rgblight" }).menus).flatMap((menu) => menu.sections.flatMap((section) => section.items)); + assert.deepEqual(items.map((item) => item.legacyId), [0x09, 0x0a, 0x80, 0x81, 0x82, 0x83]); + assert.deepEqual(items.map((item) => item.ids), [[1, 1], [1, 2], [2, 1], [2, 2], [2, 3], [2, 4]]); + assert.equal(parseViaMenus([{ label: "M", content: [{ label: "X", type: "toggle", content: ["id_x", 3, 1] }] }])[0]!.sections[0]!.items[0]!.legacyId, undefined); +}); + +test("a definition whose keys all sit in layout options still loads", () => { + const layouts = { ...SERVED.layouts, width: undefined, height: undefined, keys: [] }; + const def = parseViaDefinition({ ...SERVED, layouts }); + assert.equal(def.keys.length, 0); + assert.equal(viaActiveKeys(def, [1]).length, 1); + assert.equal(def.width, 4); + assert.throws(() => parseViaDefinition({ ...SERVED, layouts: { ...layouts, optionKeys: {} } }), /no keys/); +}); + +test("the picker offers every layer a board can have and every macro key of the protocol generation", () => { + assert.equal(viaKeycodePicker(12, 32).find((group) => group.id === "layers")!.keycodes.length, 32 * 5); + assert.equal(viaKeycodePicker(12, 3, 40).find((group) => group.id === "macros")!.keycodes.length, 40); + assert.equal(viaKeycodePicker(11, 3, 40).find((group) => group.id === "macros")!.keycodes.length, 16); +}); + +test("a v2 definition keeps its customMenus after the lighting menus", () => { + const custom = [{ label: "Extra", content: [{ label: "Mode", content: [{ label: "Level", type: "range", options: [0, 9], content: ["id_level", 0, 1] }] }] }]; + const menus = parseViaMenus(parseViaDefinition({ ...SERVED, menus: undefined, lighting: "qmk_backlight", customMenus: custom }).menus); + assert.deepEqual(menus.map((menu) => menu.label), ["Lighting", "Extra"]); +}); + +test("object-form v2 lighting extends a preset and applies its overrides", () => { + const lighting = { + extends: "qmk_backlight_rgblight", + effects: [["Off", 0], ["On", 0], ["Pulse", 0]], + underglowEffects: [["All Off", 0], ["Solid", 1], ["Rainbow", 0]], + supportedLightingValues: [9, 10, 128, 129, 131], + }; + const sections = parseViaMenus(parseViaDefinition({ ...SERVED, menus: undefined, lighting }).menus)[0]!.sections; + assert.deepEqual(sections.map((section) => section.label), ["Backlight", "Underglow"]); + const items = sections.flatMap((section) => section.items); + assert.deepEqual(items.map((item) => item.legacyId), [0x09, 0x0a, 0x80, 0x81, 0x83]); + const effect = items.find((item) => item.legacyId === 0x81)!.control; + assert.deepEqual(effect.type === "dropdown" ? effect.options.map((option) => option.label) : [], ["All Off", "Solid", "Rainbow"]); + assert.equal(items.find((item) => item.legacyId === 0x83)!.showIf, "{id_qmk_rgblight_effect} != 0 && {id_qmk_rgblight_effect} != 2"); + assert.deepEqual(parseViaDefinition({ ...SERVED, menus: undefined, lighting: { extends: "wt_rgb_backlight" } }).menus, []); +}); + +test("TO holds 16 layers before protocol 10, so layer 16 and up is not offered, parsed or described as TO", () => { + const toKeys = (protocol: number, layers: number) => viaKeycodePicker(protocol, layers).find((group) => group.id === "layers")!.keycodes.filter((code) => code >= 0x5000 && code < 0x5100); + assert.deepEqual(toKeys(9, 20), Array.from({ length: 16 }, (_, index) => 0x5010 + index)); + assert.equal(viaKeycodeFromLayoutName("TO(15)", 9), 0x501f); + assert.equal(viaKeycodeFromLayoutName("TO(16)", 9), null); + assert.equal(viaKeycodeToLayoutName(0x5020, 9), "0x5020"); + assert.equal(describeViaKeycode(0x5020, 9).label, "0x5020"); + assert.equal(viaKeycodeFromLayoutName("MO(16)", 9), 0x5110); + assert.equal(toKeys(10, 20).length, 20); + assert.equal(viaKeycodeFromLayoutName("TO(31)", 10), 0x501f); +}); + +test("without a stated size, the board is as wide as its widest layout option", () => { + const def = parseViaDefinition({ ...SERVED, layouts: { ...SERVED.layouts, width: undefined, height: undefined } }); + assert.equal(def.width, 4); + assert.equal(def.height, 2); +}); + +test("a toggle given as byte arrays keeps its off and on values and is two bytes wide", () => { + const item = (options: unknown) => parseViaMenus([{ label: "M", content: [{ label: "X", type: "toggle", options, content: ["id_x", 3, 1] }] }])[0]!.sections[0]!.items[0]!; + const wide = item([[0, 0], [0, 1]]); + assert.deepEqual(wide.control, { type: "toggle", off: 0, on: 1 }); + assert.equal(wide.bytes, 2); + assert.deepEqual(item([[1, 0], [0, 0]]).control, { type: "toggle", off: 256, on: 0 }); + assert.equal(item([0, 1]).bytes, 1); + assert.deepEqual(item(undefined).control, { type: "toggle", off: 0, on: 1 }); +}); + +test("one-shot modifiers read and write as OSM(MOD_...)", () => { + assert.equal(viaKeycodeFromLayoutName("OSM(MOD_LCTL)", 11), 0x52a1); + assert.equal(viaKeycodeFromLayoutName("OSM(MOD_LCTL | MOD_LSFT)", 11), 0x52a3); + assert.equal(viaKeycodeFromLayoutName("OSM(MOD_RALT)", 11), 0x52b4); + assert.equal(viaKeycodeFromLayoutName("OSM(MOD_LCTL)", 9), 0x5501); + assert.equal(viaKeycodeFromLayoutName("OSM(NOPE)", 11), null); + for (const [code, protocol] of [[0x52a1, 11], [0x52a3, 12], [0x52b4, 11], [0x5501, 9]] as const) { + assert.equal(viaKeycodeFromLayoutName(viaKeycodeToLayoutName(code, protocol), protocol), code); + } + assert.equal(viaKeycodeToLayoutName(0x52a1, 11), "OSM(MOD_LCTL)"); +}); + +test("a modifier combo needs a real modifier, not only the right-hand selector", () => { + assert.throws(() => viaComposeKeycode({ kind: "mods", mods: VIA_MOD.right, key: 4 }, 11), RangeError); + assert.doesNotThrow(() => viaComposeKeycode({ kind: "mods", mods: VIA_MOD.right | VIA_MOD.ctrl, key: 4 }, 11)); +});