diff --git a/docs/fater-mcr-9000b.md b/docs/fater-mcr-9000b.md new file mode 100644 index 0000000..367ae9e --- /dev/null +++ b/docs/fater-mcr-9000b.md @@ -0,0 +1,47 @@ +# Fater MCR-9000B (Holtek 04d9:a09f) + +Requested in OpenMouse-Project/openmouse#394. No capture of this mouse +exists yet; the driver is read-only until one does. + +## What the owner reported + +- VID `0x04d9` (Holtek), PID `0xa09f`, bcdDevice `0x0302`, manufacturer + string "E-Signal", product string "USB Gaming Mouse". +- Interface 0: boot mouse. Interface 1: keyboard, consumer, system control. +- Interface 2: usage page `0xFF00`, usage `0xFF00`, a 33-byte output report + (32 data bytes) and a 9-byte feature report (8 data bytes), descriptor 28 + bytes. Only the OUT endpoint was listed; no input report is declared. +- Vendor app: "Fater MCR-9000B Gaming Mouse.zip" from dl.faterco.ir + (461 MB, not inspected). Manual lists DPI 1000 to 12400 in six steps, + polling rate and response time settings, ARGB lighting, macros. + +## Where the frame comes from + +The 8-byte feature report plus 32-byte output report is the shape of the +Holtek OEM protocol that `pbludov/hv-ms735-config` drives on the HAVIT MS735 +(`04d9:a100`) and that the HP G360 tool (`12c9:1027`) uses: + +- byte 0 is the command, bit 7 set makes it a read, byte 7 is + `0xFF - sum(bytes 0..6)`. +- A read is `SET_FEATURE` followed by `GET_FEATURE`; the reply echoes the + command byte and carries the value at offset 2. +- `0x82` blink/ping, `0x83`/`0x03` polling divider (1000 Hz / n), + `0x84`/`0x04` active profile (1 to 8). +- `0x8C`/`0x0C` control page and `0x8D`/`0x0D` button page are 128-byte + blocks (DPI table at offsets 84 and 92, dpi = (byte + 1) * 100 on the + MS735) read over interrupt IN and written as 32-byte output reports. The + MCR-9000B declares no input report, so WebHID on Windows cannot read + them; the vendor app may use a different read path. + +The MS735 DPI encoding tops out at 12000, while the MCR-9000B advertises +12400, so even the page layout is likely to differ. Treat every command +above as a hypothesis for this mouse. + +## To finish + +1. Owner connects in Chrome and reports whether the status shows a polling + rate (proves the frame and the GET echo). +2. Owner records a USBPcap capture per the app's `docs/usb-capture-guide.md` + while changing polling rate, DPI, and one profile in the vendor app. +3. Add the writes (same frame without bit 7) and decode the DPI path from + the capture. diff --git a/package.json b/package.json index eec295d..6ca6e18 100644 --- a/package.json +++ b/package.json @@ -200,6 +200,10 @@ "./delux": { "types": "./dist/delux/index.d.ts", "import": "./dist/delux/index.js" + }, + "./fater": { + "types": "./dist/fater/index.d.ts", + "import": "./dist/fater/index.js" } }, "scripts": { diff --git a/src/drivers/fater/hid.test.ts b/src/drivers/fater/hid.test.ts new file mode 100644 index 0000000..b0e2c34 --- /dev/null +++ b/src/drivers/fater/hid.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { FaterHidClient } from "./hid.ts"; +import { + FATER_CMD, + FATER_CONFIG_USAGE, + FATER_CONFIG_USAGE_PAGE, + FATER_MCR_9000B_PRODUCT_ID, + FATER_VENDOR_ID, + faterDecodeReply, + faterDecodeValue, + faterDividerToHz, + faterEncodeCommand, + faterEncodeGet, + faterHzToDivider, +} from "../../fater/index.ts"; + +const hex = (bytes: Uint8Array) => [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(" "); + +// Frames hv-ms735-config puts on the wire (its report() helper, report id +// stripped): CmdPing = 0x82, CmdGetReportRateDivider = 0x83, and a divider +// write of 2 (500 Hz). +test("encodes the Holtek 8-byte frame with the 0xFF-minus-sum checksum", () => { + assert.equal(hex(faterEncodeGet(FATER_CMD.blink)), "82 00 00 00 00 00 00 7d"); + assert.equal(hex(faterEncodeGet(FATER_CMD.reportRateDivider)), "83 00 00 00 00 00 00 7c"); + assert.equal(hex(faterEncodeCommand(FATER_CMD.reportRateDivider, [0, 2])), "03 00 02 00 00 00 00 fa"); +}); + +test("decodes a GET reply with or without a leading report-id byte", () => { + const bare = new Uint8Array([0x83, 0x00, 0x04, 0, 0, 0, 0, 0x78]); + const numbered = new Uint8Array([0x00, ...bare]); + assert.equal(faterDecodeValue(bare, FATER_CMD.reportRateDivider), 4); + assert.equal(faterDecodeValue(numbered, FATER_CMD.reportRateDivider), 4); + assert.equal(faterDecodeReply(bare, FATER_CMD.profile | 0x80), null); + assert.equal(faterDecodeValue(new Uint8Array(3), FATER_CMD.reportRateDivider), null); +}); + +test("polling dividers are 1000 Hz over the byte", () => { + assert.deepEqual([1, 2, 4, 8].map(faterDividerToHz), [1000, 500, 250, 125]); + assert.equal(faterDividerToHz(0), null); + assert.equal(faterHzToDivider(125), 8); + assert.throws(() => faterHzToDivider(2000), RangeError); +}); + +function fakeDevice(replies: Record, usagePage = FATER_CONFIG_USAGE_PAGE) { + const sent: Uint8Array[] = []; + let last = 0; + const device = { + vendorId: FATER_VENDOR_ID, + productId: FATER_MCR_9000B_PRODUCT_ID, + productName: "USB Gaming Mouse", + opened: false, + collections: [{ usagePage, usage: FATER_CONFIG_USAGE, featureReports: [], inputReports: [], outputReports: [], children: [] }], + open: async () => { (device as { opened: boolean }).opened = true; }, + close: async () => {}, + sendFeatureReport: async (_id: number, data: Uint8Array) => { + sent.push(new Uint8Array(data)); + last = data[0]! & 0x7f; + }, + receiveFeatureReport: async () => { + const value = replies[last]; + if (value === undefined) throw new Error("STALL"); + return new DataView(new Uint8Array([last | 0x80, 0, value, 0, 0, 0, 0, 0]).buffer); + }, + } as unknown as HIDDevice; + return { device, sent }; +} + +test("isSupported needs the Holtek VID, the MCR-9000B PID and the 0xFF00 collection", () => { + assert.equal(FaterHidClient.isSupported(fakeDevice({}).device), true); + assert.equal(FaterHidClient.isSupported(fakeDevice({}, 0x01).device), false); + const other = fakeDevice({}).device as unknown as { productId: number }; + other.productId = 0xa100; + assert.equal(FaterHidClient.isSupported(other as unknown as HIDDevice), false); +}); + +test("readStatus reports polling rate and profile from the two GET replies", async () => { + const { device, sent } = fakeDevice({ [FATER_CMD.reportRateDivider]: 2, [FATER_CMD.profile]: 3 }); + const status = await new FaterHidClient(device).readStatus(); + assert.equal(status.name, "Fater MCR-9000B"); + assert.equal(status.pollingRateHz, 500); + assert.equal(status.activeProfile, 3); + assert.equal(status.ui?.settingsReady, false); + assert.equal(status.ui?.valuesVerified, true); + assert.deepEqual(status.firmware, []); + assert.deepEqual(sent.map(hex), ["83 00 00 00 00 00 00 7c", "84 00 00 00 00 00 00 7b"]); +}); + +test("readStatus degrades to identity when the mouse never answers", async () => { + const { device } = fakeDevice({}); + const status = await new FaterHidClient(device).readStatus(); + assert.equal(status.pollingRateHz, 0); + assert.equal(status.activeProfile, null); + assert.equal(status.ui?.valuesVerified, false); +}); diff --git a/src/drivers/fater/hid.ts b/src/drivers/fater/hid.ts new file mode 100644 index 0000000..109acb8 --- /dev/null +++ b/src/drivers/fater/hid.ts @@ -0,0 +1,96 @@ +import type { MouseStatus } from "../mouse-types.ts"; +import { + FATER_CMD, + FATER_CONFIG_USAGE_PAGE, + FATER_POLLING_RATES, + FATER_PRODUCT_IDS, + FATER_PRODUCT_NAMES, + FATER_REPORT_ID, + FATER_VENDOR_ID, + faterDecodeValue, + faterDividerToHz, + faterEncodeGet, +} from "../../fater/index.ts"; + +/** + * Fater MCR-9000B over its Holtek vendor feature report (see + * `@openmouse/protocol/fater`). Read-only on purpose: the frame is borrowed + * from a sibling firmware and has not been seen on this mouse, so the first + * hardware test only has to prove that the two GET commands echo back. Writes + * (polling divider, active profile) are the same frame without the GET bit + * and follow once a read round-trips. + */ +export class FaterHidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== FATER_VENDOR_ID) return false; + if (!FATER_PRODUCT_IDS.includes(device.productId)) return false; + const search = (collection: HIDCollectionInfo): boolean => + collection.usagePage === FATER_CONFIG_USAGE_PAGE || collection.children.some(search); + return device.collections.some(search); + } + + getDpiOptions(): number[] { + return []; + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + if (this.device.opened) await this.device.close(); + } + + async readStatus(): Promise { + await this.open(); + const divider = await this.query(FATER_CMD.reportRateDivider); + const profile = await this.query(FATER_CMD.profile); + const pollingRateHz = divider === null ? null : faterDividerToHz(divider); + const name = FATER_PRODUCT_NAMES.get(this.device.productId) + ?? (this.device.productName?.trim() || "Fater mouse"); + return { + brand: "Fater", + name, + ui: { + family: "fater", + settingsReady: false, + valuesVerified: pollingRateHz !== null, + hideUnsupportedPollingRates: true, + statusNote: pollingRateHz === null + ? "The mouse did not answer its polling-rate query, so nothing could be read." + : "Read-only for now: polling rate and active profile. DPI lives in a config page WebHID cannot read on Windows.", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: 0, + pollingRateHz: pollingRateHz ?? 0, + supportedPollingRates: [...FATER_POLLING_RATES], + activeProfile: profile, + liftOffDistance: null, + connectionType: "Wired", + firmware: [], + }; + } + + /** Value byte of a GET reply, or null when the mouse does not echo the command. */ + private query(command: number): Promise { + const run = this.queue.then(async () => { + try { + await this.device.sendFeatureReport(FATER_REPORT_ID, faterEncodeGet(command)); + const view = await this.device.receiveFeatureReport(FATER_REPORT_ID); + return faterDecodeValue(new Uint8Array(view.buffer, view.byteOffset, view.byteLength), command); + } catch { + return null; + } + }); + this.queue = run.catch(() => undefined); + return run; + } +} diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 5838179..f123919 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -153,7 +153,7 @@ export interface AtkReceiverInfo { } export interface MouseStatus { - brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo"; + brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater"; 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 74d92d4..80e7f71 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -64,6 +64,7 @@ import { MicrosoftHidClient } from "./microsoft/hid.ts"; import { MotospeedHidClient } from "./motospeed/hid.ts"; import { DareuHidClient } from "./dareu/hid.ts"; import { RedragonHidClient } from "./redragon/hid.ts"; +import { FaterHidClient } from "./fater/hid.ts"; import { IncottHidClient } from "./incott/hid.ts"; import { HyperXHidClient } from "./hyperx/hid.ts"; import { MchoseV3HidClient } from "./mchose/v3-hid.ts"; @@ -72,7 +73,7 @@ import { BytechHidClient } from "./bytech/hid.ts"; import { RapooHidClient } from "./rapoo/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient; export interface DeviceDriver { brand: string; @@ -86,6 +87,9 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ {brand: "ASUS",supports: (device) => AsusHidClient.isSupported(device), create: (device) => new AsusHidClient(device), score: () => 10,}, { brand: "Dareu", supports: (device) => DareuHidClient.isSupported(device), create: (device) => new DareuHidClient(device), score: () => 9 }, { brand: "Redragon", supports: (device) => RedragonHidClient.isSupported(device), create: (device) => new RedragonHidClient(device), score: () => 9 }, + // Shares Holtek's 0x04d9 with Redragon; disjoint by product id and by usage + // page (0xff00 here, 0xffa0 there). + { brand: "Fater", supports: (device) => FaterHidClient.isSupported(device), create: (device) => new FaterHidClient(device), score: () => 7 }, { brand: "Motospeed", supports: (device) => MotospeedHidClient.isSupported(device), create: (device) => new MotospeedHidClient(device), score: () => 7 }, { brand: "Zaunkoenig", supports: (device) => ZaunkoenigHidClient.isSupported(device), create: (device) => new ZaunkoenigHidClient(device), score: () => 10 }, { brand: "Corsair", supports: (device) => CorsairHidClient.isSupported(device), create: (device) => new CorsairHidClient(device), score: () => 8 }, diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 3969115..50fc7a9 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -110,6 +110,12 @@ import { REDRAGON_PRODUCT_IDS, REDRAGON_VENDOR_ID, } from "@openmouse/protocol/redragon"; +import { + FATER_CONFIG_USAGE, + FATER_CONFIG_USAGE_PAGE, + FATER_PRODUCT_IDS, + FATER_VENDOR_ID, +} from "@openmouse/protocol/fater"; import { MOTOSPEED_PRODUCTS, MOTOSPEED_USAGE_PAGE, MOTOSPEED_VENDOR_ID } from "@openmouse/protocol/motospeed"; export const VENDOR_ID = { @@ -161,6 +167,7 @@ export const VENDOR_ID = { microsoft: MICROSOFT_VENDOR_ID, dareu: DAREU_VENDOR_ID, redragon: REDRAGON_VENDOR_ID, + fater: FATER_VENDOR_ID, // Shares 0x093a with Glorious's Pixart-based Model O 2 / I 2 family (see // `glorious` above); GloriousHidClient.isSupported() only claims its own // catalogue product ids, so the two never overlap. @@ -196,6 +203,14 @@ export const DAREU_HID_FILTERS: HIDDeviceFilter[] = [...DAREU_PRODUCT_IDS].map(( usage: DAREU_COMMAND_USAGE, })); +/** Holtek vendor collection on the MCR-9000B's interface 2, as reported by its owner. */ +export const FATER_HID_FILTERS: HIDDeviceFilter[] = [...FATER_PRODUCT_IDS].map((productId) => ({ + vendorId: FATER_VENDOR_ID, + productId, + usagePage: FATER_CONFIG_USAGE_PAGE, + usage: FATER_CONFIG_USAGE, +})); + /** Holtek config collection measured on the M724 K1NG 1K (usbmon + usbhid-dump). */ export const REDRAGON_HID_FILTERS: HIDDeviceFilter[] = [...REDRAGON_PRODUCT_IDS].map((productId) => ({ vendorId: REDRAGON_VENDOR_ID, @@ -721,6 +736,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...ASUS_HID_FILTERS, ...DAREU_HID_FILTERS, ...REDRAGON_HID_FILTERS, + ...FATER_HID_FILTERS, ...MOTOSPEED_HID_FILTERS, ...ZAUNKOENIG_PRODUCT_IDS.map((productId) => ({ vendorId: ZAUNKOENIG_VENDOR_ID, diff --git a/src/fater/index.ts b/src/fater/index.ts new file mode 100644 index 0000000..42921a4 --- /dev/null +++ b/src/fater/index.ts @@ -0,0 +1,100 @@ +/** + * Fater MCR-9000B (`04d9:a09f`): a Holtek "E-Signal" OEM mouse. + * + * Framing comes from two independent decodes of the same OEM firmware + * family, not from a capture of this mouse: + * + * - pbludov/hv-ms735-config (GPL-2.0), a working Linux/Windows driver for the + * HAVIT MS735 (`04d9:a100`): 8-byte unnumbered feature report, byte 0 is a + * command, bit 7 set turns a command into a GET, byte 7 is a checksum. + * - The HP G360 (`12c9:1027`) vendor tool, decoded statically from its + * installer, which uses the identical frame and checksum. + * + * The MCR-9000B's vendor interface (usage page 0xFF00) declares exactly that + * 8-byte feature report plus a 32-byte output report, so the frame fits. + * Everything below is unverified on this mouse until a hardware test. + * + * Settings that need the 128-byte config pages (DPI table, buttons, lights) + * arrive as raw interrupt-IN data with no input report declared, which WebHID + * on Windows never delivers, so they stay out until a capture shows another + * path. + */ + +export const FATER_VENDOR_ID = 0x04d9; // Holtek Semiconductor +export const FATER_MCR_9000B_PRODUCT_ID = 0xa09f; +export const FATER_PRODUCT_NAMES: ReadonlyMap = new Map([ + [FATER_MCR_9000B_PRODUCT_ID, "Fater MCR-9000B"], +]); +export const FATER_PRODUCT_IDS: readonly number[] = [...FATER_PRODUCT_NAMES.keys()]; + +/** Vendor collection on USB interface 2 (reported by the MCR-9000B's owner). */ +export const FATER_CONFIG_USAGE_PAGE = 0xff00; +export const FATER_CONFIG_USAGE = 0xff00; +/** The feature report is unnumbered. */ +export const FATER_REPORT_ID = 0; +/** Payload bytes after the report id. */ +export const FATER_COMMAND_SIZE = 8; + +/** Command byte with this bit set reads the value instead of writing it. */ +export const FATER_GET_FLAG = 0x80; +export const FATER_CMD = { + /** Replies with its own command byte and nothing else; the liveness probe. */ + blink: 0x02, + /** Polling divider: 1000 Hz divided by the byte at offset 2. */ + reportRateDivider: 0x03, + /** Active onboard profile, 1-based, at offset 2. */ + profile: 0x04, +} as const; + +export const FATER_PROFILE_COUNT = 8; +/** Dividers 1, 2, 4, 8 of the 1000 Hz base rate. */ +export const FATER_POLLING_RATES: readonly number[] = [125, 250, 500, 1000]; + +/** Byte 7 is 0xFF minus the sum of bytes 0..6, modulo 256. */ +export function faterChecksum(payload: Uint8Array): number { + let sum = 0xff; + for (let i = 0; i < FATER_COMMAND_SIZE - 1; i += 1) sum -= payload[i] ?? 0; + return sum & 0xff; +} + +/** 8-byte feature payload: command, up to six argument bytes, checksum. */ +export function faterEncodeCommand(command: number, args: readonly number[] = []): Uint8Array { + if (args.length > FATER_COMMAND_SIZE - 2) throw new RangeError("Fater commands carry at most six argument bytes."); + const payload = new Uint8Array(FATER_COMMAND_SIZE); + payload[0] = command & 0xff; + args.forEach((value, i) => { payload[i + 1] = value & 0xff; }); + payload[FATER_COMMAND_SIZE - 1] = faterChecksum(payload); + return payload; +} + +export function faterEncodeGet(command: number): Uint8Array { + return faterEncodeCommand(command | FATER_GET_FLAG); +} + +/** + * The 8-byte payload of a reply that echoes `command` in its first byte, or + * null. Accepts the reply with or without a leading report-id byte, since + * WebHID implementations differ on whether unnumbered reports carry one. + */ +export function faterDecodeReply(reply: Uint8Array, command: number): Uint8Array | null { + const body = reply.length > FATER_COMMAND_SIZE && reply[0] === FATER_REPORT_ID ? reply.subarray(1) : reply; + if (body.length < FATER_COMMAND_SIZE || body[0] !== (command & 0xff)) return null; + return body.subarray(0, FATER_COMMAND_SIZE); +} + +/** Polling rate in Hz for a divider byte; null for the 0 the firmware never sends. */ +export function faterDividerToHz(divider: number): number | null { + return divider > 0 ? Math.round(1000 / divider) : null; +} + +export function faterHzToDivider(hz: number): number { + const divider = 1000 / hz; + if (!Number.isInteger(divider) || divider < 1 || divider > 0xff) throw new RangeError(`Fater mice cannot poll at ${hz} Hz.`); + return divider; +} + +/** Divider or profile replies carry the value at offset 2 (byte 1 is a zero pad). */ +export function faterDecodeValue(reply: Uint8Array, command: number): number | null { + const body = faterDecodeReply(reply, command | FATER_GET_FLAG); + return body ? body[2]! : null; +} diff --git a/src/index.ts b/src/index.ts index aff8ad5..ae47b94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,3 +34,4 @@ export * as delux from "./delux/index.js"; export * as bytech from "./bytech/index.js"; export * as motospeed from "./motospeed/index.js"; export * as rapoo from "./rapoo/index.js"; +export * as fater from "./fater/index.js"; diff --git a/tsconfig.json b/tsconfig.json index 4404799..d8ca76f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -48,7 +48,8 @@ "@openmouse/protocol/corsair": ["./src/corsair/index.ts"], "@openmouse/protocol/delux": ["./src/delux/index.ts"], "@openmouse/protocol/bytech": ["./src/bytech/index.ts"], - "@openmouse/protocol/rapoo": ["./src/rapoo/index.ts"] + "@openmouse/protocol/rapoo": ["./src/rapoo/index.ts"], + "@openmouse/protocol/fater": ["./src/fater/index.ts"] } }, "include": ["src/**/*"],