Repository navigation
feat: add Kochi Hummingbird 68 system-mode control #2
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
snekxs
merged 2 commits into
OpenMouse-Project:main
from
eichiiiwastaken:feat/kochi-hb68-system-mode
Oct 11, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
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,62 @@ | ||
| # Kochi Hummingbird 68: Windows/Mac mode | ||
|
|
||
| This is deliberately a system-mode driver, not a claim of full HB68 feature | ||
| parity. It implements device naming, both system-mode queries, and Windows/Mac | ||
| mode changes. RGB, keymaps, analog travel, profiles, firmware, reset and | ||
| calibration commands are not exposed. | ||
|
|
||
| ## Evidence | ||
|
|
||
| The public [HB68 configurator](https://hb68.devminer.xyz/device) and its | ||
| [bundled implementation](https://github.com/TheDevMinerTV/hummingbird68-webui/tree/3f30fb26553da50ca8438adf661af594558a0490/assets) | ||
| identify the framing and command meanings. This implementation was written | ||
| independently; no vendor bundle or executable is included in this package. | ||
|
|
||
| On 2026-10-11 a physical `KOCHI68 Keyboard` on macOS enumerated as USB | ||
| `1CA2:0901`. The command interface is usage page `FFA0`, usage `1`, with 64-byte | ||
| input/output reports and report ID `0`. The same product also exposes boot | ||
| keyboard collections and a vendor usage `2`; those are not claimed. | ||
|
|
||
| Hardware checks: | ||
|
|
||
| - Name query returned `KOCHI68`. | ||
| - Initial Windows query returned `0`, Mac query returned `1`. | ||
| - Windows change returned acknowledgement `1`; Windows/Mac queries returned | ||
| `1`/`0` afterward. | ||
| - Mac change returned acknowledgement `1`; Windows/Mac queries returned | ||
| `0`/`1` afterward. The original mode was restored. | ||
|
|
||
| Sanitized actual request/reply bytes are in | ||
| `src/kochi/fixtures/macos-mode.json`. They contain no serial number, keystrokes, | ||
| or personal data. Firmware version was not queried. This verifies USB commands | ||
| on this physical board on macOS, not every firmware revision or operating | ||
| system. Linux and Windows host testing remains outstanding. | ||
|
|
||
| ## Wire format | ||
|
|
||
| Requests are zero-padded 64-byte reports: | ||
|
|
||
| `[5C, payloadLength, 00, checksum, command, FF, FF, ...zeroes]` | ||
|
|
||
| Replies use group `80`; their payload starts with a reserved byte, followed by | ||
| the echoed command. Mode query/write payloads are | ||
| `[reserved, command, result, FF]`. A query result `0` means inactive, `1` active, | ||
| and `FF` unsupported. A mode-write result `1` acknowledges the request. | ||
|
|
||
| The checksum is `(35 + header + payloadLength + group + finalPayloadByte) & FF`. | ||
| It does **not** cover every payload byte. The driver checks framing, checksum, | ||
| report ID, command echo, allowed result values, and agreement of both queries. | ||
|
|
||
| Commands: `26` name, `21` Windows query, `22` Mac query, `30` Windows change, | ||
| `31` Mac change (hexadecimal). Reads worked without a synchronization command; | ||
| no bootloader handshake is issued. | ||
|
|
||
| Transactions are serialized. Writes require an acknowledgement followed by | ||
| both mode queries. A timeout or transport failure requires a new client | ||
| connection, because the protocol has no transaction ID with which to reject | ||
| late replies. The driver never speculates about persistence or writes flash: | ||
| whether the mode survives unplugging is not yet tested. | ||
|
|
||
| For Linux, WebHID/hidraw access may require a device-specific udev rule. The | ||
| application should use Windows mode for a conventional PC/Linux key layout, | ||
| and Mac mode for macOS, matching the existing configurator's selector. |
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,157 @@ | ||
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
| import { KOCHI_COMMAND, kochiChecksum } from "../../kochi/index.ts"; | ||
| import { KochiHidClient } from "./hid.ts"; | ||
| import { createSupportedClient, deviceBrand } from "../registry.ts"; | ||
| import { KOCHI_HID_FILTERS } from "../vendors.ts"; | ||
|
|
||
| class FakeDevice { | ||
| vendorId = 0x1ca2; | ||
| productId = 0x0901; | ||
| productName = "KOCHI68 Keyboard"; | ||
| opened = false; | ||
| collections = [{ usagePage: 0xffa0, usage: 1, inputReports: [], outputReports: [], featureReports: [], children: [] }]; | ||
| mode: "win" | "mac" = "mac"; | ||
| sent: number[] = []; | ||
| silent = false; | ||
| refused = false; | ||
| applyWrite = true; | ||
| inconsistent = false; | ||
| sendError = false; | ||
| wrongName = false; | ||
| listeners = new Set<(event: HIDInputReportEvent) => void>(); | ||
| get hid(): HIDDevice { return this as unknown as HIDDevice; } | ||
| async open() { this.opened = true; } | ||
| async close() { this.opened = false; } | ||
| addEventListener(_type: string, listener: (event: HIDInputReportEvent) => void) { this.listeners.add(listener); } | ||
| removeEventListener(_type: string, listener: (event: HIDInputReportEvent) => void) { this.listeners.delete(listener); } | ||
| emit(frame: Uint8Array, reportId = 0, offset = false) { | ||
| const wrapped = offset ? new Uint8Array(frame.length + 8) : frame; | ||
| if (offset) wrapped.set(frame, 4); | ||
| const event = { device: this.hid, reportId, data: new DataView(wrapped.buffer, offset ? 4 : 0, frame.length) } as HIDInputReportEvent; | ||
| this.listeners.forEach(listener => listener(event)); | ||
| } | ||
| async sendReport(reportId: number, buffer: BufferSource) { | ||
| assert.equal(reportId, 0); | ||
| const request = buffer as Uint8Array; | ||
| assert.equal(request.length, 64); | ||
| const command = request[4]!; | ||
| this.sent.push(command); | ||
| if (this.sendError) throw new Error("USB send failed"); | ||
| if (this.silent) return; | ||
| let value = 0; | ||
| if (command === KOCHI_COMMAND.setWin || command === KOCHI_COMMAND.setMac) { | ||
| value = this.refused ? 0 : 1; | ||
| if (!this.refused && this.applyWrite) this.mode = command === KOCHI_COMMAND.setWin ? "win" : "mac"; | ||
| } else { | ||
| value = Number(this.mode === (command === KOCHI_COMMAND.queryWin ? "win" : "mac")); | ||
| if (this.inconsistent) value = 1; | ||
| } | ||
| const reply = new Uint8Array(64); | ||
| reply.set([0x5c, 4, 0x80, 0, 0, command, value, 0xff]); | ||
| if (command === KOCHI_COMMAND.name) { | ||
| reply[1] = 34; | ||
| reply.fill(0, 6); | ||
| reply.set(new TextEncoder().encode(this.wrongName ? "OTHER" : "KOCHI68"), 6); | ||
| } | ||
| reply[3] = kochiChecksum(reply); | ||
| // A wrong report and a damaged checksum cannot finish the transaction. | ||
| this.emit(reply, 1); | ||
| const bad = reply.slice(); bad[3] ^= 1; this.emit(bad); | ||
| this.emit(reply, 0, true); | ||
| } | ||
| } | ||
|
|
||
| test("registry/filter claim only the hardware-identified HB68 command collection", () => { | ||
| const device = new FakeDevice(); | ||
| assert.ok(createSupportedClient(device.hid) instanceof KochiHidClient); | ||
| assert.equal(deviceBrand(new KochiHidClient(device.hid)), "Kochi"); | ||
| assert.deepEqual(KOCHI_HID_FILTERS, [{ vendorId: 0x1ca2, productId: 0x0901, usagePage: 0xffa0, usage: 1 }]); | ||
| device.collections[0]!.usage = 2; assert.equal(KochiHidClient.isSupported(device.hid), false); | ||
| device.collections[0]!.usage = 1; device.collections[0]!.usagePage = 1; assert.equal(KochiHidClient.isSupported(device.hid), false); | ||
| device.collections[0]!.usagePage = 0xffa0; device.productId++; assert.equal(KochiHidClient.isSupported(device.hid), false); | ||
| device.productId--; device.vendorId++; assert.equal(KochiHidClient.isSupported(device.hid), false); | ||
| }); | ||
|
|
||
| test("a config collection nested under a parent collection is still claimed", () => { | ||
| const device = new FakeDevice(); | ||
| const nested = device.collections[0]!; | ||
| device.collections = [{ | ||
| usagePage: 0x0001, usage: 0x06, | ||
| inputReports: [], outputReports: [], featureReports: [], | ||
| children: [nested], | ||
| }]; | ||
| assert.equal(KochiHidClient.isSupported(device.hid), true); | ||
| assert.ok(createSupportedClient(device.hid) instanceof KochiHidClient); | ||
| }); | ||
|
|
||
| test("identity and mode are read without firmware, serial, or calibration commands", async () => { | ||
| const device = new FakeDevice(); const client = new KochiHidClient(device.hid); | ||
| const status = await client.readStatus(); | ||
| assert.equal(status.name, "Hummingbird 68"); | ||
| assert.deepEqual(status.kochi, { systemMode: "mac" }); | ||
| assert.equal(status.serial, null); | ||
| assert.deepEqual(device.sent, [38, 33, 34]); | ||
| await client.close(); assert.equal(device.listeners.size, 0); | ||
| }); | ||
|
|
||
| test("concurrent mode changes are serialized with both read-back queries", async () => { | ||
| const device = new FakeDevice(); const client = new KochiHidClient(device.hid); | ||
| assert.deepEqual(await Promise.all([client.setSystemMode("win"), client.setSystemMode("mac")]), ["win", "mac"]); | ||
| assert.deepEqual(device.sent, [48, 33, 34, 49, 33, 34]); | ||
| assert.throws(() => client.setSystemMode("linux" as never), /Windows or Mac/); | ||
| await client.close(); | ||
| }); | ||
|
|
||
| test("refused writes, unmodified state, contradictions and wrong identity fail clearly", async () => { | ||
| for (const flag of ["refused", "applyWrite", "inconsistent"] as const) { | ||
| const device = new FakeDevice(); device[flag] = flag !== "applyWrite"; | ||
| const client = new KochiHidClient(device.hid); | ||
| await assert.rejects(client.setSystemMode("win"), /acknowledge|read-back|consistent/); | ||
| await client.close(); | ||
| } | ||
| const device = new FakeDevice(); device.wrongName = true; | ||
| const client = new KochiHidClient(device.hid); | ||
| await assert.rejects(client.readStatus(), /identify/); await client.close(); | ||
| }); | ||
|
|
||
| test("timeout and USB failure require a reconnect and never confirm a later operation", async () => { | ||
| for (const flag of ["silent", "sendError"] as const) { | ||
| const device = new FakeDevice(); device[flag] = true; | ||
| const client = new KochiHidClient(device.hid); | ||
| await assert.rejects(client.readSystemMode(), /answer|USB send/); | ||
| device[flag] = false; | ||
| await assert.rejects(client.setSystemMode("win"), /Reconnect/); | ||
| assert.deepEqual(device.sent, [33]); | ||
| await client.close(); | ||
| } | ||
| }); | ||
|
|
||
| test("close during a pending open releases the device without sending a command", async () => { | ||
| const device = new FakeDevice(); | ||
| let finishOpen!: () => void; | ||
| const opening = new Promise<void>((resolve) => { finishOpen = resolve; }); | ||
| device.open = async () => { await opening; device.opened = true; }; | ||
| const client = new KochiHidClient(device.hid); | ||
| const read = client.readSystemMode(); | ||
| // Let the queued exchange reach the suspended device.open(). | ||
| for (let n = 0; n < 5; n++) await Promise.resolve(); | ||
| await client.close(); | ||
| finishOpen(); | ||
| await assert.rejects(read, /Reconnect/); | ||
| assert.equal(device.opened, false); | ||
| assert.equal(device.listeners.size, 0); | ||
| assert.deepEqual(device.sent, []); | ||
| }); | ||
|
|
||
| test("disconnect rejects pending reads and removes its input listener", async () => { | ||
| const device = new FakeDevice(); device.silent = true; | ||
| const client = new KochiHidClient(device.hid); | ||
| await client.open(); | ||
| const read = client.readSystemMode(); | ||
| // Allow the queued exchange to install the listener before closing. | ||
| for (let n = 0; n < 5; n++) await Promise.resolve(); | ||
| await client.close(); | ||
| await assert.rejects(read, /disconnected/); | ||
| assert.equal(device.listeners.size, 0); | ||
| }); |
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,141 @@ | ||
| import type { KeyboardStatus } from "../keyboard-types.ts"; | ||
| import { | ||
| KOCHI_COMMAND, KOCHI_CONFIG_USAGE, KOCHI_CONFIG_USAGE_PAGE, KOCHI_HB68_PRODUCT_ID, | ||
| KOCHI_REPORT_ID, KOCHI_VENDOR_ID, assertKochiMode, decodeKochiModeAck, | ||
| decodeKochiModeQuery, decodeKochiName, encodeKochiCommand, kochiReplyPayload, | ||
| type KochiCommand, type KochiSystemMode, | ||
| } from "@openmouse/keyboard-protocol/kochi"; | ||
|
|
||
| /** USB HB68 configuration only; boot-keyboard/stream collections are not claimed. */ | ||
| export class KochiHidClient { | ||
| readonly device: HIDDevice; | ||
| private queue: Promise<unknown> = Promise.resolve(); | ||
| private listening = false; | ||
| private faulted = false; | ||
| private pending: { | ||
| command: KochiCommand; | ||
| resolve: (frame: Uint8Array) => void; | ||
| reject: (error: Error) => void; | ||
| } | null = null; | ||
|
|
||
| constructor(device: HIDDevice) { this.device = device; } | ||
|
|
||
| /** Exact 1CA2:0901 identity plus a command-capable config collection. */ | ||
| static isSupported(device: HIDDevice): boolean { | ||
| return device.vendorId === KOCHI_VENDOR_ID && device.productId === KOCHI_HB68_PRODUCT_ID | ||
| && KochiHidClient.configCollection(device.collections) !== null; | ||
| } | ||
|
|
||
| /** | ||
| * The command-capable config collection, if this device exposes one. WebHID | ||
| * permits nested collections, so search children as well: a matching HB68 | ||
| * collection can sit below a top-level application collection. | ||
| */ | ||
| private static configCollection( | ||
| collections: readonly HIDCollectionInfo[], | ||
| ): HIDCollectionInfo | null { | ||
| for (const collection of collections) { | ||
| if (collection.usagePage === KOCHI_CONFIG_USAGE_PAGE && collection.usage === KOCHI_CONFIG_USAGE) { | ||
| return collection; | ||
| } | ||
| const nested = KochiHidClient.configCollection(collection.children); | ||
| if (nested) return nested; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| async open(): Promise<void> { | ||
| if (!KochiHidClient.isSupported(this.device)) throw new Error("Not an HB68 configuration interface."); | ||
| if (this.faulted) throw new Error("Reconnect the HB68 after a failed transaction."); | ||
| if (!this.device.opened) await this.device.open(); | ||
| // close() can land while device.open() is pending and cannot see the | ||
| // device yet, so recheck after the await: release it instead of installing | ||
| // a listener or letting a command reach an already-closed session. | ||
| if (this.faulted) { | ||
| if (this.device.opened) await this.device.close(); | ||
| throw new Error("Reconnect the HB68 after a failed transaction."); | ||
| } | ||
| if (!this.listening) { | ||
| this.device.addEventListener("inputreport", this.onReport); | ||
| this.listening = true; | ||
| } | ||
| } | ||
|
|
||
| async close(): Promise<void> { | ||
| this.faulted = true; | ||
| this.pending?.reject(new Error("HB68 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 || event.reportId !== KOCHI_REPORT_ID || !this.pending) return; | ||
| const frame = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); | ||
| if (kochiReplyPayload(frame, this.pending.command)) this.pending.resolve(frame.slice()); | ||
| }; | ||
|
|
||
| private serialized<T>(operation: () => Promise<T>): Promise<T> { | ||
| const next = this.queue.then(operation); | ||
| this.queue = next.catch(() => undefined); | ||
| return next; | ||
| } | ||
|
|
||
| private async exchange(command: KochiCommand): Promise<Uint8Array> { | ||
| await this.open(); | ||
| let timer: ReturnType<typeof setTimeout> | undefined; | ||
| const reply = new Promise<Uint8Array>((resolve, reject) => { | ||
| this.pending = { command, resolve, reject }; | ||
| timer = setTimeout(() => reject(new Error(`HB68 did not answer command 0x${command.toString(16)}.`)), 1500); | ||
| }); | ||
| try { | ||
| const [frame] = await Promise.all([reply, this.device.sendReport(KOCHI_REPORT_ID, encodeKochiCommand(command))]); | ||
| return frame; | ||
| } catch (error) { | ||
| // No transaction id exists on the wire. Do not reuse this session after | ||
| // a timeout: a delayed reply could otherwise confirm a later command. | ||
| this.faulted = true; | ||
| throw error; | ||
| } finally { | ||
| clearTimeout(timer); | ||
| this.pending = null; | ||
| } | ||
| } | ||
|
|
||
| private async readMode(): Promise<KochiSystemMode> { | ||
| const win = decodeKochiModeQuery(await this.exchange(KOCHI_COMMAND.queryWin), "win"); | ||
| const mac = decodeKochiModeQuery(await this.exchange(KOCHI_COMMAND.queryMac), "mac"); | ||
| if (win === null || mac === null || win === mac) throw new Error("HB68 did not report a consistent Windows/Mac mode."); | ||
| return mac ? "mac" : "win"; | ||
| } | ||
|
|
||
| readSystemMode(): Promise<KochiSystemMode> { | ||
| return this.serialized(() => this.readMode()); | ||
| } | ||
|
|
||
| setSystemMode(mode: KochiSystemMode): Promise<KochiSystemMode> { | ||
| assertKochiMode(mode); | ||
| return this.serialized(async () => { | ||
| const frame = await this.exchange(mode === "win" ? KOCHI_COMMAND.setWin : KOCHI_COMMAND.setMac); | ||
| if (!decodeKochiModeAck(frame, mode)) throw new Error("HB68 did not acknowledge the system-mode change."); | ||
| const confirmed = await this.readMode(); | ||
| if (confirmed !== mode) throw new Error("HB68 system-mode read-back did not match the request."); | ||
| return confirmed; | ||
| }); | ||
| } | ||
|
|
||
| readStatus(): Promise<KeyboardStatus> { | ||
| return this.serialized(async () => { | ||
| const name = decodeKochiName(await this.exchange(KOCHI_COMMAND.name)); | ||
| if (name !== "KOCHI68") throw new Error("The connected keyboard did not identify as KOCHI68."); | ||
| const systemMode = await this.readMode(); | ||
| return { | ||
| brand: "Kochi", name: "Hummingbird 68", connectionType: "Wired", connectionDetail: "USB", | ||
| firmware: [], firmwareVersion: null, layout: null, serial: null, | ||
| activeProfile: null, profileCount: null, profileNames: [], | ||
| ui: { family: "kochi", defaultDisplayName: "Hummingbird 68" }, | ||
| kochi: { systemMode }, | ||
| }; | ||
| }); | ||
| } | ||
| } | ||
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.
Uh oh!
There was an error while loading. Please reload this page.