From 11a8617ce621346c395ee4cb8aa128748438bc81 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 11 Oct 2026 23:26:31 +0200 Subject: [PATCH 1/2] feat: add Kochi Hummingbird 68 system-mode control --- README.md | 6 ++ docs/kochi-hb68.md | 62 ++++++++++++++ package.json | 4 + src/drivers/keyboard-types.ts | 2 + src/drivers/kochi/hid.test.ts | 128 +++++++++++++++++++++++++++++ src/drivers/kochi/hid.ts | 115 ++++++++++++++++++++++++++ src/drivers/registry.test.ts | 6 +- src/drivers/registry.ts | 5 +- src/drivers/vendors.ts | 9 ++ src/index.ts | 1 + src/kochi/fixtures/macos-mode.json | 37 +++++++++ src/kochi/index.ts | 71 ++++++++++++++++ src/kochi/protocol.test.ts | 37 +++++++++ tsconfig.json | 33 ++++++-- 14 files changed, 505 insertions(+), 11 deletions(-) create mode 100644 docs/kochi-hb68.md create mode 100644 src/drivers/kochi/hid.test.ts create mode 100644 src/drivers/kochi/hid.ts create mode 100644 src/kochi/fixtures/macos-mode.json create mode 100644 src/kochi/index.ts create mode 100644 src/kochi/protocol.test.ts diff --git a/README.md b/README.md index 768fc8c..4b79c97 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ filters, the driver registry, and shared status types live under | --- | --- | | Wooting | `@openmouse/keyboard-protocol/wooting` | | Wuque Studio | `@openmouse/keyboard-protocol/wuque` | +| Kochi Hummingbird 68 | `@openmouse/keyboard-protocol/kochi` | + +Kochi Hummingbird 68 (`1CA2:0901`) supports reading and changing Windows/Mac +mode on its `FFA0:1` USB configuration collection. Both directions and mode +read-back were exercised on hardware on macOS. This is not full keyboard +configuration support; see [the evidence and limitations](docs/kochi-hb68.md). An exported protocol means OpenMouse implements that wire format. It does not claim every keyboard from that brand works. When a catalog provides a diff --git a/docs/kochi-hb68.md b/docs/kochi-hb68.md new file mode 100644 index 0000000..b039ff3 --- /dev/null +++ b/docs/kochi-hb68.md @@ -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. diff --git a/package.json b/package.json index e0e1106..f3b6ea7 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,10 @@ "./drivers/*": { "types": "./dist/drivers/*.d.ts", "import": "./dist/drivers/*.js" + }, + "./kochi": { + "types": "./dist/kochi/index.d.ts", + "import": "./dist/kochi/index.js" } }, "scripts": { diff --git a/src/drivers/keyboard-types.ts b/src/drivers/keyboard-types.ts index d3c228c..794554d 100644 --- a/src/drivers/keyboard-types.ts +++ b/src/drivers/keyboard-types.ts @@ -165,6 +165,8 @@ export interface WuqueStatus { } export interface KeyboardStatus { + /** HB68 Windows/Mac mode, verified through both mode queries. */ + kochi?: { systemMode: import("../kochi/index.js").KochiSystemMode }; brand: string; name: string; ui?: KeyboardUiHints; diff --git a/src/drivers/kochi/hid.test.ts b/src/drivers/kochi/hid.test.ts new file mode 100644 index 0000000..0b4a6ea --- /dev/null +++ b/src/drivers/kochi/hid.test.ts @@ -0,0 +1,128 @@ +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("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("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); +}); diff --git a/src/drivers/kochi/hid.ts b/src/drivers/kochi/hid.ts new file mode 100644 index 0000000..acfa711 --- /dev/null +++ b/src/drivers/kochi/hid.ts @@ -0,0 +1,115 @@ +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 = 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; } + + static isSupported(device: HIDDevice): boolean { + return device.vendorId === KOCHI_VENDOR_ID && device.productId === KOCHI_HB68_PRODUCT_ID + && device.collections.some(c => c.usagePage === KOCHI_CONFIG_USAGE_PAGE && c.usage === KOCHI_CONFIG_USAGE); + } + + async open(): Promise { + 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(); + if (!this.listening) { + this.device.addEventListener("inputreport", this.onReport); + this.listening = true; + } + } + + async close(): Promise { + 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(operation: () => Promise): Promise { + const next = this.queue.then(operation); + this.queue = next.catch(() => undefined); + return next; + } + + private async exchange(command: KochiCommand): Promise { + await this.open(); + let timer: ReturnType | undefined; + const reply = new Promise((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 { + 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 { + return this.serialized(() => this.readMode()); + } + + setSystemMode(mode: KochiSystemMode): Promise { + 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 { + 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 }, + }; + }); + } +} diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index 535124c..8b313d4 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import { WootingHidClient } from "./wooting/hid.ts"; import { WuqueHidClient } from "./wuque/hid.ts"; import { DEVICE_DRIVERS, clientSupportScore, createSupportedClient, deviceBrand } from "./registry.ts"; -import { SUPPORTED_HID_FILTERS, VENDOR_ID, WOOTING_HID_FILTERS, WUQUE_HID_FILTERS } from "./vendors.ts"; +import { SUPPORTED_HID_FILTERS, KOCHI_HID_FILTERS, VENDOR_ID, WOOTING_HID_FILTERS, WUQUE_HID_FILTERS } from "./vendors.ts"; import { WOOTING_PRODUCT_IDS, WOOTING_VENDOR_ID } from "@openmouse/keyboard-protocol/wooting"; import { WUQUE_CONFIG_USAGE, @@ -43,7 +43,7 @@ const WUQUE_DEVICE = (): HIDDevice => test("DEVICE_DRIVERS lists one branded entry per supported protocol", () => { assert.deepEqual( DEVICE_DRIVERS.map((driver) => driver.brand), - ["Wooting", "Wuque Studio"], + ["Kochi", "Wooting", "Wuque Studio"], ); }); @@ -103,7 +103,7 @@ test("WUQUE_HID_FILTERS pair each product id with its own vendor id", () => { }); test("SUPPORTED_HID_FILTERS concatenates the vendor filter sets with no duplicates", () => { - assert.deepEqual(SUPPORTED_HID_FILTERS, [...WOOTING_HID_FILTERS, ...WUQUE_HID_FILTERS]); + assert.deepEqual(SUPPORTED_HID_FILTERS, [...KOCHI_HID_FILTERS, ...WOOTING_HID_FILTERS, ...WUQUE_HID_FILTERS]); const seen = new Set(SUPPORTED_HID_FILTERS.map((f) => `${f.vendorId}:${f.productId}`)); assert.equal(seen.size, SUPPORTED_HID_FILTERS.length); }); diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 9f6f08f..30f59e3 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -1,7 +1,9 @@ import { WootingHidClient } from "./wooting/hid.ts"; import { WuqueHidClient } from "./wuque/hid.ts"; -export type SupportedClient = WootingHidClient | WuqueHidClient; +import { KochiHidClient } from "./kochi/hid.ts"; + +export type SupportedClient = WootingHidClient | WuqueHidClient | KochiHidClient; export interface DeviceDriver { brand: string; @@ -11,6 +13,7 @@ export interface DeviceDriver { } export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ + { brand: "Kochi", supports: (device) => KochiHidClient.isSupported(device), create: (device) => new KochiHidClient(device), score: () => 6 }, { brand: "Wooting", supports: (device) => WootingHidClient.isSupported(device), create: (device) => new WootingHidClient(device), score: () => 6 }, { brand: "Wuque Studio", supports: (device) => WuqueHidClient.isSupported(device), create: (device) => new WuqueHidClient(device), score: () => 6 }, ]; diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index d6a8525..44ecb2d 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -1,3 +1,4 @@ +import { KOCHI_VENDOR_ID, KOCHI_HB68_PRODUCT_ID, KOCHI_CONFIG_USAGE_PAGE, KOCHI_CONFIG_USAGE } from "@openmouse/keyboard-protocol/kochi"; import { WOOTING_CONFIG_USAGE, WOOTING_CONFIG_USAGE_PAGE, @@ -14,6 +15,7 @@ import { } from "@openmouse/keyboard-protocol/wuque"; export const VENDOR_ID = { + kochi: KOCHI_VENDOR_ID, wooting: WOOTING_VENDOR_ID, wuque: WUQUE_VENDOR_ID, wuqueAlt: WUQUE_VENDOR_ID_ALT, @@ -43,7 +45,14 @@ export const WUQUE_HID_FILTERS: HIDDeviceFilter[] = WUQUE_PRODUCT_IDS.map((produ } )); +/** Only the HB68 config collection; never its keyboard or streaming usages. */ +export const KOCHI_HID_FILTERS: HIDDeviceFilter[] = [{ + vendorId: KOCHI_VENDOR_ID, productId: KOCHI_HB68_PRODUCT_ID, + usagePage: KOCHI_CONFIG_USAGE_PAGE, usage: KOCHI_CONFIG_USAGE, +}]; + export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ + ...KOCHI_HID_FILTERS, ...WOOTING_HID_FILTERS, ...WUQUE_HID_FILTERS, ]; diff --git a/src/index.ts b/src/index.ts index 33b9b09..3e88d19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export * as wooting from "./wooting/index.js"; +export * as kochi from "./kochi/index.js"; diff --git a/src/kochi/fixtures/macos-mode.json b/src/kochi/fixtures/macos-mode.json new file mode 100644 index 0000000..324d567 --- /dev/null +++ b/src/kochi/fixtures/macos-mode.json @@ -0,0 +1,37 @@ +[ + { + "command": 34, + "request": "5c03009322ffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "reply": "5c048014002201ff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "command": 48, + "request": "5c03009330ffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "reply": "5c048014003001ff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "command": 33, + "request": "5c03009321ffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "reply": "5c048014002101ff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "command": 34, + "request": "5c03009322ffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "reply": "5c048014002200ff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "command": 49, + "request": "5c03009331ffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "reply": "5c048014003101ff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "command": 34, + "request": "5c03009322ffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "reply": "5c048014002201ff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "command": 33, + "request": "5c03009321ffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "reply": "5c048014002100ff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } +] diff --git a/src/kochi/index.ts b/src/kochi/index.ts new file mode 100644 index 0000000..617762f --- /dev/null +++ b/src/kochi/index.ts @@ -0,0 +1,71 @@ +/** + * Kochi Hummingbird 68's system-mode protocol. Independently implemented from + * observations of the public HB68 configurator and physical 1CA2:0901 hardware. + * See docs/kochi-hb68.md for evidence and the intentionally limited scope. + */ +export const KOCHI_VENDOR_ID = 0x1ca2; +export const KOCHI_HB68_PRODUCT_ID = 0x0901; +export const KOCHI_CONFIG_USAGE_PAGE = 0xffa0; +export const KOCHI_CONFIG_USAGE = 1; +export const KOCHI_REPORT_ID = 0; +export const KOCHI_REPORT_SIZE = 64; +export type KochiSystemMode = "win" | "mac"; + +/** Only the commands exercised on the HB68; no firmware/calibration commands. */ +export const KOCHI_COMMAND = { + name: 0x26, + queryWin: 0x21, + queryMac: 0x22, + setWin: 0x30, + setMac: 0x31, +} as const; +export type KochiCommand = (typeof KOCHI_COMMAND)[keyof typeof KOCHI_COMMAND]; + +/** Header checksum covers header fields and the final declared payload byte. */ +export function kochiChecksum(frame: Uint8Array): number { + const length = frame[1]; + if (length === undefined || length < 1 || length > 60 || frame.length < length + 4) { + throw new RangeError("Invalid Kochi payload length."); + } + return (0x35 + frame[0]! + length + frame[2]! + frame[length + 3]!) & 0xff; +} + +export function encodeKochiCommand(command: KochiCommand): Uint8Array { + if (!Object.values(KOCHI_COMMAND).includes(command)) throw new RangeError("Unsupported Kochi command."); + const frame = new Uint8Array(KOCHI_REPORT_SIZE); + frame.set([0x5c, 3, 0, 0, command, 0xff, 0xff]); + frame[3] = kochiChecksum(frame); + return frame; +} + +/** Mode queries reply as [reserved, command, 0|1|FF, FF]. FF is unsupported. */ +export function kochiReplyPayload(frame: Uint8Array, command: KochiCommand): Uint8Array | null { + const length = frame[1]; + if (frame[0] !== 0x5c || frame[2] !== 0x80 || length === undefined + || length < 3 || length > 60 || frame.length < length + 4 + || frame[5] !== command || frame[3] !== kochiChecksum(frame)) return null; + return frame.slice(4, length + 4); +} + +export function decodeKochiName(frame: Uint8Array): string | null { + const payload = kochiReplyPayload(frame, KOCHI_COMMAND.name); + if (!payload || payload.length < 34) return null; + const name = payload.slice(2, 34); + const end = name.indexOf(0); + return new TextDecoder().decode(end < 0 ? name : name.slice(0, end)).trim() || null; +} + +export function decodeKochiModeQuery(frame: Uint8Array, mode: KochiSystemMode): boolean | null { + const payload = kochiReplyPayload(frame, mode === "win" ? KOCHI_COMMAND.queryWin : KOCHI_COMMAND.queryMac); + if (!payload || payload.length !== 4 || payload[3] !== 0xff) return null; + return payload[2] === 0 ? false : payload[2] === 1 ? true : null; +} + +export function decodeKochiModeAck(frame: Uint8Array, mode: KochiSystemMode): boolean { + const payload = kochiReplyPayload(frame, mode === "win" ? KOCHI_COMMAND.setWin : KOCHI_COMMAND.setMac); + return payload?.length === 4 && payload[2] === 1 && payload[3] === 0xff; +} + +export function assertKochiMode(mode: KochiSystemMode): void { + if (mode !== "win" && mode !== "mac") throw new RangeError("Expected Windows or Mac mode."); +} diff --git a/src/kochi/protocol.test.ts b/src/kochi/protocol.test.ts new file mode 100644 index 0000000..40dda8a --- /dev/null +++ b/src/kochi/protocol.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import { KOCHI_COMMAND, decodeKochiModeAck, decodeKochiModeQuery, decodeKochiName, encodeKochiCommand, kochiReplyPayload } from "./index.ts"; + +const captures: { command: number; request: string; reply: string }[] = JSON.parse(readFileSync(new URL("./fixtures/macos-mode.json", import.meta.url), "utf8")); +const bytes = (hex: string) => Uint8Array.from(Buffer.from(hex, "hex")); + +test("requests and replies match sanitized physical HB68 mode captures", () => { + for (const capture of captures) { + const command = capture.command as Parameters[0]; + assert.deepEqual(encodeKochiCommand(command), bytes(capture.request)); + assert.ok(kochiReplyPayload(bytes(capture.reply), command)); + if (command === KOCHI_COMMAND.queryWin || command === KOCHI_COMMAND.queryMac) { + assert.equal(decodeKochiModeQuery(bytes(capture.reply), command === KOCHI_COMMAND.queryWin ? "win" : "mac"), bytes(capture.reply)[6] === 1); + } else { + assert.equal(decodeKochiModeAck(bytes(capture.reply), command === KOCHI_COMMAND.setWin ? "win" : "mac"), true); + } + } +}); + +test("name is decoded from the physical KOCHI68 reply", () => { + assert.equal(decodeKochiName(bytes("5c22803300264b4f4348493638000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000")), "KOCHI68"); +}); + +test("malformed, unrelated and unsupported replies cannot report an applied mode", () => { + const valid = bytes(captures[0]!.reply); + assert.equal(decodeKochiModeQuery(valid, "win"), null); + for (const length of [0, 1, 3, 6, 7]) assert.equal(decodeKochiModeQuery(valid.slice(0, length), "mac"), null); + for (const at of [0, 1, 2, 3, 5, 7]) { + const corrupt = valid.slice(); corrupt[at] ^= 1; + assert.equal(decodeKochiModeQuery(corrupt, "mac"), null); + } + const unsupported = valid.slice(); unsupported[6] = 0xff; + assert.equal(decodeKochiModeQuery(unsupported, "mac"), null); + assert.throws(() => encodeKochiCommand(12 as never), /Unsupported/); +}); diff --git a/tsconfig.json b/tsconfig.json index f734419..2c7d6bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2024", "DOM", "DOM.Iterable"], + "lib": [ + "ES2024", + "DOM", + "DOM.Iterable" + ], "module": "NodeNext", "moduleResolution": "NodeNext", "rootDir": "src", @@ -19,12 +23,27 @@ "noFallthroughCasesInSwitch": true, "skipLibCheck": true, "paths": { - "@openmouse/keyboard-protocol": ["./src/index.ts"], - "@openmouse/keyboard-protocol/wooting": ["./src/wooting/index.ts"], - "@openmouse/keyboard-protocol/wuque": ["./src/wuque/index.ts"], - "@openmouse/keyboard-protocol/drivers": ["./src/drivers/index.ts"] + "@openmouse/keyboard-protocol": [ + "./src/index.ts" + ], + "@openmouse/keyboard-protocol/wooting": [ + "./src/wooting/index.ts" + ], + "@openmouse/keyboard-protocol/wuque": [ + "./src/wuque/index.ts" + ], + "@openmouse/keyboard-protocol/drivers": [ + "./src/drivers/index.ts" + ], + "@openmouse/keyboard-protocol/kochi": [ + "./src/kochi/index.ts" + ] } }, - "include": ["src/**/*"], - "exclude": ["src/**/*.test.ts"] + "include": [ + "src/**/*" + ], + "exclude": [ + "src/**/*.test.ts" + ] } From 8b5db182e748f421b4336f23b3417ef8eeb5ba71 Mon Sep 17 00:00:00 2001 From: snekxs Date: Sun, 11 Oct 2026 15:37:24 -0600 Subject: [PATCH 2/2] fix(kochi): search nested config collections and fence open against close --- src/drivers/kochi/hid.test.ts | 29 +++++++++++++++++++++++++++++ src/drivers/kochi/hid.ts | 28 +++++++++++++++++++++++++++- src/kochi/index.ts | 5 +++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/drivers/kochi/hid.test.ts b/src/drivers/kochi/hid.test.ts index 0b4a6ea..e651e8d 100644 --- a/src/drivers/kochi/hid.test.ts +++ b/src/drivers/kochi/hid.test.ts @@ -73,6 +73,18 @@ test("registry/filter claim only the hardware-identified HB68 command collection 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(); @@ -115,6 +127,23 @@ test("timeout and USB failure require a reconnect and never confirm a later oper } }); +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((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); diff --git a/src/drivers/kochi/hid.ts b/src/drivers/kochi/hid.ts index acfa711..bd7a247 100644 --- a/src/drivers/kochi/hid.ts +++ b/src/drivers/kochi/hid.ts @@ -20,15 +20,41 @@ export class KochiHidClient { 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 - && device.collections.some(c => c.usagePage === KOCHI_CONFIG_USAGE_PAGE && c.usage === KOCHI_CONFIG_USAGE); + && 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 { 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; diff --git a/src/kochi/index.ts b/src/kochi/index.ts index 617762f..bc885ad 100644 --- a/src/kochi/index.ts +++ b/src/kochi/index.ts @@ -30,6 +30,7 @@ export function kochiChecksum(frame: Uint8Array): number { return (0x35 + frame[0]! + length + frame[2]! + frame[length + 3]!) & 0xff; } +/** Encode one of the allowed commands into a 64-byte report frame. */ export function encodeKochiCommand(command: KochiCommand): Uint8Array { if (!Object.values(KOCHI_COMMAND).includes(command)) throw new RangeError("Unsupported Kochi command."); const frame = new Uint8Array(KOCHI_REPORT_SIZE); @@ -47,6 +48,7 @@ export function kochiReplyPayload(frame: Uint8Array, command: KochiCommand): Uin return frame.slice(4, length + 4); } +/** Decode the null-terminated product name carried by a name reply. */ export function decodeKochiName(frame: Uint8Array): string | null { const payload = kochiReplyPayload(frame, KOCHI_COMMAND.name); if (!payload || payload.length < 34) return null; @@ -55,17 +57,20 @@ export function decodeKochiName(frame: Uint8Array): string | null { return new TextDecoder().decode(end < 0 ? name : name.slice(0, end)).trim() || null; } +/** Decode a mode query reply: true when the queried mode is active, null if unsupported. */ export function decodeKochiModeQuery(frame: Uint8Array, mode: KochiSystemMode): boolean | null { const payload = kochiReplyPayload(frame, mode === "win" ? KOCHI_COMMAND.queryWin : KOCHI_COMMAND.queryMac); if (!payload || payload.length !== 4 || payload[3] !== 0xff) return null; return payload[2] === 0 ? false : payload[2] === 1 ? true : null; } +/** True when a write reply acknowledges the requested mode change. */ export function decodeKochiModeAck(frame: Uint8Array, mode: KochiSystemMode): boolean { const payload = kochiReplyPayload(frame, mode === "win" ? KOCHI_COMMAND.setWin : KOCHI_COMMAND.setMac); return payload?.length === 4 && payload[2] === 1 && payload[3] === 0xff; } +/** Reject any value that is not the Windows or Mac mode literal. */ export function assertKochiMode(mode: KochiSystemMode): void { if (mode !== "win" && mode !== "mac") throw new RangeError("Expected Windows or Mac mode."); }