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 }],