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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions docs/kochi-hb68.md
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.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions src/drivers/keyboard-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
157 changes: 157 additions & 0 deletions src/drivers/kochi/hid.test.ts
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);
});
141 changes: 141 additions & 0 deletions src/drivers/kochi/hid.ts
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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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 },
};
});
}
}
Loading
Loading