From 3c30970672b5ffd94c4638dcc47dec99dcee9b74 Mon Sep 17 00:00:00 2001 From: Dennis Date: Mon, 17 Aug 2026 01:09:27 +1000 Subject: [PATCH 1/3] Add Keychron Nape Pro onboard layer get/set. --- src/drivers/keychron/hid.test.ts | 47 ++++++++++++++++++++++ src/drivers/keychron/hid.ts | 56 +++++++++++++++++++++++++++ src/drivers/keychron/protocol.test.ts | 18 +++++++++ src/drivers/mouse-types.ts | 7 ++++ src/keychron/index.ts | 22 ++++++++++- 5 files changed, 148 insertions(+), 2 deletions(-) diff --git a/src/drivers/keychron/hid.test.ts b/src/drivers/keychron/hid.test.ts index c0422c9..2395619 100644 --- a/src/drivers/keychron/hid.test.ts +++ b/src/drivers/keychron/hid.test.ts @@ -11,18 +11,21 @@ const { KEYCHRON_NAPE_COMMAND, KEYCHRON_PACKET_LENGTH, KEYCHRON_POLLING_TABLE, + KEYCHRON_VIA_COMMAND, } = 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; +const VIA = KEYCHRON_VIA_COMMAND; type FakeOptions = { productId?: number; productName?: string; /** Force orientation/DPI values that fail Nape Pro receiver verification. */ incompatibleReceiver?: boolean; + layerCount?: number; }; class FakeHidDevice { @@ -44,6 +47,8 @@ class FakeHidDevice { private batteryStatus = 0; private orientation = 2; // 90° private firmware = "1.0.4"; + private layerCount = 8; + private currentLayer = 1; constructor(options: FakeOptions = {}) { this.productId = options.productId ?? 0x0440; @@ -56,6 +61,7 @@ class FakeHidDevice { inputReports: [{ reportId: 0, items: [{ reportCount: 32, reportSize: 8 }] }], outputReports: [{ reportId: 0, items: [{ reportCount: 32, reportSize: 8 }] }], }] as unknown as HIDCollectionInfo[]; + this.layerCount = options.layerCount ?? 8; if (options.incompatibleReceiver) { this.orientation = 0xff; this.dpiStages = [40, 40, 40, 40, 40]; @@ -97,6 +103,20 @@ class FakeHidDevice { return; } + if (command === VIA.getLayerCount) { + reply[0] = VIA.getLayerCount; + reply[1] = this.layerCount; + this.emit(reply); + return; + } + + if (command === CMD.getCurrentLayer) { + reply[0] = CMD.getCurrentLayer; + reply[1] = this.currentLayer; + this.emit(reply); + return; + } + if (command !== CMD.miscGroup) return; reply[0] = CMD.miscGroup; reply[1] = sub; @@ -165,6 +185,11 @@ class FakeHidDevice { this.sleepSeconds = (request[4] ?? 0) | ((request[5] ?? 0) << 8); reply[2] = 0; this.emit(reply); + return; + } + if (sub === NAPE.setLayer) { + this.currentLayer = request[2] ?? this.currentLayer; + this.emit(reply); } } @@ -232,6 +257,9 @@ test("reads wired Nape Pro status from Launcher misc commands", async () => { assert.equal(status.ui?.hideProcessingCard, true); assert.equal(status.ui?.showAdvancedSection, true); assert.equal(status.sleepTimeout, 600); + assert.equal(status.keychronLayer, 1); + assert.equal(status.keychronLayerCount, 8); + assert.match(status.connectionDetail ?? "", /Layer 1/); assert.equal(status.liftOffDistance, null); }); @@ -284,6 +312,25 @@ test("rejects receiver paths that are not paired to a Nape Pro", async () => { ); }); +test("switches the active VIA layer and confirms it by reading back", async () => { + const fake = new FakeHidDevice(); + const client = new KeychronHidClient(fake as unknown as HIDDevice); + assert.equal(await client.setLayer(3), 3); + const status = await client.readStatus(); + assert.equal(status.keychronLayer, 3); + assert.equal(status.keychronLayerCount, 8); + assert.ok(fake.sent.some((packet) => + packet[0] === CMD.miscGroup && packet[1] === NAPE.setLayer && packet[2] === 3)); + await assert.rejects(() => client.setLayer(0), /between 1 and 8/); + await assert.rejects(() => client.setLayer(9), /between 1 and 8/); +}); + +test("Nape Pro user layers stay at 1–8 even when VIA reports a spare slot", async () => { + const fake = new FakeHidDevice({ layerCount: 9 }); + const status = await new KeychronHidClient(fake as unknown as HIDDevice).readStatus(); + assert.equal(status.keychronLayerCount, 8); +}); + 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/); diff --git a/src/drivers/keychron/hid.ts b/src/drivers/keychron/hid.ts index dec7189..1505059 100644 --- a/src/drivers/keychron/hid.ts +++ b/src/drivers/keychron/hid.ts @@ -6,6 +6,7 @@ import { KEYCHRON_NAPE_DPI_MAX as DPI_MAX, KEYCHRON_NAPE_DPI_MIN as DPI_MIN, KEYCHRON_NAPE_DPI_STEP as DPI_STEP, + KEYCHRON_NAPE_LAYER_COUNT as LAYER_COUNT, KEYCHRON_NAPE_SLEEP_MAX_SECONDS as SLEEP_MAX, KEYCHRON_NAPE_SLEEP_MIN_SECONDS as SLEEP_MIN, KEYCHRON_NAPE_SLEEP_OPTIONS as SLEEP_OPTIONS, @@ -15,10 +16,14 @@ import { KEYCHRON_RAW_USAGE_PAGE as RAW_USAGE_PAGE, KEYCHRON_REPORT_ID as REPORT_ID, KEYCHRON_VENDOR_ID, + KEYCHRON_VIA_COMMAND as VIA, keychronDecodeBattery, + keychronDecodeCurrentLayer, keychronDecodeFirmware, + keychronDecodeLayerCount, keychronDecodePolling, keychronDecodeSleepTimeout, + keychronEncodeSetLayer, keychronEncodeSleepTimeout, keychronPacket, } from "@openmouse/protocol/keychron"; @@ -145,6 +150,7 @@ export class KeychronHidClient { const polling = await this.getPolling().catch(() => null); const orientation = await this.getOrientation().catch(() => null); const sleepTimeout = await this.getSleepTimeout().catch(() => null); + const layers = await this.readLayers().catch(() => null); const active = stages.find((entry) => entry.index === stage) ?? stages[0]; const dpi = active?.value ?? 800; const product = PRODUCTS.get(this.device.productId); @@ -156,6 +162,7 @@ export class KeychronHidClient { viaReceiver ? `2.4 GHz (${product?.name ?? "receiver"})` : "Wired USB", orientation !== null ? `${orientation}\u00b0 orientation` : null, `DPI stage ${stage + 1}/${DPI_STAGE_COUNT}`, + layers ? `Layer ${layers.current}` : null, ].filter(Boolean).join(" · "); return { @@ -189,6 +196,8 @@ export class KeychronHidClient { liftOffDistance: null, supportedLiftOffDistances: [], sleepTimeout, + keychronLayer: layers?.current, + keychronLayerCount: layers?.count, firmware: [firmware ?? "Firmware unavailable"], }; } @@ -284,6 +293,24 @@ export class KeychronHidClient { throw new Error("Performance mode is not exposed by the Nape Pro Launcher protocol."); } + /** Selects the active user layer (1–8). */ + async setLayer(layer: number): Promise { + await this.open(); + const count = await this.usableLayerCount(); + if (!Number.isInteger(layer) || layer < 1 || layer > count) { + throw new Error(`Layer must be between 1 and ${count}.`); + } + await this.query( + (bytes) => bytes[0] === CMD.miscGroup && bytes[1] === NAPE.setLayer, + keychronEncodeSetLayer(layer), + ); + const confirmed = await this.getCurrentLayer(); + if (confirmed !== layer) { + throw new Error(`The mouse kept layer ${confirmed} instead of layer ${layer}.`); + } + return confirmed; + } + private async getDpiStage(): Promise { const response = await this.query( (bytes) => bytes[0] === CMD.miscGroup && bytes[1] === NAPE.getDpiStage, @@ -384,6 +411,35 @@ export class KeychronHidClient { return keychronDecodeFirmware(response); } + private async readLayers(): Promise<{ count: number; current: number } | null> { + const count = await this.usableLayerCount(); + if (count < 1) return null; + const current = Math.min(Math.max(await this.getCurrentLayer(), 1), count); + return { count, current }; + } + + private async usableLayerCount(): Promise { + const reported = await this.getLayerCount(); + if (reported < 1) return 0; + return Math.min(reported, LAYER_COUNT); + } + + private async getLayerCount(): Promise { + const response = await this.query( + (bytes) => bytes[0] === VIA.getLayerCount, + [VIA.getLayerCount], + ); + return keychronDecodeLayerCount(response); + } + + private async getCurrentLayer(): Promise { + const response = await this.query( + (bytes) => bytes[0] === CMD.getCurrentLayer, + [CMD.getCurrentLayer], + ); + return keychronDecodeCurrentLayer(response); + } + private async write(command: number[]): Promise { const packet = keychronPacket(command); await this.device.sendReport(REPORT_ID, packet); diff --git a/src/drivers/keychron/protocol.test.ts b/src/drivers/keychron/protocol.test.ts index 5ce64da..b8869f0 100644 --- a/src/drivers/keychron/protocol.test.ts +++ b/src/drivers/keychron/protocol.test.ts @@ -7,12 +7,17 @@ import { KEYCHRON_NAPE_DPI_STEP, KEYCHRON_NAPE_SLEEP_MAX_SECONDS, KEYCHRON_NAPE_SLEEP_MIN_SECONDS, + KEYCHRON_NAPE_LAYER_COUNT, KEYCHRON_PACKET_LENGTH, KEYCHRON_POLLING_TABLE, + KEYCHRON_VIA_COMMAND, keychronDecodeBattery, + keychronDecodeCurrentLayer, keychronDecodeFirmware, + keychronDecodeLayerCount, keychronDecodePolling, keychronDecodeSleepTimeout, + keychronEncodeSetLayer, keychronEncodeSleepTimeout, keychronPacket, } from "@openmouse/protocol/keychron"; @@ -89,3 +94,16 @@ test("sleep encodes the trackball Set_Sleep payload (sleep LE at bytes 4–5)", assert.deepEqual(keychronEncodeSleepTimeout(61), [167, 12, 0, 0, 0x3d, 0, 0, 0]); assert.deepEqual(keychronEncodeSleepTimeout(43200), [167, 12, 0, 0, 0xc0, 0xa8, 0, 0]); }); + +test("VIA layer count and current-layer get/set use 1–8", () => { + assert.equal(KEYCHRON_VIA_COMMAND.getLayerCount, 17); + assert.equal(KEYCHRON_NAPE_LAYER_COUNT, 8); + assert.equal(keychronDecodeLayerCount(new Uint8Array([17, 8])), 8); + assert.equal(keychronDecodeCurrentLayer(new Uint8Array([163, 1])), 1); + assert.equal(keychronDecodeCurrentLayer(new Uint8Array([163, 3])), 3); + assert.equal(keychronDecodeCurrentLayer(new Uint8Array([163, 8])), 8); + assert.equal(keychronDecodeCurrentLayer(new Uint8Array([163, 0])), 1); + assert.deepEqual(keychronEncodeSetLayer(1), [167, 45, 1]); + assert.deepEqual(keychronEncodeSetLayer(2), [167, 45, 2]); + assert.deepEqual(keychronEncodeSetLayer(8), [167, 45, 8]); +}); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 96b2191..c90fa19 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -182,6 +182,13 @@ export interface MouseStatus { * omitted rather than guessed at. */ razerButtonMappings?: Record; + /** + * Keychron Nape Pro user layer currently running on the device (1–8). + * Matches firmware get/set. Undefined when the layer commands were not answered. + */ + keychronLayer?: number; + /** How many onboard layers VIA reported. Undefined when unread. */ + keychronLayerCount?: number; performanceMode?: boolean | null; hyperMode?: boolean | null; sensorMode?: "Eco" | "High" | "Ultra" | null; diff --git a/src/keychron/index.ts b/src/keychron/index.ts index adae738..c4bf5a3 100644 --- a/src/keychron/index.ts +++ b/src/keychron/index.ts @@ -8,11 +8,15 @@ export const KEYCHRON_PRODUCTS = new Map Date: Mon, 17 Aug 2026 04:03:39 +1000 Subject: [PATCH 2/3] Add Nape Pro VIA keymap remaps and keep the Keychron driver Nape-only. --- .../{hid.test.ts => nape-hid.test.ts} | 142 +++++++++-- src/drivers/keychron/{hid.ts => nape-hid.ts} | 84 ++++++- ...protocol.test.ts => nape-protocol.test.ts} | 57 +++++ src/drivers/mouse-types.ts | 9 +- src/drivers/registry.ts | 6 +- src/drivers/vendors.ts | 8 +- src/keychron/index.ts | 235 +++++++++++++++++- 7 files changed, 497 insertions(+), 44 deletions(-) rename src/drivers/keychron/{hid.test.ts => nape-hid.test.ts} (66%) rename src/drivers/keychron/{hid.ts => nape-hid.ts} (85%) rename src/drivers/keychron/{protocol.test.ts => nape-protocol.test.ts} (60%) diff --git a/src/drivers/keychron/hid.test.ts b/src/drivers/keychron/nape-hid.test.ts similarity index 66% rename from src/drivers/keychron/hid.test.ts rename to src/drivers/keychron/nape-hid.test.ts index 2395619..703101b 100644 --- a/src/drivers/keychron/hid.test.ts +++ b/src/drivers/keychron/nape-hid.test.ts @@ -1,12 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; -// `hid.ts` schedules write delays and query timeouts through `window`. +// `nape-hid.ts` schedules write delays and query timeouts through `window`. Object.assign(globalThis, { window: globalThis }); -const { KeychronHidClient } = await import("./hid.ts"); +const { KeychronNapeHidClient } = await import("./nape-hid.ts"); const { KEYCHRON_COMMAND, + KEYCHRON_NAPE_KEYCODE, KEYCHRON_MISC_COMMAND, KEYCHRON_NAPE_COMMAND, KEYCHRON_PACKET_LENGTH, @@ -49,6 +50,8 @@ class FakeHidDevice { private firmware = "1.0.4"; private layerCount = 8; private currentLayer = 1; + private keycodes = Array.from({ length: 9 }, () => [0x00d1, 0x00d2, 0x00d3, 0x00d4, 0x00d5, 0, 0]); + private encoders = Array.from({ length: 9 }, () => [0x00aa, 0x00a9]); constructor(options: FakeOptions = {}) { this.productId = options.productId ?? 0x0440; @@ -117,6 +120,74 @@ class FakeHidDevice { return; } + if (command === VIA.getBuffer) { + const offset = ((request[1] ?? 0) << 8) | (request[2] ?? 0); + const size = request[3] ?? 0; + const viaLayer = Math.floor(offset / 14); + const codes = this.keycodes[viaLayer] ?? this.keycodes[0]!; + reply[0] = VIA.getBuffer; + reply[1] = request[1] ?? 0; + reply[2] = request[2] ?? 0; + reply[3] = size; + for (let index = 0; index < Math.min(codes.length, Math.floor(size / 2)); index += 1) { + const code = codes[index] ?? 0; + reply[4 + index * 2] = (code >> 8) & 0xff; + reply[5 + index * 2] = code & 0xff; + } + this.emit(reply); + return; + } + + if (command === VIA.getKeycode) { + const viaLayer = request[1] ?? 0; + const col = request[3] ?? 0; + const code = this.keycodes[viaLayer]?.[col] ?? 0; + reply[0] = VIA.getKeycode; + reply[1] = viaLayer; + reply[2] = request[2] ?? 0; + reply[3] = col; + reply[4] = (code >> 8) & 0xff; + reply[5] = code & 0xff; + this.emit(reply); + return; + } + + if (command === VIA.setKeycode) { + const viaLayer = request[1] ?? 0; + const col = request[3] ?? 0; + const code = ((request[4] ?? 0) << 8) | (request[5] ?? 0); + const layerCodes = this.keycodes[viaLayer]; + if (layerCodes && col >= 0 && col < layerCodes.length) layerCodes[col] = code; + reply.set(request.subarray(0, 6)); + this.emit(reply); + return; + } + + if (command === VIA.getEncoder) { + const viaLayer = request[1] ?? 0; + const direction = request[3] ?? 0; + const code = this.encoders[viaLayer]?.[direction] ?? 0; + reply[0] = VIA.getEncoder; + reply[1] = viaLayer; + reply[2] = request[2] ?? 0; + reply[3] = direction; + reply[4] = (code >> 8) & 0xff; + reply[5] = code & 0xff; + this.emit(reply); + return; + } + + if (command === VIA.setEncoder) { + const viaLayer = request[1] ?? 0; + const direction = request[3] ?? 0; + const code = ((request[4] ?? 0) << 8) | (request[5] ?? 0); + const pair = this.encoders[viaLayer]; + if (pair && (direction === 0 || direction === 1)) pair[direction] = code; + reply.set(request.subarray(0, 6)); + this.emit(reply); + return; + } + if (command !== CMD.miscGroup) return; reply[0] = CMD.miscGroup; reply[1] = sub; @@ -220,16 +291,16 @@ function device(productId: number, usagePage = 0xff60, usage = 0x61): HIDDevice } test("support is limited to Nape Pro and Link-KM VIA raw HID collections", () => { - assert.equal(KeychronHidClient.isSupported(device(0x0440)), true); - assert.equal(KeychronHidClient.isSupported(device(0xd026)), true); - assert.equal(KeychronHidClient.isSupported(device(0xd029)), true); - assert.equal(KeychronHidClient.isSupported(device(0x0441)), false); - assert.equal(KeychronHidClient.isSupported(device(0x0440, 0xff00, 0x61)), false); - assert.equal(KeychronHidClient.isSupported(device(0x0440, 0xff60, 1)), false); + assert.equal(KeychronNapeHidClient.isSupported(device(0x0440)), true); + assert.equal(KeychronNapeHidClient.isSupported(device(0xd026)), true); + assert.equal(KeychronNapeHidClient.isSupported(device(0xd029)), true); + assert.equal(KeychronNapeHidClient.isSupported(device(0x0441)), false); + assert.equal(KeychronNapeHidClient.isSupported(device(0x0440, 0xff00, 0x61)), false); + assert.equal(KeychronNapeHidClient.isSupported(device(0x0440, 0xff60, 1)), false); }); test("DPI options follow the Nape Pro 50–4000 step-50 ladder", () => { - const options = new KeychronHidClient(device(0x0440)).getDpiOptions(); + const options = new KeychronNapeHidClient(device(0x0440)).getDpiOptions(); assert.equal(options[0], 50); assert.equal(options.at(-1), 4000); assert.equal(options.length, (4000 - 50) / 50 + 1); @@ -238,7 +309,7 @@ test("DPI options follow the Nape Pro 50–4000 step-50 ladder", () => { 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(); + const status = await new KeychronNapeHidClient(fake as unknown as HIDDevice).readStatus(); assert.equal(status.brand, "Keychron"); assert.equal(status.name, "Nape Pro"); assert.equal(status.dpi, 800); @@ -257,15 +328,15 @@ test("reads wired Nape Pro status from Launcher misc commands", async () => { assert.equal(status.ui?.hideProcessingCard, true); assert.equal(status.ui?.showAdvancedSection, true); assert.equal(status.sleepTimeout, 600); - assert.equal(status.keychronLayer, 1); - assert.equal(status.keychronLayerCount, 8); - assert.match(status.connectionDetail ?? "", /Layer 1/); + assert.equal(status.napeLayer, 1); + assert.equal(status.napeLayerCount, 8); + assert.match(status.connectionDetail ?? "", /Layer 0/); assert.equal(status.liftOffDistance, null); }); test("writes DPI, polling, and sleep then confirms them by reading back", async () => { const fake = new FakeHidDevice(); - const client = new KeychronHidClient(fake as unknown as HIDDevice); + const client = new KeychronNapeHidClient(fake as unknown as HIDDevice); assert.equal(await client.setDpi(1600), 1600); assert.equal(await client.setDpiStageValue(0, 500), 500); assert.equal(await client.setActiveDpiStage(2), 2); @@ -288,14 +359,14 @@ test("writes DPI, polling, and sleep then confirms them by reading back", async }); test("sleep options stay inside the Launcher 1 minute–12:59:59 range", () => { - const options = new KeychronHidClient(device(0x0440)).getSleepOptions(); + const options = new KeychronNapeHidClient(device(0x0440)).getSleepOptions(); assert.ok(options.length > 0); assert.ok(options.every((seconds) => seconds >= 60 && seconds <= 12 * 3600 + 59 * 60 + 59)); assert.equal(options[0], 60); }); test("rejects sleep timeouts outside the Launcher range", async () => { - const client = new KeychronHidClient(new FakeHidDevice() as unknown as HIDDevice); + const client = new KeychronNapeHidClient(new FakeHidDevice() as unknown as HIDDevice); await assert.rejects(() => client.setSleepTimeout(59), /1 minute/); await assert.rejects(() => client.setSleepTimeout(12 * 3600 + 59 * 60 + 60), /12:59:59/); }); @@ -307,18 +378,18 @@ test("rejects receiver paths that are not paired to a Nape Pro", async () => { incompatibleReceiver: true, }); await assert.rejects( - () => new KeychronHidClient(fake as unknown as HIDDevice).open(), + () => new KeychronNapeHidClient(fake as unknown as HIDDevice).open(), /not paired to a Nape Pro/, ); }); test("switches the active VIA layer and confirms it by reading back", async () => { const fake = new FakeHidDevice(); - const client = new KeychronHidClient(fake as unknown as HIDDevice); + const client = new KeychronNapeHidClient(fake as unknown as HIDDevice); assert.equal(await client.setLayer(3), 3); const status = await client.readStatus(); - assert.equal(status.keychronLayer, 3); - assert.equal(status.keychronLayerCount, 8); + assert.equal(status.napeLayer, 3); + assert.equal(status.napeLayerCount, 8); assert.ok(fake.sent.some((packet) => packet[0] === CMD.miscGroup && packet[1] === NAPE.setLayer && packet[2] === 3)); await assert.rejects(() => client.setLayer(0), /between 1 and 8/); @@ -327,14 +398,39 @@ test("switches the active VIA layer and confirms it by reading back", async () = test("Nape Pro user layers stay at 1–8 even when VIA reports a spare slot", async () => { const fake = new FakeHidDevice({ layerCount: 9 }); - const status = await new KeychronHidClient(fake as unknown as HIDDevice).readStatus(); - assert.equal(status.keychronLayerCount, 8); + const status = await new KeychronNapeHidClient(fake as unknown as HIDDevice).readStatus(); + assert.equal(status.napeLayerCount, 8); }); test("unsupported sensor controls stay unavailable on this protocol", async () => { - const client = new KeychronHidClient(new FakeHidDevice() as unknown as HIDDevice); + const client = new KeychronNapeHidClient(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/); }); + +test("reads the Nape Pro VIA keymap and independent wheel directions", async () => { + const client = new KeychronNapeHidClient(new FakeHidDevice() as unknown as HIDDevice); + const map = await client.readLayerKeymap(1); + assert.equal(map.layer, 1); + assert.equal(map.keys[0]?.action, "Left click"); + assert.equal(map.keys[1]?.action, "Right click"); + assert.equal(map.wheel.ccw.keycode, KEYCHRON_NAPE_KEYCODE.volumeDown); + assert.equal(map.wheel.cw.keycode, KEYCHRON_NAPE_KEYCODE.volumeUp); +}); + +test("writes a button and one wheel direction then confirms them", async () => { + const fake = new FakeHidDevice(); + const client = new KeychronNapeHidClient(fake as unknown as HIDDevice); + assert.equal(await client.setKeycode(3, 5, KEYCHRON_NAPE_KEYCODE.rightClick), KEYCHRON_NAPE_KEYCODE.rightClick); + assert.equal(await client.setEncoder(3, false, KEYCHRON_NAPE_KEYCODE.scrollUp), KEYCHRON_NAPE_KEYCODE.scrollUp); + const map = await client.readLayerKeymap(3); + assert.equal(map.keys[5]?.keycode, KEYCHRON_NAPE_KEYCODE.rightClick); + assert.equal(map.wheel.ccw.action, "Scroll up"); + assert.equal(map.wheel.cw.action, "Volume up"); + assert.ok(fake.sent.some((packet) => + packet[0] === VIA.setKeycode && packet[1] === 3 && packet[3] === 5 && packet[5] === 0xd2)); + assert.ok(fake.sent.some((packet) => + packet[0] === VIA.setEncoder && packet[1] === 3 && packet[3] === 0 && packet[5] === 0xd9)); +}); diff --git a/src/drivers/keychron/hid.ts b/src/drivers/keychron/nape-hid.ts similarity index 85% rename from src/drivers/keychron/hid.ts rename to src/drivers/keychron/nape-hid.ts index 1505059..5c33378 100644 --- a/src/drivers/keychron/hid.ts +++ b/src/drivers/keychron/nape-hid.ts @@ -1,3 +1,4 @@ +/** Keychron Nape Pro VIA driver. M-series mice use a different HID protocol and need their own client. */ import type { MouseStatus } from "../mouse-types.ts"; import { KEYCHRON_COMMAND as CMD, @@ -11,7 +12,7 @@ import { KEYCHRON_NAPE_SLEEP_MIN_SECONDS as SLEEP_MIN, KEYCHRON_NAPE_SLEEP_OPTIONS as SLEEP_OPTIONS, KEYCHRON_POLLING_TABLE as POLLING_TABLE, - KEYCHRON_PRODUCTS as PRODUCTS, + KEYCHRON_NAPE_PRODUCTS as PRODUCTS, KEYCHRON_RAW_USAGE as RAW_USAGE, KEYCHRON_RAW_USAGE_PAGE as RAW_USAGE_PAGE, KEYCHRON_REPORT_ID as REPORT_ID, @@ -20,12 +21,23 @@ import { keychronDecodeBattery, keychronDecodeCurrentLayer, keychronDecodeFirmware, + keychronDecodeKeycodeReply, + keychronDecodeKeymapBuffer, keychronDecodeLayerCount, keychronDecodePolling, keychronDecodeSleepTimeout, + keychronEncodeGetBuffer, + keychronEncodeGetEncoder, + keychronEncodeGetKeycode, + keychronEncodeSetEncoder, + keychronEncodeSetKeycode, keychronEncodeSetLayer, keychronEncodeSleepTimeout, + keychronLayerKeymapFromCodes, + keychronLayerLabel, keychronPacket, + keychronUserLayerToVia, + type KeychronNapeLayerKeymap, } from "@openmouse/protocol/keychron"; const QUERY_TIMEOUT_MS = 1200; @@ -34,7 +46,7 @@ const ORIENTATION_STEPS = 8; const NAPE_DISPLAY_NAME = "Nape Pro"; const PRODUCT_IDS = new Set(PRODUCTS.keys()); -export class KeychronHidClient { +export class KeychronNapeHidClient { private responseWaiter: { match: (bytes: Uint8Array) => boolean; resolve: (bytes: Uint8Array) => void; @@ -126,7 +138,7 @@ export class KeychronHidClient { private incompatibleReceiverMessage(): string { const receiver = PRODUCTS.get(this.device.productId)?.name ?? "Keychron receiver"; return `This ${receiver} is not paired to a Nape Pro that OpenMouse can control. ` - + "Use the wired cable, or pair a supported Keychron mouse to the receiver."; + + "Use the wired cable, or pair a Nape Pro to the receiver."; } getDpiOptions(): number[] { @@ -162,7 +174,7 @@ export class KeychronHidClient { viaReceiver ? `2.4 GHz (${product?.name ?? "receiver"})` : "Wired USB", orientation !== null ? `${orientation}\u00b0 orientation` : null, `DPI stage ${stage + 1}/${DPI_STAGE_COUNT}`, - layers ? `Layer ${layers.current}` : null, + layers ? keychronLayerLabel(layers.current) : null, ].filter(Boolean).join(" · "); return { @@ -196,8 +208,8 @@ export class KeychronHidClient { liftOffDistance: null, supportedLiftOffDistances: [], sleepTimeout, - keychronLayer: layers?.current, - keychronLayerCount: layers?.count, + napeLayer: layers?.current, + napeLayerCount: layers?.count, firmware: [firmware ?? "Firmware unavailable"], }; } @@ -311,6 +323,66 @@ export class KeychronHidClient { return confirmed; } + async readLayerKeymap(layer: number): Promise { + await this.open(); + const viaLayer = keychronUserLayerToVia(layer); + const buffer = await this.query( + (bytes) => bytes[0] === VIA.getBuffer, + keychronEncodeGetBuffer(viaLayer), + ); + const columns = keychronDecodeKeymapBuffer(buffer); + const ccw = await this.query( + (bytes) => bytes[0] === VIA.getEncoder && bytes[3] === 0, + keychronEncodeGetEncoder(viaLayer, false), + ); + const cw = await this.query( + (bytes) => bytes[0] === VIA.getEncoder && bytes[3] === 1, + keychronEncodeGetEncoder(viaLayer, true), + ); + return keychronLayerKeymapFromCodes( + layer, + columns, + keychronDecodeKeycodeReply(ccw), + keychronDecodeKeycodeReply(cw), + ); + } + + async setKeycode(layer: number, col: number, keycode: number): Promise { + await this.open(); + const viaLayer = keychronUserLayerToVia(layer); + await this.query( + (bytes) => bytes[0] === VIA.setKeycode, + keychronEncodeSetKeycode(viaLayer, col, keycode), + ); + const confirmed = await this.query( + (bytes) => bytes[0] === VIA.getKeycode && bytes[3] === (col & 0xff), + keychronEncodeGetKeycode(viaLayer, col), + ); + const actual = keychronDecodeKeycodeReply(confirmed); + if (actual !== keycode) { + throw new Error(`The mouse kept 0x${actual.toString(16)} on that button instead of 0x${keycode.toString(16)}.`); + } + return actual; + } + + async setEncoder(layer: number, clockwise: boolean, keycode: number): Promise { + await this.open(); + const viaLayer = keychronUserLayerToVia(layer); + await this.query( + (bytes) => bytes[0] === VIA.setEncoder, + keychronEncodeSetEncoder(viaLayer, clockwise, keycode), + ); + const confirmed = await this.query( + (bytes) => bytes[0] === VIA.getEncoder && bytes[3] === (clockwise ? 1 : 0), + keychronEncodeGetEncoder(viaLayer, clockwise), + ); + const actual = keychronDecodeKeycodeReply(confirmed); + if (actual !== keycode) { + throw new Error(`The mouse kept 0x${actual.toString(16)} on the scroll wheel instead of 0x${keycode.toString(16)}.`); + } + return actual; + } + private async getDpiStage(): Promise { const response = await this.query( (bytes) => bytes[0] === CMD.miscGroup && bytes[1] === NAPE.getDpiStage, diff --git a/src/drivers/keychron/protocol.test.ts b/src/drivers/keychron/nape-protocol.test.ts similarity index 60% rename from src/drivers/keychron/protocol.test.ts rename to src/drivers/keychron/nape-protocol.test.ts index b8869f0..037459c 100644 --- a/src/drivers/keychron/protocol.test.ts +++ b/src/drivers/keychron/nape-protocol.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; +/** Nape Pro VIA keymap and layer codec tests. */ + import { KEYCHRON_NAPE_DPI_MAX, KEYCHRON_NAPE_DPI_MIN, @@ -11,6 +13,17 @@ import { KEYCHRON_PACKET_LENGTH, KEYCHRON_POLLING_TABLE, KEYCHRON_VIA_COMMAND, + KEYCHRON_NAPE_KEYCODE, + KEYCHRON_NAPE_BUTTON_ACTIONS, + keychronActionForKeycode, + keychronDecodeKeymapBuffer, + keychronEncodeGetBuffer, + keychronEncodeSetEncoder, + keychronEncodeSetKeycode, + keychronKeycodeForAction, + keychronLayerKeymapFromCodes, + keychronLayerLabel, + keychronUserLayerToVia, keychronDecodeBattery, keychronDecodeCurrentLayer, keychronDecodeFirmware, @@ -107,3 +120,47 @@ test("VIA layer count and current-layer get/set use 1–8", () => { assert.deepEqual(keychronEncodeSetLayer(2), [167, 45, 2]); assert.deepEqual(keychronEncodeSetLayer(8), [167, 45, 8]); }); + +test("VIA keymap buffer is packed 7 big-endian keycodes per layer", () => { + assert.equal(KEYCHRON_VIA_COMMAND.getBuffer, 18); + assert.deepEqual(keychronEncodeGetBuffer(0), [18, 0, 0, 14]); + assert.deepEqual(keychronEncodeGetBuffer(2), [18, 0, 28, 14]); + assert.deepEqual(keychronEncodeGetBuffer(3), [18, 0, 42, 14]); + const reply = new Uint8Array(32); + reply[0] = 18; + reply[3] = 14; + reply[4] = 0x00; + reply[5] = 0xd1; + reply[6] = 0x52; + reply[7] = 0x2a; + assert.deepEqual(keychronDecodeKeymapBuffer(reply).slice(0, 2), [0x00d1, 0x522a]); +}); + +test("SET_KEYCODE and SET_ENCODER pack protocol-12 codes big-endian", () => { + assert.deepEqual(keychronEncodeSetKeycode(2, 5, 0x00d4), [5, 2, 0, 5, 0x00, 0xd4]); + assert.deepEqual(keychronEncodeSetEncoder(2, false, 0x00aa), [21, 2, 0, 0, 0x00, 0xaa]); + assert.deepEqual(keychronEncodeSetEncoder(2, true, 0x00a9), [21, 2, 0, 1, 0x00, 0xa9]); +}); + +test("protocol-12 mouse and CUSTOM actions round-trip through the Nape catalog", () => { + assert.equal(keychronUserLayerToVia(1), 1); + assert.equal(keychronUserLayerToVia(3), 3); + assert.equal(keychronLayerLabel(1), "Layer 0"); + assert.equal(keychronLayerLabel(3), "Layer 2"); + assert.equal(keychronKeycodeForAction("Left click"), KEYCHRON_NAPE_KEYCODE.leftClick); + assert.equal(keychronActionForKeycode(KEYCHRON_NAPE_KEYCODE.volumeDown), "Volume down"); + assert.equal(keychronActionForKeycode(KEYCHRON_NAPE_KEYCODE.scrollMode), "Scroll mode"); + assert.equal(keychronActionForKeycode(KEYCHRON_NAPE_KEYCODE.dpiCycle), "DPI cycle"); + assert.equal(keychronActionForKeycode(KEYCHRON_NAPE_KEYCODE.customDpi), "Custom"); + assert.equal(keychronActionForKeycode(0x1234), "Custom"); + assert.ok(KEYCHRON_NAPE_BUTTON_ACTIONS.includes("Volume up")); + assert.ok(!KEYCHRON_NAPE_BUTTON_ACTIONS.includes("Custom DPI")); + const map = keychronLayerKeymapFromCodes(3, [0x00d1, 0x522a, 0x00d2, 0x7e2c, 0x00d5, 0x00d4], 0x00aa, 0x00a9); + assert.equal(map.layer, 3); + assert.deepEqual(map.keys.map((key) => key.name), ["03", "04", "01", "02", "M1", "M2"]); + assert.equal(map.keys[0]?.action, "Left click"); + assert.equal(map.keys[1]?.action, "Scroll mode"); + assert.equal(map.keys[3]?.action, "DPI cycle"); + assert.equal(map.wheel.ccw.action, "Volume down"); + assert.equal(map.wheel.cw.action, "Volume up"); +}); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index c90fa19..8aa2321 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -182,13 +182,12 @@ export interface MouseStatus { * omitted rather than guessed at. */ razerButtonMappings?: Record; - /** - * Keychron Nape Pro user layer currently running on the device (1–8). + /** Keychron Nape Pro user layer currently running on the device (1–8). * Matches firmware get/set. Undefined when the layer commands were not answered. */ - keychronLayer?: number; - /** How many onboard layers VIA reported. Undefined when unread. */ - keychronLayerCount?: number; + napeLayer?: number; + /** How many Nape onboard layers VIA reported. Undefined when unread. */ + napeLayerCount?: number; performanceMode?: boolean | null; hyperMode?: boolean | null; sensorMode?: "Eco" | "High" | "Ultra" | null; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index e388139..4cd571d 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -3,7 +3,7 @@ import { AttackSharkHidClient } from "./attackshark/hid.ts"; import { EggOp1HidClient } from "./endgame/egg-op1-hid.ts"; import { eggWeCreate, eggWeIsSupported, eggWeSupportScore, isEggWeClient, type EggWeHidClient } from "./endgame/egg-we-control.ts"; import { FinalmouseHidClient } from "./finalmouse/hid.ts"; -import { KeychronHidClient } from "./keychron/hid.ts"; +import { KeychronNapeHidClient } from "./keychron/nape-hid.ts"; import { LamzuHidClient } from "./lamzu/hid.ts"; import { LogitechHidppClient } from "./logitech/hidpp.ts"; import { ModdoHidClient } from "./moddo/hid.ts"; @@ -23,7 +23,7 @@ import { WLMouseHidClient } from "./wlmouse/hid.ts"; import { ZaunkoenigHidClient } from "./zaunkoenig/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient; +export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient; export interface DeviceDriver { brand: string; @@ -55,7 +55,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "ATK", supports: (device) => AtkHidClient.isSupported(device), create: (device) => new AtkHidClient(device), score: () => 5 }, { brand: "Attack Shark", supports: (device) => AttackSharkHidClient.isSupported(device), create: (device) => new AttackSharkHidClient(device), score: () => 5 }, { brand: "Razer", supports: (device) => RazerViperV4ProHidClient.isSupported(device), create: (device) => new RazerViperV4ProHidClient(device), score: () => 7 }, - { brand: "Keychron", supports: (device) => KeychronHidClient.isSupported(device), create: (device) => new KeychronHidClient(device), score: () => 6 }, + { brand: "Keychron", supports: (device) => KeychronNapeHidClient.isSupported(device), create: (device) => new KeychronNapeHidClient(device), score: () => 6 }, ]; function driverFor(device: HIDDevice): DeviceDriver | undefined { diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index f0aaf66..36630a0 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -41,10 +41,10 @@ export const VENDOR_ID = { zaunkoenig: ZAUNKOENIG_VENDOR_ID, } as const; -// Keychron VIA raw HID. 0x0440 is Nape Pro wired; 0xd026/0xd029 are shared Link-KM receivers. -export const KEYCHRON_PRODUCT_IDS = [0x0440, 0xd026, 0xd029] as const; +// Keychron Nape Pro VIA raw HID. 0x0440 is wired; 0xd026/0xd029 are shared Link-KM receivers. +export const KEYCHRON_NAPE_PRODUCT_IDS = [0x0440, 0xd026, 0xd029] as const; -export const KEYCHRON_HID_FILTERS: HIDDeviceFilter[] = KEYCHRON_PRODUCT_IDS.map( +export const KEYCHRON_NAPE_HID_FILTERS: HIDDeviceFilter[] = KEYCHRON_NAPE_PRODUCT_IDS.map( (productId) => ({ vendorId: VENDOR_ID.keychron, productId, usagePage: 0xff60, usage: 0x61 }), ); @@ -248,7 +248,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...RAZER_VIPER_V4_CONTROL_FILTERS, ...RAZER_DEATHADDER_ESSENTIAL_FILTERS, ...RAZER_COBRA_FILTERS, - ...KEYCHRON_HID_FILTERS, + ...KEYCHRON_NAPE_HID_FILTERS, ...RAZER_REGISTRY_FILTERS, ...RAZER_DEATHADDER_V2_FILTERS, ...EGG_WE_HID_FILTERS, diff --git a/src/keychron/index.ts b/src/keychron/index.ts index c4bf5a3..080322f 100644 --- a/src/keychron/index.ts +++ b/src/keychron/index.ts @@ -1,21 +1,34 @@ +/** Nape Pro VIA codec. Do not reuse these keymap/layer helpers for M-series Keychron mice. */ export const KEYCHRON_VENDOR_ID = 0x3434; export const KEYCHRON_RAW_USAGE_PAGE = 0xff60; export const KEYCHRON_RAW_USAGE = 0x61; export const KEYCHRON_REPORT_ID = 0; export const KEYCHRON_PACKET_LENGTH = 32; -export const KEYCHRON_PRODUCTS = new Map([ +export const KEYCHRON_NAPE_PRODUCTS = new Map([ [0x0440, { name: "Nape Pro" }], [0xd026, { name: "Keychron Link-KM", receiver: true }], [0xd029, { name: "Keychron Link-KM Type C", receiver: true }], ]); export const KEYCHRON_COMMAND = { firmwareVersion: 161, getCurrentLayer: 163, miscGroup: 167 } as const; /** Standard VIA opcodes used by Nape for keymap metadata (not the 0xA7 misc group). */ -export const KEYCHRON_VIA_COMMAND = { getLayerCount: 17 } as const; +export const KEYCHRON_VIA_COMMAND = { + getKeycode: 4, + setKeycode: 5, + getLayerCount: 17, + getBuffer: 18, + getEncoder: 20, + setEncoder: 21, +} as const; export const KEYCHRON_NAPE_COMMAND = { getOrientation: 32, getDpiStage: 33, setDpiStage: 34, setDpiValue: 35, getDpiValue: 36, setLayer: 45, getBattery: 49, getCustomDpi: 54, setCustomDpi: 55, } as const; -/** Nape Pro exposes eight user layers (1–8); VIA may report a spare slot above that. */ +/** + * Nape Pro exposes eight user layers (1–8). VIA may report a spare slot at + * index 0; GET_CURRENT_LAYER and keymap/encoder commands use the same 1–8 + * index. Keychron Launcher writes GET_BUFFER offset 14 * 3 when the mouse + * reports current layer 3. + */ export const KEYCHRON_NAPE_LAYER_COUNT = 8; export const KEYCHRON_MISC_COMMAND = { getSleep: 11, @@ -100,3 +113,219 @@ export function keychronEncodeSetLayer(layer: number): number[] { return [KEYCHRON_COMMAND.miscGroup, KEYCHRON_NAPE_COMMAND.setLayer, layer & 0xff]; } +/** Nape VIA keymap is 7 packed columns; the overlay exposes the first six. */ +export const KEYCHRON_NAPE_KEYMAP_COLUMNS = 7; +export const KEYCHRON_NAPE_KEYMAP_BUFFER_SIZE = KEYCHRON_NAPE_KEYMAP_COLUMNS * 2; +export const KEYCHRON_NAPE_KEY_CONTROLS = [ + { col: 0, name: "03" }, + { col: 1, name: "04" }, + { col: 2, name: "01" }, + { col: 3, name: "02" }, + { col: 4, name: "M1" }, + { col: 5, name: "M2" }, +] as const; +export const KEYCHRON_NAPE_ENCODER_ID = 0; +export const KEYCHRON_NAPE_ENCODER_CCW = 0; +export const KEYCHRON_NAPE_ENCODER_CW = 1; + +/** VIA protocol 12 QMK keycodes confirmed on Nape Pro firmware v1.2.6-ZK. */ +export const KEYCHRON_QK_USER = 0x7e00; +export const KEYCHRON_QK_MOMENTARY = 0x5220; +export const KEYCHRON_NAPE_KEYCODE = { + no: 0, + mute: 0x00a8, + volumeUp: 0x00a9, + volumeDown: 0x00aa, + leftClick: 0x00d1, + rightClick: 0x00d2, + middleClick: 0x00d3, + backward: 0x00d4, + forward: 0x00d5, + scrollUp: 0x00d9, + scrollDown: 0x00da, + scrollLeft: 0x00db, + scrollRight: 0x00dc, + dpiNext: KEYCHRON_QK_USER + 37, + dpiPrev: KEYCHRON_QK_USER + 38, + pollingPrev: KEYCHRON_QK_USER + 39, + pollingNext: KEYCHRON_QK_USER + 40, + orientationCycle: KEYCHRON_QK_USER + 43, + dpiCycle: KEYCHRON_QK_USER + 44, + pollingCycle: KEYCHRON_QK_USER + 45, + doubleClick: KEYCHRON_QK_USER + 46, + customDpi: KEYCHRON_QK_USER + 47, + gestureMode: KEYCHRON_QK_MOMENTARY + 9, + scrollMode: KEYCHRON_QK_MOMENTARY + 10, +} as const; + +export const KEYCHRON_NAPE_BUTTON_ACTIONS = [ + "Disabled", + "Left click", + "Right click", + "Middle click", + "Back", + "Forward", + "Double left click", + "Scroll up", + "Scroll down", + "Scroll left", + "Scroll right", + "Volume up", + "Volume down", + "Mute", + "Scroll mode", + "Gesture mode", + "DPI cycle", + "DPI up", + "DPI down", + "Polling cycle", + "Polling up", + "Polling down", + "Cycle orientation", +] as const; +export type KeychronNapeButtonAction = (typeof KEYCHRON_NAPE_BUTTON_ACTIONS)[number]; + +const KEYCHRON_NAPE_BUTTON_KEYCODES: Record = { + Disabled: KEYCHRON_NAPE_KEYCODE.no, + "Left click": KEYCHRON_NAPE_KEYCODE.leftClick, + "Right click": KEYCHRON_NAPE_KEYCODE.rightClick, + "Middle click": KEYCHRON_NAPE_KEYCODE.middleClick, + Back: KEYCHRON_NAPE_KEYCODE.backward, + Forward: KEYCHRON_NAPE_KEYCODE.forward, + "Double left click": KEYCHRON_NAPE_KEYCODE.doubleClick, + "Scroll up": KEYCHRON_NAPE_KEYCODE.scrollUp, + "Scroll down": KEYCHRON_NAPE_KEYCODE.scrollDown, + "Scroll left": KEYCHRON_NAPE_KEYCODE.scrollLeft, + "Scroll right": KEYCHRON_NAPE_KEYCODE.scrollRight, + "Volume up": KEYCHRON_NAPE_KEYCODE.volumeUp, + "Volume down": KEYCHRON_NAPE_KEYCODE.volumeDown, + Mute: KEYCHRON_NAPE_KEYCODE.mute, + "Scroll mode": KEYCHRON_NAPE_KEYCODE.scrollMode, + "Gesture mode": KEYCHRON_NAPE_KEYCODE.gestureMode, + "DPI cycle": KEYCHRON_NAPE_KEYCODE.dpiCycle, + "DPI up": KEYCHRON_NAPE_KEYCODE.dpiNext, + "DPI down": KEYCHRON_NAPE_KEYCODE.dpiPrev, + "Polling cycle": KEYCHRON_NAPE_KEYCODE.pollingCycle, + "Polling up": KEYCHRON_NAPE_KEYCODE.pollingNext, + "Polling down": KEYCHRON_NAPE_KEYCODE.pollingPrev, + "Cycle orientation": KEYCHRON_NAPE_KEYCODE.orientationCycle, +}; + +const KEYCHRON_NAPE_BUTTON_ACTION_BY_KEYCODE = new Map( + Object.entries(KEYCHRON_NAPE_BUTTON_KEYCODES).map(([action, keycode]) => [keycode, action as KeychronNapeButtonAction]), +); + +export interface KeychronNapeMappedControl { + keycode: number; + action: KeychronNapeButtonAction | "Custom"; +} + +export interface KeychronNapeLayerKeymap { + layer: number; + keys: Array; + wheel: { ccw: KeychronNapeMappedControl; cw: KeychronNapeMappedControl }; +} + +export function keychronUserLayerToVia(layer: number): number { + return Math.min(Math.max(layer, 1), KEYCHRON_NAPE_LAYER_COUNT); +} + +/** Firmware layers are 1–8; the UI and Launcher label them 0–7. */ +export function keychronLayerDisplayIndex(layer: number): number { + return keychronUserLayerToVia(layer) - 1; +} + +export function keychronLayerLabel(layer: number): string { + return `Layer ${keychronLayerDisplayIndex(layer)}`; +} + +export function keychronActionForKeycode(keycode: number): KeychronNapeButtonAction | "Custom" { + return KEYCHRON_NAPE_BUTTON_ACTION_BY_KEYCODE.get(keycode) ?? "Custom"; +} + +export function keychronKeycodeForAction(action: KeychronNapeButtonAction): number { + return KEYCHRON_NAPE_BUTTON_KEYCODES[action]; +} + +export function keychronMappedControl(keycode: number): KeychronNapeMappedControl { + return { keycode, action: keychronActionForKeycode(keycode) }; +} + +export function keychronLayerKeymapFromCodes( + layer: number, + columnCodes: readonly number[], + ccw: number, + cw: number, +): KeychronNapeLayerKeymap { + return { + layer, + keys: KEYCHRON_NAPE_KEY_CONTROLS.map((control) => ({ + ...control, + ...keychronMappedControl(columnCodes[control.col] ?? 0), + })), + wheel: { + ccw: keychronMappedControl(ccw), + cw: keychronMappedControl(cw), + }, + }; +} + +export function keychronEncodeGetBuffer(viaLayer: number): number[] { + const offset = KEYCHRON_NAPE_KEYMAP_BUFFER_SIZE * viaLayer; + return [ + KEYCHRON_VIA_COMMAND.getBuffer, + (offset >> 8) & 0xff, + offset & 0xff, + KEYCHRON_NAPE_KEYMAP_BUFFER_SIZE, + ]; +} + +export function keychronDecodeKeymapBuffer(response: Uint8Array): number[] { + const size = response[3] ?? 0; + const data = response.subarray(4, 4 + size); + const codes: number[] = []; + for (let index = 0; index + 1 < data.length; index += 2) { + codes.push(((data[index] ?? 0) << 8) | (data[index + 1] ?? 0)); + } + return codes; +} + +export function keychronEncodeGetKeycode(viaLayer: number, col: number): number[] { + return [KEYCHRON_VIA_COMMAND.getKeycode, viaLayer & 0xff, 0, col & 0xff]; +} + +export function keychronEncodeSetKeycode(viaLayer: number, col: number, keycode: number): number[] { + return [ + KEYCHRON_VIA_COMMAND.setKeycode, + viaLayer & 0xff, + 0, + col & 0xff, + (keycode >> 8) & 0xff, + keycode & 0xff, + ]; +} + +export function keychronEncodeGetEncoder(viaLayer: number, clockwise: boolean): number[] { + return [ + KEYCHRON_VIA_COMMAND.getEncoder, + viaLayer & 0xff, + KEYCHRON_NAPE_ENCODER_ID, + clockwise ? KEYCHRON_NAPE_ENCODER_CW : KEYCHRON_NAPE_ENCODER_CCW, + ]; +} + +export function keychronEncodeSetEncoder(viaLayer: number, clockwise: boolean, keycode: number): number[] { + return [ + KEYCHRON_VIA_COMMAND.setEncoder, + viaLayer & 0xff, + KEYCHRON_NAPE_ENCODER_ID, + clockwise ? KEYCHRON_NAPE_ENCODER_CW : KEYCHRON_NAPE_ENCODER_CCW, + (keycode >> 8) & 0xff, + keycode & 0xff, + ]; +} + +export function keychronDecodeKeycodeReply(response: Uint8Array): number { + return ((response[4] ?? 0) << 8) | (response[5] ?? 0); +} + From 8eba4e65b44512bad71b491c553c02a8adb8704d Mon Sep 17 00:00:00 2001 From: Dennis Date: Mon, 17 Aug 2026 04:32:53 +1000 Subject: [PATCH 3/3] Add Nape Pro per-layer orientation get and set. --- src/drivers/keychron/nape-hid.test.ts | 45 ++++++++++++++++++ src/drivers/keychron/nape-hid.ts | 47 ++++++++++++++++-- src/drivers/keychron/nape-protocol.test.ts | 15 ++++++ src/keychron/index.ts | 55 +++++++++++++++++++++- 4 files changed, 158 insertions(+), 4 deletions(-) diff --git a/src/drivers/keychron/nape-hid.test.ts b/src/drivers/keychron/nape-hid.test.ts index 703101b..aff4fe1 100644 --- a/src/drivers/keychron/nape-hid.test.ts +++ b/src/drivers/keychron/nape-hid.test.ts @@ -50,6 +50,7 @@ class FakeHidDevice { private firmware = "1.0.4"; private layerCount = 8; private currentLayer = 1; + private layerOrientations = Array.from({ length: 9 }, () => 2); private keycodes = Array.from({ length: 9 }, () => [0x00d1, 0x00d2, 0x00d3, 0x00d4, 0x00d5, 0, 0]); private encoders = Array.from({ length: 9 }, () => [0x00aa, 0x00a9]); @@ -67,6 +68,7 @@ class FakeHidDevice { this.layerCount = options.layerCount ?? 8; if (options.incompatibleReceiver) { this.orientation = 0xff; + this.layerOrientations = Array.from({ length: 9 }, () => 0xff); this.dpiStages = [40, 40, 40, 40, 40]; } } @@ -197,6 +199,26 @@ class FakeHidDevice { this.emit(reply); return; } + if (sub === NAPE.setOrientation) { + this.orientation = request[2] ?? this.orientation; + this.layerOrientations[this.currentLayer] = this.orientation; + this.emit(reply); + return; + } + if (sub === NAPE.getLayerOrientation) { + const layer = request[2] ?? 0; + reply[2] = this.layerOrientations[layer] ?? 0; + this.emit(reply); + return; + } + if (sub === NAPE.setLayerOrientation) { + const layer = request[2] ?? 0; + const index = request[3] ?? 0; + this.layerOrientations[layer] = index; + if (layer === this.currentLayer) this.orientation = index; + this.emit(reply); + return; + } if (sub === NAPE.getDpiStage) { reply[2] = this.dpiStage; this.emit(reply); @@ -260,6 +282,7 @@ class FakeHidDevice { } if (sub === NAPE.setLayer) { this.currentLayer = request[2] ?? this.currentLayer; + this.orientation = this.layerOrientations[this.currentLayer] ?? this.orientation; this.emit(reply); } } @@ -418,6 +441,7 @@ test("reads the Nape Pro VIA keymap and independent wheel directions", async () assert.equal(map.keys[1]?.action, "Right click"); assert.equal(map.wheel.ccw.keycode, KEYCHRON_NAPE_KEYCODE.volumeDown); assert.equal(map.wheel.cw.keycode, KEYCHRON_NAPE_KEYCODE.volumeUp); + assert.equal(map.orientationIndex, 2); }); test("writes a button and one wheel direction then confirms them", async () => { @@ -434,3 +458,24 @@ test("writes a button and one wheel direction then confirms them", async () => { assert.ok(fake.sent.some((packet) => packet[0] === VIA.setEncoder && packet[1] === 3 && packet[3] === 0 && packet[5] === 0xd9)); }); + +test("saves per-layer orientation as [57, layer, index] and applies live angle on the current layer", async () => { + const fake = new FakeHidDevice(); + const client = new KeychronNapeHidClient(fake as unknown as HIDDevice); + assert.equal(await client.setLayerOrientation(3, 5), 5); + assert.ok(fake.sent.some((packet) => + packet[0] === CMD.miscGroup && packet[1] === NAPE.setLayerOrientation + && packet[2] === 3 && packet[3] === 5)); + assert.ok(!fake.sent.some((packet) => + packet[0] === CMD.miscGroup && packet[1] === NAPE.setOrientation)); + const stored = await client.readLayerKeymap(3); + assert.equal(stored.orientationIndex, 5); + + fake.sent.length = 0; + await client.setLayer(3); + assert.equal(await client.setLayerOrientation(3, 2), 2); + assert.ok(fake.sent.some((packet) => + packet[0] === CMD.miscGroup && packet[1] === NAPE.setOrientation && packet[2] === 2)); + const status = await client.readStatus(); + assert.match(status.connectionDetail ?? "", /90° orientation/); +}); diff --git a/src/drivers/keychron/nape-hid.ts b/src/drivers/keychron/nape-hid.ts index 5c33378..3ea8fad 100644 --- a/src/drivers/keychron/nape-hid.ts +++ b/src/drivers/keychron/nape-hid.ts @@ -8,6 +8,7 @@ import { KEYCHRON_NAPE_DPI_MIN as DPI_MIN, KEYCHRON_NAPE_DPI_STEP as DPI_STEP, KEYCHRON_NAPE_LAYER_COUNT as LAYER_COUNT, + KEYCHRON_NAPE_ORIENTATION_STEPS as ORIENTATION_STEPS, KEYCHRON_NAPE_SLEEP_MAX_SECONDS as SLEEP_MAX, KEYCHRON_NAPE_SLEEP_MIN_SECONDS as SLEEP_MIN, KEYCHRON_NAPE_SLEEP_OPTIONS as SLEEP_OPTIONS, @@ -26,15 +27,21 @@ import { keychronDecodeLayerCount, keychronDecodePolling, keychronDecodeSleepTimeout, + keychronDecodeOrientationIndex, keychronEncodeGetBuffer, keychronEncodeGetEncoder, keychronEncodeGetKeycode, + keychronEncodeGetLayerOrientation, keychronEncodeSetEncoder, keychronEncodeSetKeycode, keychronEncodeSetLayer, + keychronEncodeSetLayerOrientation, + keychronEncodeSetOrientation, keychronEncodeSleepTimeout, keychronLayerKeymapFromCodes, keychronLayerLabel, + keychronOrientationDegrees, + keychronOrientationIndex, keychronPacket, keychronUserLayerToVia, type KeychronNapeLayerKeymap, @@ -42,7 +49,6 @@ import { const QUERY_TIMEOUT_MS = 1200; const DPI_STAGE_COUNT = 5; -const ORIENTATION_STEPS = 8; const NAPE_DISPLAY_NAME = "Nape Pro"; const PRODUCT_IDS = new Set(PRODUCTS.keys()); @@ -339,14 +345,41 @@ export class KeychronNapeHidClient { (bytes) => bytes[0] === VIA.getEncoder && bytes[3] === 1, keychronEncodeGetEncoder(viaLayer, true), ); + const orientationIndex = await this.getLayerOrientationIndex(layer).catch(() => 0); return keychronLayerKeymapFromCodes( layer, columns, keychronDecodeKeycodeReply(ccw), keychronDecodeKeycodeReply(cw), + orientationIndex >= 0 && orientationIndex < ORIENTATION_STEPS ? orientationIndex : 0, ); } + async setLayerOrientation(layer: number, index: number): Promise { + await this.open(); + const count = await this.usableLayerCount(); + if (!Number.isInteger(layer) || layer < 1 || layer > count) { + throw new Error(`Layer must be between 1 and ${count}.`); + } + const next = keychronOrientationIndex(index); + await this.query( + (bytes) => bytes[0] === CMD.miscGroup && bytes[1] === NAPE.setLayerOrientation, + keychronEncodeSetLayerOrientation(layer, next), + ); + const confirmed = await this.getLayerOrientationIndex(layer); + if (confirmed !== next) { + throw new Error(`The mouse kept orientation index ${confirmed} on that layer instead of ${next}.`); + } + const current = await this.getCurrentLayer(); + if (current === layer) { + await this.query( + (bytes) => bytes[0] === CMD.miscGroup && bytes[1] === NAPE.setOrientation, + keychronEncodeSetOrientation(next), + ); + } + return confirmed; + } + async setKeycode(layer: number, col: number, keycode: number): Promise { await this.open(); const viaLayer = keychronUserLayerToVia(layer); @@ -442,13 +475,21 @@ export class KeychronNapeHidClient { (bytes) => bytes[0] === CMD.miscGroup && bytes[1] === NAPE.getOrientation, [CMD.miscGroup, NAPE.getOrientation], ); - return response[2] ?? 0xff; + return keychronDecodeOrientationIndex(response); + } + + private async getLayerOrientationIndex(layer: number): Promise { + const response = await this.query( + (bytes) => bytes[0] === CMD.miscGroup && bytes[1] === NAPE.getLayerOrientation, + keychronEncodeGetLayerOrientation(layer), + ); + return keychronDecodeOrientationIndex(response); } private async getOrientation(): Promise { const index = await this.getOrientationIndex(); if (index < 0 || index >= ORIENTATION_STEPS) return null; - return 45 * index; + return keychronOrientationDegrees(index); } private async getCustomDpi(): Promise { diff --git a/src/drivers/keychron/nape-protocol.test.ts b/src/drivers/keychron/nape-protocol.test.ts index 037459c..d85acc9 100644 --- a/src/drivers/keychron/nape-protocol.test.ts +++ b/src/drivers/keychron/nape-protocol.test.ts @@ -18,6 +18,7 @@ import { keychronActionForKeycode, keychronDecodeKeymapBuffer, keychronEncodeGetBuffer, + keychronEncodeGetLayerOrientation, keychronEncodeSetEncoder, keychronEncodeSetKeycode, keychronKeycodeForAction, @@ -31,7 +32,11 @@ import { keychronDecodePolling, keychronDecodeSleepTimeout, keychronEncodeSetLayer, + keychronEncodeSetLayerOrientation, + keychronEncodeSetOrientation, keychronEncodeSleepTimeout, + keychronOrientationDegrees, + keychronOrientationLabel, keychronPacket, } from "@openmouse/protocol/keychron"; @@ -121,6 +126,15 @@ test("VIA layer count and current-layer get/set use 1–8", () => { assert.deepEqual(keychronEncodeSetLayer(8), [167, 45, 8]); }); +test("per-layer orientation is eight 45° steps packed as [57, layer, index]", () => { + assert.equal(keychronOrientationDegrees(0), 0); + assert.equal(keychronOrientationDegrees(2), 90); + assert.equal(keychronOrientationLabel(5), "225°"); + assert.deepEqual(keychronEncodeSetOrientation(2), [167, 52, 2]); + assert.deepEqual(keychronEncodeGetLayerOrientation(3), [167, 56, 3]); + assert.deepEqual(keychronEncodeSetLayerOrientation(3, 5), [167, 57, 3, 5]); +}); + test("VIA keymap buffer is packed 7 big-endian keycodes per layer", () => { assert.equal(KEYCHRON_VIA_COMMAND.getBuffer, 18); assert.deepEqual(keychronEncodeGetBuffer(0), [18, 0, 0, 14]); @@ -163,4 +177,5 @@ test("protocol-12 mouse and CUSTOM actions round-trip through the Nape catalog", assert.equal(map.keys[3]?.action, "DPI cycle"); assert.equal(map.wheel.ccw.action, "Volume down"); assert.equal(map.wheel.cw.action, "Volume up"); + assert.equal(map.orientationIndex, 0); }); diff --git a/src/keychron/index.ts b/src/keychron/index.ts index 080322f..de0ecc0 100644 --- a/src/keychron/index.ts +++ b/src/keychron/index.ts @@ -21,8 +21,12 @@ export const KEYCHRON_VIA_COMMAND = { } as const; export const KEYCHRON_NAPE_COMMAND = { getOrientation: 32, getDpiStage: 33, setDpiStage: 34, setDpiValue: 35, - getDpiValue: 36, setLayer: 45, getBattery: 49, getCustomDpi: 54, setCustomDpi: 55, + getDpiValue: 36, setLayer: 45, getBattery: 49, setOrientation: 52, + getCustomDpi: 54, setCustomDpi: 55, getLayerOrientation: 56, setLayerOrientation: 57, } as const; +/** Sensor heading is stored per layer in 45° steps (0–7 → 0°–315°). */ +export const KEYCHRON_NAPE_ORIENTATION_STEPS = 8; +export const KEYCHRON_NAPE_ORIENTATION_STEP_DEGREES = 45; /** * Nape Pro exposes eight user layers (1–8). VIA may report a spare slot at * index 0; GET_CURRENT_LAYER and keymap/encoder commands use the same 1–8 @@ -113,6 +117,51 @@ export function keychronEncodeSetLayer(layer: number): number[] { return [KEYCHRON_COMMAND.miscGroup, KEYCHRON_NAPE_COMMAND.setLayer, layer & 0xff]; } +export function keychronOrientationIndex(index: number): number { + if (!Number.isInteger(index) || index < 0 || index >= KEYCHRON_NAPE_ORIENTATION_STEPS) { + throw new Error(`Orientation must be a 45° step from 0° to 315°.`); + } + return index; +} + +export function keychronOrientationDegrees(index: number): number { + return KEYCHRON_NAPE_ORIENTATION_STEP_DEGREES * keychronOrientationIndex(index); +} + +export function keychronOrientationLabel(index: number): string { + return `${keychronOrientationDegrees(index)}\u00b0`; +} + +export const KEYCHRON_NAPE_ORIENTATION_OPTIONS = Array.from( + { length: KEYCHRON_NAPE_ORIENTATION_STEPS }, + (_, index) => ({ + index, + degrees: KEYCHRON_NAPE_ORIENTATION_STEP_DEGREES * index, + label: `${KEYCHRON_NAPE_ORIENTATION_STEP_DEGREES * index}\u00b0`, + }), +); + +export function keychronDecodeOrientationIndex(response: Uint8Array): number { + return response[2] ?? 0xff; +} + +export function keychronEncodeSetOrientation(index: number): number[] { + return [KEYCHRON_COMMAND.miscGroup, KEYCHRON_NAPE_COMMAND.setOrientation, keychronOrientationIndex(index)]; +} + +export function keychronEncodeGetLayerOrientation(layer: number): number[] { + return [KEYCHRON_COMMAND.miscGroup, KEYCHRON_NAPE_COMMAND.getLayerOrientation, layer & 0xff]; +} + +export function keychronEncodeSetLayerOrientation(layer: number, index: number): number[] { + return [ + KEYCHRON_COMMAND.miscGroup, + KEYCHRON_NAPE_COMMAND.setLayerOrientation, + layer & 0xff, + keychronOrientationIndex(index), + ]; +} + /** Nape VIA keymap is 7 packed columns; the overlay exposes the first six. */ export const KEYCHRON_NAPE_KEYMAP_COLUMNS = 7; export const KEYCHRON_NAPE_KEYMAP_BUFFER_SIZE = KEYCHRON_NAPE_KEYMAP_COLUMNS * 2; @@ -224,6 +273,8 @@ export interface KeychronNapeLayerKeymap { layer: number; keys: Array; wheel: { ccw: KeychronNapeMappedControl; cw: KeychronNapeMappedControl }; + /** Saved sensor heading for this layer, 0–7 (0°–315°). */ + orientationIndex: number; } export function keychronUserLayerToVia(layer: number): number { @@ -256,6 +307,7 @@ export function keychronLayerKeymapFromCodes( columnCodes: readonly number[], ccw: number, cw: number, + orientationIndex = 0, ): KeychronNapeLayerKeymap { return { layer, @@ -267,6 +319,7 @@ export function keychronLayerKeymapFromCodes( ccw: keychronMappedControl(ccw), cw: keychronMappedControl(cw), }, + orientationIndex, }; }