From b62b7c04e7b8fa9f27f62b7d8a726b4c0fded91b Mon Sep 17 00:00:00 2001 From: Shubhadeep Date: Mon, 17 Aug 2026 08:42:53 +0530 Subject: [PATCH] feat(fantech): add WG14P Yari Pro driver Add Fantech WG14P (VID 0x3151, PID 0x503D) driver with: - Command-based protocol (Family B) from GearHub qmk.top - DPI read/write: cmd 144 (get), cmd 16 (set) - Report rate: cmd 131 (get), cmd 8 (set) - Rate encoding: 0=8000Hz, 1=4000Hz, 2=2000Hz, 3=1000Hz, 4=500Hz, 5=125Hz - DPI stored as LE uint16 at bytes [8+slot*2..9+slot*2] Protocol reverse-engineered from Fantech web configurator JS bundle. --- src/drivers/fantech/hid.test.ts | 105 +++++++++++++ src/drivers/fantech/hid.ts | 251 ++++++++++++++++++++++++++++++++ src/drivers/mouse-types.ts | 2 +- src/drivers/registry.test.ts | 2 +- src/drivers/registry.ts | 4 +- src/drivers/vendors.ts | 3 + 6 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 src/drivers/fantech/hid.test.ts create mode 100644 src/drivers/fantech/hid.ts diff --git a/src/drivers/fantech/hid.test.ts b/src/drivers/fantech/hid.test.ts new file mode 100644 index 0000000..1c0b5dc --- /dev/null +++ b/src/drivers/fantech/hid.test.ts @@ -0,0 +1,105 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + CMD, + FantechHidClient, + REPORT_RATE_DECODE, + REPORT_RATE_ENCODE, +} from "./hid.ts"; + +function fakeDevice(overrides?: Partial): HIDDevice { + return { + vendorId: 0x3151, + productId: 0x503d, + productName: "Fantech WG14P", + opened: false, + collections: [ + { usagePage: 0xffff, usage: 0x02, type: 0, children: [], input: 0, output: 0, feature: 0 }, + ], + open: async () => { (overrides ?? {}).opened = true; }, + close: async () => {}, + sendFeatureReport: async () => {}, + receiveFeatureReport: async () => new DataView(new ArrayBuffer(64)), + ...overrides, + } as unknown as HIDDevice; +} + +describe("FantechHidClient", () => { + it("isSupported detects Fantech vendor config interface", () => { + const device = fakeDevice(); + assert.equal(FantechHidClient.isSupported(device), true); + }); + + it("isSupported rejects non-Fantech devices", () => { + const device = fakeDevice({ vendorId: 0x046d } as Partial); + assert.equal(FantechHidClient.isSupported(device), false); + }); + + it("sendCommand writes correct command byte", async () => { + let sent: Uint8Array | undefined; + const device = fakeDevice({ + sendFeatureReport: async (_id: number, data: ArrayBuffer | ArrayLike) => { + sent = data instanceof Uint8Array ? data : new Uint8Array(data); + }, + receiveFeatureReport: async () => new DataView(new ArrayBuffer(64)), + }); + const client = new FantechHidClient(device); + await client.sendCommand(CMD.GET_DPI, 0); + assert.ok(sent); + assert.equal(sent![0], CMD.GET_DPI); + assert.equal(sent![1], 0); + }); + + it("getReportRate decodes response correctly", async () => { + const device = fakeDevice({ + sendFeatureReport: async () => {}, + receiveFeatureReport: async () => { + const buf = new ArrayBuffer(64); + const view = new DataView(buf); + view.setUint8(0, CMD.GET_REPORT_RATE); + view.setUint8(2, 3); // 1000 Hz + return view; + }, + }); + const client = new FantechHidClient(device); + const rate = await client.getReportRate(); + assert.equal(rate, 1000); + }); + + it("getDpi decodes response correctly", async () => { + const device = fakeDevice({ + sendFeatureReport: async () => {}, + receiveFeatureReport: async () => { + const buf = new ArrayBuffer(64); + const view = new DataView(buf); + view.setUint8(2, 0); // slot 0 + view.setUint8(3, 1); // 1 slot + // X DPI = 1600 at bytes [8..9] LE + view.setUint8(8, 0x40); + view.setUint8(9, 0x06); + // Y DPI = 1600 at bytes [24..25] LE + view.setUint8(24, 0x40); + view.setUint8(25, 0x06); + return view; + }, + }); + const client = new FantechHidClient(device); + const dpi = await client.getDpi(); + assert.equal(dpi.dpiX, 1600); + assert.equal(dpi.dpiY, 1600); + assert.equal(dpi.slot, 0); + assert.equal(dpi.numSlots, 1); + }); + + it("report rate encode/decode round-trips", () => { + for (const [hz, code] of Object.entries(REPORT_RATE_ENCODE)) { + assert.equal(REPORT_RATE_DECODE[code], Number(hz)); + } + }); + + it("setReportRate rejects unsupported rates", async () => { + const device = fakeDevice(); + const client = new FantechHidClient(device); + await assert.rejects(() => client.setReportRate(9999), /Unsupported rate/); + }); +}); diff --git a/src/drivers/fantech/hid.ts b/src/drivers/fantech/hid.ts new file mode 100644 index 0000000..67735cc --- /dev/null +++ b/src/drivers/fantech/hid.ts @@ -0,0 +1,251 @@ +import type { MouseStatus } from "../mouse-types.ts"; +import { VENDOR_ID } from "../vendors.ts"; + +// Fantech command IDs (from GearHub qmk.top protocol) +export const CMD = { + // Family B (older Fantech mice) command set + GET_ALL_PARAMS: 159, // Get all parameters (profile, report rate, etc.) + GET_DPI: 144, // Get DPI settings (SENSORDPI) + SET_DPI: 16, // Set DPI settings + GET_REPORT_RATE: 131, // Get report/polling rate + SET_REPORT_RATE: 8, // Set report/polling rate + GET_DEBOUNCE: 132, // Get debounce time + SET_DEBOUNCE: 4, // Set debounce time + SET_PROFILE: 2, // Set current profile +} as const; + +// Report rate encoding for Fantech mice +export const REPORT_RATE_ENCODE: Record = { + 8000: 0, + 4000: 1, + 2000: 2, + 1000: 3, + 500: 4, + 125: 5, +}; + +export const REPORT_RATE_DECODE: Record = Object.fromEntries( + Object.entries(REPORT_RATE_ENCODE).map(([k, v]) => [v, Number(k)]), +); + +export const FANTECH_REPORT_ID = 0x00; +export const FANTECH_REPORT_SIZE = 64; + +/** + * Fantech vendor HID control. + * + * Transport: vendor usage page 0xFFFF, usage 0x02 under VID 0x3151. + * The WG14P Yari Pro exposes a 64-byte feature report on this interface + * for DPI and configuration settings. + * + * Protocol: Fantech command-based protocol (Family B). + * - Send command as first byte of 64-byte feature report + * - 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 + */ +export class FantechHidClient { + device: HIDDevice; + currentProfile = 0; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== VENDOR_ID.fantech) return false; + const hasVendorConfig = (collections: readonly HIDCollectionInfo[]): boolean => + collections.some( + (collection) => + (collection.usagePage === 0xffff && + collection.usage === 0x02) || + hasVendorConfig(collection.children), + ); + return hasVendorConfig(device.collections); + } + + get supportedPollingRates(): number[] { + return [125, 250, 500, 1000, 2000, 4000, 8000]; + } + + getDpiOptions(): number[] { + return [ + 400, 800, 1200, 1600, 2000, 2400, 3200, 4000, 4800, 5600, 6400, 8000, + 10000, 12000, 16000, 20000, 26000, 30000, + ]; + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + if (this.device.opened) await this.device.close(); + } + + // --------------------------------------------------------------------------- + // Command I/O + // --------------------------------------------------------------------------- + + /** Send a command and receive the response. */ + async sendCommand(cmd: number, ...payload: number[]): Promise { + const buf = new Uint8Array(FANTECH_REPORT_SIZE); + buf[0] = cmd; + for (let i = 0; i < payload.length && i + 1 < buf.length; i++) { + buf[i + 1] = payload[i]; + } + await this.device.sendFeatureReport(FANTECH_REPORT_ID, buf); + return this.readResponse(); + } + + /** Read a response report. */ + async readResponse(): Promise { + const view = await this.device.receiveFeatureReport(FANTECH_REPORT_ID); + return new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); + } + + // --------------------------------------------------------------------------- + // DPI Protocol + // --------------------------------------------------------------------------- + + /** Read current DPI settings. Returns DPI for active slot. */ + async getDpi(): Promise<{ + dpiX: number; + dpiY: number; + slot: number; + 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 }; + + // 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 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); + + return { + dpiX: dpiX || 1600, + dpiY: dpiY || dpiX || 1600, + slot, + numSlots, + }; + } + + /** Set DPI for a specific slot. */ + 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); + const numSlots = readResp[3] || 1; + + // 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[3] = numSlots; + + // Write X DPI as LE uint16 + buf[8 + slot * 2] = dpiX & 0xff; + buf[9 + slot * 2] = (dpiX >> 8) & 0xff; + + // Write Y DPI as LE uint16 + buf[24 + slot * 2] = dpiY & 0xff; + buf[25 + slot * 2] = (dpiY >> 8) & 0xff; + + await this.device.sendFeatureReport(FANTECH_REPORT_ID, buf); + return dpiX; + } + + // --------------------------------------------------------------------------- + // Report Rate Protocol + // --------------------------------------------------------------------------- + + /** Get current report/polling rate. */ + 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; + } + + /** 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(", ")}`, + ); + } + await this.open(); + const code = REPORT_RATE_ENCODE[hz]; + const buf = new Uint8Array(FANTECH_REPORT_SIZE); + buf[0] = CMD.SET_REPORT_RATE; + buf[1] = code; + await this.device.sendFeatureReport(FANTECH_REPORT_ID, buf); + return hz; + } + + // --------------------------------------------------------------------------- + // High-Level API + // --------------------------------------------------------------------------- + + async readStatus(): Promise { + await this.open(); + const [dpiResult, pollRate] = await Promise.allSettled([ + this.getDpi(), + this.getReportRate(), + ]); + + const dpiData = + dpiResult.status === "fulfilled" ? dpiResult.value : null; + const pollRateVal = + pollRate.status === "fulfilled" ? pollRate.value : 1000; + + return { + brand: "Fantech", + name: this.device.productName || "Fantech Mouse", + ui: { + family: "fantech", + settingsReady: dpiData !== null, + hideLodLow: true, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + defaultDisplayName: this.device.productName || "Fantech Mouse", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: dpiData?.dpiX ?? 1600, + dpiY: dpiData?.dpiY ?? dpiData?.dpiX ?? 1600, + pollingRateHz: pollRateVal, + supportedPollingRates: this.supportedPollingRates, + activeProfile: this.currentProfile, + connectionType: "Wired", + connectionDetail: "USB", + liftOffDistance: null, + firmware: ["Fantech mouse"], + }; + } + + async setDpi(dpi: number, dpiY = dpi): Promise { + await this.open(); + const current = await this.getDpi(); + await this.setDpiForSlot(dpi, dpiY, current.slot); + return dpi; + } + + async setPollingRate(rate: number): Promise { + if (!this.supportedPollingRates.includes(rate)) { + throw new Error("Fantech supports 125, 250, 500, 1000, 2000, 4000, or 8000 Hz."); + } + await this.setReportRate(rate); + return rate; + } +} diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 96b2191..0afa86e 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -113,7 +113,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"; + brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech"; name: string; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index 59aeb3f..a6e8ae5 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -12,7 +12,7 @@ import { ORBITAL_DEVICES } from "@openmouse/protocol/orbital"; const DEVICES_DIR = dirname(fileURLToPath(import.meta.url)); const REPORT_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0x0f, 0x10, 0x11, 0x20, 0xa1]; -const USAGE_PAGES = [0x01, 0x0c, 0xff, 0xff00, 0xff01, 0xff02, 0xff0a, 0xff43, 0xff60]; +const USAGE_PAGES = [0x01, 0x0c, 0xff, 0xff00, 0xff01, 0xff02, 0xff0a, 0xff43, 0xff60, 0xffff]; function report(reportId: number, byteLength = 16): HIDReportInfo { return { reportId, items: [{ reportSize: 8, reportCount: byteLength }] } as unknown as HIDReportInfo; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index e388139..0792640 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -1,6 +1,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 { eggWeCreate, eggWeIsSupported, eggWeSupportScore, isEggWeClient, type EggWeHidClient } from "./endgame/egg-we-control.ts"; import { FinalmouseHidClient } from "./finalmouse/hid.ts"; import { KeychronHidClient } from "./keychron/hid.ts"; @@ -23,7 +24,7 @@ import { WLMouseHidClient } from "./wlmouse/hid.ts"; import { ZaunkoenigHidClient } from "./zaunkoenig/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient; +export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient; export interface DeviceDriver { brand: string; @@ -56,6 +57,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "Attack Shark", supports: (device) => AttackSharkHidClient.isSupported(device), create: (device) => new AttackSharkHidClient(device), score: () => 5 }, { brand: "Razer", supports: (device) => RazerViperV4ProHidClient.isSupported(device), create: (device) => new RazerViperV4ProHidClient(device), score: () => 7 }, { brand: "Keychron", supports: (device) => KeychronHidClient.isSupported(device), create: (device) => new KeychronHidClient(device), score: () => 6 }, + { brand: "Fantech", supports: (device) => FantechHidClient.isSupported(device), create: (device) => new FantechHidClient(device), score: () => 5 }, ]; function driverFor(device: HIDDevice): DeviceDriver | undefined { diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index f0aaf66..14fe28d 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -39,6 +39,7 @@ export const VENDOR_ID = { ninjutsoLegacy: NINJUTSO_LEGACY_VENDOR_ID, ninjutso: NINJUTSO_VENDOR_ID, zaunkoenig: ZAUNKOENIG_VENDOR_ID, + fantech: 0x3151, } as const; // Keychron VIA raw HID. 0x0440 is Nape Pro wired; 0xd026/0xd029 are shared Link-KM receivers. @@ -258,4 +259,6 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...[...NINJUTSO_MOUSE_PRODUCT_IDS, ...NINJUTSO_RECEIVER_PRODUCT_IDS] .map((productId) => ({ vendorId: NINJUTSO_VENDOR_ID, productId })), ...LOGITECH_RECEIVER_FILTERS, + // Fantech mice use vendor usage page 0xFFFF, usage 0x02 for configuration. + { vendorId: VENDOR_ID.fantech, usagePage: 0xffff, usage: 0x02 }, ];