diff --git a/src/drivers/fantech/hid.test.ts b/src/drivers/fantech/hid.test.ts index 1c0b5dc..e2affd6 100644 --- a/src/drivers/fantech/hid.test.ts +++ b/src/drivers/fantech/hid.test.ts @@ -102,4 +102,78 @@ describe("FantechHidClient", () => { const client = new FantechHidClient(device); await assert.rejects(() => client.setReportRate(9999), /Unsupported rate/); }); + + it("only advertises polling rates it can encode", () => { + const client = new FantechHidClient(fakeDevice()); + for (const hz of client.supportedPollingRates) { + assert.ok(hz in REPORT_RATE_ENCODE, `${hz} Hz is advertised but cannot be encoded`); + } + assert.deepEqual(client.supportedPollingRates, [125, 500, 1000, 2000, 4000, 8000]); + }); + + // A device on the wrong protocol family (VID 0x3151 PID 0x402D, measured) + // ACKs the write and answers 64 zero bytes. Decoding that used to yield + // 1600 DPI and 8000 Hz, both invented. + it("getDpi rejects an all-zero response instead of reporting 1600", async () => { + const client = new FantechHidClient(fakeDevice()); + await assert.rejects(() => client.getDpi(), /not answered/); + }); + + it("getReportRate rejects an all-zero response instead of decoding 8000 Hz", async () => { + const client = new FantechHidClient(fakeDevice()); + await assert.rejects(() => client.getReportRate(), /not answered/); + }); + + it("readStatus throws when the device answers nothing", async () => { + const client = new FantechHidClient(fakeDevice()); + await assert.rejects(() => client.readStatus(), /did not answer/); + }); + + it("readStatus reports only what was answered, with no fabricated link type", async () => { + const device = fakeDevice({ + receiveFeatureReport: async () => { + const view = new DataView(new ArrayBuffer(64)); + view.setUint8(2, 3); // report-rate code 3 = 1000 Hz; also DPI slot 3 + view.setUint8(3, 4); // four DPI slots + view.setUint16(8 + 3 * 2, 1600, true); + view.setUint16(24 + 3 * 2, 1600, true); + return view; + }, + }); + const status = await new FantechHidClient(device).readStatus(); + assert.equal(status.dpi, 1600); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.ui?.settingsReady, true); + assert.equal(status.connectionType, undefined); + assert.deepEqual(status.firmware, []); + }); + + it("setDpiForSlot leaves the other DPI slots untouched", async () => { + const sent: Uint8Array[] = []; + const device = fakeDevice({ + sendFeatureReport: async (_id: number, data: ArrayBuffer | ArrayLike) => { + sent.push(data instanceof Uint8Array ? data : new Uint8Array(data)); + }, + receiveFeatureReport: async () => { + const view = new DataView(new ArrayBuffer(64)); + view.setUint8(2, 0); // active slot + view.setUint8(3, 2); // two slots in use + view.setUint16(8, 800, true); // slot 0 X + view.setUint16(10, 3200, true); // slot 1 X + view.setUint16(24, 800, true); // slot 0 Y + view.setUint16(26, 3200, true); // slot 1 Y + return view; + }, + }); + + await new FantechHidClient(device).setDpiForSlot(1600, 1600, 0); + + const write = sent[sent.length - 1]; + const u16 = (buf: Uint8Array, i: number) => buf[i] | (buf[i + 1] << 8); + assert.equal(write[0], CMD.SET_DPI); + assert.equal(u16(write, 8), 1600); // slot 0 X updated + assert.equal(u16(write, 24), 1600); // slot 0 Y updated + assert.equal(u16(write, 10), 3200); // slot 1 X carried over + assert.equal(u16(write, 26), 3200); // slot 1 Y carried over + }); }); diff --git a/src/drivers/fantech/hid.ts b/src/drivers/fantech/hid.ts index 67735cc..30d48ad 100644 --- a/src/drivers/fantech/hid.ts +++ b/src/drivers/fantech/hid.ts @@ -1,5 +1,6 @@ import type { MouseStatus } from "../mouse-types.ts"; import { VENDOR_ID } from "../vendors.ts"; +import { LINGBAO_PRODUCTS } from "../lingbao/hid.ts"; // Fantech command IDs (from GearHub qmk.top protocol) export const CMD = { @@ -14,7 +15,17 @@ export const CMD = { SET_PROFILE: 2, // Set current profile } as const; -// Report rate encoding for Fantech mice +/** + * Report rate encoding for Fantech mice. + * + * This table is also the single source of truth for `supportedPollingRates`: + * a rate the driver cannot encode must never reach the UI, which renders one + * button per advertised rate and would throw on click. 250 Hz is deliberately + * absent — Fantech's own spec sheet lists it for the WG14P, but its wire + * encoding has never been captured, and inventing a code byte would silently + * set some other rate. Add it here, and nowhere else, once a capture confirms + * the value. + */ export const REPORT_RATE_ENCODE: Record = { 8000: 0, 4000: 1, @@ -31,6 +42,20 @@ export const REPORT_RATE_DECODE: Record = Object.fromEntries( export const FANTECH_REPORT_ID = 0x00; export const FANTECH_REPORT_SIZE = 64; +/** DPI slots the Family B report layout has room for (bytes [8..23] X, [24..39] Y). */ +export const FANTECH_MAX_DPI_SLOTS = 8; + +/** First byte of the per-slot X DPI array, in both a GET_DPI response and a SET_DPI command. */ +const DPI_X_OFFSET = 8; +/** First byte of the per-slot Y DPI array, immediately after the X array. */ +const DPI_Y_OFFSET = DPI_X_OFFSET + FANTECH_MAX_DPI_SLOTS * 2; +/** One past the end of the Y array; the whole DPI table is [DPI_X_OFFSET, DPI_END). */ +const DPI_END = DPI_Y_OFFSET + FANTECH_MAX_DPI_SLOTS * 2; + +function rejectionMessage(result: PromiseRejectedResult): string { + return result.reason instanceof Error ? result.reason.message : String(result.reason); +} + /** * Fantech vendor HID control. * @@ -43,6 +68,23 @@ export const FANTECH_REPORT_SIZE = 64; * - Read response as 64-byte feature report * - DPI stored as LE uint16 in bytes [8+h*2..9+h*2] (X) and [24+h*2..25+h*2] (Y) * - Report rate encoded as 0=8000, 1=4000, 2=2000, 3=1000, 4=500, 5=125 + * + * NOT every VID 0x3151 mouse speaks Family B, and the ones that do not are + * indistinguishable from the ones that do until you ask them something. The + * 2.4G receiver sold as PID 0x402D presents byte-for-byte the interface + * described above — vendor page 0xFFFF, usage 0x02, one unnumbered 64-byte + * feature report — and ACKs every SET_FEATURE write without error, yet + * answers GET_DPI, GET_REPORT_RATE, GET_ALL_PARAMS and GET_DEBOUNCE alike + * with 64 zero bytes (measured on Windows across 0-500ms read delays and + * three different command layouts). + * + * Reading those zeros as data is worse than failing outright: byte 2 of a + * blank response decodes to report-rate code 0 = 8000 Hz, and a zero DPI used + * to fall back to 1600, so `readStatus()` returned a perfectly plausible + * "ready" mouse whose every number was invented. Each read below therefore + * rejects a blank response, and `readStatus()` throws when nothing answers at + * all — the caller treats that as "try the next candidate driver" (see + * `connectToInterface()` in the desktop app under `src/native-hid/scan.ts`). */ export class FantechHidClient { device: HIDDevice; @@ -54,6 +96,12 @@ export class FantechHidClient { static isSupported(device: HIDDevice): boolean { if (device.vendorId !== VENDOR_ID.fantech) return false; + // 0x3151 is the MicLink/mlzn ODM vendor id, not Fantech's own, and the + // Lingbao M5 Pro answers on the identical 0xFFFF/0x02 interface while + // speaking a different dialect entirely (2.4G relay + checksum). It has + // its own driver; leave its product ids to it, or driverFor() would hand + // this one a device it cannot read. + if (LINGBAO_PRODUCTS.has(device.productId)) return false; const hasVendorConfig = (collections: readonly HIDCollectionInfo[]): boolean => collections.some( (collection) => @@ -64,8 +112,15 @@ export class FantechHidClient { return hasVendorConfig(device.collections); } + /** + * Only the rates this driver can actually put on the wire. Derived from + * REPORT_RATE_ENCODE so the list the UI renders and the list + * `setReportRate()` accepts cannot drift apart. + */ get supportedPollingRates(): number[] { - return [125, 250, 500, 1000, 2000, 4000, 8000]; + return Object.keys(REPORT_RATE_ENCODE) + .map(Number) + .sort((a, b) => a - b); } getDpiOptions(): number[] { @@ -87,7 +142,7 @@ export class FantechHidClient { // Command I/O // --------------------------------------------------------------------------- - /** Send a command and receive the response. */ + /** Send a command and return the raw response, answered or not. */ async sendCommand(cmd: number, ...payload: number[]): Promise { const buf = new Uint8Array(FANTECH_REPORT_SIZE); buf[0] = cmd; @@ -98,6 +153,28 @@ export class FantechHidClient { return this.readResponse(); } + /** + * Send a command that must be answered, rejecting a response that carries no + * answer at all. A device on a different protocol family still ACKs the + * feature write and hands back a zero-filled buffer, which is exactly what + * used to be decoded into invented DPI and polling-rate readings — see the + * class docs. + */ + async sendQuery(cmd: number, ...payload: number[]): Promise { + const resp = await this.sendCommand(cmd, ...payload); + if (resp.length < FANTECH_REPORT_SIZE) { + throw new Error( + `Fantech command ${cmd} returned ${resp.length} bytes, expected ${FANTECH_REPORT_SIZE}.`, + ); + } + if (resp.every((byte) => byte === 0)) { + throw new Error( + `Fantech command ${cmd} was not answered (all-zero response); this device does not speak the Family B protocol.`, + ); + } + return resp; + } + /** Read a response report. */ async readResponse(): Promise { const view = await this.device.receiveFeatureReport(FANTECH_REPORT_ID); @@ -108,7 +185,11 @@ export class FantechHidClient { // DPI Protocol // --------------------------------------------------------------------------- - /** Read current DPI settings. Returns DPI for active slot. */ + /** + * Read current DPI settings for the active slot. Throws when the device does + * not answer, or answers without a usable DPI — a caller must never be handed + * a fabricated default it cannot tell apart from a real reading. + */ async getDpi(): Promise<{ dpiX: number; dpiY: number; @@ -116,50 +197,53 @@ export class FantechHidClient { numSlots: number; }> { await this.open(); - const resp = await this.sendCommand(CMD.GET_DPI, this.currentProfile); - if (resp.length < 10) return { dpiX: 1600, dpiY: 1600, slot: 0, numSlots: 1 }; + const resp = await this.sendQuery(CMD.GET_DPI, this.currentProfile); // Response structure (Family B): // Byte 2: current DPI slot index // Byte 3: number of DPI slots // Bytes [8..23]: X DPI per slot (LE uint16, up to 8 slots) // Bytes [24..39]: Y DPI per slot (LE uint16, up to 8 slots) - const slot = resp[2] > 8 ? 0 : resp[2]; + const slot = resp[2] >= FANTECH_MAX_DPI_SLOTS ? 0 : resp[2]; const numSlots = resp[3] || 1; - const dpiX = resp[8 + slot * 2] | (resp[9 + slot * 2] << 8); - const dpiY = resp[24 + slot * 2] | (resp[25 + slot * 2] << 8); + const dpiX = resp[DPI_X_OFFSET + slot * 2] | (resp[DPI_X_OFFSET + 1 + slot * 2] << 8); + const dpiY = resp[DPI_Y_OFFSET + slot * 2] | (resp[DPI_Y_OFFSET + 1 + slot * 2] << 8); + if (!dpiX) { + throw new Error(`Fantech GET_DPI answered, but slot ${slot} carries no DPI value.`); + } - return { - dpiX: dpiX || 1600, - dpiY: dpiY || dpiX || 1600, - slot, - numSlots, - }; + return { dpiX, dpiY: dpiY || dpiX, slot, numSlots }; } - /** Set DPI for a specific slot. */ + /** Set DPI for a specific slot, leaving every other slot exactly as it was. */ async setDpiForSlot(dpiX: number, dpiY: number, slot = 0): Promise { await this.open(); - // First read current state - const readResp = await this.sendCommand(CMD.GET_DPI, this.currentProfile); + // Read current state first. SET_DPI carries the WHOLE per-slot DPI table, + // so every slot that is not being written has to be echoed back unchanged; + // sending a freshly zeroed buffer wiped the other DPI stages. + const readResp = await this.sendQuery(CMD.GET_DPI, this.currentProfile); const numSlots = readResp[3] || 1; + const target = slot >= FANTECH_MAX_DPI_SLOTS ? 0 : slot; // Build the Set DPI command const buf = new Uint8Array(FANTECH_REPORT_SIZE); buf[0] = CMD.SET_DPI; buf[1] = this.currentProfile; - buf[2] = slot > 8 ? 0 : slot; + buf[2] = target; buf[3] = numSlots; + // Carry every slot's existing X and Y over before overwriting the target's. + buf.set(readResp.subarray(DPI_X_OFFSET, DPI_END), DPI_X_OFFSET); + // Write X DPI as LE uint16 - buf[8 + slot * 2] = dpiX & 0xff; - buf[9 + slot * 2] = (dpiX >> 8) & 0xff; + buf[DPI_X_OFFSET + target * 2] = dpiX & 0xff; + buf[DPI_X_OFFSET + 1 + target * 2] = (dpiX >> 8) & 0xff; // Write Y DPI as LE uint16 - buf[24 + slot * 2] = dpiY & 0xff; - buf[25 + slot * 2] = (dpiY >> 8) & 0xff; + buf[DPI_Y_OFFSET + target * 2] = dpiY & 0xff; + buf[DPI_Y_OFFSET + 1 + target * 2] = (dpiY >> 8) & 0xff; await this.device.sendFeatureReport(FANTECH_REPORT_ID, buf); return dpiX; @@ -169,19 +253,23 @@ export class FantechHidClient { // Report Rate Protocol // --------------------------------------------------------------------------- - /** Get current report/polling rate. */ + /** Get current report/polling rate. Throws when the device does not answer. */ async getReportRate(): Promise { await this.open(); - const resp = await this.sendCommand(CMD.GET_REPORT_RATE); - const code = resp.length > 2 ? resp[2] : 3; - return REPORT_RATE_DECODE[code] ?? 1000; + const resp = await this.sendQuery(CMD.GET_REPORT_RATE); + const code = resp[2]; + const hz = REPORT_RATE_DECODE[code]; + if (hz === undefined) { + throw new Error(`Fantech GET_REPORT_RATE returned unknown rate code ${code}.`); + } + return hz; } /** Set report/polling rate. */ async setReportRate(hz: number): Promise { if (!(hz in REPORT_RATE_ENCODE)) { throw new Error( - `Unsupported rate ${hz} Hz. Supported: ${Object.keys(REPORT_RATE_ENCODE).join(", ")}`, + `Unsupported rate ${hz} Hz. Supported: ${this.supportedPollingRates.join(", ")}`, ); } await this.open(); @@ -204,6 +292,14 @@ export class FantechHidClient { this.getReportRate(), ]); + // Nothing answered at all: this interface is not a Family B control + // channel, so fail instead of returning a status assembled from defaults. + if (dpiResult.status === "rejected" && pollRate.status === "rejected") { + throw new Error( + `Fantech control interface did not answer. ${rejectionMessage(dpiResult)} ${rejectionMessage(pollRate)}`, + ); + } + const dpiData = dpiResult.status === "fulfilled" ? dpiResult.value : null; const pollRateVal = @@ -227,10 +323,14 @@ export class FantechHidClient { pollingRateHz: pollRateVal, supportedPollingRates: this.supportedPollingRates, activeProfile: this.currentProfile, - connectionType: "Wired", - connectionDetail: "USB", + // connectionType/connectionDetail are deliberately unset. Family B + // carries no link information, and VID 0x3151 covers both wired mice and + // 2.4G receivers, so the "Wired (USB)" that used to be hardcoded here + // mislabelled every wireless one. OverviewPage hides the row when absent. liftOffDistance: null, - firmware: ["Fantech mouse"], + // No firmware command is implemented; an empty list hides the row instead + // of showing the placeholder string that used to sit here. + firmware: [], }; } @@ -243,7 +343,9 @@ export class FantechHidClient { async setPollingRate(rate: number): Promise { if (!this.supportedPollingRates.includes(rate)) { - throw new Error("Fantech supports 125, 250, 500, 1000, 2000, 4000, or 8000 Hz."); + throw new Error( + `Fantech supports ${this.supportedPollingRates.join(", ")} Hz.`, + ); } await this.setReportRate(rate); return rate; diff --git a/src/drivers/lingbao/hid.test.ts b/src/drivers/lingbao/hid.test.ts new file mode 100644 index 0000000..c0b2f52 --- /dev/null +++ b/src/drivers/lingbao/hid.test.ts @@ -0,0 +1,229 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + CMD, + LingbaoHidClient, + REPORT_RATE_DECODE, + encodeCommand, +} from "./hid.ts"; + +const M5_PRO_RECEIVER = { vendorId: 0x3151, productId: 0x402d }; +const M5_PRO_WIRED = { vendorId: 0x3151, productId: 0x4026 }; + +function vendorCollection() { + return { + usagePage: 0xffff, + usage: 0x02, + type: 0, + children: [], + inputReports: [], + outputReports: [], + featureReports: [], + } as unknown as HIDCollectionInfo; +} + +/** + * A receiver that behaves the way the real one does: it answers its own 0xF7 + * status poll itself, and only hands back a device reply after a checksummed + * command followed by 0xFC. + */ +function fakeReceiver(options: { + replies?: Record; + mouseOnline?: boolean; + mouseBattery?: number; + ids?: Partial<{ vendorId: number; productId: number }>; +} = {}) { + const replies = options.replies ?? {}; + const sent: Uint8Array[] = []; + let pending: number[] | null = null; + + const status = () => { + const s = new Uint8Array(64); + s[0] = 1; // canRead + s[1] = 0; // keyboard battery + s[2] = options.mouseBattery ?? 45; // mouse battery + s[3] = 1; // keyboard offline + s[4] = (options.mouseOnline ?? true) ? 0 : 1; // mouse online when 0 + s[5] = 1; // canSend + return s; + }; + + let last = status(); + const device = { + ...M5_PRO_RECEIVER, + ...options.ids, + productName: "2.4G Wireless Mouse", + opened: true, + collections: [vendorCollection()], + open: async () => {}, + close: async () => {}, + sendFeatureReport: async (_id: number, data: Uint8Array) => { + sent.push(new Uint8Array(data)); + if (data[0] === 0xf7) last = status(); + else if (data[0] === 0xf6) { /* select target */ } + else if (data[0] === 0xfc) { + const out = new Uint8Array(64); + if (pending) out.set(pending); + last = out; + } else { + pending = replies[data[0]] ?? null; + } + }, + receiveFeatureReport: async () => new DataView(last.buffer.slice(0)), + }; + return { device: device as unknown as HIDDevice, sent }; +} + +/** Build a GET_DPI reply with the given per-stage X values. */ +function dpiReply(xs: number[], activeIndex: number, rgb: number[] = []) { + const reply = new Array(64).fill(0); + reply[0] = CMD.GET_DPI; + reply[2] = activeIndex; + reply[3] = xs.length; + xs.forEach((value, i) => { + reply[8 + i * 2] = value & 0xff; + reply[9 + i * 2] = value >> 8; + reply[24 + i * 2] = value & 0xff; + reply[25 + i * 2] = value >> 8; + if (rgb[i] !== undefined) { + reply[40 + i * 3] = (rgb[i] >> 16) & 0xff; + reply[41 + i * 3] = (rgb[i] >> 8) & 0xff; + reply[42 + i * 3] = rgb[i] & 0xff; + } + }); + return reply; +} + +describe("LingbaoHidClient", () => { + it("stamps the Bit7 checksum the M5 Pro echoed back", () => { + // Checksums observed live in the device's own replies. + assert.equal(encodeCommand([CMD.GET_FIRMWARE])[7], 0x7f); + assert.equal(encodeCommand([CMD.GET_USB_VERSION])[7], 0x70); + assert.equal(encodeCommand([CMD.GET_REPORT_RATE])[7], 0x7c); + assert.equal(encodeCommand([CMD.GET_DPI, 0x00])[7], 0x2b); + }); + + it("claims only the M5 Pro product ids on VID 0x3151", () => { + assert.equal(LingbaoHidClient.isSupported(fakeReceiver().device), true); + assert.equal(LingbaoHidClient.isSupported(fakeReceiver({ ids: M5_PRO_WIRED }).device), true); + // 0x503d is Fantech's WG14P on the same shared ODM vendor id. + assert.equal(LingbaoHidClient.isSupported(fakeReceiver({ ids: { productId: 0x503d } }).device), false); + assert.equal(LingbaoHidClient.isSupported(fakeReceiver({ ids: { vendorId: 0x046d } }).device), false); + }); + + it("relays over 2.4G but talks directly when wired", () => { + assert.equal(new LingbaoHidClient(fakeReceiver().device).transport, "dongle"); + assert.equal(new LingbaoHidClient(fakeReceiver({ ids: M5_PRO_WIRED }).device).transport, "direct"); + }); + + it("caps polling at 8 kHz on the receiver and 1 kHz wired", () => { + assert.deepEqual( + new LingbaoHidClient(fakeReceiver().device).supportedPollingRates, + [125, 250, 500, 1000, 2000, 4000, 8000], + ); + assert.deepEqual( + new LingbaoHidClient(fakeReceiver({ ids: M5_PRO_WIRED }).device).supportedPollingRates, + [125, 250, 500, 1000], + ); + }); + + it("decodes the DPI table captured from the real M5 Pro", async () => { + // Live bytes: D4 00 02 06 00 00 00 2B 90 01 20 03 40 06 80 0C 00 19 90 65 + const xs = [400, 800, 1600, 3200, 6400, 26000]; + const { device } = fakeReceiver({ replies: { [CMD.GET_DPI]: dpiReply(xs, 2, [0, 0, 0x0000ff]) } }); + const { stages, activeIndex } = await new LingbaoHidClient(device).getDpi(); + + assert.equal(activeIndex, 2); + assert.deepEqual(stages.map((s) => s.x), xs); + assert.equal(stages[2].x, 1600, "active stage is 1600 DPI"); + assert.equal(stages[5].x, 26000, "PAW3395 tops out at 26000"); + assert.equal(stages[2].rgb, 0x0000ff); + }); + + it("decodes report rate with the seven-entry mouse table", async () => { + // The keyboard table maps code 5 to 125 Hz; on a mouse it is 250 Hz. + assert.equal(REPORT_RATE_DECODE[5], 250); + assert.equal(REPORT_RATE_DECODE[6], 125); + + const reply = new Array(64).fill(0); + reply[0] = CMD.GET_REPORT_RATE; + reply[2] = 0; + const { device } = fakeReceiver({ replies: { [CMD.GET_REPORT_RATE]: reply } }); + assert.equal(await new LingbaoHidClient(device).getReportRate(), 8000); + }); + + it("performs the receiver handshake in order before a read", async () => { + const { device, sent } = fakeReceiver({ replies: { [CMD.GET_DPI]: dpiReply([1600], 0) } }); + await new LingbaoHidClient(device).getDpi(); + + const ids = sent.map((buf) => buf[0]); + assert.equal(ids[0], 0xf6, "select the mouse as target first"); + assert.equal(sent[0][1], 0x05, "target code for the mouse"); + assert.ok(ids.indexOf(0xf7) > 0, "poll receiver status"); + const command = ids.indexOf(CMD.GET_DPI); + assert.ok(command > ids.indexOf(0xf7), "command goes out after a ready poll"); + assert.ok(ids.indexOf(0xfc) > command, "notice-read comes after the command"); + }); + + it("rejects an unanswered command instead of reading zeros as data", async () => { + const { device } = fakeReceiver({ replies: {} }); + await assert.rejects( + () => new LingbaoHidClient(device).getDpi(), + /not answered \(all-zero response\)/, + ); + }); + + it("reports an unlinked mouse as its own failure, not a protocol failure", async () => { + const { device } = fakeReceiver({ mouseOnline: false }); + await assert.rejects( + () => new LingbaoHidClient(device).readStatus(), + /no mouse is linked/, + ); + }); + + it("readStatus reports battery, link type and sensor", async () => { + const rate = new Array(64).fill(0); + rate[0] = CMD.GET_REPORT_RATE; + rate[2] = 0; // 8000 Hz + const { device } = fakeReceiver({ + mouseBattery: 45, + replies: { [CMD.GET_DPI]: dpiReply([400, 800, 1600], 2), [CMD.GET_REPORT_RATE]: rate }, + }); + + const status = await new LingbaoHidClient(device).readStatus(); + assert.equal(status.brand, "Lingbao"); + assert.equal(status.name, "Lingbao M5 Pro (2.4G receiver)"); + assert.equal(status.batteryPercent, 45); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.connectionDetail, "2.4 GHz"); + assert.equal(status.dpi, 1600); + assert.equal(status.activeDpiStage, 2); + assert.deepEqual(status.dpiStages, [400, 800, 1600]); + assert.equal(status.pollingRateHz, 8000); + assert.ok(status.firmware.includes("PixArt PAW3395")); + }); + + it("setDpiForStage keeps the other stages and their indicator colours", async () => { + const { device, sent } = fakeReceiver({ + replies: { [CMD.GET_DPI]: dpiReply([800, 1600, 3200], 0, [0x100000, 0x110000, 0x120000]) }, + }); + await new LingbaoHidClient(device).setDpiForStage(6400, 6400, 1); + + const write = sent.filter((buf) => buf[0] === CMD.SET_DPI).at(-1); + assert.ok(write, "a SET_DPI report should have been sent"); + const u16 = (buf: Uint8Array, i: number) => buf[i] | (buf[i + 1] << 8); + assert.equal(write![3], 3, "stage count preserved"); + assert.equal(u16(write!, 8), 800); // stage 0 untouched + assert.equal(u16(write!, 10), 6400); // stage 1 updated + assert.equal(u16(write!, 12), 3200); // stage 2 untouched + assert.equal(u16(write!, 26), 6400); // stage 1 Y updated + assert.equal(write![40], 0x10); // indicator colours carried over + assert.equal(write![43], 0x11); + assert.equal(write![46], 0x12); + }); + + it("refuses a rate the wired mode cannot reach", async () => { + const client = new LingbaoHidClient(fakeReceiver({ ids: M5_PRO_WIRED }).device); + await assert.rejects(() => client.setPollingRate(8000), /Unsupported rate 8000 Hz/); + }); +}); diff --git a/src/drivers/lingbao/hid.ts b/src/drivers/lingbao/hid.ts new file mode 100644 index 0000000..07a9223 --- /dev/null +++ b/src/drivers/lingbao/hid.ts @@ -0,0 +1,562 @@ +import type { MouseStatus } from "../mouse-types.ts"; + +/** + * Lingbao M5 Pro vendor HID control. + * + * Verified end to end against real hardware: a Lingbao M5 Pro (PixArt + * PAW3395) on its 2.4G receiver, VID 0x3151 / PID 0x402D, Windows 11. + * + * Lingbao ships no protocol of its own — the mouse is configured by GearHub-V5 + * (https://www.qmk.top), the ODM web tool behind a long list of brands, and + * everything below was read out of GearHub's own bundle and then confirmed + * byte for byte on the device. VID 0x3151 belongs to MicLink/mlzn, the ODM, + * NOT to Lingbao and not to Fantech (which the existing `fantech` driver in + * this repo assumes); several unrelated brands enumerate under it, which is + * why the product-id tables below are exact rather than vendor-wide. + * + * ── Transport ──────────────────────────────────────────────────────────── + * USB interface 2, vendor usage page 0xFFFF, usage 0x02, one unnumbered + * 64-byte feature report. Commands are 9 bytes zero-padded into that report. + * Measured on the M5 Pro: `FeatureReportByteLength = 65` (report id + 64). + * + * ── Checksum ("Bit7", GearHub's own name for it) ───────────────────────── + * byte[7] = 255 - (sum(bytes 0..6) & 0xFF). Without it the device ACKs the + * SET_FEATURE and answers 64 zero bytes — silently, no error. + * + * ── 2.4G is a relay, not a pipe ────────────────────────────────────────── + * The receiver does not forward a command just because one was written to it. + * A read over 2.4G is a four-step exchange, and the receiver's OWN commands + * are raw — they carry no checksum: + * + * 1. 0xF6 0x05 select the paired mouse as the target (0x0A = keyboard) + * 2. 0xF7 poll receiver status until it reports ready + * 3. the checksummed 9-byte command + * 4. 0xFC "notice read", then read the feature report back + * + * Omit any of 1/2/4 and every reply is 64 zeros — indistinguishable from a + * device that does not speak the protocol at all. This is the single reason + * the `fantech` driver reads nothing from this hardware: it has the right + * interface and roughly the right DPI layout, but no relay, no checksum, and + * a different command set. + * + * Step 2 is a real gate. An idle M5 Pro drops off its receiver within a few + * minutes; `mouseOnline` then goes false and every command goes unanswered + * until the mouse is moved. `readStatus()` surfaces that as its own error so + * it is never mistaken for a protocol mismatch. + * + * Plugged in by cable the mouse enumerates as PID 0x4026 and skips the relay + * entirely: send the checksummed command, read the reply straight back. + */ + +/** Receiver-level commands. Raw — the Bit7 checksum is NOT applied to these. */ +const DONGLE_CMD = { + SELECT_TARGET: 0xf6, + GET_STATUS: 0xf7, + NOTICE_READ: 0xfc, +} as const; + +const TARGET_MOUSE = 0x05; + +/** + * Mouse-class command ids. GearHub numbers them so every read is its matching + * write plus 0x80 (SET_REPORT 0x03 / GET_REPORT 0x83, SET_DPI 0x54 / GET_DPI + * 0xD4). All of these were confirmed echoing on the M5 Pro. + */ +export const CMD = { + GET_FIRMWARE: 0x80, + GET_USB_VERSION: 0x8f, + SET_REPORT_RATE: 0x03, + GET_REPORT_RATE: 0x83, + GET_PROFILE: 0x85, + GET_DEBOUNCE: 0x86, + SET_DPI: 0x54, + GET_DPI: 0xd4, +} as const; + +/** + * Report-rate codes for the MOUSE class — seven entries, with 250 Hz at code + * 5. GearHub's keyboard class uses a six-entry table where code 5 means + * 125 Hz instead; decoding a mouse with that table reports 250 Hz as 125 Hz + * and cannot express 125 Hz at all. (The `fantech` driver in this repo + * carries the six-entry keyboard table.) + */ +export const REPORT_RATE_DECODE: Record = { + 0: 8000, + 1: 4000, + 2: 2000, + 3: 1000, + 4: 500, + 5: 250, + 6: 125, +}; + +export const REPORT_RATE_ENCODE: Record = Object.fromEntries( + Object.entries(REPORT_RATE_DECODE).map(([code, hz]) => [hz, Number(code)]), +); + +export const LINGBAO_VENDOR_ID = 0x3151; +export const LINGBAO_REPORT_ID = 0x00; +export const LINGBAO_REPORT_SIZE = 64; +export const LINGBAO_CMD_SIZE = 9; + +/** DPI stages the report layout has room for. */ +export const LINGBAO_MAX_DPI_STAGES = 8; + +// GET_DPI response layout, from GearHub's getDPI() and confirmed on hardware: +// [2] active stage index, [3] stage count, +// [8 + i*2] X as LE uint16, [24 + i*2] Y as LE uint16, +// [40 + i*3] that stage's indicator colour as r, g, b. +const DPI_X_OFFSET = 8; +const DPI_Y_OFFSET = 24; +const DPI_RGB_OFFSET = 40; + +export interface LingbaoProduct { + model: string; + /** How this product id is reached: through the 2.4G receiver, or directly. */ + transport: "dongle" | "direct"; + sensor: string; + maxPollingHz: number; + minDpi: number; + maxDpi: number; + dpiStep: number; +} + +/** + * The M5 Pro presents two product ids: the 2.4G receiver, and the mouse + * itself when connected by cable. Both were seen on the test machine. + * + * The receiver is an 8K part — GearHub's device table flags 0x402D + * `reportRate: 8e3`, and the mouse answered GET_REPORT with code 0 (8000 Hz) + * live. Lingbao's own spec sheet for the base M5 Pro says 1000 Hz, so either + * that sheet undersells the receiver or the retail bundle varies; the value + * below follows the hardware and GearHub, not the sheet. + * + * Bluetooth is the mouse's third mode. It is not listed here: over BLE the + * device enumerates on a different usage page entirely (0xFF35/0xFF66, + * usage 0x0202) and GearHub drives it through a separate read path, which + * this driver does not implement. + */ +export const LINGBAO_PRODUCTS: ReadonlyMap = new Map([ + [0x402d, { + model: "M5 Pro (2.4G receiver)", + transport: "dongle", + sensor: "PixArt PAW3395", + maxPollingHz: 8000, + minDpi: 50, + maxDpi: 26000, + dpiStep: 50, + }], + [0x4026, { + model: "M5 Pro (wired)", + transport: "direct", + sensor: "PixArt PAW3395", + maxPollingHz: 1000, + minDpi: 50, + maxDpi: 26000, + dpiStep: 50, + }], +]); + +const ALL_RATES = [125, 250, 500, 1000, 2000, 4000, 8000]; + +export interface LingbaoDongleStatus { + canRead: boolean; + canSend: boolean; + keyboardOnline: boolean; + mouseOnline: boolean; + keyboardBattery: number; + mouseBattery: number; +} + +export interface LingbaoDpiStage { + x: number; + y: number; + rgb: number; +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Pad to 9 bytes and stamp the Bit7 checksum into byte 7. */ +export function encodeCommand(bytes: readonly number[]): Uint8Array { + const out = new Uint8Array(Math.max(LINGBAO_CMD_SIZE, bytes.length)); + out.set(bytes); + let sum = 0; + for (let i = 0; i < 7; i++) sum = (sum + out[i]) & 0xff; + out[7] = (255 - sum) & 0xff; + return out; +} + +export class LingbaoHidClient { + device: HIDDevice; + currentProfile = 0; + + readonly product: LingbaoProduct | undefined; + + private targetSelected = false; + + /** + * The relay is stateful — select target, poll ready, send, notice-read, + * read — so two exchanges in flight at once interleave and corrupt each + * other. Every public operation is chained through here so only one runs at + * a time, the same way GearHub serialises through its own send queue. + */ + private queue: Promise = Promise.resolve(); + + private enqueue(task: () => Promise): Promise { + const run = this.queue.then(task, task); + this.queue = run.catch(() => undefined); + return run; + } + + constructor(device: HIDDevice) { + this.device = device; + this.product = LINGBAO_PRODUCTS.get(device.productId); + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== LINGBAO_VENDOR_ID) return false; + if (!LINGBAO_PRODUCTS.has(device.productId)) return false; + const hasControl = (collections: readonly HIDCollectionInfo[]): boolean => + collections.some( + (collection) => + (collection.usagePage === 0xffff && collection.usage === 0x02) || + hasControl(collection.children ?? []), + ); + return hasControl(device.collections); + } + + /** Whether reads go through the 2.4G relay or straight to the device. */ + get transport(): "dongle" | "direct" { + return this.product?.transport ?? "direct"; + } + + get supportedPollingRates(): number[] { + const max = this.product?.maxPollingHz ?? 1000; + return ALL_RATES.filter((hz) => hz <= max); + } + + /** PAW3395 steps in 50 DPI increments; these are the round values worth offering. */ + getDpiOptions(): number[] { + return [ + 400, 800, 1200, 1600, 2000, 2400, 3200, 4000, 4800, 5600, 6400, 8000, + 10000, 12000, 16000, 20000, 26000, + ]; + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + if (this.device.opened) await this.device.close(); + } + + // --------------------------------------------------------------------------- + // Raw feature-report I/O + // --------------------------------------------------------------------------- + + private async rawSend(bytes: readonly number[] | Uint8Array): Promise { + const buf = new Uint8Array(LINGBAO_REPORT_SIZE); + buf.set( + bytes instanceof Uint8Array + ? bytes.subarray(0, LINGBAO_REPORT_SIZE) + : bytes.slice(0, LINGBAO_REPORT_SIZE), + ); + await this.device.sendFeatureReport(LINGBAO_REPORT_ID, buf); + } + + private async rawRead(): Promise { + const view = await this.device.receiveFeatureReport(LINGBAO_REPORT_ID); + return new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); + } + + // --------------------------------------------------------------------------- + // 2.4G receiver relay + // --------------------------------------------------------------------------- + + /** + * Poll the receiver for link state and battery. A receiver command, so no + * checksum. Measured layout, live: `01 00 2D 01 00 01` — ready to read, + * no keyboard, mouse at 0x2D = 45%, keyboard offline, mouse online, ready + * to send. + */ + async readDongleStatus(): Promise { + return this.enqueue(() => this.pollStatus()); + } + + /** Unqueued: callers already holding the queue slot use this. */ + private async pollStatus(): Promise { + await this.rawSend([DONGLE_CMD.GET_STATUS]); + await delay(10); + const r = await this.rawRead(); + return { + canRead: r[0] === 1, + keyboardBattery: r[1], + mouseBattery: r[2], + keyboardOnline: r[3] === 0, + mouseOnline: r[4] === 0, + canSend: r[5] === 1, + }; + } + + private async selectMouse(): Promise { + if (this.targetSelected) return; + await this.rawSend([DONGLE_CMD.SELECT_TARGET, TARGET_MOUSE]); + await delay(50); + this.targetSelected = true; + } + + private async waitReady(direction: "send" | "read"): Promise { + for (let attempt = 0; attempt < 5; attempt++) { + await delay(100); + const status = await this.pollStatus(); + if (direction === "send" ? status.canSend : status.canRead) return true; + } + return false; + } + + // --------------------------------------------------------------------------- + // Command exchange + // --------------------------------------------------------------------------- + + /** + * Run one command and return its reply, rejecting a reply that is not an + * answer. The device validates by echo: it repeats the command id in byte + * 0. An unrelayed or unchecksummed command comes back as 64 zeros, which is + * exactly what must never be mistaken for data. + */ + async command(bytes: readonly number[]): Promise { + return this.enqueue(() => this.exchange(bytes)); + } + + private async exchange(bytes: readonly number[]): Promise { + await this.open(); + const encoded = encodeCommand(bytes); + + if (this.transport === "direct") { + await this.rawSend(encoded); + await delay(10); + return this.verifyEcho(await this.rawRead(), bytes[0]); + } + + await this.selectMouse(); + if (!(await this.waitReady("send"))) { + throw new Error("Lingbao receiver never became ready to send."); + } + await this.rawSend(encoded); + if (!(await this.waitReady("read"))) { + throw new Error("Lingbao receiver never became ready to read."); + } + await this.rawSend([DONGLE_CMD.NOTICE_READ]); + await delay(10); + return this.verifyEcho(await this.rawRead(), bytes[0]); + } + + private verifyEcho(resp: Uint8Array, cmd: number): Uint8Array { + if (resp.length < LINGBAO_REPORT_SIZE) { + throw new Error( + `Lingbao command 0x${cmd.toString(16)} returned ${resp.length} bytes, expected ${LINGBAO_REPORT_SIZE}.`, + ); + } + if (resp[0] !== cmd) { + const blank = resp.every((byte) => byte === 0); + throw new Error( + blank + ? `Lingbao command 0x${cmd.toString(16)} was not answered (all-zero response).` + : `Lingbao command 0x${cmd.toString(16)} answered with id 0x${resp[0].toString(16)}.`, + ); + } + return resp; + } + + // --------------------------------------------------------------------------- + // Reads + // --------------------------------------------------------------------------- + + /** Device id — the key GearHub looks its model table up by. M5 Pro answers 2285. */ + async getDeviceId(): Promise { + const resp = await this.command([CMD.GET_USB_VERSION]); + return (resp[1] | (resp[2] << 8) | (resp[3] << 16) | (resp[4] << 24)) >>> 0; + } + + /** Firmware revision: byte 2 high, byte 1 low. M5 Pro answers 0x0303. */ + async getFirmwareVersion(): Promise { + const resp = await this.command([CMD.GET_FIRMWARE]); + return (resp[2] << 8) | resp[1]; + } + + async getReportRate(): Promise { + const resp = await this.command([CMD.GET_REPORT_RATE]); + const hz = REPORT_RATE_DECODE[resp[2]]; + if (hz === undefined) { + throw new Error(`Lingbao GET_REPORT_RATE returned unknown code ${resp[2]}.`); + } + return hz; + } + + /** Every DPI stage of the active profile, plus which one is selected. */ + async getDpi(): Promise<{ stages: LingbaoDpiStage[]; activeIndex: number }> { + const resp = await this.command([CMD.GET_DPI, this.currentProfile]); + const activeIndex = resp[2] >= LINGBAO_MAX_DPI_STAGES ? 0 : resp[2]; + const count = Math.min(resp[3], LINGBAO_MAX_DPI_STAGES); + if (count === 0) throw new Error("Lingbao GET_DPI reported no DPI stages."); + + const stages: LingbaoDpiStage[] = []; + for (let i = 0; i < count; i++) { + stages.push({ + x: resp[DPI_X_OFFSET + i * 2] | (resp[DPI_X_OFFSET + 1 + i * 2] << 8), + y: resp[DPI_Y_OFFSET + i * 2] | (resp[DPI_Y_OFFSET + 1 + i * 2] << 8), + rgb: + (resp[DPI_RGB_OFFSET + i * 3] << 16) | + (resp[DPI_RGB_OFFSET + 1 + i * 3] << 8) | + resp[DPI_RGB_OFFSET + 2 + i * 3], + }); + } + return { stages, activeIndex }; + } + + // --------------------------------------------------------------------------- + // Writes + // --------------------------------------------------------------------------- + + async setReportRate(hz: number): Promise { + const code = REPORT_RATE_ENCODE[hz]; + if (code === undefined || !this.supportedPollingRates.includes(hz)) { + throw new Error( + `Unsupported rate ${hz} Hz. Supported: ${this.supportedPollingRates.join(", ")}`, + ); + } + await this.open(); + // SET_REPORT carries the code in byte 2, not byte 1. + const cmd = new Uint8Array(LINGBAO_CMD_SIZE); + cmd[0] = CMD.SET_REPORT_RATE; + cmd[2] = code; + await this.sendWrite(cmd); + return hz; + } + + /** + * Replace one DPI stage. SET_DPI carries the whole stage table, so the other + * stages — and every stage's indicator colour — are read back and echoed + * unchanged rather than zeroed. + */ + async setDpiForStage(x: number, y: number, index: number): Promise { + const { stages, activeIndex } = await this.getDpi(); + const target = index >= 0 && index < stages.length ? index : activeIndex; + stages[target] = { ...stages[target], x, y }; + + const cmd = new Uint8Array(LINGBAO_REPORT_SIZE); + cmd[0] = CMD.SET_DPI; + cmd[1] = this.currentProfile; + cmd[2] = target; + cmd[3] = stages.length; + stages.forEach((stage, i) => { + cmd[DPI_X_OFFSET + i * 2] = stage.x & 0xff; + cmd[DPI_X_OFFSET + 1 + i * 2] = (stage.x >> 8) & 0xff; + cmd[DPI_Y_OFFSET + i * 2] = stage.y & 0xff; + cmd[DPI_Y_OFFSET + 1 + i * 2] = (stage.y >> 8) & 0xff; + cmd[DPI_RGB_OFFSET + i * 3] = (stage.rgb >> 16) & 0xff; + cmd[DPI_RGB_OFFSET + 1 + i * 3] = (stage.rgb >> 8) & 0xff; + cmd[DPI_RGB_OFFSET + 2 + i * 3] = stage.rgb & 0xff; + }); + await this.sendWrite(cmd); + return x; + } + + /** A write takes the same relay path as a read, minus the read-back. */ + private async sendWrite(cmd: Uint8Array): Promise { + return this.enqueue(() => this.writeThrough(cmd)); + } + + private async writeThrough(cmd: Uint8Array): Promise { + const encoded = encodeCommand([...cmd]); + if (this.transport === "direct") { + await this.rawSend(encoded); + await delay(10); + return; + } + await this.selectMouse(); + if (!(await this.waitReady("send"))) { + throw new Error("Lingbao receiver never became ready to send."); + } + await this.rawSend(encoded); + await delay(50); + } + + // --------------------------------------------------------------------------- + // High-Level API + // --------------------------------------------------------------------------- + + async readStatus(): Promise { + await this.open(); + + let battery: number | null = null; + let connectionType: "Wired" | "Wireless" = "Wired"; + let connectionDetail = "USB"; + + if (this.transport === "dongle") { + connectionType = "Wireless"; + connectionDetail = "2.4 GHz"; + const status = await this.readDongleStatus(); + if (!status.mouseOnline) { + throw new Error( + "Lingbao receiver is present but no mouse is linked to it — the mouse is asleep, powered off, or switched to Bluetooth/wired mode. Move the mouse and try again.", + ); + } + battery = status.mouseBattery > 0 && status.mouseBattery <= 100 ? status.mouseBattery : null; + } + + // The DPI read has to work: it carries the values the UI exists to show, + // and it is the cheapest proof the whole relay + checksum path is right. + const dpi = await this.getDpi(); + const active = dpi.stages[dpi.activeIndex] ?? dpi.stages[0]; + + const [rateResult, firmwareResult] = await Promise.allSettled([ + this.getReportRate(), + this.getFirmwareVersion(), + ]); + const pollingRateHz = rateResult.status === "fulfilled" ? rateResult.value : 1000; + const firmware = + firmwareResult.status === "fulfilled" && firmwareResult.value !== 0 + ? [`v${(firmwareResult.value >> 8) & 0xff}.${String(firmwareResult.value & 0xff).padStart(2, "0")}`] + : []; + + const name = this.product ? `Lingbao ${this.product.model}` : this.device.productName || "Lingbao Mouse"; + + return { + brand: "Lingbao", + name, + ui: { + family: "lingbao", + settingsReady: true, + hideLodLow: true, + hideProcessingCard: true, + defaultDisplayName: this.product ? "Lingbao M5 Pro" : name, + }, + batteryPercent: battery, + batteryState: battery === null ? "Unknown" : "Discharging", + dpi: active.x, + dpiY: active.y, + supportsSeparateDpiAxes: true, + dpiStages: dpi.stages.map((stage) => stage.x), + activeDpiStage: dpi.activeIndex, + pollingRateHz, + supportedPollingRates: this.supportedPollingRates, + activeProfile: this.currentProfile, + connectionType, + connectionDetail, + liftOffDistance: null, + firmware: this.product ? [...firmware, this.product.sensor] : firmware, + }; + } + + async setDpi(dpi: number, dpiY = dpi): Promise { + const { activeIndex } = await this.getDpi(); + await this.setDpiForStage(dpi, dpiY, activeIndex); + return dpi; + } + + async setPollingRate(rate: number): Promise { + return this.setReportRate(rate); + } +} diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 8021702..633b2ef 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -118,7 +118,7 @@ export type MouseLightingMode = | "Breathing dual"; export interface MouseStatus { - brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious"; + brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "Lingbao"; name: string; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 80d7219..5e052dd 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -2,6 +2,7 @@ import { AtkHidClient } from "./atk/hid.ts"; import { AttackSharkHidClient } from "./attackshark/hid.ts"; import { EggOp1HidClient } from "./endgame/egg-op1-hid.ts"; import { FantechHidClient } from "./fantech/hid.ts"; +import { LingbaoHidClient } from "./lingbao/hid.ts"; import { eggWeCreate, eggWeIsSupported, eggWeSupportScore, isEggWeClient, type EggWeHidClient } from "./endgame/egg-we-control.ts"; import { FinalmouseHidClient } from "./finalmouse/hid.ts"; import { KeychronM6HidClient } from "./keychron/m6-hid.ts"; @@ -42,7 +43,7 @@ import { GloriousHidClient } from "./glorious/hid.ts"; import { GloriousClassicHidClient } from "./glorious/classic-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 | KeychronM6HidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient; +export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronM6HidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | LingbaoHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient; export interface DeviceDriver { brand: string; @@ -76,6 +77,12 @@ 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) => KeychronNapeHidClient.isSupported(device), create: (device) => new KeychronNapeHidClient(device), score: () => 6 }, + // Ahead of Fantech: the Lingbao M5 Pro answers on the same VID 0x3151, + // usage page 0xFFFF, usage 0x02 interface that FantechHidClient claims, but + // needs the 2.4G relay handshake and the Bit7 checksum that driver has no + // notion of. Scoped to the M5 Pro's two product ids so it cannot shadow + // Fantech's own hardware. + { brand: "Lingbao", supports: (device) => LingbaoHidClient.isSupported(device), create: (device) => new LingbaoHidClient(device), score: () => 7 }, { 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 }, { brand: "WALLHACK", supports: (device) => WallhackMouseHidClient.isSupported(device), create: (device) => new WallhackMouseHidClient(device), score: () => 8 }, diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 8eb3199..8b5ec4c 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -1,4 +1,5 @@ import { EGG_WE_HID_FILTERS } from "./endgame/egg-we-control.ts"; +import { LINGBAO_PRODUCTS, LINGBAO_VENDOR_ID } from "./lingbao/hid.ts"; import { GWOLVES_PRODUCTS } from "./gwolves/products.ts"; import { LOGITECH_BOLT_PRODUCT_IDS, @@ -377,6 +378,15 @@ export const WALLHACK_HID_FILTERS: HIDDeviceFilter[] = [ [WALLHACK_VENDOR_ID, WALLHACK_KEYBOARD_ALT_VENDOR_ID].map((vendorId) => ({ vendorId, productId, usagePage: WALLHACK_KEYBOARD_USAGE_PAGE, usage: WALLHACK_KEYBOARD_USAGE }))), ]; +/** + * The Lingbao M5 Pro's 2.4G receiver and its wired product id. 0x3151 is the + * MicLink/mlzn ODM vendor id, shared with unrelated keyboards and mice, so + * these are requested per product id rather than vendor-wide. + */ +export const LINGBAO_HID_FILTERS: HIDDeviceFilter[] = [...LINGBAO_PRODUCTS.keys()].map( + (productId) => ({ vendorId: LINGBAO_VENDOR_ID, productId, usagePage: 0xffff, usage: 0x02 }), +); + export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...ZAUNKOENIG_PRODUCT_IDS.map((productId) => ({ vendorId: ZAUNKOENIG_VENDOR_ID, @@ -428,6 +438,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...[...NINJUTSO_MOUSE_PRODUCT_IDS, ...NINJUTSO_RECEIVER_PRODUCT_IDS] .map((productId) => ({ vendorId: NINJUTSO_VENDOR_ID, productId })), ...LOGITECH_RECEIVER_FILTERS, + ...LINGBAO_HID_FILTERS, // Fantech mice use vendor usage page 0xFFFF, usage 0x02 for configuration. { vendorId: VENDOR_ID.fantech, usagePage: 0xffff, usage: 0x02 }, ...WALLHACK_HID_FILTERS,