Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/drivers/fantech/hid.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<number>) => {
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
});
});
166 changes: 134 additions & 32 deletions src/drivers/fantech/hid.ts
Original file line numberDiff line numberDiff line change
@@ -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 = {
Expand All@@ -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<number, number> = {
8000: 0,
4000: 1,
Expand All@@ -31,6 +42,20 @@ export const REPORT_RATE_DECODE: Record<number, number> = 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.
*
Expand All@@ -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;
Expand All@@ -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) =>
Expand All@@ -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[] {
Expand All@@ -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<Uint8Array> {
const buf = new Uint8Array(FANTECH_REPORT_SIZE);
buf[0] = cmd;
Expand All@@ -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<Uint8Array> {
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<Uint8Array> {
const view = await this.device.receiveFeatureReport(FANTECH_REPORT_ID);
Expand All@@ -108,58 +185,65 @@ 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;
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 };
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<number> {
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;
Expand All@@ -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<number> {
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<number> {
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();
Expand All@@ -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 =
Expand All@@ -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: [],
};
}

Expand All@@ -243,7 +343,9 @@ export class FantechHidClient {

async setPollingRate(rate: number): Promise<number> {
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;
Expand Down
Loading