diff --git a/docs/atk-testing.md b/docs/atk-testing.md new file mode 100644 index 0000000..02d8e0f --- /dev/null +++ b/docs/atk-testing.md @@ -0,0 +1,182 @@ +# 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. The receiver telemetry described below was +tested separately. The full sensor table and ranges, and the CID/MID mapping, +were independently transcribed from the public ATK HUB 3.2.21 bundle; the +low-range records below cross-check that transcription. + +- 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, sleep timeout, 125 Hz polling, 2 mm lift-off, + 1 ms debounce, and straight-line correction changes were confirmed through + wired EEPROM readback and restored. +- ATK HUB 3.2.21 exposes wired polling at 125, 250, 500, and 1,000 Hz; debounce + at 0, 1, 2, 4, 8, 15, and 20 ms; 1 mm and 2 mm lift-off; and straight-line + correction in the advanced EEPROM block. OpenMouse follows those exact wired + EEPROM paths. +- The same HUB build exposes up to eight DPI stages for this identity. The + active two stages were read as 800 and 1,600 DPI. Active-stage selection and + arbitrary-stage values were changed, confirmed, and restored. The configured + count remained at two because adding stages changes the user's profile shape. +- A DPI-stage color, Basic/Competitive performance mode, Ultra Long Range, and + DPI lighting effect/brightness/speed were each changed, confirmed through + device readback, and restored. The final complete run reported every setting + at its captured baseline. + +PAW3395SE maps targets 50 through 10,000 in 50-DPI increments to codes 1 +through 235 while skipping these codes: + +```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. The stock receiver retains +the current live-settings behavior transcribed from OpenVXE: + +- Polling: 250, 500, and 1,000 Hz through selector `0x0b`. +- Angle snapping: selector `0x01`. +- Debounce: selector `0x02`, 1 through 20 ms. +- Lift-off distance: selector `0x03`, Low or High. + +The wired CID/MID `2,32` path instead follows ATK HUB's EEPROM configuration: +polling at `0x0000`, stage count and active stage in the same system block, +up to eight DPI records from `0x000c`, lift-off at `0x000a`, and debounce, +Motion Sync, sleep, straight-line correction, and ripple control at `0x00a9`. +DPI-stage colors start at `0x002c`, DPI lighting is at `0x004c`, and the +six-byte record at `0x00b5` stores sensor-sleep enabled, sensor-sleep time, and +the sensor model exposed by the HUB as Basic/Competitive mode. Each field has a +paired checksum byte, and the complete record must be written atomically. Ultra +Long Range uses write command `0x16` and read command `0x17` rather than EEPROM. + +The middle pair at `0x00b5` read `0c 49` while the one-minute user sleep timer +at `0x00a9` read `06 4f`. Attempts to treat the `0x00b5` pair as a second copy +of the configurable timer did not retain non-default values, so OpenMouse does +not write it when changing sleep timeout. + +Angle values from EEPROM are accepted only when each value/checksum pair sums +to `0x55`. An unprogrammed `ff ff ff ff` row reports both angle fields as +unsupported. + +Battery command `0x04` is decoded according to its declared payload length: +percent requires one byte, the charging flag requires two, and big-endian cell +voltage requires four. A missing or short reply leaves unavailable fields +unknown rather than interpreting padding as data. + +## Profile selection and read-only button inspection + +ATK HUB exposes four firmware-managed configuration banks through +`GetCurrentConfig` (`0x0e`) and `SetCurrentConfig` (`0x0f`). OpenMouse currently +reads the active bank and can switch banks over the verified wired transport. +The selector request declares one data byte at frame offset 4 and stores the +zero-based bank at offset 5. Hardware testing cycled all four banks, captured +5,376 bytes from each, restored bank 0, and reproduced its original SHA-256 +exactly. The captures differed across settings, buttons, and shortcut storage, +confirming that EEPROM reads are redirected through the active bank. + +A recovery test isolated a two-event macro in bank 3, slot 0, without assigning +it to a button. After a factory-reset experiment, banks 0 through 2 reproduced +their original complete-image SHA-256 hashes. Bank 3 reproduced its original +image outside slot 0; replaying ATK HUB's four-chunk macro reset sequence set the +slot count to zero. Previously programmed event bytes cannot be changed back to +their erased `0xff` representation through the observed EEPROM command, but a +zero count makes them unreachable and matches the vendor's empty-macro +semantics. The active bank was restored to bank 0. This also confirmed that +checksum-protected settings and macro headers must retain the vendor's record +and transaction boundaries during writes. + +The wired R1 button matrix contains six four-byte records at `0x0060`, +`0x0064`, `0x0068`, `0x006c`, `0x0070`, and `0x0074`. Each record is +`[class, value1, value2, checksum]` and all four bytes must sum to `0x55` +modulo 256. OpenMouse reads them in the same three eight-byte groups as ATK +HUB, preserves unknown values, and reports checksum failures. It does not write +button records. Shortcut slots begin at `0x0100` in 32-byte increments. The +vendor address table defines 12 macro slots beginning at `0x0300` in 384-byte +increments and ending at `0x1500`. Neither region is read during ordinary +status polling. + +Receiver command `0x03` reports online state and the three-byte RF identifier. +Command `0x06` reports pairing status and seconds remaining. Both are safe +telemetry reads. On the verified `0x3554:0xf58e` receiver, OpenMouse can start +pairing command `0x05` for the R1 SE+ identity `[CID 0x02, MID 0x20]` after an +explicit preparation step. Cancellation command `0x13` remains unavailable. + +The Nordic receiver was identified over USB as `0x3554:0xf58e`, product `VXE +Mouse 1K Dongle`, firmware/bcdDevice 1.10. Its configuration channel is +interface 1 with usage page `0xff02`, usage `2`, and report `0x08`. Read-only +hardware probes produced checksum-valid replies for online status (`0x03`), +pairing status (`0x06`), and dongle version (`0x1d`). The online reply declared +one payload byte for status while retaining the three-byte RF identifier in its +fixed frame positions, so the driver requires the declared status byte and +decodes the identifier from the complete validated frame. + +Pairing was validated with the mouse cable unplugged and the mouse switched to +2.4 GHz mode. After command `0x05`, holding left click, wheel click, and right +click until the indicator flashed completed the exchange. Command `0x06` +reached status 2 with zero seconds remaining, command `0x03` reported the mouse +online, and the RF identifier remained stable across repeated reads. A prior +attempt without the physical button gesture also reached status 2 but remained +offline, so status 2 alone is not success. OpenMouse requires both terminal +status 2 and online telemetry. Profiles 0 through 2 retained their exact +pre-pairing hashes, profile 3 retained its zero-count cleared macro state, and +the active bank was restored to bank 0 after verification. + +## Firmware package inspection + +The official R1 SE+ 3.15 COMPX package is 261,382 bytes. Its 720-byte logical +header sits in an 8,192-byte header area followed by a 253,190-byte payload. +The header identifies normal endpoint `0x3554:0xf58f`, boot endpoint +`0x3554:0xf406`, IC `CX52850P`, sensor `3395se`, and version `0x315`. + +The prepare-command descriptor stores the raw CRC-32 register state (initial +value `0xffffffff`, polynomial `0xedb88320`, no final XOR) in big-endian order. +For version 3.15 it is `0x4026708e`; for version 3.14 it is `0xab88f525`. +OpenMouse's package parser checks this payload CRC and package bounds. The +vendor upgrader reads the header's `headCRC` field but does not appear to +validate it, so OpenMouse does not claim that field as an integrity check. + +Firmware parsing is read-only. Entering boot mode, erase preparation, chunk +transfers, factory reset, and macro clearing remain intentionally unavailable. diff --git a/src/atk/index.ts b/src/atk/index.ts index 1bdf5e6..54f2162 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; @@ -112,3 +233,294 @@ export function atkDecodeVxeR1PollingCode(code: number): number | null { return VXE_POLLING_CODES.find(([value]) => (value & 0xff) === (code & 0xff))?.[1] ?? null; } +// ── COMPX command and stored-profile inspection ──────────────────────────── + +/** Command ids used by the COMPX configuration transport. */ +export const ATK_COMPX_COMMAND = { + getWirelessMouseOnline: 0x03, + setWirelessDonglePair: 0x05, + getWirelessDonglePairResult: 0x06, + restoreFactory: 0x09, + enterUsbUpgradeMode: 0x0d, + getCurrentConfig: 0x0e, + setCurrentConfig: 0x0f, + dongleExitPair: 0x13, + getDongleVersion: 0x1d, + reportMouseUpgradeError: 0x5a, + reportMouseUpgradeStatus: 0x5b, +} as const; + +/** The R1 HUB profile picker exposes four firmware-managed configuration banks. */ +export const ATK_R1_PROFILE_COUNT = 4; + +export function atkDecodeCurrentProfile(data: Uint8Array | readonly number[]): number | null { + const profile = data[0]; + return profile !== undefined && profile < ATK_R1_PROFILE_COUNT ? profile : null; +} + +/** Build SetCurrentConfig with the vendor's length byte and zero-based bank. */ +export function atkBuildSetCurrentProfile(profile: number): Uint8Array { + if (!Number.isInteger(profile) || profile < 0 || profile >= ATK_R1_PROFILE_COUNT) { + throw new Error(`ATK profile must be between 0 and ${ATK_R1_PROFILE_COUNT - 1}.`); + } + const payload = new Uint8Array(16); + payload[0] = ATK_COMPX_COMMAND.setCurrentConfig; + payload[4] = 1; + payload[5] = profile; + const sum = 0x08 + payload.subarray(0, 15).reduce((total, byte) => total + byte, 0); + payload[15] = (CHECKSUM_TOTAL - (sum & 0xff)) & 0xff; + return payload; +} + +/** Build SetWirelessDonglePair for one exact mouse model identity. */ +export function atkBuildReceiverPairRequest(cid: number, mid: number): Uint8Array { + if (![cid, mid].every((value) => Number.isInteger(value) && value >= 0 && value <= 0xff)) { + throw new Error("ATK receiver pairing CID and MID must be bytes."); + } + const payload = new Uint8Array(16); + payload[0] = ATK_COMPX_COMMAND.setWirelessDonglePair; + payload[4] = 2; + payload[5] = cid; + payload[6] = mid; + const sum = 0x08 + payload.subarray(0, 15).reduce((total, byte) => total + byte, 0); + payload[15] = (CHECKSUM_TOTAL - (sum & 0xff)) & 0xff; + return payload; +} + +export const ATK_R1_BUTTONS = [ + { id: "left", label: "Left button", address: 0x0060 }, + { id: "right", label: "Right button", address: 0x0064 }, + { id: "middle", label: "Middle button", address: 0x0068 }, + { id: "back", label: "Back button", address: 0x006c }, + { id: "forward", label: "Forward button", address: 0x0070 }, + { id: "bottom", label: "Bottom button", address: 0x0074 }, +] as const; + +export const ATK_R1_SHORTCUT_BASE = 0x0100; +export const ATK_R1_SHORTCUT_SLOT_LENGTH = 32; +export const ATK_R1_MACRO_BASE = 0x0300; +export const ATK_R1_MACRO_SLOT_LENGTH = 384; +export const ATK_R1_MACRO_SLOT_COUNT = 12; + +export type AtkR1ButtonId = (typeof ATK_R1_BUTTONS)[number]["id"]; + +/** Four-byte EEPROM button assignment: class, value 1, value 2, checksum. */ +export interface AtkButtonAssignment { + keyClass: number; + value1: number; + value2: number; + checksum: number; + checksumValid: boolean; + label: string; + raw: string; +} + +export const ATK_BUTTON_CLASS = { + disabled: 0x00, + mouse: 0x01, + dpi: 0x02, + horizontalScroll: 0x03, + firepower: 0x04, + shortcut: 0x05, + macro: 0x06, + reportRate: 0x07, + lighting: 0x08, + profile: 0x09, + dpiLock: 0x0a, + wheel: 0x0b, +} as const; + +const BUTTON_CLASS_LABELS: Readonly> = { + [ATK_BUTTON_CLASS.disabled]: "Disabled", + [ATK_BUTTON_CLASS.mouse]: "Mouse button", + [ATK_BUTTON_CLASS.dpi]: "DPI control", + [ATK_BUTTON_CLASS.horizontalScroll]: "Horizontal scroll", + [ATK_BUTTON_CLASS.firepower]: "Fire key", + [ATK_BUTTON_CLASS.shortcut]: "Shortcut", + [ATK_BUTTON_CLASS.macro]: "Macro", + [ATK_BUTTON_CLASS.reportRate]: "Polling rate", + [ATK_BUTTON_CLASS.lighting]: "Lighting control", + [ATK_BUTTON_CLASS.profile]: "Profile control", + [ATK_BUTTON_CLASS.dpiLock]: "DPI lock", + [ATK_BUTTON_CLASS.wheel]: "Wheel", +}; + +const MOUSE_ACTION_LABELS: Readonly> = { + 0x00: "Disabled", + 0x01: "Left click", + 0x02: "Right click", + 0x04: "Middle click", + 0x08: "Back", + 0x10: "Forward", +}; + +const DPI_ACTION_LABELS: Readonly> = { + 0x01: "DPI cycle", + 0x02: "DPI up", + 0x03: "DPI down", +}; + +function hexByte(value: number): string { + return value.toString(16).padStart(2, "0"); +} + +/** Describe only action values whose meaning is present in the vendor enum. */ +export function atkDescribeButtonAction(keyClass: number, value1: number, value2: number): string { + if (keyClass === ATK_BUTTON_CLASS.disabled) return "Disabled"; + if (keyClass === ATK_BUTTON_CLASS.mouse) return MOUSE_ACTION_LABELS[value1] ?? `Mouse button 0x${hexByte(value1)}`; + if (keyClass === ATK_BUTTON_CLASS.dpi) return DPI_ACTION_LABELS[value1] ?? `DPI control 0x${hexByte(value1)}`; + if (keyClass === ATK_BUTTON_CLASS.horizontalScroll) { + return value1 === 1 ? "Horizontal scroll left" : value1 === 2 ? "Horizontal scroll right" : `Horizontal scroll 0x${hexByte(value1)}`; + } + if (keyClass === ATK_BUTTON_CLASS.wheel) { + return value1 === 1 ? "Wheel up" : value1 === 2 ? "Wheel down" : `Wheel 0x${hexByte(value1)}`; + } + const kind = BUTTON_CLASS_LABELS[keyClass] ?? `Unknown class 0x${hexByte(keyClass)}`; + return `${kind} (0x${hexByte(value1)}, 0x${hexByte(value2)})`; +} + +export function atkDecodeButtonAssignment(data: Uint8Array | readonly number[]): AtkButtonAssignment | null { + if (data.length < 4) return null; + const [keyClass, value1, value2, checksum] = data; + const raw = [keyClass!, value1!, value2!, checksum!].map(hexByte).join(" "); + return { + keyClass: keyClass!, + value1: value1!, + value2: value2!, + checksum: checksum!, + checksumValid: (keyClass! + value1! + value2! + checksum!) % 0x100 === CHECKSUM_TOTAL, + label: atkDescribeButtonAction(keyClass!, value1!, value2!), + raw, + }; +} + +export interface AtkReceiverStatus { + online: boolean; + status: number; + /** RF id in the firmware's rfId1/rfId2/rfId3 display order. */ + rfId: string; +} + +export function atkDecodeReceiverStatus(data: Uint8Array | readonly number[]): AtkReceiverStatus | null { + if (data.length < 4) return null; + return { + online: data[0] === 1, + status: data[0]!, + rfId: [data[3]!, data[2]!, data[1]!].map(hexByte).join("").toUpperCase(), + }; +} + +export interface AtkPairingStatus { + status: number; + secondsRemaining: number; +} + +export function atkDecodePairingStatus(data: Uint8Array | readonly number[]): AtkPairingStatus | null { + return data.length < 2 ? null : { status: data[0]!, secondsRemaining: data[1]! }; +} + +// ── COMPX firmware package inspection ────────────────────────────────────── + +export const ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET = 8192; +export const ATK_COMPX_FIRMWARE_HEADER_LENGTH = 720; +const ATK_FIRMWARE_FIELD_LENGTH = 64; +const ATK_FIRMWARE_FIELD_BASE = 23; + +export interface AtkFirmwareEndpoint { + vendorId: number; + productId: number; + path: string; +} + +export interface AtkCompxFirmwareInfo { + headerCrc: number; + headerLength: number; + firmwareLength: number; + nextFileAddress: number; + version: string; + deviceType: number; + cid: number; + mid: number; + fileId: string; + icName: string; + sensorName: string; + productName: string; + bootInput: AtkFirmwareEndpoint; + bootOutput: AtkFirmwareEndpoint; + normalInput: AtkFirmwareEndpoint; + normalOutput: AtkFirmwareEndpoint; + resetCommand: Uint8Array; + prepareCommand: Uint8Array; + downloadCommand: Uint8Array; + payloadCrc: number; + payloadCrcValid: boolean; +} + +function firmwareField(data: Uint8Array, index: number): Uint8Array { + const start = ATK_FIRMWARE_FIELD_BASE + index * ATK_FIRMWARE_FIELD_LENGTH; + return data.subarray(start, start + ATK_FIRMWARE_FIELD_LENGTH); +} + +function asciiField(data: Uint8Array, index: number): string { + const field = firmwareField(data, index); + const end = field.indexOf(0); + return new TextDecoder("ascii").decode(end < 0 ? field : field.subarray(0, end)); +} + +function firmwareEndpoint(path: string): AtkFirmwareEndpoint { + const match = /(?:^|&)vid_([0-9a-f]+)&pid_([0-9a-f]+)(?:&|$)/i.exec(path); + if (!match) throw new Error(`Invalid COMPX firmware endpoint: ${path || "empty"}.`); + return { vendorId: Number.parseInt(match[1]!, 16), productId: Number.parseInt(match[2]!, 16), path }; +} + +/** CRC-32 register state used by COMPX packages: init 0xffffffff, no final xor. */ +export function atkCompxPayloadCrc(data: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of data) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ ((crc & 1) === 0 ? 0 : 0xedb88320); + } + return crc >>> 0; +} + +function commandField(data: Uint8Array, index: number): Uint8Array { + return new Uint8Array(firmwareField(data, index)); +} + +/** Parse and integrity-check the first firmware image in a COMPX package. */ +export function atkParseCompxFirmware(data: Uint8Array): AtkCompxFirmwareInfo { + if (data.length < ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET) throw new Error("COMPX firmware package is shorter than its header area."); + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const headerLength = view.getUint32(4, true); + const firmwareLength = view.getUint32(8, true); + if (headerLength !== ATK_COMPX_FIRMWARE_HEADER_LENGTH) throw new Error(`Unsupported COMPX header length ${headerLength}.`); + if (firmwareLength < 1 || ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET + firmwareLength > data.length) { + throw new Error("COMPX firmware payload length exceeds the package."); + } + const prepareCommand = commandField(data, 7); + const payloadCrc = new DataView(prepareCommand.buffer, prepareCommand.byteOffset, prepareCommand.byteLength).getUint32(19, false); + const payload = data.subarray(ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET, ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET + firmwareLength); + return { + headerCrc: view.getUint32(0, true), + headerLength, + firmwareLength, + nextFileAddress: view.getUint32(12, true), + version: view.getUint32(16, true).toString(16), + deviceType: view.getUint8(20), + cid: view.getUint8(21), + mid: view.getUint8(22), + fileId: asciiField(data, 0), + icName: asciiField(data, 1), + bootInput: firmwareEndpoint(asciiField(data, 2)), + bootOutput: firmwareEndpoint(asciiField(data, 3)), + normalInput: firmwareEndpoint(asciiField(data, 4)), + normalOutput: firmwareEndpoint(asciiField(data, 5)), + resetCommand: commandField(data, 6), + prepareCommand, + downloadCommand: commandField(data, 8), + sensorName: asciiField(data, 9), + productName: asciiField(data, 10), + payloadCrc, + payloadCrcValid: atkCompxPayloadCrc(payload) === payloadCrc, + }; +} diff --git a/src/drivers/atk/hid.test.ts b/src/drivers/atk/hid.test.ts index c0ea40c..0ac4f9e 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); @@ -88,16 +99,23 @@ function device(productId = 0x1085, productName = "Wireless mouse -1k dongle"): /** 16-byte EEPROM read reply for address 0x0070 carrying the given data row. */ function readReply(row: number[]): number[] { - return [0x08, 0x00, 0x00, 0x70, 0x04, ...row, 0, 0, 0, 0, 0, 0, 0]; + const frame = [0x08, 0x00, 0x00, 0x70, 0x04, ...row, 0, 0, 0, 0, 0, 0, 0]; + sealReply(frame); + return frame; } /** 16-byte read reply echoing `data` at the given EEPROM address. */ function reply(cmd: number, address: number, data: number[]): number[] { const frame = [cmd, 0x00, (address >> 8) & 0xff, address & 0xff, data.length, ...data]; while (frame.length < 16) frame.push(0x00); + sealReply(frame); return frame; } +function sealReply(frame: number[]): void { + frame[15] = (0x55 - 0x08 - frame.slice(0, 15).reduce((total, byte) => total + byte, 0)) & 0xff; +} + function wrote(fake: HIDDevice): Uint8Array { const write = (fake as unknown as FakeAtkDevice).sent.find(({ reportId, data }) => reportId === 8 && data[0] === 0x07); @@ -123,26 +141,194 @@ function sumFrame(reportId: number, payload: Uint8Array): number { return sum & 0xff; } +function button(keyClass: number, value1: number, value2 = 0): number[] { + return [keyClass, value1, value2, (0x55 - keyClass - value1 - value2) & 0xff]; +} + test("support is limited to 0x373b with the vendor config collection", () => { assert.equal(AtkHidClient.isSupported(device(0x1085)), true); assert.equal(AtkHidClient.isSupported(device(0x11d5, "ATK dongle")), true); assert.equal(AtkHidClient.isSupported({ ...device(), vendorId: 0x1234 }), false); }); +test("R1 SE+ transports are claimed without overlapping the Pulsar fallback", () => { + const wired = device(0xf58f, "VXE R1SE+"); + Object.assign(wired, { vendorId: 0x3554 }); + const collection = wired.collections[0]!; + collection.inputReports = [{ reportId: 0x08, items: [] }]; + collection.outputReports = [{ reportId: 0x08, items: [] }]; + const receiver = device(0xf58e, "VXE Mouse 1K Dongle"); + Object.assign(receiver, { vendorId: 0x3554 }); + + assert.equal(AtkHidClient.isSupported(wired), true); + assert.equal(AtkHidClient.isSupported(receiver), true); + assert.equal(PulsarHidClient.isSupported(wired), false); + assert.equal(PulsarHidClient.isSupported(receiver), false); + assert.ok(createSupportedClient(wired) instanceof AtkHidClient); + assert.ok(createSupportedClient(receiver) instanceof AtkHidClient); + assert.equal(SUPPORTED_HID_FILTERS.some((filter) => + filter.vendorId === 0x3554 && filter.productId === 0xf58f + && filter.usagePage === 0xff02 && filter.usage === 2), true); + assert.equal(SUPPORTED_HID_FILTERS.some((filter) => + filter.vendorId === 0x3554 && filter.productId === 0xf58e + && filter.usagePage === 0xff02 && filter.usage === 2), true); +}); + test("R1 receiver advertises only its stock polling rates", () => { const wlmouseStyle = new AtkHidClient(device(0x1085)); + const compxReceiverDevice = device(0xf58e, "VXE Mouse 1K Dongle"); + Object.assign(compxReceiverDevice, { vendorId: 0x3554 }); + const compxReceiver = new AtkHidClient(compxReceiverDevice); const notR1 = new AtkHidClient(device(0x11d5, "ATK dongle")); assert.deepEqual(wlmouseStyle.getSupportedPollingRates(), [250, 500, 1000]); + assert.deepEqual(compxReceiver.getSupportedPollingRates(), [250, 500, 1000]); assert.deepEqual(notR1.getSupportedPollingRates(), [125, 250, 500, 1000, 2000, 4000, 8000]); }); +test("the COMPX receiver is wireless even without a product string", () => { + const receiver = device(0xf58e, ""); + Object.assign(receiver, { vendorId: 0x3554 }); + assert.equal(new AtkHidClient(receiver).isWireless(), true); +}); + +test("wired R1 SE+ advertises its HUB polling, debounce, and sleep options", () => { + const wired = device(0xf58f, "VXE R1SE+"); + Object.assign(wired, { vendorId: 0x3554 }); + const client = new AtkHidClient(wired); + + assert.deepEqual(client.getSupportedPollingRates(), [125, 250, 500, 1000]); + assert.deepEqual(client.getDebounceOptions(), [0, 1, 2, 4, 8, 15, 20]); + assert.deepEqual(client.getSleepOptions(), [30, 60, 120, 180, 300, 1200, 1500, 1800]); +}); + +test("wired R1 reads the active profile and six lossless button records", async () => { + const fake = device(0xf58f, "VXE R1SE+"); + Object.assign(fake, { vendorId: 0x3554 }); + (fake as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0, [0x02, 0x20]), + reply(0x0e, 0, [0x02]), + reply(0x08, 0x0060, [...button(1, 1), ...button(1, 2)]), + reply(0x08, 0x0068, [...button(1, 4), ...button(1, 8)]), + reply(0x08, 0x0070, [...button(1, 16), ...button(9, 1)]), + ]; + + const stored = await new AtkHidClient(fake).readR1StoredConfiguration(); + assert.equal(stored.activeProfile, 3); + assert.deepEqual(stored.buttons.map(({ id, action, checksumValid }) => ({ id, action, checksumValid })), [ + { id: "left", action: "Left click", checksumValid: true }, + { id: "right", action: "Right click", checksumValid: true }, + { id: "middle", action: "Middle click", checksumValid: true }, + { id: "back", action: "Back", checksumValid: true }, + { id: "forward", action: "Forward", checksumValid: true }, + { id: "bottom", action: "Profile control (0x01, 0x00)", checksumValid: true }, + ]); + assert.deepEqual((fake as unknown as FakeAtkDevice).sent.map(({ data }) => data[0]), [0x10, 0x0e, 0x08, 0x08, 0x08]); +}); + +test("wired R1 switches configuration banks with readback", async () => { + const fake = device(0xf58f, "VXE R1SE+"); + Object.assign(fake, { vendorId: 0x3554 }); + const transport = fake as unknown as FakeAtkDevice; + transport.ignoredCommands.add(0x0f); + transport.replies = [reply(0x10, 0, [2, 32]), reply(0x0e, 0, [2])]; + + assert.equal(await new AtkHidClient(fake).setR1ActiveProfile(3), 3); + const selection = transport.sent.find(({ data }) => data[0] === 0x0f)?.data; + assert.ok(selection); + assert.deepEqual([...selection!.subarray(0, 7)], [0x0f, 0, 0, 0, 1, 2, 0]); + assert.equal(sumFrame(8, selection!), 0x55); +}); + +test("R1 receiver telemetry reads online identity and pairing countdown without entering pairing", async () => { + const fake = device(); + const online = reply(0x03, 0, [1, 0xaa, 0xbb, 0xcc]); + // The Nordic 0xf58e receiver declares only the status byte while retaining + // its fixed-position RF identifier in the following frame bytes. + online[4] = 1; + sealReply(online); + (fake as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0, [0x02, 0x20]), + online, + reply(0x06, 0, [2, 29]), + ]; + + assert.deepEqual(await new AtkHidClient(fake).readR1ReceiverInfo(), { + online: true, + status: 1, + rfId: "CCBBAA", + pairingStatus: 2, + pairingSecondsRemaining: 29, + }); + assert.deepEqual((fake as unknown as FakeAtkDevice).sent.map(({ data }) => data[0]), [0x10, 0x03, 0x06]); + assert.equal((fake as unknown as FakeAtkDevice).sent.some(({ data }) => data[0] === 0x05), false); +}); + +test("R1 receiver pairing sends the verified R1 SE+ identity", async () => { + const fake = device(0xf58e, "VXE Mouse 1K Dongle"); + Object.assign(fake, { vendorId: 0x3554 }); + const transport = fake as unknown as FakeAtkDevice; + transport.replies = [reply(0x10, 0, [0x02, 0x20]), reply(0x05, 0, [])]; + + await new AtkHidClient(fake).startR1ReceiverPairing(0x02, 0x20); + + const request = transport.sent.find(({ data }) => data[0] === 0x05)?.data; + assert.ok(request); + assert.deepEqual([...request!.subarray(0, 8)], [0x05, 0, 0, 0, 2, 0x02, 0x20, 0]); + assert.equal(sumFrame(8, request!), 0x55); +}); + +test("R1 inspection rejects unsuccessful and corrupt command replies", async () => { + const failedProfile = device(0xf58f, "VXE R1SE+"); + Object.assign(failedProfile, { vendorId: 0x3554 }); + const failed = reply(0x0e, 0, [0]); + failed[1] = 1; + sealReply(failed); + (failedProfile as unknown as FakeAtkDevice).replies = [reply(0x10, 0, [2, 32]), failed]; + await assert.rejects(new AtkHidClient(failedProfile).readR1StoredConfiguration(), /did not answer/); + + const corruptReceiver = device(); + const corrupt = reply(0x03, 0, [1, 0xaa, 0xbb, 0xcc]); + corrupt[15] ^= 0xff; + (corruptReceiver as unknown as FakeAtkDevice).replies = [reply(0x10, 0, [2, 32]), corrupt]; + await assert.rejects(new AtkHidClient(corruptReceiver).readR1ReceiverInfo(), /did not answer/); + + const corruptButtons = device(0xf58f, "VXE R1SE+"); + Object.assign(corruptButtons, { vendorId: 0x3554 }); + const corruptButtonGroup = reply(0x08, 0x0060, [...button(1, 1), ...button(1, 2)]); + corruptButtonGroup[15] ^= 0xff; + (corruptButtons as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0, [2, 32]), + reply(0x0e, 0, [0]), + corruptButtonGroup, + ]; + await assert.rejects(new AtkHidClient(corruptButtons).readR1StoredConfiguration(), /did not answer/); + + const otherR1 = device(0xf58f, "VXE R1"); + Object.assign(otherR1, { vendorId: 0x3554 }); + (otherR1 as unknown as FakeAtkDevice).replies = [reply(0x10, 0, [2, 12])]; + await assert.rejects(new AtkHidClient(otherR1).setR1ActiveProfile(2), /verified wired transport/); + assert.equal((otherR1 as unknown as FakeAtkDevice).sent.some(({ data }) => data[0] === 0x0f), false); + + const corruptColor = device(0xf58f, "VXE R1SE+"); + Object.assign(corruptColor, { vendorId: 0x3554 }); + const invalidGroup = reply(0x08, 0x002c, [1, 2, 3, 4, 5, 6, 7, 8]); + invalidGroup[15] ^= 0xff; + (corruptColor as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0, [2, 32]), + reply(0x08, 0, [1, 0x54, 2, 0x53, 0, 0x55]), + invalidGroup, + ]; + await assert.rejects(new AtkHidClient(corruptColor).setDpiStageColor(0, "#112233"), /did not answer/); + assert.equal((corruptColor as unknown as FakeAtkDevice).sent.some(({ data }) => data[0] === 0x07), false); +}); + test("R1 setPollingRate writes the 0x0070 live-settings row", async () => { const fake = device(0x1085); - (fake as unknown as FakeAtkDevice).replies = [readReply([0x0b, 0x01, 0x00, 0x54])]; + (fake as unknown as FakeAtkDevice).replies = [readReply([0x0b, 0x02, 0x00, 0x53])]; const client = new AtkHidClient(fake); - assert.equal(await client.setPollingRate(1000), 1000); + assert.equal(await client.setPollingRate(500), 500); const writes = (fake as unknown as FakeAtkDevice).sent.filter(({ reportId, data }) => reportId === 8 && data[0] === 0x07); @@ -152,9 +338,9 @@ test("R1 setPollingRate writes the 0x0070 live-settings row", async () => { assert.equal(write[3], 0x70); assert.equal(write[4], 0x04); assert.equal(write[5], 0x0b, "polling selector"); - assert.equal(write[6], 0x01, "1000 Hz code"); + assert.equal(write[6], 0x02, "500 Hz code"); assert.equal(write[7], 0x00); - assert.equal(write[8], 0x54, "0x55 - 0x01"); + assert.equal(write[8], 0x53, "0x55 - 0x02"); assert.equal(sumFrame(8, write), 0x55, "frame checksum"); }); @@ -211,31 +397,272 @@ test("R1 setAngleSnapping writes the 0x0070 angle live-settings row", async () = assertR1LiveSettingsWrite(off.device, 0x01, 0x00); }); +test("wired R1 writes 125 Hz through the system EEPROM and confirms it", async () => { + const fake = device(0xf58f, "VXE R1SE+"); + Object.assign(fake, { vendorId: 0x3554 }); + (fake as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x0000, [0x08, 0x4d, 0x02, 0x53, 0x00, 0x55]), + ]; + + assert.equal(await new AtkHidClient(fake).setPollingRate(125), 125); + assert.deepEqual(Array.from(wrote(fake).subarray(2, 7)), [0x00, 0x00, 0x02, 0x08, 0x4d]); +}); + +test("wired R1 persists zero debounce through the advanced EEPROM", async () => { + const fake = device(0xf58f, "VXE R1SE+"); + Object.assign(fake, { vendorId: 0x3554 }); + const advanced = [0x04, 0x51, 0x00, 0x55, 0x06, 0x4f, 0x00, 0x55, 0x00, 0x55]; + (fake as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x00a9, advanced), + reply(0x08, 0x00a9, [0x00, 0x55, ...advanced.slice(2)]), + ]; + + assert.equal(await new AtkHidClient(fake).setDebounceTime(0), 0); + assert.deepEqual(Array.from(wrote(fake).subarray(2, 15)), [0x00, 0xa9, 0x0a, 0x00, 0x55, ...advanced.slice(2)]); +}); + +test("wired R1 persists LOD and straight-line correction through EEPROM", async () => { + const lod = device(0xf58f, "VXE R1SE+"); + Object.assign(lod, { vendorId: 0x3554 }); + (lod as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x000a, [0x02, 0x53]), + ]; + assert.equal(await new AtkHidClient(lod).setLiftOffDistance("High"), "High"); + assert.deepEqual(Array.from(wrote(lod).subarray(2, 7)), [0x00, 0x0a, 0x02, 0x02, 0x53]); + + const line = device(0xf58f, "VXE R1SE+"); + Object.assign(line, { vendorId: 0x3554 }); + const advanced = [0x00, 0x55, 0x00, 0x55, 0x06, 0x4f, 0x00, 0x55, 0x00, 0x55]; + (line as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x00a9, advanced), + reply(0x08, 0x00a9, [...advanced.slice(0, 6), 0x01, 0x54, ...advanced.slice(8)]), + ]; + assert.equal(await new AtkHidClient(line).setAngleSnapping(true), true); + assert.deepEqual(Array.from(wrote(line).subarray(5, 15)), [...advanced.slice(0, 6), 0x01, 0x54, ...advanced.slice(8)]); + + const motion = device(0xf58f, "VXE R1SE+"); + Object.assign(motion, { vendorId: 0x3554 }); + (motion as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x00a9, advanced), + reply(0x08, 0x00a9, [...advanced.slice(0, 2), 0x01, 0x54, ...advanced.slice(4)]), + ]; + assert.equal(await new AtkHidClient(motion).setMotionSync(true), true); + assert.deepEqual(Array.from(wrote(motion).subarray(5, 15)), [...advanced.slice(0, 2), 0x01, 0x54, ...advanced.slice(4)]); +}); + +test("wired R1 edits the active stage and an arbitrary stage value", async () => { + const activeDevice = device(0xf58f, "VXE R1SE+"); + Object.assign(activeDevice, { vendorId: 0x3554 }); + (activeDevice as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x0000, [0x01, 0x54, 0x08, 0x4d, 0x00, 0x55]), + reply(0x08, 0x0004, [0x07, 0x4e]), + reply(0x08, 0x0028, [0x25, 0x25, 0x00, 0x0b]), + ]; + assert.equal(await new AtkHidClient(activeDevice).setActiveDpiStage(7), 7); + assert.deepEqual(Array.from(wrote(activeDevice).subarray(2, 7)), [0x00, 0x04, 0x02, 0x07, 0x4e]); + + const valueDevice = device(0xf58f, "VXE R1SE+"); + Object.assign(valueDevice, { vendorId: 0x3554 }); + (valueDevice as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x0000, [0x01, 0x54, 0x08, 0x4d, 0x00, 0x55]), + reply(0x08, 0x0028, [0x25, 0x25, 0x00, 0x0b]), + ]; + assert.equal(await new AtkHidClient(valueDevice).setDpiStageValue(7, 1600), 1600); + assert.deepEqual(Array.from(wrote(valueDevice).subarray(2, 9)), [0x00, 0x28, 0x04, 0x25, 0x25, 0x00, 0x0b]); +}); + +test("wired R1 writes performance, long-range, stage color, and DPI lighting", async () => { + const performance = device(0xf58f, "VXE R1SE+"); + Object.assign(performance, { vendorId: 0x3554 }); + (performance as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x00b5, [0x01, 0x54, 0x0c, 0x49, 0x01, 0x54]), + reply(0x08, 0x00b5, [0x01, 0x54, 0x0c, 0x49, 0x00, 0x55]), + ]; + assert.equal(await new AtkHidClient(performance).setPerformanceMode(false), false); + + const longRange = device(0xf58f, "VXE R1SE+"); + Object.assign(longRange, { vendorId: 0x3554 }); + (longRange as unknown as FakeAtkDevice).ignoredCommands.add(0x16); + (longRange as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x17, 0x0000, [0x01]), + ]; + assert.equal(await new AtkHidClient(longRange).setLongRangeMode(true), true); + assert.equal((longRange as unknown as FakeAtkDevice).sent.some(({ data }) => data[0] === 0x16 && data[5] === 1), true); + + const color = device(0xf58f, "VXE R1SE+"); + Object.assign(color, { vendorId: 0x3554 }); + (color as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x0000, [0x01, 0x54, 0x02, 0x53, 0x00, 0x55]), + reply(0x08, 0x002c, [0x04, 0x00, 0xff, 0x52, 0xff, 0x00, 0x00, 0x56]), + reply(0x08, 0x002c, [0x01, 0x02, 0x03, 0x4f, 0xff, 0x00, 0x00, 0x56]), + ]; + assert.equal(await new AtkHidClient(color).setDpiStageColor(0, "#010203"), "#010203"); + + const lighting = device(0xf58f, "VXE R1SE+"); + Object.assign(lighting, { vendorId: 0x3554 }); + const expectedLighting = [0x02, 0x53, 0xff, 0x56, 0x03, 0x52, 0x01, 0x54]; + (lighting as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x004c, [0x01, 0x54, 0x80, 0xd5, 0x05, 0x50, 0x00, 0x55]), + reply(0x08, 0x004c, expectedLighting), + ]; + await new AtkHidClient(lighting).setDpiLighting(2, 2, 1); + assert.deepEqual(Array.from(wrote(lighting).subarray(5, 13)), expectedLighting); +}); + +test("wired R1 accepts its exact sleep options before identity is cached", async () => { + const fake = device(0xf58f, "VXE R1SE+"); + Object.assign(fake, { vendorId: 0x3554 }); + const advanced = [0x00, 0x55, 0x00, 0x55, 0x06, 0x4f, 0x00, 0x55, 0x00, 0x55]; + (fake as unknown as FakeAtkDevice).replies = [ + reply(0x10, 0x0000, [0x02, 0x20]), + reply(0x08, 0x00a9, advanced), + reply(0x08, 0x00a9, [...advanced.slice(0, 4), 0x12, 0x43, ...advanced.slice(6)]), + ]; + assert.equal(await new AtkHidClient(fake).setSleepTimeout(180), 180); + assert.deepEqual(Array.from(wrote(fake).subarray(9, 11)), [0x12, 0x43]); +}); + test("NON-R1 debounce ceiling still applies on the A9 family", () => { assert.equal(new AtkHidClient(device(0x1085)).getDebounceMaxMs(), 20); assert.equal(new AtkHidClient(device(0x11d5, "ATK dongle")).getDebounceMaxMs(), 15); }); -test("R1 readStatus hides the unsupported medium lift-off level", async () => { - const fake = device(0x1085); +test("wired R1 SE+ status uses identity, PAW3395SE, and all configured DPI stages", async () => { + const fake = device(0xf58f, "VXE R1SE+"); + Object.assign(fake, { vendorId: 0x3554 }); (fake as unknown as FakeAtkDevice).replies = [ - 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(0x08, 0x0010, [0x25, 0x25, 0x00, 0x0b]), + reply(0x12, 0x0000, [0x03, 0x15]), + reply(0x08, 0x000a, [0x01, 0x54]), + reply(0x08, 0x00a9, [0x00, 0x55, 0x00, 0x55, 0x06, 0x4f, 0x00, 0x55, 0x00, 0x55]), + reply(0x08, 0x00bd, [0x00, 0x55, 0x00, 0x55]), + reply(0x08, 0x00b5, [0x01, 0x54, 0x0c, 0x49, 0x01, 0x54]), + reply(0x08, 0x004c, [0x01, 0x54, 0x80, 0xd5, 0x05, 0x50, 0x00, 0x55]), + reply(0x08, 0x002c, [0x04, 0x00, 0xff, 0x52, 0xff, 0x00, 0x00, 0x56]), + reply(0x17, 0x0000, [0x00]), + reply(0x0e, 0x0000, [0x00]), + reply(0x08, 0x0060, [...button(1, 1), ...button(1, 2)]), + reply(0x08, 0x0068, [...button(1, 4), ...button(1, 8)]), + reply(0x08, 0x0070, [...button(1, 16), ...button(2, 1)]), ]; const client = new AtkHidClient(fake); 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.deepEqual(status.dpiStages, [800, 1600]); + assert.equal(status.activeDpiStage, 0); + assert.equal(status.activeProfile, 1); + assert.equal(status.atkProfileCount, 4); + assert.equal(status.atkButtonMappings?.length, 6); + assert.equal(status.ui?.dpiStageEditor?.maxStages, 8); + assert.equal(status.batteryPercent, 95); + assert.equal(status.batteryState, "Charging"); + assert.equal(status.batteryVoltageMv, null); + 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); + assert.equal(status.performanceMode, true); + assert.equal(status.longRangeMode, false); + assert.deepEqual(status.dpiStageColors, ["#0400ff", "#ff0000"]); + assert.equal(status.dpiLedMode, 0); + assert.equal(status.dpiLedBrightness, 1); + assert.equal(status.dpiLedSpeed, 2); +}); + +test("R1 receiver readStatus fails before fallback decoding when CID/MID times out", async () => { + 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..87d83eb 100644 --- a/src/drivers/atk/hid.ts +++ b/src/drivers/atk/hid.ts @@ -4,38 +4,62 @@ import { WE_REPORT_ID, weBuildCmdPayload, wePackScalarPair, + weReportChecksum, + weUnpackScalarPair, } from "@openmouse/protocol/endgame-gear-we"; import type { MouseStatus } from "../mouse-types.ts"; import { VENDOR_ID } from "../vendors.ts"; import { + ATK_COMPX_COMMAND, + ATK_R1_BUTTONS, + ATK_R1_PROFILE_COUNT, ATK_VXE_R1_ANGLE_SELECTOR, ATK_VXE_R1_DEBOUNCE_SELECTOR, ATK_VXE_R1_LOD_SELECTOR, ATK_VXE_R1_POLLING_RATES, ATK_VXE_R1_SETTINGS_REGISTER, + ATK_SENSORS, + atkBuildReceiverPairRequest, + atkBuildSetCurrentProfile, atkDecodeLiftOff, + atkDecodeButtonAssignment, + atkDecodeCurrentProfile, + atkDecodePairingStatus, + atkDecodeReceiverStatus, atkDecodeVxeR1PollingCode, + atkDpiOptionsForSensor, atkPackDpiStage, + 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 SET_LONG_RANGE_COMMAND = 0x16; +const GET_LONG_RANGE_COMMAND = 0x17; const FRAME_LENGTH = 16; const DATA_OFFSET = 5; const MAX_DATA_LENGTH = 10; const REPLY_TIMEOUT_MS = 500; const WRITE_SETTLE_MS = 10; +const R1_LIVE_WRITE_SETTLE_MS = 250; +const R1_PROFILE_SWITCH_SETTLE_MS = 100; +const MAX_IDENTIFY_ATTEMPTS = 3; // The VXE R1 SE/SE+ ships its "Wireless mouse -1k dongle" under 0x373b:0x1085 // (Beken MCU). It shares the A9 EEPROM map for DPI/advanced/lod, but the poll // rate lives in the live-settings row; see the codec for the full story. const VXE_R1_RECEIVER_PID = 0x1085; +const VXE_R1_COMPX_RECEIVER_PID = 0xf58e; +const VXE_R1_COMPX_MOUSE_PID = 0xf58f; const R1_SETTINGS_LENGTH = 4; // Byte addresses in the mouse's configuration EEPROM. @@ -45,10 +69,13 @@ const REGISTER = { liftOffDistance: 0x000a, // Four bytes per DPI stage. dpiBase: 0x000c, + dpiColorBase: 0x002c, + dpiLighting: 0x004c, // 0x00a9: debounce, motion sync, sleep timer, linear correction, ripple control. advanced: 0x00a9, // 0x00bd: angle tuning degrees, then the angle-snapping enable flag. angle: 0x00bd, + sensorPerformance: 0x00b5, } as const; const SYSTEM_LENGTH = 6; @@ -56,6 +83,7 @@ const ADVANCED_LENGTH = 10; const ANGLE_LENGTH = 4; const DPI_STAGE_LENGTH = 4; const MAX_DPI_STAGES = 6; +const R1_MAX_DPI_STAGES = 8; const DPI_MIN = 50; const DPI_MAX = 42000; @@ -63,8 +91,14 @@ const DEBOUNCE_MAX_MS = 15; // The R1 accepts 1-20 ms in its live-settings debounce entry (per OpenVXE). const R1_DEBOUNCE_MAX_MS = 20; const SLEEP_STEP_SECONDS = 10; -const SLEEP_MAX_SECONDS = 0xff * SLEEP_STEP_SECONDS; const SLEEP_SECONDS: readonly number[] = [30, 60, 120, 300, 600, 1800]; +const R1_SLEEP_SECONDS: readonly number[] = [30, 60, 120, 180, 300, 1200, 1500, 1800]; +const R1_DEBOUNCE_MILLISECONDS: readonly number[] = [0, 1, 2, 4, 8, 15, 20]; +const R1_SENSOR_PERFORMANCE_LENGTH = 6; +const R1_DPI_LIGHTING_LENGTH = 8; +const R1_DPI_COLOR_GROUP_LENGTH = 8; +const R1_DPI_BRIGHTNESS = [0x10, 0x80, 0xff] as const; +const R1_DPI_SPEED = [1, 3, 5] as const; const POLLING_RATES: ReadonlyArray = [ [0x08, 125], @@ -99,6 +133,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 +145,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 +156,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,40 +168,59 @@ 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 * mouse's own "ATK A9 PLUS 2.0 NK". */ isWireless(): boolean { - return /receiver|dongle/i.test(this.device.productName || ""); + return this.device.productId === VXE_R1_RECEIVER_PID + || (this.device.vendorId === VENDOR_ID.vgn && this.device.productId === VXE_R1_COMPX_RECEIVER_PID) + || /receiver|dongle/i.test(this.device.productName || ""); } /** VXE R1 SE/SE+ on its stock 1K receiver (Beken MCU, per OpenVXE). */ 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[] { - return SLEEP_SECONDS; + return this.isR1() && !this.usesR1LiveSettings() ? R1_SLEEP_SECONDS : SLEEP_SECONDS; } getDebounceMaxMs(): number { return this.isR1() ? R1_DEBOUNCE_MAX_MS : DEBOUNCE_MAX_MS; } + getDebounceOptions(): readonly number[] { + return this.isR1() && !this.usesR1LiveSettings() + ? R1_DEBOUNCE_MILLISECONDS + : Array.from( + { length: this.getDebounceMaxMs() + (this.usesR1LiveSettings() ? 0 : 1) }, + (_, index) => index + (this.usesR1LiveSettings() ? 1 : 0), + ); + } + getSupportedPollingRates(): number[] { - return this.isR1() + return this.usesR1LiveSettings() ? [...ATK_VXE_R1_POLLING_RATES] + : this.isR1() + ? POLLING_RATES.map(([, hertz]) => hertz).filter((hertz) => hertz <= 1000) : POLLING_RATES.map(([, hertz]) => hertz); } @@ -168,6 +229,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,57 +239,195 @@ 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)); + const stageCount = this.stageCount(system); + const activeDpiStage = this.stageIndex(system); + const dpiStages: Array<{ x: number; y: number }> = []; + for (let index = 0; index < stageCount; index += 1) dpiStages.push(await this.readDpiStage(index)); + const stage = dpiStages[activeDpiStage]!; if (live && this.lastStatus) { return this.lastStatus = { ...this.lastStatus, - batteryPercent: battery, + batteryPercent: battery?.percent ?? null, + batteryState: batteryState(battery), + batteryVoltageMv: battery?.millivolts ?? null, pollingRateHz: await this.readPollingRate(system), dpi: stage.x, dpiY: stage.y, + dpiStages: dpiStages.map(({ x }) => x), + activeDpiStage, }; } const firmware = await this.readFirmware(); const liftOffDistance = await this.read(REGISTER.liftOffDistance, 2); const advanced = await this.read(REGISTER.advanced, ADVANCED_LENGTH); const angle = await this.read(REGISTER.angle, ANGLE_LENGTH).catch(() => null); + const r1Extras = this.usesVerifiedR1WiredTransport() ? await this.readR1Extras(stageCount) : null; + const stored = this.usesVerifiedR1WiredTransport() + ? await this.readR1StoredConfiguration().catch(() => null) + : null; + const receiver = this.usesR1LiveSettings() + ? await this.readR1ReceiverInfo().catch(() => null) + : null; + const angleTuning = angle ? weUnpackScalarPair(angle[0], angle[1]) : null; + const angleSnapping = angle ? weUnpackScalarPair(angle[2], angle[3]) : null; return this.lastStatus = { - brand: "ATK", + brand: this.deviceBrand(), name: this.displayName(), ui: { family: "atk", hideUnsupportedPollingRates: true, forceShowBattery: battery !== null, + dpiStageEditor: this.usesVerifiedR1WiredTransport() ? { + maxStages: R1_MAX_DPI_STAGES, + countEditable: false, + minDpi: ATK_SENSORS.PAW3395SE.minDpi, + maxDpi: ATK_SENSORS.PAW3395SE.maxDpi, + stepDpi: 50, + } : undefined, + dpiLighting: r1Extras ? { + modes: [0, 1, 2], + brightness: [0, 1, 2], + speed: [0, 1, 2], + } : undefined, }, - batteryPercent: battery, - batteryState: "Unknown", + batteryPercent: battery?.percent ?? null, + batteryState: batteryState(battery), + batteryVoltageMv: battery?.millivolts ?? null, dpi: stage.x, dpiY: stage.y, + dpiStages: dpiStages.map(({ x }) => x), + dpiStageColors: r1Extras?.dpiStageColors, + activeDpiStage, supportsSeparateDpiAxes: false, pollingRateHz: await this.readPollingRate(system), supportedPollingRates: this.getSupportedPollingRates(), - activeProfile: null, + activeProfile: stored?.activeProfile ?? null, + atkProfileCount: stored ? ATK_R1_PROFILE_COUNT : undefined, + atkButtonMappings: stored?.buttons, + atkReceiver: receiver ?? undefined, connectionType: this.isWireless() ? "Wireless" : "Wired", connectionDetail: this.isWireless() ? "2.4 GHz receiver" : "Wired USB", debounceMs: advanced[0], motionSync: advanced[2] === 1, sleepTimeout: advanced[4] * SLEEP_STEP_SECONDS || null, rippleControl: advanced[8] === 1, - angleSnapping: angle ? angle[2] === 1 : null, - angleTuning: angle ? this.decodeAngle(angle[0]) : null, + performanceMode: r1Extras?.performanceMode, + longRangeMode: r1Extras?.longRangeMode, + dpiLedMode: r1Extras?.dpiLedMode, + dpiLedBrightness: r1Extras?.dpiLedBrightness, + dpiLedSpeed: r1Extras?.dpiLedSpeed, + angleSnapping: this.isR1() + ? advanced[6] === 1 + : angleSnapping === null ? null : angleSnapping === 1, + angleTuning: angleTuning === null ? null : this.decodeAngle(angleTuning), liftOffDistance: this.decodeLiftOffDistance(liftOffDistance[0]), supportedLiftOffDistances: this.isR1() ? ["Low", "High"] : undefined, firmware, }; } + /** Read the active R1 profile and its six button records without changing either. */ + async readR1StoredConfiguration(): Promise<{ + activeProfile: number; + buttons: NonNullable; + }> { + await this.identify(); + if (!this.usesVerifiedR1WiredTransport()) { + throw new Error("Stored R1 configuration inspection is available only over the verified wired transport."); + } + const activeProfile = await this.readR1CurrentProfile(); + + const buttons: NonNullable = []; + for (let group = 0; group < ATK_R1_BUTTONS.length; group += 2) { + const first = ATK_R1_BUTTONS[group]!; + const data = await this.read(first.address, 8); + for (let offset = 0; offset < 2; offset += 1) { + const button = ATK_R1_BUTTONS[group + offset]; + if (!button) continue; + const assignment = atkDecodeButtonAssignment(data.subarray(offset * 4, offset * 4 + 4)); + if (!assignment) throw new Error(`The ${button.label.toLowerCase()} assignment is truncated.`); + buttons.push({ + id: button.id, + name: button.label, + address: button.address, + keyClass: assignment.keyClass, + value1: assignment.value1, + value2: assignment.value2, + checksumValid: assignment.checksumValid, + action: assignment.label, + raw: assignment.raw, + }); + } + } + return { activeProfile: activeProfile + 1, buttons }; + } + + /** Select one of the four verified wired R1 banks and require readback. */ + async setR1ActiveProfile(profile: number): Promise { + await this.identify(); + if (!this.usesVerifiedR1WiredTransport()) { + throw new Error("R1 profile switching is available only over the verified wired transport."); + } + if (!Number.isInteger(profile) || profile < 1 || profile > ATK_R1_PROFILE_COUNT) { + throw new Error(`R1 profile must be between 1 and ${ATK_R1_PROFILE_COUNT}.`); + } + await this.send(atkBuildSetCurrentProfile(profile - 1)); + await delay(R1_PROFILE_SWITCH_SETTLE_MS); + const confirmed = (await this.readR1CurrentProfile()) + 1; + if (confirmed !== profile) throw new Error(`The mouse kept profile ${confirmed} instead of ${profile}.`); + this.lastStatus = null; + return confirmed; + } + + /** Read receiver online/RF identity and pairing countdown; this never starts pairing. */ + async readR1ReceiverInfo(): Promise> { + await this.identify(); + if (!this.isR1() || !this.usesR1LiveSettings()) { + throw new Error("R1 receiver telemetry is available only through the receiver transport."); + } + const onlineReply = await this.exchange( + weBuildCmdPayload(ATK_COMPX_COMMAND.getWirelessMouseOnline), + (frame) => frame[0] === ATK_COMPX_COMMAND.getWirelessMouseOnline + && frame[1] === 0 && frame[4] >= 1 && this.hasValidChecksum(frame), + ); + const online = atkDecodeReceiverStatus(onlineReply.subarray(DATA_OFFSET)); + if (!online) throw new Error("The receiver returned a truncated online-status reply."); + const pairingReply = await this.exchange( + weBuildCmdPayload(ATK_COMPX_COMMAND.getWirelessDonglePairResult), + (frame) => frame[0] === ATK_COMPX_COMMAND.getWirelessDonglePairResult + && frame[1] === 0 && frame[4] >= 2 && this.hasValidChecksum(frame), + ).catch(() => null); + const pairing = pairingReply ? atkDecodePairingStatus(pairingReply.subarray(DATA_OFFSET)) : null; + return { + ...online, + pairingStatus: pairing?.status ?? null, + pairingSecondsRemaining: pairing?.secondsRemaining ?? null, + }; + } + + /** Start receiver pairing for one exact CID/MID; callers must poll telemetry for completion. */ + async startR1ReceiverPairing(cid: number, mid: number): Promise { + await this.identify(); + if (this.device.vendorId !== VENDOR_ID.vgn || this.device.productId !== VXE_R1_COMPX_RECEIVER_PID) { + throw new Error("R1 receiver pairing is available only through the verified VXE Mouse 1K Dongle."); + } + await this.exchange( + atkBuildReceiverPairRequest(cid, mid), + (frame) => frame[0] === ATK_COMPX_COMMAND.setWirelessDonglePair + && frame[1] === 0 && this.hasValidChecksum(frame), + ); + } + async setPollingRate(pollingRateHz: number): Promise { - if (this.isR1()) return await this.setR1PollingRate(pollingRateHz); + if (!this.usesR1LiveSettings()) await this.identify(); + if (this.usesR1LiveSettings()) return await this.setR1PollingRate(pollingRateHz); const encoded = POLLING_RATES.find(([, hertz]) => hertz === pollingRateHz); if (!encoded) throw new Error(`This mouse does not support ${pollingRateHz} Hz.`); await this.write(REGISTER.system, wePackScalarPair(encoded[0])); + if (this.isR1()) await delay(R1_LIVE_WRITE_SETTLE_MS); const confirmed = this.decodePollingRate((await this.read(REGISTER.system, SYSTEM_LENGTH))[0]); if (confirmed !== pollingRateHz) { throw new Error(`The mouse kept ${confirmed} Hz instead of ${pollingRateHz} Hz.`); @@ -237,13 +437,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()}.`); @@ -252,9 +457,128 @@ export class AtkHidClient { return confirmed.x; } + async setActiveDpiStage(index: number): Promise { + await this.identify(); + if (this.isR1() && !this.usesVerifiedR1WiredTransport()) { + throw new Error("R1 DPI stage selection is available only over the verified R1 SE+ wired transport."); + } + const system = await this.read(REGISTER.system, SYSTEM_LENGTH); + const count = this.stageCount(system); + if (!Number.isInteger(index) || index < 0 || index >= count) { + throw new Error(`DPI stage must be between 1 and ${count}.`); + } + await this.write(REGISTER.system + 4, wePackScalarPair(index)); + const pair = await this.read(REGISTER.system + 4, 2); + const confirmed = weUnpackScalarPair(pair[0]!, pair[1]!); + if (confirmed !== index) throw new Error(`The mouse kept DPI stage ${(confirmed ?? 0) + 1} instead of ${index + 1}.`); + const stage = await this.readDpiStage(index); + this.patch({ activeDpiStage: confirmed, dpi: stage.x, dpiY: stage.y }); + return confirmed; + } + + async setDpiStageValue(index: number, dpi: number): Promise { + await this.identify(); + if (this.isR1() && !this.usesVerifiedR1WiredTransport()) { + throw new Error("R1 DPI stage editing is available only over the verified R1 SE+ wired transport."); + } + const system = await this.read(REGISTER.system, SYSTEM_LENGTH); + const count = this.stageCount(system); + if (!Number.isInteger(index) || index < 0 || index >= count) { + throw new Error(`DPI stage must be between 1 and ${count}.`); + } + const sensor = this.product?.sensor ?? null; + const options = sensor ? atkDpiOptionsForSensor(sensor) : this.getDpiOptions(); + if (!Number.isInteger(dpi) || !options.includes(dpi)) { + throw new Error(`${dpi.toLocaleString()} is not a supported DPI value.`); + } + const packed = sensor ? atkPackDpiStageForSensor(sensor, dpi, dpi) : atkPackDpiStage(dpi, dpi); + if (!packed) throw new Error(`${dpi.toLocaleString()} DPI is not representable by this sensor.`); + await this.write(this.dpiAddress(index), packed); + const confirmed = await this.readDpiStage(index); + if (confirmed.x !== dpi || confirmed.y !== dpi) { + throw new Error(`The mouse kept ${confirmed.x.toLocaleString()} DPI instead of ${dpi.toLocaleString()}.`); + } + const dpiStages = this.lastStatus?.dpiStages?.slice() ?? []; + while (dpiStages.length < count) dpiStages.push(dpiStages.at(-1) ?? dpi); + dpiStages[index] = confirmed.x; + const active = this.lastStatus?.activeDpiStage ?? this.stageIndex(system); + this.patch({ + dpiStages, + ...(active === index ? { dpi: confirmed.x, dpiY: confirmed.y } : {}), + }); + return confirmed.x; + } + + async setDpiStageColor(index: number, color: string): Promise { + await this.identify(); + if (!this.usesVerifiedR1WiredTransport()) { + throw new Error("DPI stage colors are not available on this connection."); + } + const rgb = parseHexColor(color); + const count = this.stageCount(await this.read(REGISTER.system, SYSTEM_LENGTH)); + if (!rgb || !Number.isInteger(index) || index < 0 || index >= count) { + throw new Error("The DPI stage or colour is invalid."); + } + const groupAddress = REGISTER.dpiColorBase + Math.floor(index / 2) * R1_DPI_COLOR_GROUP_LENGTH; + const group = Array.from(await this.read(groupAddress, R1_DPI_COLOR_GROUP_LENGTH)); + group.splice((index % 2) * 4, 4, ...packRgb(rgb)); + await this.write(groupAddress, group); + const confirmedGroup = await this.read(groupAddress, R1_DPI_COLOR_GROUP_LENGTH); + const confirmed = unpackRgb(confirmedGroup.subarray((index % 2) * 4, (index % 2 + 1) * 4)); + if (confirmed !== color.toLowerCase()) throw new Error(`The mouse kept ${confirmed ?? "an invalid colour"} instead of ${color}.`); + const dpiStageColors = this.lastStatus?.dpiStageColors?.slice() ?? []; + while (dpiStageColors.length < count) dpiStageColors.push("#000000"); + dpiStageColors[index] = confirmed; + this.patch({ dpiStageColors }); + return confirmed; + } + + async setPerformanceMode(enabled: boolean): Promise { + const confirmed = await this.writeR1PerformanceBlock((block) => { + block.splice(4, 2, ...wePackScalarPair(enabled ? 1 : 0)); + }); + const value = weUnpackScalarPair(confirmed[4]!, confirmed[5]!) === 1; + if (value !== enabled) throw new Error(`The mouse left performance mode ${value ? "on" : "off"}.`); + this.patch({ performanceMode: value }); + return value; + } + + async setLongRangeMode(enabled: boolean): Promise { + await this.identify(); + if (!this.usesVerifiedR1WiredTransport()) throw new Error("Long-range mode is not available on this connection."); + await this.send(weBuildCmdPayload(SET_LONG_RANGE_COMMAND, [0, 0, 0, 10, enabled ? 1 : 0])); + await delay(WRITE_SETTLE_MS); + const confirmed = await this.readLongRangeMode(); + if (confirmed !== enabled) throw new Error(`The mouse left long-range mode ${confirmed ? "on" : "off"}.`); + this.patch({ longRangeMode: confirmed }); + return confirmed; + } + + async setDpiLighting(mode: number, brightness: number, speed: number): Promise { + await this.identify(); + if (!this.usesVerifiedR1WiredTransport()) throw new Error("DPI lighting is not available on this connection."); + if (![0, 1, 2].includes(mode) || ![0, 1, 2].includes(brightness) || ![0, 1, 2].includes(speed)) { + throw new Error("The DPI lighting setting is invalid."); + } + const block = Array.from(await this.read(REGISTER.dpiLighting, R1_DPI_LIGHTING_LENGTH)); + const effect = mode === 2 ? 2 : 1; + block.splice(0, 2, ...wePackScalarPair(effect)); + block.splice(2, 2, ...wePackScalarPair(R1_DPI_BRIGHTNESS[brightness]!)); + block.splice(4, 2, ...wePackScalarPair(R1_DPI_SPEED[speed]!)); + block.splice(6, 2, ...wePackScalarPair(mode === 0 ? 0 : 1)); + await this.write(REGISTER.dpiLighting, block); + const confirmed = this.decodeR1DpiLighting(await this.read(REGISTER.dpiLighting, R1_DPI_LIGHTING_LENGTH)); + if (!confirmed || confirmed.dpiLedMode !== mode + || confirmed.dpiLedBrightness !== brightness || confirmed.dpiLedSpeed !== speed) { + throw new Error("The mouse did not retain its DPI lighting settings."); + } + this.patch(confirmed); + } + async setLiftOffDistance(value: LiftOffDistance): Promise { - if (this.isR1()) return await this.setR1LiftOffDistance(value); - const encoded = LIFT_OFF_CODES.find(([, name]) => name === value); + if (!this.usesR1LiveSettings()) await this.identify(); + if (this.usesR1LiveSettings()) return await this.setR1LiftOffDistance(value); + const encoded = (this.isR1() ? R1_LIFT_OFF_CODES : LIFT_OFF_CODES).find(([, name]) => name === value); if (!encoded) throw new Error(`This mouse does not support a ${value.toLowerCase()} lift-off distance.`); await this.write(REGISTER.liftOffDistance, wePackScalarPair(encoded[0])); const confirmed = this.decodeLiftOffDistance((await this.read(REGISTER.liftOffDistance, 2))[0]); @@ -266,15 +590,19 @@ export class AtkHidClient { } async setMotionSync(enabled: boolean): Promise { + await this.identify(); return await this.setAdvancedFlag(2, enabled, "motionSync", "Motion Sync"); } async setRippleControl(enabled: boolean): Promise { + await this.identify(); return await this.setAdvancedFlag(8, enabled, "rippleControl", "ripple control"); } async setAngleSnapping(enabled: boolean): Promise { - if (this.isR1()) return await this.setR1AngleSnapping(enabled); + if (!this.usesR1LiveSettings()) await this.identify(); + if (this.usesR1LiveSettings()) return await this.setR1AngleSnapping(enabled); + if (this.isR1()) return await this.setAdvancedFlag(6, enabled, "angleSnapping", "straight-line correction"); const group = await this.read(REGISTER.angle, ANGLE_LENGTH); await this.write(REGISTER.angle, [group[0], enabled ? 1 : 0].flatMap((value) => wePackScalarPair(value))); const confirmed = (await this.read(REGISTER.angle, ANGLE_LENGTH))[2] === 1; @@ -284,9 +612,11 @@ export class AtkHidClient { } async setDebounceTime(milliseconds: number): Promise { - if (this.isR1()) return await this.setR1DebounceTime(milliseconds); - if (!Number.isInteger(milliseconds) || milliseconds < 0 || milliseconds > DEBOUNCE_MAX_MS) { - throw new Error(`Debounce must be a whole number of milliseconds between 0 and ${DEBOUNCE_MAX_MS}.`); + if (!this.usesR1LiveSettings()) await this.identify(); + if (this.usesR1LiveSettings()) return await this.setR1DebounceTime(milliseconds); + const options = this.getDebounceOptions(); + if (!Number.isInteger(milliseconds) || !options.includes(milliseconds)) { + throw new Error(`This mouse does not support ${milliseconds} ms debounce.`); } const confirmed = await this.writeAdvanced(0, milliseconds); if (confirmed !== milliseconds) { @@ -297,8 +627,13 @@ export class AtkHidClient { } async setSleepTimeout(seconds: number): Promise { - if (!Number.isInteger(seconds) || seconds < SLEEP_STEP_SECONDS || seconds > SLEEP_MAX_SECONDS) { - throw new Error(`The sleep timeout must be between ${SLEEP_STEP_SECONDS} and ${SLEEP_MAX_SECONDS} seconds.`); + if (!this.usesR1LiveSettings()) await this.identify(); + const valid = this.isR1() + ? this.getSleepOptions().includes(seconds) + : Number.isInteger(seconds) && seconds >= SLEEP_STEP_SECONDS + && seconds <= 0xff * SLEEP_STEP_SECONDS && seconds % SLEEP_STEP_SECONDS === 0; + if (!valid) { + throw new Error(`This mouse does not support a ${seconds} second sleep timeout.`); } const units = Math.round(seconds / SLEEP_STEP_SECONDS); const confirmed = await this.writeAdvanced(4, units) * SLEEP_STEP_SECONDS; @@ -312,7 +647,7 @@ export class AtkHidClient { private async setAdvancedFlag( offset: number, enabled: boolean, - field: "motionSync" | "rippleControl", + field: "motionSync" | "rippleControl" | "angleSnapping", label: string, ): Promise { const confirmed = await this.writeAdvanced(offset, enabled ? 1 : 0) === 1; @@ -329,21 +664,108 @@ export class AtkHidClient { return (await this.read(REGISTER.advanced, ADVANCED_LENGTH))[offset]; } + private async readR1Extras(stageCount: number): Promise<{ + dpiStageColors: string[]; + performanceMode: boolean; + longRangeMode: boolean; + dpiLedMode: number; + dpiLedBrightness: number; + dpiLedSpeed: number; + } | null> { + try { + const performance = await this.read(REGISTER.sensorPerformance, R1_SENSOR_PERFORMANCE_LENGTH); + const lighting = this.decodeR1DpiLighting(await this.read(REGISTER.dpiLighting, R1_DPI_LIGHTING_LENGTH)); + const dpiStageColors: string[] = []; + for (let groupIndex = 0; groupIndex < Math.ceil(stageCount / 2); groupIndex += 1) { + const group = await this.read( + REGISTER.dpiColorBase + groupIndex * R1_DPI_COLOR_GROUP_LENGTH, + R1_DPI_COLOR_GROUP_LENGTH, + ); + for (let slot = 0; slot < 2 && dpiStageColors.length < stageCount; slot += 1) { + const color = unpackRgb(group.subarray(slot * 4, slot * 4 + 4)); + if (!color) throw new Error("The mouse reported a DPI stage color that failed its checksum."); + dpiStageColors.push(color); + } + } + const performanceMode = weUnpackScalarPair(performance[4]!, performance[5]!); + if ((performanceMode !== 0 && performanceMode !== 1) || !lighting) { + throw new Error("The mouse reported invalid R1 extended settings."); + } + return { + dpiStageColors, + performanceMode: performanceMode === 1, + longRangeMode: await this.readLongRangeMode(), + ...lighting, + }; + } catch { + return null; + } + } + + private decodeR1DpiLighting(block: Uint8Array): { + dpiLedMode: number; + dpiLedBrightness: number; + dpiLedSpeed: number; + } | null { + if (block.length < R1_DPI_LIGHTING_LENGTH) return null; + const effect = weUnpackScalarPair(block[0]!, block[1]!); + const brightness = weUnpackScalarPair(block[2]!, block[3]!); + const speed = weUnpackScalarPair(block[4]!, block[5]!); + const enabled = weUnpackScalarPair(block[6]!, block[7]!); + const brightnessIndex = R1_DPI_BRIGHTNESS.indexOf(brightness as typeof R1_DPI_BRIGHTNESS[number]); + const speedIndex = R1_DPI_SPEED.indexOf(speed as typeof R1_DPI_SPEED[number]); + if ((effect !== 1 && effect !== 2) || brightnessIndex < 0 || speedIndex < 0 + || (enabled !== 0 && enabled !== 1)) return null; + return { + dpiLedMode: enabled === 0 ? 0 : effect === 2 ? 2 : 1, + dpiLedBrightness: brightnessIndex, + dpiLedSpeed: speedIndex, + }; + } + + private async readLongRangeMode(): Promise { + const reply = await this.exchange( + weBuildCmdPayload(GET_LONG_RANGE_COMMAND), + (frame) => frame[0] === GET_LONG_RANGE_COMMAND && frame[1] === 0 + && frame[4] >= 1 && frame[DATA_OFFSET] <= 1 && this.hasValidChecksum(frame), + ); + return reply[DATA_OFFSET] === 1; + } + + private async writeR1PerformanceBlock(change: (block: number[]) => void): Promise { + await this.identify(); + if (!this.usesVerifiedR1WiredTransport()) { + throw new Error("Performance mode is not available on this connection."); + } + const block = Array.from(await this.read(REGISTER.sensorPerformance, R1_SENSOR_PERFORMANCE_LENGTH)); + change(block); + await this.write(REGISTER.sensorPerformance, block); + return await this.read(REGISTER.sensorPerformance, R1_SENSOR_PERFORMANCE_LENGTH); + } + private dpiAddress(index: number): number { return REGISTER.dpiBase + index * DPI_STAGE_LENGTH; } 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; } private stageIndex(system: Uint8Array): number { - const stages = Math.min(Math.max(system[2], 1), MAX_DPI_STAGES); + const stages = this.stageCount(system); return Math.min(system[4], stages - 1); } + private stageCount(system: Uint8Array): number { + const max = this.isR1() ? R1_MAX_DPI_STAGES : MAX_DPI_STAGES; + return Math.min(Math.max(system[2], 1), max); + } + private decodePollingRate(value: number): number { const match = POLLING_RATES.find(([code]) => code === value); if (!match) throw new Error(`The mouse reported an unknown polling-rate value 0x${value.toString(16)}.`); @@ -351,6 +773,7 @@ export class AtkHidClient { } private decodeLiftOffDistance(code: number): LiftOffDistance | null { + if (this.isR1()) return R1_LIFT_OFF_CODES.find(([value]) => value === code)?.[1] ?? null; const millimetres = atkDecodeLiftOff(code); if (millimetres === null) return null; if (millimetres < 1) return "Low"; @@ -367,7 +790,7 @@ export class AtkHidClient { * may be unpopulated, so fall back to the receiver's ceiling when unknown. */ private async readPollingRate(system?: Uint8Array): Promise { - if (this.isR1()) { + if (this.usesR1LiveSettings()) { const settings = await this.read(ATK_VXE_R1_SETTINGS_REGISTER, R1_SETTINGS_LENGTH).catch(() => null); const decoded = settings ? atkDecodeVxeR1PollingCode(settings[1]) : null; return decoded ?? 1000; @@ -385,6 +808,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 +818,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 +831,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 +841,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 +850,61 @@ export class AtkHidClient { if (this.lastStatus) this.lastStatus = { ...this.lastStatus, ...changes }; } + private hasValidChecksum(frame: Uint8Array): boolean { + return frame.length === FRAME_LENGTH + && frame[15] === weReportChecksum(WE_REPORT_ID, [...frame.subarray(0, 15)]); + } + + private async readR1CurrentProfile(): Promise { + const reply = await this.exchange( + weBuildCmdPayload(ATK_COMPX_COMMAND.getCurrentConfig), + (frame) => frame[0] === ATK_COMPX_COMMAND.getCurrentConfig + && frame[1] === 0 && frame[4] >= 1 && this.hasValidChecksum(frame), + ); + const profile = atkDecodeCurrentProfile(reply.subarray(DATA_OFFSET)); + if (profile === null) throw new Error("The mouse reported an invalid active configuration bank."); + return profile; + } + + private async identify(): Promise { + if (this.identified) return; + if (this.identifyAttempts >= MAX_IDENTIFY_ATTEMPTS) { + 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 === VXE_R1_COMPX_RECEIVER_PID || this.device.productId === VXE_R1_COMPX_MOUSE_PID)) + || /\bvxe\s+r1(?:\s*se\+?)?\b/i.test(this.device.productName || ""); + } + + private usesR1LiveSettings(): boolean { + return this.isR1() && this.isWireless(); + } + + private usesVerifiedR1WiredTransport(): boolean { + return this.device.vendorId === VENDOR_ID.vgn && this.device.productId === VXE_R1_COMPX_MOUSE_PID + && this.product === ATK_PRODUCTS["2,32"] && !this.isWireless(); + } + /** * Command 0x12 (GetMouseVersion) reports the version as BCD, matching the * Endgame Gear siblings: an A9 Nearlink dongle answering 0x01 0x23 is 1.23. @@ -439,12 +921,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 { @@ -452,6 +945,7 @@ export class AtkHidClient { weBuildCmdPayload(WE_CMD_READ_EEPROM, [0, (address >> 8) & 0xff, address & 0xff, length]), // Some firmware answers a read with the write command id. (frame) => (frame[0] === WE_CMD_READ_EEPROM || frame[0] === WE_CMD_WRITE_EEPROM) + && frame[1] === 0 && this.hasValidChecksum(frame) && frame[2] === ((address >> 8) & 0xff) && frame[3] === (address & 0xff) && frame[4] >= length, @@ -460,6 +954,9 @@ export class AtkHidClient { } private async write(address: number, data: readonly number[]): Promise { + if (this.isR1() && !this.usesR1LiveSettings() && !this.usesVerifiedR1WiredTransport()) { + throw new Error("Persistent R1 EEPROM writes are available only over the verified R1 SE+ wired transport."); + } const payload = weBuildCmdPayload( WE_CMD_WRITE_EEPROM, [0, (address >> 8) & 0xff, address & 0xff, data.length, ...data], @@ -472,6 +969,13 @@ export class AtkHidClient { }); } + private async send(frame: Uint8Array): Promise { + await this.run(async () => { + await this.open(); + await this.device.sendReport(WE_REPORT_ID, new Uint8Array(frame).buffer); + }); + } + /** Send a frame and resolve with the first input report the matcher accepts. */ private async exchange(frame: Uint8Array, matches: (frame: Uint8Array) => boolean): Promise { return await this.run(async () => { @@ -512,6 +1016,28 @@ 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)); } + +function parseHexColor(color: string): readonly [number, number, number] | null { + const match = /^#([0-9a-f]{6})$/i.exec(color); + if (!match) return null; + const value = Number.parseInt(match[1]!, 16); + return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]; +} + +function packRgb(rgb: readonly [number, number, number]): number[] { + const sum = (rgb[0] + rgb[1] + rgb[2]) & 0xff; + return [...rgb, (0x55 - sum) & 0xff]; +} + +function unpackRgb(record: Uint8Array): string | null { + if (record.length < 4 || (record[0]! + record[1]! + record[2]! + record[3]!) % 0x100 !== 0x55) return null; + return `#${[record[0], record[1], record[2]].map((value) => value!.toString(16).padStart(2, "0")).join("")}`; +} diff --git a/src/drivers/atk/products.ts b/src/drivers/atk/products.ts new file mode 100644 index 0000000..9f846ce --- /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 R1 SE+ transports under COMPX's shared vendor id. */ +export const ATK_COMPX_PRODUCT_IDS: readonly number[] = [0xf58e, 0xf58f]; diff --git a/src/drivers/atk/protocol.test.ts b/src/drivers/atk/protocol.test.ts index 1fd8f0e..a2f8174 100644 --- a/src/drivers/atk/protocol.test.ts +++ b/src/drivers/atk/protocol.test.ts @@ -2,17 +2,41 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + ATK_BUTTON_CLASS, + ATK_COMPX_COMMAND, + ATK_COMPX_FIRMWARE_HEADER_LENGTH, + ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET, + ATK_R1_BUTTONS, + ATK_R1_MACRO_BASE, + ATK_R1_MACRO_SLOT_COUNT, + ATK_R1_MACRO_SLOT_LENGTH, + ATK_R1_PROFILE_COUNT, + ATK_R1_SHORTCUT_BASE, + ATK_R1_SHORTCUT_SLOT_LENGTH, + ATK_SENSORS, + atkBuildReceiverPairRequest, + atkBuildSetCurrentProfile, + atkCompxPayloadCrc, + atkDecodeButtonAssignment, + atkDecodeCurrentProfile, atkDecodeLiftOff, + atkDecodePairingStatus, + atkDecodeReceiverStatus, + atkDpiOptionsForSensor, atkDecodeVxeR1PollingCode, atkPackDpiStage, + atkPackDpiStageForSensor, atkPackVxeR1LiveSetting, atkPackVxeR1PollingSetting, + atkParseCompxFirmware, atkUnpackDpiStage, + atkUnpackDpiStageForSensor, ATK_VXE_R1_ANGLE_SELECTOR, 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 +56,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); @@ -63,3 +152,117 @@ test("R1 angle/debounce/LOD settings pack as their live-settings selectors", () assert.deepEqual(atkPackVxeR1LiveSetting(ATK_VXE_R1_LOD_SELECTOR, 1), [0x03, 0x01, 0x00, 0x54]); assert.deepEqual(atkPackVxeR1LiveSetting(ATK_VXE_R1_LOD_SELECTOR, 2), [0x03, 0x02, 0x00, 0x53]); }); + +test("COMPX command ids and R1 storage geometry match the vendor protocol", () => { + assert.equal(ATK_COMPX_COMMAND.getWirelessMouseOnline, 0x03); + assert.equal(ATK_COMPX_COMMAND.getCurrentConfig, 0x0e); + assert.equal(ATK_COMPX_COMMAND.setCurrentConfig, 0x0f); + assert.equal(ATK_COMPX_COMMAND.reportMouseUpgradeError, 0x5a); + assert.equal(ATK_COMPX_COMMAND.reportMouseUpgradeStatus, 0x5b); + assert.equal(ATK_R1_PROFILE_COUNT, 4); + assert.deepEqual(ATK_R1_BUTTONS.map(({ address }) => address), [0x60, 0x64, 0x68, 0x6c, 0x70, 0x74]); + assert.equal(ATK_R1_SHORTCUT_BASE, 0x100); + assert.equal(ATK_R1_SHORTCUT_SLOT_LENGTH, 32); + assert.equal(ATK_R1_MACRO_BASE, 0x300); + assert.equal(ATK_R1_MACRO_SLOT_LENGTH, 384); + assert.equal(ATK_R1_MACRO_SLOT_COUNT, 12); +}); + +test("current profile, receiver, and pairing replies are decoded without writes", () => { + assert.equal(atkDecodeCurrentProfile([0]), 0); + assert.equal(atkDecodeCurrentProfile([3]), 3); + assert.equal(atkDecodeCurrentProfile([4]), null); + assert.equal(atkDecodeCurrentProfile([]), null); + assert.deepEqual(atkDecodeReceiverStatus([1, 0xaa, 0xbb, 0xcc]), { + online: true, + status: 1, + rfId: "CCBBAA", + }); + assert.deepEqual(atkDecodeReceiverStatus([0, 0, 0, 0]), { online: false, status: 0, rfId: "000000" }); + assert.deepEqual(atkDecodeReceiverStatus([2, 0xaa, 0xbb, 0xcc]), { + online: false, + status: 2, + rfId: "CCBBAA", + }); + assert.equal(atkDecodeReceiverStatus([1, 2, 3]), null); + assert.deepEqual(atkDecodePairingStatus([2, 29]), { status: 2, secondsRemaining: 29 }); + assert.equal(atkDecodePairingStatus([2]), null); +}); + +test("profile selection uses the vendor length field and zero-based bank", () => { + assert.deepEqual([...atkBuildSetCurrentProfile(2)], [ + 0x0f, 0, 0, 0, 1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x3b, + ]); + assert.throws(() => atkBuildSetCurrentProfile(-1), /between 0 and 3/); + assert.throws(() => atkBuildSetCurrentProfile(4), /between 0 and 3/); +}); + +test("receiver pairing encodes the exact mouse CID and MID", () => { + assert.deepEqual([...atkBuildReceiverPairRequest(0x02, 0x20)], [ + 0x05, 0, 0, 0, 2, 0x02, 0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0x24, + ]); + assert.throws(() => atkBuildReceiverPairRequest(-1, 0x20), /must be bytes/); + assert.throws(() => atkBuildReceiverPairRequest(0x02, 0x100), /must be bytes/); +}); + +test("button assignments preserve unknown values and report checksum corruption", () => { + assert.deepEqual(atkDecodeButtonAssignment([ATK_BUTTON_CLASS.mouse, 1, 0, 0x53]), { + keyClass: 1, + value1: 1, + value2: 0, + checksum: 0x53, + checksumValid: true, + label: "Left click", + raw: "01 01 00 53", + }); + const unknown = atkDecodeButtonAssignment([0xfe, 0xaa, 0xbb, 0x00]); + assert.equal(unknown?.checksumValid, false); + assert.equal(unknown?.label, "Unknown class 0xfe (0xaa, 0xbb)"); + assert.equal(atkDecodeButtonAssignment([1, 2, 3]), null); +}); + +test("COMPX firmware parser checks endpoints, geometry, and raw payload CRC", () => { + const payload = new Uint8Array([1, 2, 3]); + assert.equal(atkCompxPayloadCrc(payload), 0xaa437fe2); + const file = new Uint8Array(ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET + payload.length); + const view = new DataView(file.buffer); + view.setUint32(0, 0x55552135, true); + view.setUint32(4, ATK_COMPX_FIRMWARE_HEADER_LENGTH, true); + view.setUint32(8, payload.length, true); + view.setUint32(16, 0x315, true); + view.setUint8(20, 1); + view.setUint8(21, 2); + view.setUint8(22, 32); + const writeField = (index: number, value: string): void => { + file.set(new TextEncoder().encode(value), 23 + index * 64); + }; + writeField(0, "ComUsbUpgradeFile"); + writeField(1, "CX52850P"); + writeField(2, "vid_3554&pid_f406&mi_01&col01"); + writeField(3, "vid_3554&pid_f406&mi_01&col02"); + writeField(4, "vid_3554&pid_f58f&mi_01&col05"); + writeField(5, "vid_3554&pid_f58f&mi_01&col05"); + writeField(9, "3395se"); + const prepare = 23 + 7 * 64; + file.set([49, 1, 6, 0xb0], prepare); + view.setUint32(prepare + 19, atkCompxPayloadCrc(payload), false); + file.set(payload, ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET); + + const parsed = atkParseCompxFirmware(file); + assert.equal(parsed.version, "315"); + assert.equal(parsed.cid, 2); + assert.equal(parsed.mid, 32); + assert.equal(parsed.icName, "CX52850P"); + assert.equal(parsed.sensorName, "3395se"); + assert.deepEqual(parsed.normalInput, { + vendorId: 0x3554, + productId: 0xf58f, + path: "vid_3554&pid_f58f&mi_01&col05", + }); + assert.equal(parsed.payloadCrcValid, true); + + file[ATK_COMPX_FIRMWARE_PAYLOAD_OFFSET] ^= 0xff; + assert.equal(atkParseCompxFirmware(file).payloadCrcValid, false); + view.setUint32(8, payload.length + 1, true); + assert.throws(() => atkParseCompxFirmware(file), /payload length exceeds/); +}); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 8021702..835213a 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -65,6 +65,12 @@ export interface MouseUiHints { maxDpi: number; stepDpi: number; }; + /** Simple DPI indicator modes normalized as 0 off, 1 steady, 2 breathing. */ + dpiLighting?: { + modes: readonly (0 | 1 | 2)[]; + brightness: readonly number[]; + speed: readonly number[]; + }; } /** @@ -117,8 +123,28 @@ export type MouseLightingMode = | "Breathing single" | "Breathing dual"; +export interface AtkStoredButton { + id: "left" | "right" | "middle" | "back" | "forward" | "bottom"; + name: string; + address: number; + keyClass: number; + value1: number; + value2: number; + checksumValid: boolean; + action: string; + raw: string; +} + +export interface AtkReceiverInfo { + online: boolean; + status: number; + rfId: string; + pairingStatus: number | null; + pairingSecondsRemaining: number | null; +} + export interface MouseStatus { - brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "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; @@ -138,6 +164,12 @@ export interface MouseStatus { pollingRateHz: number; supportedPollingRates?: number[]; activeProfile: number | null; + /** Number of firmware-managed ATK configuration banks, when readable. */ + atkProfileCount?: number; + /** Lossless, read-only ATK button assignments from the active configuration bank. */ + atkButtonMappings?: AtkStoredButton[]; + /** Receiver and pairing telemetry. Pairing controls are intentionally separate. */ + atkReceiver?: AtkReceiverInfo; deviceMode?: "Onboard" | "Host" | "Unknown"; unitId?: string | null; modelId?: string | null; @@ -156,6 +188,8 @@ export interface MouseStatus { motionSync?: boolean | null; /** On-device DPI stages, where supported (Teevolution, Ninjutso, …). */ dpiStages?: number[]; + /** RGB colour for each DPI stage, as lowercase #rrggbb. */ + dpiStageColors?: string[]; /** Active DPI stage index into `dpiStages` (0-based). */ activeDpiStage?: number; ninjutsoSystemMode?: "High Speed" | "Competitive" | "Ultra" | null; @@ -195,6 +229,7 @@ export interface MouseStatus { napeLayerCount?: number; performanceMode?: boolean | null; hyperMode?: boolean | null; + longRangeMode?: boolean | null; sensorMode?: "Eco" | "High" | "Ultra" | null; sensorModeStored?: 0 | 1 | null; sensorModeEditable?: boolean | null; 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,