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
538 changes: 538 additions & 0 deletions captures/vxe-r1-pro/receiver-atk-hub-profiles.txt

Large diffs are not rendered by default.

287 changes: 287 additions & 0 deletions captures/vxe-r1-pro/receiver-atk-hub.txt

Large diffs are not rendered by default.

95 changes: 95 additions & 0 deletions docs/vxe-r1-pro-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# VXE R1 Pro hardware notes

This document records what was verified on a real VXE R1 Pro for the ATK
driver. Only the 1K receiver was tested; the wired transport is untested and
stays read-only.

## Identity and transport

- Receiver: VID/PID `0x3554:0xf58a`, product string "VXE NordicMouse 1K
Dongle". This is the same USB id as the R1 Pro Max receiver.
- Mouse identity (`GetMouseCIDMID`, command `0x10`): CID `0x02`, MID `0x08`,
firmware 1.20.
- Sensor: PAW3395 (50-DPI step codec).
- Configuration collection: usage page `0xff02`, usage `0x02`.

ATK HUB lists the wired R1 Pro as CID/MID `2,7` on `0x3554:0xf58c`; the
hardware reports `2,8` through the receiver.

### CID/MID is scoped by USB vendor

Under ATK's `0x373b` vendor id, CID/MID `2,8` is the VXE R1 NK, a PAW3395SE
mouse. The receiver is not in the vendor catalog, so the driver used to fall
back to the only catalog entry with that pair and decode the R1 Pro with the
PAW3395SE codec. That codec has no code `0x07`, so a 400-DPI stage
(`07 07 00 47`) aborted the status read with a checksum error even though the
row's checksum was valid.

Ten of the twelve Compx (`0x3554`) CID/MID pairs in the catalog name a
different mouse under `0x373b`. A CID/MID-only catalog match is therefore
accepted only when the entry belongs to the connected USB vendor, and the R1
Pro is registered as a Compx-only identity.

## Verified receiver behavior

`captures/vxe-r1-pro/receiver-atk-hub.txt` and
`captures/vxe-r1-pro/receiver-atk-hub-profiles.txt` are logs of ATK HUB
(`v3-hub.atk.store`) changing each setting below. The frames match the R1 Pro
Max receiver protocol, so the R1 Pro reuses those write/echo helpers. Each
setting was then written by the driver on the same mouse, confirmed by
readback, and restored.

| Setting | Row | Notes |
| --- | --- | --- |
| Polling rate (125–1,000 Hz) | 10-byte system row at `0x0000` | |
| DPI stage count, active stage | 10-byte system row at `0x0000` | Stage count was written by the driver only; HUB's stage-count change was not captured. |
| DPI stage value | 8-byte two-stage group from `0x000c` | 1,500 DPI is `1d 1d 00 1b`. HUB rewrites all four groups; the driver writes the one that changed. |
| Lift-off distance | pair at `0x000a` | 1 = 1 mm, 2 = 2 mm. |
| Debounce, Motion Sync, sleep, straight-line correction, ripple control | 10-byte advanced row at `0x00a9` | Offsets 0, 2, 4, 6, 8. |
| DPI stage colours | 8-byte two-colour group from `0x002c` | |
| DPI lighting | 8-byte row at `0x004c` | See below. |
| Ultra Long Range | command `0x16`, readback `0x17` | |
| Configuration bank (profile) 1–4 | command `0x0f`, readback `0x0e` | Zero-based bank at data offset 0; the receiver echoes the request about 100 ms later. |

### Profiles and buttons

The mouse holds four configuration banks. Switching to bank 2 through the
driver changed the reported DPI stages from 400/800/3,200/1,200 to
400/800/1,600/3,200, and switching back restored them, so EEPROM reads follow
the active bank as they do on the other R1s.

The six button records at `0x0060`–`0x0074` use the R1 layout and are read
only. The stock assignments read as left, right, middle, back, forward and DPI
cycle.

ATK HUB's "computer config" entries are stored on the PC: applying one selects
bank 4 and rewrites its rows from the host. The driver does not model them.

### DPI lighting

The row is `effect | brightness | speed | enabled`, each a value/checksum pair.
Always On and Breathing match the R1 Pro Max. Off differs:

- ATK HUB sends `00 00 | brightness unchanged | speed | 00 55`.
- The mouse stores `02 53` in the effect pair regardless, so Off is carried by
the enabled flag alone. A row reading `02 53 80 d5 05 50 00 55` is Off.

The driver sends HUB's Off row, does not require the effect pair to read back,
and decodes enabled = 0 as Off for this identity.

### Sleep timer

