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,