Repository navigation
feat(keychron): add Hall-effect keyboard protocol and driver #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
8ffa5a5
feat(keychron): add Hall-effect keyboard protocol and driver
snekxs 7401eb1
feat(keychron): name work/key modes, add key bitmap and profile rename
snekxs b65fa10
feat(keychron): add physical layouts, keymap read and key legends
snekxs b03ba3c
fix(keychron): keep the session usable when the keymap read is ignored
snekxs 6c4cca4
feat(keychron): add key remapping with protocol-aware keycode tables
snekxs 60b36f1
Merge origin/main into feat/keychron-hall-effect
snekxs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| # Keychron Hall-effect keyboards | ||
|
|
||
| Keychron's Hall-effect (HE/TMR) boards share one configuration protocol, the | ||
| `KC_HE` command group (`0xA9`) on the VIA raw-HID collection. The codec is | ||
| `@openmouse/keyboard-protocol/keychron`; the WebHID client is | ||
| `KeychronHidClient` in `src/drivers/keychron/hid.ts`. | ||
|
|
||
| ## Status | ||
|
|
||
| Implemented from static analysis of Keychron Launcher and **not yet run against | ||
| a physical board**. The unit tests pin the byte layouts Launcher uses; they do | ||
| not show firmware behaviour. Treat every write as unverified until a board has | ||
| been exercised, and note the gaps below before relying on a value. | ||
|
|
||
| ## Evidence | ||
|
|
||
| - Launcher (`launcher.keychron.com`, `main.d60cabdb24f8f1c7.js`) contains five | ||
| command classes, `getCommandVersion()` 1 to 5, that build the same | ||
| `[0xA9, subcommand, ...]` reports. Their differences are reproduced here: | ||
| one-byte travel up to v4 and 16-bit travel in v5, 4-byte key slots up to v4 | ||
| and 7-byte slots in v5, and a v4+ profile-info tail with DKS and snap-tap | ||
| counts (v5 adds the travel unit). | ||
| - Reports are 32 bytes, report ID 0, usage page `0xFF60`, usage `0x61`. | ||
| - The product catalog (`src/keychron/products.ts`, 169 boards) comes from | ||
| Launcher's per-board definitions at | ||
| `static/device/{vid*65536+pid}/json/v3.json`, gathered on 2026-10-30. A board | ||
| is included when its definition declares the `HE` feature, an `extra.he` | ||
| block or an `axisList`, or when its name says HE/TMR (three boards, marked | ||
| `launcherFlag: false`). | ||
|
|
||
| ## What is implemented | ||
|
|
||
| | Area | Commands | | ||
| | --- | --- | | ||
| | Identity | version and capability bits, profile count/size/active slot, profile names | | ||
| | Travel | actuation, rapid-trigger press/release, global and per-key (`setTravel`) | | ||
| | Profiles | select, read and decode the whole buffer, save, clear, rename (28-byte UTF-16BE name, length byte `2 * chars`, then save) | | ||
| | Advanced keys | DKS decode and write/remove, snap-tap decode and write, turbo/DKR/toggle slot decode | | ||
| | Response | curve read/write, live per-key travel | | ||
| | Layout and legends | physical key layout per board (`keychronLayoutFor`), keymap read through the standard VIA dynamic-keymap command (`readKeymap`, `readLayerCount`), and `keychronKeycodeLegend` for key labels | | ||
| | Maintenance | calibration start/stop/state, switch axis type read/write, single-key reset, joystick mode | | ||
|
|
||
| ## Layouts and legends | ||
|
|
||
| `src/keychron/layouts.ts` holds the physical key layout (position, size, | ||
| rotation, knob index) for every catalogued board, 103 distinct layouts taken | ||
| from Launcher's definitions with decorative art removed. The profile buffer has | ||
| no key names, so legends come from the keymap: the driver reads it with VIA's | ||
| `DYNAMIC_KEYMAP_GET_BUFFER` (0x12, 14 keys per read, big-endian keycodes), the | ||
| same way Launcher does. `keychronKeycodeLegend` labels basic keycodes, | ||
| modifiers, layer keys and mod/layer-tap wrappers, and shows the hex value for | ||
| anything else (custom or vendor codes). | ||
|
|
||
| ## Key remapping | ||
|
|
||
| `KeychronHidClient.setKeycode(layer, row, col, code)` sets one key with the | ||
| standard VIA `dynamic_keymap_set_keycode` command (`0x05`, big-endian code), as | ||
| Launcher does, then reads the key back (`0x04`) and fails if the board did not | ||
| keep it. Launcher itself does not wait for a reply, so the read-back is this | ||
| driver's addition. Encoders use `0x15`/`0x14` (frames only; no driver method yet). | ||
|
|
||
| The keycode tables depend on the VIA protocol version the board reports | ||
| (`readViaProtocol`, command `0x01`): layer keys sit at `0x5200`/`0x5220`... on | ||
| protocol 11 and later and at `0x5000`/`0x5100`... before that, and the custom | ||
| (Keychron-specific) keys start at `0x7E00` (12+), `0x7F00` (11) or `0x5F80`. | ||
| `setKeycode` only sends codes the table for the board's version can name, so a | ||
| layer or custom code is never sent with the wrong table. `keychronKeycodeCatalog` | ||
| builds the picker's categories from the same table, including the board's | ||
| custom keys. `remapWarnings` flags replacing the only layer key on a layer. | ||
|
|
||
| Not covered: macros, tap-dance and other quantum keycodes beyond the layer, | ||
| mod-tap and layer-tap forms, encoder remapping in the driver, and resetting the | ||
| keymap. A remap persists in the board's memory. | ||
|
|
||
| ## Write parameters (from Launcher's call sites) | ||
|
|
||
| - `workMode`: Global 0, Static 1, QuickTrigger (rapid trigger) 2. | ||
| - `keyMode`: 1 applies to every key; 0 applies to the keys set in a 24-byte | ||
| bitmap (byte `3 * row + floor(col / 8)`, bit `col % 8`). | ||
| - Launcher follows every write with `saveProfile`, which the driver leaves to | ||
| the caller. | ||
|
|
||
| ## Known gaps | ||
|
|
||
| - Travel range: Launcher's displayed range is `min` up to `max + deadzone` | ||
| (`extra.he.distance`); the deadzone extends the top, it is not a lower bound. | ||
| - The profile buffer layout past the snap-tap region, the v5 DKS/snap-tap | ||
| write offsets beyond what Launcher sets, and turbo/DKR write commands are not | ||
| covered. | ||
| - `readStatus` reports connection type as unknown; the raw-HID path is the same | ||
| over USB and the 2.4 GHz receiver, and Bluetooth was not examined. | ||
| - `settingsReady` stays false, so hosts do not show a keyboard settings surface | ||
| until a board has verified the writes. | ||
| - Non-HE Keychron keyboards (QMK/VIA, ZMK, "DEBOUNCE" boards) use a different | ||
| feature set and are not claimed. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,227 @@ | ||
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
| import { KEYCHRON_HE_COMMAND, KEYCHRON_HE_PRODUCTS, KEYCHRON_HE_SUBCOMMAND as SUB } from "../../keychron/index.ts"; | ||
| import { KeychronHidClient } from "./hid.ts"; | ||
|
|
||
| const ID = Number(Object.keys(KEYCHRON_HE_PRODUCTS)[0]); | ||
| const { rows, cols } = KEYCHRON_HE_PRODUCTS[ID]!; | ||
|
|
||
| const SIZE = 4 * (rows * cols + 1) + 19 * 20 + 3 * 20 + 32; | ||
|
|
||
| /** A v4 board with three profiles; the active one is slot 1. */ | ||
| class FakeBoard { | ||
| vendorId = 0x3434; | ||
| productId = ID; | ||
| productName = "Keychron"; | ||
| opened = false; | ||
| collections = [{ usagePage: 0xff60, usage: 0x61, inputReports: [], outputReports: [], featureReports: [], children: [] }]; | ||
| listeners = new Set<(event: HIDInputReportEvent) => void>(); | ||
| sent: Uint8Array[] = []; | ||
| silent = false; | ||
| silentVia = false; | ||
| viaProtocol = 12; | ||
| keymap = new Map<number, number>(); | ||
| keycodeAt(layer: number, key: number) { return this.keymap.get(layer * 10_000 + key) ?? 4 + (key % 26); } | ||
| ignoreKeycodeWrites = false; | ||
| nak = false; | ||
| buffer = new Uint8Array(SIZE); | ||
| version = 4; | ||
| get hid(): HIDDevice { return this as unknown as HIDDevice; } | ||
| async open() { this.opened = true; } | ||
| async close() { this.opened = false; } | ||
| addEventListener(_t: string, l: (e: HIDInputReportEvent) => void) { this.listeners.add(l); } | ||
| removeEventListener(_t: string, l: (e: HIDInputReportEvent) => void) { this.listeners.delete(l); } | ||
| emit(frame: Uint8Array, reportId = 0) { | ||
| const event = { device: this.hid, reportId, data: new DataView(frame.buffer, frame.byteOffset, frame.length) } as HIDInputReportEvent; | ||
| this.listeners.forEach((l) => l(event)); | ||
| } | ||
| async sendReport(reportId: number, data: BufferSource) { | ||
| assert.equal(reportId, 0); | ||
| const request = data as Uint8Array; | ||
| assert.equal(request.length, 32); | ||
| this.sent.push(request.slice()); | ||
| if (this.silent) return; | ||
| if ([0x01, 0x04, 0x05, 0x11, 0x12].includes(request[0]!)) { | ||
| if (this.silentVia) return; | ||
| const via = new Uint8Array(32); | ||
| via.set(request.slice(0, 6)); | ||
| if (request[0] === 0x01) via.set([0, this.viaProtocol], 1); | ||
| else if (request[0] === 0x11) via[1] = 4; | ||
| else if (request[0] === 0x05) { if (!this.ignoreKeycodeWrites) this.keymap.set(request[1]! * 10_000 + request[2]! * cols + request[3]!, request[4]! << 8 | request[5]!); return; } | ||
| else if (request[0] === 0x04) { const code = this.keycodeAt(request[1]!, request[2]! * cols + request[3]!); via[4] = code >> 8; via[5] = code & 0xff; } | ||
| else for (let i = 0; i < request[3]!; i += 2) { | ||
| const key = ((request[1]! << 8 | request[2]!) + i) / 2; | ||
| const code = this.keycodeAt(0, key); via[4 + i] = code >> 8; via[5 + i] = code & 0xff; | ||
| } | ||
| this.emit(via); | ||
| return; | ||
| } | ||
| assert.equal(request[0], KEYCHRON_HE_COMMAND); | ||
| const reply = new Uint8Array(32); | ||
| reply.set(request.slice(0, 2)); | ||
| switch (request[1]) { | ||
| case SUB.getVersion: reply.set([this.version, 0, 0b100001], 2); break; | ||
| case SUB.getProfilesInfo: reply.set([1, 3, SIZE & 0xff, SIZE >> 8, 20, 20], 2); break; | ||
| case SUB.getProfileBuffer: { | ||
| reply.set(request.slice(2, 6), 2); | ||
| const offset = request[3]! | (request[4]! << 8); | ||
| const length = request[5]!; | ||
| // The name window sits in the last 32 bytes: "P" + slot digit. | ||
| const nameStart = SIZE - 32; | ||
| const name = new Uint8Array(32); | ||
| name.set([0, 0x50, 0, 0x30 + request[2]!]); | ||
| for (let i = 0; i < length; i++) { | ||
| const at = offset + i; | ||
| reply[6 + i] = at >= nameStart ? name[at - nameStart]! : this.buffer[at]!; | ||
| } | ||
| break; | ||
| } | ||
| case SUB.setProfileName: break; | ||
| case SUB.setTravel: case SUB.saveProfileBuffer: reply[2] = this.nak ? 1 : 0; break; | ||
| case SUB.getRealtimeTravel: reply.set([request[2]!, request[3]!, 33], 3); break; | ||
| default: break; | ||
| } | ||
| this.emit(new Uint8Array(32).fill(0x7f), 1); // wrong report id never completes a transaction | ||
| this.emit(reply); | ||
| } | ||
| } | ||
|
|
||
| const client = (board: FakeBoard) => new KeychronHidClient(board.hid); | ||
|
|
||
| test("isSupported needs a catalogued HE product and the raw-HID collection", () => { | ||
| assert.equal(KeychronHidClient.isSupported(new FakeBoard().hid), true); | ||
| const other = new FakeBoard(); other.productId = 0x0001; | ||
| assert.equal(KeychronHidClient.isSupported(other.hid), false); | ||
| const vendor = new FakeBoard(); vendor.vendorId = 0x1234; | ||
| assert.equal(KeychronHidClient.isSupported(vendor.hid), false); | ||
| const page = new FakeBoard(); page.collections = [{ ...page.collections[0]!, usage: 1 }]; | ||
| assert.equal(KeychronHidClient.isSupported(page.hid), false); | ||
| }); | ||
|
|
||
| test("readStatus reports version, units and profile names", async () => { | ||
| const board = new FakeBoard(); | ||
| const status = await client(board).readStatus(); | ||
| assert.equal(status.brand, "Keychron"); | ||
| assert.equal(status.activeProfile, 1); | ||
| assert.equal(status.profileCount, 3); | ||
| assert.deepEqual(status.profileNames, ["P0", "P1", "P2"]); | ||
| assert.equal(status.keychron?.heVersion, 4); | ||
| assert.equal(status.keychron?.unit, 0.1); | ||
| assert.equal(status.keychron?.features.switchAxis, true); | ||
| assert.equal(status.keychron?.features.supportMechanical, true); | ||
| }); | ||
|
|
||
| test("readProfile walks the buffer in 26-byte reads and decodes keys", async () => { | ||
| const board = new FakeBoard(); | ||
| const word = (12 << 2) | (3 << 8); | ||
| board.buffer.set([word & 0xff, (word >> 8) & 0xff, 0, 0], 4 * (cols + 1)); // key (1,0) | ||
| const profile = await client(board).readProfile(); | ||
| assert.equal(profile.keys[1]![0]!.distance, 12); | ||
| assert.equal(profile.keys[1]![0]!.advancePress, 3); | ||
| const reads = board.sent.filter((frame) => frame[1] === SUB.getProfileBuffer); | ||
| assert.ok(reads.length >= Math.ceil(SIZE / 26)); | ||
| assert.ok(reads.every((frame) => frame[2] === 1 && frame[5]! <= 26)); | ||
| await assert.rejects(() => client(board).readProfile(9), RangeError); | ||
| }); | ||
|
|
||
| test("setTravel targets the active profile and requires an acknowledgement", async () => { | ||
| const board = new FakeBoard(); | ||
| const keyboard = client(board); | ||
| await keyboard.setTravel({ workMode: 1, press: 20, sensitivePress: 4, keyMode: 1 }); | ||
| const write = board.sent.find((frame) => frame[1] === SUB.setTravel)!; | ||
| assert.deepEqual([...write.slice(2, 8)], [1, 1, 20, 4, 4, 1]); | ||
| await keyboard.saveProfile(); | ||
| assert.equal(board.sent.at(-1)![1], SUB.saveProfileBuffer); | ||
| board.nak = true; | ||
| await assert.rejects(() => keyboard.saveProfile(), /did not save/); | ||
| }); | ||
|
|
||
| test("renameProfile writes the name then saves", async () => { | ||
| const board = new FakeBoard(); | ||
| await client(board).renameProfile(2, "Aim"); | ||
| const rename = board.sent.find((frame) => frame[1] === SUB.setProfileName)!; | ||
| assert.deepEqual([...rename.slice(2, 8)], [2, 6, 0, 0x41, 0, 0x69]); | ||
| assert.equal(board.sent.at(-1)![1], SUB.saveProfileBuffer); | ||
| await assert.rejects(() => client(board).renameProfile(7, "x"), RangeError); | ||
| await assert.rejects(() => client(board).renameProfile(0, ""), RangeError); | ||
| }); | ||
|
|
||
| test("readKeymap reads one layer through the VIA keymap command", async () => { | ||
| const board = new FakeBoard(); | ||
| const keymap = await client(board).readKeymap(0); | ||
| assert.equal(keymap.length, rows); | ||
| assert.ok(keymap.every((row) => row.length === cols)); | ||
| assert.deepEqual(keymap[0]!.slice(0, 3), [4, 5, 6]); | ||
| const reads = board.sent.filter((frame) => frame[0] === 0x12); | ||
| assert.equal(reads.length, Math.ceil(rows * cols / 14)); | ||
| assert.equal(await client(board).readLayerCount(), 4); | ||
| }); | ||
|
|
||
| test("a board that ignores the keymap read keeps its session for HE commands", async () => { | ||
| const board = new FakeBoard(); | ||
| board.silentVia = true; | ||
| const keyboard = client(board); | ||
| await assert.rejects(() => keyboard.readKeymap(0), /did not answer/); | ||
| await assert.rejects(() => keyboard.readLayerCount(), /did not answer/); | ||
| assert.equal((await keyboard.readStatus()).brand, "Keychron"); | ||
| assert.equal(await keyboard.readKeyTravel(1, 1), 33); | ||
| }); | ||
|
|
||
| test("setKeycode writes one key and confirms it by reading it back", async () => { | ||
| const board = new FakeBoard(); | ||
| const keyboard = client(board); | ||
| assert.equal(await keyboard.readViaProtocol(), 12); | ||
| assert.equal(await keyboard.setKeycode(0, 1, 2, 0x2c), 0x2c); // Space | ||
| const write = board.sent.find((frame) => frame[0] === 0x05)!; | ||
| assert.deepEqual([...write.slice(0, 6)], [0x05, 0, 1, 2, 0x00, 0x2c]); | ||
| assert.equal((await keyboard.readKeymap(0))[1]![2], 0x2c); | ||
| await assert.rejects(() => keyboard.setKeycode(0, rows, 0, 4), RangeError); | ||
| await assert.rejects(() => keyboard.setKeycode(0, 0, 0, 0x7e40), /not valid/); | ||
| }); | ||
|
|
||
| test("setKeycode fails loudly when the board does not keep the assignment", async () => { | ||
| const board = new FakeBoard(); | ||
| board.ignoreKeycodeWrites = true; | ||
| await assert.rejects(() => client(board).setKeycode(0, 0, 0, 0x2c), /did not keep/); | ||
| }); | ||
|
|
||
| test("older firmware uses the legacy layer-key table and rejects modern layer codes", async () => { | ||
| const board = new FakeBoard(); | ||
| board.viaProtocol = 10; | ||
| const keyboard = client(board); | ||
| assert.equal(await keyboard.setKeycode(0, 0, 0, 0x5100), 0x5100); // MO(0) on the legacy table | ||
| await assert.rejects(() => keyboard.setKeycode(0, 0, 1, 0x5221), /not valid/); // modern MO(1) means something else | ||
| }); | ||
|
|
||
| test("remapWarnings flags replacing the only layer key on a layer", async () => { | ||
| const board = new FakeBoard(); | ||
| board.keymap.set(0, 0x5221); // MO(1), the only layer key on layer 0 | ||
| const keyboard = client(board); | ||
| assert.equal((await keyboard.remapWarnings(0, 0, 0, 0x04)).length, 1); | ||
| assert.deepEqual(await keyboard.remapWarnings(0, 0, 0, 0x5223), []); | ||
| assert.deepEqual(await keyboard.remapWarnings(0, 0, 1, 0x04), []); | ||
| }); | ||
|
|
||
| test("live travel matches the echoed key", async () => { | ||
| const board = new FakeBoard(); | ||
| assert.equal(await client(board).readKeyTravel(2, 5), 33); | ||
| }); | ||
|
|
||
| test("a silent board faults the session so a late reply cannot confirm a later command", async () => { | ||
| const board = new FakeBoard(); | ||
| board.silent = true; | ||
| const keyboard = client(board); | ||
| await assert.rejects(() => keyboard.readStatus(), /did not answer/); | ||
| board.silent = false; | ||
| await assert.rejects(() => keyboard.readStatus(), /Reconnect/); | ||
| }); | ||
|
|
||
| test("close releases the device and rejects further use", async () => { | ||
| const board = new FakeBoard(); | ||
| const keyboard = client(board); | ||
| await keyboard.readStatus(); | ||
| await keyboard.close(); | ||
| assert.equal(board.opened, false); | ||
| assert.equal(board.listeners.size, 0); | ||
| await assert.rejects(() => keyboard.open(), /Reconnect/); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove the unsupported slot-decoding claim.
decodeKeychronProfileBufferdoes not decode turbo, DKR, or toggle slot contents. It only exposes the key’s advanced mode and index. Update the table so consumers do not rely on unavailable data. (raw.githubusercontent.com)