Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions docs/atk-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# ATK / VXE hardware testing

Use the vendor configuration interface (`usagePage 0xff02`, `usage 0x02`). It
uses report ID `0x08` with a 16-byte payload. Do not record device serial
numbers in captures or test documentation.

## Identification

Shared USB product IDs do not reliably identify the mouse or sensor. The driver
sends CID/MID command `0x10` and looks up the returned pair in
`src/drivers/atk/products.ts`. A failed identification is retried up to three
times because a sleeping wireless mouse may not answer. Closing the client
resets that retry budget.

A successful but unknown ATK identity retains the historical A9 codec and
USB-name behavior. Generic ATK devices that do not answer CID/MID do the same.
A shared VXE R1 transport that does not answer instead fails before using that
fallback codec. Known VXE identities report the VXE brand.

## Verified VXE R1 SE+

The raw EEPROM and identity values below were captured directly from one VXE R1
SE+ over its wired connection. Receiver behavior for this model has not been
tested or claimed. The full sensor table and ranges, and the CID/MID mapping,
were independently transcribed from the public ATK HUB 3.2.21 bundle; the
low-range records below cross-check that transcription.

- USB: VID/PID `0x3554:0xf58f`, product `VXE R1SE+`, firmware/bcdDevice 3.15.
- Configuration channel: interface 1, usage page `0xff02`, usage `2`.
- CID/MID: `2,32`, identified by ATK HUB as VXE R1SE+ with PAW3395SE.
- Battery response: declared payload `5f 01` reports 95% and charging. Bytes
after the declared payload are padding and are not interpreted as voltage.
- Vendor range: 200 through 18,000 DPI.
- EEPROM DPI stage `12 12 00 31` decoded as 800 DPI.
- EEPROM DPI stage `25 25 00 0b` decoded as 1,600 DPI.
- EEPROM DPI stage `4b 4b 00 bf` decoded as 3,200 DPI.
- Writes at 200, 10,000, 10,100, and 18,000 DPI were each confirmed through
device readback, including the high-DPI mode transition, then restored to
800 DPI.
- OpenMouse was also exercised in Chromium through WebHID: it identified the
wired mouse, displayed 800 DPI and 1,000 Hz, applied 850 DPI through the
staged-save UI, and restored 800 DPI.
- Motion Sync, ripple control, and sleep timeout changes were confirmed through
device readback and restored. Polling changes were acknowledged and restored.
- Lift-off distance and angle snapping use the firmware's fire-and-forget live
row; both commands and their restores completed, but the device does not
expose a reliable independent readback for these writes.
- Debounce writing was not exercised because the captured value was 0 while the
vendor-supported writable range begins at 1 ms, preventing an exact restore.

PAW3395SE maps targets 50 through 10,000 in 50-DPI increments to codes 1
through 235 while skipping these codes:

```text
7, 13, 20, 26, 33, 40, 46, 53, 60, 66, 73, 80, 86, 93, 100, 106, 113,
120, 126, 133, 140, 146, 153, 160, 166, 173, 180, 186, 193, 200, 206,
213, 220, 226, 233
```

The exposed writable options are 200 through 10,000 in 50-DPI increments,
then 10,100 through 18,000 in 100-DPI increments. Values above 10,000 encode
half the requested DPI and set bit 1 in that axis's mode nibble. This is mode
bit 1 for X and mode bit 5 for Y. Codes in the skipped set and invalid mode
combinations must be rejected rather than decoded approximately.

## R1 live settings

R1 family detection uses the identified product family, with the known receiver
PID and R1 USB product name retained as fallbacks. This makes wired CID/MID
`2,32` use the same current-main live-settings behavior as other R1 variants:

- Polling: 250, 500, and 1,000 Hz through selector `0x0b`.
- Angle snapping: selector `0x01`.
- Debounce: selector `0x02`, 1 through 20 ms.
- Lift-off distance: selector `0x03`, Low or High.

Angle values from EEPROM are accepted only when each value/checksum pair sums
to `0x55`. An unprogrammed `ff ff ff ff` row reports both angle fields as
unsupported.

Battery command `0x04` is decoded according to its declared payload length:
percent requires one byte, the charging flag requires two, and big-endian cell
voltage requires four. A missing or short reply leaves unavailable fields
unknown rather than interpreting padding as data.
122 changes: 121 additions & 1 deletion src/atk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AtkSensor, AtkSensorProfile> = {
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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -111,4 +232,3 @@ export function atkPackVxeR1PollingSetting(pollingRateHz: number): number[] | nu
export function atkDecodeVxeR1PollingCode(code: number): number | null {
return VXE_POLLING_CODES.find(([value]) => (value & 0xff) === (code & 0xff))?.[1] ?? null;
}

Loading