diff --git a/src/drivers/keychron/hid.test.ts b/src/drivers/keychron/nape-hid.test.ts similarity index 52% rename from src/drivers/keychron/hid.test.ts rename to src/drivers/keychron/nape-hid.test.ts index c0422c9..aff4fe1 100644 --- a/src/drivers/keychron/hid.test.ts +++ b/src/drivers/keychron/nape-hid.test.ts @@ -1,28 +1,32 @@ 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, 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 +48,11 @@ class FakeHidDevice { private batteryStatus = 0; private orientation = 2; // 90° 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]); constructor(options: FakeOptions = {}) { this.productId = options.productId ?? 0x0440; @@ -56,8 +65,10 @@ 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.layerOrientations = Array.from({ length: 9 }, () => 0xff); this.dpiStages = [40, 40, 40, 40, 40]; } } @@ -97,6 +108,88 @@ 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 === 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; @@ -106,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); @@ -165,6 +278,12 @@ 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.orientation = this.layerOrientations[this.currentLayer] ?? this.orientation; + this.emit(reply); } } @@ -195,16 +314,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); @@ -213,7 +332,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); @@ -232,12 +351,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.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); @@ -260,14 +382,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/); }); @@ -279,15 +401,81 @@ 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 KeychronNapeHidClient(fake as unknown as HIDDevice); + assert.equal(await client.setLayer(3), 3); + const status = await client.readStatus(); + 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/); + 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 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); + assert.equal(map.orientationIndex, 2); +}); + +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)); +}); + +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/hid.ts b/src/drivers/keychron/nape-hid.ts similarity index 70% rename from src/drivers/keychron/hid.ts rename to src/drivers/keychron/nape-hid.ts index dec7189..3ea8fad 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, @@ -6,30 +7,52 @@ 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_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, 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, KEYCHRON_VENDOR_ID, + KEYCHRON_VIA_COMMAND as VIA, keychronDecodeBattery, + keychronDecodeCurrentLayer, keychronDecodeFirmware, + keychronDecodeKeycodeReply, + keychronDecodeKeymapBuffer, + keychronDecodeLayerCount, keychronDecodePolling, keychronDecodeSleepTimeout, + keychronDecodeOrientationIndex, + keychronEncodeGetBuffer, + keychronEncodeGetEncoder, + keychronEncodeGetKeycode, + keychronEncodeGetLayerOrientation, + keychronEncodeSetEncoder, + keychronEncodeSetKeycode, + keychronEncodeSetLayer, + keychronEncodeSetLayerOrientation, + keychronEncodeSetOrientation, keychronEncodeSleepTimeout, + keychronLayerKeymapFromCodes, + keychronLayerLabel, + keychronOrientationDegrees, + keychronOrientationIndex, keychronPacket, + keychronUserLayerToVia, + type KeychronNapeLayerKeymap, } from "@openmouse/protocol/keychron"; 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()); -export class KeychronHidClient { +export class KeychronNapeHidClient { private responseWaiter: { match: (bytes: Uint8Array) => boolean; resolve: (bytes: Uint8Array) => void; @@ -121,7 +144,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[] { @@ -145,6 +168,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 +180,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 ? keychronLayerLabel(layers.current) : null, ].filter(Boolean).join(" · "); return { @@ -189,6 +214,8 @@ export class KeychronHidClient { liftOffDistance: null, supportedLiftOffDistances: [], sleepTimeout, + napeLayer: layers?.current, + napeLayerCount: layers?.count, firmware: [firmware ?? "Firmware unavailable"], }; } @@ -284,6 +311,111 @@ 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; + } + + 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), + ); + 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); + 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, @@ -343,13 +475,21 @@ export class KeychronHidClient { (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 { @@ -384,6 +524,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/nape-protocol.test.ts b/src/drivers/keychron/nape-protocol.test.ts new file mode 100644 index 0000000..d85acc9 --- /dev/null +++ b/src/drivers/keychron/nape-protocol.test.ts @@ -0,0 +1,181 @@ +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, + 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, + KEYCHRON_NAPE_KEYCODE, + KEYCHRON_NAPE_BUTTON_ACTIONS, + keychronActionForKeycode, + keychronDecodeKeymapBuffer, + keychronEncodeGetBuffer, + keychronEncodeGetLayerOrientation, + keychronEncodeSetEncoder, + keychronEncodeSetKeycode, + keychronKeycodeForAction, + keychronLayerKeymapFromCodes, + keychronLayerLabel, + keychronUserLayerToVia, + keychronDecodeBattery, + keychronDecodeCurrentLayer, + keychronDecodeFirmware, + keychronDecodeLayerCount, + keychronDecodePolling, + keychronDecodeSleepTimeout, + keychronEncodeSetLayer, + keychronEncodeSetLayerOrientation, + keychronEncodeSetOrientation, + keychronEncodeSleepTimeout, + keychronOrientationDegrees, + keychronOrientationLabel, + 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", + }); +}); + +test("sleep decodes little-endian seconds from the Get_Sleep reply layout", () => { + assert.equal(keychronDecodeSleepTimeout(new Uint8Array([167, 11, 0, 0, 0, 0x3d, 0])), 61); + assert.equal(keychronDecodeSleepTimeout(new Uint8Array([167, 11, 0, 0, 0, 0x78, 0])), 120); + assert.equal(keychronDecodeSleepTimeout(new Uint8Array([167, 11, 0, 0, 0, 0xcf, 0xb6])), 46799); + assert.equal(KEYCHRON_NAPE_SLEEP_MIN_SECONDS, 60); + assert.equal(KEYCHRON_NAPE_SLEEP_MAX_SECONDS, 12 * 3600 + 59 * 60 + 59); +}); + +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]); +}); + +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]); + 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"); + assert.equal(map.orientationIndex, 0); +}); diff --git a/src/drivers/keychron/protocol.test.ts b/src/drivers/keychron/protocol.test.ts deleted file mode 100644 index 5ce64da..0000000 --- a/src/drivers/keychron/protocol.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -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_NAPE_SLEEP_MAX_SECONDS, - KEYCHRON_NAPE_SLEEP_MIN_SECONDS, - KEYCHRON_PACKET_LENGTH, - KEYCHRON_POLLING_TABLE, - keychronDecodeBattery, - keychronDecodeFirmware, - keychronDecodePolling, - keychronDecodeSleepTimeout, - keychronEncodeSleepTimeout, - 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", - }); -}); - -test("sleep decodes little-endian seconds from the Get_Sleep reply layout", () => { - assert.equal(keychronDecodeSleepTimeout(new Uint8Array([167, 11, 0, 0, 0, 0x3d, 0])), 61); - assert.equal(keychronDecodeSleepTimeout(new Uint8Array([167, 11, 0, 0, 0, 0x78, 0])), 120); - assert.equal(keychronDecodeSleepTimeout(new Uint8Array([167, 11, 0, 0, 0, 0xcf, 0xb6])), 46799); - assert.equal(KEYCHRON_NAPE_SLEEP_MIN_SECONDS, 60); - assert.equal(KEYCHRON_NAPE_SLEEP_MAX_SECONDS, 12 * 3600 + 59 * 60 + 59); -}); - -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]); -}); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 0afa86e..f838637 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -182,6 +182,12 @@ 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. + */ + 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 0792640..74df29a 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -4,7 +4,7 @@ import { EggOp1HidClient } from "./endgame/egg-op1-hid.ts"; import { FantechHidClient } from "./fantech/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"; @@ -24,7 +24,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 | FantechHidClient; +export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient; export interface DeviceDriver { brand: string; @@ -56,7 +56,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 }, { brand: "Fantech", supports: (device) => FantechHidClient.isSupported(device), create: (device) => new FantechHidClient(device), score: () => 5 }, ]; diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 14fe28d..6ee3ae9 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -42,10 +42,10 @@ export const VENDOR_ID = { fantech: 0x3151, } 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 }), ); @@ -249,7 +249,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 adae738..de0ecc0 100644 --- a/src/keychron/index.ts +++ b/src/keychron/index.ts @@ -1,18 +1,39 @@ +/** 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, miscGroup: 167 } as const; +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 = { + 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, 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 + * 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, setSleep: 12, @@ -82,3 +103,282 @@ export function keychronEncodeSleepTimeout(seconds: number): number[] { ]; } +export function keychronDecodeLayerCount(response: Uint8Array): number { + return response[1] ?? 0; +} + +export function keychronDecodeCurrentLayer(response: Uint8Array): number { + const reported = response[1] ?? 1; + if (reported < 1) return 1; + return Math.min(reported, KEYCHRON_NAPE_LAYER_COUNT); +} + +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; +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 }; + /** Saved sensor heading for this layer, 0–7 (0°–315°). */ + orientationIndex: number; +} + +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, + orientationIndex = 0, +): KeychronNapeLayerKeymap { + return { + layer, + keys: KEYCHRON_NAPE_KEY_CONTROLS.map((control) => ({ + ...control, + ...keychronMappedControl(columnCodes[control.col] ?? 0), + })), + wheel: { + ccw: keychronMappedControl(ccw), + cw: keychronMappedControl(cw), + }, + orientationIndex, + }; +} + +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); +} +