From 3152e56acfb34895b36426823dd32e695b7d40e3 Mon Sep 17 00:00:00 2001 From: sakethkanchi Date: Fri, 4 Sep 2026 15:08:14 -0400 Subject: [PATCH 1/3] atk: decode PAW3395 DPI correctly and identify VXE mice by CID/MID The ATK driver applied the A9's PAW3950Ultra DPI encoding to every device behind vendor id 0x373b. ATK HUB Web 3.2.21 branches that decoder per sensor: PAW3950/PAW3950DM/PAW3395/PAW3395Ultra pack a plain 10-bit count of 50-DPI steps instead. A PAW3395 stage read with the A9 encoding is a fifth of the real value. A receiver's product id cannot say which sensor a mouse has: 0x373b:0x1085 is a generic "Wireless mouse 1k dongle" reused across models, and the R1 alone ships with PAW3311, PAW3395, PAW3395SE, PAW3395Ultra, PAW3950Ultra and CORE26K. So identify the mouse the way the vendor's own HUB does, with GetMouseCIDMID (command 0x10), and look the pair up in a catalog that carries the sensor. - add per-sensor profiles and the step-50 codec to src/atk - add src/drivers/atk/products.ts, keyed "," - pick the DPI encoding, ceiling and DPI ladder from the sensor, for reads and writes alike; an unidentified mouse keeps the A9 behaviour, matching the HUB's own fallback - report VXE as its own brand via deviceBrand(), as Lamzu/CRDRAKO already do - claim VXE's wired transport (0x3554:0xf58f, "Compx VXE R1"), which speaks the same 0xff02/report-0x08 channel. Product-id gated: 0x3554 is shared with the VGN Dragonfly F2, so the id also joins the Pulsar fallback's CLAIMED_VGN_PRODUCT_IDS - decode the rest of GetBatteryLevel: the reply carries a charge flag and a cell voltage next to the percentage, so report batteryState and batteryVoltageMv instead of a hardcoded "Unknown" - treat an unprogrammed angle register as unsupported. It reads 0xff and fails the (value, 0x55-value) pair, but decoded as -1 degrees - retry identification instead of caching a timeout, so a mouse that was asleep is not left misnamed and mis-decoded for the session Sensors whose DPI mapping is a lookup table (PAW3395SE, PAW3315, PAW3311, PAW3320) are deliberately not implemented; those tables are not captured here and a guessed step would misreport DPI silently. Verified on a VXE R1 (CID/MID 2,12, PAW3395, firmware Mouse 3.13) over both transports: DPI now reads 1600 rather than 320, ceiling 30000 rather than 42000, and the mouse names itself "VXE R1". Battery was captured across a charge cycle: flag 0 with a steady voltage on battery, flag 1 with the voltage climbing on the cable. Writes were not exercised. See docs/atk-testing.md. --- docs/atk-testing.md | 114 ++++++++++++++++++++++++++ src/atk/index.ts | 94 ++++++++++++++++++++++ src/drivers/atk/hid.ts | 133 +++++++++++++++++++++++++++---- src/drivers/atk/products.ts | 55 +++++++++++++ src/drivers/atk/protocol.test.ts | 103 ++++++++++++++++++++++++ src/drivers/mouse-types.ts | 2 +- src/drivers/pulsar/pulsar-hid.ts | 2 + src/drivers/registry.ts | 2 + src/drivers/vendors.ts | 6 ++ 9 files changed, 494 insertions(+), 17 deletions(-) create mode 100644 docs/atk-testing.md create mode 100644 src/drivers/atk/products.ts diff --git a/docs/atk-testing.md b/docs/atk-testing.md new file mode 100644 index 0000000..99a0012 --- /dev/null +++ b/docs/atk-testing.md @@ -0,0 +1,114 @@ +# ATK / VXE hardware test checklist + +Test in Chrome or Edge over HTTPS. Select the vendor configuration collection +(`usagePage 0xff02`, `usage 0x02`) when the browser lists multiple HID +interfaces; the plain pointer and keyboard collections cannot answer commands. + +Supported identifiers: + +- VID `373b` — ATK, shared with its VXE sibling brand +- The discovery filter matches vendor + `usagePage 0xff02`, not product ids: a + 2.4 GHz receiver's product id is reused across models. `0x373b:0x1085` + ("Wireless mouse -1k dongle") is one such shared receiver. + +## Identifying the mouse, not the receiver + +Because the receiver's product id says nothing about the mouse behind it, the +driver reads a CID/MID identity pair from the mouse (`GetMouseCIDMID`, +command `0x10`) and looks it up in `src/drivers/atk/products.ts`. This matters +beyond the displayed name: the pair selects the sensor, and the sensor selects +the DPI encoding. + +| sensor | DPI encoding | ceiling | +| --- | --- | --- | +| PAW3950Ultra | 10-DPI steps, 50-DPI steps above 10,050, doubled above 30,000 | 42,000 | +| PAW3950 / PAW3950DM | flat 50-DPI steps, doubled above 30,000 | 36,000 | +| PAW3395 / PAW3395Ultra | flat 50-DPI steps, doubled above 30,000 | 30,000 | +| CORE26K | flat 50-DPI steps | 26,000 | + +Reading a PAW3395 stage with the PAW3950Ultra encoding yields a fifth of the +real value (a 1,600 DPI stage reads as 320). An unidentified mouse keeps the +PAW3950Ultra behaviour, matching the vendor HUB's own fallback. + +The R1 alone ships with at least six different sensors across revisions +(PAW3311, PAW3395, PAW3395SE, PAW3395Ultra, PAW3950Ultra, CORE26K). The model +name on the box is therefore **not** enough to pick an encoding — only the +CID/MID pair is. Sensors whose DPI mapping is a lookup table rather than a +formula (PAW3395SE, PAW3315, PAW3311, PAW3320) are deliberately not implemented +here; their tables have not been captured, and a guessed step would silently +misreport DPI. + +## Verified hardware + +- **VXE R1** — CID/MID `2,12`, sensor PAW3395, firmware `Mouse 3.13`. Read-only + verification on Linux (`/dev/hidraw*`, 2026-09-04): + - DPI stages at EEPROM `0x000c`..`0x001b` read `0f 0f 00 37` / `17 17 00 27` / + `1f 1f 00 17` / `3f 3f 00 d7` → 800 / 1200 / 1600 / 3200 DPI. + - Polling code `0x01` → 1,000 Hz. The R1 ships with a 1K receiver, but an 8K + receiver is sold separately and works with the same mouse, so the polling + ceiling is a property of the receiver in use, not of the model. The driver + therefore offers the full 125–8,000 Hz ladder rather than capping by product + id, and reports whatever rate the mouse actually returns. + - Firmware, lift-off, debounce, motion sync and sleep read correctly; battery + percent, charge state and voltage are covered under "Battery" below. + - Angle register `0x00bd` reads `ff ff ff ff` — unprogrammed. It fails the + value/checksum pair, so angle tuning and angle snapping report as + unsupported rather than decoding `0xff` as −1°. + - Writes were **not** exercised. `verified: true` in the catalog records the + identity and read path only. + + Both transports were verified on the same unit, reporting the same identity, + DPI and firmware through each: + - `0x373b:0x1085` — 2.4 GHz receiver, `connectionType: "Wireless"`. + - `0x3554:0xf58f` — wired, `connectionType: "Wired"`. This id lives under + COMPX's vendor id, shared with the VGN Dragonfly F2, so it is claimed by + product id (`ATK_COMPX_PRODUCT_IDS`) and excluded from the Pulsar fallback's + `CLAIMED_VGN_PRODUCT_IDS`. The vendor's table reuses `0xf58f` for the R1SE + and R1SE+ too, which are PAW3395SE — another reason the sensor must come + from CID/MID rather than the product id. + +### Battery + +`GetBatteryLevel` (`0x04`) answers `[percent, charging, voltage_hi, voltage_lo]`. +All three fields are decoded and were captured over a charge cycle: + +| state | charging byte | voltage | percent | +| --- | --- | --- | --- | +| on battery | `0`, voltage steady | 3,786 mV | 40% | +| on the cable | `1`, voltage climbing 3,893 → 3,938 mV | 3,969 mV | 70% | +| unplugged again | `0` | 3,875 mV | 50% | + +A non-zero charging byte means charging. ATK's own HUB tests `=== 2` on some +families, which this treats as charging too; only a non-zero code meaning "not +charging" would be misread, and none has been observed. + +**Expect the percentage to drop when you unplug the cable.** The mouse derives +percent from cell voltage, and a charging cell sits above its resting voltage, so +a charging reading is optimistic (70% at 3,969 mV charging against 50% at +3,875 mV resting on the same cell, minutes apart). This is the mouse's own +reporting, not a decode error — do not "fix" it. + +A mouse that answers nothing reports `"Unknown"`, which is not the same as +`"Discharging"`: a sleeping 2.4 GHz mouse answers nothing at all. + +Note that the receiver's command channel goes idle while the mouse is on the +cable — the mouse serves one link at a time — so the two transports can never be +read at the same instant. + +## Checklist + +1. Connect the device and confirm the wired/wireless state, battery, firmware + version, DPI and polling rate are correct. A mouse reached through a + receiver must show its own model name, not the receiver's USB string. +2. Confirm the reported DPI matches what the vendor's own HUB shows for the same + stage. A value five times too large or too small means the sensor mapping is + wrong for that CID/MID, not that the stage is corrupt. +3. Change one setting at a time: DPI, polling rate, lift-off distance, debounce, + motion sync, ripple control and sleep timeout. Each setter reads its value + back and raises if the mouse kept the old one. +4. Reload after each write and confirm the value persisted. +5. Confirm a sleeping wireless mouse recovers: a sleeping mouse answers nothing, + so it must read correctly after being woken rather than staying unidentified. +6. Record the CID/MID pair, firmware version, sensor and any failing setting in + the issue or pull request. A new CID/MID entry may only be marked + `verified: true` once that exact pair has been read from hardware. diff --git a/src/atk/index.ts b/src/atk/index.ts index 44f426c..417c22c 100644 --- a/src/atk/index.ts +++ b/src/atk/index.ts @@ -53,6 +53,100 @@ export function atkUnpackDpiStage(data: Uint8Array | readonly number[]): { x: nu }; } +/** + * Sensor families behind vendor id 0x373b, named as ATK's own HUB bundle names + * them. The A9-era `atkEncodeDpiAxis` above is only correct for PAW3950Ultra; + * the PAW3395/PAW3950 family packs a plain 10-bit count of 50-DPI steps, so a + * stage read with the wrong family is wrong by a factor of five. Which family a + * mouse belongs to is not derivable from the USB ids — a receiver's product id + * is shared across models — so it comes from the CID/MID identity the mouse + * reports (see drivers/atk/products.ts). + */ +export type AtkSensor = + | "PAW3950Ultra" + | "PAW3950" + | "PAW3950DM" + | "PAW3395Ultra" + | "PAW3395" + | "CORE26K"; + +/** How a sensor packs DPI: `ultra` is the segmented A9 encoding, `step50` the 50-DPI count. */ +export type AtkDpiFamily = "ultra" | "step50"; + +export interface AtkSensorProfile { + family: AtkDpiFamily; + /** Limits the vendor's HUB enforces for this sensor. */ + minDpi: number; + maxDpi: number; + stepDpi: number; +} + +/** + * Transcribed from the DPI limit table in ATK HUB Web 3.2.21. Sensors whose + * encoding is a lookup table rather than a formula (PAW3395SE, PAW3315, + * PAW3311, PAW3320) are deliberately absent: their tables have not been + * captured here, and a guessed step would silently misreport DPI. + */ +export const ATK_SENSORS: Record = { + PAW3950Ultra: { family: "ultra", minDpi: 10, maxDpi: 42000, stepDpi: 10 }, + PAW3950: { family: "step50", minDpi: 50, maxDpi: 36000, stepDpi: 50 }, + PAW3950DM: { family: "step50", minDpi: 50, maxDpi: 36000, stepDpi: 50 }, + PAW3395Ultra: { family: "step50", minDpi: 100, maxDpi: 30000, stepDpi: 50 }, + PAW3395: { family: "step50", minDpi: 100, maxDpi: 30000, stepDpi: 50 }, + CORE26K: { family: "step50", minDpi: 50, maxDpi: 26000, stepDpi: 50 }, +}; + +/** Above this the step doubles to 100 DPI and the per-axis double bit is set. */ +const STEP50_DOUBLE_ABOVE = 30000; + +/** `step50` axis: a 10-bit count of 50-DPI steps, optionally doubled. */ +export function atkDecodeDpiAxisStep50(byte: number, nibble: number): number { + const count = (byte & 0xff) | (((nibble >> 2) & 0x03) << 8); + const dpi = (count + 1) * 50; + return (nibble & 1) !== 0 ? dpi * 2 : dpi; +} + +export function atkEncodeDpiAxisStep50(dpi: number): { byte: number; nibble: number } { + const doubled = dpi > STEP50_DOUBLE_ABOVE; + const count = Math.round(dpi / (doubled ? 100 : 50)) - 1; + return { byte: count & 0xff, nibble: (((count >> 8) & 0x03) << 2) | (doubled ? 1 : 0) }; +} + +function axisCodec(sensor: AtkSensor | null): { + decode: (byte: number, nibble: number) => number; + encode: (dpi: number) => { byte: number; nibble: number }; +} { + // An unidentified mouse keeps the historical A9 behaviour rather than + // silently switching encoding, matching the vendor HUB's own fallback. + return (sensor !== null && ATK_SENSORS[sensor]?.family === "step50") + ? { decode: atkDecodeDpiAxisStep50, encode: atkEncodeDpiAxisStep50 } + : { decode: atkDecodeDpiAxis, encode: atkEncodeDpiAxis }; +} + +export function atkPackDpiStageForSensor(sensor: AtkSensor | null, x: number, y: number): number[] { + const { encode } = axisCodec(sensor); + const encodedX = encode(x); + const encodedY = encode(y); + const mode = ((encodedY.nibble & 0x0f) << 4) | (encodedX.nibble & 0x0f); + const sum = (encodedX.byte + encodedY.byte + mode) & 0xff; + return [encodedX.byte, encodedY.byte, mode, (CHECKSUM_TOTAL - sum) & 0xff]; +} + +export function atkUnpackDpiStageForSensor( + sensor: AtkSensor | null, + data: Uint8Array | readonly number[], +): { x: number; y: number } | null { + if (data.length < 4) return null; + const sum = (data[0]! + data[1]! + data[2]! + data[3]!) & 0xff; + if (sum !== CHECKSUM_TOTAL) return null; + const { decode } = axisCodec(sensor); + return { + x: decode(data[0]!, data[2]! & 0x0f), + y: decode(data[1]!, (data[2]! >> 4) & 0x0f), + }; +} + + /** Register holds tenths of a millimetre offset by 6 (code 1 = 0.7 mm). */ export function atkDecodeLiftOff(code: number): number | null { return code ? (code + 6) / 10 : null; diff --git a/src/drivers/atk/hid.ts b/src/drivers/atk/hid.ts index a515bda..3078c46 100644 --- a/src/drivers/atk/hid.ts +++ b/src/drivers/atk/hid.ts @@ -4,21 +4,32 @@ import { WE_REPORT_ID, weBuildCmdPayload, wePackScalarPair, + weUnpackScalarPair, } from "@openmouse/protocol/endgame-gear-we"; import type { MouseStatus } from "../mouse-types.ts"; import { VENDOR_ID } from "../vendors.ts"; -import { atkDecodeLiftOff, atkPackDpiStage, atkUnpackDpiStage } from "@openmouse/protocol/atk"; +import { + ATK_SENSORS, + atkDecodeLiftOff, + atkPackDpiStageForSensor, + atkUnpackDpiStageForSensor, +} from "@openmouse/protocol/atk"; +import { type AtkProduct, ATK_COMPX_PRODUCT_IDS, ATK_PRODUCTS } from "./products.ts"; // ATK mice (A9 family and siblings) use the same OEM framing as the Endgame // Gear WE series — 16-byte EEPROM commands on report 0x08 — but carry them on // output/input reports rather than feature reports. const BATTERY_COMMAND = 0x04; const VERSION_COMMAND = 0x12; +// GetMouseCIDMID: identifies the mouse behind a shared receiver product id. +const CIDMID_COMMAND = 0x10; const FRAME_LENGTH = 16; const DATA_OFFSET = 5; const MAX_DATA_LENGTH = 10; const REPLY_TIMEOUT_MS = 500; const WRITE_SETTLE_MS = 10; +// A sleeping mouse answers nothing; ask a few times before giving up for good. +const MAX_IDENTIFY_ATTEMPTS = 3; // Byte addresses in the mouse's configuration EEPROM. const REGISTER = { @@ -73,6 +84,9 @@ export class AtkHidClient { private queue: Promise = Promise.resolve(); private lastStatus: MouseStatus | null = null; + private product: AtkProduct | null = null; + private identified = false; + private identifyAttempts = 0; constructor(device: HIDDevice) { this.device = device; @@ -82,7 +96,11 @@ export class AtkHidClient { const search = (collection: HIDCollectionInfo): boolean => (collection.usagePage === 0xff02 && collection.usage === 0x0002) || collection.children.some(search); - return device.vendorId === VENDOR_ID.atk && device.collections.some(search); + if (!device.collections.some(search)) return false; + if (device.vendorId === VENDOR_ID.atk) return true; + // VXE's wired transports sit under COMPX's 0x3554, which is shared with the + // VGN Dragonfly F2 and its own driver — so only known product ids here. + return device.vendorId === VENDOR_ID.vgn && ATK_COMPX_PRODUCT_IDS.includes(device.productId); } async open(): Promise { @@ -91,6 +109,8 @@ export class AtkHidClient { async close(): Promise { this.lastStatus = null; + this.product = null; + this.identified = false; if (this.device.opened) await this.device.close(); } @@ -99,12 +119,20 @@ export class AtkHidClient { return false; } + /** VXE-branded units report their own model, so the receiver's generic USB string is a last resort. */ displayName(): string { + const product = this.product; + if (product) return `${product.brand} ${product.model}`; const name = this.device.productName?.trim(); if (!name) return "ATK"; return /^atk/i.test(name) ? name : `ATK ${name}`; } + /** Registry hook: 0x373b covers both ATK and its VXE sibling brand. */ + deviceBrand(): AtkProduct["brand"] { + return this.product?.brand ?? "ATK"; + } + /** * A wired A9 still reports a battery level, so the receiver is identified by * its own product string instead: "ATK Nearlink Mouse Dongle" against the @@ -115,7 +143,8 @@ export class AtkHidClient { } maxDpi(): number { - return DPI_MAX; + const sensor = this.product?.sensor; + return sensor ? ATK_SENSORS[sensor].maxDpi : DPI_MAX; } getSleepOptions(): readonly number[] { @@ -131,26 +160,58 @@ export class AtkHidClient { } /** - * The encoding steps by 10 DPI, then 50 above 10,000 and 100 above 30,000. - * Models top out below 42,000; writes are confirmed by reading back. + * PAW3950Ultra steps by 10 DPI, then 50 above 10,000 and 100 above 30,000. + * The PAW3395/PAW3950 family steps by a flat 50 up to its own ceiling. + * Writes are confirmed by reading back either way. */ getDpiOptions(): number[] { const options: number[] = []; + const profile = this.product ? ATK_SENSORS[this.product.sensor] : null; + if (profile?.family === "step50") { + for (let dpi = profile.minDpi; dpi <= profile.maxDpi; dpi += profile.stepDpi) options.push(dpi); + return options; + } for (let dpi = DPI_MIN; dpi <= 10000; dpi += 10) options.push(dpi); for (let dpi = 10050; dpi <= 30000; dpi += 50) options.push(dpi); for (let dpi = 30100; dpi <= DPI_MAX; dpi += 100) options.push(dpi); return options; } + /** + * A receiver's product id is shared across models, so the mouse's own CID/MID + * is what names it and picks its DPI encoding. An unidentified mouse keeps the + * historical A9 behaviour. + * + * A sleeping 2.4 GHz mouse answers nothing, so a timeout must not be cached as + * "unidentified" — it would leave a woken mouse misnamed, and its DPI decoded + * with the wrong sensor's encoding, for the rest of the session. Retried a few + * times, then left alone so a mouse that does not implement the command does + * not pay the timeout on every status read. + */ + private async identify(): Promise { + if (this.identified || this.identifyAttempts >= MAX_IDENTIFY_ATTEMPTS) return; + this.identifyAttempts += 1; + const reply = await this.exchange( + weBuildCmdPayload(CIDMID_COMMAND), + (frame) => frame[0] === CIDMID_COMMAND && frame[4] >= 2, + ).catch(() => null); + if (!reply) return; + this.identified = true; + this.product = ATK_PRODUCTS[`${reply[DATA_OFFSET]},${reply[DATA_OFFSET + 1]}`] ?? null; + } + async readStatus(live = false): Promise { await this.open(); + await this.identify(); const battery = await this.readBattery(); const system = await this.read(REGISTER.system, SYSTEM_LENGTH); const stage = await this.readDpiStage(this.stageIndex(system)); if (live && this.lastStatus) { return this.lastStatus = { ...this.lastStatus, - batteryPercent: battery, + batteryPercent: battery?.percent ?? null, + batteryState: batteryState(battery), + batteryVoltageMv: battery?.millivolts ?? null, pollingRateHz: this.decodePollingRate(system[0]), dpi: stage.x, dpiY: stage.y, @@ -160,16 +221,21 @@ export class AtkHidClient { const liftOffDistance = await this.read(REGISTER.liftOffDistance, 2); const advanced = await this.read(REGISTER.advanced, ADVANCED_LENGTH); const angle = await this.read(REGISTER.angle, ANGLE_LENGTH).catch(() => null); + // Unprogrammed EEPROM reads back as 0xff, which fails the value/checksum + // pair. Report "not supported" rather than decoding 0xff as -1 degrees. + const angleTuning = angle ? weUnpackScalarPair(angle[0], angle[1]) : null; + const angleSnapping = angle ? weUnpackScalarPair(angle[2], angle[3]) : null; return this.lastStatus = { - brand: "ATK", + brand: this.deviceBrand(), name: this.displayName(), ui: { family: "atk", hideUnsupportedPollingRates: true, forceShowBattery: battery !== null, }, - batteryPercent: battery, - batteryState: "Unknown", + batteryPercent: battery?.percent ?? null, + batteryState: batteryState(battery), + batteryVoltageMv: battery?.millivolts ?? null, dpi: stage.x, dpiY: stage.y, supportsSeparateDpiAxes: false, @@ -182,8 +248,8 @@ export class AtkHidClient { motionSync: advanced[2] === 1, sleepTimeout: advanced[4] * SLEEP_STEP_SECONDS || null, rippleControl: advanced[8] === 1, - angleSnapping: angle ? angle[2] === 1 : null, - angleTuning: angle ? this.decodeAngle(angle[0]) : null, + angleSnapping: angleSnapping === null ? null : angleSnapping === 1, + angleTuning: angleTuning === null ? null : this.decodeAngle(angleTuning), liftOffDistance: this.decodeLiftOffDistance(liftOffDistance[0]), firmware, }; @@ -202,13 +268,18 @@ export class AtkHidClient { } async setDpi(dpi: number, dpiY: number = dpi): Promise { + await this.identify(); + const sensor = this.product?.sensor ?? null; + const profile = sensor ? ATK_SENSORS[sensor] : null; + const minDpi = profile?.minDpi ?? DPI_MIN; + const maxDpi = profile?.maxDpi ?? DPI_MAX; for (const value of [dpi, dpiY]) { - if (!Number.isInteger(value) || value < DPI_MIN || value > DPI_MAX) { + if (!Number.isInteger(value) || value < minDpi || value > maxDpi) { throw new Error(`${value.toLocaleString()} is not a supported DPI value.`); } } const index = this.stageIndex(await this.read(REGISTER.system, SYSTEM_LENGTH)); - await this.write(this.dpiAddress(index), atkPackDpiStage(dpi, dpiY)); + await this.write(this.dpiAddress(index), atkPackDpiStageForSensor(sensor, dpi, dpiY)); const confirmed = await this.readDpiStage(index); if (confirmed.x !== dpi || confirmed.y !== dpiY) { throw new Error(`The mouse kept ${confirmed.x.toLocaleString()} DPI instead of ${dpi.toLocaleString()}.`); @@ -296,7 +367,10 @@ export class AtkHidClient { } private async readDpiStage(index: number): Promise<{ x: number; y: number }> { - const stage = atkUnpackDpiStage(await this.read(this.dpiAddress(index), DPI_STAGE_LENGTH)); + const stage = atkUnpackDpiStageForSensor( + this.product?.sensor ?? null, + await this.read(this.dpiAddress(index), DPI_STAGE_LENGTH), + ); if (!stage) throw new Error("The mouse reported a DPI stage that failed its checksum."); return stage; } @@ -343,12 +417,28 @@ export class AtkHidClient { return [`Mouse ${Number(bcd(data[0]))}.${bcd(data[1])}`]; } - private async readBattery(): Promise { + /** + * GetBatteryLevel answers `[percent, charging, mV_hi, mV_lo]`. + * + * The charging byte was captured as 0 on battery, with the voltage steady, and + * 1 on the cable, with the voltage climbing monotonically and the percentage + * rising — so a non-zero byte means charging. ATK's own HUB tests `=== 2` for + * charging on some families, which this treats as charging too; only a + * non-zero code that means "not charging" would be misread, and none has been + * observed. + */ + private async readBattery(): Promise<{ percent: number; charging: boolean; millivolts: number | null } | null> { const reply = await this.exchange( weBuildCmdPayload(BATTERY_COMMAND), (frame) => frame[0] === BATTERY_COMMAND, ).catch(() => null); - return reply ? Math.min(reply[DATA_OFFSET], 100) : null; + if (!reply) return null; + const millivolts = (reply[DATA_OFFSET + 2] << 8) | reply[DATA_OFFSET + 3]; + return { + percent: Math.min(reply[DATA_OFFSET], 100), + charging: reply[DATA_OFFSET + 1] !== 0, + millivolts: millivolts > 0 ? millivolts : null, + }; } private async read(address: number, length: number): Promise { @@ -416,6 +506,17 @@ function copyDataView(view: DataView): Uint8Array { return new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); } +/** + * A mouse that answered nothing is "Unknown" rather than "Discharging": the + * two are not the same, and a sleeping 2.4 GHz mouse answers nothing at all. + */ +function batteryState( + battery: { charging: boolean } | null, +): MouseStatus["batteryState"] { + if (!battery) return "Unknown"; + return battery.charging ? "Charging" : "Discharging"; +} + function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } diff --git a/src/drivers/atk/products.ts b/src/drivers/atk/products.ts new file mode 100644 index 0000000..1c4468d --- /dev/null +++ b/src/drivers/atk/products.ts @@ -0,0 +1,55 @@ +import type { AtkSensor } from "@openmouse/protocol/atk"; + +/** + * ATK and VXE mice reached through a 2.4 GHz receiver cannot be identified by + * their USB ids: one receiver product id is reused across models. ATK's own HUB + * handles this by reading a CID/MID identity pair from the mouse + * (`GetMouseCIDMID`, command 0x10) and looking the pair up in its device table. + * This catalog is the same lookup, keyed `","`. + * + * The sensor matters beyond cosmetics: it selects the DPI encoding. Reading a + * PAW3395 stage with the A9's PAW3950Ultra encoding reports a fifth of the real + * value. + */ +export interface AtkProduct { + /** VXE is ATK's sibling brand; both ship behind vendor id 0x373b. */ + brand: "ATK" | "VXE"; + model: string; + sensor: AtkSensor; + /** + * Only `true` once this exact CID/MID has been read from real hardware and + * its decoded DPI cross-checked. Sharing a sensor with a verified sibling is + * not sufficient. + */ + verified: boolean; +} + +/** + * Names and sensors transcribed from ATK HUB Web 3.2.21's device table, which + * keys these entries by `mouseCidMid` with `identifyByCidMid: true`. + * + * Deliberately narrow: the R1 ships with at least six different sensors across + * revisions (PAW3311, PAW3395, PAW3395SE, PAW3395Ultra, PAW3950Ultra, CORE26K), + * and three of those use lookup-table DPI encodings this repo has not captured. + * Only pairs whose sensor has a known encoding belong here. + */ +export const ATK_PRODUCTS: Record = { + // Verified on hardware: firmware "Mouse 3.13" behind receiver 0x373b:0x1085, + // stages read back as 800/1200/1600/3200 under the step-50 encoding. + "2,12": { brand: "VXE", model: "R1", sensor: "PAW3395", verified: true }, + // Same entry in the vendor table, white colourway, same sensor and firmware + // mark ("r1") — untested here. + "2,11": { brand: "VXE", model: "R1", sensor: "PAW3395", verified: false }, +}; + +/** + * VXE mice also appear under COMPX's vendor id 0x3554 on their wired (and other + * non-receiver) transports — `0x3554:0xf58f` is "Compx VXE R1", with the same + * 0xff02 / report 0x08 command channel as the 0x373b receivers. + * + * Product-id gated rather than vendor-wide: 0x3554 is shared with the VGN + * Dragonfly F2 Master+ (`0xfb56`/`0xfb57`), which has its own driver and a + * different wire protocol. The vendor's table reuses 0xf58f across the R1, R1SE + * and R1SE+, so the sensor still comes from the mouse's CID/MID, not this id. + */ +export const ATK_COMPX_PRODUCT_IDS: readonly number[] = [0xf58f]; diff --git a/src/drivers/atk/protocol.test.ts b/src/drivers/atk/protocol.test.ts index 181eb3b..090de25 100644 --- a/src/drivers/atk/protocol.test.ts +++ b/src/drivers/atk/protocol.test.ts @@ -2,10 +2,15 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + ATK_SENSORS, atkDecodeLiftOff, atkPackDpiStage, + atkPackDpiStageForSensor, atkUnpackDpiStage, + atkUnpackDpiStageForSensor, } from "@openmouse/protocol/atk"; +import { ATK_PRODUCTS } from "./products.ts"; +import { AtkHidClient } from "./hid.ts"; test("DPI stages survive a round trip across every step range", () => { for (const [x, y] of [[50, 50], [800, 800], [10000, 1600], [10050, 10050], [26000, 26000], [42000, 42000]]) { @@ -31,3 +36,101 @@ test("Lift-off codes decode to millimetres", () => { assert.equal(atkDecodeLiftOff(11), 1.7); assert.equal(atkDecodeLiftOff(0), null); }); + +/** + * Captured from a VXE R1 (CID/MID 2,12, PAW3395, firmware "Mouse 3.13") behind + * receiver 0x373b:0x1085. Stage bytes read from EEPROM 0x000c..0x001b, whose + * four stages the mouse's own DPI button cycles as 800/1200/1600/3200. + */ +const R1_STAGES: ReadonlyArray = [ + [[0x0f, 0x0f, 0x00, 0x37], 800], + [[0x17, 0x17, 0x00, 0x27], 1200], + [[0x1f, 0x1f, 0x00, 0x17], 1600], + [[0x3f, 0x3f, 0x00, 0xd7], 3200], +]; + +test("a PAW3395 stage decodes as 50-DPI steps, not the A9's 10-DPI steps", () => { + for (const [bytes, dpi] of R1_STAGES) { + assert.deepEqual( + atkUnpackDpiStageForSensor("PAW3395", bytes), + { x: dpi, y: dpi }, + `PAW3395 stage ${bytes.map((b) => b.toString(16)).join(" ")}`, + ); + // The A9 encoding reads the same bytes a fifth as large; that mismatch is + // the bug this sensor split fixes. + assert.deepEqual(atkUnpackDpiStage(bytes), { x: dpi / 5, y: dpi / 5 }); + } +}); + +test("step-50 sensors round trip DPI across the doubling threshold", () => { + for (const sensor of ["PAW3395", "PAW3950", "CORE26K"] as const) { + const { minDpi, maxDpi } = ATK_SENSORS[sensor]; + for (const dpi of [minDpi, 800, 1600, 26000, maxDpi]) { + if (dpi > maxDpi) continue; + const stage = atkPackDpiStageForSensor(sensor, dpi, dpi); + + assert.equal(stage.reduce((total, byte) => total + byte, 0) & 0xff, 0x55, `${sensor} ${dpi} checksum`); + assert.deepEqual(atkUnpackDpiStageForSensor(sensor, stage), { x: dpi, y: dpi }, `${sensor} ${dpi}`); + } + } +}); + +test("separate axes keep their own doubling flag above 30,000 DPI", () => { + const stage = atkPackDpiStageForSensor("PAW3950", 36000, 1600); + + assert.deepEqual(atkUnpackDpiStageForSensor("PAW3950", stage), { x: 36000, y: 1600 }); +}); + +test("an unidentified mouse keeps the A9 encoding", () => { + assert.deepEqual( + atkUnpackDpiStageForSensor(null, [0x1f, 0x1f, 0x00, 0x17]), + atkUnpackDpiStage([0x1f, 0x1f, 0x00, 0x17]), + ); + assert.deepEqual(atkPackDpiStageForSensor(null, 1600, 1600), atkPackDpiStage(1600, 1600)); +}); + +test("every catalogued product names a sensor whose encoding is implemented", () => { + for (const [cidMid, product] of Object.entries(ATK_PRODUCTS)) { + assert.ok(/^\d+,\d+$/.test(cidMid), `${cidMid} is a "cid,mid" key`); + assert.ok(ATK_SENSORS[product.sensor], `${product.model} sensor ${product.sensor} has a profile`); + } +}); + +function deviceWith(vendorId: number, productId: number): HIDDevice { + return { + vendorId, + productId, + collections: [{ + usagePage: 0xff02, + usage: 0x02, + children: [], + inputReports: [{ reportId: 0x08, items: [{ reportSize: 8, reportCount: 16 }] }], + outputReports: [{ reportId: 0x08, items: [{ reportSize: 8, reportCount: 16 }] }], + featureReports: [], + }], + } as unknown as HIDDevice; +} + +test("both of the VXE R1's transports are claimed", () => { + // 2.4 GHz receiver under ATK's own vendor id. + assert.ok(AtkHidClient.isSupported(deviceWith(0x373b, 0x1085))); + // Wired, under COMPX's 0x3554. + assert.ok(AtkHidClient.isSupported(deviceWith(0x3554, 0xf58f))); +}); + +test("0x3554 is claimed by product id, never vendor-wide", () => { + // The VGN Dragonfly F2 shares this vendor id and has its own driver. + for (const productId of [0xfb56, 0xfb57, 0xf520]) { + assert.equal(AtkHidClient.isSupported(deviceWith(0x3554, productId)), false, productId.toString(16)); + } +}); + +test("a device without the 0xff02 config collection is not claimed", () => { + const pointerOnly = { + vendorId: 0x373b, + productId: 0x1085, + collections: [{ usagePage: 0x01, usage: 0x02, children: [], inputReports: [], outputReports: [], featureReports: [] }], + } as unknown as HIDDevice; + + assert.equal(AtkHidClient.isSupported(pointerOnly), false); +}); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 8021702..7ce32cd 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -118,7 +118,7 @@ export type MouseLightingMode = | "Breathing dual"; export interface MouseStatus { - brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious"; + brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious"; name: string; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; diff --git a/src/drivers/pulsar/pulsar-hid.ts b/src/drivers/pulsar/pulsar-hid.ts index 0329499..9194c17 100644 --- a/src/drivers/pulsar/pulsar-hid.ts +++ b/src/drivers/pulsar/pulsar-hid.ts @@ -15,6 +15,7 @@ import { pulsarVgnDpiOptions, pulsarVgnEncodeDpi, } from "@openmouse/protocol/pulsar"; +import { ATK_COMPX_PRODUCT_IDS } from "../atk/products.ts"; // The Pulsar 4K Wireless Receiver is sold as a Pulsar product but enumerates // under the shared Teevolution/VGN vendor id (0x3554) and speaks the same @@ -25,6 +26,7 @@ const VGN_VENDOR_ID = 0x3554; const CLAIMED_VGN_PRODUCT_IDS: ReadonlySet = new Set([ 0xf520, 0xf523, 0xf5bb, 0xf522, // Teevolution (Terra Pro family) 0xfb56, 0xfb57, // VGN Dragonfly F2 Master+ + ...ATK_COMPX_PRODUCT_IDS, // VXE wired units, driven by the ATK driver ]); const PULSAR_POLLING_RATES = [125, 250, 500, 1000, 2000, 4000, 8000]; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 80d7219..65edcf7 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -111,5 +111,7 @@ export function clientSupportScore(device: HIDDevice): number { export function deviceBrand(client: SupportedClient): string { if (client instanceof EggOp1HidClient || isEggWeClient(client)) return "Endgame Gear"; if (client instanceof LamzuHidClient) return client.deviceBrand(); + // 0x373b covers ATK and its VXE sibling brand; the mouse's CID/MID says which. + if (client instanceof AtkHidClient) return client.deviceBrand(); return driverFor(client.device)?.brand ?? "Unknown"; } diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 8eb3199..d1cc7b4 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -1,3 +1,4 @@ +import { ATK_COMPX_PRODUCT_IDS } from "./atk/products.ts"; import { EGG_WE_HID_FILTERS } from "./endgame/egg-we-control.ts"; import { GWOLVES_PRODUCTS } from "./gwolves/products.ts"; import { @@ -412,6 +413,11 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ { vendorId: VENDOR_ID.vgn, productId: 0xfb56 }, { vendorId: VENDOR_ID.vgn, productId: 0xfb57 }, { vendorId: VENDOR_ID.atk, usagePage: 0xff02, usage: 2 }, + // VXE's wired transport under COMPX's shared 0x3554: request only the config + // collection, so the picker lists it instead of the plain pointer interface. + ...ATK_COMPX_PRODUCT_IDS.map((productId) => ( + { vendorId: VENDOR_ID.vgn, productId, usagePage: 0xff02, usage: 2 } + )), { vendorId: VENDOR_ID.attackShark }, { vendorId: VENDOR_ID.attackSharkX }, ...RAZER_VIPER_V4_CONTROL_FILTERS, From 687eb38f7e136b6beef205e9ee5cb4e26b417e7f Mon Sep 17 00:00:00 2001 From: logm1lo Date: Sat, 5 Sep 2026 10:47:37 +0700 Subject: [PATCH 2/3] atk: add wired VXE R1 SE+ support Co-authored-by: sakethkanchi --- docs/atk-testing.md | 84 +++++++++++++++ src/atk/index.ts | 122 +++++++++++++++++++++- src/drivers/atk/hid.test.ts | 143 +++++++++++++++++++++++--- src/drivers/atk/hid.ts | 119 ++++++++++++++++++--- src/drivers/atk/products.ts | 19 ++++ src/drivers/atk/protocol.test.ts | 70 +++++++++++++ src/drivers/mouse-types.ts | 2 +- src/drivers/pulsar/pulsar-hid.test.ts | 1 + src/drivers/pulsar/pulsar-hid.ts | 2 + src/drivers/registry.ts | 1 + src/drivers/vendors.ts | 4 + 11 files changed, 535 insertions(+), 32 deletions(-) create mode 100644 docs/atk-testing.md create mode 100644 src/drivers/atk/products.ts diff --git a/docs/atk-testing.md b/docs/atk-testing.md new file mode 100644 index 0000000..dbc7e76 --- /dev/null +++ b/docs/atk-testing.md @@ -0,0 +1,84 @@ +# ATK / VXE hardware testing + +Use the vendor configuration interface (`usagePage 0xff02`, `usage 0x02`). It +uses report ID `0x08` with a 16-byte payload. Do not record device serial +numbers in captures or test documentation. + +## Identification + +Shared USB product IDs do not reliably identify the mouse or sensor. The driver +sends CID/MID command `0x10` and looks up the returned pair in +`src/drivers/atk/products.ts`. A failed identification is retried up to three +times because a sleeping wireless mouse may not answer. Closing the client +resets that retry budget. + +A successful but unknown ATK identity retains the historical A9 codec and +USB-name behavior. Generic ATK devices that do not answer CID/MID do the same. +A shared VXE R1 transport that does not answer instead fails before using that +fallback codec. Known VXE identities report the VXE brand. + +## Verified VXE R1 SE+ + +The raw EEPROM and identity values below were captured directly from one VXE R1 +SE+ over its wired connection. Receiver behavior for this model has not been +tested or claimed. The full sensor table and ranges, and the CID/MID mapping, +were independently transcribed from the public ATK HUB 3.2.21 bundle; the +low-range records below cross-check that transcription. + +- USB: VID/PID `0x3554:0xf58f`, product `VXE R1SE+`, firmware/bcdDevice 3.15. +- Configuration channel: interface 1, usage page `0xff02`, usage `2`. +- CID/MID: `2,32`, identified by ATK HUB as VXE R1SE+ with PAW3395SE. +- Battery response: declared payload `5f 01` reports 95% and charging. Bytes + after the declared payload are padding and are not interpreted as voltage. +- Vendor range: 200 through 18,000 DPI. +- EEPROM DPI stage `12 12 00 31` decoded as 800 DPI. +- EEPROM DPI stage `25 25 00 0b` decoded as 1,600 DPI. +- EEPROM DPI stage `4b 4b 00 bf` decoded as 3,200 DPI. +- Writes at 200, 10,000, 10,100, and 18,000 DPI were each confirmed through + device readback, including the high-DPI mode transition, then restored to + 800 DPI. +- OpenMouse was also exercised in Chromium through WebHID: it identified the + wired mouse, displayed 800 DPI and 1,000 Hz, applied 850 DPI through the + staged-save UI, and restored 800 DPI. +- Motion Sync, ripple control, and sleep timeout changes were confirmed through + device readback and restored. Polling changes were acknowledged and restored. +- Lift-off distance and angle snapping use the firmware's fire-and-forget live + row; both commands and their restores completed, but the device does not + expose a reliable independent readback for these writes. +- Debounce writing was not exercised because the captured value was 0 while the + vendor-supported writable range begins at 1 ms, preventing an exact restore. + +PAW3395SE maps targets 50 through 10,000 in 50-DPI increments to codes 1 +through 235 while skipping these codes: + +```text +7, 13, 20, 26, 33, 40, 46, 53, 60, 66, 73, 80, 86, 93, 100, 106, 113, +120, 126, 133, 140, 146, 153, 160, 166, 173, 180, 186, 193, 200, 206, +213, 220, 226, 233 +``` + +The exposed writable options are 200 through 10,000 in 50-DPI increments, +then 10,100 through 18,000 in 100-DPI increments. Values above 10,000 encode +half the requested DPI and set bit 1 in that axis's mode nibble. This is mode +bit 1 for X and mode bit 5 for Y. Codes in the skipped set and invalid mode +combinations must be rejected rather than decoded approximately. + +## R1 live settings + +R1 family detection uses the identified product family, with the known receiver +PID and R1 USB product name retained as fallbacks. This makes wired CID/MID +`2,32` use the same current-main live-settings behavior as other R1 variants: + +- Polling: 250, 500, and 1,000 Hz through selector `0x0b`. +- Angle snapping: selector `0x01`. +- Debounce: selector `0x02`, 1 through 20 ms. +- Lift-off distance: selector `0x03`, Low or High. + +Angle values from EEPROM are accepted only when each value/checksum pair sums +to `0x55`. An unprogrammed `ff ff ff ff` row reports both angle fields as +unsupported. + +Battery command `0x04` is decoded according to its declared payload length: +percent requires one byte, the charging flag requires two, and big-endian cell +voltage requires four. A missing or short reply leaves unavailable fields +unknown rather than interpreting padding as data. diff --git a/src/atk/index.ts b/src/atk/index.ts index 1bdf5e6..2113af7 100644 --- a/src/atk/index.ts +++ b/src/atk/index.ts @@ -15,6 +15,42 @@ const CHECKSUM_TOTAL = 0x55; +export type AtkSensor = + | "PAW3950Ultra" + | "PAW3950" + | "PAW3950DM" + | "PAW3395Ultra" + | "PAW3395" + | "PAW3395SE" + | "CORE26K"; + +export type AtkDpiFamily = "ultra" | "step50" | "paw3395se"; + +export interface AtkSensorProfile { + family: AtkDpiFamily; + minDpi: number; + maxDpi: number; +} + +/** Limits and encoding families transcribed from ATK HUB 3.2.21. */ +export const ATK_SENSORS: Record = { + PAW3950Ultra: { family: "ultra", minDpi: 10, maxDpi: 42000 }, + PAW3950: { family: "step50", minDpi: 50, maxDpi: 36000 }, + PAW3950DM: { family: "step50", minDpi: 50, maxDpi: 36000 }, + PAW3395Ultra: { family: "step50", minDpi: 100, maxDpi: 30000 }, + PAW3395: { family: "step50", minDpi: 100, maxDpi: 30000 }, + PAW3395SE: { family: "paw3395se", minDpi: 200, maxDpi: 18000 }, + CORE26K: { family: "step50", minDpi: 50, maxDpi: 26000 }, +}; + +const PAW3395SE_INVALID_CODES = new Set([ + 7, 13, 20, 26, 33, 40, 46, 53, 60, 66, 73, 80, 86, 93, 100, 106, 113, + 120, 126, 133, 140, 146, 153, 160, 166, 173, 180, 186, 193, 200, 206, + 213, 220, 226, 233, +]); +const PAW3395SE_CODES = Array.from({ length: 235 }, (_, index) => index + 1) + .filter((code) => !PAW3395SE_INVALID_CODES.has(code)); + /** * Per-axis mode nibble: bits 2-3 extend the value byte, bit 1 selects the * 50-DPI step range above 10,000, bit 0 doubles the result above 30,000. @@ -59,6 +95,91 @@ export function atkUnpackDpiStage(data: Uint8Array | readonly number[]): { x: nu }; } +function atkEncodeDpiAxisStep50(dpi: number): { byte: number; nibble: number } { + const doubled = dpi > 30000; + const count = Math.round(dpi / (doubled ? 100 : 50)) - 1; + return { byte: count & 0xff, nibble: (((count >> 8) & 0x03) << 2) | (doubled ? 1 : 0) }; +} + +function atkDecodeDpiAxisStep50(byte: number, nibble: number): number { + const count = (byte & 0xff) | (((nibble >> 2) & 0x03) << 8); + const dpi = (count + 1) * 50; + return (nibble & 1) !== 0 ? dpi * 2 : dpi; +} + +function atkEncodeDpiAxisPaw3395Se(dpi: number): { byte: number; nibble: number } | null { + const doubled = dpi > 10000; + const baseDpi = doubled ? dpi / 2 : dpi; + if (!Number.isInteger(baseDpi) || baseDpi < 50 || baseDpi > 10000 || baseDpi % 50 !== 0) return null; + const code = PAW3395SE_CODES[baseDpi / 50 - 1]; + return code === undefined ? null : { byte: code, nibble: doubled ? 2 : 0 }; +} + +function atkDecodeDpiAxisPaw3395Se(byte: number, nibble: number): number | null { + if ((nibble & ~2) !== 0) return null; + const index = PAW3395SE_CODES.indexOf(byte & 0xff); + if (index < 0) return null; + const baseDpi = (index + 1) * 50; + if ((nibble & 2) !== 0) return baseDpi > 5000 ? baseDpi * 2 : null; + return baseDpi; +} + +export function atkPackDpiStageForSensor(sensor: AtkSensor | null, x: number, y: number): number[] | null { + if (sensor) { + const options = atkDpiOptionsForSensor(sensor); + if (!options.includes(x) || !options.includes(y)) return null; + } + const family = sensor ? ATK_SENSORS[sensor].family : "ultra"; + const encode = family === "paw3395se" + ? atkEncodeDpiAxisPaw3395Se + : family === "step50" + ? atkEncodeDpiAxisStep50 + : atkEncodeDpiAxis; + const encodedX = encode(x); + const encodedY = encode(y); + if (!encodedX || !encodedY) return null; + const mode = ((encodedY.nibble & 0x0f) << 4) | (encodedX.nibble & 0x0f); + const sum = (encodedX.byte + encodedY.byte + mode) & 0xff; + return [encodedX.byte, encodedY.byte, mode, (CHECKSUM_TOTAL - sum) & 0xff]; +} + +export function atkUnpackDpiStageForSensor( + sensor: AtkSensor | null, + data: Uint8Array | readonly number[], +): { x: number; y: number } | null { + if (data.length < 4 || (data[0]! + data[1]! + data[2]! + data[3]!) % 0x100 !== CHECKSUM_TOTAL) return null; + const family = sensor ? ATK_SENSORS[sensor].family : "ultra"; + const decode = family === "paw3395se" + ? atkDecodeDpiAxisPaw3395Se + : family === "step50" + ? atkDecodeDpiAxisStep50 + : atkDecodeDpiAxis; + const x = decode(data[0]!, data[2]! & 0x0f); + const y = decode(data[1]!, (data[2]! >> 4) & 0x0f); + return x === null || y === null ? null : { x, y }; +} + +export function atkDpiOptionsForSensor(sensor: AtkSensor): number[] { + const profile = ATK_SENSORS[sensor]; + if (profile.family === "ultra") { + const options: number[] = []; + for (let dpi = profile.minDpi; dpi <= 10000; dpi += 10) options.push(dpi); + for (let dpi = 10050; dpi <= 30000; dpi += 50) options.push(dpi); + for (let dpi = 30100; dpi <= profile.maxDpi; dpi += 100) options.push(dpi); + return options; + } + if (profile.family === "paw3395se") { + const options: number[] = []; + for (let dpi = profile.minDpi; dpi <= 10000; dpi += 50) options.push(dpi); + for (let dpi = 10100; dpi <= profile.maxDpi; dpi += 100) options.push(dpi); + return options; + } + const options: number[] = []; + for (let dpi = profile.minDpi; dpi <= Math.min(profile.maxDpi, 30000); dpi += 50) options.push(dpi); + for (let dpi = 30100; dpi <= profile.maxDpi; dpi += 100) options.push(dpi); + return options; +} + /** Register holds tenths of a millimetre offset by 6 (code 1 = 0.7 mm). */ export function atkDecodeLiftOff(code: number): number | null { return code ? (code + 6) / 10 : null; @@ -111,4 +232,3 @@ export function atkPackVxeR1PollingSetting(pollingRateHz: number): number[] | nu export function atkDecodeVxeR1PollingCode(code: number): number | null { return VXE_POLLING_CODES.find(([value]) => (value & 0xff) === (code & 0xff))?.[1] ?? null; } - diff --git a/src/drivers/atk/hid.test.ts b/src/drivers/atk/hid.test.ts index c0ea40c..5d483e4 100644 --- a/src/drivers/atk/hid.test.ts +++ b/src/drivers/atk/hid.test.ts @@ -1,12 +1,16 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { atkPackDpiStage } from "@openmouse/protocol/atk"; import { AtkHidClient } from "./hid.ts"; +import { PulsarHidClient } from "../pulsar/pulsar-hid.ts"; +import { createSupportedClient, deviceBrand } from "../registry.ts"; +import { SUPPORTED_HID_FILTERS } from "../vendors.ts"; type Sent = { reportId: number; data: Uint8Array }; /** - * Minimal controllable stand-in for the R1 dongle: it records outgoing frames + * Minimal controllable stand-in for an ATK/VXE config interface: it records outgoing frames * and answers each incoming read with the next queued reply, dispatching the * input report on an idle callback so the driver's exchange promise resolves. */ @@ -26,6 +30,8 @@ class FakeAtkDevice { readonly sent: Sent[] = []; replies: number[][] = []; + identifyFailures = 0; + ignoredCommands = new Set(); private listeners = new Set<(event: HIDInputReportEvent) => void>(); async open(): Promise { @@ -58,8 +64,13 @@ class FakeAtkDevice { const frame = new Uint8Array(data); this.sent.push({ reportId, data: frame }); // EEPROM writes (0x07) are fire-and-forget; read and informational - // commands (0x04 battery, 0x08 EEPROM, 0x12 version) get a reply. + // commands (0x04 battery, 0x08 EEPROM, 0x10 identity, 0x12 version) get a reply. if (frame[0] === 0x07) return; + if (frame[0] === 0x10 && this.identifyFailures > 0) { + this.identifyFailures -= 1; + throw new Error("mouse asleep"); + } + if (this.ignoredCommands.has(frame[0]!)) return; const reply = this.replies.shift(); if (!reply) return; const payload = new Uint8Array(reply); @@ -129,6 +140,21 @@ test("support is limited to 0x373b with the vendor config collection", () => { assert.equal(AtkHidClient.isSupported({ ...device(), vendorId: 0x1234 }), false); }); +test("wired R1 SE+ is claimed without overlapping the Pulsar fallback", () => { + const wired = device(0xf58f, "VXE R1SE+"); + Object.assign(wired, { vendorId: 0x3554 }); + const collection = wired.collections[0]!; + collection.inputReports = [{ reportId: 0x08, items: [] }]; + collection.outputReports = [{ reportId: 0x08, items: [] }]; + + assert.equal(AtkHidClient.isSupported(wired), true); + assert.equal(PulsarHidClient.isSupported(wired), false); + assert.ok(createSupportedClient(wired) instanceof AtkHidClient); + assert.equal(SUPPORTED_HID_FILTERS.some((filter) => + filter.vendorId === 0x3554 && filter.productId === 0xf58f + && filter.usagePage === 0xff02 && filter.usage === 2), true); +}); + test("R1 receiver advertises only its stock polling rates", () => { const wlmouseStyle = new AtkHidClient(device(0x1085)); const notR1 = new AtkHidClient(device(0x11d5, "ATK dongle")); @@ -216,26 +242,111 @@ test("NON-R1 debounce ceiling still applies on the A9 family", () => { assert.equal(new AtkHidClient(device(0x11d5, "ATK dongle")).getDebounceMaxMs(), 15); }); -test("R1 readStatus hides the unsupported medium lift-off level", async () => { - const fake = device(0x1085); +test("wired R1 SE+ status uses identity, PAW3395SE, battery, and R1 live settings", async () => { + const fake = device(0xf58f, "VXE R1SE+"); + Object.assign(fake, { vendorId: 0x3554 }); (fake as unknown as FakeAtkDevice).replies = [ - reply(0x04, 0x0000, [0x5f]), - reply(0x08, 0x0000, [0x40, 0x15, 0x02, 0x53, 0x00, 0x55]), - reply(0x08, 0x000c, [0x4f, 0x4f, 0x00, 0xb7]), - reply(0x12, 0x0000, [0x03, 0x13]), - reply(0x08, 0x000a, [0x04, 0x51]), - reply(0x08, 0x00a9, [0x08, 0x4d, 0x00, 0x55, 0x1e, 0x37, 0x00, 0x55, 0x00, 0x55]), - reply(0x08, 0x00bd, [0xff, 0xff, 0xff, 0xff]), - reply(0x08, 0x0070, [0x01, 0x10, 0x00, 0x44]), + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x04, 0x0000, [0x5f, 0x01]), + reply(0x08, 0x0000, [0x01, 0x54, 0x02, 0x53, 0x00, 0x55]), + reply(0x08, 0x000c, [0x12, 0x12, 0x00, 0x31]), + reply(0x12, 0x0000, [0x03, 0x15]), + reply(0x08, 0x000a, [0x01, 0x54]), + reply(0x08, 0x00a9, [0x00, 0x55, 0x00, 0x55, 0x06, 0x4f, 0x00, 0x55, 0x00, 0x55]), + reply(0x08, 0x00bd, [0x00, 0x55, 0x00, 0x55]), + reply(0x08, 0x0070, [0x01, 0x08, 0x00, 0x4c]), ]; const client = new AtkHidClient(fake); const status = await client.readStatus(); + assert.equal(status.brand, "VXE"); + assert.equal(status.name, "VXE R1 SE+"); + assert.equal(deviceBrand(client), "VXE"); + assert.equal(status.dpi, 800); + assert.equal(status.batteryPercent, 95); + assert.equal(status.batteryState, "Charging"); + assert.equal(status.batteryVoltageMv, null); + assert.deepEqual(status.firmware, ["Mouse 3.15"]); assert.deepEqual(status.supportedLiftOffDistances, ["Low", "High"]); assert.equal(status.pollingRateHz, 1000); - assert.equal(status.liftOffDistance, "Medium"); - assert.equal(status.debounceMs, 8); + assert.equal(status.liftOffDistance, "Low"); + assert.equal(status.debounceMs, 0); assert.equal(status.motionSync, false); - assert.equal(status.sleepTimeout, 300); + assert.equal(status.sleepTimeout, 60); assert.equal(status.angleSnapping, false); -}); \ No newline at end of file + assert.equal(status.angleTuning, 0); +}); + +test("R1 receiver readStatus fails before fallback decoding when CID/MID times out", async () => { + const fake = device(0x1085); + const client = new AtkHidClient(fake); + + await assert.rejects(client.readStatus(), /did not answer CID\/MID; refusing to use the fallback DPI codec/); + assert.deepEqual((fake as unknown as FakeAtkDevice).sent.map(({ data }) => data[0]), [0x10]); +}); + +test("wired R1 setDpi fails before fallback writing when CID/MID times out", async () => { + const fake = device(0xf58f, "VXE R1SE+"); + Object.assign(fake, { vendorId: 0x3554 }); + const raw = fake as unknown as FakeAtkDevice; + const client = new AtkHidClient(fake); + + await assert.rejects(client.setDpi(800), /did not answer CID\/MID; refusing to use the fallback DPI codec/); + assert.deepEqual(raw.sent.map(({ data }) => data[0]), [0x10]); +}); + +test("R1 stays fail-closed after its CID/MID retry budget is exhausted", async () => { + const fake = device(0x1085); + const raw = fake as unknown as FakeAtkDevice; + raw.identifyFailures = 3; + const client = new AtkHidClient(fake); + + for (let attempt = 0; attempt < 4; attempt += 1) { + await assert.rejects(client.setDpi(800), /did not answer CID\/MID; refusing to use the fallback DPI codec/); + } + assert.deepEqual(raw.sent.map(({ data }) => data[0]), [0x10, 0x10, 0x10]); +}); + +test("generic ATK setDpi retains fallback when CID/MID does not answer", async () => { + const fake = device(0x11d5, "ATK dongle"); + const raw = fake as unknown as FakeAtkDevice; + raw.ignoredCommands.add(0x10); + raw.replies = [ + reply(0x08, 0x0000, [0x01, 0x54, 0x01, 0x54, 0x00, 0x55]), + reply(0x08, 0x000c, atkPackDpiStage(800, 800)), + ]; + + assert.equal(await new AtkHidClient(fake).setDpi(800), 800); + assert.deepEqual(Array.from(wrote(fake).subarray(5, 9)), atkPackDpiStage(800, 800)); +}); + +test("successful unknown CID/MID uses the generic fallback codec", async () => { + const fake = device(0x11d5, "ATK dongle"); + (fake as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0xfe, 0xed]), + reply(0x08, 0x0000, [0x01, 0x54, 0x01, 0x54, 0x00, 0x55]), + reply(0x08, 0x000c, atkPackDpiStage(800, 800)), + ]; + + assert.equal(await new AtkHidClient(fake).setDpi(800), 800); + assert.deepEqual(Array.from(wrote(fake).subarray(5, 9)), atkPackDpiStage(800, 800)); +}); + +test("one-byte battery reply leaves state and voltage unknown", async () => { + const fake = device(0x11d5, "ATK dongle"); + (fake as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0xfe, 0xed]), + reply(0x04, 0x0000, [0x5f]), + reply(0x08, 0x0000, [0x01, 0x54, 0x01, 0x54, 0x00, 0x55]), + reply(0x08, 0x000c, atkPackDpiStage(800, 800)), + reply(0x12, 0x0000, [0x01, 0x23]), + reply(0x08, 0x000a, [0x04, 0x51]), + reply(0x08, 0x00a9, [0x08, 0x4d, 0x00, 0x55, 0x1e, 0x37, 0x00, 0x55, 0x00, 0x55]), + reply(0x08, 0x00bd, [0x00, 0x55, 0x00, 0x55]), + ]; + + const status = await new AtkHidClient(fake).readStatus(); + assert.equal(status.batteryPercent, 95); + assert.equal(status.batteryState, "Unknown"); + assert.equal(status.batteryVoltageMv, null); +}); diff --git a/src/drivers/atk/hid.ts b/src/drivers/atk/hid.ts index 759fe70..7de2dbd 100644 --- a/src/drivers/atk/hid.ts +++ b/src/drivers/atk/hid.ts @@ -4,6 +4,7 @@ import { WE_REPORT_ID, weBuildCmdPayload, wePackScalarPair, + weUnpackScalarPair, } from "@openmouse/protocol/endgame-gear-we"; import type { MouseStatus } from "../mouse-types.ts"; import { VENDOR_ID } from "../vendors.ts"; @@ -13,24 +14,32 @@ import { ATK_VXE_R1_LOD_SELECTOR, ATK_VXE_R1_POLLING_RATES, ATK_VXE_R1_SETTINGS_REGISTER, + ATK_SENSORS, atkDecodeLiftOff, atkDecodeVxeR1PollingCode, + atkDpiOptionsForSensor, atkPackDpiStage, + atkPackDpiStageForSensor, atkPackVxeR1LiveSetting, atkPackVxeR1PollingSetting, atkUnpackDpiStage, + atkUnpackDpiStageForSensor, } from "@openmouse/protocol/atk"; +import { type AtkProduct, ATK_COMPX_PRODUCT_IDS, ATK_PRODUCTS } from "./products.ts"; // ATK mice (A9 family and siblings) use the same OEM framing as the Endgame // Gear WE series — 16-byte EEPROM commands on report 0x08 — but carry them on // output/input reports rather than feature reports. const BATTERY_COMMAND = 0x04; const VERSION_COMMAND = 0x12; +const CIDMID_COMMAND = 0x10; const FRAME_LENGTH = 16; const DATA_OFFSET = 5; const MAX_DATA_LENGTH = 10; const REPLY_TIMEOUT_MS = 500; const WRITE_SETTLE_MS = 10; +const R1_LIVE_WRITE_SETTLE_MS = 250; +const MAX_IDENTIFY_ATTEMPTS = 3; // The VXE R1 SE/SE+ ships its "Wireless mouse -1k dongle" under 0x373b:0x1085 // (Beken MCU). It shares the A9 EEPROM map for DPI/advanced/lod, but the poll @@ -99,6 +108,9 @@ export class AtkHidClient { private queue: Promise = Promise.resolve(); private lastStatus: MouseStatus | null = null; + private product: AtkProduct | null = null; + private identified = false; + private identifyAttempts = 0; constructor(device: HIDDevice) { this.device = device; @@ -108,7 +120,9 @@ export class AtkHidClient { const search = (collection: HIDCollectionInfo): boolean => (collection.usagePage === 0xff02 && collection.usage === 0x0002) || collection.children.some(search); - return device.vendorId === VENDOR_ID.atk && device.collections.some(search); + if (!device.collections.some(search)) return false; + if (device.vendorId === VENDOR_ID.atk) return true; + return device.vendorId === VENDOR_ID.vgn && ATK_COMPX_PRODUCT_IDS.includes(device.productId); } async open(): Promise { @@ -117,6 +131,9 @@ export class AtkHidClient { async close(): Promise { this.lastStatus = null; + this.product = null; + this.identified = false; + this.identifyAttempts = 0; if (this.device.opened) await this.device.close(); } @@ -126,11 +143,16 @@ export class AtkHidClient { } displayName(): string { + if (this.product) return `${this.product.brand} ${this.product.model}`; const name = this.device.productName?.trim(); if (!name) return "ATK"; return /^atk/i.test(name) ? name : `ATK ${name}`; } + deviceBrand(): AtkProduct["brand"] { + return this.product?.brand ?? (/^vxe\b/i.test(this.device.productName || "") ? "VXE" : "ATK"); + } + /** * A wired A9 still reports a battery level, so the receiver is identified by * its own product string instead: "ATK Nearlink Mouse Dongle" against the @@ -142,11 +164,12 @@ export class AtkHidClient { /** VXE R1 SE/SE+ on its stock 1K receiver (Beken MCU, per OpenVXE). */ isR1(): boolean { - return this.device.productId === VXE_R1_RECEIVER_PID; + return this.product?.family === "r1" + || this.usesSharedR1Transport(); } maxDpi(): number { - return DPI_MAX; + return this.product ? ATK_SENSORS[this.product.sensor].maxDpi : DPI_MAX; } getSleepOptions(): readonly number[] { @@ -168,6 +191,7 @@ export class AtkHidClient { * Models top out below 42,000; writes are confirmed by reading back. */ getDpiOptions(): number[] { + if (this.product) return atkDpiOptionsForSensor(this.product.sensor); const options: number[] = []; for (let dpi = DPI_MIN; dpi <= 10000; dpi += 10) options.push(dpi); for (let dpi = 10050; dpi <= 30000; dpi += 50) options.push(dpi); @@ -177,13 +201,16 @@ export class AtkHidClient { async readStatus(live = false): Promise { await this.open(); + await this.identify(); const battery = await this.readBattery(); const system = await this.read(REGISTER.system, SYSTEM_LENGTH); const stage = await this.readDpiStage(this.stageIndex(system)); if (live && this.lastStatus) { return this.lastStatus = { ...this.lastStatus, - batteryPercent: battery, + batteryPercent: battery?.percent ?? null, + batteryState: batteryState(battery), + batteryVoltageMv: battery?.millivolts ?? null, pollingRateHz: await this.readPollingRate(system), dpi: stage.x, dpiY: stage.y, @@ -193,16 +220,19 @@ export class AtkHidClient { const liftOffDistance = await this.read(REGISTER.liftOffDistance, 2); const advanced = await this.read(REGISTER.advanced, ADVANCED_LENGTH); const angle = await this.read(REGISTER.angle, ANGLE_LENGTH).catch(() => null); + const angleTuning = angle ? weUnpackScalarPair(angle[0], angle[1]) : null; + const angleSnapping = angle ? weUnpackScalarPair(angle[2], angle[3]) : null; return this.lastStatus = { - brand: "ATK", + brand: this.deviceBrand(), name: this.displayName(), ui: { family: "atk", hideUnsupportedPollingRates: true, forceShowBattery: battery !== null, }, - batteryPercent: battery, - batteryState: "Unknown", + batteryPercent: battery?.percent ?? null, + batteryState: batteryState(battery), + batteryVoltageMv: battery?.millivolts ?? null, dpi: stage.x, dpiY: stage.y, supportsSeparateDpiAxes: false, @@ -215,8 +245,8 @@ export class AtkHidClient { motionSync: advanced[2] === 1, sleepTimeout: advanced[4] * SLEEP_STEP_SECONDS || null, rippleControl: advanced[8] === 1, - angleSnapping: angle ? angle[2] === 1 : null, - angleTuning: angle ? this.decodeAngle(angle[0]) : null, + angleSnapping: angleSnapping === null ? null : angleSnapping === 1, + angleTuning: angleTuning === null ? null : this.decodeAngle(angleTuning), liftOffDistance: this.decodeLiftOffDistance(liftOffDistance[0]), supportedLiftOffDistances: this.isR1() ? ["Low", "High"] : undefined, firmware, @@ -224,6 +254,7 @@ export class AtkHidClient { } async setPollingRate(pollingRateHz: number): Promise { + if (!this.isR1()) await this.identify(); if (this.isR1()) return await this.setR1PollingRate(pollingRateHz); const encoded = POLLING_RATES.find(([, hertz]) => hertz === pollingRateHz); if (!encoded) throw new Error(`This mouse does not support ${pollingRateHz} Hz.`); @@ -237,13 +268,18 @@ export class AtkHidClient { } async setDpi(dpi: number, dpiY: number = dpi): Promise { + await this.identify(); + const sensor = this.product?.sensor ?? null; + const options = sensor ? atkDpiOptionsForSensor(sensor) : null; for (const value of [dpi, dpiY]) { - if (!Number.isInteger(value) || value < DPI_MIN || value > DPI_MAX) { + if (!Number.isInteger(value) || (options ? !options.includes(value) : value < DPI_MIN || value > DPI_MAX)) { throw new Error(`${value.toLocaleString()} is not a supported DPI value.`); } } const index = this.stageIndex(await this.read(REGISTER.system, SYSTEM_LENGTH)); - await this.write(this.dpiAddress(index), atkPackDpiStage(dpi, dpiY)); + const stage = sensor ? atkPackDpiStageForSensor(sensor, dpi, dpiY) : atkPackDpiStage(dpi, dpiY); + if (!stage) throw new Error(`${dpi.toLocaleString()} DPI is not representable by this sensor.`); + await this.write(this.dpiAddress(index), stage); const confirmed = await this.readDpiStage(index); if (confirmed.x !== dpi || confirmed.y !== dpiY) { throw new Error(`The mouse kept ${confirmed.x.toLocaleString()} DPI instead of ${dpi.toLocaleString()}.`); @@ -253,6 +289,7 @@ export class AtkHidClient { } async setLiftOffDistance(value: LiftOffDistance): Promise { + if (!this.isR1()) await this.identify(); if (this.isR1()) return await this.setR1LiftOffDistance(value); const encoded = LIFT_OFF_CODES.find(([, name]) => name === value); if (!encoded) throw new Error(`This mouse does not support a ${value.toLowerCase()} lift-off distance.`); @@ -274,6 +311,7 @@ export class AtkHidClient { } async setAngleSnapping(enabled: boolean): Promise { + if (!this.isR1()) await this.identify(); if (this.isR1()) return await this.setR1AngleSnapping(enabled); const group = await this.read(REGISTER.angle, ANGLE_LENGTH); await this.write(REGISTER.angle, [group[0], enabled ? 1 : 0].flatMap((value) => wePackScalarPair(value))); @@ -284,6 +322,7 @@ export class AtkHidClient { } async setDebounceTime(milliseconds: number): Promise { + if (!this.isR1()) await this.identify(); if (this.isR1()) return await this.setR1DebounceTime(milliseconds); if (!Number.isInteger(milliseconds) || milliseconds < 0 || milliseconds > DEBOUNCE_MAX_MS) { throw new Error(`Debounce must be a whole number of milliseconds between 0 and ${DEBOUNCE_MAX_MS}.`); @@ -334,7 +373,10 @@ export class AtkHidClient { } private async readDpiStage(index: number): Promise<{ x: number; y: number }> { - const stage = atkUnpackDpiStage(await this.read(this.dpiAddress(index), DPI_STAGE_LENGTH)); + const data = await this.read(this.dpiAddress(index), DPI_STAGE_LENGTH); + const stage = this.product + ? atkUnpackDpiStageForSensor(this.product.sensor, data) + : atkUnpackDpiStage(data); if (!stage) throw new Error("The mouse reported a DPI stage that failed its checksum."); return stage; } @@ -385,6 +427,7 @@ export class AtkHidClient { if (!data) throw new Error(`This mouse does not support ${pollingRateHz} Hz.`); await this.write(ATK_VXE_R1_SETTINGS_REGISTER, data); const confirmed = await this.readPollingRate(); + await delay(R1_LIVE_WRITE_SETTLE_MS); this.patch({ pollingRateHz: confirmed }); return confirmed; } @@ -394,6 +437,7 @@ export class AtkHidClient { const encoded = R1_LIFT_OFF_CODES.find(([, name]) => name === value); if (!encoded) throw new Error(`This mouse does not support a ${value.toLowerCase()} lift-off distance.`); await this.write(ATK_VXE_R1_SETTINGS_REGISTER, atkPackVxeR1LiveSetting(ATK_VXE_R1_LOD_SELECTOR, encoded[0])); + await delay(R1_LIVE_WRITE_SETTLE_MS); this.patch({ liftOffDistance: value }); return value; } @@ -406,6 +450,7 @@ export class AtkHidClient { ATK_VXE_R1_SETTINGS_REGISTER, atkPackVxeR1LiveSetting(ATK_VXE_R1_DEBOUNCE_SELECTOR, milliseconds), ); + await delay(R1_LIVE_WRITE_SETTLE_MS); this.patch({ debounceMs: milliseconds }); return milliseconds; } @@ -415,6 +460,7 @@ export class AtkHidClient { ATK_VXE_R1_SETTINGS_REGISTER, atkPackVxeR1LiveSetting(ATK_VXE_R1_ANGLE_SELECTOR, enabled ? 0x10 : 0x00), ); + await delay(R1_LIVE_WRITE_SETTLE_MS); this.patch({ angleSnapping: enabled }); return enabled; } @@ -423,6 +469,35 @@ export class AtkHidClient { if (this.lastStatus) this.lastStatus = { ...this.lastStatus, ...changes }; } + private async identify(): Promise { + if (this.identified) return; + if (this.identifyAttempts >= MAX_IDENTIFY_ATTEMPTS) { + if (this.usesSharedR1Transport()) { + throw new Error("The VXE R1 did not answer CID/MID; refusing to use the fallback DPI codec."); + } + return; + } + this.identifyAttempts += 1; + const reply = await this.exchange( + weBuildCmdPayload(CIDMID_COMMAND), + (frame) => frame[0] === CIDMID_COMMAND && frame[4] >= 2, + ).catch(() => null); + if (!reply) { + if (this.usesSharedR1Transport()) { + throw new Error("The VXE R1 did not answer CID/MID; refusing to use the fallback DPI codec."); + } + return; + } + this.identified = true; + this.product = ATK_PRODUCTS[`${reply[DATA_OFFSET]},${reply[DATA_OFFSET + 1]}`] ?? null; + } + + private usesSharedR1Transport(): boolean { + return this.device.productId === VXE_R1_RECEIVER_PID + || (this.device.vendorId === VENDOR_ID.vgn && this.device.productId === 0xf58f) + || /\bvxe\s+r1(?:\s*se\+?)?\b/i.test(this.device.productName || ""); + } + /** * Command 0x12 (GetMouseVersion) reports the version as BCD, matching the * Endgame Gear siblings: an A9 Nearlink dongle answering 0x01 0x23 is 1.23. @@ -439,12 +514,23 @@ export class AtkHidClient { return [`Mouse ${Number(bcd(data[0]))}.${bcd(data[1])}`]; } - private async readBattery(): Promise { + private async readBattery(): Promise<{ + percent: number | null; + charging: boolean | null; + millivolts: number | null; + } | null> { const reply = await this.exchange( weBuildCmdPayload(BATTERY_COMMAND), (frame) => frame[0] === BATTERY_COMMAND, ).catch(() => null); - return reply ? Math.min(reply[DATA_OFFSET], 100) : null; + if (!reply) return null; + const length = Math.min(reply[4], MAX_DATA_LENGTH); + const millivolts = length >= 4 ? (reply[DATA_OFFSET + 2] << 8) | reply[DATA_OFFSET + 3] : 0; + return { + percent: length >= 1 ? Math.min(reply[DATA_OFFSET], 100) : null, + charging: length >= 2 ? reply[DATA_OFFSET + 1] !== 0 : null, + millivolts: millivolts > 0 ? millivolts : null, + }; } private async read(address: number, length: number): Promise { @@ -512,6 +598,11 @@ function copyDataView(view: DataView): Uint8Array { return new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); } +function batteryState(battery: { charging: boolean | null } | null): MouseStatus["batteryState"] { + if (!battery || battery.charging === null) return "Unknown"; + return battery.charging ? "Charging" : "Discharging"; +} + function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } diff --git a/src/drivers/atk/products.ts b/src/drivers/atk/products.ts new file mode 100644 index 0000000..5db58da --- /dev/null +++ b/src/drivers/atk/products.ts @@ -0,0 +1,19 @@ +import type { AtkSensor } from "@openmouse/protocol/atk"; + +export interface AtkProduct { + brand: "ATK" | "VXE"; + model: string; + sensor: AtkSensor; + family?: "r1"; + verified: boolean; +} + +/** Mouse identity returned by GetMouseCIDMID (command 0x10). */ +export const ATK_PRODUCTS: Record = { + "2,11": { brand: "VXE", model: "R1", sensor: "PAW3395", family: "r1", verified: false }, + "2,12": { brand: "VXE", model: "R1", sensor: "PAW3395", family: "r1", verified: true }, + "2,32": { brand: "VXE", model: "R1 SE+", sensor: "PAW3395SE", family: "r1", verified: true }, +}; + +/** Known VXE wired transports under COMPX's shared vendor id. */ +export const ATK_COMPX_PRODUCT_IDS: readonly number[] = [0xf58f]; diff --git a/src/drivers/atk/protocol.test.ts b/src/drivers/atk/protocol.test.ts index 1fd8f0e..6a4a519 100644 --- a/src/drivers/atk/protocol.test.ts +++ b/src/drivers/atk/protocol.test.ts @@ -2,17 +2,22 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + ATK_SENSORS, atkDecodeLiftOff, + atkDpiOptionsForSensor, atkDecodeVxeR1PollingCode, atkPackDpiStage, + atkPackDpiStageForSensor, atkPackVxeR1LiveSetting, atkPackVxeR1PollingSetting, atkUnpackDpiStage, + atkUnpackDpiStageForSensor, ATK_VXE_R1_ANGLE_SELECTOR, ATK_VXE_R1_DEBOUNCE_SELECTOR, ATK_VXE_R1_LOD_SELECTOR, ATK_VXE_R1_POLLING_RATES, } from "@openmouse/protocol/atk"; +import { ATK_PRODUCTS } from "./products.ts"; test("DPI stages survive a round trip across every step range", () => { for (const [x, y] of [[50, 50], [800, 800], [10000, 1600], [10050, 10050], [26000, 26000], [42000, 42000]]) { @@ -32,6 +37,71 @@ test("DPI stages with a corrupt checksum are rejected", () => { assert.equal(atkUnpackDpiStage([1, 2, 3]), null); }); +test("PAW3395SE decodes exact wired EEPROM captures", () => { + const captures: ReadonlyArray = [ + [[0x12, 0x12, 0x00, 0x31], 800], + [[0x25, 0x25, 0x00, 0x0b], 1600], + [[0x4b, 0x4b, 0x00, 0xbf], 3200], + ]; + for (const [stage, dpi] of captures) { + assert.deepEqual(atkUnpackDpiStageForSensor("PAW3395SE", stage), { x: dpi, y: dpi }); + assert.deepEqual(atkPackDpiStageForSensor("PAW3395SE", dpi, dpi), stage); + } +}); + +test("PAW3395SE uses independent doubled bits above 10000 DPI", () => { + const stage = atkPackDpiStageForSensor("PAW3395SE", 10100, 18000); + assert.ok(stage); + assert.equal(stage[2], 0x22); + assert.deepEqual(atkUnpackDpiStageForSensor("PAW3395SE", stage), { x: 10100, y: 18000 }); + + const xOnly = atkPackDpiStageForSensor("PAW3395SE", 10100, 3200); + const yOnly = atkPackDpiStageForSensor("PAW3395SE", 3200, 10100); + assert.equal(xOnly?.[2], 0x02); + assert.equal(yOnly?.[2], 0x20); +}); + +test("PAW3395SE rejects code holes and invalid doubled values", () => { + const invalidCodes = [ + 0, 7, 13, 20, 26, 33, 40, 46, 53, 60, 66, 73, 80, 86, 93, 100, 106, + 113, 120, 126, 133, 140, 146, 153, 160, 166, 173, 180, 186, 193, 200, + 206, 213, 220, 226, 233, 236, 255, + ]; + for (const code of invalidCodes) { + const checksum = (0x55 - code * 2) & 0xff; + assert.equal(atkUnpackDpiStageForSensor("PAW3395SE", [code, code, 0, checksum]), null, `code ${code}`); + } + assert.equal(atkUnpackDpiStageForSensor("PAW3395SE", [0x12, 0x12, 0x22, 0x0f]), null); + assert.equal(atkPackDpiStageForSensor("PAW3395SE", 10050, 10050), null); + assert.equal(atkPackDpiStageForSensor("PAW3395SE", 18100, 18100), null); +}); + +test("PAW3395SE exposes only the representable vendor range", () => { + const options = atkDpiOptionsForSensor("PAW3395SE"); + assert.deepEqual(options.slice(0, 3), [200, 250, 300]); + assert.deepEqual(options.slice(-3), [17800, 17900, 18000]); + assert.equal(options.length, 277); + assert.ok(options.every((dpi) => dpi <= 10000 ? dpi % 50 === 0 : dpi % 100 === 0)); + assert.equal(options.includes(10050), false); + assert.equal(options.includes(10100), true); + for (const dpi of options) { + const stage = atkPackDpiStageForSensor("PAW3395SE", dpi, dpi); + assert.ok(stage, `${dpi} DPI encodes`); + assert.deepEqual(atkUnpackDpiStageForSensor("PAW3395SE", stage), { x: dpi, y: dpi }); + } +}); + +test("the verified R1 SE+ identity selects PAW3395SE", () => { + assert.deepEqual(ATK_PRODUCTS["2,32"], { + brand: "VXE", + model: "R1 SE+", + sensor: "PAW3395SE", + family: "r1", + verified: true, + }); + assert.equal(ATK_SENSORS.PAW3395SE.maxDpi, 18000); +}); + test("Lift-off codes decode to millimetres", () => { assert.equal(atkDecodeLiftOff(1), 0.7); assert.equal(atkDecodeLiftOff(4), 1); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 8021702..7ce32cd 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -118,7 +118,7 @@ export type MouseLightingMode = | "Breathing dual"; export interface MouseStatus { - brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious"; + brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious"; name: string; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; diff --git a/src/drivers/pulsar/pulsar-hid.test.ts b/src/drivers/pulsar/pulsar-hid.test.ts index 602066a..7571103 100644 --- a/src/drivers/pulsar/pulsar-hid.test.ts +++ b/src/drivers/pulsar/pulsar-hid.test.ts @@ -30,6 +30,7 @@ test("supports the Pulsar 4K Wireless Receiver on the shared VGN vendor id", () test("does not claim product ids owned by the Teevolution and VGN drivers", () => { assert.equal(PulsarHidClient.isSupported(device(0x3554, 0xf520)), false); assert.equal(PulsarHidClient.isSupported(device(0x3554, 0xfb56)), false); + assert.equal(PulsarHidClient.isSupported(device(0x3554, 0xf58f)), false); }); test("rejects devices without the report-8 control collection", () => { diff --git a/src/drivers/pulsar/pulsar-hid.ts b/src/drivers/pulsar/pulsar-hid.ts index 0329499..bae1b6a 100644 --- a/src/drivers/pulsar/pulsar-hid.ts +++ b/src/drivers/pulsar/pulsar-hid.ts @@ -15,6 +15,7 @@ import { pulsarVgnDpiOptions, pulsarVgnEncodeDpi, } from "@openmouse/protocol/pulsar"; +import { ATK_COMPX_PRODUCT_IDS } from "../atk/products.ts"; // The Pulsar 4K Wireless Receiver is sold as a Pulsar product but enumerates // under the shared Teevolution/VGN vendor id (0x3554) and speaks the same @@ -25,6 +26,7 @@ const VGN_VENDOR_ID = 0x3554; const CLAIMED_VGN_PRODUCT_IDS: ReadonlySet = new Set([ 0xf520, 0xf523, 0xf5bb, 0xf522, // Teevolution (Terra Pro family) 0xfb56, 0xfb57, // VGN Dragonfly F2 Master+ + ...ATK_COMPX_PRODUCT_IDS, // VXE wired units ]); const PULSAR_POLLING_RATES = [125, 250, 500, 1000, 2000, 4000, 8000]; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 80d7219..d592f13 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -111,5 +111,6 @@ export function clientSupportScore(device: HIDDevice): number { export function deviceBrand(client: SupportedClient): string { if (client instanceof EggOp1HidClient || isEggWeClient(client)) return "Endgame Gear"; if (client instanceof LamzuHidClient) return client.deviceBrand(); + if (client instanceof AtkHidClient) return client.deviceBrand(); return driverFor(client.device)?.brand ?? "Unknown"; } diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 8eb3199..b49a0d8 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -1,3 +1,4 @@ +import { ATK_COMPX_PRODUCT_IDS } from "./atk/products.ts"; import { EGG_WE_HID_FILTERS } from "./endgame/egg-we-control.ts"; import { GWOLVES_PRODUCTS } from "./gwolves/products.ts"; import { @@ -412,6 +413,9 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ { vendorId: VENDOR_ID.vgn, productId: 0xfb56 }, { vendorId: VENDOR_ID.vgn, productId: 0xfb57 }, { vendorId: VENDOR_ID.atk, usagePage: 0xff02, usage: 2 }, + ...ATK_COMPX_PRODUCT_IDS.map((productId) => ( + { vendorId: VENDOR_ID.vgn, productId, usagePage: 0xff02, usage: 2 } + )), { vendorId: VENDOR_ID.attackShark }, { vendorId: VENDOR_ID.attackSharkX }, ...RAZER_VIPER_V4_CONTROL_FILTERS, From 2bd619c58c4d0e4a035d1d6714f5c77fad509eca Mon Sep 17 00:00:00 2001 From: logm1lo Date: Sat, 5 Sep 2026 19:40:02 +0700 Subject: [PATCH 3/3] atk: complete VXE R1 SE+ controls --- docs/atk-testing.md | 120 +++++++- src/atk/index.ts | 292 +++++++++++++++++++ src/drivers/atk/hid.test.ts | 332 ++++++++++++++++++++- src/drivers/atk/hid.ts | 483 +++++++++++++++++++++++++++++-- src/drivers/atk/products.ts | 4 +- src/drivers/atk/protocol.test.ts | 133 +++++++++ src/drivers/mouse-types.ts | 35 +++ 7 files changed, 1354 insertions(+), 45 deletions(-) diff --git a/docs/atk-testing.md b/docs/atk-testing.md index dbc7e76..02d8e0f 100644 --- a/docs/atk-testing.md +++ b/docs/atk-testing.md @@ -20,8 +20,8 @@ fallback codec. Known VXE identities report the VXE brand. ## Verified VXE R1 SE+ The raw EEPROM and identity values below were captured directly from one VXE R1 -SE+ over its wired connection. Receiver behavior for this model has not been -tested or claimed. The full sensor table and ranges, and the CID/MID mapping, +SE+ over its wired connection. The receiver telemetry described below was +tested separately. The full sensor table and ranges, and the CID/MID mapping, were independently transcribed from the public ATK HUB 3.2.21 bundle; the low-range records below cross-check that transcription. @@ -40,13 +40,21 @@ low-range records below cross-check that transcription. - OpenMouse was also exercised in Chromium through WebHID: it identified the wired mouse, displayed 800 DPI and 1,000 Hz, applied 850 DPI through the staged-save UI, and restored 800 DPI. -- Motion Sync, ripple control, and sleep timeout changes were confirmed through - device readback and restored. Polling changes were acknowledged and restored. -- Lift-off distance and angle snapping use the firmware's fire-and-forget live - row; both commands and their restores completed, but the device does not - expose a reliable independent readback for these writes. -- Debounce writing was not exercised because the captured value was 0 while the - vendor-supported writable range begins at 1 ms, preventing an exact restore. +- Motion Sync, ripple control, sleep timeout, 125 Hz polling, 2 mm lift-off, + 1 ms debounce, and straight-line correction changes were confirmed through + wired EEPROM readback and restored. +- ATK HUB 3.2.21 exposes wired polling at 125, 250, 500, and 1,000 Hz; debounce + at 0, 1, 2, 4, 8, 15, and 20 ms; 1 mm and 2 mm lift-off; and straight-line + correction in the advanced EEPROM block. OpenMouse follows those exact wired + EEPROM paths. +- The same HUB build exposes up to eight DPI stages for this identity. The + active two stages were read as 800 and 1,600 DPI. Active-stage selection and + arbitrary-stage values were changed, confirmed, and restored. The configured + count remained at two because adding stages changes the user's profile shape. +- A DPI-stage color, Basic/Competitive performance mode, Ultra Long Range, and + DPI lighting effect/brightness/speed were each changed, confirmed through + device readback, and restored. The final complete run reported every setting + at its captured baseline. PAW3395SE maps targets 50 through 10,000 in 50-DPI increments to codes 1 through 235 while skipping these codes: @@ -66,14 +74,29 @@ combinations must be rejected rather than decoded approximately. ## R1 live settings R1 family detection uses the identified product family, with the known receiver -PID and R1 USB product name retained as fallbacks. This makes wired CID/MID -`2,32` use the same current-main live-settings behavior as other R1 variants: +PID and R1 USB product name retained as fallbacks. The stock receiver retains +the current live-settings behavior transcribed from OpenVXE: - Polling: 250, 500, and 1,000 Hz through selector `0x0b`. - Angle snapping: selector `0x01`. - Debounce: selector `0x02`, 1 through 20 ms. - Lift-off distance: selector `0x03`, Low or High. +The wired CID/MID `2,32` path instead follows ATK HUB's EEPROM configuration: +polling at `0x0000`, stage count and active stage in the same system block, +up to eight DPI records from `0x000c`, lift-off at `0x000a`, and debounce, +Motion Sync, sleep, straight-line correction, and ripple control at `0x00a9`. +DPI-stage colors start at `0x002c`, DPI lighting is at `0x004c`, and the +six-byte record at `0x00b5` stores sensor-sleep enabled, sensor-sleep time, and +the sensor model exposed by the HUB as Basic/Competitive mode. Each field has a +paired checksum byte, and the complete record must be written atomically. Ultra +Long Range uses write command `0x16` and read command `0x17` rather than EEPROM. + +The middle pair at `0x00b5` read `0c 49` while the one-minute user sleep timer +at `0x00a9` read `06 4f`. Attempts to treat the `0x00b5` pair as a second copy +of the configurable timer did not retain non-default values, so OpenMouse does +not write it when changing sleep timeout. + Angle values from EEPROM are accepted only when each value/checksum pair sums to `0x55`. An unprogrammed `ff ff ff ff` row reports both angle fields as unsupported. @@ -82,3 +105,78 @@ Battery command `0x04` is decoded according to its declared payload length: percent requires one byte, the charging flag requires two, and big-endian cell voltage requires four. A missing or short reply leaves unavailable fields unknown rather than interpreting padding as data. + +## Profile selection and read-only button inspection + +ATK HUB exposes four firmware-managed configuration banks through +`GetCurrentConfig` (`0x0e`) and `SetCurrentConfig` (`0x0f`). OpenMouse currently +reads the active bank and can switch banks over the verified wired transport. +The selector request declares one data byte at frame offset 4 and stores the +zero-based bank at offset 5. Hardware testing cycled all four banks, captured +5,376 bytes from each, restored bank 0, and reproduced its original SHA-256 +exactly. The captures differed across settings, buttons, and shortcut storage, +confirming that EEPROM reads are redirected through the active bank. + +A recovery test isolated a two-event macro in bank 3, slot 0, without assigning +it to a button. After a factory-reset experiment, banks 0 through 2 reproduced +their original complete-image SHA-256 hashes. Bank 3 reproduced its original +image outside slot 0; replaying ATK HUB's four-chunk macro reset sequence set the +slot count to zero. Previously programmed event bytes cannot be changed back to +their erased `0xff` representation through the observed EEPROM command, but a +zero count makes them unreachable and matches the vendor's empty-macro +semantics. The active bank was restored to bank 0. This also confirmed that +checksum-protected settings and macro headers must retain the vendor's record +and transaction boundaries during writes. + +The wired R1 button matrix contains six four-byte records at `0x0060`, +`0x0064`, `0x0068`, `0x006c`, `0x0070`, and `0x0074`. Each record is +`[class, value1, value2, checksum]` and all four bytes must sum to `0x55` +modulo 256. OpenMouse reads them in the same three eight-byte groups as ATK +HUB, preserves unknown values, and reports checksum failures. It does not write +button records. Shortcut slots begin at `0x0100` in 32-byte increments. The +vendor address table defines 12 macro slots beginning at `0x0300` in 384-byte +increments and ending at `0x1500`. Neither region is read during ordinary +status polling. + +Receiver command `0x03` reports online state and the three-byte RF identifier. +Command `0x06` reports pairing status and seconds remaining. Both are safe +telemetry reads. On the verified `0x3554:0xf58e` receiver, OpenMouse can start +pairing command `0x05` for the R1 SE+ identity `[CID 0x02, MID 0x20]` after an +explicit preparation step. Cancellation command `0x13` remains unavailable. + +The Nordic receiver was identified over USB as `0x3554:0xf58e`, product `VXE +Mouse 1K Dongle`, firmware/bcdDevice 1.10. Its configuration channel is +interface 1 with usage page `0xff02`, usage `2`, and report `0x08`. Read-only +hardware probes produced checksum-valid replies for online status (`0x03`), +pairing status (`0x06`), and dongle version (`0x1d`). The online reply declared +one payload byte for status while retaining the three-byte RF identifier in its +fixed frame positions, so the driver requires the declared status byte and +decodes the identifier from the complete validated frame. + +Pairing was validated with the mouse cable unplugged and the mouse switched to +2.4 GHz mode. After command `0x05`, holding left click, wheel click, and right +click until the indicator flashed completed the exchange. Command `0x06` +reached status 2 with zero seconds remaining, command `0x03` reported the mouse +online, and the RF identifier remained stable across repeated reads. A prior +attempt without the physical button gesture also reached status 2 but remained +offline, so status 2 alone is not success. OpenMouse requires both terminal +status 2 and online telemetry. Profiles 0 through 2 retained their exact +pre-pairing hashes, profile 3 retained its zero-count cleared macro state, and +the active bank was restored to bank 0 after verification. + +## Firmware package inspection + +The official R1 SE+ 3.15 COMPX package is 261,382 bytes. Its 720-byte logical +header sits in an 8,192-byte header area followed by a 253,190-byte payload. +The header identifies normal endpoint `0x3554:0xf58f`, boot endpoint +`0x3554:0xf406`, IC `CX52850P`, sensor `3395se`, and version `0x315`. + +The prepare-command descriptor stores the raw CRC-32 register state (initial +value `0xffffffff`, polynomial `0xedb88320`, no final XOR) in big-endian order. +For version 3.15 it is `0x4026708e`; for version 3.14 it is `0xab88f525`. +OpenMouse's package parser checks this payload CRC and package bounds. The +vendor upgrader reads the header's `headCRC` field but does not appear to +validate it, so OpenMouse does not claim that field as an integrity check. + +Firmware parsing is read-only. Entering boot mode, erase preparation, chunk +transfers, factory reset, and macro clearing remain intentionally unavailable. diff --git a/src/atk/index.ts b/src/atk/index.ts index 2113af7..54f2162 100644 --- a/src/atk/index.ts +++ b/src/atk/index.ts @@ -232,3 +232,295 @@ export function atkPackVxeR1PollingSetting(pollingRateHz: number): number[] | nu export function atkDecodeVxeR1PollingCode(code: number): number | null { return VXE_POLLING_CODES.find(([value]) => (value & 0xff) === (code & 0xff))?.[1] ?? null; } + +// ── COMPX command and stored-profile inspection ──────────────────────────── + +/** Command ids used by the COMPX configuration transport. */ +export const ATK_COMPX_COMMAND = { + getWirelessMouseOnline: 0x03, + setWirelessDonglePair: 0x05, + getWirelessDonglePairResult: 0x06, + restoreFactory: 0x09, + enterUsbUpgradeMode: 0x0d, + getCurrentConfig: 0x0e, + setCurrentConfig: 0x0f, + dongleExitPair: 0x13, + getDongleVersion: 0x1d, + reportMouseUpgradeError: 0x5a, + reportMouseUpgradeStatus: 0x5b, +} as const; + +/** The R1 HUB profile picker exposes four firmware-managed configuration banks. */ +export const ATK_R1_PROFILE_COUNT = 4; + +export function atkDecodeCurrentProfile(data: Uint8Array | readonly number[]): number | null { + const profile = data[0]; + return profile !== undefined && profile < ATK_R1_PROFILE_COUNT ? profile : null; +} + +/** Build SetCurrentConfig with the vendor's length byte and zero-based bank. */ +export function atkBuildSetCurrentProfile(profile: number): Uint8Array { + if (!Number.isInteger(profile) || profile < 0 || profile >= ATK_R1_PROFILE_COUNT) { + throw new Error(`ATK profile must be between 0 and ${ATK_R1_PROFILE_COUNT - 1}.`); + } + const payload = new Uint8Array(16); + payload[0] = ATK_COMPX_COMMAND.setCurrentConfig; + payload[4] = 1; + payload[5] = profile; + const sum = 0x08 + payload.subarray(0, 15).reduce((total, byte) => total + byte, 0); + payload[15] = (CHECKSUM_TOTAL - (sum & 0xff)) & 0xff; + return payload; +} + +/** Build SetWirelessDonglePair for one exact mouse model identity. */ +export function atkBuildReceiverPairRequest(cid: number, mid: number): Uint8Array { + if (![cid, mid].every((value) => Number.isInteger(value) && value >= 0 && value <= 0xff)) { + throw new Error("ATK receiver pairing CID and MID must be bytes."); + } + const payload = new Uint8Array(16); + payload[0] = ATK_COMPX_COMMAND.setWirelessDonglePair; + payload[4] = 2; + payload[5] = cid; + payload[6] = mid; + const sum = 0x08 + payload.subarray(0, 15).reduce((total, byte) => total + byte, 0); + payload[15] = (CHECKSUM_TOTAL - (sum & 0xff)) & 0xff; + return payload; +} + +export const ATK_R1_BUTTONS = [ + { id: "left", label: "Left button", address: 0x0060 }, + { id: "right", label: "Right button", address: 0x0064 }, + { id: "middle", label: "Middle button", address: 0x0068 }, + { id: "back", label: "Back button", address: 0x006c }, + { id: "forward", label: "Forward button", address: 0x0070 }, + { id: "bottom", label: "Bottom button", address: 0x0074 }, +] as const; + +export const ATK_R1_SHORTCUT_BASE = 0x0100; +export const ATK_R1_SHORTCUT_SLOT_LENGTH = 32; +export const ATK_R1_MACRO_BASE = 0x0300; +export const ATK_R1_MACRO_SLOT_LENGTH = 384; +export const ATK_R1_MACRO_SLOT_COUNT = 12; + +export type AtkR1ButtonId = (typeof ATK_R1_BUTTONS)[number]["id"]; + +/** Four-byte EEPROM button assignment: class, value 1, value 2, checksum. */ +export interface AtkButtonAssignment { + keyClass: number; + value1: number; + value2: number; + checksum: number; + checksumValid: boolean; + label: string; + raw: string; +} + +export const ATK_BUTTON_CLASS = { + disabled: 0x00, + mouse: 0x01, + dpi: 0x02, + horizontalScroll: 0x03, + firepower: 0x04, + shortcut: 0x05, + macro: 0x06, + reportRate: 0x07, + lighting: 0x08, + profile: 0x09, + dpiLock: 0x0a, + wheel: 0x0b, +} as const; + +const BUTTON_CLASS_LABELS: Readonly> = { + [ATK_BUTTON_CLASS.disabled]: "Disabled", + [ATK_BUTTON_CLASS.mouse]: "Mouse button", + [ATK_BUTTON_CLASS.dpi]: "DPI control", + [ATK_BUTTON_CLASS.horizontalScroll]: "Horizontal scroll", + [ATK_BUTTON_CLASS.firepower]: "Fire key", + [ATK_BUTTON_CLASS.shortcut]: "Shortcut", + [ATK_BUTTON_CLASS.macro]: "Macro", + [ATK_BUTTON_CLASS.reportRate]: "Polling rate", + [ATK_BUTTON_CLASS.lighting]: "Lighting control", + [ATK_BUTTON_CLASS.profile]: "Profile control", + [ATK_BUTTON_CLASS.dpiLock]: "DPI lock", + [ATK_BUTTON_CLASS.wheel]: "Wheel", +}; + +const MOUSE_ACTION_LABELS: Readonly> = { + 0x00: "Disabled", + 0x01: "Left click", + 0x02: "Right click", + 0x04: "Middle click", + 0x08: "Back", + 0x10: "Forward", +}; + +const DPI_ACTION_LABELS: Readonly> = { + 0x01: "DPI cycle", + 0x02: "DPI up", + 0x03: "DPI down", +}; + +function hexByte(value: number): string { + return value.toString(16).padStart(2, "0"); +} + +/** Describe only action values whose meaning is present in the vendor enum. */ +export function atkDescribeButtonAction(keyClass: number, value1: number, value2: number): string { + if (keyClass === ATK_BUTTON_CLASS.disabled) return "Disabled"; + if (keyClass === ATK_BUTTON_CLASS.mouse) return MOUSE_ACTION_LABELS[value1] ?? `Mouse button 0x${hexByte(value1)}`; + if (keyClass === ATK_BUTTON_CLASS.dpi) return DPI_ACTION_LABELS[value1] ?? `DPI control 0x${hexByte(value1)}`; + if (keyClass === ATK_BUTTON_CLASS.horizontalScroll) { + return value1 === 1 ? "Horizontal scroll left" : value1 === 2 ? "Horizontal scroll right" : `Horizontal scroll 0x${hexByte(value1)}`; + } + if (keyClass === ATK_BUTTON_CLASS.wheel) { + return value1 === 1 ? "Wheel up" : value1 === 2 ? "Wheel down" : `Wheel 0x${hexByte(value1)}`; + } + const kind = BUTTON_CLASS_LABELS[keyClass] ?? `Unknown class 0x${hexByte(keyClass)}`; + return `${kind} (0x${hexByte(value1)}, 0x${hexByte(value2)})`; +} + +export function atkDecodeButtonAssignment(data: Uint8Array | readonly number[]): AtkButtonAssignment | null { + if (data.length < 4) return null; + const [keyClass, value1, value2, checksum] = data; + const raw = [keyClass!, value1!, value2!, checksum!].map(hexByte).join(" "); + return { + keyClass: keyClass!, + value1: value1!, + value2: value2!, + checksum: checksum!, + checksumValid: (keyClass! + value1! + value2! + checksum!) % 0x100 === CHECKSUM_TOTAL, + label: atkDescribeButtonAction(keyClass!, value1!, value2!), + raw, + }; +} + +export interface AtkReceiverStatus { + online: boolean; + status: number; + /** RF id in the firmware's rfId1/rfId2/rfId3 display order. */ + rfId: string; +} + +export function atkDecodeReceiverStatus(data: Uint8Array | readonly number[]): AtkReceiverStatus | null { + if (data.length < 4) return null; + return { + online: data[0] === 1, + status: data[0]!, + rfId: [data[3]!, data[2]!, data[1]!].map(hexByte).join("").toUpperCase(), + }; +} + +export interface AtkPairingStatus { + status: number; + secondsRemaining: number; +} + +export function atkDecodePairingStatus(data: Uint8Array | readonly number[]): AtkPairingStatus | null { + return data.length < 2 ? null : { status: data[0]!, secondsRemaining: data[1]! }; +} + +// ── COMPX firmware package inspection ────────────────────────────────────── + +export const ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET = 8192; +export const ATK_COMPX_FIRMWARE_HEADER_LENGTH = 720; +const ATK_FIRMWARE_FIELD_LENGTH = 64; +const ATK_FIRMWARE_FIELD_BASE = 23; + +export interface AtkFirmwareEndpoint { + vendorId: number; + productId: number; + path: string; +} + +export interface AtkCompxFirmwareInfo { + headerCrc: number; + headerLength: number; + firmwareLength: number; + nextFileAddress: number; + version: string; + deviceType: number; + cid: number; + mid: number; + fileId: string; + icName: string; + sensorName: string; + productName: string; + bootInput: AtkFirmwareEndpoint; + bootOutput: AtkFirmwareEndpoint; + normalInput: AtkFirmwareEndpoint; + normalOutput: AtkFirmwareEndpoint; + resetCommand: Uint8Array; + prepareCommand: Uint8Array; + downloadCommand: Uint8Array; + payloadCrc: number; + payloadCrcValid: boolean; +} + +function firmwareField(data: Uint8Array, index: number): Uint8Array { + const start = ATK_FIRMWARE_FIELD_BASE + index * ATK_FIRMWARE_FIELD_LENGTH; + return data.subarray(start, start + ATK_FIRMWARE_FIELD_LENGTH); +} + +function asciiField(data: Uint8Array, index: number): string { + const field = firmwareField(data, index); + const end = field.indexOf(0); + return new TextDecoder("ascii").decode(end < 0 ? field : field.subarray(0, end)); +} + +function firmwareEndpoint(path: string): AtkFirmwareEndpoint { + const match = /(?:^|&)vid_([0-9a-f]+)&pid_([0-9a-f]+)(?:&|$)/i.exec(path); + if (!match) throw new Error(`Invalid COMPX firmware endpoint: ${path || "empty"}.`); + return { vendorId: Number.parseInt(match[1]!, 16), productId: Number.parseInt(match[2]!, 16), path }; +} + +/** CRC-32 register state used by COMPX packages: init 0xffffffff, no final xor. */ +export function atkCompxPayloadCrc(data: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of data) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ ((crc & 1) === 0 ? 0 : 0xedb88320); + } + return crc >>> 0; +} + +function commandField(data: Uint8Array, index: number): Uint8Array { + return new Uint8Array(firmwareField(data, index)); +} + +/** Parse and integrity-check the first firmware image in a COMPX package. */ +export function atkParseCompxFirmware(data: Uint8Array): AtkCompxFirmwareInfo { + if (data.length < ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET) throw new Error("COMPX firmware package is shorter than its header area."); + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const headerLength = view.getUint32(4, true); + const firmwareLength = view.getUint32(8, true); + if (headerLength !== ATK_COMPX_FIRMWARE_HEADER_LENGTH) throw new Error(`Unsupported COMPX header length ${headerLength}.`); + if (firmwareLength < 1 || ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET + firmwareLength > data.length) { + throw new Error("COMPX firmware payload length exceeds the package."); + } + const prepareCommand = commandField(data, 7); + const payloadCrc = new DataView(prepareCommand.buffer, prepareCommand.byteOffset, prepareCommand.byteLength).getUint32(19, false); + const payload = data.subarray(ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET, ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET + firmwareLength); + return { + headerCrc: view.getUint32(0, true), + headerLength, + firmwareLength, + nextFileAddress: view.getUint32(12, true), + version: view.getUint32(16, true).toString(16), + deviceType: view.getUint8(20), + cid: view.getUint8(21), + mid: view.getUint8(22), + fileId: asciiField(data, 0), + icName: asciiField(data, 1), + bootInput: firmwareEndpoint(asciiField(data, 2)), + bootOutput: firmwareEndpoint(asciiField(data, 3)), + normalInput: firmwareEndpoint(asciiField(data, 4)), + normalOutput: firmwareEndpoint(asciiField(data, 5)), + resetCommand: commandField(data, 6), + prepareCommand, + downloadCommand: commandField(data, 8), + sensorName: asciiField(data, 9), + productName: asciiField(data, 10), + payloadCrc, + payloadCrcValid: atkCompxPayloadCrc(payload) === payloadCrc, + }; +} diff --git a/src/drivers/atk/hid.test.ts b/src/drivers/atk/hid.test.ts index 5d483e4..0ac4f9e 100644 --- a/src/drivers/atk/hid.test.ts +++ b/src/drivers/atk/hid.test.ts @@ -99,16 +99,23 @@ function device(productId = 0x1085, productName = "Wireless mouse -1k dongle"): /** 16-byte EEPROM read reply for address 0x0070 carrying the given data row. */ function readReply(row: number[]): number[] { - return [0x08, 0x00, 0x00, 0x70, 0x04, ...row, 0, 0, 0, 0, 0, 0, 0]; + const frame = [0x08, 0x00, 0x00, 0x70, 0x04, ...row, 0, 0, 0, 0, 0, 0, 0]; + sealReply(frame); + return frame; } /** 16-byte read reply echoing `data` at the given EEPROM address. */ function reply(cmd: number, address: number, data: number[]): number[] { const frame = [cmd, 0x00, (address >> 8) & 0xff, address & 0xff, data.length, ...data]; while (frame.length < 16) frame.push(0x00); + sealReply(frame); return frame; } +function sealReply(frame: number[]): void { + frame[15] = (0x55 - 0x08 - frame.slice(0, 15).reduce((total, byte) => total + byte, 0)) & 0xff; +} + function wrote(fake: HIDDevice): Uint8Array { const write = (fake as unknown as FakeAtkDevice).sent.find(({ reportId, data }) => reportId === 8 && data[0] === 0x07); @@ -134,41 +141,194 @@ function sumFrame(reportId: number, payload: Uint8Array): number { return sum & 0xff; } +function button(keyClass: number, value1: number, value2 = 0): number[] { + return [keyClass, value1, value2, (0x55 - keyClass - value1 - value2) & 0xff]; +} + test("support is limited to 0x373b with the vendor config collection", () => { assert.equal(AtkHidClient.isSupported(device(0x1085)), true); assert.equal(AtkHidClient.isSupported(device(0x11d5, "ATK dongle")), true); assert.equal(AtkHidClient.isSupported({ ...device(), vendorId: 0x1234 }), false); }); -test("wired R1 SE+ is claimed without overlapping the Pulsar fallback", () => { +test("R1 SE+ transports are claimed without overlapping the Pulsar fallback", () => { const wired = device(0xf58f, "VXE R1SE+"); Object.assign(wired, { vendorId: 0x3554 }); const collection = wired.collections[0]!; collection.inputReports = [{ reportId: 0x08, items: [] }]; collection.outputReports = [{ reportId: 0x08, items: [] }]; + const receiver = device(0xf58e, "VXE Mouse 1K Dongle"); + Object.assign(receiver, { vendorId: 0x3554 }); assert.equal(AtkHidClient.isSupported(wired), true); + assert.equal(AtkHidClient.isSupported(receiver), true); assert.equal(PulsarHidClient.isSupported(wired), false); + assert.equal(PulsarHidClient.isSupported(receiver), false); assert.ok(createSupportedClient(wired) instanceof AtkHidClient); + assert.ok(createSupportedClient(receiver) instanceof AtkHidClient); assert.equal(SUPPORTED_HID_FILTERS.some((filter) => filter.vendorId === 0x3554 && filter.productId === 0xf58f && filter.usagePage === 0xff02 && filter.usage === 2), true); + assert.equal(SUPPORTED_HID_FILTERS.some((filter) => + filter.vendorId === 0x3554 && filter.productId === 0xf58e + && filter.usagePage === 0xff02 && filter.usage === 2), true); }); test("R1 receiver advertises only its stock polling rates", () => { const wlmouseStyle = new AtkHidClient(device(0x1085)); + const compxReceiverDevice = device(0xf58e, "VXE Mouse 1K Dongle"); + Object.assign(compxReceiverDevice, { vendorId: 0x3554 }); + const compxReceiver = new AtkHidClient(compxReceiverDevice); const notR1 = new AtkHidClient(device(0x11d5, "ATK dongle")); assert.deepEqual(wlmouseStyle.getSupportedPollingRates(), [250, 500, 1000]); + assert.deepEqual(compxReceiver.getSupportedPollingRates(), [250, 500, 1000]); assert.deepEqual(notR1.getSupportedPollingRates(), [125, 250, 500, 1000, 2000, 4000, 8000]); }); +test("the COMPX receiver is wireless even without a product string", () => { + const receiver = device(0xf58e, ""); + Object.assign(receiver, { vendorId: 0x3554 }); + assert.equal(new AtkHidClient(receiver).isWireless(), true); +}); + +test("wired R1 SE+ advertises its HUB polling, debounce, and sleep options", () => { + const wired = device(0xf58f, "VXE R1SE+"); + Object.assign(wired, { vendorId: 0x3554 }); + const client = new AtkHidClient(wired); + + assert.deepEqual(client.getSupportedPollingRates(), [125, 250, 500, 1000]); + assert.deepEqual(client.getDebounceOptions(), [0, 1, 2, 4, 8, 15, 20]); + assert.deepEqual(client.getSleepOptions(), [30, 60, 120, 180, 300, 1200, 1500, 1800]); +}); + +test("wired R1 reads the active profile and six lossless button records", async () => { + const fake = device(0xf58f, "VXE R1SE+"); + Object.assign(fake, { vendorId: 0x3554 }); + (fake as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0, [0x02, 0x20]), + reply(0x0e, 0, [0x02]), + reply(0x08, 0x0060, [...button(1, 1), ...button(1, 2)]), + reply(0x08, 0x0068, [...button(1, 4), ...button(1, 8)]), + reply(0x08, 0x0070, [...button(1, 16), ...button(9, 1)]), + ]; + + const stored = await new AtkHidClient(fake).readR1StoredConfiguration(); + assert.equal(stored.activeProfile, 3); + assert.deepEqual(stored.buttons.map(({ id, action, checksumValid }) => ({ id, action, checksumValid })), [ + { id: "left", action: "Left click", checksumValid: true }, + { id: "right", action: "Right click", checksumValid: true }, + { id: "middle", action: "Middle click", checksumValid: true }, + { id: "back", action: "Back", checksumValid: true }, + { id: "forward", action: "Forward", checksumValid: true }, + { id: "bottom", action: "Profile control (0x01, 0x00)", checksumValid: true }, + ]); + assert.deepEqual((fake as unknown as FakeAtkDevice).sent.map(({ data }) => data[0]), [0x10, 0x0e, 0x08, 0x08, 0x08]); +}); + +test("wired R1 switches configuration banks with readback", async () => { + const fake = device(0xf58f, "VXE R1SE+"); + Object.assign(fake, { vendorId: 0x3554 }); + const transport = fake as unknown as FakeAtkDevice; + transport.ignoredCommands.add(0x0f); + transport.replies = [reply(0x10, 0, [2, 32]), reply(0x0e, 0, [2])]; + + assert.equal(await new AtkHidClient(fake).setR1ActiveProfile(3), 3); + const selection = transport.sent.find(({ data }) => data[0] === 0x0f)?.data; + assert.ok(selection); + assert.deepEqual([...selection!.subarray(0, 7)], [0x0f, 0, 0, 0, 1, 2, 0]); + assert.equal(sumFrame(8, selection!), 0x55); +}); + +test("R1 receiver telemetry reads online identity and pairing countdown without entering pairing", async () => { + const fake = device(); + const online = reply(0x03, 0, [1, 0xaa, 0xbb, 0xcc]); + // The Nordic 0xf58e receiver declares only the status byte while retaining + // its fixed-position RF identifier in the following frame bytes. + online[4] = 1; + sealReply(online); + (fake as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0, [0x02, 0x20]), + online, + reply(0x06, 0, [2, 29]), + ]; + + assert.deepEqual(await new AtkHidClient(fake).readR1ReceiverInfo(), { + online: true, + status: 1, + rfId: "CCBBAA", + pairingStatus: 2, + pairingSecondsRemaining: 29, + }); + assert.deepEqual((fake as unknown as FakeAtkDevice).sent.map(({ data }) => data[0]), [0x10, 0x03, 0x06]); + assert.equal((fake as unknown as FakeAtkDevice).sent.some(({ data }) => data[0] === 0x05), false); +}); + +test("R1 receiver pairing sends the verified R1 SE+ identity", async () => { + const fake = device(0xf58e, "VXE Mouse 1K Dongle"); + Object.assign(fake, { vendorId: 0x3554 }); + const transport = fake as unknown as FakeAtkDevice; + transport.replies = [reply(0x10, 0, [0x02, 0x20]), reply(0x05, 0, [])]; + + await new AtkHidClient(fake).startR1ReceiverPairing(0x02, 0x20); + + const request = transport.sent.find(({ data }) => data[0] === 0x05)?.data; + assert.ok(request); + assert.deepEqual([...request!.subarray(0, 8)], [0x05, 0, 0, 0, 2, 0x02, 0x20, 0]); + assert.equal(sumFrame(8, request!), 0x55); +}); + +test("R1 inspection rejects unsuccessful and corrupt command replies", async () => { + const failedProfile = device(0xf58f, "VXE R1SE+"); + Object.assign(failedProfile, { vendorId: 0x3554 }); + const failed = reply(0x0e, 0, [0]); + failed[1] = 1; + sealReply(failed); + (failedProfile as unknown as FakeAtkDevice).replies = [reply(0x10, 0, [2, 32]), failed]; + await assert.rejects(new AtkHidClient(failedProfile).readR1StoredConfiguration(), /did not answer/); + + const corruptReceiver = device(); + const corrupt = reply(0x03, 0, [1, 0xaa, 0xbb, 0xcc]); + corrupt[15] ^= 0xff; + (corruptReceiver as unknown as FakeAtkDevice).replies = [reply(0x10, 0, [2, 32]), corrupt]; + await assert.rejects(new AtkHidClient(corruptReceiver).readR1ReceiverInfo(), /did not answer/); + + const corruptButtons = device(0xf58f, "VXE R1SE+"); + Object.assign(corruptButtons, { vendorId: 0x3554 }); + const corruptButtonGroup = reply(0x08, 0x0060, [...button(1, 1), ...button(1, 2)]); + corruptButtonGroup[15] ^= 0xff; + (corruptButtons as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0, [2, 32]), + reply(0x0e, 0, [0]), + corruptButtonGroup, + ]; + await assert.rejects(new AtkHidClient(corruptButtons).readR1StoredConfiguration(), /did not answer/); + + const otherR1 = device(0xf58f, "VXE R1"); + Object.assign(otherR1, { vendorId: 0x3554 }); + (otherR1 as unknown as FakeAtkDevice).replies = [reply(0x10, 0, [2, 12])]; + await assert.rejects(new AtkHidClient(otherR1).setR1ActiveProfile(2), /verified wired transport/); + assert.equal((otherR1 as unknown as FakeAtkDevice).sent.some(({ data }) => data[0] === 0x0f), false); + + const corruptColor = device(0xf58f, "VXE R1SE+"); + Object.assign(corruptColor, { vendorId: 0x3554 }); + const invalidGroup = reply(0x08, 0x002c, [1, 2, 3, 4, 5, 6, 7, 8]); + invalidGroup[15] ^= 0xff; + (corruptColor as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0, [2, 32]), + reply(0x08, 0, [1, 0x54, 2, 0x53, 0, 0x55]), + invalidGroup, + ]; + await assert.rejects(new AtkHidClient(corruptColor).setDpiStageColor(0, "#112233"), /did not answer/); + assert.equal((corruptColor as unknown as FakeAtkDevice).sent.some(({ data }) => data[0] === 0x07), false); +}); + test("R1 setPollingRate writes the 0x0070 live-settings row", async () => { const fake = device(0x1085); - (fake as unknown as FakeAtkDevice).replies = [readReply([0x0b, 0x01, 0x00, 0x54])]; + (fake as unknown as FakeAtkDevice).replies = [readReply([0x0b, 0x02, 0x00, 0x53])]; const client = new AtkHidClient(fake); - assert.equal(await client.setPollingRate(1000), 1000); + assert.equal(await client.setPollingRate(500), 500); const writes = (fake as unknown as FakeAtkDevice).sent.filter(({ reportId, data }) => reportId === 8 && data[0] === 0x07); @@ -178,9 +338,9 @@ test("R1 setPollingRate writes the 0x0070 live-settings row", async () => { assert.equal(write[3], 0x70); assert.equal(write[4], 0x04); assert.equal(write[5], 0x0b, "polling selector"); - assert.equal(write[6], 0x01, "1000 Hz code"); + assert.equal(write[6], 0x02, "500 Hz code"); assert.equal(write[7], 0x00); - assert.equal(write[8], 0x54, "0x55 - 0x01"); + assert.equal(write[8], 0x53, "0x55 - 0x02"); assert.equal(sumFrame(8, write), 0x55, "frame checksum"); }); @@ -237,12 +397,148 @@ test("R1 setAngleSnapping writes the 0x0070 angle live-settings row", async () = assertR1LiveSettingsWrite(off.device, 0x01, 0x00); }); +test("wired R1 writes 125 Hz through the system EEPROM and confirms it", async () => { + const fake = device(0xf58f, "VXE R1SE+"); + Object.assign(fake, { vendorId: 0x3554 }); + (fake as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x0000, [0x08, 0x4d, 0x02, 0x53, 0x00, 0x55]), + ]; + + assert.equal(await new AtkHidClient(fake).setPollingRate(125), 125); + assert.deepEqual(Array.from(wrote(fake).subarray(2, 7)), [0x00, 0x00, 0x02, 0x08, 0x4d]); +}); + +test("wired R1 persists zero debounce through the advanced EEPROM", async () => { + const fake = device(0xf58f, "VXE R1SE+"); + Object.assign(fake, { vendorId: 0x3554 }); + const advanced = [0x04, 0x51, 0x00, 0x55, 0x06, 0x4f, 0x00, 0x55, 0x00, 0x55]; + (fake as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x00a9, advanced), + reply(0x08, 0x00a9, [0x00, 0x55, ...advanced.slice(2)]), + ]; + + assert.equal(await new AtkHidClient(fake).setDebounceTime(0), 0); + assert.deepEqual(Array.from(wrote(fake).subarray(2, 15)), [0x00, 0xa9, 0x0a, 0x00, 0x55, ...advanced.slice(2)]); +}); + +test("wired R1 persists LOD and straight-line correction through EEPROM", async () => { + const lod = device(0xf58f, "VXE R1SE+"); + Object.assign(lod, { vendorId: 0x3554 }); + (lod as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x000a, [0x02, 0x53]), + ]; + assert.equal(await new AtkHidClient(lod).setLiftOffDistance("High"), "High"); + assert.deepEqual(Array.from(wrote(lod).subarray(2, 7)), [0x00, 0x0a, 0x02, 0x02, 0x53]); + + const line = device(0xf58f, "VXE R1SE+"); + Object.assign(line, { vendorId: 0x3554 }); + const advanced = [0x00, 0x55, 0x00, 0x55, 0x06, 0x4f, 0x00, 0x55, 0x00, 0x55]; + (line as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x00a9, advanced), + reply(0x08, 0x00a9, [...advanced.slice(0, 6), 0x01, 0x54, ...advanced.slice(8)]), + ]; + assert.equal(await new AtkHidClient(line).setAngleSnapping(true), true); + assert.deepEqual(Array.from(wrote(line).subarray(5, 15)), [...advanced.slice(0, 6), 0x01, 0x54, ...advanced.slice(8)]); + + const motion = device(0xf58f, "VXE R1SE+"); + Object.assign(motion, { vendorId: 0x3554 }); + (motion as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x00a9, advanced), + reply(0x08, 0x00a9, [...advanced.slice(0, 2), 0x01, 0x54, ...advanced.slice(4)]), + ]; + assert.equal(await new AtkHidClient(motion).setMotionSync(true), true); + assert.deepEqual(Array.from(wrote(motion).subarray(5, 15)), [...advanced.slice(0, 2), 0x01, 0x54, ...advanced.slice(4)]); +}); + +test("wired R1 edits the active stage and an arbitrary stage value", async () => { + const activeDevice = device(0xf58f, "VXE R1SE+"); + Object.assign(activeDevice, { vendorId: 0x3554 }); + (activeDevice as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x0000, [0x01, 0x54, 0x08, 0x4d, 0x00, 0x55]), + reply(0x08, 0x0004, [0x07, 0x4e]), + reply(0x08, 0x0028, [0x25, 0x25, 0x00, 0x0b]), + ]; + assert.equal(await new AtkHidClient(activeDevice).setActiveDpiStage(7), 7); + assert.deepEqual(Array.from(wrote(activeDevice).subarray(2, 7)), [0x00, 0x04, 0x02, 0x07, 0x4e]); + + const valueDevice = device(0xf58f, "VXE R1SE+"); + Object.assign(valueDevice, { vendorId: 0x3554 }); + (valueDevice as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x0000, [0x01, 0x54, 0x08, 0x4d, 0x00, 0x55]), + reply(0x08, 0x0028, [0x25, 0x25, 0x00, 0x0b]), + ]; + assert.equal(await new AtkHidClient(valueDevice).setDpiStageValue(7, 1600), 1600); + assert.deepEqual(Array.from(wrote(valueDevice).subarray(2, 9)), [0x00, 0x28, 0x04, 0x25, 0x25, 0x00, 0x0b]); +}); + +test("wired R1 writes performance, long-range, stage color, and DPI lighting", async () => { + const performance = device(0xf58f, "VXE R1SE+"); + Object.assign(performance, { vendorId: 0x3554 }); + (performance as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x00b5, [0x01, 0x54, 0x0c, 0x49, 0x01, 0x54]), + reply(0x08, 0x00b5, [0x01, 0x54, 0x0c, 0x49, 0x00, 0x55]), + ]; + assert.equal(await new AtkHidClient(performance).setPerformanceMode(false), false); + + const longRange = device(0xf58f, "VXE R1SE+"); + Object.assign(longRange, { vendorId: 0x3554 }); + (longRange as unknown as FakeAtkDevice).ignoredCommands.add(0x16); + (longRange as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x17, 0x0000, [0x01]), + ]; + assert.equal(await new AtkHidClient(longRange).setLongRangeMode(true), true); + assert.equal((longRange as unknown as FakeAtkDevice).sent.some(({ data }) => data[0] === 0x16 && data[5] === 1), true); + + const color = device(0xf58f, "VXE R1SE+"); + Object.assign(color, { vendorId: 0x3554 }); + (color as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x0000, [0x01, 0x54, 0x02, 0x53, 0x00, 0x55]), + reply(0x08, 0x002c, [0x04, 0x00, 0xff, 0x52, 0xff, 0x00, 0x00, 0x56]), + reply(0x08, 0x002c, [0x01, 0x02, 0x03, 0x4f, 0xff, 0x00, 0x00, 0x56]), + ]; + assert.equal(await new AtkHidClient(color).setDpiStageColor(0, "#010203"), "#010203"); + + const lighting = device(0xf58f, "VXE R1SE+"); + Object.assign(lighting, { vendorId: 0x3554 }); + const expectedLighting = [0x02, 0x53, 0xff, 0x56, 0x03, 0x52, 0x01, 0x54]; + (lighting as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x004c, [0x01, 0x54, 0x80, 0xd5, 0x05, 0x50, 0x00, 0x55]), + reply(0x08, 0x004c, expectedLighting), + ]; + await new AtkHidClient(lighting).setDpiLighting(2, 2, 1); + assert.deepEqual(Array.from(wrote(lighting).subarray(5, 13)), expectedLighting); +}); + +test("wired R1 accepts its exact sleep options before identity is cached", async () => { + const fake = device(0xf58f, "VXE R1SE+"); + Object.assign(fake, { vendorId: 0x3554 }); + const advanced = [0x00, 0x55, 0x00, 0x55, 0x06, 0x4f, 0x00, 0x55, 0x00, 0x55]; + (fake as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x00a9, advanced), + reply(0x08, 0x00a9, [...advanced.slice(0, 4), 0x12, 0x43, ...advanced.slice(6)]), + ]; + assert.equal(await new AtkHidClient(fake).setSleepTimeout(180), 180); + assert.deepEqual(Array.from(wrote(fake).subarray(9, 11)), [0x12, 0x43]); +}); + test("NON-R1 debounce ceiling still applies on the A9 family", () => { assert.equal(new AtkHidClient(device(0x1085)).getDebounceMaxMs(), 20); assert.equal(new AtkHidClient(device(0x11d5, "ATK dongle")).getDebounceMaxMs(), 15); }); -test("wired R1 SE+ status uses identity, PAW3395SE, battery, and R1 live settings", async () => { +test("wired R1 SE+ status uses identity, PAW3395SE, and all configured DPI stages", async () => { const fake = device(0xf58f, "VXE R1SE+"); Object.assign(fake, { vendorId: 0x3554 }); (fake as unknown as FakeAtkDevice).replies = [ @@ -250,11 +546,19 @@ test("wired R1 SE+ status uses identity, PAW3395SE, battery, and R1 live setting reply(0x04, 0x0000, [0x5f, 0x01]), reply(0x08, 0x0000, [0x01, 0x54, 0x02, 0x53, 0x00, 0x55]), reply(0x08, 0x000c, [0x12, 0x12, 0x00, 0x31]), + reply(0x08, 0x0010, [0x25, 0x25, 0x00, 0x0b]), reply(0x12, 0x0000, [0x03, 0x15]), reply(0x08, 0x000a, [0x01, 0x54]), reply(0x08, 0x00a9, [0x00, 0x55, 0x00, 0x55, 0x06, 0x4f, 0x00, 0x55, 0x00, 0x55]), reply(0x08, 0x00bd, [0x00, 0x55, 0x00, 0x55]), - reply(0x08, 0x0070, [0x01, 0x08, 0x00, 0x4c]), + reply(0x08, 0x00b5, [0x01, 0x54, 0x0c, 0x49, 0x01, 0x54]), + reply(0x08, 0x004c, [0x01, 0x54, 0x80, 0xd5, 0x05, 0x50, 0x00, 0x55]), + reply(0x08, 0x002c, [0x04, 0x00, 0xff, 0x52, 0xff, 0x00, 0x00, 0x56]), + reply(0x17, 0x0000, [0x00]), + reply(0x0e, 0x0000, [0x00]), + reply(0x08, 0x0060, [...button(1, 1), ...button(1, 2)]), + reply(0x08, 0x0068, [...button(1, 4), ...button(1, 8)]), + reply(0x08, 0x0070, [...button(1, 16), ...button(2, 1)]), ]; const client = new AtkHidClient(fake); @@ -263,6 +567,12 @@ test("wired R1 SE+ status uses identity, PAW3395SE, battery, and R1 live setting assert.equal(status.name, "VXE R1 SE+"); assert.equal(deviceBrand(client), "VXE"); assert.equal(status.dpi, 800); + assert.deepEqual(status.dpiStages, [800, 1600]); + assert.equal(status.activeDpiStage, 0); + assert.equal(status.activeProfile, 1); + assert.equal(status.atkProfileCount, 4); + assert.equal(status.atkButtonMappings?.length, 6); + assert.equal(status.ui?.dpiStageEditor?.maxStages, 8); assert.equal(status.batteryPercent, 95); assert.equal(status.batteryState, "Charging"); assert.equal(status.batteryVoltageMv, null); @@ -275,6 +585,12 @@ test("wired R1 SE+ status uses identity, PAW3395SE, battery, and R1 live setting assert.equal(status.sleepTimeout, 60); assert.equal(status.angleSnapping, false); assert.equal(status.angleTuning, 0); + assert.equal(status.performanceMode, true); + assert.equal(status.longRangeMode, false); + assert.deepEqual(status.dpiStageColors, ["#0400ff", "#ff0000"]); + assert.equal(status.dpiLedMode, 0); + assert.equal(status.dpiLedBrightness, 1); + assert.equal(status.dpiLedSpeed, 2); }); test("R1 receiver readStatus fails before fallback decoding when CID/MID times out", async () => { diff --git a/src/drivers/atk/hid.ts b/src/drivers/atk/hid.ts index 7de2dbd..87d83eb 100644 --- a/src/drivers/atk/hid.ts +++ b/src/drivers/atk/hid.ts @@ -4,18 +4,28 @@ import { WE_REPORT_ID, weBuildCmdPayload, wePackScalarPair, + weReportChecksum, weUnpackScalarPair, } from "@openmouse/protocol/endgame-gear-we"; import type { MouseStatus } from "../mouse-types.ts"; import { VENDOR_ID } from "../vendors.ts"; import { + ATK_COMPX_COMMAND, + ATK_R1_BUTTONS, + ATK_R1_PROFILE_COUNT, ATK_VXE_R1_ANGLE_SELECTOR, ATK_VXE_R1_DEBOUNCE_SELECTOR, ATK_VXE_R1_LOD_SELECTOR, ATK_VXE_R1_POLLING_RATES, ATK_VXE_R1_SETTINGS_REGISTER, ATK_SENSORS, + atkBuildReceiverPairRequest, + atkBuildSetCurrentProfile, atkDecodeLiftOff, + atkDecodeButtonAssignment, + atkDecodeCurrentProfile, + atkDecodePairingStatus, + atkDecodeReceiverStatus, atkDecodeVxeR1PollingCode, atkDpiOptionsForSensor, atkPackDpiStage, @@ -33,18 +43,23 @@ import { type AtkProduct, ATK_COMPX_PRODUCT_IDS, ATK_PRODUCTS } from "./products const BATTERY_COMMAND = 0x04; const VERSION_COMMAND = 0x12; const CIDMID_COMMAND = 0x10; +const SET_LONG_RANGE_COMMAND = 0x16; +const GET_LONG_RANGE_COMMAND = 0x17; const FRAME_LENGTH = 16; const DATA_OFFSET = 5; const MAX_DATA_LENGTH = 10; const REPLY_TIMEOUT_MS = 500; const WRITE_SETTLE_MS = 10; const R1_LIVE_WRITE_SETTLE_MS = 250; +const R1_PROFILE_SWITCH_SETTLE_MS = 100; const MAX_IDENTIFY_ATTEMPTS = 3; // The VXE R1 SE/SE+ ships its "Wireless mouse -1k dongle" under 0x373b:0x1085 // (Beken MCU). It shares the A9 EEPROM map for DPI/advanced/lod, but the poll // rate lives in the live-settings row; see the codec for the full story. const VXE_R1_RECEIVER_PID = 0x1085; +const VXE_R1_COMPX_RECEIVER_PID = 0xf58e; +const VXE_R1_COMPX_MOUSE_PID = 0xf58f; const R1_SETTINGS_LENGTH = 4; // Byte addresses in the mouse's configuration EEPROM. @@ -54,10 +69,13 @@ const REGISTER = { liftOffDistance: 0x000a, // Four bytes per DPI stage. dpiBase: 0x000c, + dpiColorBase: 0x002c, + dpiLighting: 0x004c, // 0x00a9: debounce, motion sync, sleep timer, linear correction, ripple control. advanced: 0x00a9, // 0x00bd: angle tuning degrees, then the angle-snapping enable flag. angle: 0x00bd, + sensorPerformance: 0x00b5, } as const; const SYSTEM_LENGTH = 6; @@ -65,6 +83,7 @@ const ADVANCED_LENGTH = 10; const ANGLE_LENGTH = 4; const DPI_STAGE_LENGTH = 4; const MAX_DPI_STAGES = 6; +const R1_MAX_DPI_STAGES = 8; const DPI_MIN = 50; const DPI_MAX = 42000; @@ -72,8 +91,14 @@ const DEBOUNCE_MAX_MS = 15; // The R1 accepts 1-20 ms in its live-settings debounce entry (per OpenVXE). const R1_DEBOUNCE_MAX_MS = 20; const SLEEP_STEP_SECONDS = 10; -const SLEEP_MAX_SECONDS = 0xff * SLEEP_STEP_SECONDS; const SLEEP_SECONDS: readonly number[] = [30, 60, 120, 300, 600, 1800]; +const R1_SLEEP_SECONDS: readonly number[] = [30, 60, 120, 180, 300, 1200, 1500, 1800]; +const R1_DEBOUNCE_MILLISECONDS: readonly number[] = [0, 1, 2, 4, 8, 15, 20]; +const R1_SENSOR_PERFORMANCE_LENGTH = 6; +const R1_DPI_LIGHTING_LENGTH = 8; +const R1_DPI_COLOR_GROUP_LENGTH = 8; +const R1_DPI_BRIGHTNESS = [0x10, 0x80, 0xff] as const; +const R1_DPI_SPEED = [1, 3, 5] as const; const POLLING_RATES: ReadonlyArray = [ [0x08, 125], @@ -159,7 +184,9 @@ export class AtkHidClient { * mouse's own "ATK A9 PLUS 2.0 NK". */ isWireless(): boolean { - return /receiver|dongle/i.test(this.device.productName || ""); + return this.device.productId === VXE_R1_RECEIVER_PID + || (this.device.vendorId === VENDOR_ID.vgn && this.device.productId === VXE_R1_COMPX_RECEIVER_PID) + || /receiver|dongle/i.test(this.device.productName || ""); } /** VXE R1 SE/SE+ on its stock 1K receiver (Beken MCU, per OpenVXE). */ @@ -173,16 +200,27 @@ export class AtkHidClient { } getSleepOptions(): readonly number[] { - return SLEEP_SECONDS; + return this.isR1() && !this.usesR1LiveSettings() ? R1_SLEEP_SECONDS : SLEEP_SECONDS; } getDebounceMaxMs(): number { return this.isR1() ? R1_DEBOUNCE_MAX_MS : DEBOUNCE_MAX_MS; } + getDebounceOptions(): readonly number[] { + return this.isR1() && !this.usesR1LiveSettings() + ? R1_DEBOUNCE_MILLISECONDS + : Array.from( + { length: this.getDebounceMaxMs() + (this.usesR1LiveSettings() ? 0 : 1) }, + (_, index) => index + (this.usesR1LiveSettings() ? 1 : 0), + ); + } + getSupportedPollingRates(): number[] { - return this.isR1() + return this.usesR1LiveSettings() ? [...ATK_VXE_R1_POLLING_RATES] + : this.isR1() + ? POLLING_RATES.map(([, hertz]) => hertz).filter((hertz) => hertz <= 1000) : POLLING_RATES.map(([, hertz]) => hertz); } @@ -204,7 +242,11 @@ export class AtkHidClient { await this.identify(); const battery = await this.readBattery(); const system = await this.read(REGISTER.system, SYSTEM_LENGTH); - const stage = await this.readDpiStage(this.stageIndex(system)); + const stageCount = this.stageCount(system); + const activeDpiStage = this.stageIndex(system); + const dpiStages: Array<{ x: number; y: number }> = []; + for (let index = 0; index < stageCount; index += 1) dpiStages.push(await this.readDpiStage(index)); + const stage = dpiStages[activeDpiStage]!; if (live && this.lastStatus) { return this.lastStatus = { ...this.lastStatus, @@ -214,12 +256,21 @@ export class AtkHidClient { pollingRateHz: await this.readPollingRate(system), dpi: stage.x, dpiY: stage.y, + dpiStages: dpiStages.map(({ x }) => x), + activeDpiStage, }; } const firmware = await this.readFirmware(); const liftOffDistance = await this.read(REGISTER.liftOffDistance, 2); const advanced = await this.read(REGISTER.advanced, ADVANCED_LENGTH); const angle = await this.read(REGISTER.angle, ANGLE_LENGTH).catch(() => null); + const r1Extras = this.usesVerifiedR1WiredTransport() ? await this.readR1Extras(stageCount) : null; + const stored = this.usesVerifiedR1WiredTransport() + ? await this.readR1StoredConfiguration().catch(() => null) + : null; + const receiver = this.usesR1LiveSettings() + ? await this.readR1ReceiverInfo().catch(() => null) + : null; const angleTuning = angle ? weUnpackScalarPair(angle[0], angle[1]) : null; const angleSnapping = angle ? weUnpackScalarPair(angle[2], angle[3]) : null; return this.lastStatus = { @@ -229,23 +280,48 @@ export class AtkHidClient { family: "atk", hideUnsupportedPollingRates: true, forceShowBattery: battery !== null, + dpiStageEditor: this.usesVerifiedR1WiredTransport() ? { + maxStages: R1_MAX_DPI_STAGES, + countEditable: false, + minDpi: ATK_SENSORS.PAW3395SE.minDpi, + maxDpi: ATK_SENSORS.PAW3395SE.maxDpi, + stepDpi: 50, + } : undefined, + dpiLighting: r1Extras ? { + modes: [0, 1, 2], + brightness: [0, 1, 2], + speed: [0, 1, 2], + } : undefined, }, batteryPercent: battery?.percent ?? null, batteryState: batteryState(battery), batteryVoltageMv: battery?.millivolts ?? null, dpi: stage.x, dpiY: stage.y, + dpiStages: dpiStages.map(({ x }) => x), + dpiStageColors: r1Extras?.dpiStageColors, + activeDpiStage, supportsSeparateDpiAxes: false, pollingRateHz: await this.readPollingRate(system), supportedPollingRates: this.getSupportedPollingRates(), - activeProfile: null, + activeProfile: stored?.activeProfile ?? null, + atkProfileCount: stored ? ATK_R1_PROFILE_COUNT : undefined, + atkButtonMappings: stored?.buttons, + atkReceiver: receiver ?? undefined, connectionType: this.isWireless() ? "Wireless" : "Wired", connectionDetail: this.isWireless() ? "2.4 GHz receiver" : "Wired USB", debounceMs: advanced[0], motionSync: advanced[2] === 1, sleepTimeout: advanced[4] * SLEEP_STEP_SECONDS || null, rippleControl: advanced[8] === 1, - angleSnapping: angleSnapping === null ? null : angleSnapping === 1, + performanceMode: r1Extras?.performanceMode, + longRangeMode: r1Extras?.longRangeMode, + dpiLedMode: r1Extras?.dpiLedMode, + dpiLedBrightness: r1Extras?.dpiLedBrightness, + dpiLedSpeed: r1Extras?.dpiLedSpeed, + angleSnapping: this.isR1() + ? advanced[6] === 1 + : angleSnapping === null ? null : angleSnapping === 1, angleTuning: angleTuning === null ? null : this.decodeAngle(angleTuning), liftOffDistance: this.decodeLiftOffDistance(liftOffDistance[0]), supportedLiftOffDistances: this.isR1() ? ["Low", "High"] : undefined, @@ -253,12 +329,105 @@ export class AtkHidClient { }; } + /** Read the active R1 profile and its six button records without changing either. */ + async readR1StoredConfiguration(): Promise<{ + activeProfile: number; + buttons: NonNullable; + }> { + await this.identify(); + if (!this.usesVerifiedR1WiredTransport()) { + throw new Error("Stored R1 configuration inspection is available only over the verified wired transport."); + } + const activeProfile = await this.readR1CurrentProfile(); + + const buttons: NonNullable = []; + for (let group = 0; group < ATK_R1_BUTTONS.length; group += 2) { + const first = ATK_R1_BUTTONS[group]!; + const data = await this.read(first.address, 8); + for (let offset = 0; offset < 2; offset += 1) { + const button = ATK_R1_BUTTONS[group + offset]; + if (!button) continue; + const assignment = atkDecodeButtonAssignment(data.subarray(offset * 4, offset * 4 + 4)); + if (!assignment) throw new Error(`The ${button.label.toLowerCase()} assignment is truncated.`); + buttons.push({ + id: button.id, + name: button.label, + address: button.address, + keyClass: assignment.keyClass, + value1: assignment.value1, + value2: assignment.value2, + checksumValid: assignment.checksumValid, + action: assignment.label, + raw: assignment.raw, + }); + } + } + return { activeProfile: activeProfile + 1, buttons }; + } + + /** Select one of the four verified wired R1 banks and require readback. */ + async setR1ActiveProfile(profile: number): Promise { + await this.identify(); + if (!this.usesVerifiedR1WiredTransport()) { + throw new Error("R1 profile switching is available only over the verified wired transport."); + } + if (!Number.isInteger(profile) || profile < 1 || profile > ATK_R1_PROFILE_COUNT) { + throw new Error(`R1 profile must be between 1 and ${ATK_R1_PROFILE_COUNT}.`); + } + await this.send(atkBuildSetCurrentProfile(profile - 1)); + await delay(R1_PROFILE_SWITCH_SETTLE_MS); + const confirmed = (await this.readR1CurrentProfile()) + 1; + if (confirmed !== profile) throw new Error(`The mouse kept profile ${confirmed} instead of ${profile}.`); + this.lastStatus = null; + return confirmed; + } + + /** Read receiver online/RF identity and pairing countdown; this never starts pairing. */ + async readR1ReceiverInfo(): Promise> { + await this.identify(); + if (!this.isR1() || !this.usesR1LiveSettings()) { + throw new Error("R1 receiver telemetry is available only through the receiver transport."); + } + const onlineReply = await this.exchange( + weBuildCmdPayload(ATK_COMPX_COMMAND.getWirelessMouseOnline), + (frame) => frame[0] === ATK_COMPX_COMMAND.getWirelessMouseOnline + && frame[1] === 0 && frame[4] >= 1 && this.hasValidChecksum(frame), + ); + const online = atkDecodeReceiverStatus(onlineReply.subarray(DATA_OFFSET)); + if (!online) throw new Error("The receiver returned a truncated online-status reply."); + const pairingReply = await this.exchange( + weBuildCmdPayload(ATK_COMPX_COMMAND.getWirelessDonglePairResult), + (frame) => frame[0] === ATK_COMPX_COMMAND.getWirelessDonglePairResult + && frame[1] === 0 && frame[4] >= 2 && this.hasValidChecksum(frame), + ).catch(() => null); + const pairing = pairingReply ? atkDecodePairingStatus(pairingReply.subarray(DATA_OFFSET)) : null; + return { + ...online, + pairingStatus: pairing?.status ?? null, + pairingSecondsRemaining: pairing?.secondsRemaining ?? null, + }; + } + + /** Start receiver pairing for one exact CID/MID; callers must poll telemetry for completion. */ + async startR1ReceiverPairing(cid: number, mid: number): Promise { + await this.identify(); + if (this.device.vendorId !== VENDOR_ID.vgn || this.device.productId !== VXE_R1_COMPX_RECEIVER_PID) { + throw new Error("R1 receiver pairing is available only through the verified VXE Mouse 1K Dongle."); + } + await this.exchange( + atkBuildReceiverPairRequest(cid, mid), + (frame) => frame[0] === ATK_COMPX_COMMAND.setWirelessDonglePair + && frame[1] === 0 && this.hasValidChecksum(frame), + ); + } + async setPollingRate(pollingRateHz: number): Promise { - if (!this.isR1()) await this.identify(); - if (this.isR1()) return await this.setR1PollingRate(pollingRateHz); + if (!this.usesR1LiveSettings()) await this.identify(); + if (this.usesR1LiveSettings()) return await this.setR1PollingRate(pollingRateHz); const encoded = POLLING_RATES.find(([, hertz]) => hertz === pollingRateHz); if (!encoded) throw new Error(`This mouse does not support ${pollingRateHz} Hz.`); await this.write(REGISTER.system, wePackScalarPair(encoded[0])); + if (this.isR1()) await delay(R1_LIVE_WRITE_SETTLE_MS); const confirmed = this.decodePollingRate((await this.read(REGISTER.system, SYSTEM_LENGTH))[0]); if (confirmed !== pollingRateHz) { throw new Error(`The mouse kept ${confirmed} Hz instead of ${pollingRateHz} Hz.`); @@ -288,10 +457,128 @@ export class AtkHidClient { return confirmed.x; } + async setActiveDpiStage(index: number): Promise { + await this.identify(); + if (this.isR1() && !this.usesVerifiedR1WiredTransport()) { + throw new Error("R1 DPI stage selection is available only over the verified R1 SE+ wired transport."); + } + const system = await this.read(REGISTER.system, SYSTEM_LENGTH); + const count = this.stageCount(system); + if (!Number.isInteger(index) || index < 0 || index >= count) { + throw new Error(`DPI stage must be between 1 and ${count}.`); + } + await this.write(REGISTER.system + 4, wePackScalarPair(index)); + const pair = await this.read(REGISTER.system + 4, 2); + const confirmed = weUnpackScalarPair(pair[0]!, pair[1]!); + if (confirmed !== index) throw new Error(`The mouse kept DPI stage ${(confirmed ?? 0) + 1} instead of ${index + 1}.`); + const stage = await this.readDpiStage(index); + this.patch({ activeDpiStage: confirmed, dpi: stage.x, dpiY: stage.y }); + return confirmed; + } + + async setDpiStageValue(index: number, dpi: number): Promise { + await this.identify(); + if (this.isR1() && !this.usesVerifiedR1WiredTransport()) { + throw new Error("R1 DPI stage editing is available only over the verified R1 SE+ wired transport."); + } + const system = await this.read(REGISTER.system, SYSTEM_LENGTH); + const count = this.stageCount(system); + if (!Number.isInteger(index) || index < 0 || index >= count) { + throw new Error(`DPI stage must be between 1 and ${count}.`); + } + const sensor = this.product?.sensor ?? null; + const options = sensor ? atkDpiOptionsForSensor(sensor) : this.getDpiOptions(); + if (!Number.isInteger(dpi) || !options.includes(dpi)) { + throw new Error(`${dpi.toLocaleString()} is not a supported DPI value.`); + } + const packed = sensor ? atkPackDpiStageForSensor(sensor, dpi, dpi) : atkPackDpiStage(dpi, dpi); + if (!packed) throw new Error(`${dpi.toLocaleString()} DPI is not representable by this sensor.`); + await this.write(this.dpiAddress(index), packed); + const confirmed = await this.readDpiStage(index); + if (confirmed.x !== dpi || confirmed.y !== dpi) { + throw new Error(`The mouse kept ${confirmed.x.toLocaleString()} DPI instead of ${dpi.toLocaleString()}.`); + } + const dpiStages = this.lastStatus?.dpiStages?.slice() ?? []; + while (dpiStages.length < count) dpiStages.push(dpiStages.at(-1) ?? dpi); + dpiStages[index] = confirmed.x; + const active = this.lastStatus?.activeDpiStage ?? this.stageIndex(system); + this.patch({ + dpiStages, + ...(active === index ? { dpi: confirmed.x, dpiY: confirmed.y } : {}), + }); + return confirmed.x; + } + + async setDpiStageColor(index: number, color: string): Promise { + await this.identify(); + if (!this.usesVerifiedR1WiredTransport()) { + throw new Error("DPI stage colors are not available on this connection."); + } + const rgb = parseHexColor(color); + const count = this.stageCount(await this.read(REGISTER.system, SYSTEM_LENGTH)); + if (!rgb || !Number.isInteger(index) || index < 0 || index >= count) { + throw new Error("The DPI stage or colour is invalid."); + } + const groupAddress = REGISTER.dpiColorBase + Math.floor(index / 2) * R1_DPI_COLOR_GROUP_LENGTH; + const group = Array.from(await this.read(groupAddress, R1_DPI_COLOR_GROUP_LENGTH)); + group.splice((index % 2) * 4, 4, ...packRgb(rgb)); + await this.write(groupAddress, group); + const confirmedGroup = await this.read(groupAddress, R1_DPI_COLOR_GROUP_LENGTH); + const confirmed = unpackRgb(confirmedGroup.subarray((index % 2) * 4, (index % 2 + 1) * 4)); + if (confirmed !== color.toLowerCase()) throw new Error(`The mouse kept ${confirmed ?? "an invalid colour"} instead of ${color}.`); + const dpiStageColors = this.lastStatus?.dpiStageColors?.slice() ?? []; + while (dpiStageColors.length < count) dpiStageColors.push("#000000"); + dpiStageColors[index] = confirmed; + this.patch({ dpiStageColors }); + return confirmed; + } + + async setPerformanceMode(enabled: boolean): Promise { + const confirmed = await this.writeR1PerformanceBlock((block) => { + block.splice(4, 2, ...wePackScalarPair(enabled ? 1 : 0)); + }); + const value = weUnpackScalarPair(confirmed[4]!, confirmed[5]!) === 1; + if (value !== enabled) throw new Error(`The mouse left performance mode ${value ? "on" : "off"}.`); + this.patch({ performanceMode: value }); + return value; + } + + async setLongRangeMode(enabled: boolean): Promise { + await this.identify(); + if (!this.usesVerifiedR1WiredTransport()) throw new Error("Long-range mode is not available on this connection."); + await this.send(weBuildCmdPayload(SET_LONG_RANGE_COMMAND, [0, 0, 0, 10, enabled ? 1 : 0])); + await delay(WRITE_SETTLE_MS); + const confirmed = await this.readLongRangeMode(); + if (confirmed !== enabled) throw new Error(`The mouse left long-range mode ${confirmed ? "on" : "off"}.`); + this.patch({ longRangeMode: confirmed }); + return confirmed; + } + + async setDpiLighting(mode: number, brightness: number, speed: number): Promise { + await this.identify(); + if (!this.usesVerifiedR1WiredTransport()) throw new Error("DPI lighting is not available on this connection."); + if (![0, 1, 2].includes(mode) || ![0, 1, 2].includes(brightness) || ![0, 1, 2].includes(speed)) { + throw new Error("The DPI lighting setting is invalid."); + } + const block = Array.from(await this.read(REGISTER.dpiLighting, R1_DPI_LIGHTING_LENGTH)); + const effect = mode === 2 ? 2 : 1; + block.splice(0, 2, ...wePackScalarPair(effect)); + block.splice(2, 2, ...wePackScalarPair(R1_DPI_BRIGHTNESS[brightness]!)); + block.splice(4, 2, ...wePackScalarPair(R1_DPI_SPEED[speed]!)); + block.splice(6, 2, ...wePackScalarPair(mode === 0 ? 0 : 1)); + await this.write(REGISTER.dpiLighting, block); + const confirmed = this.decodeR1DpiLighting(await this.read(REGISTER.dpiLighting, R1_DPI_LIGHTING_LENGTH)); + if (!confirmed || confirmed.dpiLedMode !== mode + || confirmed.dpiLedBrightness !== brightness || confirmed.dpiLedSpeed !== speed) { + throw new Error("The mouse did not retain its DPI lighting settings."); + } + this.patch(confirmed); + } + async setLiftOffDistance(value: LiftOffDistance): Promise { - if (!this.isR1()) await this.identify(); - if (this.isR1()) return await this.setR1LiftOffDistance(value); - const encoded = LIFT_OFF_CODES.find(([, name]) => name === value); + if (!this.usesR1LiveSettings()) await this.identify(); + if (this.usesR1LiveSettings()) return await this.setR1LiftOffDistance(value); + const encoded = (this.isR1() ? R1_LIFT_OFF_CODES : LIFT_OFF_CODES).find(([, name]) => name === value); if (!encoded) throw new Error(`This mouse does not support a ${value.toLowerCase()} lift-off distance.`); await this.write(REGISTER.liftOffDistance, wePackScalarPair(encoded[0])); const confirmed = this.decodeLiftOffDistance((await this.read(REGISTER.liftOffDistance, 2))[0]); @@ -303,16 +590,19 @@ export class AtkHidClient { } async setMotionSync(enabled: boolean): Promise { + await this.identify(); return await this.setAdvancedFlag(2, enabled, "motionSync", "Motion Sync"); } async setRippleControl(enabled: boolean): Promise { + await this.identify(); return await this.setAdvancedFlag(8, enabled, "rippleControl", "ripple control"); } async setAngleSnapping(enabled: boolean): Promise { - if (!this.isR1()) await this.identify(); - if (this.isR1()) return await this.setR1AngleSnapping(enabled); + if (!this.usesR1LiveSettings()) await this.identify(); + if (this.usesR1LiveSettings()) return await this.setR1AngleSnapping(enabled); + if (this.isR1()) return await this.setAdvancedFlag(6, enabled, "angleSnapping", "straight-line correction"); const group = await this.read(REGISTER.angle, ANGLE_LENGTH); await this.write(REGISTER.angle, [group[0], enabled ? 1 : 0].flatMap((value) => wePackScalarPair(value))); const confirmed = (await this.read(REGISTER.angle, ANGLE_LENGTH))[2] === 1; @@ -322,10 +612,11 @@ export class AtkHidClient { } async setDebounceTime(milliseconds: number): Promise { - if (!this.isR1()) await this.identify(); - if (this.isR1()) return await this.setR1DebounceTime(milliseconds); - if (!Number.isInteger(milliseconds) || milliseconds < 0 || milliseconds > DEBOUNCE_MAX_MS) { - throw new Error(`Debounce must be a whole number of milliseconds between 0 and ${DEBOUNCE_MAX_MS}.`); + if (!this.usesR1LiveSettings()) await this.identify(); + if (this.usesR1LiveSettings()) return await this.setR1DebounceTime(milliseconds); + const options = this.getDebounceOptions(); + if (!Number.isInteger(milliseconds) || !options.includes(milliseconds)) { + throw new Error(`This mouse does not support ${milliseconds} ms debounce.`); } const confirmed = await this.writeAdvanced(0, milliseconds); if (confirmed !== milliseconds) { @@ -336,8 +627,13 @@ export class AtkHidClient { } async setSleepTimeout(seconds: number): Promise { - if (!Number.isInteger(seconds) || seconds < SLEEP_STEP_SECONDS || seconds > SLEEP_MAX_SECONDS) { - throw new Error(`The sleep timeout must be between ${SLEEP_STEP_SECONDS} and ${SLEEP_MAX_SECONDS} seconds.`); + if (!this.usesR1LiveSettings()) await this.identify(); + const valid = this.isR1() + ? this.getSleepOptions().includes(seconds) + : Number.isInteger(seconds) && seconds >= SLEEP_STEP_SECONDS + && seconds <= 0xff * SLEEP_STEP_SECONDS && seconds % SLEEP_STEP_SECONDS === 0; + if (!valid) { + throw new Error(`This mouse does not support a ${seconds} second sleep timeout.`); } const units = Math.round(seconds / SLEEP_STEP_SECONDS); const confirmed = await this.writeAdvanced(4, units) * SLEEP_STEP_SECONDS; @@ -351,7 +647,7 @@ export class AtkHidClient { private async setAdvancedFlag( offset: number, enabled: boolean, - field: "motionSync" | "rippleControl", + field: "motionSync" | "rippleControl" | "angleSnapping", label: string, ): Promise { const confirmed = await this.writeAdvanced(offset, enabled ? 1 : 0) === 1; @@ -368,6 +664,85 @@ export class AtkHidClient { return (await this.read(REGISTER.advanced, ADVANCED_LENGTH))[offset]; } + private async readR1Extras(stageCount: number): Promise<{ + dpiStageColors: string[]; + performanceMode: boolean; + longRangeMode: boolean; + dpiLedMode: number; + dpiLedBrightness: number; + dpiLedSpeed: number; + } | null> { + try { + const performance = await this.read(REGISTER.sensorPerformance, R1_SENSOR_PERFORMANCE_LENGTH); + const lighting = this.decodeR1DpiLighting(await this.read(REGISTER.dpiLighting, R1_DPI_LIGHTING_LENGTH)); + const dpiStageColors: string[] = []; + for (let groupIndex = 0; groupIndex < Math.ceil(stageCount / 2); groupIndex += 1) { + const group = await this.read( + REGISTER.dpiColorBase + groupIndex * R1_DPI_COLOR_GROUP_LENGTH, + R1_DPI_COLOR_GROUP_LENGTH, + ); + for (let slot = 0; slot < 2 && dpiStageColors.length < stageCount; slot += 1) { + const color = unpackRgb(group.subarray(slot * 4, slot * 4 + 4)); + if (!color) throw new Error("The mouse reported a DPI stage color that failed its checksum."); + dpiStageColors.push(color); + } + } + const performanceMode = weUnpackScalarPair(performance[4]!, performance[5]!); + if ((performanceMode !== 0 && performanceMode !== 1) || !lighting) { + throw new Error("The mouse reported invalid R1 extended settings."); + } + return { + dpiStageColors, + performanceMode: performanceMode === 1, + longRangeMode: await this.readLongRangeMode(), + ...lighting, + }; + } catch { + return null; + } + } + + private decodeR1DpiLighting(block: Uint8Array): { + dpiLedMode: number; + dpiLedBrightness: number; + dpiLedSpeed: number; + } | null { + if (block.length < R1_DPI_LIGHTING_LENGTH) return null; + const effect = weUnpackScalarPair(block[0]!, block[1]!); + const brightness = weUnpackScalarPair(block[2]!, block[3]!); + const speed = weUnpackScalarPair(block[4]!, block[5]!); + const enabled = weUnpackScalarPair(block[6]!, block[7]!); + const brightnessIndex = R1_DPI_BRIGHTNESS.indexOf(brightness as typeof R1_DPI_BRIGHTNESS[number]); + const speedIndex = R1_DPI_SPEED.indexOf(speed as typeof R1_DPI_SPEED[number]); + if ((effect !== 1 && effect !== 2) || brightnessIndex < 0 || speedIndex < 0 + || (enabled !== 0 && enabled !== 1)) return null; + return { + dpiLedMode: enabled === 0 ? 0 : effect === 2 ? 2 : 1, + dpiLedBrightness: brightnessIndex, + dpiLedSpeed: speedIndex, + }; + } + + private async readLongRangeMode(): Promise { + const reply = await this.exchange( + weBuildCmdPayload(GET_LONG_RANGE_COMMAND), + (frame) => frame[0] === GET_LONG_RANGE_COMMAND && frame[1] === 0 + && frame[4] >= 1 && frame[DATA_OFFSET] <= 1 && this.hasValidChecksum(frame), + ); + return reply[DATA_OFFSET] === 1; + } + + private async writeR1PerformanceBlock(change: (block: number[]) => void): Promise { + await this.identify(); + if (!this.usesVerifiedR1WiredTransport()) { + throw new Error("Performance mode is not available on this connection."); + } + const block = Array.from(await this.read(REGISTER.sensorPerformance, R1_SENSOR_PERFORMANCE_LENGTH)); + change(block); + await this.write(REGISTER.sensorPerformance, block); + return await this.read(REGISTER.sensorPerformance, R1_SENSOR_PERFORMANCE_LENGTH); + } + private dpiAddress(index: number): number { return REGISTER.dpiBase + index * DPI_STAGE_LENGTH; } @@ -382,10 +757,15 @@ export class AtkHidClient { } private stageIndex(system: Uint8Array): number { - const stages = Math.min(Math.max(system[2], 1), MAX_DPI_STAGES); + const stages = this.stageCount(system); return Math.min(system[4], stages - 1); } + private stageCount(system: Uint8Array): number { + const max = this.isR1() ? R1_MAX_DPI_STAGES : MAX_DPI_STAGES; + return Math.min(Math.max(system[2], 1), max); + } + private decodePollingRate(value: number): number { const match = POLLING_RATES.find(([code]) => code === value); if (!match) throw new Error(`The mouse reported an unknown polling-rate value 0x${value.toString(16)}.`); @@ -393,6 +773,7 @@ export class AtkHidClient { } private decodeLiftOffDistance(code: number): LiftOffDistance | null { + if (this.isR1()) return R1_LIFT_OFF_CODES.find(([value]) => value === code)?.[1] ?? null; const millimetres = atkDecodeLiftOff(code); if (millimetres === null) return null; if (millimetres < 1) return "Low"; @@ -409,7 +790,7 @@ export class AtkHidClient { * may be unpopulated, so fall back to the receiver's ceiling when unknown. */ private async readPollingRate(system?: Uint8Array): Promise { - if (this.isR1()) { + if (this.usesR1LiveSettings()) { const settings = await this.read(ATK_VXE_R1_SETTINGS_REGISTER, R1_SETTINGS_LENGTH).catch(() => null); const decoded = settings ? atkDecodeVxeR1PollingCode(settings[1]) : null; return decoded ?? 1000; @@ -469,6 +850,22 @@ export class AtkHidClient { if (this.lastStatus) this.lastStatus = { ...this.lastStatus, ...changes }; } + private hasValidChecksum(frame: Uint8Array): boolean { + return frame.length === FRAME_LENGTH + && frame[15] === weReportChecksum(WE_REPORT_ID, [...frame.subarray(0, 15)]); + } + + private async readR1CurrentProfile(): Promise { + const reply = await this.exchange( + weBuildCmdPayload(ATK_COMPX_COMMAND.getCurrentConfig), + (frame) => frame[0] === ATK_COMPX_COMMAND.getCurrentConfig + && frame[1] === 0 && frame[4] >= 1 && this.hasValidChecksum(frame), + ); + const profile = atkDecodeCurrentProfile(reply.subarray(DATA_OFFSET)); + if (profile === null) throw new Error("The mouse reported an invalid active configuration bank."); + return profile; + } + private async identify(): Promise { if (this.identified) return; if (this.identifyAttempts >= MAX_IDENTIFY_ATTEMPTS) { @@ -494,10 +891,20 @@ export class AtkHidClient { private usesSharedR1Transport(): boolean { return this.device.productId === VXE_R1_RECEIVER_PID - || (this.device.vendorId === VENDOR_ID.vgn && this.device.productId === 0xf58f) + || (this.device.vendorId === VENDOR_ID.vgn + && (this.device.productId === VXE_R1_COMPX_RECEIVER_PID || this.device.productId === VXE_R1_COMPX_MOUSE_PID)) || /\bvxe\s+r1(?:\s*se\+?)?\b/i.test(this.device.productName || ""); } + private usesR1LiveSettings(): boolean { + return this.isR1() && this.isWireless(); + } + + private usesVerifiedR1WiredTransport(): boolean { + return this.device.vendorId === VENDOR_ID.vgn && this.device.productId === VXE_R1_COMPX_MOUSE_PID + && this.product === ATK_PRODUCTS["2,32"] && !this.isWireless(); + } + /** * Command 0x12 (GetMouseVersion) reports the version as BCD, matching the * Endgame Gear siblings: an A9 Nearlink dongle answering 0x01 0x23 is 1.23. @@ -538,6 +945,7 @@ export class AtkHidClient { weBuildCmdPayload(WE_CMD_READ_EEPROM, [0, (address >> 8) & 0xff, address & 0xff, length]), // Some firmware answers a read with the write command id. (frame) => (frame[0] === WE_CMD_READ_EEPROM || frame[0] === WE_CMD_WRITE_EEPROM) + && frame[1] === 0 && this.hasValidChecksum(frame) && frame[2] === ((address >> 8) & 0xff) && frame[3] === (address & 0xff) && frame[4] >= length, @@ -546,6 +954,9 @@ export class AtkHidClient { } private async write(address: number, data: readonly number[]): Promise { + if (this.isR1() && !this.usesR1LiveSettings() && !this.usesVerifiedR1WiredTransport()) { + throw new Error("Persistent R1 EEPROM writes are available only over the verified R1 SE+ wired transport."); + } const payload = weBuildCmdPayload( WE_CMD_WRITE_EEPROM, [0, (address >> 8) & 0xff, address & 0xff, data.length, ...data], @@ -558,6 +969,13 @@ export class AtkHidClient { }); } + private async send(frame: Uint8Array): Promise { + await this.run(async () => { + await this.open(); + await this.device.sendReport(WE_REPORT_ID, new Uint8Array(frame).buffer); + }); + } + /** Send a frame and resolve with the first input report the matcher accepts. */ private async exchange(frame: Uint8Array, matches: (frame: Uint8Array) => boolean): Promise { return await this.run(async () => { @@ -606,3 +1024,20 @@ function batteryState(battery: { charging: boolean | null } | null): MouseStatus function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } + +function parseHexColor(color: string): readonly [number, number, number] | null { + const match = /^#([0-9a-f]{6})$/i.exec(color); + if (!match) return null; + const value = Number.parseInt(match[1]!, 16); + return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]; +} + +function packRgb(rgb: readonly [number, number, number]): number[] { + const sum = (rgb[0] + rgb[1] + rgb[2]) & 0xff; + return [...rgb, (0x55 - sum) & 0xff]; +} + +function unpackRgb(record: Uint8Array): string | null { + if (record.length < 4 || (record[0]! + record[1]! + record[2]! + record[3]!) % 0x100 !== 0x55) return null; + return `#${[record[0], record[1], record[2]].map((value) => value!.toString(16).padStart(2, "0")).join("")}`; +} diff --git a/src/drivers/atk/products.ts b/src/drivers/atk/products.ts index 5db58da..9f846ce 100644 --- a/src/drivers/atk/products.ts +++ b/src/drivers/atk/products.ts @@ -15,5 +15,5 @@ export const ATK_PRODUCTS: Record = { "2,32": { brand: "VXE", model: "R1 SE+", sensor: "PAW3395SE", family: "r1", verified: true }, }; -/** Known VXE wired transports under COMPX's shared vendor id. */ -export const ATK_COMPX_PRODUCT_IDS: readonly number[] = [0xf58f]; +/** Known VXE R1 SE+ transports under COMPX's shared vendor id. */ +export const ATK_COMPX_PRODUCT_IDS: readonly number[] = [0xf58e, 0xf58f]; diff --git a/src/drivers/atk/protocol.test.ts b/src/drivers/atk/protocol.test.ts index 6a4a519..a2f8174 100644 --- a/src/drivers/atk/protocol.test.ts +++ b/src/drivers/atk/protocol.test.ts @@ -2,14 +2,33 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + ATK_BUTTON_CLASS, + ATK_COMPX_COMMAND, + ATK_COMPX_FIRMWARE_HEADER_LENGTH, + ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET, + ATK_R1_BUTTONS, + ATK_R1_MACRO_BASE, + ATK_R1_MACRO_SLOT_COUNT, + ATK_R1_MACRO_SLOT_LENGTH, + ATK_R1_PROFILE_COUNT, + ATK_R1_SHORTCUT_BASE, + ATK_R1_SHORTCUT_SLOT_LENGTH, ATK_SENSORS, + atkBuildReceiverPairRequest, + atkBuildSetCurrentProfile, + atkCompxPayloadCrc, + atkDecodeButtonAssignment, + atkDecodeCurrentProfile, atkDecodeLiftOff, + atkDecodePairingStatus, + atkDecodeReceiverStatus, atkDpiOptionsForSensor, atkDecodeVxeR1PollingCode, atkPackDpiStage, atkPackDpiStageForSensor, atkPackVxeR1LiveSetting, atkPackVxeR1PollingSetting, + atkParseCompxFirmware, atkUnpackDpiStage, atkUnpackDpiStageForSensor, ATK_VXE_R1_ANGLE_SELECTOR, @@ -133,3 +152,117 @@ test("R1 angle/debounce/LOD settings pack as their live-settings selectors", () assert.deepEqual(atkPackVxeR1LiveSetting(ATK_VXE_R1_LOD_SELECTOR, 1), [0x03, 0x01, 0x00, 0x54]); assert.deepEqual(atkPackVxeR1LiveSetting(ATK_VXE_R1_LOD_SELECTOR, 2), [0x03, 0x02, 0x00, 0x53]); }); + +test("COMPX command ids and R1 storage geometry match the vendor protocol", () => { + assert.equal(ATK_COMPX_COMMAND.getWirelessMouseOnline, 0x03); + assert.equal(ATK_COMPX_COMMAND.getCurrentConfig, 0x0e); + assert.equal(ATK_COMPX_COMMAND.setCurrentConfig, 0x0f); + assert.equal(ATK_COMPX_COMMAND.reportMouseUpgradeError, 0x5a); + assert.equal(ATK_COMPX_COMMAND.reportMouseUpgradeStatus, 0x5b); + assert.equal(ATK_R1_PROFILE_COUNT, 4); + assert.deepEqual(ATK_R1_BUTTONS.map(({ address }) => address), [0x60, 0x64, 0x68, 0x6c, 0x70, 0x74]); + assert.equal(ATK_R1_SHORTCUT_BASE, 0x100); + assert.equal(ATK_R1_SHORTCUT_SLOT_LENGTH, 32); + assert.equal(ATK_R1_MACRO_BASE, 0x300); + assert.equal(ATK_R1_MACRO_SLOT_LENGTH, 384); + assert.equal(ATK_R1_MACRO_SLOT_COUNT, 12); +}); + +test("current profile, receiver, and pairing replies are decoded without writes", () => { + assert.equal(atkDecodeCurrentProfile([0]), 0); + assert.equal(atkDecodeCurrentProfile([3]), 3); + assert.equal(atkDecodeCurrentProfile([4]), null); + assert.equal(atkDecodeCurrentProfile([]), null); + assert.deepEqual(atkDecodeReceiverStatus([1, 0xaa, 0xbb, 0xcc]), { + online: true, + status: 1, + rfId: "CCBBAA", + }); + assert.deepEqual(atkDecodeReceiverStatus([0, 0, 0, 0]), { online: false, status: 0, rfId: "000000" }); + assert.deepEqual(atkDecodeReceiverStatus([2, 0xaa, 0xbb, 0xcc]), { + online: false, + status: 2, + rfId: "CCBBAA", + }); + assert.equal(atkDecodeReceiverStatus([1, 2, 3]), null); + assert.deepEqual(atkDecodePairingStatus([2, 29]), { status: 2, secondsRemaining: 29 }); + assert.equal(atkDecodePairingStatus([2]), null); +}); + +test("profile selection uses the vendor length field and zero-based bank", () => { + assert.deepEqual([...atkBuildSetCurrentProfile(2)], [ + 0x0f, 0, 0, 0, 1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x3b, + ]); + assert.throws(() => atkBuildSetCurrentProfile(-1), /between 0 and 3/); + assert.throws(() => atkBuildSetCurrentProfile(4), /between 0 and 3/); +}); + +test("receiver pairing encodes the exact mouse CID and MID", () => { + assert.deepEqual([...atkBuildReceiverPairRequest(0x02, 0x20)], [ + 0x05, 0, 0, 0, 2, 0x02, 0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0x24, + ]); + assert.throws(() => atkBuildReceiverPairRequest(-1, 0x20), /must be bytes/); + assert.throws(() => atkBuildReceiverPairRequest(0x02, 0x100), /must be bytes/); +}); + +test("button assignments preserve unknown values and report checksum corruption", () => { + assert.deepEqual(atkDecodeButtonAssignment([ATK_BUTTON_CLASS.mouse, 1, 0, 0x53]), { + keyClass: 1, + value1: 1, + value2: 0, + checksum: 0x53, + checksumValid: true, + label: "Left click", + raw: "01 01 00 53", + }); + const unknown = atkDecodeButtonAssignment([0xfe, 0xaa, 0xbb, 0x00]); + assert.equal(unknown?.checksumValid, false); + assert.equal(unknown?.label, "Unknown class 0xfe (0xaa, 0xbb)"); + assert.equal(atkDecodeButtonAssignment([1, 2, 3]), null); +}); + +test("COMPX firmware parser checks endpoints, geometry, and raw payload CRC", () => { + const payload = new Uint8Array([1, 2, 3]); + assert.equal(atkCompxPayloadCrc(payload), 0xaa437fe2); + const file = new Uint8Array(ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET + payload.length); + const view = new DataView(file.buffer); + view.setUint32(0, 0x55552135, true); + view.setUint32(4, ATK_COMPX_FIRMWARE_HEADER_LENGTH, true); + view.setUint32(8, payload.length, true); + view.setUint32(16, 0x315, true); + view.setUint8(20, 1); + view.setUint8(21, 2); + view.setUint8(22, 32); + const writeField = (index: number, value: string): void => { + file.set(new TextEncoder().encode(value), 23 + index * 64); + }; + writeField(0, "ComUsbUpgradeFile"); + writeField(1, "CX52850P"); + writeField(2, "vid_3554&pid_f406&mi_01&col01"); + writeField(3, "vid_3554&pid_f406&mi_01&col02"); + writeField(4, "vid_3554&pid_f58f&mi_01&col05"); + writeField(5, "vid_3554&pid_f58f&mi_01&col05"); + writeField(9, "3395se"); + const prepare = 23 + 7 * 64; + file.set([49, 1, 6, 0xb0], prepare); + view.setUint32(prepare + 19, atkCompxPayloadCrc(payload), false); + file.set(payload, ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET); + + const parsed = atkParseCompxFirmware(file); + assert.equal(parsed.version, "315"); + assert.equal(parsed.cid, 2); + assert.equal(parsed.mid, 32); + assert.equal(parsed.icName, "CX52850P"); + assert.equal(parsed.sensorName, "3395se"); + assert.deepEqual(parsed.normalInput, { + vendorId: 0x3554, + productId: 0xf58f, + path: "vid_3554&pid_f58f&mi_01&col05", + }); + assert.equal(parsed.payloadCrcValid, true); + + file[ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET] ^= 0xff; + assert.equal(atkParseCompxFirmware(file).payloadCrcValid, false); + view.setUint32(8, payload.length + 1, true); + assert.throws(() => atkParseCompxFirmware(file), /payload length exceeds/); +}); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 7ce32cd..835213a 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -65,6 +65,12 @@ export interface MouseUiHints { maxDpi: number; stepDpi: number; }; + /** Simple DPI indicator modes normalized as 0 off, 1 steady, 2 breathing. */ + dpiLighting?: { + modes: readonly (0 | 1 | 2)[]; + brightness: readonly number[]; + speed: readonly number[]; + }; } /** @@ -117,6 +123,26 @@ export type MouseLightingMode = | "Breathing single" | "Breathing dual"; +export interface AtkStoredButton { + id: "left" | "right" | "middle" | "back" | "forward" | "bottom"; + name: string; + address: number; + keyClass: number; + value1: number; + value2: number; + checksumValid: boolean; + action: string; + raw: string; +} + +export interface AtkReceiverInfo { + online: boolean; + status: number; + rfId: string; + pairingStatus: number | null; + pairingSecondsRemaining: number | null; +} + export interface MouseStatus { brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious"; name: string; @@ -138,6 +164,12 @@ export interface MouseStatus { pollingRateHz: number; supportedPollingRates?: number[]; activeProfile: number | null; + /** Number of firmware-managed ATK configuration banks, when readable. */ + atkProfileCount?: number; + /** Lossless, read-only ATK button assignments from the active configuration bank. */ + atkButtonMappings?: AtkStoredButton[]; + /** Receiver and pairing telemetry. Pairing controls are intentionally separate. */ + atkReceiver?: AtkReceiverInfo; deviceMode?: "Onboard" | "Host" | "Unknown"; unitId?: string | null; modelId?: string | null; @@ -156,6 +188,8 @@ export interface MouseStatus { motionSync?: boolean | null; /** On-device DPI stages, where supported (Teevolution, Ninjutso, …). */ dpiStages?: number[]; + /** RGB colour for each DPI stage, as lowercase #rrggbb. */ + dpiStageColors?: string[]; /** Active DPI stage index into `dpiStages` (0-based). */ activeDpiStage?: number; ninjutsoSystemMode?: "High Speed" | "Competitive" | "Ultra" | null; @@ -195,6 +229,7 @@ export interface MouseStatus { napeLayerCount?: number; performanceMode?: boolean | null; hyperMode?: boolean | null; + longRangeMode?: boolean | null; sensorMode?: "Eco" | "High" | "Ultra" | null; sensorModeStored?: 0 | 1 | null; sensorModeEditable?: boolean | null;