diff --git a/src/drivers/keychron/hid.test.ts b/src/drivers/keychron/hid.test.ts index 0fa1b82..de043a7 100644 --- a/src/drivers/keychron/hid.test.ts +++ b/src/drivers/keychron/hid.test.ts @@ -1,8 +1,169 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { KeychronHidClient } from "./hid.ts"; -import { VENDOR_ID } from "../vendors.ts"; +// `hid.ts` schedules write delays and query timeouts through `window`. +Object.assign(globalThis, { window: globalThis }); + +const { KeychronHidClient } = await import("./hid.ts"); +const { + KEYCHRON_COMMAND, + KEYCHRON_MISC_COMMAND, + KEYCHRON_NAPE_COMMAND, + KEYCHRON_PACKET_LENGTH, + KEYCHRON_POLLING_TABLE, +} = await import("@openmouse/protocol/keychron"); +const { VENDOR_ID } = await import("../vendors.ts"); + +const CMD = KEYCHRON_COMMAND; +const MISC = KEYCHRON_MISC_COMMAND; +const NAPE = KEYCHRON_NAPE_COMMAND; + +type FakeOptions = { + productId?: number; + productName?: string; + /** Force orientation/DPI values that fail Nape Pro receiver verification. */ + incompatibleReceiver?: boolean; +}; + +class FakeHidDevice { + readonly vendorId = VENDOR_ID.keychron; + readonly productId: number; + readonly productName: string; + readonly collections: HIDCollectionInfo[]; + opened = false; + readonly sent: Uint8Array[] = []; + + private listeners = new Map void>(); + private dpiStages = [400, 800, 1600, 2400, 4000]; + private dpiStage = 1; + private customDpi = 800; + private pollingIndex = 3; // 1000 Hz + private pollingMask = 0b0001_1111; // 8K…500 + private batteryPercent = 76; + private batteryStatus = 0; + private orientation = 2; // 90° + private firmware = "1.0.4"; + + constructor(options: FakeOptions = {}) { + this.productId = options.productId ?? 0x0440; + this.productName = options.productName ?? "Keychron Nape Pro"; + this.collections = [{ + usagePage: 0xff60, + usage: 0x61, + children: [], + featureReports: [], + inputReports: [{ reportId: 0, items: [{ reportCount: 32, reportSize: 8 }] }], + outputReports: [{ reportId: 0, items: [{ reportCount: 32, reportSize: 8 }] }], + }] as unknown as HIDCollectionInfo[]; + if (options.incompatibleReceiver) { + this.orientation = 0xff; + this.dpiStages = [40, 40, 40, 40, 40]; + } + } + + addEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.set(type, listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + if (this.listeners.get(type) === listener) this.listeners.delete(type); + } + + async open(): Promise { + this.opened = true; + } + + async close(): Promise { + this.opened = false; + } + + async sendReport(_reportId: number, data: BufferSource): Promise { + const request = data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + this.sent.push(request.slice()); + + const command = request[0] ?? 0; + const sub = request[1] ?? 0; + const reply = new Uint8Array(KEYCHRON_PACKET_LENGTH); + + if (command === CMD.firmwareVersion) { + reply[0] = CMD.firmwareVersion; + for (let index = 0; index < this.firmware.length; index += 1) { + reply[1 + index] = this.firmware.charCodeAt(index); + } + this.emit(reply); + return; + } + + if (command !== CMD.miscGroup) return; + reply[0] = CMD.miscGroup; + reply[1] = sub; + + if (sub === NAPE.getOrientation) { + reply[2] = this.orientation; + this.emit(reply); + return; + } + if (sub === NAPE.getDpiStage) { + reply[2] = this.dpiStage; + this.emit(reply); + return; + } + if (sub === NAPE.setDpiStage) { + this.dpiStage = Math.min(request[2] ?? 0, this.dpiStages.length - 1); + return; + } + if (sub === NAPE.setDpiValue) { + const stage = request[2] ?? 0; + const dpi = (request[3] ?? 0) | ((request[4] ?? 0) << 8); + if (stage >= 0 && stage < this.dpiStages.length) this.dpiStages[stage] = dpi; + return; + } + if (sub === NAPE.getDpiValue) { + const stage = request[2] ?? 0; + const dpi = this.dpiStages[stage] ?? 0; + reply[2] = dpi & 0xff; + reply[3] = (dpi >> 8) & 0xff; + this.emit(reply); + return; + } + if (sub === NAPE.getBattery) { + reply[2] = this.batteryPercent; + reply[3] = this.batteryStatus; + this.emit(reply); + return; + } + if (sub === NAPE.getCustomDpi) { + reply[2] = this.customDpi & 0xff; + reply[3] = (this.customDpi >> 8) & 0xff; + this.emit(reply); + return; + } + if (sub === NAPE.setCustomDpi) { + this.customDpi = (request[2] ?? 0) | ((request[3] ?? 0) << 8); + return; + } + if (sub === MISC.getPolling) { + reply[5] = this.pollingMask; + reply[6] = this.pollingIndex; + this.emit(reply); + return; + } + if (sub === MISC.setPolling) { + this.pollingIndex = request[2] ?? this.pollingIndex; + } + } + + private emit(reply: Uint8Array): void { + queueMicrotask(() => { + this.listeners.get("inputreport")?.({ + reportId: 0, + data: new DataView(reply.buffer, reply.byteOffset, reply.byteLength), + }); + }); + } +} function device(productId: number, usagePage = 0xff60, usage = 0x61): HIDDevice { return { @@ -29,10 +190,61 @@ test("support is limited to Nape Pro and Link-KM VIA raw HID collections", () => assert.equal(KeychronHidClient.isSupported(device(0x0440, 0xff60, 1)), false); }); -test("DPI options follow the Nape Pro 50–3200 step-50 ladder", () => { +test("DPI options follow the Nape Pro 50–4000 step-50 ladder", () => { const options = new KeychronHidClient(device(0x0440)).getDpiOptions(); assert.equal(options[0], 50); - assert.equal(options.at(-1), 3200); - assert.equal(options.length, (3200 - 50) / 50 + 1); + assert.equal(options.at(-1), 4000); + assert.equal(options.length, (4000 - 50) / 50 + 1); assert.ok(options.every((dpi, index) => index === 0 || dpi - options[index - 1]! === 50)); }); + +test("reads wired Nape Pro status from Launcher misc commands", async () => { + const fake = new FakeHidDevice(); + const status = await new KeychronHidClient(fake as unknown as HIDDevice).readStatus(); + assert.equal(status.brand, "Keychron"); + assert.equal(status.name, "Nape Pro"); + assert.equal(status.dpi, 800); + assert.equal(status.pollingRateHz, 1000); + assert.deepEqual(status.supportedPollingRates, [500, 1000, 2000, 4000, 8000]); + assert.equal(status.batteryPercent, 76); + assert.equal(status.batteryState, "Discharging"); + assert.deepEqual(status.firmware, ["v1.0.4"]); + assert.match(status.connectionDetail ?? "", /Wired USB/); + assert.match(status.connectionDetail ?? "", /90° orientation/); + assert.equal(status.ui?.family, "keychron-nape"); + assert.equal(status.ui?.hideProcessingCard, true); + assert.equal(status.liftOffDistance, null); +}); + +test("writes DPI and polling then confirms them by reading back", async () => { + const fake = new FakeHidDevice(); + const client = new KeychronHidClient(fake as unknown as HIDDevice); + assert.equal(await client.setDpi(1600), 1600); + assert.equal(await client.setPollingRate(2000), 2000); + assert.ok(fake.sent.some((packet) => + packet[0] === CMD.miscGroup && packet[1] === NAPE.setDpiValue + && ((packet[3] ?? 0) | ((packet[4] ?? 0) << 8)) === 1600)); + assert.ok(fake.sent.some((packet) => + packet[0] === CMD.miscGroup && packet[1] === MISC.setPolling + && packet[2] === KEYCHRON_POLLING_TABLE.indexOf(2000))); +}); + +test("rejects receiver paths that are not paired to a Nape Pro", async () => { + const fake = new FakeHidDevice({ + productId: 0xd026, + productName: "Keychron Link-KM", + incompatibleReceiver: true, + }); + await assert.rejects( + () => new KeychronHidClient(fake as unknown as HIDDevice).open(), + /not paired to a Nape Pro/, + ); +}); + +test("unsupported sensor controls stay unavailable on this protocol", async () => { + const client = new KeychronHidClient(new FakeHidDevice() as unknown as HIDDevice); + await assert.rejects(() => client.setLiftOffDistance("Medium"), /not exposed/); + await assert.rejects(() => client.setMotionSync(true), /not exposed/); + await assert.rejects(() => client.setAngleSnapping(false), /not exposed/); + await assert.rejects(() => client.setDebounceTime(4), /not exposed/); +}); diff --git a/src/drivers/keychron/hid.ts b/src/drivers/keychron/hid.ts index e3199c4..165b5fc 100644 --- a/src/drivers/keychron/hid.ts +++ b/src/drivers/keychron/hid.ts @@ -3,12 +3,16 @@ import { KEYCHRON_COMMAND as CMD, KEYCHRON_MISC_COMMAND as MISC, KEYCHRON_NAPE_COMMAND as NAPE, + KEYCHRON_NAPE_DPI_MAX as DPI_MAX, + KEYCHRON_NAPE_DPI_MIN as DPI_MIN, + KEYCHRON_NAPE_DPI_STEP as DPI_STEP, KEYCHRON_POLLING_TABLE as POLLING_TABLE, KEYCHRON_PRODUCTS as PRODUCTS, KEYCHRON_RAW_USAGE as RAW_USAGE, KEYCHRON_RAW_USAGE_PAGE as RAW_USAGE_PAGE, KEYCHRON_REPORT_ID as REPORT_ID, KEYCHRON_VENDOR_ID, + keychronDecodeBattery, keychronDecodeFirmware, keychronDecodePolling, keychronPacket, @@ -16,9 +20,6 @@ import { const QUERY_TIMEOUT_MS = 1200; const DPI_STAGE_COUNT = 5; -const DPI_MIN = 50; -const DPI_MAX = 3200; -const DPI_STEP = 50; const ORIENTATION_STEPS = 8; const NAPE_DISPLAY_NAME = "Nape Pro"; const PRODUCT_IDS = new Set(PRODUCTS.keys()); @@ -259,14 +260,7 @@ export class KeychronHidClient { (bytes) => bytes[0] === CMD.miscGroup && bytes[1] === NAPE.getBattery, [CMD.miscGroup, NAPE.getBattery], ); - const percent = response[2] ?? 0xff; - const status = response[3] ?? 0; - const state: MouseStatus["batteryState"] = status === 1 - ? "Charging" - : status === 2 - ? "Full" - : "Discharging"; - return { percent, state }; + return keychronDecodeBattery(response); } private async getOrientationIndex(): Promise { diff --git a/src/drivers/keychron/protocol.test.ts b/src/drivers/keychron/protocol.test.ts new file mode 100644 index 0000000..3c28399 --- /dev/null +++ b/src/drivers/keychron/protocol.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + KEYCHRON_NAPE_DPI_MAX, + KEYCHRON_NAPE_DPI_MIN, + KEYCHRON_NAPE_DPI_STEP, + KEYCHRON_PACKET_LENGTH, + KEYCHRON_POLLING_TABLE, + keychronDecodeBattery, + keychronDecodeFirmware, + keychronDecodePolling, + keychronPacket, +} from "@openmouse/protocol/keychron"; + +test("Nape Pro DPI range is model-specific (50–4000 step 50)", () => { + assert.equal(KEYCHRON_NAPE_DPI_MIN, 50); + assert.equal(KEYCHRON_NAPE_DPI_MAX, 4000); + assert.equal(KEYCHRON_NAPE_DPI_STEP, 50); +}); + +test("packets are fixed 32-byte VIA raw HID reports", () => { + const packet = keychronPacket([167, 36, 2]); + assert.equal(packet.length, KEYCHRON_PACKET_LENGTH); + assert.deepEqual([...packet.slice(0, 3)], [167, 36, 2]); + assert.ok(packet.slice(3).every((byte) => byte === 0)); +}); + +test("polling falls back when the misc reply is blank", () => { + const blank = new Uint8Array(KEYCHRON_PACKET_LENGTH); + blank[0] = 167; + blank[1] = 13; + assert.deepEqual(keychronDecodePolling(blank), { + rateHz: 1000, + supported: [125, 500, 1000], + }); +}); + +test("polling decodes the Keychron rate mask and active index", () => { + const response = new Uint8Array(KEYCHRON_PACKET_LENGTH); + response[0] = 167; + response[1] = 13; + // Bits 0–3 advertise 8K/4K/2K/1K; index 1 is 4000 Hz. + response[5] = 0b0000_1111; + response[6] = 1; + assert.deepEqual(keychronDecodePolling(response), { + rateHz: 4000, + supported: [1000, 2000, 4000, 8000], + }); + assert.equal(KEYCHRON_POLLING_TABLE[1], 4000); +}); + +test("firmware strings keep a leading v when the device omits it", () => { + const withPrefix = new Uint8Array([161, 0x76, 0x31, 0x2e, 0x32, 0x2e, 0x33, 0]); + const bare = new Uint8Array([161, 0x31, 0x2e, 0x30, 0x2e, 0x31, 0]); + assert.equal(keychronDecodeFirmware(withPrefix), "v1.2.3"); + assert.equal(keychronDecodeFirmware(bare), "v1.0.1"); + assert.equal(keychronDecodeFirmware(new Uint8Array([161, 0])), null); +}); + +test("battery decodes percent and charge state", () => { + assert.deepEqual(keychronDecodeBattery(new Uint8Array([167, 49, 88, 0])), { + percent: 88, + state: "Discharging", + }); + assert.deepEqual(keychronDecodeBattery(new Uint8Array([167, 49, 90, 1])), { + percent: 90, + state: "Charging", + }); + assert.deepEqual(keychronDecodeBattery(new Uint8Array([167, 49, 100, 2])), { + percent: 100, + state: "Full", + }); +}); diff --git a/src/keychron/index.ts b/src/keychron/index.ts index 94f4f47..aaee3b1 100644 --- a/src/keychron/index.ts +++ b/src/keychron/index.ts @@ -15,6 +15,10 @@ export const KEYCHRON_NAPE_COMMAND = { } as const; export const KEYCHRON_MISC_COMMAND = { getPolling: 13, setPolling: 14 } as const; export const KEYCHRON_POLLING_TABLE = [8000, 4000, 2000, 1000, 500, 250, 125] as const; +/** Nape Pro only — observed on firmware v1.2.6-ZK (stage 5 stores 4000). Other Keychron mice should define their own ranges. */ +export const KEYCHRON_NAPE_DPI_MIN = 50; +export const KEYCHRON_NAPE_DPI_MAX = 4000; +export const KEYCHRON_NAPE_DPI_STEP = 50; export function keychronPacket(command: readonly number[]): Uint8Array { const packet = new Uint8Array(KEYCHRON_PACKET_LENGTH); @@ -38,3 +42,16 @@ export function keychronDecodeFirmware(response: Uint8Array): string | null { return text.startsWith("v") ? text : `v${text}`; } +export function keychronDecodeBattery( + response: Uint8Array, +): { percent: number; state: "Charging" | "Full" | "Discharging" } { + const percent = response[2] ?? 0xff; + const status = response[3] ?? 0; + const state = status === 1 + ? "Charging" + : status === 2 + ? "Full" + : "Discharging"; + return { percent, state }; +} +