When the sleep timer changes, ATK HUB also writes the 6-byte row at `0x00b5`
(`01 54 | timer | 01 54`). The driver writes only `0x00a9`, as it does for the
R1 Pro Max; the readback confirms the value, but that the mouse then sleeps on
the new schedule was not timed.

## Not enabled

- Button-assignment writes and macros: not captured, and not implemented by
the driver for any R1.
- Performance Mode (`0x00b5` bytes 4–5): ATK HUB showed no such switch for
this mouse, so the driver neither reports nor writes it.
- Separate X/Y DPI: not tested.
- Wired transport: not tested. It is not on the verified-wired list, so
persistent writes stay blocked there.
16 changes: 11 additions & 5 deletions src/drivers/atk/hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1300,11 +1300,17 @@ test("a mouse behind the generic 8K dongle-L is decoded with the sensor its CID/
});

test("a CID/MID shared by models with different sensors stays on the generic codec behind a receiver", () => {
assert.equal(atkCatalogSensorForCidMid("2,77"), "PAW3395SE");
assert.equal(atkCatalogSensorForCidMid("1,2"), "PAW3950Ultra");
assert.equal(atkCatalogSensorForCidMid("2,5"), null);
assert.equal(atkCatalogSensorForCidMid("250,250"), null);
assert.equal(atkCatalogSensorForCidMid(null), null);
assert.equal(atkCatalogSensorForCidMid("2,77", 0x373b), "PAW3395SE");
assert.equal(atkCatalogSensorForCidMid("1,2", 0x373b), "PAW3950Ultra");
assert.equal(atkCatalogSensorForCidMid("2,5", 0x373b), null);
assert.equal(atkCatalogSensorForCidMid("250,250", 0x373b), null);
assert.equal(atkCatalogSensorForCidMid(null, 0x373b), null);
// CID/MID 2,8 is the VXE R1 NK under ATK's vendor id only; a Compx (0x3554)
// device reporting it is a different mouse and must not borrow that entry.
assert.equal(atkCatalogSensorForCidMid("2,8", 0x373b), "PAW3395SE");
assert.equal(atkCatalogSensorForCidMid("2,8", 0x3554), null);
assert.equal(atkCatalogDevice(0x3554, 0xf58a, "2,8"), undefined);
assert.equal(atkCatalogDevice(0x373b, 0x10c9, "2,8")?.model, "VXE R1 NK");
assert.equal(atkCatalogDevice(0x373b, 0x1278, "2,77")?.model, "ATK X1 SE NK");
assert.equal(atkCatalogDevice(0x373b, 0x1278, "2,5"), undefined);
});
126 changes: 89 additions & 37 deletions src/drivers/atk/hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
atkUnpackDpiStage,
atkUnpackDpiStageForSensor,
} from "@openmouse/protocol/atk";
import { type AtkProduct, ATK_COMPX_PRODUCT_IDS, ATK_PRODUCTS, atkCatalogDevice, atkCatalogSensorForCidMid, atkCatalogSensorToSensor } from "./products.ts";
import { type AtkProduct, ATK_COMPX_PRODUCT_IDS, ATK_COMPX_PRODUCTS, ATK_PRODUCTS, atkCatalogDevice, atkCatalogSensorForCidMid, atkCatalogSensorToSensor, atkProductFor } from "./products.ts";
import type { AtkDeviceDescriptor, AtkDeviceFeatures } from "./device-catalog.generated.ts";
import type { AtkSensor } from "@openmouse/protocol/atk";

