From a3381bcf6133f7a110918c3be4452ad61a2b9cd0 Mon Sep 17 00:00:00 2001 From: ydw1904 Date: Wed, 30 Sep 2026 01:09:54 +0800 Subject: [PATCH] feat(keychron): support the G3 Air on Launcher's 8K Nordic protocol The G3 Air's Launcher config (static/device/875876471/json/v3.json) forces the "8k_nordic" protocol: the 4K family's 0xff0a collection and framing with Orbital's DMS v2 settings blocks. Decoded from Keychron Launcher (main.be11320b2a72b61b.js, webpack module 20706, plus the lazy settings chunks for ranges): - sensor block 04/81/01, written as 04/bc/02: processing toggles, 20K FPS mode, lift-off (0.7, 1 and 2 mm), a signed angle, five X/Y DPI stages and their colours - system block 04/83/03, written as 04/98/04: sleep in seconds, debounce, and a six-gear polling table whose active byte is a gear index. From firmware 1.6.0 a second gear set for 2.4 GHz follows and the write grows to 04/a0/04 - buttons 03/81/01 and 03/85/04 with 4-byte codes (01 00 f0-f4 00 for mouse buttons, 07 00 0x 00 for DPI), profiles 02/82/02, and a save (0a/81/01) after every write, as Launcher does A polling change picks the gear that already holds the rate, else puts the rate in the active gear, which is what Launcher's assistant does. Fields Launcher never edits (full speed, lift-down, glass, quick response, wake sources, wheel reverse) are written back as read. The driver claims the wired G3 Air (0xd077) and the Ultra-Link 8K receivers 0xd05b and 0xd078 from Keychron's product list. A receiver has to answer the unrouted handshake with Keychron's or Lemokey's vendor ID in bytes 6-7 (Launcher's test for this protocol) before anything is written; bytes 8-9 name the paired mouse. When the mouse does not answer, readStatus returns the name alone. The 4K driver now leaves these ids alone and names the product ID when it meets an 8K Nordic receiver it does not know. Not yet tested on hardware. Co-Authored-By: Claude Opus 5.5 --- src/drivers/keychron/mouse-4k-hid.ts | 10 +- .../keychron/mouse-8k-nordic-hid.test.ts | 315 ++++++++ src/drivers/keychron/mouse-8k-nordic-hid.ts | 723 ++++++++++++++++++ src/drivers/registry.test.ts | 4 + src/drivers/registry.ts | 4 +- src/keychron/index.ts | 21 + 6 files changed, 1074 insertions(+), 3 deletions(-) create mode 100644 src/drivers/keychron/mouse-8k-nordic-hid.test.ts create mode 100644 src/drivers/keychron/mouse-8k-nordic-hid.ts diff --git a/src/drivers/keychron/mouse-4k-hid.ts b/src/drivers/keychron/mouse-4k-hid.ts index b12c892..d3c01d1 100644 --- a/src/drivers/keychron/mouse-4k-hid.ts +++ b/src/drivers/keychron/mouse-4k-hid.ts @@ -3,6 +3,7 @@ import { KEYCHRON_4K_MICE as MICE, KEYCHRON_4K_USAGE as USAGE, KEYCHRON_4K_USAGE_PAGE as USAGE_PAGE, + KEYCHRON_8K_NORDIC_PRODUCT_IDS, KEYCHRON_M6_USAGE_PAGE, KEYCHRON_VENDOR_ID, } from "@openmouse/protocol/keychron"; @@ -98,9 +99,14 @@ export class Keychron4kHidClient { this.receiver = !MICE.some((mouse) => mouse.productId === device.productId); } - /** Launcher prefers the M6's 0xffc1 protocol when a device offers both, so this does too. */ + /** + * Launcher prefers the M6's 0xffc1 protocol when a device offers both, so + * this does too. The 8K Nordic mice and receivers share the collection and + * go to their own driver. + */ static isSupported(device: HIDDevice): boolean { return device.vendorId === KEYCHRON_VENDOR_ID + && !KEYCHRON_8K_NORDIC_PRODUCT_IDS.includes(device.productId) && device.collections.some((collection) => collection.usagePage === USAGE_PAGE && collection.usage === USAGE) && !device.collections.some((collection) => collection.usagePage === KEYCHRON_M6_USAGE_PAGE); } @@ -328,7 +334,7 @@ export class Keychron4kHidClient { // Strict match: a routed live report from the mouse (0x41) must not stand in for the receiver's own answer. const handshake = await this.request(commandPacket(CMD.power), (bytes) => bytes[0] === CMD.power[0] && bytes[3] === CMD.power[2], false); if ((handshake[6] === 0x34 && handshake[7] === 0x34) || (handshake[6] === 0x2d && handshake[7] === 0x36)) { - throw new Error("This Keychron mouse uses the 8K Nordic protocol, which OpenMouse does not support yet."); + throw new Error(`This Keychron device (product ID 0x${this.device.productId.toString(16).padStart(4, "0")}) uses the 8K Nordic protocol, but OpenMouse does not know it yet. Please report the ID.`); } const version = await this.request(commandPacket(CMD.version), replyTo(CMD.version)).catch(() => null); const modelId = version ? readU16(version, 4) : -1; diff --git a/src/drivers/keychron/mouse-8k-nordic-hid.test.ts b/src/drivers/keychron/mouse-8k-nordic-hid.test.ts new file mode 100644 index 0000000..2cf5cda --- /dev/null +++ b/src/drivers/keychron/mouse-8k-nordic-hid.test.ts @@ -0,0 +1,315 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { Keychron4kHidClient } from "./mouse-4k-hid.ts"; +import { + Keychron8kNordicHidClient, + keychronNordicButtonLabel, + keychronNordicDecodeSensor, + keychronNordicDecodeSystem, + keychronNordicEncodeSensor, + keychronNordicEncodeSystem, +} from "./mouse-8k-nordic-hid.ts"; + +/** + * Answers Launcher's "8k_nordic" protocol with the byte layout its command + * classes read and write (main.be11320b2a72b61b.js, module 20706); there is + * no G3 Air capture yet. As a receiver it answers only its own status + * unrouted and needs 0x40 on byte 0 for everything meant for the mouse. + */ +class FakeNordicMouse { + vendorId = 0x3434; + opened = false; + readonly sent: Uint8Array[] = []; + readonly collections = [{ usagePage: 0xff0a, usage: 0x01, inputReports: [], outputReports: [], featureReports: [] }]; + private listener: ((event: unknown) => void) | null = null; + /** Each block as the mouse stores it, at packet offsets. */ + sensor = new Uint8Array(64); + system = new Uint8Array(64); + buttons = new Uint8Array(64); + power = { state: 3, percent: 76, profile: 1 }; + /** Bytes 6-9 of the receiver's own status: Keychron's vendor ID, then the G3 Air's product ID. */ + paired = [0x34, 0x34, 0x77, 0xd0]; + firmware = [1, 5, 2]; + awake = true; + /** Routed packets to ignore, as a lossy 2.4 GHz link would. */ + drop = 0; + + constructor(readonly productId: number, private readonly receiver = false) { + const s = this.sensor; + s.set([0, 1, 1, 0, 1, 1, 0], 4); // ripple, motion sync, 20K FPS and lift-down on + s[11] = 1; // 1 mm + s[12] = 0xfb; // -5° + s[13] = 1; + s[14] = 0x07; // three stages + [[400, 400], [800, 800], [1600, 1600], [3200, 3000], [6400, 6400]].forEach(([x, y], stage) => { + s.set([x! & 0xff, x! >> 8, y! & 0xff, y! >> 8], 15 + stage * 4); + }); + s.set([0xff, 0, 0, 0, 0xff, 0], 43); // red, green + const y = this.system; + y.set([0x58, 0x02, 0x58, 0x02], 4); // 600 s, twice + y[8] = 2; // USB gear 3 + y[9] = 8; + y.set([1, 1, 1, 0, 0, 0, 0, 0, 0, 0], 10); // quick response, button and motion wake + y[20] = 0x3f; + y.set([0, 2, 3, 4, 5, 6], 21); // 125, 500, 1000, 2000, 4000, 8000 + y[28] = 3; // 2.4 GHz gear 4 + y[29] = 0x0f; + y.set([3, 4, 5, 6, 0, 0], 30); // 1000, 2000, 4000, 8000 + [[1, 0, 0xf0, 0], [1, 0, 0xf1, 0], [1, 0, 0xf2, 0], [1, 0, 0xf4, 0], [1, 0, 0xf3, 0]] + .forEach((code, index) => this.buttons.set(code, 4 + index * 4)); + } + + async open(): Promise { this.opened = true; } + async close(): Promise { this.opened = false; } + addEventListener(_type: string, listener: (event: unknown) => void): void { this.listener = listener; } + removeEventListener(): void { this.listener = null; } + + async sendReport(reportId: number, data: BufferSource): Promise { + const packet = new Uint8Array(data instanceof ArrayBuffer ? data : data.buffer).slice(); + assert.equal(reportId, 0); + assert.equal(packet.length, 64); + const sum = packet.slice(0, 63).reduce((total, byte) => total + byte, 0); + assert.equal(packet[63], (0xa1 - (sum & 0xff)) & 0xff, "bad checksum"); + this.sent.push(packet); + const routed = ((packet[0] ?? 0) & 0x40) !== 0; + const command = (packet[0] ?? 0) & 0xbf; + const reply = new Uint8Array(64); + reply.set(packet.slice(0, 4)); + if (this.receiver && !routed) { + if (command !== 0x01) return; + reply.set([1, 5, ...this.paired], 4); + return this.emit(reply); + } + assert.equal(routed, this.receiver, "only a receiver takes routed packets"); + if (!this.awake) return; + if (this.drop > 0) { + this.drop -= 1; + return; + } + // Writes land only as far as byte 2's payload length reaches. + const payload = packet.slice(4, Math.min(4 + ((packet[2] ?? 0) & 0x7f), 63)); + switch (`${command}/${packet[3]}`) { + case "0/0": + reply[8] = (this.firmware[1]! << 4) | this.firmware[2]!; + reply[9] = this.firmware[0]!; + break; + case "1/1": + reply.set([1, 5, 0x34, 0x34, 0x77, 0xd0, this.power.state, this.power.percent, this.power.profile], 4); + break; + case "2/2": + this.power.profile = packet[4] ?? 0; + break; + case "3/1": + reply.set(this.buttons.slice(4, 63), 4); + break; + case "3/4": + this.buttons.set(payload.slice(1, 5), 4 + (packet[4] ?? 0) * 4); + break; + case "4/1": + reply.set(this.sensor.slice(4, 63), 4); + break; + case "4/2": + this.sensor.set(payload, 4); + break; + case "4/3": + reply.set(this.system.slice(4, 63), 4); + break; + case "4/4": + this.system.set(payload, 4); + break; + case "10/1": + break; + default: + return; + } + this.emit(reply); + } + + private emit(bytes: Uint8Array): void { + queueMicrotask(() => this.listener?.({ data: new DataView(bytes.buffer), reportId: 0 })); + } +} + +function connect(mouse: FakeNordicMouse): Keychron8kNordicHidClient { + return new Keychron8kNordicHidClient(mouse as unknown as HIDDevice); +} + +function lastWrite(mouse: FakeNordicMouse, command: number, sub: number): Uint8Array { + const packet = mouse.sent.filter((sent) => ((sent[0] ?? 0) & 0xbf) === command && sent[3] === sub).at(-1); + assert.ok(packet, `no ${command}/${sub} packet was sent`); + return packet; +} + +test("claims the G3 Air and its receivers, and the 4K driver lets them go", () => { + for (const productId of [0xd077, 0xd05b, 0xd078]) { + const device = new FakeNordicMouse(productId) as unknown as HIDDevice; + assert.equal(Keychron8kNordicHidClient.isSupported(device), true); + assert.equal(Keychron4kHidClient.isSupported(device), false); + } + const m4 = new FakeNordicMouse(0xd040) as unknown as HIDDevice; + assert.equal(Keychron8kNordicHidClient.isSupported(m4), false); + assert.equal(Keychron4kHidClient.isSupported(m4), true); +}); + +test("reads a wired G3 Air", async () => { + const mouse = new FakeNordicMouse(0xd077); + const status = await connect(mouse).readStatus(); + + assert.equal(status.name, "Keychron G3 Air"); + assert.equal(status.connectionType, "Wired"); + assert.deepEqual(status.dpiStages, [400, 800, 1600]); + assert.equal(status.dpi, 800); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.liftOffDistance, "Medium"); + assert.equal(status.angleTuning, -5); + assert.equal(status.angleSnapping, false); + assert.equal(status.rippleControl, true); + assert.equal(status.motionSync, true); + assert.equal(status.performanceMode, true); + assert.equal(status.debounceMs, 8); + assert.equal(status.sleepTimeout, 600); + assert.equal(status.activeProfile, 2); + assert.equal(status.batteryPercent, 76); + assert.equal(status.batteryState, "Discharging"); + assert.deepEqual(status.buttonMappings, { + Left: "Left Click", Right: "Right Click", Middle: "Middle Click", Back: "Back", Forward: "Forward", + }); + assert.deepEqual(status.firmware, ["v1.5.2"]); + assert.ok(mouse.sent.every((packet) => ((packet[0] ?? 0) & 0x40) === 0), "wired packets must not be routed"); +}); + +test("writes the sensor block the way Launcher builds it, then saves", async () => { + const mouse = new FakeNordicMouse(0xd077); + const client = connect(mouse); + + assert.equal(await client.setDpiStageValue(2, 30_000), 30_000); + const write = lastWrite(mouse, 0x04, 0x02); + assert.deepEqual([...write.slice(0, 15)], [0x04, 0, 0xbc, 0x02, 0, 1, 1, 0, 1, 1, 0, 1, 0xfb, 1, 0x07]); + assert.deepEqual([...write.slice(23, 27)], [0x30, 0x75, 0x30, 0x75], "stage 3 is 30000 on both axes"); + assert.deepEqual([...write.slice(27, 31)], [0x80, 0x0c, 0xb8, 0x0b], "stage 4 keeps its own Y"); + assert.deepEqual([...write.slice(35, 43)], [0, 0, 0, 0, 0, 0, 0, 0]); + assert.deepEqual([...write.slice(43, 49)], [0xff, 0, 0, 0, 0xff, 0], "stage colours ride along"); + + assert.equal(await client.setLiftOffDistance("Low"), "Low"); + assert.equal(await client.setAngleTuning(-30), -30); + assert.equal(await client.setPerformanceMode(false), false); + assert.equal(await client.setDpiStageCount(5), 5); + assert.equal(await client.setActiveDpiStage(4), 4); + assert.deepEqual([...mouse.sensor.slice(8, 15)], [0, 1, 0, 0, 0xe2, 4, 0x1f]); + assert.equal(mouse.sent.filter((packet) => packet[0] === 0x0a).length, 6, "every write is followed by a save"); + await assert.rejects(client.setDpi(30_050), /multiple of 50/); + await assert.rejects(client.setAngleTuning(91), /between -90 and 90/); +}); + +test("picks the gear that holds a rate, or rewrites the active gear", async () => { + const mouse = new FakeNordicMouse(0xd077); + const client = connect(mouse); + + assert.equal(await client.setPollingRate(8000), 8000); + const write = lastWrite(mouse, 0x04, 0x04); + assert.deepEqual([...write.slice(0, 4)], [0x04, 0, 0x98, 0x04], "no 2.4 GHz set before firmware 1.6.0"); + assert.deepEqual([...write.slice(4, 27)], [ + 0x58, 0x02, 0x58, 0x02, 5, 8, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, + 0x3f, 0, 2, 3, 4, 5, 6, + ]); + assert.deepEqual([...write.slice(27, 36)], [0, 0, 0, 0, 0, 0, 0, 0, 0]); + + mouse.system[20] = 0x07; // the button cycles 125, 500 and 1000 Hz only + mouse.system[8] = 2; + assert.equal(await client.setPollingRate(4000), 4000); + assert.deepEqual([...mouse.system.slice(20, 27)], [0x07, 0, 2, 5, 4, 5, 6], "4000 Hz replaces the active gear"); + assert.equal(mouse.system[8], 2); + await assert.rejects(client.setPollingRate(250), /does not support 250 Hz/); +}); + +test("debounce and sleep go through the system block", async () => { + const mouse = new FakeNordicMouse(0xd077); + const client = connect(mouse); + + assert.equal(await client.setDebounceTime(4), 4); + assert.equal(await client.setSleepTimeout(1800), 1800); + assert.deepEqual([...mouse.system.slice(4, 10)], [0x08, 0x07, 0x08, 0x07, 2, 4]); + assert.deepEqual([...mouse.system.slice(10, 13)], [1, 1, 1], "quick response and wake sources are kept"); + await assert.rejects(client.setSleepTimeout(90), /whole minutes/); + await assert.rejects(client.setDebounceTime(21), /between 0 and 20/); +}); + +test("routes through the receiver and uses its own gears on firmware 1.6.0", async () => { + const receiver = new FakeNordicMouse(0xd05b, true); + receiver.firmware = [1, 6, 0]; + const client = connect(receiver); + const status = await client.readStatus(); + + assert.equal(status.name, "Keychron G3 Air"); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.pollingRateHz, 8000); + assert.equal(await client.setPollingRate(2000), 2000); + const write = lastWrite(receiver, 0x04, 0x04); + assert.deepEqual([...write.slice(0, 4)], [0x44, 0, 0xa0, 0x04]); + assert.equal(write[8], 2, "the USB gear stays"); + assert.deepEqual([...write.slice(28, 36)], [1, 0x0f, 3, 4, 5, 6, 0, 0]); + assert.equal(await client.setProfile(5), 5); + + const [handshake, ...rest] = receiver.sent; + assert.equal(handshake?.[0], 0x01, "the handshake goes to the receiver itself"); + assert.ok(rest.every((packet) => ((packet[0] ?? 0) & 0x40) !== 0), "everything else is routed"); +}); + +test("resends an unanswered command through the receiver", async () => { + const receiver = new FakeNordicMouse(0xd078, true); + receiver.drop = 1; + const status = await connect(receiver).readStatus(); + + assert.equal(status.ui?.settingsReady, undefined); + assert.equal(receiver.sent.filter((packet) => packet[0] === 0x40).length, 2, "the version query went out twice"); +}); + +test("remaps buttons and keeps a Left Click", async () => { + const mouse = new FakeNordicMouse(0xd077); + const client = connect(mouse); + + await client.setButtonMapping("Forward", "DPI Loop"); + assert.deepEqual([...lastWrite(mouse, 0x03, 0x04).slice(0, 9)], [0x03, 0, 0x85, 0x04, 3, 0x07, 0, 0x03, 0]); + assert.equal((await client.readStatus()).buttonMappings?.Forward, "DPI Loop"); + await assert.rejects(client.setButtonMapping("Left", "Right Click"), /Left Click/); + await assert.rejects(client.setButtonMapping("Wheel", "Back"), /no "Wheel" button/); +}); + +test("refuses a receiver on another protocol before writing anything", async () => { + const receiver = new FakeNordicMouse(0xd05b, true); + receiver.paired = [87, 1, 0, 0]; // a 4K receiver keeps battery and profile here + const client = connect(receiver); + + await assert.rejects(client.readStatus(), /8K Nordic/); + await assert.rejects(client.setDpi(800), /8K Nordic/); + assert.ok(receiver.sent.every((packet) => packet[0] === 0x01), "only handshakes went out"); +}); + +test("reports an unreachable mouse without its settings", async () => { + const mouse = new FakeNordicMouse(0xd077); + mouse.awake = false; + const status = await connect(mouse).readStatus(); + + assert.equal(status.name, "Keychron G3 Air"); + assert.equal(status.ui?.settingsReady, false); + assert.deepEqual(status.firmware, []); +}); + +test("codec round trips keep what Launcher does not edit", () => { + const mouse = new FakeNordicMouse(0xd077); + const sensor = keychronNordicDecodeSensor(mouse.sensor); + assert.deepEqual(sensor.reserved, [0, 1, 0]); + assert.deepEqual([...keychronNordicEncodeSensor(sensor).slice(4, 58)], [...mouse.sensor.slice(4, 58)]); + + mouse.system[22] = 1; // the unused raw value, which Launcher reads as 125 Hz + const system = keychronNordicDecodeSystem(mouse.system); + assert.deepEqual(system.gears[0].rates, [0, 0, 2, 3, 4, 5]); + assert.deepEqual(system.gears[1], { level: 3, count: 4, rates: [2, 3, 4, 5, 0, 0] }); + const separate = keychronNordicEncodeSystem(system, true); + assert.deepEqual([...separate.slice(28, 36)], [3, 0x0f, 3, 4, 5, 6, 0, 0]); + assert.equal(separate[22], 0, "raw 1 goes back as 0, as Launcher writes it"); + + assert.equal(keychronNordicButtonLabel([0x09, 0, 2, 0]), "Macro"); + assert.equal(keychronNordicButtonLabel([0x00, 0x01, 0x06, 0x00]), "Custom"); + assert.equal(keychronNordicButtonLabel([0, 0, 0, 0]), "Disabled"); +}); diff --git a/src/drivers/keychron/mouse-8k-nordic-hid.ts b/src/drivers/keychron/mouse-8k-nordic-hid.ts new file mode 100644 index 0000000..87b5944 --- /dev/null +++ b/src/drivers/keychron/mouse-8k-nordic-hid.ts @@ -0,0 +1,723 @@ +import type { MouseStatus } from "../mouse-types.ts"; +import { + KEYCHRON_4K_USAGE as USAGE, + KEYCHRON_4K_USAGE_PAGE as USAGE_PAGE, + KEYCHRON_8K_NORDIC_MICE as MICE, + KEYCHRON_8K_NORDIC_PRODUCT_IDS as PRODUCT_IDS, + KEYCHRON_VENDOR_ID, + LEMOKEY_VENDOR_ID, +} from "@openmouse/protocol/keychron"; +import { orbitalFinishPacket } from "@openmouse/protocol/orbital"; + +const PACKET_LENGTH = 64; +const QUERY_TIMEOUT_MS = 1000; +/** Launcher's receiver queue resends an unanswered command three times; wired it sends once. */ +const RECEIVER_RESENDS = 3; +const DPI_STAGE_COUNT = 5; +/** The G3 Air config (launcher.keychron.com/static/device/875876471/json/v3.json) says 100-30000; Launcher steps by 50. */ +const DPI_MIN = 100; +const DPI_MAX = 30_000; +const DPI_STEP = 50; +/** Launcher's POLLING_RATE_VALUE_SCALE. Gear table bytes are an index into it plus one, with 0 for 125 Hz. */ +const POLLING_RATES = [125, 500, 1000, 2000, 4000, 8000] as const; +const POLLING_GEARS = 6; +/** Sensor byte 11, per the G3 Air config: 0 = 0.7 mm, 1 = 1 mm, 2 = 2 mm. */ +const LOD_BY_LEVEL = { Low: 0, Medium: 1, High: 2 } as const; +/** Launcher's angle slider; the byte is signed. */ +const ANGLE_LIMIT = 90; +const DEBOUNCE_MAX_MS = 20; +/** Launcher takes 1-240 minutes and stores seconds. These are the M6 driver's choices. */ +const SLEEP_MINUTES = [1, 3, 5, 10, 15, 30, 60, 120, 240] as const; +const SLEEP_MAX_MINUTES = 240; +const PROFILE_COUNT = 5; +/** From this firmware the system block holds a second polling gear set for 2.4 GHz. */ +const SEPARATE_RATES_FIRMWARE = [1, 6, 0] as const; + +/** Bytes 0, 2 and 3 of each command; byte 2 is 0x80 | payload length. */ +const CMD = { + version: [0x00, 0x81, 0x00], + status: [0x01, 0x81, 0x01], + profile: [0x02, 0x82, 0x02], + readButtons: [0x03, 0x81, 0x01], + writeButton: [0x03, 0x85, 0x04], + readSensor: [0x04, 0x81, 0x01], + writeSensor: [0x04, 0xbc, 0x02], + readSystem: [0x04, 0x83, 0x03], + /** 24-byte payload; 32 bytes (0xa0) once the 2.4 GHz gear set exists. */ + writeSystem: [0x04, 0x98, 0x04], + save: [0x0a, 0x81, 0x01], +} as const; +type Command = (typeof CMD)[keyof typeof CMD]; + +/** Physical buttons by their index in the 0x03 table (the G3 Air config's key list). */ +const BUTTONS = [ + { name: "Left", index: 0 }, + { name: "Right", index: 1 }, + { name: "Middle", index: 2 }, + { name: "Back", index: 4 }, + { name: "Forward", index: 3 }, +] as const; +/** + * Four-byte button codes from the key enums Launcher's 8k_nordic commands use + * (its 4K set, webpack module 45710): mouse buttons are 01 00 f0-f4 00, the + * same table the GearHub driver confirmed on hardware. + */ +const BUTTON_ACTIONS: ReadonlyArray = [ + ["Left Click", [0x01, 0x00, 0xf0, 0x00]], + ["Right Click", [0x01, 0x00, 0xf1, 0x00]], + ["Middle Click", [0x01, 0x00, 0xf2, 0x00]], + ["Back", [0x01, 0x00, 0xf3, 0x00]], + ["Forward", [0x01, 0x00, 0xf4, 0x00]], + ["DPI Loop", [0x07, 0x00, 0x03, 0x00]], + ["DPI +", [0x07, 0x00, 0x01, 0x00]], + ["DPI -", [0x07, 0x00, 0x02, 0x00]], + ["Disabled", [0x00, 0x00, 0x00, 0x00]], +]; +const MACRO_CODE = 0x09; + +type LiftOff = NonNullable; +type SensorFlag = "angleSnapping" | "rippleControl" | "motionSync" | "maxSpeed"; + +export type KeychronNordicSensor = { + angleSnapping: boolean; + rippleControl: boolean; + motionSync: boolean; + /** Launcher's "20K FPS" switch (maxSpeedMode). */ + maxSpeed: boolean; + /** Bytes 7, 9 and 10 (full-speed mode, lift-down, glass mode). Launcher never edits them. */ + reserved: [number, number, number]; + lod: number; + angle: number; + activeStage: number; + stageCount: number; + /** Five X/Y pairs; only the first `stageCount` are in use. */ + stages: Array<[number, number]>; + /** Stage indicator colours, 5 x RGB. */ + colors: number[]; +}; + +export type KeychronNordicGears = { + /** The active gear: an index into `rates`, not a rate. */ + level: number; + /** How many gears the polling button cycles through. */ + count: number; + /** Six indexes into POLLING_RATES. */ + rates: number[]; +}; + +export type KeychronNordicSystem = { + sleepSeconds: number; + bleSleepSeconds: number; + debounceMs: number; + /** Bytes 10-19: quick response, wake sources, wheel reverse, key modes, RF power, BLE slot. */ + reserved: number[]; + /** The USB set (shared before firmware 1.6.0), then the 2.4 GHz set. */ + gears: [KeychronNordicGears, KeychronNordicGears]; +}; + +type Power = { state: number; percent: number; profile: number }; +type Identity = { firmware: string; separateRates: boolean }; + +/** + * Keychron Launcher's "8k_nordic" mouse protocol (Keychron G3 Air), decoded + * from Launcher (main.be11320b2a72b61b.js, webpack module 20706) and not yet + * confirmed on hardware. It shares the 4K family's collection and framing: + * 64-byte report 0, byte 63 = 0xa1 - sum, 0x40 on byte 0 to route through + * the receiver (echoed on the reply). The settings are Orbital's DMS v2 + * blocks, with Keychron's polling gear table added to the system block. + * Every offset below is a packet offset; reads and writes share them. + * + * Sensor block (read 04/81/01, write 04/bc/02): + * [4..10] angle snapping, ripple, motion sync, full speed, 20K FPS, lift-down, glass (0/1) + * [11] lift-off code; [12] sensor angle, signed + * [13] active stage; [14] enabled-stage mask (1, 3, 7, 15, 31) + * [15..34] five stages of X then Y DPI, little-endian 16-bit + * [43..57] five stage colours, RGB + * System block (read 04/83/03, write 04/98/04 or 04/a0/04): + * [4..5] sleep in seconds; [6..7] Bluetooth sleep; both little-endian + * [8] active polling gear; [9] debounce in ms + * [10..19] quick response, wake sources, wheel reverse, key modes, RF power, BLE slot + * [20] enabled-gear mask; [21..26] gear table + * [28..35] the same three fields for 2.4 GHz, from firmware 1.6.0 + * Status (01/81/01): [4] link, [6..9] vendor and product ID, [10] charge + * state (3 counts as none), [11] battery percent, [12] profile. Sent to the + * receiver unrouted, it answers itself with the paired mouse's IDs, which is + * how Launcher tells this protocol from the 4K one. + * Version (00/81/00): [9] major, [8] minor and patch nibbles. + * Buttons: 03/81/01 returns a 4-byte code per index at [4 + 4i]; 03/85/04 + * writes one ([4] index, [5..8] code). Writes are followed by a save + * (0a/81/01), as Launcher does; a profile switch (02/82/02) is not. + */ +export class Keychron8kNordicHidClient { + readonly device: HIDDevice; + /** Anything that is not a known wired mouse is one of the Ultra-Link 8K receivers. */ + private readonly receiver: boolean; + private listening = false; + private name: string | null = null; + private identity: Identity | null = null; + private waiter: { + match: (bytes: Uint8Array) => boolean; + resolve: (bytes: Uint8Array) => void; + reject: (reason: Error) => void; + } | null = null; + + private readonly onInputReport = (event: HIDInputReportEvent): void => { + if (!this.waiter) return; + const bytes = new Uint8Array(event.data.buffer.slice( + event.data.byteOffset, + event.data.byteOffset + event.data.byteLength, + )); + if (!this.waiter.match(bytes)) return; + const waiter = this.waiter; + this.waiter = null; + waiter.resolve(bytes); + }; + + constructor(device: HIDDevice) { + this.device = device; + this.receiver = !MICE.some((mouse) => mouse.productId === device.productId); + } + + static isSupported(device: HIDDevice): boolean { + return device.vendorId === KEYCHRON_VENDOR_ID + && PRODUCT_IDS.includes(device.productId) + && device.collections.some((collection) => collection.usagePage === USAGE_PAGE && collection.usage === USAGE); + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + if (!this.listening) { + this.device.addEventListener("inputreport", this.onInputReport); + this.listening = true; + } + } + + async close(): Promise { + if (this.listening) { + this.device.removeEventListener("inputreport", this.onInputReport); + this.listening = false; + } + this.waiter?.reject(new Error(`The ${this.label} was closed.`)); + this.waiter = null; + if (this.device.opened) await this.device.close(); + } + + getDpiOptions(): number[] { + return Array.from({ length: (DPI_MAX - DPI_MIN) / DPI_STEP + 1 }, (_, index) => DPI_MIN + index * DPI_STEP); + } + + getDebounceOptions(): number[] { + return Array.from({ length: DEBOUNCE_MAX_MS + 1 }, (_, ms) => ms); + } + + getSleepOptions(): number[] { + return SLEEP_MINUTES.map((minutes) => minutes * 60); + } + + /** Falls back to the name alone when the mouse does not answer, e.g. asleep behind its receiver. */ + async readStatus(): Promise { + const name = await this.readName(); + let identity: Identity; + let sensor: KeychronNordicSensor; + let system: KeychronNordicSystem; + let power: Power; + try { + identity = await this.readIdentity(); + sensor = await this.readSensor(); + system = await this.readSystem(); + power = await this.readPower(); + } catch { + return this.unreachableStatus(name); + } + const buttons = await this.readButtons().catch(() => null); + const gears = system.gears[this.gearSet(identity)]; + const pollingRateHz = POLLING_RATES[gears.rates[gears.level] ?? -1] ?? 1000; + const liftOffDistance = (Object.keys(LOD_BY_LEVEL) as Array) + .find((level) => LOD_BY_LEVEL[level] === sensor.lod) ?? null; + return { + brand: "Keychron", + name, + ui: { + family: "keychron-8k-nordic", + defaultDisplayName: name, + hideUnsupportedPollingRates: true, + forceShowBattery: true, + statusNote: "Lift-off: Low is 0.7 mm, Medium is 1 mm, High is 2 mm.", + dpiStageEditor: { + maxStages: DPI_STAGE_COUNT, + countEditable: true, + minDpi: DPI_MIN, + maxDpi: DPI_MAX, + stepDpi: DPI_STEP, + }, + }, + batteryPercent: power.percent <= 100 ? power.percent : null, + batteryState: power.state === 2 ? "Full" : power.state === 1 ? "Charging" : "Discharging", + dpi: sensor.stages[sensor.activeStage]?.[0] ?? 800, + dpiStages: sensor.stages.slice(0, sensor.stageCount).map(([x]) => x), + activeDpiStage: sensor.activeStage, + pollingRateHz, + supportedPollingRates: [...POLLING_RATES], + activeProfile: power.profile + 1, + profileCount: PROFILE_COUNT, + connectionType: this.receiver ? "Wireless" : "Wired", + connectionDetail: this.receiver ? "2.4 GHz (Keychron Ultra-Link 8K)" : "Wired USB", + liftOffDistance, + supportedLiftOffDistances: Object.keys(LOD_BY_LEVEL) as LiftOff[], + motionSync: sensor.motionSync, + angleSnapping: sensor.angleSnapping, + rippleControl: sensor.rippleControl, + performanceMode: sensor.maxSpeed, + angleTuning: sensor.angle, + debounceMs: system.debounceMs, + sleepTimeout: system.sleepSeconds > 0 ? system.sleepSeconds : null, + ...(buttons ? { + buttonMappings: Object.fromEntries(BUTTONS.map(({ name: button, index }) => [button, keychronNordicButtonLabel(buttons[index]!)])), + buttonOptions: BUTTON_ACTIONS.map(([label]) => label), + } : {}), + firmware: [identity.firmware], + }; + } + + async setDpi(dpi: number): Promise { + this.requireDpi(dpi); + return this.setDpiStageValue((await this.readSensor()).activeStage, dpi); + } + + async setDpiStageValue(stage: number, dpi: number): Promise { + this.requireDpi(dpi); + const sensor = await this.readSensor(); + this.requireStage(stage, sensor.stageCount); + // The panel edits one axis, so the stage gets X = Y; other stages keep their pairs. + const stages = sensor.stages.map((pair, index): [number, number] => (index === stage ? [dpi, dpi] : pair)); + const [x, y] = (await this.writeSensor({ ...sensor, stages })).stages[stage] ?? []; + if (x !== dpi || y !== dpi) throw new Error(`The ${this.label} kept ${x} DPI on stage ${stage + 1} instead of ${dpi} DPI.`); + return dpi; + } + + async setActiveDpiStage(stage: number): Promise { + const sensor = await this.readSensor(); + this.requireStage(stage, sensor.stageCount); + const confirmed = (await this.writeSensor({ ...sensor, activeStage: stage })).activeStage; + if (confirmed !== stage) throw new Error(`The ${this.label} kept DPI stage ${confirmed + 1}.`); + return confirmed; + } + + async setDpiStageCount(count: number): Promise { + if (!Number.isInteger(count) || count < 1 || count > DPI_STAGE_COUNT) { + throw new Error(`The ${this.label} holds between 1 and ${DPI_STAGE_COUNT} DPI stages.`); + } + const sensor = await this.readSensor(); + const activeStage = Math.min(sensor.activeStage, count - 1); + const confirmed = (await this.writeSensor({ ...sensor, stageCount: count, activeStage })).stageCount; + if (confirmed !== count) throw new Error(`The ${this.label} kept ${confirmed} DPI stages instead of ${count}.`); + return confirmed; + } + + /** + * Picks the gear that already holds the rate, or puts the rate in the + * active gear, which is what Launcher's own assistant does. + */ + async setPollingRate(rateHz: number): Promise { + const rate = POLLING_RATES.indexOf(rateHz as (typeof POLLING_RATES)[number]); + if (rate < 0) throw new Error(`The ${this.label} does not support ${rateHz} Hz.`); + const identity = await this.readIdentity(); + const system = await this.readSystem(); + const set = this.gearSet(identity); + const gears = system.gears[set]; + const gear = gears.rates.slice(0, gears.count).indexOf(rate); + const next: KeychronNordicGears = gear >= 0 + ? { ...gears, level: gear } + : { ...gears, rates: gears.rates.map((value, index) => (index === gears.level ? rate : value)) }; + const updated: KeychronNordicSystem = { ...system, gears: set === 0 ? [next, system.gears[1]] : [system.gears[0], next] }; + const confirmed = (await this.writeSystem(updated)).gears[set]; + const actual = POLLING_RATES[confirmed.rates[confirmed.level] ?? -1]; + if (actual !== rateHz) throw new Error(`The ${this.label} kept ${actual ?? "an unknown rate"} Hz instead of ${rateHz} Hz.`); + return actual; + } + + async setLiftOffDistance(lod: LiftOff): Promise { + const code = LOD_BY_LEVEL[lod as keyof typeof LOD_BY_LEVEL]; + if (code === undefined) throw new Error(`The ${this.label} has no ${lod} lift-off distance.`); + const confirmed = (await this.writeSensor({ ...(await this.readSensor()), lod: code })).lod; + if (confirmed !== code) throw new Error(`The ${this.label} kept lift-off code ${confirmed}.`); + return lod; + } + + async setMotionSync(enabled: boolean): Promise { + return this.writeFlag("motionSync", enabled); + } + + async setAngleSnapping(enabled: boolean): Promise { + return this.writeFlag("angleSnapping", enabled); + } + + async setRippleControl(enabled: boolean): Promise { + return this.writeFlag("rippleControl", enabled); + } + + async setPerformanceMode(enabled: boolean): Promise { + return this.writeFlag("maxSpeed", enabled); + } + + async setAngleTuning(degrees: number): Promise { + if (!Number.isInteger(degrees) || Math.abs(degrees) > ANGLE_LIMIT) { + throw new Error(`The ${this.label} angle must be a whole number between -${ANGLE_LIMIT} and ${ANGLE_LIMIT} degrees.`); + } + const confirmed = (await this.writeSensor({ ...(await this.readSensor()), angle: degrees })).angle; + if (confirmed !== degrees) throw new Error(`The ${this.label} kept a ${confirmed}° sensor angle instead of ${degrees}°.`); + return confirmed; + } + + async setDebounceTime(debounceMs: number): Promise { + if (!Number.isInteger(debounceMs) || debounceMs < 0 || debounceMs > DEBOUNCE_MAX_MS) { + throw new Error(`The ${this.label} debounce must be between 0 and ${DEBOUNCE_MAX_MS} ms.`); + } + const confirmed = (await this.writeSystem({ ...(await this.readSystem()), debounceMs })).debounceMs; + if (confirmed !== debounceMs) throw new Error(`The ${this.label} kept ${confirmed} ms debounce instead of ${debounceMs} ms.`); + return confirmed; + } + + /** Launcher writes the same timeout to the 2.4 GHz and Bluetooth fields. */ + async setSleepTimeout(seconds: number): Promise { + const minutes = seconds / 60; + if (!Number.isInteger(minutes) || minutes < 1 || minutes > SLEEP_MAX_MINUTES) { + throw new Error(`The ${this.label} sleeps after 1 to ${SLEEP_MAX_MINUTES} whole minutes.`); + } + const system = await this.readSystem(); + const confirmed = (await this.writeSystem({ ...system, sleepSeconds: seconds, bleSleepSeconds: seconds })).sleepSeconds; + if (confirmed !== seconds) throw new Error(`The ${this.label} kept a ${confirmed} s sleep timeout instead of ${seconds} s.`); + return confirmed; + } + + /** Switch the onboard profile (1-based, as the panel numbers them). */ + async setProfile(profile: number): Promise { + if (!Number.isInteger(profile) || profile < 1 || profile > PROFILE_COUNT) { + throw new Error(`The ${this.label} profile must be between 1 and ${PROFILE_COUNT}.`); + } + await this.readIdentity(); + const packet = commandPacket(CMD.profile); + packet[4] = profile - 1; + await this.request(packet, replyTo(CMD.profile)); + // The ack can arrive before the switch lands, so wait for the status report to show it. + let current = -1; + for (let attempt = 0; attempt < 3; attempt += 1) { + current = (await this.readPower()).profile; + if (current === profile - 1) return profile; + await new Promise((resolve) => setTimeout(resolve, 150)); + } + throw new Error(`The ${this.label} stayed on profile ${current + 1}.`); + } + + async setButtonMapping(button: string, action: string): Promise { + const slot = BUTTONS.find((entry) => entry.name === button); + if (!slot) throw new Error(`The ${this.label} has no "${button}" button.`); + const code = BUTTON_ACTIONS.find(([label]) => label === action)?.[1]; + if (!code) throw new Error(`Unknown button action "${action}".`); + const codes = await this.readButtons(); + if (keychronNordicButtonLabel(codes[slot.index]!) === action) return; + codes[slot.index] = [...code]; + if (!BUTTONS.some(({ index }) => keychronNordicButtonLabel(codes[index]!) === "Left Click")) { + throw new Error("Keep at least one button as Left Click."); + } + const packet = commandPacket(CMD.writeButton); + packet[4] = slot.index; + packet.set(code, 5); + await this.request(packet, replyTo(CMD.writeButton)); + await this.save(); + const confirmed = keychronNordicButtonLabel((await this.readButtons())[slot.index]!); + if (confirmed !== action) throw new Error(`The ${this.label} kept ${confirmed} on ${button} instead of ${action}.`); + } + + private get label(): string { + return this.name ?? "Keychron mouse"; + } + + /** Which gear set this connection uses: 2.4 GHz has its own from firmware 1.6.0. */ + private gearSet(identity: Identity): 0 | 1 { + return this.receiver && identity.separateRates ? 1 : 0; + } + + private unreachableStatus(name: string): MouseStatus { + return { + brand: "Keychron", + name, + ui: { + family: "keychron-8k-nordic", + defaultDisplayName: name, + settingsReady: false, + statusNote: this.receiver + ? "The mouse did not answer through the receiver. Wake it and reconnect." + : "The mouse did not answer its settings reads. Reconnect it and try again.", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: 0, + pollingRateHz: 0, + activeProfile: null, + connectionType: this.receiver ? "Wireless" : "Wired", + liftOffDistance: null, + firmware: this.identity ? [this.identity.firmware] : [], + }; + } + + private async writeFlag(flag: SensorFlag, enabled: boolean): Promise { + const confirmed = (await this.writeSensor({ ...(await this.readSensor()), [flag]: enabled }))[flag]; + if (confirmed !== enabled) throw new Error(`The ${this.label} kept ${flag} ${confirmed ? "on" : "off"}.`); + return confirmed; + } + + private async writeSensor(next: KeychronNordicSensor): Promise { + await this.request(keychronNordicEncodeSensor(next), replyTo(CMD.writeSensor)); + await this.save(); + return await this.readSensor(); + } + + private async writeSystem(next: KeychronNordicSystem): Promise { + const identity = await this.readIdentity(); + await this.request(keychronNordicEncodeSystem(next, identity.separateRates), replyTo(CMD.writeSystem)); + await this.save(); + return await this.readSystem(); + } + + private async save(): Promise { + await this.request(commandPacket(CMD.save), (bytes) => ((bytes[0] ?? 0) & 0xbf) === CMD.save[0]); + } + + private requireDpi(dpi: number): void { + if (!Number.isInteger(dpi) || dpi < DPI_MIN || dpi > DPI_MAX || dpi % DPI_STEP !== 0) { + throw new Error(`The ${this.label} DPI must be a multiple of ${DPI_STEP} between ${DPI_MIN} and ${DPI_MAX}.`); + } + } + + private requireStage(stage: number, stageCount: number): void { + if (!Number.isInteger(stage) || stage < 0 || stage >= stageCount) { + throw new Error(`DPI stage must be between 1 and ${stageCount}.`); + } + } + + /** + * Read once per connection. A receiver answers this handshake itself, so + * it goes out unrouted; bytes 6-7 must carry Keychron's or Lemokey's vendor + * ID (Launcher's test for this protocol) and bytes 8-9 name the paired mouse. + * Every read and write passes through here first, so a receiver on another + * protocol is refused before anything is written. + */ + private async readName(): Promise { + await this.open(); + if (this.name) return this.name; + let productId = this.device.productId; + if (this.receiver) { + // Strict match: a routed status report from the mouse (0x41) must not stand in for the receiver's answer. + const handshake = await this.request(commandPacket(CMD.status), (bytes) => bytes[0] === CMD.status[0] && bytes[3] === CMD.status[2], false); + const vendorId = readU16(handshake, 6); + if (vendorId !== KEYCHRON_VENDOR_ID && vendorId !== LEMOKEY_VENDOR_ID) { + throw new Error("This Keychron receiver did not report a paired 8K Nordic mouse."); + } + productId = readU16(handshake, 8); + } + this.name = MICE.find((mouse) => mouse.productId === productId)?.name ?? "Keychron 8K mouse"; + return this.name; + } + + /** The mouse's firmware (routed), which decides the system block layout. */ + private async readIdentity(): Promise { + await this.readName(); + if (this.identity) return this.identity; + const reply = await this.request(commandPacket(CMD.version), replyTo(CMD.version)); + const version = [reply[9] ?? 0, (reply[8] ?? 0) >> 4, (reply[8] ?? 0) & 0x0f]; + const newer = version.findIndex((part, index) => part !== SEPARATE_RATES_FIRMWARE[index]); + this.identity = { + firmware: `v${version.join(".")}`, + separateRates: newer < 0 || version[newer]! > SEPARATE_RATES_FIRMWARE[newer]!, + }; + return this.identity; + } + + private async readSensor(): Promise { + await this.readName(); + return keychronNordicDecodeSensor(await this.request(commandPacket(CMD.readSensor), replyTo(CMD.readSensor))); + } + + private async readSystem(): Promise { + await this.readName(); + return keychronNordicDecodeSystem(await this.request(commandPacket(CMD.readSystem), replyTo(CMD.readSystem))); + } + + private async readPower(): Promise { + await this.readName(); + const bytes = await this.request(commandPacket(CMD.status), replyTo(CMD.status)); + const state = bytes[10] ?? 0; + return { + state: state === 3 ? 0 : state, + percent: bytes[11] ?? 0, + profile: Math.min(bytes[12] ?? 0, PROFILE_COUNT - 1), + }; + } + + /** Four-byte codes for every table index up to the last button. */ + private async readButtons(): Promise { + await this.readName(); + const bytes = await this.request(commandPacket(CMD.readButtons), replyTo(CMD.readButtons)); + const last = Math.max(...BUTTONS.map(({ index }) => index)); + return Array.from({ length: last + 1 }, (_, index) => Array.from(bytes.slice(4 + index * 4, 8 + index * 4))); + } + + /** Routes through the receiver unless told not to, fills in the checksum, and resends like Launcher's queue. */ + private async request(packet: Uint8Array, match: (bytes: Uint8Array) => boolean, route = true): Promise { + const routed = this.receiver && route; + const finished = new Uint8Array(orbitalFinishPacket(packet, routed)); + for (let resends = routed ? RECEIVER_RESENDS : 0; ; resends -= 1) { + const reply = await this.exchange(finished, match); + if (reply) return reply; + if (resends === 0) { + throw new Error(routed + ? `The ${this.label} did not answer through the receiver. Wake the mouse and try again.` + : `The ${this.label} did not answer command 0x${packet[0]?.toString(16)}.`); + } + } + } + + /** One send; resolves null when no matching reply arrives in time. */ + private async exchange(packet: Uint8Array, match: (bytes: Uint8Array) => boolean): Promise { + if (this.waiter) throw new Error(`Another ${this.label} request is already in progress.`); + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.waiter = null; + resolve(null); + }, QUERY_TIMEOUT_MS); + this.waiter = { + match, + resolve: (bytes) => { + clearTimeout(timeout); + resolve(bytes); + }, + reject: (reason) => { + clearTimeout(timeout); + reject(reason); + }, + }; + this.device.sendReport(0, packet).catch((error: unknown) => { + this.waiter?.reject(new Error(`Chrome could not write the ${this.label} HID report. ${error instanceof Error ? error.message : String(error)}`)); + this.waiter = null; + }); + }); + } +} + +export function keychronNordicDecodeSensor(bytes: Uint8Array): KeychronNordicSensor { + const stageCount = Math.min(countBits(bytes[14] ?? 0) || DPI_STAGE_COUNT, DPI_STAGE_COUNT); + const angle = bytes[12] ?? 0; + return { + angleSnapping: Boolean(bytes[4]), + rippleControl: Boolean(bytes[5]), + motionSync: Boolean(bytes[6]), + maxSpeed: Boolean(bytes[8]), + reserved: [bytes[7] ?? 0, bytes[9] ?? 0, bytes[10] ?? 0], + lod: bytes[11] ?? 0, + // Launcher reads anything above its +90 limit as negative. + angle: angle > ANGLE_LIMIT ? angle - 256 : angle, + activeStage: Math.min(bytes[13] ?? 0, stageCount - 1), + stageCount, + stages: Array.from({ length: DPI_STAGE_COUNT }, (_, stage): [number, number] => [ + readU16(bytes, 15 + stage * 4), + readU16(bytes, 17 + stage * 4), + ]), + colors: Array.from(bytes.slice(43, 58)), + }; +} + +/** The sensor block exactly as Launcher builds it: every field it knows, zeros elsewhere. */ +export function keychronNordicEncodeSensor(sensor: KeychronNordicSensor): Uint8Array { + const packet = commandPacket(CMD.writeSensor); + packet[4] = Number(sensor.angleSnapping); + packet[5] = Number(sensor.rippleControl); + packet[6] = Number(sensor.motionSync); + packet[7] = sensor.reserved[0]; + packet[8] = Number(sensor.maxSpeed); + packet[9] = sensor.reserved[1]; + packet[10] = sensor.reserved[2]; + packet[11] = sensor.lod; + packet[12] = sensor.angle & 0xff; + packet[13] = sensor.activeStage; + packet[14] = (1 << sensor.stageCount) - 1; + sensor.stages.forEach(([x, y], stage) => { + writeU16(packet, 15 + stage * 4, x); + writeU16(packet, 17 + stage * 4, y); + }); + packet.set(sensor.colors.slice(0, 15), 43); + return packet; +} + +export function keychronNordicDecodeSystem(bytes: Uint8Array): KeychronNordicSystem { + const gears = (levelAt: number, maskAt: number, tableAt: number): KeychronNordicGears => { + const count = Math.min(countBits(bytes[maskAt] ?? 0) || POLLING_GEARS, POLLING_GEARS); + return { + level: Math.min(bytes[levelAt] ?? 0, count - 1), + count, + // Stored as rate index + 1; 0 (and the unused 1) mean 125 Hz. + rates: Array.from(bytes.slice(tableAt, tableAt + POLLING_GEARS), (raw) => (raw > 0 ? raw - 1 : 0)), + }; + }; + return { + sleepSeconds: readU16(bytes, 4), + bleSleepSeconds: readU16(bytes, 6), + debounceMs: bytes[9] ?? 0, + reserved: Array.from(bytes.slice(10, 20)), + gears: [gears(8, 20, 21), gears(28, 29, 30)], + }; +} + +/** Launcher sends the 2.4 GHz gear set only to firmware that has one. */ +export function keychronNordicEncodeSystem(system: KeychronNordicSystem, separateRates: boolean): Uint8Array { + const packet = commandPacket(CMD.writeSystem); + if (separateRates) packet[2] = 0x80 | 32; + writeU16(packet, 4, system.sleepSeconds); + writeU16(packet, 6, system.bleSleepSeconds); + packet[9] = system.debounceMs; + packet.set(system.reserved.slice(0, 10), 10); + const writeGears = (gears: KeychronNordicGears, levelAt: number, maskAt: number, tableAt: number): void => { + packet[levelAt] = gears.level; + packet[maskAt] = (1 << gears.count) - 1; + gears.rates.forEach((rate, gear) => { + packet[tableAt + gear] = rate > 0 ? rate + 1 : 0; + }); + }; + writeGears(system.gears[0], 8, 20, 21); + if (separateRates) writeGears(system.gears[1], 28, 29, 30); + return packet; +} + +/** "Macro" and "Custom" cover codes the remapper cannot offer (macros, keys, media). */ +export function keychronNordicButtonLabel(code: readonly number[]): string { + if (code[0] === MACRO_CODE) return "Macro"; + return BUTTON_ACTIONS.find(([, bytes]) => bytes.every((byte, index) => byte === code[index]))?.[0] ?? "Custom"; +} + +function commandPacket(command: Command): Uint8Array { + const packet = new Uint8Array(PACKET_LENGTH); + packet[0] = command[0]; + packet[2] = command[1]; + packet[3] = command[2]; + return packet; +} + +/** Replies echo bytes 0 and 3; the receiver adds 0x40 to byte 0. */ +function replyTo(command: Command): (bytes: Uint8Array) => boolean { + return (bytes) => ((bytes[0] ?? 0) & 0xbf) === command[0] && bytes[3] === command[2]; +} + +function countBits(value: number): number { + let count = 0; + for (let rest = value; rest; rest >>= 1) count += rest & 1; + return count; +} + +function readU16(bytes: Uint8Array, offset: number): number { + return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8); +} + +function writeU16(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >> 8) & 0xff; +} diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index c07a94e..ed3c4da 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -9,6 +9,7 @@ import { SUPPORTED_HID_FILTERS, VENDOR_ID } from "./vendors.ts"; import { LAMZU_ATLANTIS_PRODUCTS, LAMZU_PRODUCTS } from "@openmouse/protocol/lamzu"; import { ORBITAL_DEVICES } from "@openmouse/protocol/orbital"; import { MCHOSE_V3_PRODUCT_IDS } from "@openmouse/protocol/mchose"; +import { KEYCHRON_8K_NORDIC_PRODUCT_IDS } from "@openmouse/protocol/keychron"; import { DELUX_M600_PRO_WIRED_PID, DELUX_M800_MINI_WIRELESS_PID, @@ -86,6 +87,9 @@ function candidateProductIds(): number[] { // The MCHOSE V3 driver matches on an id allowlist and shares its usage // page with the V2, so the probe needs a real one to reach it at all. ...MCHOSE_V3_PRODUCT_IDS, + // The Keychron 8K Nordic driver claims its ids out of the 4K family's + // shared collection, so the probe needs them to reach it. + ...KEYCHRON_8K_NORDIC_PRODUCT_IDS, // Claimed by id alone and defined outside src/drivers, so the source // scan below would not find it. DELUX_M600_PRO_WIRED_PID, diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index b18abc1..dd70cbe 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -9,6 +9,7 @@ import { GearHubHidClient } from "./gearhub/hid.ts"; import { eggWeCreate, eggWeIsSupported, eggWeSupportScore, isEggWeClient, type EggWeHidClient } from "./endgame/egg-we-control.ts"; import { FinalmouseHidClient } from "./finalmouse/hid.ts"; import { Keychron4kHidClient } from "./keychron/mouse-4k-hid.ts"; +import { Keychron8kNordicHidClient } from "./keychron/mouse-8k-nordic-hid.ts"; import { KeychronM6HidClient } from "./keychron/m6-hid.ts"; import { KeychronNapeHidClient } from "./keychron/nape-hid.ts"; import { LamzuAtlantisHidClient } from "./lamzu-atlantis/hid.ts"; @@ -68,7 +69,7 @@ import { KyuProMx1Client } from "./ryunix/kyu-pro-mx1-hid.ts"; import { BytechHidClient } from "./bytech/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | KeychronM6HidClient | Keychron4kHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | KeychronM6HidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient; export interface DeviceDriver { brand: string; @@ -118,6 +119,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "Razer", supports: (device) => RazerViperV4ProHidClient.isSupported(device), create: (device) => new RazerViperV4ProHidClient(device), score: () => 7 }, { brand: "Keychron", supports: (device) => KeychronM6HidClient.isSupported(device), create: (device) => new KeychronM6HidClient(device), score: () => 7 }, { brand: "Keychron", supports: (device) => Keychron4kHidClient.isSupported(device), create: (device) => new Keychron4kHidClient(device), score: () => 7 }, + { brand: "Keychron", supports: (device) => Keychron8kNordicHidClient.isSupported(device), create: (device) => new Keychron8kNordicHidClient(device), score: () => 7 }, { brand: "Keychron", supports: (device) => KeychronNapeHidClient.isSupported(device), create: (device) => new KeychronNapeHidClient(device), score: () => 6 }, // Ahead of Fantech: GearHub-V5 mice (Lingbao M5 Pro, Attack Shark R2, …) // answer on the same VID 0x3151, usage page 0xFFFF, usage 0x02 interface diff --git a/src/keychron/index.ts b/src/keychron/index.ts index a333b35..2eb7794 100644 --- a/src/keychron/index.ts +++ b/src/keychron/index.ts @@ -37,6 +37,27 @@ export const KEYCHRON_4K_MICE: ReadonlyArray<{ productId: number; modelId: numbe { productId: 0xd041, modelId: 0x0622, name: "Keychron M3 Mini 4K" }, { productId: 0xd045, modelId: 0x0623, name: "Keychron M2 4K" }, ]; +/** + * Mice on Launcher's "8k_nordic" protocol: the same 0xff0a collection and + * framing as the 4K family, with Orbital's DMS v2 settings layout. The G3 Air + * config forces that protocol by name; Launcher's product list describes the + * mouse as "54L" (nRF54L15). + */ +export const KEYCHRON_8K_NORDIC_MICE: ReadonlyArray<{ productId: number; name: string }> = [ + { productId: 0xd077, name: "Keychron G3 Air" }, +]; +/** + * Ultra-Link 8K receivers from Keychron's product list. 0xd05b is listed with + * an nRF54LM20A, the receiver half of the 54L platform; 0xd078 was added next + * to the G3 Air. Neither pairing is confirmed on hardware. + */ +export const KEYCHRON_8K_NORDIC_RECEIVER_PRODUCT_IDS: readonly number[] = [0xd05b, 0xd078]; +export const KEYCHRON_8K_NORDIC_PRODUCT_IDS: readonly number[] = [ + ...KEYCHRON_8K_NORDIC_MICE.map((mouse) => mouse.productId), + ...KEYCHRON_8K_NORDIC_RECEIVER_PRODUCT_IDS, +]; +/** Lemokey, Keychron's gaming brand; an 8K Nordic handshake carries either vendor ID. */ +export const LEMOKEY_VENDOR_ID = 0x362d; export const KEYCHRON_PRODUCTS = new Map([ [0x0440, { name: "Nape Pro" }], [0xd026, { name: "Keychron Link-KM", receiver: true }],