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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions docs/via-support.md
Original file line number Diff line number Diff line change
@@ -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 <https://docs.qmk.fm/keycodes_basic> (`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/<vendorId*65536+productId>.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".
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"docs/steelseries-support.md",
"docs/keychron-support.md",
"docs/arbiter-support.md",
"docs/via-support.md",
"README.md",
"CONTRIBUTING.md"
],
Expand Down Expand Up @@ -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": {
Expand Down
15 changes: 15 additions & 0 deletions src/drivers/keyboard-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
}
18 changes: 16 additions & 2 deletions src/drivers/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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));
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/drivers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,7 +16,8 @@ export type SupportedClient =
| LogitechKeyboardHidClient
| SteelSeriesKeyboardHidClient
| KeychronHidClient
| ArbiterKeyboardHidClient;
| ArbiterKeyboardHidClient
| ViaHidClient;

export interface DeviceDriver {
brand: string;
Expand All @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions src/drivers/vendors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -154,4 +161,5 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [
...STEELSERIES_HID_FILTERS,
...KEYCHRON_HID_FILTERS,
...ARBITER_HID_FILTERS,
...VIA_HID_FILTERS,
];
Loading
Loading