Expand Down Expand Up @@ -156,6 +156,8 @@ export class AtkHidClient {
readonly device: HIDDevice;

private queue: Promise<unknown> = Promise.resolve();
/** Serializes whole read/patch/write transactions on shared EEPROM rows. */
private rowQueue: Promise<unknown> = Promise.resolve();
private lastStatus: MouseStatus | null = null;
private product: AtkProduct | null = null;
private identified = false;
Expand Down Expand Up @@ -399,6 +401,9 @@ export class AtkHidClient {
dpiLighting: r1Extras && this.features().noDpiRGB !== true ? {
modes: [0, 1, 2],
brightness: [0, 1, 2],
// ATK HUB offers no brightness while breathing on these two, and
// setDpiLighting leaves the stored pair alone in that mode.
brightnessModes: this.isR1ProMax() || this.isR1Pro() ? [1] : undefined,
speed: [0, 1, 2],
} : undefined,
},
Expand All @@ -424,7 +429,8 @@ export class AtkHidClient {
motionSync: advanced[2] === 1,
sleepTimeout: advanced[4] * SLEEP_STEP_SECONDS || null,
rippleControl: advanced[8] === 1,
performanceMode: r1Extras?.performanceMode ?? a9PerformanceMode,
// ATK HUB offers no performance-mode switch for the R1 Pro.
performanceMode: this.isR1Pro() ? undefined : r1Extras?.performanceMode ?? a9PerformanceMode,
longRangeMode: r1Extras?.longRangeMode,
atkSensorMode: f1Extras?.sensorMode ?? null,
atkAntiMistouchMs: f1Extras?.antiMistouchMs ?? null,
Expand Down Expand Up @@ -800,6 +806,7 @@ export class AtkHidClient {

async setPerformanceMode(enabled: boolean): Promise<boolean> {
await this.identify();
if (this.isR1Pro()) throw new Error("Performance mode is not available on this connection.");

const change = (block: number[]): void => {
block.splice(4, 2, ...wePackScalarPair(enabled ? 1 : 0));
Expand Down Expand Up @@ -961,14 +968,19 @@ export class AtkHidClient {
// Off = 00 00 | 00 00 | speed | 00 55
// Always On = 01 54 | brightness | speed | 01 54
// Breathing = 02 53 | preserve brightness | speed | 01 54
// The R1 Pro shares that encoding, except that ATK HUB leaves its
// brightness pair untouched when switching the light off, and the mouse
// keeps its previous effect pair instead of storing 00 00: there, Off is
// carried by the trailing enabled flag alone.
const block = Array.from(await this.read(REGISTER.dpiLighting, R1_DPI_LIGHTING_LENGTH));
const proMax = this.isR1ProMax();
const r1Pro = this.isR1Pro();
const proMax = this.isR1ProMax() || r1Pro;

const expectedEffect = proMax && mode === 0
? [0, 0]
: wePackScalarPair(mode === 2 ? 2 : 1);

const expectedBrightness = proMax && mode === 2
const expectedBrightness = (proMax && mode === 2) || (r1Pro && mode === 0)
? [block[2]!, block[3]!]
: mode === 0
? [0, 0]
Expand All @@ -988,7 +1000,8 @@ export class AtkHidClient {
const expectedSpeed = wePackScalarPair(R1_DPI_SPEED[speed]!);
const expectedEnabled = wePackScalarPair(mode === 0 ? 0 : 1);
const expected = [...expectedEffect, ...expectedBrightness, ...expectedSpeed, ...expectedEnabled];
if (!expected.every((byte, index) => raw[index] === byte)) {
const firstChecked = r1Pro && mode === 0 ? expectedEffect.length : 0;
if (!expected.every((byte, index) => index < firstChecked || raw[index] === byte)) {
throw new Error("The mouse did not retain its DPI lighting settings.");
}

Expand Down Expand Up @@ -1173,10 +1186,12 @@ export class AtkHidClient {

/** The advanced block is written whole, so read it back, patch one pair, write. */
private async writeAdvanced(offset: number, value: number): Promise<number> {
const group = Array.from(await this.read(REGISTER.advanced, ADVANCED_LENGTH));
group.splice(offset, 2, ...wePackScalarPair(value));
await this.write(REGISTER.advanced, group);
return (await this.read(REGISTER.advanced, ADVANCED_LENGTH))[offset];
return await this.inRowTransaction(async () => {
const group = Array.from(await this.read(REGISTER.advanced, ADVANCED_LENGTH));
group.splice(offset, 2, ...wePackScalarPair(value));
await this.write(REGISTER.advanced, group);
return (await this.read(REGISTER.advanced, ADVANCED_LENGTH))[offset];
});
}

/**
Expand Down Expand Up @@ -1324,6 +1339,11 @@ export class AtkHidClient {

if (brightnessIndex < 0 || speedIndex < 0 || (enabled !== 0 && enabled !== 1)) return null;

// The R1 Pro stores Off as enabled=0 and leaves the last effect in place.
if (this.isR1Pro() && enabled === 0) {
return { dpiLedMode: 0, dpiLedBrightness: brightnessIndex, dpiLedSpeed: speedIndex };
}

// R1 Pro Max may report the trailing flag as 0 after reconnect even when
// the stored effect is Breathing. Treat the effect field as authoritative.
// Keep effect=1 + enabled=0 as the legacy Off encoding used by other R1s.
Expand Down Expand Up @@ -1422,7 +1442,14 @@ export class AtkHidClient {
const stage = sensor
? atkUnpackDpiStageForSensor(sensor, data)
: atkUnpackDpiStage(data);
if (!stage) throw new Error("The mouse reported a DPI stage that failed its checksum.");
if (!stage) {
// A row can sum correctly and still hold a value this sensor's codec
// rejects, which points at a misidentified model rather than bad data.
const checksumValid = data.reduce((total, byte) => total + byte, 0) % 0x100 === 0x55;
throw new Error(checksumValid
? "The mouse reported a DPI stage this sensor cannot produce; the model may be misidentified."
: "The mouse reported a DPI stage that failed its checksum.");
}
return stage;
}

Expand Down Expand Up @@ -1583,7 +1610,7 @@ export class AtkHidClient {
}
this.identified = true;
this.cidMid = `${reply[DATA_OFFSET]},${reply[DATA_OFFSET + 1]}`;
this.product = ATK_PRODUCTS[this.cidMid] ?? null;
this.product = atkProductFor(this.device.vendorId, this.cidMid);
}

/**
Expand Down Expand Up @@ -1615,7 +1642,7 @@ export class AtkHidClient {
*/
private sensorKey(): AtkSensor | null {
if (this.product) return this.product.sensor;
return atkCatalogSensorToSensor(this.catalogEntry()?.sensor ?? null) ?? atkCatalogSensorForCidMid(this.cidMid);
return atkCatalogSensorToSensor(this.catalogEntry()?.sensor ?? null) ?? atkCatalogSensorForCidMid(this.cidMid, this.device.vendorId);
}

private usesSharedR1Transport(): boolean {
Expand All @@ -1635,10 +1662,21 @@ export class AtkHidClient {
return this.product === ATK_PRODUCTS["2,27"];
}

/** VXE R1 Pro (Compx CID 2, MID 8), captured behind the F58A 1K dongle. */
private isR1Pro(): boolean {
return this.device.vendorId === VENDOR_ID.vgn && this.product === ATK_COMPX_PRODUCTS["2,8"];
}

/**
* The F58A dongle's captured write/echo protocol. The R1 Pro Max and the R1
* Pro both pair with this dongle and ATK HUB sends them the same rows, so
* the R1 Pro reuses the Pro Max receiver helpers for the settings that were
* captured on it; see docs/vxe-r1-pro-testing.md for what was not.
*/
private usesVerifiedR1ProMaxReceiverTransport(): boolean {
return this.device.vendorId === VENDOR_ID.vgn
&& this.device.productId === VXE_R1_PRO_MAX_RECEIVER_PID
&& this.isR1ProMax();
&& (this.isR1ProMax() || this.isR1Pro());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

/**
Expand Down Expand Up @@ -1735,32 +1773,34 @@ export class AtkHidClient {
}

const address = REGISTER.advanced;
const block = Array.from(await this.read(address, ADVANCED_LENGTH));
block.splice(offset, 2, ...wePackScalarPair(value));

const payload = weBuildCmdPayload(
WE_CMD_WRITE_EEPROM,
[
0,
(address >> 8) & 0xff,
address & 0xff,
block.length,
...block,
],
);
return await this.inRowTransaction(async () => {
const block = Array.from(await this.read(address, ADVANCED_LENGTH));
block.splice(offset, 2, ...wePackScalarPair(value));

const payload = weBuildCmdPayload(
WE_CMD_WRITE_EEPROM,
[
0,
(address >> 8) & 0xff,
address & 0xff,
block.length,
...block,
],
);

await this.exchange(
payload,
(frame) => frame[0] === WE_CMD_WRITE_EEPROM
&& frame[1] === 0
&& frame[2] === ((address >> 8) & 0xff)
&& frame[3] === (address & 0xff)
&& frame[4] === block.length
&& this.hasValidChecksum(frame)
&& block.every((byte, index) => frame[DATA_OFFSET + index] === byte),
);
await this.exchange(
payload,
(frame) => frame[0] === WE_CMD_WRITE_EEPROM
&& frame[1] === 0
&& frame[2] === ((address >> 8) & 0xff)
&& frame[3] === (address & 0xff)
&& frame[4] === block.length
&& this.hasValidChecksum(frame)
&& block.every((byte, index) => frame[DATA_OFFSET + index] === byte),
);

return (await this.read(address, ADVANCED_LENGTH))[offset]!;
return (await this.read(address, ADVANCED_LENGTH))[offset]!;
});
}

/**
Expand Down Expand Up @@ -1923,6 +1963,18 @@ export class AtkHidClient {
this.queue = started.catch(() => undefined);
return await started;
}

/**
* Holds a whole read/patch/write of a shared row, so two setters patching
* different fields cannot both read the old row and overwrite each other.
* The task's own reads and writes still go through run(); this is a
* separate chain, so nesting cannot deadlock.
*/
private async inRowTransaction<T>(task: () => Promise<T>): Promise<T> {
const started = this.rowQueue.then(task, task);
this.rowQueue = started.catch(() => undefined);
return await started;
}
}

function copyDataView(view: DataView): Uint8Array {
Expand Down
Loading
Loading