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/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..e5acdc4 --- /dev/null +++ b/src/drivers/utechsmart/hid.test.ts @@ -0,0 +1,333 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +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. +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; + failSend?: (bytes: number[]) => Error | undefined; + 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; + 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) { + 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 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!; + 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 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], + [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); + } + 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(); + 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); +}); + +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("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); + 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 brackets the full active-profile word, applies, closes, then verifies with exact delays", async (t) => { + const fake = new FakeVenus(); + 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 () => { + 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..c77fc20 --- /dev/null +++ b/src/drivers/utechsmart/hid.ts @@ -0,0 +1,280 @@ +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_LONG_REPORT_ID, + 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, + utechsmartVenusEncodeApply, + utechsmartVenusEncodeControl, + utechsmartVenusEncodeDpi, + utechsmartVenusEncodeSession, + utechsmartVenusEncodeRead, + utechsmartVenusPlanWrite, + utechsmartVenusReadReportId, + utechsmartVenusStageAddress, + type UtechsmartVenusButtonAction, + type UtechsmartVenusChange, + type UtechsmartVenusControl, + type UtechsmartVenusMemory, + type UtechsmartVenusProfile, +} from "../../utechsmart/index.js"; + +const READ_DELAY_MS = 5; +const WRITE_DELAY_MS = 8; +// Button/DPI writes stay bare: no F1 or F5 (and never the F1 00 00 reset). +const COMMIT_AFTER_WRITE: readonly UtechsmartVenusControl[] = []; +// 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-", + ...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 && + [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. + * 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; + 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: profile.pollingHz !== null && profile.stages.every((stage) => stage.exact), + 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 from device memory. Effective polling rate has not been measured.", + }, + 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 { + // 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 }); + 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): Promise { + const frames = utechsmartVenusPlanWrite(memory, change); + 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; + } + 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]!); + 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, 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, delayMs)); + } + + 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, 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..61cfda5 --- /dev/null +++ b/src/utechsmart/venus.test.ts @@ -0,0 +1,286 @@ +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("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); + 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")); +}); + +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)); + } +}); + +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 { + 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("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; + 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 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; + 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, 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]], + ]; + 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("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])]); + 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..c70b5d6 --- /dev/null +++ b/src/utechsmart/venus.ts @@ -0,0 +1,374 @@ +/** + * 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. + * 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. + * 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, + 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; +} +/** 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, 0xf1, 2, 8]); + 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]!; +} +/** 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; +} +/** 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; + /** Known code and range; false when DPI uses a fallback. */ + exact: boolean; +} +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, + 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, + 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), 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. + * Bare button/DPI writes were confirmed. Polling requires the caller to bracket + * 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; + 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 []; + 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))]; +}