From 335d519ac77130fb6df6f28c7317d7ec1b5697f2 Mon Sep 17 00:00:00 2001 From: AI Date: Sat, 5 Sep 2026 07:57:28 +0700 Subject: [PATCH] lingbao: add M5 Pro driver, and stop Fantech inventing readings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Lingbao M5 Pro (PixArt PAW3395) enumerates under VID 0x3151 on the very same 0xFFFF/0x02 vendor interface FantechHidClient claims, so the registry handed it to that driver — which could not read a single value from it, and reported fabricated ones rather than failing. 0x3151 belongs to MicLink/mlzn, the ODM, not to Fantech. The mouse is configured by GearHub-V5 (qmk.top); its protocol was read out of that bundle and then verified byte for byte against real hardware. LingbaoHidClient implements the three things the Fantech driver lacks: - The Bit7 checksum, byte[7] = 255 - sum(bytes 0..6). Without it the device ACKs the write and answers 64 zero bytes, silently. - The 2.4G relay. A receiver does not forward a command just because one was written to it: select the target (0xF6 0x05), poll status until ready (0xF7), send, then notice-read (0xFC) before reading back. 0xF7 also carries link state and battery. - The mouse-class command set (GET_DPI 0xD4 / SET_DPI 0x54) and its seven-entry report-rate table, in which code 5 means 250 Hz. The Fantech driver carries the six-entry keyboard table, where code 5 means 125 Hz instead. Commands are serialised through a queue. The relay is stateful, so two exchanges in flight at once interleave and corrupt each other — readStatus() ran into this on its first concurrent read. Scoped to the M5 Pro's two product ids (0x402D receiver, 0x4026 wired) so it cannot shadow Fantech's own hardware, and FantechHidClient now leaves those two ids alone; the registry's one-driver-per-device test enforces it. Verified live on a Lingbao M5 Pro receiver: DPI stages 400/800/1600/3200/ 6400/26000 with stage 2 active, 8000 Hz, battery 44%, firmware v3.03, device id 2285. Bluetooth mode is not covered — over BLE the mouse moves to a different usage page and a separate read path. The same investigation showed FantechHidClient inventing data on any device that does not answer, so this also fixes: - readStatus() throws instead of returning 1600 DPI / 8000 Hz decoded from an all-zero reply, letting the caller try the next driver - setDpiForSlot() no longer zeroes every DPI slot it is not writing - 250 Hz is no longer advertised when it cannot be encoded, so the UI cannot render a button that throws on click - the hardcoded "Wired (USB)" and the placeholder firmware string are gone rather than mislabelling wireless units Co-Authored-By: Claude Opus 5 (1M context) --- src/drivers/fantech/hid.test.ts | 74 +++++ src/drivers/fantech/hid.ts | 166 ++++++++-- src/drivers/lingbao/hid.test.ts | 229 +++++++++++++ src/drivers/lingbao/hid.ts | 562 ++++++++++++++++++++++++++++++++ src/drivers/mouse-types.ts | 2 +- src/drivers/registry.ts | 9 +- src/drivers/vendors.ts | 11 + 7 files changed, 1019 insertions(+), 34 deletions(-) create mode 100644 src/drivers/lingbao/hid.test.ts create mode 100644 src/drivers/lingbao/hid.ts 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,