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,