From 0f2cfefe142fcb8e189b93ae771311192796ef15 Mon Sep 17 00:00:00 2001 From: javinnav Date: Sat, 14 Nov 2026 13:31:25 +0000 Subject: [PATCH 1/4] feat(utechsmart): add Venus MMO (04d9:fc55) Holtek codec Pure codecs for the UtechSmart Venus MMO wired mouse: F2/F3 feature-report framing, reply validation, DPI (x1/x2/x3 range bands), button actions including modifier combos, status decoding and minimal write plans. Decoded against a memory dump and vendor-UI readings from real hardware; F1 commit and F5 polling commands remain unverified. --- package.json | 4 + src/index.ts | 1 + src/utechsmart/index.ts | 2 + src/utechsmart/venus.test.ts | 241 ++++++++++++++++++++++++ src/utechsmart/venus.ts | 348 +++++++++++++++++++++++++++++++++++ 5 files changed, 596 insertions(+) create mode 100644 src/utechsmart/index.ts create mode 100644 src/utechsmart/venus.test.ts create mode 100644 src/utechsmart/venus.ts diff --git a/package.json b/package.json index a360811..118dbee 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,10 @@ "types": "./dist/redragon/index.d.ts", "import": "./dist/redragon/index.js" }, + "./utechsmart": { + "types": "./dist/utechsmart/index.d.ts", + "import": "./dist/utechsmart/index.js" + }, "./drivers": { "types": "./dist/drivers/index.d.ts", "import": "./dist/drivers/index.js" diff --git a/src/index.ts b/src/index.ts index ce0629f..5fc4cfe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ export * as incott from "./incott/index.js"; export * as ryunix from "./ryunix/index.js"; export * as amkette from "./amkette/index.js"; export * as redragon from "./redragon/index.js"; +export * as utechsmart from "./utechsmart/index.js"; export * as delux from "./delux/index.js"; export * as bytech from "./bytech/index.js"; export * as motospeed from "./motospeed/index.js"; diff --git a/src/utechsmart/index.ts b/src/utechsmart/index.ts new file mode 100644 index 0000000..3b3672c --- /dev/null +++ b/src/utechsmart/index.ts @@ -0,0 +1,2 @@ +/** Pure FC55 codecs; no transport, device discovery or automatic commit sequence. */ +export * from "./venus.js"; diff --git a/src/utechsmart/venus.test.ts b/src/utechsmart/venus.test.ts new file mode 100644 index 0000000..59cf818 --- /dev/null +++ b/src/utechsmart/venus.test.ts @@ -0,0 +1,241 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import * as v from "./index.js"; + +const hex = (text: string) => Uint8Array.from(text.trim().split(/\s+/).map((byte) => Number.parseInt(byte, 16))); +// Sanitized excerpts of the user's real 04d9:fc55 dump0-500.bin. No runtime file I/O. +const factoryDpi = hex("05 00 00 00 01 16 00 00 00 00 01 2d 00 00 00 00 01 5a 00 00 00 00 01 5c 01 00 00 00 01 7c 04 00 00 00"); +const profile2Dpi = hex("05 00 03 00 01 09 00 00 00 00 01 0b 00 00 00 00 01 12 00 00 00 00 01 16 00 00 00 00 01 1b 00 00 00 00"); +const factoryButtons = hex(`14 00 81 00 00 00 82 00 00 00 83 00 00 00 99 81 03 00 8a 00 00 00 89 00 00 00 + 90 00 1e 00 90 00 1f 00 90 00 20 00 90 00 21 00 90 00 22 00 90 00 23 00 + 90 00 24 00 90 00 25 00 90 00 26 00 90 00 27 00 90 00 57 00 90 00 56 00 + 00 00 00 00 8d 00 00 00 00 00 00 00 00 00 00 00 8b 00 00 00 8c 00 00 00 ff ff ff ff`); +const profile2Buttons = hex(`14 00 81 00 00 00 82 00 00 00 83 00 00 00 99 81 03 00 8a 00 00 00 89 00 00 00 + 8f 01 1e 00 8f 01 1f 00 8f 01 20 00 8f 01 21 00 8f 01 22 00 8f 01 23 00 + 8f 01 24 00 8f 01 25 00 8f 01 26 00 8f 01 27 00 8f 01 57 00 8f 01 56 00 + 00 00 00 00 8d 00 00 00 00 00 00 00 00 00 00 00 8b 00 00 00 8c 00 00 00 ff ff ff ff`); +function fixture(): v.UtechsmartVenusMemory { + const data = new Uint8Array(0x3b6); + data.set(hex("01 00 00 dc 05 c0 02 00 01 00 02 00 02 00 02 00"), 0x2c); + [0x40, 0x100, 0x1b0, 0x260, 0x310].forEach((base, p) => { + data.set(p === 1 ? profile2Dpi : factoryDpi, base); + if (p === 2 || p === 4) data[base + 2] = 1; + }); + [0x80, 0x140, 0x1f0, 0x2a0, 0x350].forEach((base, p) => data.set(p === 1 ? profile2Buttons : factoryButtons, base)); + return { address: 0, data }; +} + +test("identity, interface and report sizes", () => { + assert.equal(v.UTECHSMART_VENUS_VENDOR_ID, 0x04d9); + assert.equal(v.UTECHSMART_VENUS_PRODUCT_ID, 0xfc55); + assert.equal(v.UTECHSMART_VENUS_INTERFACE, 2); + assert.equal(v.UTECHSMART_VENUS_CONFIG_USAGE_PAGE, 0xffa0); + assert.equal(v.UTECHSMART_VENUS_CONFIG_USAGE, 1); + assert.equal(v.UTECHSMART_VENUS_REPORT_SIZE, 16); + assert.equal(v.UTECHSMART_VENUS_LONG_REPORT_SIZE, 64); +}); + +test("hardware read/write frames include id, zero reserved bytes and no checksum", () => { + assert.deepEqual(v.utechsmartVenusEncodeRead(0x123, 8), hex("02 f2 23 01 08 00 00 00 00 00 00 00 00 00 00 00")); + assert.deepEqual(v.utechsmartVenusEncodeWrite(0x123, [1, 2, 3]), hex("02 f3 23 01 03 00 00 00 01 02 03 00 00 00 00 00")); + assert.equal(v.utechsmartVenusEncodeRead(0x100, 56)[4], 56); + assert.equal(v.utechsmartVenusReadReportId(8), 2); + assert.equal(v.utechsmartVenusReadReportId(9), 3); + assert.equal(v.utechsmartVenusReadReportId(56), 3); + for (const address of [-1, 0x10000, 0.5, NaN]) assert.throws(() => v.utechsmartVenusEncodeRead(address, 1)); + for (const length of [0, 57, 1.5, NaN]) assert.throws(() => v.utechsmartVenusEncodeRead(0, length)); + assert.throws(() => v.utechsmartVenusEncodeRead(0xffff, 2)); + for (const data of [[], new Array(9).fill(0), [256], [-1], [0.5]]) assert.throws(() => v.utechsmartVenusEncodeWrite(0, data)); + assert.throws(() => v.utechsmartVenusEncodeWrite(0xffff, [1, 2])); +}); + +test("F1 controls are explicit and cannot build reset; F5 is polling, never a bracket", () => { + const controls = { enter: 1, buttons: 2, dpi: 4, led: 8, exit: 16 } as const; + for (const [name, code] of Object.entries(controls)) { + const frame = v.utechsmartVenusEncodeControl(name as keyof typeof controls); + assert.equal(frame.length, 16); + assert.deepEqual([...frame.slice(0, 4)], [2, 0xf1, 2, code]); + assert.ok(frame.slice(4).every((b) => b === 0)); + } + assert.throws(() => v.utechsmartVenusEncodeControl("reset" as "enter")); + for (const [hz, code] of [[125, 8], [250, 4], [500, 2], [1000, 1]]) { + assert.deepEqual(v.utechsmartVenusEncodePolling(hz!), Uint8Array.from([2, 0xf5, code!, ...new Array(13).fill(0)])); + } + assert.throws(() => v.utechsmartVenusEncodePolling(2000)); +}); + +function reply(address: number, length: number): Uint8Array { + const bytes = new Uint8Array(length <= 8 ? 16 : 64); + bytes.set([length <= 8 ? 2 : 3, 8, 0, 0x60 + (address >> 8), length, 0, 0xfa, 0xfa]); + bytes.set(Array.from({ length }, (_, i) => i + 1), 8); + return bytes; +} +test("reply validation accepts unreliable odd-address echo and both report formats", () => { + for (const length of [1, 8, 9, 56]) { + const answer = reply(0x123, length); + assert.deepEqual(v.utechsmartVenusDecodeRead(answer, 0x123, length), answer.slice(8, 8 + length)); + assert.deepEqual(v.utechsmartVenusDecodeRead(answer.subarray(1), 0x123, length), answer.slice(8, 8 + length)); + answer[2] = 0x22; + assert.equal(v.utechsmartVenusDecodeRead(answer, 0x123, length)[0], 1); + } +}); +test("reply validation rejects tag, length, sentinel, report id, truncation and wrong status", () => { + for (const [index, value] of [[0, 3], [1, 7], [3, 0x32], [4, 7], [6, 0], [7, 0]]) { + const answer = reply(0x123, 8); + answer[index!] = value!; + assert.throws(() => v.utechsmartVenusDecodeRead(answer, 0x123, 8)); + } + assert.throws(() => v.utechsmartVenusDecodeRead(reply(0x123, 8).slice(0, 15), 0x123, 8)); + const unknownStatus = reply(0x123, 8); + unknownStatus[3] = 0x99; + assert.equal(v.utechsmartVenusDecodeRead(unknownStatus, 0x123, 8, { validateStatus: false }).length, 8); +}); + +test("profile, polling, stage and button address helpers enforce integer bounds", () => { + const dpiBases = [0x40, 0x100, 0x1b0, 0x260, 0x310]; + const buttonBases = [0x80, 0x140, 0x1f0, 0x2a0, 0x350]; + for (let p = 0; p < 5; p++) { + assert.equal(v.utechsmartVenusProfileAddress(p), dpiBases[p]); + assert.equal(v.utechsmartVenusPollAddress(p), 0x32 + 2 * p); + assert.equal(v.utechsmartVenusStageAddress(p, 9), dpiBases[p]! + 58); + assert.equal(v.utechsmartVenusButtonAddress(p, 19), buttonBases[p]! + 78); + } + for (const p of [-1, 5, 0.5, NaN]) { + for (const call of [() => v.utechsmartVenusProfileAddress(p), () => v.utechsmartVenusPollAddress(p), () => v.utechsmartVenusStageAddress(p, 0), () => v.utechsmartVenusButtonAddress(p, 0)]) assert.throws(call); + } + for (const s of [-1, 10, 0.5, NaN]) assert.throws(() => v.utechsmartVenusStageAddress(0, s)); + for (const s of [-1, 20, 0.5, NaN]) assert.throws(() => v.utechsmartVenusButtonAddress(0, s)); +}); + +const pairs = [[0x09, 0, 400], [0x0b, 0, 500], [0x12, 0, 800], [0x16, 0, 1000], [0x1b, 0, 1200], [0x2d, 0, 2000], [0x5a, 0, 4000], [0x5c, 1, 8200], [0x71, 1, 10000], [0x6a, 4, 14100], [0x7c, 4, 16400]] as const; +for (const [value, range, dpi] of pairs) test(`confirmed DPI ${dpi} = (${value}, ${range})`, () => { + assert.equal(v.utechsmartVenusDecodeDpi(value, range), dpi); + assert.deepEqual(v.utechsmartVenusEncodeDpi(dpi), { value, range, dpi }); +}); +test("all vendor DPI labels round-trip, with lower ties and cap exception", () => { + assert.equal(v.UTECHSMART_VENUS_DPI_LABELS.length, 106); + for (const dpi of v.UTECHSMART_VENUS_DPI_LABELS) { + const encoded = v.utechsmartVenusEncodeDpi(dpi); + assert.equal(encoded.dpi, dpi); + assert.equal(v.utechsmartVenusDecodeDpi(encoded.value, encoded.range), dpi); + } + for (const [input, output] of [[200, 200], [250, 200], [251, 300], [6300, 6200], [6301, 6400], [12500, 12400], [12501, 12600], [12750, 12600], [12751, 12900], [16300, 16200], [16301, 16400], [15600, 15600], [16200, 16200], [16400, 16400]]) { + assert.equal(v.utechsmartVenusEncodeDpi(input!).dpi, output); + } + for (const dpi of [199, 16401, NaN, Infinity, 200.5]) assert.throws(() => v.utechsmartVenusEncodeDpi(dpi)); + assert.equal(v.utechsmartVenusDecodeDpi(5, 4), 670); + assert.equal(v.utechsmartVenusDecodeDpi(5, 1), 440); + assert.equal(v.utechsmartVenusDecodeDpi(5, 0xfe), 220); + assert.equal(v.utechsmartVenusDecodeDpi(9, 0xfe), 400); +}); + +test("button codec reuses Holtek names, supports single/keypad keys and standard 8-bit modifiers", () => { + for (const [name, code] of [["Left click", 0x81], ["Right click", 0x82], ["Middle click", 0x83], ["Back", 0x84], ["Forward", 0x85], ["DPI down", 0x89], ["DPI up", 0x8a], ["Profile cycle", 0x8d], ["Disabled", 0]] as const) { + const action = { kind: "action", name } as const; + assert.deepEqual(v.utechsmartVenusDecodeButtonAction([code, 0, 0, 0]), action); + assert.deepEqual(v.utechsmartVenusEncodeButtonAction(action), Uint8Array.from([code, 0, 0, 0])); + } + for (const usage of [0x1e, 0x27, 0x57, 0x56]) { + assert.deepEqual(v.utechsmartVenusDecodeButtonAction([0x90, 0, usage, 0]), { kind: "key", usage }); + assert.deepEqual(v.utechsmartVenusEncodeButtonAction({ kind: "key", usage }), Uint8Array.from([0x90, 0, usage, 0])); + for (const modifiers of [0, 1, 3, 0x10, 0xff]) { + const action = { kind: "combo", modifiers, usage } as const; + assert.deepEqual(v.utechsmartVenusDecodeButtonAction([0x8f, modifiers, usage, 0]), action); + assert.deepEqual(v.utechsmartVenusEncodeButtonAction(action), Uint8Array.from([0x8f, modifiers, usage, 0])); + } + } + assert.equal(v.utechsmartVenusButtonActionName({ kind: "combo", modifiers: 1, usage: 0x1e }), "Keys Ctrl+1"); + assert.equal(v.utechsmartVenusButtonActionName({ kind: "key", usage: 0x57 }), "Key Numpad +"); + assert.equal(v.utechsmartVenusButtonActionName({ kind: "combo", modifiers: 1, usage: 0x56 }), "Keys Ctrl+Numpad -"); + assert.equal(v.utechsmartVenusButtonActionName({ kind: "combo", modifiers: 0x10, usage: 0x1e }), "Keys Right Ctrl+1"); +}); +test("unknown and noncanonical button records round-trip without assigning speculative semantics", () => { + for (const raw of [[0x99, 0x81, 3, 0], [0x81, 1, 0, 0], [0xfe, 0xab, 0xcd, 0xef], [0x8f, 1, 0x1e, 9], [0x90, 1, 0x1e, 0]]) { + const action = v.utechsmartVenusDecodeButtonAction(raw); + assert.equal(action.kind, "raw"); + assert.deepEqual(v.utechsmartVenusEncodeButtonAction(action), Uint8Array.from(raw)); + } + assert.throws(() => v.utechsmartVenusDecodeButtonAction([0])); + assert.throws(() => v.utechsmartVenusEncodeButtonAction({ kind: "key", usage: 256 })); + assert.throws(() => v.utechsmartVenusEncodeButtonAction({ kind: "combo", modifiers: -1, usage: 4 })); + assert.throws(() => v.utechsmartVenusEncodeButtonAction({ kind: "action", name: "Reset" })); +}); + +test("status decodes real dump excerpts including Ctrl side buttons and raw fire record", () => { + const memory = fixture(); + const before = memory.data.slice(); + const status = v.utechsmartVenusDecodeStatus(memory); + assert.equal(status.activeProfile, 1); + assert.deepEqual(status.profiles.map((p) => p.pollingHz), [500, 1000, 500, 500, 500]); + assert.deepEqual(status.profiles[1]!.stages.map((s) => s.dpi), [400, 500, 800, 1000, 1200]); + assert.equal(status.profiles[1]!.currentStage, 3); + assert.deepEqual(status.profiles[0]!.stages.map((s) => s.dpi), [1000, 2000, 4000, 8200, 16400]); + assert.equal(status.profiles[0]!.currentStage, 0); + const usages = [0x1e, 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x57, 0x56]; + for (const profile of status.profiles) { + assert.equal(profile.stages.length, 5); + assert.equal(profile.buttons.length, 20); + assert.deepEqual(profile.buttons.slice(6, 18), usages.map((usage) => profile.index === 1 ? { kind: "combo", modifiers: 1, usage } : { kind: "key", usage })); + assert.equal(profile.buttons[3]!.kind, "raw"); + } + assert.deepEqual(memory.data, before); +}); +test("status exposes unknown DPI range and polling code, rejects incomplete or invalid counts", () => { + const memory = fixture(); + memory.data[0x46] = 0xfe; + memory.data[0x32] = 3; + const status = v.utechsmartVenusDecodeStatus(memory); + assert.equal(status.profiles[0]!.pollingHz, null); + assert.equal(status.profiles[0]!.pollingCode, 3); + assert.equal(status.profiles[0]!.stages[0]!.range, 0xfe); + assert.equal(status.profiles[0]!.stages[0]!.raw[2], 0xfe); + assert.throws(() => v.utechsmartVenusDecodeStatus({ address: 0x40, data: factoryDpi })); + for (const count of [0, 11]) { + memory.data[0x40] = count; + assert.throws(() => v.utechsmartVenusDecodeStatus(memory)); + } + memory.data[0x40] = 10; + memory.data[0x42] = 9; + assert.equal(v.utechsmartVenusDecodeStatus(memory).profiles[0]!.stages.length, 10); + memory.data[0x40] = 1; + memory.data[0x42] = 0; + assert.equal(v.utechsmartVenusDecodeStatus(memory).profiles[0]!.stages.length, 1); + memory.data[0x80] = 21; + assert.throws(() => v.utechsmartVenusDecodeStatus(memory)); +}); + +test("write plans emit only minimal F3 byte spans and preserve unknown/reserved/tail bytes", () => { + const memory = fixture(); + memory.data.set([0xa5, 0x16, 0, 0xc1, 0xc2, 0xc3], 0x44); + memory.data[0x33] = 0x7e; + const before = memory.data.slice(); + const cases: [v.UtechsmartVenusChange, number, number[]][] = [ + [{ kind: "dpi", profile: 0, stage: 0, dpi: 8200 }, 0x45, [0x5c, 1]], + [{ kind: "dpi", profile: 0, stage: 0, dpi: 2000 }, 0x45, [0x2d]], + [{ kind: "currentStage", profile: 0, stage: 4 }, 0x42, [4]], + [{ kind: "polling", profile: 0, hz: 1000 }, 0x32, [1]], + [{ kind: "button", profile: 1, slot: 6, action: { kind: "combo", modifiers: 3, usage: 0x1e } }, 0x15b, [3]], + [{ kind: "button", profile: 0, slot: 19, action: { kind: "raw", bytes: hex("ff ab cd ef") } }, 0xce, [0xff, 0xab, 0xcd, 0xef]], + ]; + for (const [change, address, data] of cases) { + const plan = v.utechsmartVenusPlanWrite(memory, change); + assert.deepEqual(plan, [v.utechsmartVenusEncodeWrite(address, data)]); + const after = memory.data.slice(); + for (const frame of plan) after.set(frame.slice(8, 8 + frame[4]!), frame[2]! + 256 * frame[3]!); + for (let i = 0; i < before.length; i++) if (i < address || i >= address + data.length) assert.equal(after[i], before[i]); + } + assert.deepEqual(memory.data, before); + assert.deepEqual(v.utechsmartVenusPlanWrite(memory, { kind: "dpi", profile: 0, stage: 0, dpi: 1000 }), []); + assert.deepEqual(v.utechsmartVenusPlanWrite(memory, { kind: "currentStage", profile: 0, stage: 0 }), []); + assert.deepEqual(v.utechsmartVenusPlanWrite(memory, { kind: "polling", profile: 0, hz: 500 }), []); + assert.deepEqual(v.utechsmartVenusPlanWrite(memory, { kind: "button", profile: 0, slot: 0, action: { kind: "action", name: "Left click" } }), []); +}); +test("write plans accept address-based excerpts but reject missing bytes, inactive stages and tail slots", () => { + const excerpt = { address: 0x40, data: factoryDpi.slice() }; + assert.deepEqual(v.utechsmartVenusPlanWrite(excerpt, { kind: "dpi", profile: 0, stage: 0, dpi: 2000 }), [v.utechsmartVenusEncodeWrite(0x45, [0x2d])]); + assert.throws(() => v.utechsmartVenusPlanWrite(excerpt, { kind: "dpi", profile: 0, stage: 5, dpi: 2000 })); + assert.throws(() => v.utechsmartVenusPlanWrite(excerpt, { kind: "currentStage", profile: 0, stage: 5 })); + assert.throws(() => v.utechsmartVenusPlanWrite(excerpt, { kind: "polling", profile: 0, hz: 500 })); + assert.throws(() => v.utechsmartVenusPlanWrite(fixture(), { kind: "button", profile: 0, slot: 20, action: { kind: "action", name: "Disabled" } })); + assert.throws(() => v.utechsmartVenusPlanWrite(fixture(), { kind: "polling", profile: 0, hz: 2000 })); +}); diff --git a/src/utechsmart/venus.ts b/src/utechsmart/venus.ts new file mode 100644 index 0000000..fa462a3 --- /dev/null +++ b/src/utechsmart/venus.ts @@ -0,0 +1,348 @@ +/** + * UtechSmart Venus MMO wired, Holtek 04d9:fc55. + * Memory layout, F2 replies, bare F3 writes, keyboard combos and the cited DPI + * pairs were verified on the user's hardware by read probes/vendor UI/readback. + * F1 meanings and F5 polling are inferred from a reverse-engineered Linux tool, + * NOT hardware-verified. No Redragon session bracket, reset or LED memory writer. + * This module has not itself been exercised against hardware. Callers must read + * back after writing; persistence across power cycles is not established here. + */ +import { + redragonEncodeRead, redragonEncodeWrite, redragonButtonCodec, + REDRAGON_SINGLE_KEY_ACTION, +} from "../redragon/holtek.js"; +import { REDRAGON_M724_DPI_LABELS, redragonM724EncodeDpi } from "../redragon/m724.js"; +import { KEY_USAGES, NUMPAD_USAGES, keyName } from "../redragon/keys.js"; + +export const UTECHSMART_VENUS_VENDOR_ID = 0x04d9; +export const UTECHSMART_VENUS_PRODUCT_ID = 0xfc55; +export const UTECHSMART_VENUS_INTERFACE = 2; +export const UTECHSMART_VENUS_CONFIG_USAGE_PAGE = 0xffa0; +export const UTECHSMART_VENUS_CONFIG_USAGE = 0x01; +export const UTECHSMART_VENUS_REPORT_ID = 2; +export const UTECHSMART_VENUS_REPORT_SIZE = 16; +export const UTECHSMART_VENUS_LONG_REPORT_ID = 3; +export const UTECHSMART_VENUS_LONG_REPORT_SIZE = 64; +export const UTECHSMART_VENUS_MAX_READ = 56; +export const UTECHSMART_VENUS_MAX_WRITE = 8; +export const UTECHSMART_VENUS_PROFILE_COUNT = 5; +export const UTECHSMART_VENUS_MAX_STAGES = 10; +export const UTECHSMART_VENUS_BUTTON_COUNT = 20; +export const UTECHSMART_VENUS_ACTIVE_PROFILE_ADDRESS = 0x002c; +export const UTECHSMART_VENUS_PROFILE_BASES = [0x0040, 0x0100, 0x01b0, 0x0260, 0x0310] as const; +export const UTECHSMART_VENUS_BUTTON_BASES = [0x0080, 0x0140, 0x01f0, 0x02a0, 0x0350] as const; +export const UTECHSMART_VENUS_POLLING_RATES = [125, 250, 500, 1000] as const; + +function integer(value: number, min: number, max: number, name: string): void { + if (!Number.isInteger(value) || value < min || value > max) { + throw new Error(`UtechSmart Venus ${name} ${value} is outside ${min}-${max}.`); + } +} +function transfer(address: number, length: number, max: number): void { + integer(address, 0, 0xffff, "address"); + integer(length, 1, max, "length"); + if (address + length > 0x10000) throw new Error("UtechSmart Venus transfer crosses 16-bit address space."); +} +function bytes(value: ArrayLike, length: number): Uint8Array { + if (value.length !== length) throw new Error(`UtechSmart Venus expected ${length} bytes.`); + return Uint8Array.from(Array.from(value, (byte) => { + integer(byte, 0, 255, "byte"); + return byte; + })); +} + +/** Hardware-confirmed F2 request, full 16-byte frame including report ID. */ +export function utechsmartVenusEncodeRead(address: number, length: number): Uint8Array { + transfer(address, length, UTECHSMART_VENUS_MAX_READ); + // Same F2 request as Holtek Redragon; FC55 permits a longer GET_FEATURE reply. + const frame = redragonEncodeRead(address, Math.min(length, 8)); + frame[4] = length; + return frame; +} +/** Which GET_FEATURE report follows F2: 2 for <=8 bytes, otherwise 3 (<=56). */ +export function utechsmartVenusReadReportId(length: number): 2 | 3 { + integer(length, 1, UTECHSMART_VENUS_MAX_READ, "read length"); + return length <= 8 ? 2 : 3; +} +/** + * Hardware-confirmed bare F3 write. Byte 5 stays zero; no checksum or ACK. + * Bounds check is numeric only, NOT an address allowlist: unmapped addresses + * can STALL the device. Prefer the scoped write planner over arbitrary writes. + */ +export function utechsmartVenusEncodeWrite(address: number, data: ArrayLike): Uint8Array { + transfer(address, data.length, UTECHSMART_VENUS_MAX_WRITE); + return redragonEncodeWrite(address, data); +} +/** + * Validate FC55's header and return a copy of the data. Accepts full reports or + * report-ID-stripped bodies. The unreliable echo-low byte is deliberately ignored + * even for odd addresses. Status checking may be disabled for unknown firmware. + */ +export function utechsmartVenusDecodeRead( + answer: Uint8Array, address: number, length: number, + options: { validateStatus?: boolean } = {}, +): Uint8Array { + transfer(address, length, UTECHSMART_VENUS_MAX_READ); + const reportId = utechsmartVenusReadReportId(length); + const body = answer[0] === reportId ? answer.subarray(1) : answer; + if (body.length < 7 + length || body[0] !== 8 || body[3] !== length || + body[5] !== 0xfa || body[6] !== 0xfa || + (options.validateStatus !== false && body[2] !== ((0x60 + (address >> 8)) & 0xff))) { + throw new Error("UtechSmart Venus unexpected read reply."); + } + return body.slice(7, 7 + length); +} + +export type UtechsmartVenusControl = "enter" | "buttons" | "dpi" | "led" | "exit"; +/** UNVERIFIED F1 meanings from the Linux tool. No reset command is exposed. */ +export function utechsmartVenusEncodeControl(control: UtechsmartVenusControl): Uint8Array { + const codes = { enter: 1, buttons: 2, dpi: 4, led: 8, exit: 16 } as const; + if (!Object.hasOwn(codes, control)) throw new Error("UtechSmart Venus unknown control."); + const frame = new Uint8Array(16); + frame.set([2, 0xf1, 2, codes[control]]); + return frame; +} +function pollingCode(hz: number): number { + if (!UTECHSMART_VENUS_POLLING_RATES.some((rate) => rate === hz)) throw new Error(`UtechSmart Venus unsupported polling rate ${hz}.`); + return 1000 / hz; +} +/** UNVERIFIED F5 polling command. Unlike Redragon, F5 is NOT a session bracket. */ +export function utechsmartVenusEncodePolling(hz: number): Uint8Array { + const frame = new Uint8Array(16); + frame.set([2, 0xf5, pollingCode(hz)]); + return frame; +} + +/** Hardware-observed profile block; indexes throughout this API are zero-based. */ +export function utechsmartVenusProfileAddress(profile: number): number { + integer(profile, 0, 4, "profile"); + return UTECHSMART_VENUS_PROFILE_BASES[profile]!; +} +/** Hardware-observed polling record is [1000 / Hz, unknown] at 0x32 + 2p. */ +export function utechsmartVenusPollAddress(profile: number): number { + utechsmartVenusProfileAddress(profile); + return 0x32 + 2 * profile; +} +/** Six-byte stage record [flag, code, range, unknown, unknown, unknown]. */ +export function utechsmartVenusStageAddress(profile: number, stage: number): number { + const base = utechsmartVenusProfileAddress(profile); + // Ten-stage capacity is inferred from the reference tool; five observed. + integer(stage, 0, 9, "stage"); + return base + 4 + 6 * stage; +} +/** Only slots 0-19, never the four trailing records or FF sentinel. */ +export function utechsmartVenusButtonAddress(profile: number, slot: number): number { + utechsmartVenusProfileAddress(profile); + integer(slot, 0, 19, "button slot"); + return UTECHSMART_VENUS_BUTTON_BASES[profile]! + 2 + 4 * slot; +} + +// M724_CODES is private. Derive the identical FC55 code table from the exported +// M724 codec/labels rather than duplicating src/redragon/m724.ts's 61-byte table. +const BASE_DPI_LABELS = REDRAGON_M724_DPI_LABELS.filter((dpi) => dpi <= 6200); +const BASE_CODES = BASE_DPI_LABELS.map((dpi) => redragonM724EncodeDpi(dpi).value); +/** Vendor label policy: x1, x2, x3, then the special 16400 cap/label. */ +export const UTECHSMART_VENUS_DPI_LABELS: readonly number[] = [ + ...REDRAGON_M724_DPI_LABELS, + ...Array.from({ length: 13 }, (_, i) => 12600 + i * 300), 16400, +]; +export interface UtechsmartVenusDpiCode { dpi: number; value: number; range: 0 | 1 | 4 } +/** Snap to nearest vendor label (lower wins ties); reject outside 200-16400. */ +export function utechsmartVenusEncodeDpi(dpi: number): UtechsmartVenusDpiCode { + integer(dpi, 200, 16400, "DPI"); + let label = UTECHSMART_VENUS_DPI_LABELS[0]!; + for (const candidate of UTECHSMART_VENUS_DPI_LABELS) { + if (Math.abs(candidate - dpi) < Math.abs(label - dpi)) label = candidate; + } + if (label <= 12400) return redragonM724EncodeDpi(label); + if (label === 16400) return { dpi: label, value: 124, range: 4 }; + return { dpi: label, value: redragonM724EncodeDpi(label / 3).value, range: 4 }; +} +/** + * Known-code lookup plus M724's 400/9 fallback rounded to 10 after scaling. + * Range 4 triples; unknown ranges use x1 like the sibling, without throwing. + * Status retains the raw code/range, so this fallback is not a semantic claim. + */ +export function utechsmartVenusDecodeDpi(value: number, range: number): number { + if (value === 124 && range === 4) return 16400; + const scale = range === 1 ? 2 : range === 4 ? 3 : 1; + const index = BASE_CODES.indexOf(value); + return index >= 0 ? BASE_DPI_LABELS[index]! * scale : Math.round(value * scale * 400 / 9 / 10) * 10; +} + +/** Only FC55-observed actions; fire key and unsupported actions stay raw. */ +export const UTECHSMART_VENUS_BUTTON_ACTIONS: ReadonlyArray = [ + ["Left click", [0x81]], ["Right click", [0x82]], ["Middle click", [0x83]], + ["Back", [0x84]], ["Forward", [0x85]], ["DPI down", [0x89]], + ["DPI up", [0x8a]], ["Profile cycle", [0x8d]], ["Disabled", [0]], +]; +const BUTTON_CODEC = redragonButtonCodec("FC55", UTECHSMART_VENUS_BUTTON_ACTIONS, [REDRAGON_SINGLE_KEY_ACTION, 0]); +const KEYS = [...KEY_USAGES, ...NUMPAD_USAGES]; +export type UtechsmartVenusButtonAction = + | { kind: "action"; name: string } + | { kind: "key"; usage: number } + | { kind: "combo"; modifiers: number; usage: number } + | { kind: "raw"; bytes: Uint8Array }; + +/** Decode exactly four bytes; noncanonical and unknown actions round-trip raw. */ +export function utechsmartVenusDecodeButtonAction(value: ArrayLike): UtechsmartVenusButtonAction { + const raw = bytes(value, 4); + const name = BUTTON_CODEC.decode(raw); + if (UTECHSMART_VENUS_BUTTON_ACTIONS.some(([action]) => action === name)) return { kind: "action", name }; + if (raw[0] === 0x90 && raw[1] === 0 && raw[3] === 0) return { kind: "key", usage: raw[2]! }; + if (raw[0] === 0x8f && raw[3] === 0) return { kind: "combo", modifiers: raw[1]!, usage: raw[2]! }; + return { kind: "raw", bytes: raw }; +} +/** Preserve raw records; standard HID modifier bitmask includes right-side bits. */ +export function utechsmartVenusEncodeButtonAction(action: UtechsmartVenusButtonAction): Uint8Array { + switch (action.kind) { + case "raw": return bytes(action.bytes, 4); + case "action": { + if (!UTECHSMART_VENUS_BUTTON_ACTIONS.some(([name]) => name === action.name)) throw new Error("UtechSmart Venus unknown button action."); + return Uint8Array.from(BUTTON_CODEC.encode(action.name)); + } + case "key": { + integer(action.usage, 0, 255, "key usage"); + const key = KEY_USAGES.find(([, usage]) => usage === action.usage); + return key ? Uint8Array.from(BUTTON_CODEC.encode(`Key ${key[0]}`)) : Uint8Array.from([0x90, 0, action.usage, 0]); + } + case "combo": { + integer(action.usage, 0, 255, "key usage"); + integer(action.modifiers, 0, 255, "modifiers"); + // Shared codec covers named shortcuts. Arbitrary combos/keypad/right + // modifiers need the same wire format but aren't offered by its encoder. + const raw = [0x8f, action.modifiers, action.usage, 0]; + const name = BUTTON_CODEC.decode(raw); + return Uint8Array.from(BUTTON_CODEC.options.includes(name) ? BUTTON_CODEC.encode(name) : raw); + } + default: throw new Error("UtechSmart Venus unknown button action kind."); + } +} +/** Shared Holtek naming, extended for keypad and standard right modifiers. */ +export function utechsmartVenusButtonActionName(action: UtechsmartVenusButtonAction): string { + const raw = utechsmartVenusEncodeButtonAction(action); + const shared = BUTTON_CODEC.decode(raw); + if (action.kind === "key") { + const key = KEYS.find(([, usage]) => usage === action.usage)?.[0]; + return key ? `Key ${key}` : shared; + } + if (action.kind === "combo") { + const name = keyName(action.modifiers, action.usage, KEYS); + if (name) return shared.startsWith("Vendor action") ? `Keys ${name}` : shared; + const key = KEYS.find(([, usage]) => usage === action.usage)?.[0]; + if (key) { + const modifiers = ["Ctrl", "Shift", "Alt", "Win", "Right Ctrl", "Right Shift", "Right Alt", "Right Win"]; + return `Keys ${[...modifiers.filter((_, bit) => action.modifiers & (1 << bit)), key].join("+")}`; + } + } + return shared; +} + +/** Contiguous memory excerpt; address is the absolute address of data[0]. */ +export interface UtechsmartVenusMemory { address: number; data: Uint8Array } +function readMemory(memory: UtechsmartVenusMemory, address: number, length: number): Uint8Array { + integer(memory.address, 0, 0xffff, "excerpt address"); + if (memory.address + memory.data.length > 0x10000) throw new Error("UtechSmart Venus excerpt exceeds address space."); + const offset = address - memory.address; + if (offset < 0 || offset + length > memory.data.length) throw new Error(`UtechSmart Venus excerpt missing bytes at 0x${address.toString(16)}.`); + return memory.data.slice(offset, offset + length); +} +function profileHeader(memory: UtechsmartVenusMemory, profile: number): Uint8Array { + const header = readMemory(memory, utechsmartVenusProfileAddress(profile), 4); + integer(header[0]!, 1, 10, "enabled stage count"); + integer(header[2]!, 0, header[0]! - 1, "current stage"); + return header; +} +function buttonCount(memory: UtechsmartVenusMemory, profile: number): void { + const count = readMemory(memory, utechsmartVenusButtonAddress(profile, 0) - 2, 2); + if ((count[0]! | count[1]! << 8) !== 20) throw new Error("UtechSmart Venus unexpected button slot count."); +} +export interface UtechsmartVenusStage { + dpi: number; value: number; range: number; raw: Uint8Array; +} +export interface UtechsmartVenusProfile { + index: number; pollingHz: number | null; pollingCode: number; + currentStage: number; stages: UtechsmartVenusStage[]; buttons: UtechsmartVenusButtonAction[]; +} +export interface UtechsmartVenusStatus { activeProfile: number; profiles: UtechsmartVenusProfile[] } +/** Decode only observed settings, not LED/calibration memory. Requires all five profiles. */ +export function utechsmartVenusDecodeStatus(memory: UtechsmartVenusMemory): UtechsmartVenusStatus { + const activeProfile = readMemory(memory, UTECHSMART_VENUS_ACTIVE_PROFILE_ADDRESS, 1)[0]!; + integer(activeProfile, 0, 4, "active profile"); + const profiles = Array.from({ length: 5 }, (_, index): UtechsmartVenusProfile => { + const header = profileHeader(memory, index); + const polling = readMemory(memory, utechsmartVenusPollAddress(index), 2)[0]!; + buttonCount(memory, index); + const stages = Array.from({ length: header[0]! }, (_, stage): UtechsmartVenusStage => { + const raw = readMemory(memory, utechsmartVenusStageAddress(index, stage), 6); + return { dpi: utechsmartVenusDecodeDpi(raw[1]!, raw[2]!), value: raw[1]!, range: raw[2]!, raw }; + }); + return { + index, pollingHz: [1, 2, 4, 8].includes(polling) ? 1000 / polling : null, pollingCode: polling, + currentStage: header[2]!, stages, + buttons: Array.from({ length: 20 }, (_, slot) => utechsmartVenusDecodeButtonAction(readMemory(memory, utechsmartVenusButtonAddress(index, slot), 4))), + }; + }); + return { activeProfile, profiles }; +} + +export type UtechsmartVenusChange = + | { kind: "dpi"; profile: number; stage: number; dpi: number } + | { kind: "currentStage"; profile: number; stage: number } + | { kind: "polling"; profile: number; hz: number } + | { kind: "button"; profile: number; slot: number; action: UtechsmartVenusButtonAction }; +/** + * Plan ONE change using current readback. Returns no frames for a no-op, otherwise + * one minimal contiguous F3 span (<=4 bytes). Never mutates the excerpt, rewrites + * unknown bytes, enables extra stages, touches trailing wheel records, or emits + * F1/F5 automatically. A stage change requires its four-byte profile header plus + * the six-byte stage record; button edits require count+slot; polling needs two + * bytes. Refresh readback before planning and verify again after applying. + * Writes applying bare was confirmed; durability/commit requirements remain open. + */ +export function utechsmartVenusPlanWrite(memory: UtechsmartVenusMemory, change: UtechsmartVenusChange): Uint8Array[] { + let address: number; + let before: Uint8Array; + let after: Uint8Array; + switch (change.kind) { + case "dpi": + case "currentStage": { + const header = profileHeader(memory, change.profile); + integer(change.stage, 0, header[0]! - 1, "enabled stage"); + if (change.kind === "currentStage") { + address = utechsmartVenusProfileAddress(change.profile) + 2; + before = header.slice(2, 3); + after = Uint8Array.of(change.stage); + } else { + address = utechsmartVenusStageAddress(change.profile, change.stage); + before = readMemory(memory, address, 6); + after = before.slice(); + const encoded = utechsmartVenusEncodeDpi(change.dpi); + after[1] = encoded.value; + after[2] = encoded.range; + } + break; + } + case "polling": { + address = utechsmartVenusPollAddress(change.profile); + before = readMemory(memory, address, 2); + after = before.slice(); + after[0] = pollingCode(change.hz); + break; + } + case "button": { + address = utechsmartVenusButtonAddress(change.profile, change.slot); + buttonCount(memory, change.profile); + before = readMemory(memory, address, 4); + after = utechsmartVenusEncodeButtonAction(change.action); + break; + } + default: throw new Error("UtechSmart Venus unsupported change."); + } + let first = 0; + while (first < before.length && before[first] === after[first]) first++; + if (first === before.length) return []; + let end = before.length; + while (before[end - 1] === after[end - 1]) end--; + return [utechsmartVenusEncodeWrite(address + first, after.slice(first, end))]; +} From ecc488d242a3e636e6c9ba1f349bc9a1ee874ef9 Mon Sep 17 00:00:00 2001 From: javinnav Date: Sat, 14 Nov 2026 13:45:06 +0000 Subject: [PATCH 2/4] feat(utechsmart): add Venus MMO WebHID driver Registers the 04d9:fc55 config collection (0xFFA0:0x01) as a UtechSmart client, disjoint from the Redragon Holtek mice by product id. Reads the active profile's DPI stages, polling rate and 20 button slots, and writes DPI, polling and button mappings (including modifier combos) through minimal F3 frames verified by readback. F1 commit frames stay disabled and the F5 polling frame stays behind a constant until hardware testing decides both. --- src/drivers/mouse-types.ts | 2 +- src/drivers/registry.test.ts | 18 +- src/drivers/registry.ts | 7 +- src/drivers/utechsmart/hid.test.ts | 227 +++++++++++++++++++++++++ src/drivers/utechsmart/hid.ts | 257 +++++++++++++++++++++++++++++ src/drivers/vendors.ts | 16 ++ 6 files changed, 523 insertions(+), 4 deletions(-) create mode 100644 src/drivers/utechsmart/hid.test.ts create mode 100644 src/drivers/utechsmart/hid.ts diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 78bb6c0..86c7943 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -208,7 +208,7 @@ export interface MagneticCalibrationProgress { } export interface MouseStatus { - brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Vancer" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "AJAZZ" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Amkette" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master" | "AJAZZ" | "Scyrox" | "Hitscan" | "ROCCAT" | "Thermaltake" | "Redgear" | "AULA"; + brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Vancer" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "AJAZZ" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "UtechSmart" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Amkette" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master" | "AJAZZ" | "Scyrox" | "Hitscan" | "ROCCAT" | "Thermaltake" | "Redgear" | "AULA"; name: string; /** Optional LunaFury controls, populated only after successful device reads. */ lunafury?: import("../lamzu/lunafury.js").LunaFurySettings; diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index 8109820..d562616 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -4,7 +4,9 @@ import { readdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { DEVICE_DRIVERS } from "./registry.ts"; +import { DEVICE_DRIVERS, createSupportedClient, deviceBrand } from "./registry.ts"; +import { UtechsmartVenusHidClient } from "./utechsmart/hid.ts"; +import { RedragonHidClient } from "./redragon/hid.ts"; import { SUPPORTED_HID_FILTERS, VENDOR_ID } from "./vendors.ts"; import { LAMZU_ATLANTIS_PRODUCTS, LAMZU_PRODUCTS } from "@openmouse/protocol/lamzu"; import { SCYROX_PRODUCTS } from "@openmouse/protocol/scyrox"; @@ -34,6 +36,20 @@ test("Redgear picker filter uses the protocol identity constants", () => { ); }); +test("Holtek Venus discovery is disjoint from Redragon and picker targets config only", () => { + assert.equal(VENDOR_ID.utechsmart, 0x04d9); + assert.deepEqual(SUPPORTED_HID_FILTERS.filter((filter) => filter.productId === 0xfc55), [ + { vendorId: 0x04d9, productId: 0xfc55, usagePage: 0xffa0, usage: 1 }, + ]); + for (const productId of [0xfc55, 0xfc7a, 0xfc61]) { + const device = { vendorId: 0x04d9, productId, collections: [collection(0xffa0, 1, { feature: [2, 3] })] } as HIDDevice; + const client = createSupportedClient(device)!; + assert.ok(client instanceof (productId === 0xfc55 ? UtechsmartVenusHidClient : RedragonHidClient)); + assert.equal(deviceBrand(client), productId === 0xfc55 ? "UtechSmart" : "Redragon"); + assert.equal(DEVICE_DRIVERS.filter((driver) => driver.supports(device)).length, 1); + } +}); + const DEVICES_DIR = dirname(fileURLToPath(import.meta.url)); const REPORT_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0x09, 0x0e, 0x0f, 0x10, 0x11, 0x20, 0x51, 0xa1, 0xb3, 0xb4, 0xb5]; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index a245b65..880ee94 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -85,6 +85,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 { UtechsmartVenusHidClient } from "./utechsmart/hid.ts"; import { AulaSc380ProHidClient } from "./aula/hid.ts"; import { RedragonM690ProHidClient } from "./redragon/m690-pro-hid.ts"; import { RedragonM719ProHidClient } from "./redragon/m719-pro-hid.ts"; @@ -106,7 +107,7 @@ import { HitscanHidClient } from "./hitscan/hid.ts"; import { HitscanXviHidClient } from "./hitscan/xvi-hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | 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 | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesSenseiRivalProHidClient | SteelSeriesSensei310HidClient | SteelSeriesPrimeMiniHidClient | SteelSeriesRival5HidClient | SteelSeriesRival3Gen2HidClient | SteelSeriesRival3WirelessV2HidClient | SteelSeriesAerox3WirelessGen2HidClient | SteelSeriesRival600HidClient | SteelSeriesRival100HidClient | SteelSeriesRival110HidClient | SteelSeriesRival300HidClient | SteelSeriesSenseiRawHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | GloriousSinowealthHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | RedragonM719ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | AmketteXsHidClient | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAj200pHidClient | AjazzAk820HidClient | ScyroxHidClient | HitscanHidClient | RoccatHidClient | ThermaltakeHidClient | RedgearX13HidClient | AulaSc380ProHidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | 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 | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesSenseiRivalProHidClient | SteelSeriesSensei310HidClient | SteelSeriesPrimeMiniHidClient | SteelSeriesRival5HidClient | SteelSeriesRival3Gen2HidClient | SteelSeriesRival3WirelessV2HidClient | SteelSeriesAerox3WirelessGen2HidClient | SteelSeriesRival600HidClient | SteelSeriesRival100HidClient | SteelSeriesRival110HidClient | SteelSeriesRival300HidClient | SteelSeriesSenseiRawHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | GloriousSinowealthHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | UtechsmartVenusHidClient | RedragonM690ProHidClient | RedragonM719ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | AmketteXsHidClient | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAj200pHidClient | AjazzAk820HidClient | ScyroxHidClient | HitscanHidClient | RoccatHidClient | ThermaltakeHidClient | RedgearX13HidClient | AulaSc380ProHidClient; export interface DeviceDriver { brand: string; supports(device: HIDDevice): boolean; @@ -121,6 +122,8 @@ 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 }, + // Same Holtek VID/config collection as Redragon; disjoint by PID 0xfc55. + { brand: "UtechSmart", supports: (device) => UtechsmartVenusHidClient.isSupported(device), create: (device) => new UtechsmartVenusHidClient(device), score: () => 9 }, // Shares SinoWealth's 0x258a with the Glorious classic line; disjoint by // product id, and the client refuses a settings block that is not the // M690 PRO's. @@ -128,7 +131,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ // Shares Compx/Areson's 0x25a7 with AttackSharkHidClient; disjoint by // product id (0xfa7e/0xfab0) and usage page (0xff02/0xff01 vs 0xffff). { brand: "Redragon", supports: (device) => RedragonM719ProHidClient.isSupported(device), create: (device) => new RedragonM719ProHidClient(device), score: (device) => RedragonM719ProHidClient.supportScore(device) }, - // Shares Holtek's 0x04d9 with Redragon; disjoint by product id and by usage + // Shares Holtek's 0x04d9 with Redragon and UtechSmart; 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 }, diff --git a/src/drivers/utechsmart/hid.test.ts b/src/drivers/utechsmart/hid.test.ts new file mode 100644 index 0000000..9e2bb10 --- /dev/null +++ b/src/drivers/utechsmart/hid.test.ts @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { UtechsmartVenusHidClient } from "./hid.ts"; + +const hex = (text: string) => Uint8Array.from(text.trim().split(/\s+/).map((byte) => parseInt(byte, 16))); +// Sanitized real dump excerpts copied from src/utechsmart/venus.test.ts. +const factoryDpi = hex("05 00 00 00 01 16 00 00 00 00 01 2d 00 00 00 00 01 5a 00 00 00 00 01 5c 01 00 00 00 01 7c 04 00 00 00"); +const profile2Dpi = hex("05 00 03 00 01 09 00 00 00 00 01 0b 00 00 00 00 01 12 00 00 00 00 01 16 00 00 00 00 01 1b 00 00 00 00"); +const factoryButtons = hex(`14 00 81 00 00 00 82 00 00 00 83 00 00 00 99 81 03 00 8a 00 00 00 89 00 00 00 + 90 00 1e 00 90 00 1f 00 90 00 20 00 90 00 21 00 90 00 22 00 90 00 23 00 + 90 00 24 00 90 00 25 00 90 00 26 00 90 00 27 00 90 00 57 00 90 00 56 00 + 00 00 00 00 8d 00 00 00 00 00 00 00 00 00 00 00 8b 00 00 00 8c 00 00 00 ff ff ff ff`); +const profile2Buttons = hex(`14 00 81 00 00 00 82 00 00 00 83 00 00 00 99 81 03 00 8a 00 00 00 89 00 00 00 + 8f 01 1e 00 8f 01 1f 00 8f 01 20 00 8f 01 21 00 8f 01 22 00 8f 01 23 00 + 8f 01 24 00 8f 01 25 00 8f 01 26 00 8f 01 27 00 8f 01 57 00 8f 01 56 00 + 00 00 00 00 8d 00 00 00 00 00 00 00 00 00 00 00 8b 00 00 00 8c 00 00 00 ff ff ff ff`); +function dumpedMemory(): Uint8Array { + const data = new Uint8Array(0x500); + data.set(hex("01 00 00 dc 05 c0 02 00 01 00 02 00 02 00 02 00"), 0x2c); + [0x40, 0x100, 0x1b0, 0x260, 0x310].forEach((base, p) => data.set(p === 1 ? profile2Dpi : factoryDpi, base)); + [0x80, 0x140, 0x1f0, 0x2a0, 0x350].forEach((base, p) => data.set(p === 1 ? profile2Buttons : factoryButtons, base)); + return data; +} +class FakeVenus { + vendorId = 0x04d9; + productId = 0xfc55; + productName = "USB Gaming Mouse"; + opened = false; + collections = [{ usagePage: 0xffa0, usage: 1, children: [], + featureReports: [{ reportId: 2, items: [] }, { reportId: 3, items: [] }], + inputReports: [], outputReports: [] }] as HIDCollectionInfo[]; + memory = dumpedMemory(); + sent: number[][] = []; + received: number[] = []; + ignoreWrites = false; + failCommand: number | null = null; + malformed = false; + stripReportId = false; + private pending: { address: number; length: number } | null = null; + async open() { this.opened = true; } + async close() { this.opened = false; } + async sendFeatureReport(reportId: number, data: BufferSource) { + assert.equal(reportId, 2); + const bytes = [...new Uint8Array(data as ArrayBuffer)]; + assert.equal(bytes.length, 15, "WebHID send excludes report id"); + this.sent.push(bytes); + const [cmd, lo = 0, hi = 0, length = 0] = bytes; + if (cmd === this.failCommand) { this.failCommand = null; throw new Error("simulated STALL"); } + const address = lo | (hi << 8); + if (cmd === 0xf2) { + assert.equal(this.pending, null, "F2/GET pairs must not overlap"); + assert.ok(length >= 1 && length <= 56); + this.pending = { address, length }; + } else if (cmd === 0xf3) { + if (!this.ignoreWrites) this.memory.set(bytes.slice(7, 7 + length), address); + } else assert.equal(cmd, 0xf5, "no F1 or session brackets"); + } + async receiveFeatureReport(reportId: number) { + const { address, length } = this.pending!; + this.pending = null; + assert.equal(reportId, length <= 8 ? 2 : 3); + this.received.push(reportId); + const answer = new Uint8Array(length <= 8 ? 16 : 64); + answer.set([reportId, 8, 0, 0x60 + (address >> 8), length, 0, 0xfa, 0xfa]); + answer.set(this.memory.slice(address, address + length), 8); + if (this.malformed) answer[6] = 0; + // Exercise byteOffset/byteLength, not just a view of the entire buffer. + const storage = new Uint8Array(answer.length + 7); + storage.set(answer, 4); + return new DataView(storage.buffer, 4 + Number(this.stripReportId), answer.length - Number(this.stripReportId)); + } + writes() { return this.sent.filter((frame) => frame[0] !== 0xf2); } +} +const device = (fake: FakeVenus) => fake as unknown as HIDDevice; +const client = (fake = new FakeVenus()) => new UtechsmartVenusHidClient(device(fake)); +const frame = (body: string) => [...hex(body), ...new Array(15 - hex(body).length).fill(0)]; + +test("isSupported claims only Venus config collection with feature report 2", () => { + assert.equal(UtechsmartVenusHidClient.isSupported(device(new FakeVenus())), true); + for (const [vendorId, productId, usagePage, usage] of [ + [0x04d9, 0xfc55, 1, 2], [0x04d9, 0xfc55, 1, 6], [0x04d9, 0xfc55, 0x0c, 1], + [0x04d9, 0xfc7a, 0xffa0, 1], [0x04d9, 0xfc61, 0xffa0, 1], [0x1234, 0xfc55, 0xffa0, 1], + ]) { + const fake = new FakeVenus(); + Object.assign(fake, { vendorId, productId }); + Object.assign(fake.collections[0]!, { usagePage, usage }); + assert.equal(UtechsmartVenusHidClient.isSupported(device(fake)), false); + } + const fake = new FakeVenus(); + fake.collections[0]!.featureReports = []; + assert.equal(UtechsmartVenusHidClient.isSupported(device(fake)), false); +}); + +test("readStatus decodes active profile 2 and Ctrl side buttons from real dump", async () => { + const fake = new FakeVenus(); + const c = client(fake); + const status = await c.readStatus(); + assert.equal(fake.opened, true); + assert.equal(status.brand, "UtechSmart"); + assert.equal(status.name, "UtechSmart Venus MMO"); + assert.equal(status.activeProfile, 2); + assert.equal(status.profileCount, 5); + assert.equal(status.pollingRateHz, 1000); + assert.deepEqual(status.dpiStages, [400, 500, 800, 1000, 1200]); + assert.equal(status.activeDpiStage, 3); + assert.equal(status.dpi, 1000); + assert.equal(status.ui?.valuesVerified, true); + assert.equal(status.ui?.dpiStageEditor?.countEditable, false); + assert.equal(status.ui?.dpiStageEditor?.maxStages, 5); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.lighting, undefined); + assert.equal("setProfile" in c, false); + assert.equal(Object.keys(status.buttonMappings!).length, 19, "unused slot 18 is hidden"); + assert.equal(status.buttonMappings!.Left, "Left click"); + assert.equal(status.buttonMappings!.Profile, "Profile cycle"); + for (let side = 1; side <= 12; side++) { + const key = side <= 9 ? String(side) : side === 10 ? "0" : side === 11 ? "Numpad +" : "Numpad -"; + const action = `Keys Ctrl+${key}`; + assert.equal(status.buttonMappings![`Side ${side}`], action); + assert.ok(status.buttonOptions!.includes(action)); + } + assert.equal(status.fixedButtons?.includes("Fire"), true, "unknown fire record is not guessed"); + assert.deepEqual(fake.writes(), []); + assert.ok(fake.received.includes(3), "long reads use report 3"); + await c.close(); + assert.equal(fake.opened, false); +}); + +test("DPI setter plans minimal F3 from fresh bytes, preserves unknown bytes and verifies", async () => { + const fake = new FakeVenus(); + fake.memory.set([0xa5, 0x09, 0, 0xc1, 0xc2, 0xc3], 0x104); + const c = client(fake); + assert.equal(await c.setDpiStageValue(0, 8200), 8200); + assert.deepEqual(fake.writes(), [frame("f3 05 01 02 00 00 00 5c 01")]); + assert.deepEqual([...fake.memory.slice(0x104, 0x10a)], [0xa5, 0x5c, 1, 0xc1, 0xc2, 0xc3]); + assert.deepEqual(fake.sent.at(-1), frame("f2 05 01 02"), "read touched range after writing"); + assert.equal((await c.readStatus()).dpiStages![0], 8200); +}); + +test("polling writes active profile byte then F5, no commits, then readback", async () => { + const fake = new FakeVenus(); + fake.memory[0x35] = 0x7e; + assert.equal(await client(fake).setPollingRate(125), 125); + assert.deepEqual(fake.writes(), [frame("f3 34 00 01 00 00 00 08"), frame("f5 08")]); + assert.equal(fake.memory[0x35], 0x7e); + assert.equal(fake.memory[0x32], 2, "other profile unchanged"); + assert.deepEqual(fake.sent.at(-1), frame("f2 34 00 01")); +}); + +test("buttons support singles, modifier combinations and native actions with minimal writes", async () => { + const fake = new FakeVenus(); + const c = client(fake); + await c.setButtonMapping("Side 1", "Keys Ctrl+Shift+1"); + assert.deepEqual(fake.writes(), [frame("f3 5b 01 01 00 00 00 03")]); + assert.deepEqual(fake.sent.at(-1), frame("f2 5b 01 01")); + for (const action of ["Key A", "Keys Shift+A", "Switch window (Alt+Tab)", "File explorer (Win+E)", "Keys Ctrl+Shift+Alt+Win+1", "DPI up", "DPI down", "Profile cycle", "Disabled", "Right click"]) { + await c.setButtonMapping("Side 2", action); + assert.equal((await c.readStatus()).buttonMappings!["Side 2"], action); + } + assert.ok(fake.writes().every((bytes) => bytes[0] === 0xf3)); +}); + +test("last left click safety, hidden slot and invalid requests send no writes", async () => { + const fake = new FakeVenus(); + const c = client(fake); + await assert.rejects(() => c.setButtonMapping("Left", "Disabled"), /Keep Left click/); + await assert.rejects(() => c.setButtonMapping("Unused", "Left click"), /no button/); + await assert.rejects(() => c.setButtonMapping("Side 1", "Teleport"), /not offered/); + await assert.rejects(() => c.setDpiStageValue(5, 800), /stage/); + await assert.rejects(() => c.setDpiStageValue(0, 16401), /DPI/); + await assert.rejects(() => c.setPollingRate(2000), /polling/); + assert.deepEqual(fake.writes(), []); + await c.setButtonMapping("Side 1", "Left click"); + await c.setButtonMapping("Left", "Right click"); + assert.equal((await c.readStatus()).buttonMappings!["Side 1"], "Left click"); +}); + +test("every setter rejects readback mismatch", async () => { + for (const operation of [ + (c: UtechsmartVenusHidClient) => c.setDpiStageValue(0, 8200), + (c: UtechsmartVenusHidClient) => c.setPollingRate(125), + (c: UtechsmartVenusHidClient) => c.setButtonMapping("Side 1", "Disabled"), + ]) { + const fake = new FakeVenus(); + fake.ignoreWrites = true; + await assert.rejects(() => operation(client(fake)), /readback mismatch.*0x/i); + } +}); + +test("thrown read or write send rejects and releases queue for next operation", async () => { + for (const command of [0xf2, 0xf3, 0xf5]) { + const fake = new FakeVenus(); + fake.failCommand = command; + const c = client(fake); + const failure = c.setPollingRate(125); + const recovery = c.readStatus(); + await assert.rejects(() => failure, /simulated STALL/); + assert.equal((await recovery).activeProfile, 2); + } +}); + +test("malformed replies reject; report-ID-stripped replies and subviews work", async () => { + const fake = new FakeVenus(); + const c = client(fake); + fake.malformed = true; + await assert.rejects(() => c.readStatus(), /unexpected read reply/); + fake.malformed = false; + fake.stripReportId = true; + assert.equal((await c.readStatus()).dpi, 1000); +}); + +test("concurrent requests serialize F2/GET and setters use new memory, no-op emits no F3", async () => { + const fake = new FakeVenus(); + const c = client(fake); + await Promise.all([c.readStatus(), c.setDpiStageValue(0, 800), c.setDpiStageValue(0, 400), c.readStatus()]); + assert.deepEqual(fake.writes(), [frame("f3 05 01 01 00 00 00 12"), frame("f3 05 01 01 00 00 00 09")]); + await c.setDpiStageValue(0, 400); + assert.equal(fake.writes().length, 2); +}); + +test("status rejects corrupt profile, stage/count or polling rather than inventing values", async () => { + for (const [address, value] of [[0x2c, 5], [0x100, 0], [0x102, 5], [0x140, 21], [0x34, 3]]) { + const fake = new FakeVenus(); + fake.memory[address!] = value!; + await assert.rejects(() => client(fake).readStatus(), /profile|stage|count|polling/); + } +}); diff --git a/src/drivers/utechsmart/hid.ts b/src/drivers/utechsmart/hid.ts new file mode 100644 index 0000000..0a88968 --- /dev/null +++ b/src/drivers/utechsmart/hid.ts @@ -0,0 +1,257 @@ +import type { MouseStatus } from "../mouse-types.js"; +import { KEY_USAGES, NUMPAD_USAGES } from "../../redragon/keys.js"; +import { + UTECHSMART_VENUS_ACTIVE_PROFILE_ADDRESS, + UTECHSMART_VENUS_BUTTON_ACTIONS, + UTECHSMART_VENUS_BUTTON_BASES, + UTECHSMART_VENUS_BUTTON_COUNT, + UTECHSMART_VENUS_CONFIG_USAGE, + UTECHSMART_VENUS_CONFIG_USAGE_PAGE, + UTECHSMART_VENUS_DPI_LABELS, + UTECHSMART_VENUS_MAX_READ, + UTECHSMART_VENUS_MAX_STAGES, + UTECHSMART_VENUS_POLLING_RATES, + UTECHSMART_VENUS_PRODUCT_ID, + UTECHSMART_VENUS_PROFILE_BASES, + UTECHSMART_VENUS_PROFILE_COUNT, + UTECHSMART_VENUS_REPORT_ID, + UTECHSMART_VENUS_VENDOR_ID, + utechsmartVenusButtonActionName, + utechsmartVenusDecodeStatus, + utechsmartVenusDecodeRead, + utechsmartVenusEncodeControl, + utechsmartVenusEncodeDpi, + utechsmartVenusEncodePolling, + utechsmartVenusEncodeRead, + utechsmartVenusPlanWrite, + utechsmartVenusReadReportId, + utechsmartVenusStageAddress, + type UtechsmartVenusButtonAction, + type UtechsmartVenusChange, + type UtechsmartVenusControl, + type UtechsmartVenusMemory, + type UtechsmartVenusProfile, +} from "@openmouse/protocol/utechsmart"; + +const READ_DELAY_MS = 5; +const WRITE_DELAY_MS = 8; +// F1 commit semantics/persistence are UNVERIFIED. Hardware testing will decide +// this list in one place; v1 sends no F1 (and never the F1 00 00 reset). +const COMMIT_AFTER_WRITE: readonly UtechsmartVenusControl[] = []; +// UNVERIFIED: the reverse-engineered tool claims F5 applies polling immediately. +// This is NOT Redragon's F5 session bracket. +const APPLY_POLLING_IMMEDIATELY = true; + +// The table has 20 slots; slot 18 is unused and deliberately not remappable. +const BUTTON_NAMES = ["Left", "Right", "Middle", "Fire", "DPI+", "DPI-", + ...Array.from({ length: 12 }, (_, index) => `Side ${index + 1}`), null, "Profile"] as const; + +/** Shared remapper accepts string choices, not structured chords. Offer named + * codec actions/shortcuts and all left Ctrl/Shift/Alt/GUI combinations per key. + * The codec's spelling for GUI is Win; keypad keys are included too. + */ +const BUTTON_OPTIONS = new Map(); +for (const [name] of UTECHSMART_VENUS_BUTTON_ACTIONS) BUTTON_OPTIONS.set(name, { kind: "action", name }); +for (const [, usage] of [...KEY_USAGES, ...NUMPAD_USAGES]) { + const key = { kind: "key", usage } as const; + BUTTON_OPTIONS.set(utechsmartVenusButtonActionName(key), key); + for (let modifiers = 1; modifiers <= 15; modifiers++) { + const combo = { kind: "combo", modifiers, usage } as const; + BUTTON_OPTIONS.set(utechsmartVenusButtonActionName(combo), combo); + } +} + +function hasConfigCollection(collections: readonly HIDCollectionInfo[]): boolean { + return collections.some((collection) => + (collection.usagePage === UTECHSMART_VENUS_CONFIG_USAGE_PAGE && + collection.usage === UTECHSMART_VENUS_CONFIG_USAGE && + (collection.featureReports ?? []).some((report) => report.reportId === UTECHSMART_VENUS_REPORT_ID)) || + hasConfigCollection(collection.children ?? [])); +} + +/** Wired Venus MMO 04d9:fc55, on interface 2's vendor config collection only. + * Transport/read memory and bare F3 writes are owner-verified; this client has + * not been tested on hardware. Readback confirms memory, not durable storage + * or effective sensor/polling behavior. No LEDs, macros or profile switching. + */ +export class UtechsmartVenusHidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + + constructor(device: HIDDevice) { + if (!UtechsmartVenusHidClient.isSupported(device)) throw new Error("Not a supported UtechSmart Venus config device."); + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + return device.vendorId === UTECHSMART_VENUS_VENDOR_ID && device.productId === UTECHSMART_VENUS_PRODUCT_ID && + hasConfigCollection(device.collections); + } + + get supportedPollingRates(): number[] { return [...UTECHSMART_VENUS_POLLING_RATES]; } + getDpiOptions(): number[] { return [...UTECHSMART_VENUS_DPI_LABELS]; } + + 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 { + return await this.run(async () => { + await this.open(); + const memory = await this.readMemory(); + const profile = this.activeProfile(memory); + const mappings: Record = {}; + const fixedButtons: string[] = []; + BUTTON_NAMES.forEach((name, slot) => { + if (name === null) return; + const action = profile.buttons[slot]!; + mappings[name] = utechsmartVenusButtonActionName(action); + if (!BUTTON_OPTIONS.has(mappings[name]!)) fixedButtons.push(name); + }); + return { + brand: "UtechSmart", + name: "UtechSmart Venus MMO", + ui: { + family: "utechsmart-venus", + settingsReady: true, + valuesVerified: true, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + defaultDisplayName: "UtechSmart Venus MMO", + dpiStageEditor: { + // Only the enabled stages are editable; ten-slot capacity is inferred. + maxStages: profile.stages.length, + countEditable: false, + minDpi: UTECHSMART_VENUS_DPI_LABELS[0]!, + maxDpi: UTECHSMART_VENUS_DPI_LABELS.at(-1)!, + stepDpi: 100, + }, + statusNote: "Writes are read back; persistence and immediate polling application are not yet hardware-verified.", + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: profile.stages[profile.currentStage]!.dpi, + dpiStages: profile.stages.map((stage) => stage.dpi), + activeDpiStage: profile.currentStage, + pollingRateHz: profile.pollingHz!, + supportedPollingRates: this.supportedPollingRates, + activeProfile: profile.index + 1, + profileCount: UTECHSMART_VENUS_PROFILE_COUNT, + buttonMappings: mappings, + buttonOptions: [...BUTTON_OPTIONS.keys()], + fixedButtons, + connectionType: "Wired", + liftOffDistance: null, + firmware: [], + }; + }); + } + + /** Sibling convention: return the snapped DPI, not a cached status. */ + async setDpiStageValue(stage: number, dpi: number): Promise { + const encoded = utechsmartVenusEncodeDpi(dpi); + utechsmartVenusStageAddress(0, stage); + return await this.run(async () => { + await this.open(); + const memory = await this.readMemory(); + const profile = this.activeProfile(memory).index; + await this.writeAndConfirm(memory, { kind: "dpi", profile, stage, dpi }); + return encoded.dpi; + }); + } + + async setPollingRate(hz: number): Promise { + const polling = utechsmartVenusEncodePolling(hz); // Validate before I/O. + return await this.run(async () => { + await this.open(); + const memory = await this.readMemory(); + const profile = this.activeProfile(memory).index; + await this.writeAndConfirm(memory, { kind: "polling", profile, hz }, + APPLY_POLLING_IMMEDIATELY ? polling : undefined); + return hz; + }); + } + + async setButtonMapping(button: string, action: string): Promise { + const slot = BUTTON_NAMES.findIndex((name) => name === button); + if (slot < 0) throw new Error(`UtechSmart Venus has no button named "${button}".`); + const value = BUTTON_OPTIONS.get(action); + if (!value) throw new Error(`UtechSmart Venus action "${action}" is not offered.`); + await this.run(async () => { + await this.open(); + const memory = await this.readMemory(); + const profile = this.activeProfile(memory); + if (action !== "Left click" && !profile.buttons.some((assigned, index) => + index !== slot && BUTTON_NAMES[index] !== null && assigned.kind === "action" && assigned.name === "Left click")) { + throw new Error("Keep Left click on at least one button, or the mouse cannot click."); + } + await this.writeAndConfirm(memory, { kind: "button", profile: profile.index, slot, action: value }); + }); + } + + /** The public status decoder needs all five profiles. Read only known settings + * ranges (no arbitrary gaps, LED/calibration memory, or trailing button slots). + */ + private async readMemory(): Promise { + const data = new Uint8Array(UTECHSMART_VENUS_BUTTON_BASES.at(-1)! + 2 + 4 * UTECHSMART_VENUS_BUTTON_COUNT); + data.set(await this.readRange(UTECHSMART_VENUS_ACTIVE_PROFILE_ADDRESS, 16), UTECHSMART_VENUS_ACTIVE_PROFILE_ADDRESS); + for (let profile = 0; profile < UTECHSMART_VENUS_PROFILE_COUNT; profile++) { + const base = UTECHSMART_VENUS_PROFILE_BASES[profile]!; + data.set(await this.readRange(base, 4 + 6 * UTECHSMART_VENUS_MAX_STAGES), base); + const buttons = UTECHSMART_VENUS_BUTTON_BASES[profile]!; + data.set(await this.readRange(buttons, 2 + 4 * UTECHSMART_VENUS_BUTTON_COUNT), buttons); + } + return { address: 0, data }; + } + + private activeProfile(memory: UtechsmartVenusMemory): UtechsmartVenusProfile { + const status = utechsmartVenusDecodeStatus(memory); + const profile = status.profiles[status.activeProfile]!; + if (profile.pollingHz === null) throw new Error(`UtechSmart Venus reported invalid polling code ${profile.pollingCode}.`); + return profile; + } + + private async readRange(address: number, length: number): Promise { + const out = new Uint8Array(length); + for (let offset = 0; offset < length; offset += UTECHSMART_VENUS_MAX_READ) { + out.set(await this.readBytes(address + offset, Math.min(UTECHSMART_VENUS_MAX_READ, length - offset)), offset); + } + return out; + } + + private async readBytes(address: number, length: number): Promise { + await this.sendFrame(utechsmartVenusEncodeRead(address, length)); + const view = await this.device.receiveFeatureReport(utechsmartVenusReadReportId(length)); + // Windows/Chrome includes the report id; codec accepts both forms. Respect + // the DataView bounds, as with the sibling Holtek transport. + const answer = new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); + return utechsmartVenusDecodeRead(answer, address, length); + } + + private async writeAndConfirm(memory: UtechsmartVenusMemory, change: UtechsmartVenusChange, polling?: Uint8Array): Promise { + const frames = utechsmartVenusPlanWrite(memory, change); + for (const frame of frames) await this.sendFrame(frame); + if (frames.length > 0) { + for (const control of COMMIT_AFTER_WRITE) await this.sendFrame(utechsmartVenusEncodeControl(control)); + } + if (polling) await this.sendFrame(polling); + for (const frame of frames) { + const address = frame[2]! | (frame[3]! << 8); + const expected = frame.slice(8, 8 + frame[4]!); + const actual = await this.readBytes(address, expected.length); + if (actual.some((byte, index) => byte !== expected[index])) { + throw new Error(`UtechSmart Venus readback mismatch at 0x${address.toString(16)}: expected ${[...expected]}, got ${[...actual]}.`); + } + } + } + + private async sendFrame(frame: Uint8Array): Promise { + await this.device.sendFeatureReport(UTECHSMART_VENUS_REPORT_ID, frame.slice(1).buffer as ArrayBuffer); + await new Promise((resolve) => setTimeout(resolve, frame[1] === 0xf2 ? READ_DELAY_MS : WRITE_DELAY_MS)); + } + + private async run(operation: () => Promise): Promise { + const result = this.queue.then(operation, operation); + this.queue = result.then(() => undefined, () => undefined); + return await result; + } +} diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index a2a9e36..38f0ff4 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -151,6 +151,12 @@ import { REDRAGON_PRODUCT_IDS, REDRAGON_VENDOR_ID, } from "@openmouse/protocol/redragon"; +import { + UTECHSMART_VENUS_CONFIG_USAGE, + UTECHSMART_VENUS_CONFIG_USAGE_PAGE, + UTECHSMART_VENUS_PRODUCT_ID, + UTECHSMART_VENUS_VENDOR_ID, +} from "@openmouse/protocol/utechsmart"; import { FATER_CONFIG_USAGE, FATER_CONFIG_USAGE_PAGE, @@ -238,6 +244,7 @@ export const VENDOR_ID = { microsoft: MICROSOFT_VENDOR_ID, dareu: DAREU_VENDOR_ID, redragon: REDRAGON_VENDOR_ID, + utechsmart: UTECHSMART_VENUS_VENDOR_ID, // Shares SinoWealth's 0x258a with the Glorious classic line (see // `gloriousClassic` above); disjoint by product id. redragonM690Pro: REDRAGON_M690_PRO_VENDOR_ID, @@ -346,6 +353,14 @@ export const REDRAGON_M719_PRO_HID_FILTERS: HIDDeviceFilter[] = REDRAGON_M719_PR }, ]); +/** Owner-verified Venus config collection on USB interface 2; no mouse/keyboard grants. */ +export const UTECHSMART_VENUS_HID_FILTERS: HIDDeviceFilter[] = [{ + vendorId: UTECHSMART_VENUS_VENDOR_ID, + productId: UTECHSMART_VENUS_PRODUCT_ID, + usagePage: UTECHSMART_VENUS_CONFIG_USAGE_PAGE, + usage: UTECHSMART_VENUS_CONFIG_USAGE, +}]; + /** Holtek config collection `0xFFA0:0x01` on USB interface 2 of the M724 and M612. */ export const REDRAGON_HID_FILTERS: HIDDeviceFilter[] = [...REDRAGON_PRODUCT_IDS].map((productId) => ({ vendorId: REDRAGON_VENDOR_ID, @@ -990,6 +1005,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...ASUS_HID_FILTERS, ...DAREU_HID_FILTERS, ...REDRAGON_HID_FILTERS, + ...UTECHSMART_VENUS_HID_FILTERS, ...REDRAGON_M690_PRO_HID_FILTERS, ...REDRAGON_M719_PRO_HID_FILTERS, ...FATER_HID_FILTERS, From 5dfbeee071cd7ef34663d036e73fdd514923027b Mon Sep 17 00:00:00 2001 From: javinnav Date: Sat, 14 Nov 2026 16:12:25 +0000 Subject: [PATCH 3/4] fix(utechsmart): apply Venus polling inside F5 session brackets F5 is a 0/1 session bracket on the Venus (04d9:fc55), not a polling divisor. Sending F5 with the divisor hung the mouse at 125 Hz. Polling now writes the full two-byte word at 0x32+2p inside F5(0) / F3 / F1 02 08 / F5(1), with the delays used by the vendor software, then reads it back. The sequence was verified on hardware (stored word 04->02->04, no other byte changed). Unchanged rates send nothing; failures attempt one F5(1) close and keep the original error. Button and DPI writes are unchanged. Effective report rate is not measured. --- src/drivers/utechsmart/hid.test.ts | 88 +++++++++++++++++++++++++++--- src/drivers/utechsmart/hid.ts | 55 +++++++++++++------ src/utechsmart/venus.test.ts | 38 +++++++++++-- src/utechsmart/venus.ts | 35 +++++++++--- 4 files changed, 176 insertions(+), 40 deletions(-) diff --git a/src/drivers/utechsmart/hid.test.ts b/src/drivers/utechsmart/hid.test.ts index 9e2bb10..aca68ee 100644 --- a/src/drivers/utechsmart/hid.test.ts +++ b/src/drivers/utechsmart/hid.test.ts @@ -34,6 +34,7 @@ class FakeVenus { received: number[] = []; ignoreWrites = false; failCommand: number | null = null; + failSend?: (bytes: number[]) => Error | undefined; malformed = false; stripReportId = false; private pending: { address: number; length: number } | null = null; @@ -45,6 +46,8 @@ class FakeVenus { assert.equal(bytes.length, 15, "WebHID send excludes report id"); this.sent.push(bytes); const [cmd, lo = 0, hi = 0, length = 0] = bytes; + const failure = this.failSend?.(bytes); + if (failure) throw failure; if (cmd === this.failCommand) { this.failCommand = null; throw new Error("simulated STALL"); } const address = lo | (hi << 8); if (cmd === 0xf2) { @@ -53,7 +56,12 @@ class FakeVenus { this.pending = { address, length }; } else if (cmd === 0xf3) { if (!this.ignoreWrites) this.memory.set(bytes.slice(7, 7 + length), address); - } else assert.equal(cmd, 0xf5, "no F1 or session brackets"); + } else if (cmd === 0xf5) { + assert.ok(lo === 0 || lo === 1, "F5 is a session bracket, never a polling divisor"); + assert.ok(bytes.slice(2).every((byte) => byte === 0)); + } else { + assert.deepEqual(bytes, frame("f1 02 08"), "only the settings apply F1 is allowed"); + } } async receiveFeatureReport(reportId: number) { const { address, length } = this.pending!; @@ -137,14 +145,78 @@ test("DPI setter plans minimal F3 from fresh bytes, preserves unknown bytes and assert.equal((await c.readStatus()).dpiStages![0], 8200); }); -test("polling writes active profile byte then F5, no commits, then readback", async () => { +test("polling brackets the full active-profile word, applies, closes, then verifies with exact delays", async (t) => { const fake = new FakeVenus(); - fake.memory[0x35] = 0x7e; - assert.equal(await client(fake).setPollingRate(125), 125); - assert.deepEqual(fake.writes(), [frame("f3 34 00 01 00 00 00 08"), frame("f5 08")]); - assert.equal(fake.memory[0x35], 0x7e); - assert.equal(fake.memory[0x32], 2, "other profile unchanged"); - assert.deepEqual(fake.sent.at(-1), frame("f2 34 00 01")); + const delays: { frame: number[] | undefined; ms: number | undefined }[] = []; + const originalTimeout = globalThis.setTimeout; + t.mock.method(globalThis, "setTimeout", (callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]) => { + delays.push({ frame: fake.sent.at(-1), ms }); + return originalTimeout(callback, ms, ...args); + }); + const c = client(fake); + for (const [hz, code, second] of [[125, "08", 0x7e], [500, "02", 0]]) { + fake.memory[0x35] = second!; + fake.sent = []; + delays.length = 0; + const word = `f3 34 00 02 00 00 00 ${code} ${second!.toString(16)}`; + assert.equal(await c.setPollingRate(hz!), hz); + assert.deepEqual(fake.writes(), [frame("f5 00"), frame(word), frame("f1 02 08"), frame("f5 01")]); + assert.deepEqual(delays.slice(-5), [ + { frame: frame("f5 00"), ms: 100 }, + { frame: frame(word), ms: 8 }, + { frame: frame("f1 02 08"), ms: 100 }, + { frame: frame("f5 01"), ms: 100 }, + { frame: frame("f2 34 00 02"), ms: 5 }, + ]); + assert.equal(fake.memory[0x35], second); + assert.equal(fake.memory[0x32], 2, "other profile unchanged"); + assert.deepEqual(fake.sent.at(-1), frame("f2 34 00 02")); + } +}); + +test("unchanged polling sends no F3, F1 or session brackets", async () => { + const fake = new FakeVenus(); + assert.equal(await client(fake).setPollingRate(1000), 1000); + assert.deepEqual(fake.writes(), []); +}); + +test("polling failures attempt one best-effort close and preserve the original error", async () => { + for (const failedCommand of [0xf3, 0xf1, 0xf5]) { + const fake = new FakeVenus(); + const original = new Error("original failure"); + let failed = false; + fake.failSend = (bytes) => { + if (bytes[0] === 0xf5 && bytes[1] === 0) return; + if (!failed && bytes[0] === failedCommand) { + failed = true; + return original; + } + if (failed && bytes[0] === 0xf5) return new Error("cleanup failure"); + }; + await assert.rejects(() => client(fake).setPollingRate(125), (error) => error === original); + const commands = failedCommand === 0xf3 ? [frame("f5 00"), frame("f3 34 00 02 00 00 00 08 00")] + : [frame("f5 00"), frame("f3 34 00 02 00 00 00 08 00"), frame("f1 02 08")]; + if (failedCommand === 0xf5) commands.push(frame("f5 01")); + assert.deepEqual(fake.writes(), [...commands, frame("f5 01")]); + } +}); + +test("polling readback mismatch also attempts a best-effort close", async () => { + const fake = new FakeVenus(); + fake.ignoreWrites = true; + await assert.rejects(() => client(fake).setPollingRate(125), /readback mismatch/); + assert.deepEqual(fake.writes(), [frame("f5 00"), frame("f3 34 00 02 00 00 00 08 00"), + frame("f1 02 08"), frame("f5 01"), frame("f5 01")]); +}); + +test("invalid polling rates reject before opening or sending any I/O", async () => { + const fake = new FakeVenus(); + const c = client(fake); + for (const hz of [2000, 0, NaN, Infinity, 125.5]) { + await assert.rejects(() => c.setPollingRate(hz), /polling/); + } + assert.equal(fake.opened, false); + assert.deepEqual(fake.sent, []); }); test("buttons support singles, modifier combinations and native actions with minimal writes", async () => { diff --git a/src/drivers/utechsmart/hid.ts b/src/drivers/utechsmart/hid.ts index 0a88968..0d376cd 100644 --- a/src/drivers/utechsmart/hid.ts +++ b/src/drivers/utechsmart/hid.ts @@ -19,9 +19,10 @@ import { utechsmartVenusButtonActionName, utechsmartVenusDecodeStatus, utechsmartVenusDecodeRead, + utechsmartVenusEncodeApply, utechsmartVenusEncodeControl, utechsmartVenusEncodeDpi, - utechsmartVenusEncodePolling, + utechsmartVenusEncodeSession, utechsmartVenusEncodeRead, utechsmartVenusPlanWrite, utechsmartVenusReadReportId, @@ -31,16 +32,15 @@ import { type UtechsmartVenusControl, type UtechsmartVenusMemory, type UtechsmartVenusProfile, -} from "@openmouse/protocol/utechsmart"; +} from "../../utechsmart/index.js"; const READ_DELAY_MS = 5; const WRITE_DELAY_MS = 8; -// F1 commit semantics/persistence are UNVERIFIED. Hardware testing will decide -// this list in one place; v1 sends no F1 (and never the F1 00 00 reset). +// Button/DPI writes stay bare: no F1 or F5 (and never the F1 00 00 reset). const COMMIT_AFTER_WRITE: readonly UtechsmartVenusControl[] = []; -// UNVERIFIED: the reverse-engineered tool claims F5 applies polling immediately. -// This is NOT Redragon's F5 session bracket. -const APPLY_POLLING_IMMEDIATELY = true; +// Hardware-verified polling sequence: F5(0), full F3 word, F1 02 08, F5(1). +// F5 brackets a settings session; readback does not measure effective Hz. +const POLLING_SETTLE_DELAY_MS = 100; // The table has 20 slots; slot 18 is unused and deliberately not remappable. const BUTTON_NAMES = ["Left", "Right", "Middle", "Fire", "DPI+", "DPI-", @@ -125,7 +125,7 @@ export class UtechsmartVenusHidClient { maxDpi: UTECHSMART_VENUS_DPI_LABELS.at(-1)!, stepDpi: 100, }, - statusNote: "Writes are read back; persistence and immediate polling application are not yet hardware-verified.", + statusNote: "Writes are read back; persistence is unverified and effective polling Hz has not been measured.", }, batteryPercent: null, batteryState: "Unknown", @@ -160,13 +160,15 @@ export class UtechsmartVenusHidClient { } async setPollingRate(hz: number): Promise { - const polling = utechsmartVenusEncodePolling(hz); // Validate before I/O. + // Validate before opening the device or reading memory. + if (!UTECHSMART_VENUS_POLLING_RATES.some((rate) => rate === hz)) { + throw new Error(`UtechSmart Venus unsupported polling rate ${hz}.`); + } return await this.run(async () => { await this.open(); const memory = await this.readMemory(); const profile = this.activeProfile(memory).index; - await this.writeAndConfirm(memory, { kind: "polling", profile, hz }, - APPLY_POLLING_IMMEDIATELY ? polling : undefined); + await this.writeAndConfirm(memory, { kind: "polling", profile, hz }); return hz; }); } @@ -227,13 +229,30 @@ export class UtechsmartVenusHidClient { return utechsmartVenusDecodeRead(answer, address, length); } - private async writeAndConfirm(memory: UtechsmartVenusMemory, change: UtechsmartVenusChange, polling?: Uint8Array): Promise { + private async writeAndConfirm(memory: UtechsmartVenusMemory, change: UtechsmartVenusChange): Promise { const frames = utechsmartVenusPlanWrite(memory, change); - for (const frame of frames) await this.sendFrame(frame); - if (frames.length > 0) { - for (const control of COMMIT_AFTER_WRITE) await this.sendFrame(utechsmartVenusEncodeControl(control)); + if (frames.length === 0) return; + if (change.kind === "polling") { + try { + await this.sendFrame(utechsmartVenusEncodeSession(0), POLLING_SETTLE_DELAY_MS); + for (const frame of frames) await this.sendFrame(frame); + await this.sendFrame(utechsmartVenusEncodeApply(), POLLING_SETTLE_DELAY_MS); + await this.sendFrame(utechsmartVenusEncodeSession(1), POLLING_SETTLE_DELAY_MS); + await this.confirmWrites(frames); + } catch (error) { + // Even a rejected send may have reached the device. Try closing once, + // including after a readback failure; never mask the original error. + try { await this.sendFrame(utechsmartVenusEncodeSession(1), POLLING_SETTLE_DELAY_MS); } catch { /* best effort */ } + throw error; + } + return; } - if (polling) await this.sendFrame(polling); + for (const frame of frames) await this.sendFrame(frame); + for (const control of COMMIT_AFTER_WRITE) await this.sendFrame(utechsmartVenusEncodeControl(control)); + await this.confirmWrites(frames); + } + + private async confirmWrites(frames: Uint8Array[]): Promise { for (const frame of frames) { const address = frame[2]! | (frame[3]! << 8); const expected = frame.slice(8, 8 + frame[4]!); @@ -244,9 +263,9 @@ export class UtechsmartVenusHidClient { } } - private async sendFrame(frame: Uint8Array): Promise { + private async sendFrame(frame: Uint8Array, delayMs = frame[1] === 0xf2 ? READ_DELAY_MS : WRITE_DELAY_MS): Promise { await this.device.sendFeatureReport(UTECHSMART_VENUS_REPORT_ID, frame.slice(1).buffer as ArrayBuffer); - await new Promise((resolve) => setTimeout(resolve, frame[1] === 0xf2 ? READ_DELAY_MS : WRITE_DELAY_MS)); + await new Promise((resolve) => setTimeout(resolve, delayMs)); } private async run(operation: () => Promise): Promise { diff --git a/src/utechsmart/venus.test.ts b/src/utechsmart/venus.test.ts index 59cf818..db39014 100644 --- a/src/utechsmart/venus.test.ts +++ b/src/utechsmart/venus.test.ts @@ -49,7 +49,7 @@ test("hardware read/write frames include id, zero reserved bytes and no checksum assert.throws(() => v.utechsmartVenusEncodeWrite(0xffff, [1, 2])); }); -test("F1 controls are explicit and cannot build reset; F5 is polling, never a bracket", () => { +test("legacy F1 controls remain explicit and cannot build reset", () => { const controls = { enter: 1, buttons: 2, dpi: 4, led: 8, exit: 16 } as const; for (const [name, code] of Object.entries(controls)) { const frame = v.utechsmartVenusEncodeControl(name as keyof typeof controls); @@ -58,10 +58,19 @@ test("F1 controls are explicit and cannot build reset; F5 is polling, never a br assert.ok(frame.slice(4).every((b) => b === 0)); } assert.throws(() => v.utechsmartVenusEncodeControl("reset" as "enter")); - for (const [hz, code] of [[125, 8], [250, 4], [500, 2], [1000, 1]]) { - assert.deepEqual(v.utechsmartVenusEncodePolling(hz!), Uint8Array.from([2, 0xf5, code!, ...new Array(13).fill(0)])); +}); + +test("F5 brackets settings writes with 0/1, never a polling divisor", () => { + for (const state of [0, 1] as const) { + assert.deepEqual(v.utechsmartVenusEncodeSession(state), Uint8Array.from([2, 0xf5, state, ...new Array(13).fill(0)])); + } + for (const state of [-1, 2, 4, 8, 0.5, NaN]) { + assert.throws(() => v.utechsmartVenusEncodeSession(state as 0)); } - assert.throws(() => v.utechsmartVenusEncodePolling(2000)); +}); + +test("settings apply is F1 02 08 without relying on the legacy LED interpretation", () => { + assert.deepEqual(v.utechsmartVenusEncodeApply(), hex("02 f1 02 08 00 00 00 00 00 00 00 00 00 00 00 00")); }); function reply(address: number, length: number): Uint8Array { @@ -204,7 +213,7 @@ test("status exposes unknown DPI range and polling code, rejects incomplete or i assert.throws(() => v.utechsmartVenusDecodeStatus(memory)); }); -test("write plans emit only minimal F3 byte spans and preserve unknown/reserved/tail bytes", () => { +test("write plans emit minimal F3 spans except full polling words and preserve unknown/reserved/tail bytes", () => { const memory = fixture(); memory.data.set([0xa5, 0x16, 0, 0xc1, 0xc2, 0xc3], 0x44); memory.data[0x33] = 0x7e; @@ -213,7 +222,7 @@ test("write plans emit only minimal F3 byte spans and preserve unknown/reserved/ [{ kind: "dpi", profile: 0, stage: 0, dpi: 8200 }, 0x45, [0x5c, 1]], [{ kind: "dpi", profile: 0, stage: 0, dpi: 2000 }, 0x45, [0x2d]], [{ kind: "currentStage", profile: 0, stage: 4 }, 0x42, [4]], - [{ kind: "polling", profile: 0, hz: 1000 }, 0x32, [1]], + [{ kind: "polling", profile: 0, hz: 1000 }, 0x32, [1, 0x7e]], [{ kind: "button", profile: 1, slot: 6, action: { kind: "combo", modifiers: 3, usage: 0x1e } }, 0x15b, [3]], [{ kind: "button", profile: 0, slot: 19, action: { kind: "raw", bytes: hex("ff ab cd ef") } }, 0xce, [0xff, 0xab, 0xcd, 0xef]], ]; @@ -230,6 +239,23 @@ test("write plans emit only minimal F3 byte spans and preserve unknown/reserved/ assert.deepEqual(v.utechsmartVenusPlanWrite(memory, { kind: "polling", profile: 0, hz: 500 }), []); assert.deepEqual(v.utechsmartVenusPlanWrite(memory, { kind: "button", profile: 0, slot: 0, action: { kind: "action", name: "Left click" } }), []); }); +test("polling plans use vendor codes and full preserved words in every profile", () => { + const memory = fixture(); + for (let profile = 0; profile < 5; profile++) { + const address = 0x32 + 2 * profile; + const second = 0xa0 + profile; + for (const [hz, code] of [[1000, 1], [500, 2], [250, 4], [125, 8]] as const) { + memory.data.set([code === 1 ? 2 : 1, second], address); + const before = memory.data.slice(); + const change = { kind: "polling", profile, hz } as const; + assert.deepEqual(v.utechsmartVenusPlanWrite(memory, change), [v.utechsmartVenusEncodeWrite(address, [code, second])]); + assert.deepEqual(memory.data, before, "planning must not mutate readback"); + memory.data[address] = code; + assert.deepEqual(v.utechsmartVenusPlanWrite(memory, change), []); + } + } +}); + test("write plans accept address-based excerpts but reject missing bytes, inactive stages and tail slots", () => { const excerpt = { address: 0x40, data: factoryDpi.slice() }; assert.deepEqual(v.utechsmartVenusPlanWrite(excerpt, { kind: "dpi", profile: 0, stage: 0, dpi: 2000 }), [v.utechsmartVenusEncodeWrite(0x45, [0x2d])]); diff --git a/src/utechsmart/venus.ts b/src/utechsmart/venus.ts index fa462a3..e8cf6fc 100644 --- a/src/utechsmart/venus.ts +++ b/src/utechsmart/venus.ts @@ -2,8 +2,10 @@ * UtechSmart Venus MMO wired, Holtek 04d9:fc55. * Memory layout, F2 replies, bare F3 writes, keyboard combos and the cited DPI * pairs were verified on the user's hardware by read probes/vendor UI/readback. - * F1 meanings and F5 polling are inferred from a reverse-engineered Linux tool, - * NOT hardware-verified. No Redragon session bracket, reset or LED memory writer. + * Polling writes were hardware-verified with F5 0/1 session brackets and F1 02 08 + * apply, matching the official software. Effective Hz was NOT measured; apply's + * broader meaning and the legacy F1 control names remain partly unverified. + * No reset or LED memory writer. * This module has not itself been exercised against hardware. Callers must read * back after writing; persistence across power cycles is not established here. */ @@ -106,10 +108,22 @@ function pollingCode(hz: number): number { if (!UTECHSMART_VENUS_POLLING_RATES.some((rate) => rate === hz)) throw new Error(`UtechSmart Venus unsupported polling rate ${hz}.`); return 1000 / hz; } -/** UNVERIFIED F5 polling command. Unlike Redragon, F5 is NOT a session bracket. */ -export function utechsmartVenusEncodePolling(hz: number): Uint8Array { +/** Hardware-verified F5 session bracket: 0 before settings writes, 1 after apply. + * F5 is NOT a polling divisor. Readback confirms stored codes, not effective Hz. + */ +export function utechsmartVenusEncodeSession(state: 0 | 1): Uint8Array { + integer(state, 0, 1, "session state"); + const frame = new Uint8Array(16); + frame.set([2, 0xf5, state]); + return frame; +} +/** Official software's settings apply, also verified in the polling sequence. + * Its broader commit/persistence semantics remain unverified. The legacy F1 + * control encoder calls this code "led" based on an unreliable Linux tool. + */ +export function utechsmartVenusEncodeApply(): Uint8Array { const frame = new Uint8Array(16); - frame.set([2, 0xf5, pollingCode(hz)]); + frame.set([2, 0xf1, 2, 8]); return frame; } @@ -118,7 +132,9 @@ export function utechsmartVenusProfileAddress(profile: number): number { integer(profile, 0, 4, "profile"); return UTECHSMART_VENUS_PROFILE_BASES[profile]!; } -/** Hardware-observed polling record is [1000 / Hz, unknown] at 0x32 + 2p. */ +/** Polling word is [vendor code (1000 / labeled Hz), unknown] at 0x32 + 2p. + * Stored codes are verified; effective Hz has not been measured. + */ export function utechsmartVenusPollAddress(profile: number): number { utechsmartVenusProfileAddress(profile); return 0x32 + 2 * profile; @@ -293,12 +309,14 @@ export type UtechsmartVenusChange = | { kind: "button"; profile: number; slot: number; action: UtechsmartVenusButtonAction }; /** * Plan ONE change using current readback. Returns no frames for a no-op, otherwise - * one minimal contiguous F3 span (<=4 bytes). Never mutates the excerpt, rewrites + * one minimal contiguous F3 span (<=4 bytes), except polling always writes its + * full two-byte word with byte 1 preserved. Never mutates the excerpt, changes * unknown bytes, enables extra stages, touches trailing wheel records, or emits * F1/F5 automatically. A stage change requires its four-byte profile header plus * the six-byte stage record; button edits require count+slot; polling needs two * bytes. Refresh readback before planning and verify again after applying. - * Writes applying bare was confirmed; durability/commit requirements remain open. + * Bare button/DPI writes were confirmed. Polling requires the caller to bracket + * F3 with F5 0/1 and send settings apply; durability remains unverified. */ export function utechsmartVenusPlanWrite(memory: UtechsmartVenusMemory, change: UtechsmartVenusChange): Uint8Array[] { let address: number; @@ -342,6 +360,7 @@ export function utechsmartVenusPlanWrite(memory: UtechsmartVenusMemory, change: let first = 0; while (first < before.length && before[first] === after[first]) first++; if (first === before.length) return []; + if (change.kind === "polling") return [utechsmartVenusEncodeWrite(address, after)]; let end = before.length; while (before[end - 1] === after[end - 1]) end--; return [utechsmartVenusEncodeWrite(address + first, after.slice(first, end))]; From e0bc2b4eef6d5561219edf7076553a8a3ebf9f86 Mon Sep 17 00:00:00 2001 From: javinnav Date: Sat, 14 Nov 2026 16:36:04 +0000 Subject: [PATCH 4/4] fix(utechsmart): tighten Venus support check and value verification Require both feature reports 2 and 3 on the same vendor collection, since long reads use report 3. Report valuesVerified only when every enabled DPI stage of the active profile decodes from a known code and range, and expose an additive exact flag on decoded stages. Update the driver notes with what was exercised on the owner's unit; effective polling rate and polling persistence across a replug remain unmeasured. --- src/drivers/utechsmart/hid.test.ts | 40 +++++++++++++++++++++++++++--- src/drivers/utechsmart/hid.ts | 16 +++++++----- src/utechsmart/venus.test.ts | 19 ++++++++++++++ src/utechsmart/venus.ts | 15 ++++++++--- 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/drivers/utechsmart/hid.test.ts b/src/drivers/utechsmart/hid.test.ts index aca68ee..e5acdc4 100644 --- a/src/drivers/utechsmart/hid.test.ts +++ b/src/drivers/utechsmart/hid.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { UtechsmartVenusHidClient } from "./hid.ts"; +import { UtechsmartVenusHidClient } from "./hid.js"; const hex = (text: string) => Uint8Array.from(text.trim().split(/\s+/).map((byte) => parseInt(byte, 16))); // Sanitized real dump excerpts copied from src/utechsmart/venus.test.ts. @@ -83,7 +83,7 @@ const device = (fake: FakeVenus) => fake as unknown as HIDDevice; const client = (fake = new FakeVenus()) => new UtechsmartVenusHidClient(device(fake)); const frame = (body: string) => [...hex(body), ...new Array(15 - hex(body).length).fill(0)]; -test("isSupported claims only Venus config collection with feature report 2", () => { +test("isSupported claims only Venus config collection with feature reports 2 and 3", () => { assert.equal(UtechsmartVenusHidClient.isSupported(device(new FakeVenus())), true); for (const [vendorId, productId, usagePage, usage] of [ [0x04d9, 0xfc55, 1, 2], [0x04d9, 0xfc55, 1, 6], [0x04d9, 0xfc55, 0x0c, 1], @@ -94,8 +94,21 @@ test("isSupported claims only Venus config collection with feature report 2", () Object.assign(fake.collections[0]!, { usagePage, usage }); assert.equal(UtechsmartVenusHidClient.isSupported(device(fake)), false); } + for (const reportIds of [[], [2], [3]]) { + const fake = new FakeVenus(); + fake.collections[0]!.featureReports = reportIds.map((reportId) => ({ reportId, items: [] })); + assert.equal(UtechsmartVenusHidClient.isSupported(device(fake)), false, `reports ${reportIds}`); + } +}); + +test("isSupported finds both reports on a nested vendor collection, not separate collections", () => { const fake = new FakeVenus(); - fake.collections[0]!.featureReports = []; + const child = fake.collections[0]!; + fake.collections = [{ ...child, usagePage: 1, usage: 2, featureReports: [], children: [child] }]; + assert.equal(UtechsmartVenusHidClient.isSupported(device(fake)), true); + child.featureReports = [{ reportId: 2, items: [] }]; + assert.equal(UtechsmartVenusHidClient.isSupported(device(fake)), false); + fake.collections.push({ ...child, featureReports: [{ reportId: 3, items: [] }] }); assert.equal(UtechsmartVenusHidClient.isSupported(device(fake)), false); }); @@ -134,6 +147,27 @@ test("readStatus decodes active profile 2 and Ctrl side buttons from real dump", assert.equal(fake.opened, false); }); +test("readStatus marks fallback DPI values unverified even on a noncurrent enabled stage", async () => { + for (const [value, range, dpi] of [[5, 0, 220], [9, 0xfe, 400], [5, 0xfe, 220]]) { + const fake = new FakeVenus(); + fake.memory.set([value!, range!], 0x11d); // Active profile, enabled stage 4; current stage is 3. + const status = await client(fake).readStatus(); + assert.equal(status.dpiStages![4], dpi); + assert.equal(status.dpi, 1000); + assert.equal(status.ui?.valuesVerified, false, `DPI pair ${value}/${range}`); + } +}); + +test("readStatus verifies known ranges and ignores fallback in inactive profiles or disabled stages", async () => { + const fake = new FakeVenus(); + fake.memory[0x2c] = 0; // Factory profile includes ranges 0, 1 and the special range-4 cap. + fake.memory.set([5, 0xfe], 0x105); // Inactive profile. + fake.memory.set([5, 0xfe], 0x63); // Disabled stage 5 in the active profile. + const status = await client(fake).readStatus(); + assert.deepEqual(status.dpiStages, [1000, 2000, 4000, 8200, 16400]); + assert.equal(status.ui?.valuesVerified, true); +}); + test("DPI setter plans minimal F3 from fresh bytes, preserves unknown bytes and verifies", async () => { const fake = new FakeVenus(); fake.memory.set([0xa5, 0x09, 0, 0xc1, 0xc2, 0xc3], 0x104); diff --git a/src/drivers/utechsmart/hid.ts b/src/drivers/utechsmart/hid.ts index 0d376cd..c77fc20 100644 --- a/src/drivers/utechsmart/hid.ts +++ b/src/drivers/utechsmart/hid.ts @@ -8,6 +8,7 @@ import { UTECHSMART_VENUS_CONFIG_USAGE, UTECHSMART_VENUS_CONFIG_USAGE_PAGE, UTECHSMART_VENUS_DPI_LABELS, + UTECHSMART_VENUS_LONG_REPORT_ID, UTECHSMART_VENUS_MAX_READ, UTECHSMART_VENUS_MAX_STAGES, UTECHSMART_VENUS_POLLING_RATES, @@ -65,14 +66,17 @@ function hasConfigCollection(collections: readonly HIDCollectionInfo[]): boolean return collections.some((collection) => (collection.usagePage === UTECHSMART_VENUS_CONFIG_USAGE_PAGE && collection.usage === UTECHSMART_VENUS_CONFIG_USAGE && - (collection.featureReports ?? []).some((report) => report.reportId === UTECHSMART_VENUS_REPORT_ID)) || + [UTECHSMART_VENUS_REPORT_ID, UTECHSMART_VENUS_LONG_REPORT_ID].every((reportId) => + (collection.featureReports ?? []).some((report) => report.reportId === reportId))) || hasConfigCollection(collection.children ?? [])); } /** Wired Venus MMO 04d9:fc55, on interface 2's vendor config collection only. - * Transport/read memory and bare F3 writes are owner-verified; this client has - * not been tested on hardware. Readback confirms memory, not durable storage - * or effective sensor/polling behavior. No LEDs, macros or profile switching. + * Owner-tested on Windows/Edge WebHID: status, button remaps (including Ctrl), + * DPI stage values and polling 1000/500/125 Hz were exercised and read back. + * Button/DPI F3 writes persisted across replug. Polling uses + * F5(0)/F3/F1 02 08/F5(1); its replug persistence was not tested and effective + * report rate was not measured. No LEDs, macros or profile switching. */ export class UtechsmartVenusHidClient { readonly device: HIDDevice; @@ -113,7 +117,7 @@ export class UtechsmartVenusHidClient { ui: { family: "utechsmart-venus", settingsReady: true, - valuesVerified: true, + valuesVerified: profile.pollingHz !== null && profile.stages.every((stage) => stage.exact), hideUnsupportedPollingRates: true, hideProcessingCard: true, defaultDisplayName: "UtechSmart Venus MMO", @@ -125,7 +129,7 @@ export class UtechsmartVenusHidClient { maxDpi: UTECHSMART_VENUS_DPI_LABELS.at(-1)!, stepDpi: 100, }, - statusNote: "Writes are read back; persistence is unverified and effective polling Hz has not been measured.", + statusNote: "Writes are read back from device memory. Effective polling rate has not been measured.", }, batteryPercent: null, batteryState: "Unknown", diff --git a/src/utechsmart/venus.test.ts b/src/utechsmart/venus.test.ts index db39014..61cfda5 100644 --- a/src/utechsmart/venus.test.ts +++ b/src/utechsmart/venus.test.ts @@ -138,6 +138,25 @@ test("all vendor DPI labels round-trip, with lower ties and cap exception", () = assert.equal(v.utechsmartVenusDecodeDpi(9, 0xfe), 400); }); +test("decoded stages distinguish exact vendor labels from fallback code/range pairs", () => { + const memory = fixture(); + for (const dpi of v.UTECHSMART_VENUS_DPI_LABELS) { + const encoded = v.utechsmartVenusEncodeDpi(dpi); + memory.data.set([encoded.value, encoded.range], 0x105); + const stage = v.utechsmartVenusDecodeStatus(memory).profiles[1]!.stages[0]!; + assert.equal(stage.dpi, dpi); + assert.equal(stage.exact, true, `vendor DPI ${dpi}`); + } + for (const [value, range, dpi] of [[5, 0, 220], [5, 1, 440], [5, 4, 670], [9, 0xfe, 400], [5, 0xfe, 220]]) { + memory.data.set([value!, range!], 0x105); + const stage = v.utechsmartVenusDecodeStatus(memory).profiles[1]!.stages[0]!; + assert.equal(stage.dpi, dpi); + assert.equal(stage.exact, false, `fallback pair ${value}/${range}`); + assert.equal(stage.value, value); + assert.equal(stage.range, range); + } +}); + test("button codec reuses Holtek names, supports single/keypad keys and standard 8-bit modifiers", () => { for (const [name, code] of [["Left click", 0x81], ["Right click", 0x82], ["Middle click", 0x83], ["Back", 0x84], ["Forward", 0x85], ["DPI down", 0x89], ["DPI up", 0x8a], ["Profile cycle", 0x8d], ["Disabled", 0]] as const) { const action = { kind: "action", name } as const; diff --git a/src/utechsmart/venus.ts b/src/utechsmart/venus.ts index e8cf6fc..c70b5d6 100644 --- a/src/utechsmart/venus.ts +++ b/src/utechsmart/venus.ts @@ -6,8 +6,9 @@ * apply, matching the official software. Effective Hz was NOT measured; apply's * broader meaning and the legacy F1 control names remain partly unverified. * No reset or LED memory writer. - * This module has not itself been exercised against hardware. Callers must read - * back after writing; persistence across power cycles is not established here. + * The WebHID client was owner-tested on Windows/Edge; button/DPI F3 writes + * persisted across replug. Polling replug persistence was not tested. + * Callers must read back after writing. */ import { redragonEncodeRead, redragonEncodeWrite, redragonButtonCodec, @@ -275,6 +276,8 @@ function buttonCount(memory: UtechsmartVenusMemory, profile: number): void { } export interface UtechsmartVenusStage { dpi: number; value: number; range: number; raw: Uint8Array; + /** Known code and range; false when DPI uses a fallback. */ + exact: boolean; } export interface UtechsmartVenusProfile { index: number; pollingHz: number | null; pollingCode: number; @@ -291,7 +294,11 @@ export function utechsmartVenusDecodeStatus(memory: UtechsmartVenusMemory): Utec buttonCount(memory, index); const stages = Array.from({ length: header[0]! }, (_, stage): UtechsmartVenusStage => { const raw = readMemory(memory, utechsmartVenusStageAddress(index, stage), 6); - return { dpi: utechsmartVenusDecodeDpi(raw[1]!, raw[2]!), value: raw[1]!, range: raw[2]!, raw }; + return { + dpi: utechsmartVenusDecodeDpi(raw[1]!, raw[2]!), value: raw[1]!, range: raw[2]!, raw, + exact: [0, 1, 4].includes(raw[2]!) && + (BASE_CODES.includes(raw[1]!) || (raw[1] === 124 && raw[2] === 4)), + }; }); return { index, pollingHz: [1, 2, 4, 8].includes(polling) ? 1000 / polling : null, pollingCode: polling, @@ -316,7 +323,7 @@ export type UtechsmartVenusChange = * the six-byte stage record; button edits require count+slot; polling needs two * bytes. Refresh readback before planning and verify again after applying. * Bare button/DPI writes were confirmed. Polling requires the caller to bracket - * F3 with F5 0/1 and send settings apply; durability remains unverified. + * F3 with F5 0/1 and send settings apply; polling replug persistence is untested. */ export function utechsmartVenusPlanWrite(memory: UtechsmartVenusMemory, change: UtechsmartVenusChange): Uint8Array[] { let address: number;