From 3152e56acfb34895b36426823dd32e695b7d40e3 Mon Sep 17 00:00:00 2001 From: sakethkanchi Date: Fri, 4 Sep 2026 15:08:14 -0400 Subject: [PATCH 1/2] 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/2] 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,