From 4cfcb4f80dd578de16bed641739173305cb1d03c Mon Sep 17 00:00:00 2001 From: ydw1904 Date: Sun, 23 Aug 2026 16:23:35 +0800 Subject: [PATCH] feat(keychron): add M6 wired support Assisted-by: Codex --- src/drivers/keychron/m6-hid.test.ts | 129 ++++++++++++ src/drivers/keychron/m6-hid.ts | 304 ++++++++++++++++++++++++++++ src/drivers/registry.test.ts | 4 +- src/drivers/registry.ts | 4 +- src/drivers/vendors.ts | 5 + src/keychron/index.ts | 13 +- 6 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 src/drivers/keychron/m6-hid.test.ts create mode 100644 src/drivers/keychron/m6-hid.ts diff --git a/src/drivers/keychron/m6-hid.test.ts b/src/drivers/keychron/m6-hid.test.ts new file mode 100644 index 0000000..784b4fa --- /dev/null +++ b/src/drivers/keychron/m6-hid.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +Object.assign(globalThis, { window: globalThis }); + +const { KeychronM6HidClient } = await import("./m6-hid.ts"); +const { VENDOR_ID } = await import("../vendors.ts"); + +class FakeM6Device { + readonly vendorId = VENDOR_ID.keychron; + readonly productId = 0xd060; + readonly productName = "Keychron M6"; + readonly collections: HIDCollectionInfo[] = [{ + usagePage: 0xffc1, + usage: 0x01, + children: [], + featureReports: [], + inputReports: [{ reportId: 0xb4, items: [{ reportCount: 63, reportSize: 8 }] }], + outputReports: [{ reportId: 0xb3, items: [{ reportCount: 63, reportSize: 8 }] }], + }] as unknown as HIDCollectionInfo[]; + opened = false; + readonly sent: Array<{ reportId: number; packet: Uint8Array }> = []; + private listeners = new Map void>(); + private activeDpiStage = 0; + private dpiStages = [400, 800, 1600, 3200, 5000]; + private pollingIndex = 1; + private readonly pollingTable = [0, 1, 2]; + + addEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.set(type, listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + if (this.listeners.get(type) === listener) this.listeners.delete(type); + } + + async open(): Promise { this.opened = true; } + async close(): Promise { this.opened = false; } + + async sendReport(reportId: number, data: BufferSource): Promise { + const packet = data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + this.sent.push({ reportId, packet: packet.slice() }); + if (reportId === 0xb3 && packet[0] === 6) { + this.emit(0xb4, this.statusPacket()); + return; + } + if (reportId === 0xb5 && packet[0] === 0x40) { + this.activeDpiStage = packet[1] ?? this.activeDpiStage; + this.dpiStages = this.dpiStages.map((_, index) => (packet[4 + index * 2] ?? 0) | ((packet[5 + index * 2] ?? 0) << 8)); + this.emit(0xb6, new Uint8Array([0xe4])); + return; + } + if (reportId === 0xb5 && packet[0] === 0x41) { + this.pollingIndex = packet[1] ?? this.pollingIndex; + this.emit(0xb6, new Uint8Array([0xe4])); + } + } + + private statusPacket(): Uint8Array { + const packet = new Uint8Array(63); + packet[0] = 6; + packet[1] = this.activeDpiStage; + packet[2] = this.pollingIndex << 4; + this.dpiStages.forEach((dpi, index) => { + packet[5 + index * 2] = dpi & 0xff; + packet[6 + index * 2] = (dpi >> 8) & 0xff; + }); + packet[19] = 0x80 | 99; + packet.set(this.pollingTable, 43); + packet[49] = this.pollingTable.length; + packet[50] = this.dpiStages.length; + return packet; + } + + private emit(reportId: number, reply: Uint8Array): void { + queueMicrotask(() => this.listeners.get("inputreport")?.({ + reportId, + data: new DataView(reply.buffer, reply.byteOffset, reply.byteLength), + })); + } +} + +function device(productId = 0xd060, usagePage = 0xffc1): HIDDevice { + return { + vendorId: VENDOR_ID.keychron, + productId, + productName: "Keychron M6", + collections: [{ + usagePage, + usage: 0x01, + children: [], + featureReports: [], + inputReports: [{ reportId: 0xb4, items: [{ reportCount: 63, reportSize: 8 }] }], + outputReports: [{ reportId: 0xb3, items: [{ reportCount: 63, reportSize: 8 }] }], + }], + } as unknown as HIDDevice; +} + +test("Keychron M6 is limited to its verified 0xffc1 control interface", () => { + assert.equal(KeychronM6HidClient.isSupported(device()), true); + assert.equal(KeychronM6HidClient.isSupported(device(0xd060, 0xff60)), false); + assert.equal(KeychronM6HidClient.isSupported(device(0xd029)), true); + assert.equal(KeychronM6HidClient.isSupported({ ...device(), productId: 0xd061 } as HIDDevice), false); +}); + +test("reads the M6 8k-protocol status report", async () => { + const status = await new KeychronM6HidClient(new FakeM6Device() as unknown as HIDDevice).readStatus(); + assert.equal(status.name, "Keychron M6"); + assert.equal(status.dpi, 400); + assert.deepEqual(status.dpiStages, [400, 800, 1600, 3200, 5000]); + assert.equal(status.activeDpiStage, 0); + assert.equal(status.pollingRateHz, 500); + assert.deepEqual(status.supportedPollingRates, [125, 500, 1000]); + assert.equal(status.batteryPercent, 99); + assert.equal(status.batteryState, "Charging"); + assert.equal(status.ui?.family, "keychron-m6"); +}); + +test("writes M6 DPI and polling settings, then reads them back", async () => { + const fake = new FakeM6Device(); + const client = new KeychronM6HidClient(fake as unknown as HIDDevice); + assert.equal(await client.setDpi(1200), 1200); + assert.equal(await client.setActiveDpiStage(2), 2); + assert.equal(await client.setPollingRate(1000), 1000); + assert.ok(fake.sent.some(({ reportId, packet }) => reportId === 0xb5 && packet[0] === 0x40)); + assert.ok(fake.sent.some(({ reportId, packet }) => reportId === 0xb5 && packet[0] === 0x41)); +}); diff --git a/src/drivers/keychron/m6-hid.ts b/src/drivers/keychron/m6-hid.ts new file mode 100644 index 0000000..da18c47 --- /dev/null +++ b/src/drivers/keychron/m6-hid.ts @@ -0,0 +1,304 @@ +import type { MouseStatus } from "../mouse-types.ts"; +import { + KEYCHRON_M6_COMMAND_REPORT_ID as COMMAND_REPORT_ID, + KEYCHRON_M6_COMMAND_RESPONSE_REPORT_ID as COMMAND_RESPONSE_REPORT_ID, + KEYCHRON_M6_PRODUCT_ID as PRODUCT_ID, + KEYCHRON_M6_RECEIVER_PRODUCT_ID as RECEIVER_PRODUCT_ID, + KEYCHRON_M6_SETTINGS_REPORT_ID as SETTINGS_REPORT_ID, + KEYCHRON_M6_SETTINGS_RESPONSE_REPORT_ID as SETTINGS_RESPONSE_REPORT_ID, + KEYCHRON_M6_STATUS_COMMAND as STATUS_COMMAND, + KEYCHRON_M6_STATUS_PACKET_LENGTH as PACKET_LENGTH, + KEYCHRON_M6_USAGE as USAGE, + KEYCHRON_M6_USAGE_PAGE as USAGE_PAGE, + KEYCHRON_VENDOR_ID, +} from "@openmouse/protocol/keychron"; + +const QUERY_TIMEOUT_MS = 1200; +const DPI_STAGE_COUNT = 5; +const DPI_MIN = 100; +const DPI_MAX = 26_000; +const DPI_STEP = 50; +const POLLING_RATES = [125, 500, 1000] as const; + +type M6Settings = { + activeDpiStage: number; + dpiStages: number[]; + pollingTable: number[]; + pollingIndex: number; + batteryPercent: number; + charging: boolean; +}; + +/** + * Keychron M6 wired client. The 0xffc1 collection uses 63-byte reports, + * unlike the VIA raw-HID protocol used by the Nape Pro. + */ +export class KeychronM6HidClient { + readonly device: HIDDevice; + private openedListener = false; + private responseWaiter: { + reportId: number; + match: (bytes: Uint8Array) => boolean; + resolve: (bytes: Uint8Array) => void; + reject: (reason: Error) => void; + } | null = null; + + private readonly onInputReport = (event: HIDInputReportEvent): void => { + if (event.reportId !== this.responseWaiter?.reportId) return; + const bytes = new Uint8Array(event.data.buffer.slice( + event.data.byteOffset, + event.data.byteOffset + event.data.byteLength, + )); + if (!this.responseWaiter.match(bytes)) return; + const waiter = this.responseWaiter; + this.responseWaiter = null; + waiter.resolve(bytes); + }; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + return device.vendorId === KEYCHRON_VENDOR_ID + && (device.productId === PRODUCT_ID || device.productId === RECEIVER_PRODUCT_ID) + && device.collections.some((collection) => + collection.usagePage === USAGE_PAGE + && collection.usage === USAGE + && collection.outputReports.some((report) => report.reportId === COMMAND_REPORT_ID) + && collection.inputReports.some((report) => report.reportId === COMMAND_RESPONSE_REPORT_ID)); + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + if (!this.openedListener) { + this.device.addEventListener("inputreport", this.onInputReport); + this.openedListener = true; + } + } + + async close(): Promise { + if (this.openedListener) { + this.device.removeEventListener("inputreport", this.onInputReport); + this.openedListener = false; + } + this.responseWaiter?.reject(new Error("The Keychron M6 device was closed.")); + this.responseWaiter = 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); + } + + readonly canDisableSleep = false; + + async readStatus(): Promise { + await this.open(); + const settings = this.parseStatus(await this.queryStatus()); + const activeDpiStage = Math.min(settings.activeDpiStage, settings.dpiStages.length - 1); + const dpi = settings.dpiStages[activeDpiStage] ?? settings.dpiStages[0] ?? 800; + const pollingRateHz = POLLING_RATES[settings.pollingTable[settings.pollingIndex] ?? 2] ?? 1000; + const supportedPollingRates = settings.pollingTable + .map((value) => POLLING_RATES[value]) + .filter((value) => value !== undefined) as number[]; + + return { + brand: "Keychron", + name: "Keychron M6", + ui: { + family: "keychron-m6", + defaultDisplayName: "Keychron M6", + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + hideSleepCard: true, + forceShowBattery: true, + dpiStageEditor: { + maxStages: DPI_STAGE_COUNT, + countEditable: false, + minDpi: DPI_MIN, + maxDpi: DPI_MAX, + stepDpi: DPI_STEP, + }, + }, + batteryPercent: settings.batteryPercent <= 100 ? settings.batteryPercent : null, + batteryState: settings.charging ? "Charging" : "Discharging", + dpi, + dpiStages: settings.dpiStages, + activeDpiStage, + pollingRateHz, + supportedPollingRates: supportedPollingRates.length ? supportedPollingRates : [pollingRateHz], + activeProfile: null, + connectionType: this.device.productId === RECEIVER_PRODUCT_ID ? "Wireless" : "Wired", + connectionDetail: this.device.productId === RECEIVER_PRODUCT_ID + ? "2.4 GHz (Keychron Link-KM)" + : "Wired USB", + liftOffDistance: null, + supportedLiftOffDistances: [], + firmware: ["Firmware version not yet decoded"], + }; + } + + async setDpi(dpi: number): Promise { + if (!Number.isInteger(dpi) || dpi < DPI_MIN || dpi > DPI_MAX || dpi % DPI_STEP !== 0) { + throw new Error(`Keychron M6 DPI must be a multiple of ${DPI_STEP} between ${DPI_MIN} and ${DPI_MAX}.`); + } + await this.open(); + const settings = this.parseStatus(await this.queryStatus()); + const active = Math.min(settings.activeDpiStage, DPI_STAGE_COUNT - 1); + settings.dpiStages[active] = dpi; + await this.writeSettings(this.dpiSettingsPacket(settings)); + const confirmed = this.parseStatus(await this.queryStatus()).dpiStages[active]; + if (confirmed !== dpi) throw new Error(`The Keychron M6 kept ${confirmed} DPI instead of ${dpi} DPI.`); + return confirmed; + } + + async setActiveDpiStage(stage: number): Promise { + if (!Number.isInteger(stage) || stage < 0 || stage >= DPI_STAGE_COUNT) { + throw new Error(`DPI stage must be between 1 and ${DPI_STAGE_COUNT}.`); + } + await this.open(); + const settings = this.parseStatus(await this.queryStatus()); + settings.activeDpiStage = stage; + await this.writeSettings(this.dpiSettingsPacket(settings)); + const confirmed = this.parseStatus(await this.queryStatus()).activeDpiStage; + if (confirmed !== stage) throw new Error(`The Keychron M6 kept DPI stage ${confirmed + 1}.`); + return confirmed; + } + + async setPollingRate(rateHz: number): Promise { + await this.open(); + const settings = this.parseStatus(await this.queryStatus()); + const supported = settings.pollingTable + .map((value) => POLLING_RATES[value]) + .filter((value) => value !== undefined) as number[]; + if (!supported.includes(rateHz)) throw new Error(`The Keychron M6 does not support ${rateHz} Hz on this connection.`); + const pollingIndex = settings.pollingTable.findIndex((value) => POLLING_RATES[value] === rateHz); + if (pollingIndex < 0) throw new Error(`The Keychron M6 has no polling-rate entry for ${rateHz} Hz.`); + settings.pollingIndex = pollingIndex; + await this.writeSettings(this.pollingSettingsPacket(settings)); + const confirmed = this.parseStatus(await this.queryStatus()); + const actual = POLLING_RATES[confirmed.pollingTable[confirmed.pollingIndex] ?? 2] ?? 1000; + if (actual !== rateHz) throw new Error(`The Keychron M6 kept ${actual} Hz instead of ${rateHz} Hz.`); + return actual; + } + + async setLiftOffDistance(_lod: NonNullable): Promise { + throw new Error("Lift-off distance writes are not yet validated for the Keychron M6."); + } + + async setMotionSync(_enabled: boolean): Promise { + throw new Error("Motion Sync writes are not yet validated for the Keychron M6."); + } + + async setAngleSnapping(_enabled: boolean): Promise { + throw new Error("Angle-snapping writes are not yet validated for the Keychron M6."); + } + + async setRippleControl(_enabled: boolean): Promise { + throw new Error("Ripple-control writes are not yet validated for the Keychron M6."); + } + + async setDebounceTime(_debounceMs: number): Promise { + throw new Error("Debounce writes are not yet validated for the Keychron M6."); + } + + private parseStatus(bytes: Uint8Array): M6Settings { + if (bytes.length < 51 || bytes[0] !== STATUS_COMMAND) { + throw new Error("The Keychron M6 returned an invalid status report."); + } + const dpiStages = Array.from({ length: DPI_STAGE_COUNT }, (_, index) => { + const offset = 5 + index * 2; + return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8); + }); + const pollingCount = Math.min(bytes[49] || 6, 6); + return { + activeDpiStage: bytes[1] ?? 0, + dpiStages, + pollingTable: Array.from(bytes.slice(43, 43 + pollingCount)), + pollingIndex: ((bytes[2] ?? 0) >> 4) & 0x0f, + batteryPercent: (bytes[19] ?? 0) & 0x7f, + charging: ((bytes[19] ?? 0) & 0x80) !== 0, + }; + } + + private dpiSettingsPacket(settings: M6Settings): Uint8Array { + const packet = new Uint8Array(20); + packet[0] = 0x40; + packet[1] = settings.activeDpiStage; + packet[2] = settings.activeDpiStage; + packet[3] = settings.activeDpiStage; + settings.dpiStages.forEach((dpi, index) => { + packet[4 + index * 2] = dpi & 0xff; + packet[5 + index * 2] = (dpi >> 8) & 0xff; + }); + packet[14] = DPI_STAGE_COUNT; + return packet; + } + + private pollingSettingsPacket(settings: M6Settings): Uint8Array { + const packet = new Uint8Array(20); + packet[0] = 0x41; + packet[1] = settings.pollingIndex; + packet[2] = settings.pollingIndex; + packet[9] = settings.pollingTable.length; + packet.set(settings.pollingTable.slice(0, 6), 3); + return packet; + } + + private async queryStatus(): Promise { + const packet = new Uint8Array(PACKET_LENGTH); + packet[0] = STATUS_COMMAND; + return await this.query(COMMAND_REPORT_ID, COMMAND_RESPONSE_REPORT_ID, (bytes) => bytes[0] === STATUS_COMMAND, packet); + } + + private async writeSettings(packet: Uint8Array): Promise { + await this.query( + SETTINGS_REPORT_ID, + SETTINGS_RESPONSE_REPORT_ID, + (bytes) => bytes[0] === packet[0] || bytes[0] === 0xe4, + packet, + ); + } + + private async query( + reportId: number, + responseReportId: number, + match: (bytes: Uint8Array) => boolean, + packet: Uint8Array, + ): Promise { + if (this.responseWaiter) throw new Error("Another Keychron M6 request is already in progress."); + let timeout = 0; + let rejectResponse: ((reason: Error) => void) | null = null; + const response = new Promise((resolve, reject) => { + rejectResponse = reject; + timeout = window.setTimeout(() => { + this.responseWaiter = null; + reject(new Error(`The Keychron M6 did not answer command 0x${packet[0]?.toString(16)}.`)); + }, QUERY_TIMEOUT_MS); + this.responseWaiter = { + reportId: responseReportId, + match, + resolve: (bytes) => { + window.clearTimeout(timeout); + resolve(bytes); + }, + reject: (reason) => { + window.clearTimeout(timeout); + reject(reason); + }, + }; + }); + void response.catch(() => undefined); + try { + await this.device.sendReport(reportId, new Uint8Array(packet).buffer); + } catch (error) { + this.responseWaiter = null; + const detail = error instanceof Error ? error.message : String(error); + (rejectResponse as ((reason: Error) => void) | null)?.( + new Error(`Chrome could not write Keychron M6 HID report. ${detail}`), + ); + } + return await response; + } +} diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index c920c8e..aa0c365 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -11,8 +11,8 @@ import { ORBITAL_DEVICES } from "@openmouse/protocol/orbital"; const DEVICES_DIR = dirname(fileURLToPath(import.meta.url)); -const REPORT_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0x0f, 0x10, 0x11, 0x20, 0xa1]; -const USAGE_PAGES = [0x01, 0x0c, 0xff, 0xff00, 0xff01, 0xff02, 0xff0a, 0xff1c, 0xff43, 0xff55, 0xff60, 0xffa0, 0xffff]; +const REPORT_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0x0f, 0x10, 0x11, 0x20, 0xa1, 0xb3, 0xb4]; +const USAGE_PAGES = [0x01, 0x0c, 0xff, 0xff00, 0xff01, 0xff02, 0xff0a, 0xff1c, 0xff43, 0xff55, 0xff60, 0xffa0, 0xffc1, 0xffff]; function report(reportId: number, byteLength = 16): HIDReportInfo { return { reportId, items: [{ reportSize: 8, reportCount: byteLength }] } as unknown as HIDReportInfo; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index c345651..a8929ed 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -5,6 +5,7 @@ import { FantechHidClient } from "./fantech/hid.ts"; import { eggWeCreate, eggWeIsSupported, eggWeSupportScore, isEggWeClient, type EggWeHidClient } from "./endgame/egg-we-control.ts"; import { FinalmouseHidClient } from "./finalmouse/hid.ts"; import { KeychronHidClient } from "./keychron/hid.ts"; +import { KeychronM6HidClient } from "./keychron/m6-hid.ts"; import { LamzuHidClient } from "./lamzu/hid.ts"; import { LogitechHidppClient } from "./logitech/hidpp.ts"; import { ModdoHidClient } from "./moddo/hid.ts"; @@ -28,7 +29,7 @@ import { ZaunkoenigHidClient } from "./zaunkoenig/hid.ts"; import { GWolvesHidClient } from "./gwolves/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient; +export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | KeychronM6HidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient; export interface DeviceDriver { brand: string; @@ -60,6 +61,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "ATK", supports: (device) => AtkHidClient.isSupported(device), create: (device) => new AtkHidClient(device), score: () => 5 }, { brand: "Attack Shark", supports: (device) => AttackSharkHidClient.isSupported(device), create: (device) => new AttackSharkHidClient(device), score: () => 5 }, { brand: "Razer", supports: (device) => RazerViperV4ProHidClient.isSupported(device), create: (device) => new RazerViperV4ProHidClient(device), score: () => 7 }, + { brand: "Keychron", supports: (device) => KeychronM6HidClient.isSupported(device), create: (device) => new KeychronM6HidClient(device), score: () => 7 }, { brand: "Keychron", supports: (device) => KeychronHidClient.isSupported(device), create: (device) => new KeychronHidClient(device), score: () => 6 }, { brand: "Fantech", supports: (device) => FantechHidClient.isSupported(device), create: (device) => new FantechHidClient(device), score: () => 5 }, { brand: "Wooting", supports: (device) => WootingHidClient.isSupported(device), create: (device) => new WootingHidClient(device), score: () => 6 }, diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index cc1ba3a..56bed5c 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -69,6 +69,11 @@ export const KEYCHRON_HID_FILTERS: HIDDeviceFilter[] = KEYCHRON_PRODUCT_IDS.map( (productId) => ({ vendorId: VENDOR_ID.keychron, productId, usagePage: 0xff60, usage: 0x61 }), ); +export const KEYCHRON_M6_HID_FILTERS: HIDDeviceFilter[] = [ + { vendorId: VENDOR_ID.keychron, productId: 0xd060, usagePage: 0xffc1, usage: 0x01 }, + { vendorId: VENDOR_ID.keychron, productId: 0xd029, usagePage: 0xffc1, usage: 0x01 }, +]; + // moddoMOUSE exposes its vendor config interface on usage page 0xff, usage 0x01 // (older firmware answers on usage 0x02). Offer both so the picker lists the // control interface; the driver rejects anything without the config report. diff --git a/src/keychron/index.ts b/src/keychron/index.ts index adae738..824a066 100644 --- a/src/keychron/index.ts +++ b/src/keychron/index.ts @@ -3,6 +3,18 @@ export const KEYCHRON_RAW_USAGE_PAGE = 0xff60; export const KEYCHRON_RAW_USAGE = 0x61; export const KEYCHRON_REPORT_ID = 0; export const KEYCHRON_PACKET_LENGTH = 32; +/** Keychron M6 wired control interface and report layout, verified on PID 0xd060. */ +export const KEYCHRON_M6_PRODUCT_ID = 0xd060; +/** Link-KM receiver paired with the Keychron M6, verified on 2.4 GHz. */ +export const KEYCHRON_M6_RECEIVER_PRODUCT_ID = 0xd029; +export const KEYCHRON_M6_USAGE_PAGE = 0xffc1; +export const KEYCHRON_M6_USAGE = 0x01; +export const KEYCHRON_M6_COMMAND_REPORT_ID = 0xb3; +export const KEYCHRON_M6_COMMAND_RESPONSE_REPORT_ID = 0xb4; +export const KEYCHRON_M6_SETTINGS_REPORT_ID = 0xb5; +export const KEYCHRON_M6_SETTINGS_RESPONSE_REPORT_ID = 0xb6; +export const KEYCHRON_M6_STATUS_COMMAND = 0x06; +export const KEYCHRON_M6_STATUS_PACKET_LENGTH = 63; export const KEYCHRON_PRODUCTS = new Map([ [0x0440, { name: "Nape Pro" }], [0xd026, { name: "Keychron Link-KM", receiver: true }], @@ -81,4 +93,3 @@ export function keychronEncodeSleepTimeout(seconds: number): number[] { 0, ]; } -