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