Skip to content
Merged
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
58 changes: 58 additions & 0 deletions docs/pulsar-x2a-v3-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Pulsar X2A v3 / shared 8K receiver follow-up

Ticket identity: VID `0x3710`, wired X2A v3 PID `0x3404`, shared 8K receiver
PID `0x5403`, Windows 11. The wired ID and the `0xffff:0x01` control-interface
restriction were already added in [PR #144](https://github.com/OpenMouse-Project/mouse-protocol/pull/144).

This follow-up corrects the receiver's 8K capability list, gives the wired
mouse its model name, and stops interpreting a failed/unrecognized polling
reply as 1000 Hz. The read-only polling decoder is unchanged; its physical
accuracy still needs a capture correlated with the vendor application's setting.
No polling write is added. The existing wired/1K capability list is preserved.
The generic receiver name does not identify the paired mouse.

The reported 8K capability agrees with [Pulsar's X2A v3 product page](https://jp.pulsar.gg/products/x2a-v3-gaming-mouse),
which requires the separately sold 8K dongle for 8K wireless operation. This
does not establish the format of a polling-rate write or mark either path as
hardware-verified by OpenMouse.

## What the reporter should test

1. In the current panel, connect over cable. Confirm X2A v3 and Wired. Change
DPI from 700 to 800 (or another supported value), apply, confirm the read-back
and observed mouse behavior, then restore the original setting.
2. Connect through the 8K receiver. Confirm Wireless and a generic dongle name.
Repeat the DPI test; try debounce and Motion Sync separately and report
their before/after values and any error. These setters already exist.
3. Set 1000, 2000, 4000 and 8000 Hz in Pulsar's application one at a time.
Close that application before refreshing OpenMouse. For each value, download
Diagnostics and record the UI readout. A missing/unknown reply should show a
dash, not claim 1000 Hz. The polling buttons remain read-only.
4. Include the feature-report packets for the polling query
(`08 85 03`, 64-byte report 0) at each vendor setting, so the existing
decoding table can be checked against real hardware.

After granting the receiver in the browser picker, this prints only the Pulsar
device names and report descriptors; it does not rely on Chrome's `copy()` helper:

```js
console.log(JSON.stringify((await navigator.hid.getDevices())
.filter(device => device.vendorId === 0x3710)
.map(device => ({
name: device.productName,
pid: `0x${device.productId.toString(16)}`,
collections: device.collections.map(collection => ({
usagePage: `0x${collection.usagePage.toString(16)}`,
usage: `0x${collection.usage.toString(16)}`,
featureReports: collection.featureReports.map(report => ({
id: report.reportId,
bytes: report.items.reduce((bits, item) => bits + item.reportSize * item.reportCount, 0) / 8,
})),
})),
})), null, 2));
```

An empty array means that browser has not retained a grant for a Pulsar device.
The ticket's linked screenshots and diagnostic JSON were not available in the
supplied transcript, so the unresolved hardware behavior cannot be inferred from
those attachments. No Bridge changes are needed for this follow-up.
58 changes: 56 additions & 2 deletions src/drivers/pulsar/pulsar-xs1-hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ interface FakeState {
debounce: number;
}

function fakeDevice(productId: number, productName = "Pulsar X3 Medium 1K Dongle") {
function fakeDevice(
productId: number,
productName = "Pulsar X3 Medium 1K Dongle",
options: { pollingCode?: number; failPolling?: boolean } = {},
) {
const sent: Uint8Array[] = [];
const state: FakeState = {
dpi: 1600,
Expand Down Expand Up @@ -64,7 +68,10 @@ function fakeDevice(productId: number, productName = "Pulsar X3 Medium 1K Dongle
reply[2] = request[2];
reply[3] = request[3];
if (request[1] === 0x08 && request[2] === 0x81) reply[6] = 80;
if (request[1] === 0x08 && request[2] === 0x85) reply[7] = 30;
if (request[1] === 0x08 && request[2] === 0x85) {
if (options.failPolling) throw new Error("Polling query failed");
reply[7] = options.pollingCode ?? 30;
}
if (request[1] === 0x05 && request[2] === 0x82) {
reply[7] = state.dpi & 0xff;
reply[8] = state.dpi >> 8;
Expand Down Expand Up @@ -170,6 +177,53 @@ test("reports the wired X3 as wired", async () => {
assert.equal(status.connectionType, "Wired");
});

test("the X2A v3 cable ID is named and stays on the wired 1K capability path", async () => {
const client = new PulsarXs1HidClient(fakeDevice(0x3404).device);
const status = await client.readStatus();
assert.equal(status.name, "Pulsar X2A v3");
assert.equal(status.connectionType, "Wired");
assert.equal((await client.readDeviceInfo()).maximumPollingRateHz, 1000);
assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000]);
});

test("the shared 8K dongle advertises high rates without inventing the paired mouse model", async () => {
for (const [code, rate] of [[15, 2000], [8, 4000], [4, 8000]]) {
const client = new PulsarXs1HidClient(fakeDevice(0x5403, "1K Dongle", { pollingCode: code }).device);
const status = await client.readStatus();
assert.equal(status.name, "Pulsar 8K Dongle");
assert.equal(status.connectionType, "Wireless");
assert.equal((await client.readDeviceInfo()).maximumPollingRateHz, 8000);
assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000, 2000, 4000, 8000]);
assert.equal(status.pollingRateHz, rate);
assert.equal(status.ui?.pollingReadOnly, true);
}
});

test("unknown and failed polling reads never turn into a fabricated 1000 Hz", async () => {
for (const options of [{ pollingCode: 0 }, { pollingCode: 255 }, { failPolling: true }]) {
const status = await new PulsarXs1HidClient(fakeDevice(0x5403, "8K Dongle", options).device).readStatus();
assert.equal(status.pollingRateHz, 0);
assert.equal(status.dpi, 1600);
assert.equal(status.ui?.pollingReadOnly, true);
assert.match(status.ui!.pollingNote!, /did not return a recognized polling rate/);
}
const oneK = await new PulsarXs1HidClient(fakeDevice(0x5402, "1K Dongle", { pollingCode: 4 }).device).readStatus();
assert.equal(oneK.pollingRateHz, 0);
assert.deepEqual(oneK.supportedPollingRates, [125, 250, 500, 1000]);
});

test("X2A cable and shared 8K receiver retain confirmed DPI, debounce and processing writes", async () => {
for (const productId of [0x3404, 0x5403]) {
const client = new PulsarXs1HidClient(fakeDevice(productId).device);
assert.equal(await client.setDpi(800), 800);
assert.equal(await client.setDebounceTime(4), 4);
assert.equal(await client.setMotionSync(false), false);
assert.equal(await client.setRippleControl(true), true);
assert.equal(await client.setAngleSnapping(true), true);
assert.equal(await client.setLiftOffDistance("Low"), "Low");
}
});

test("writes and confirms the DPI", async () => {
const { device, sent } = fakeDevice(0x5402);
const client = new PulsarXs1HidClient(device);
Expand Down
33 changes: 23 additions & 10 deletions src/drivers/pulsar/pulsar-xs1-hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,16 @@ type LiftOffDistance = NonNullable<MouseStatus["liftOffDistance"]>;
const RESPONSE_ATTEMPTS = 5;
const RESPONSE_DELAY_MS = 25;
const DPI_VALUE_OFFSET = 7;
// The shared 8K receiver in the X2A v3 ticket enumerates as 3710:5403.
const EIGHT_K_RECEIVER_PRODUCT_ID = 0x5403;
const EIGHT_K_POLLING_RATES = [...POLLING_RATES, 2000, 4000, 8000];

// The X3 family dongles report a generic "1K Dongle" product name from WebHID,
// which is useless in the UI. Known product ids map to the paired model name.
// Only model-specific IDs may name a mouse. The shared 8K receiver's ID says
// nothing about which model is paired with it.
const XS1_PRODUCT_NAMES: ReadonlyMap<number, string> = new Map([
[0x3404, "Pulsar X2A v3"],
[0x5402, "Pulsar X3 M"],
[EIGHT_K_RECEIVER_PRODUCT_ID, "Pulsar 8K Dongle"],
]);

const LIFT_OFF_DISTANCES: ReadonlyArray<readonly [number, LiftOffDistance]> = [
Expand Down Expand Up @@ -113,7 +118,7 @@ export class PulsarXs1HidClient {
await this.open();
this.deviceInfo = {
connection: PULSAR_XS1_WIRELESS_PRODUCT_IDS.has(this.device.productId) ? "Wireless" : "Wired",
maximumPollingRateHz: 1000,
maximumPollingRateHz: this.device.productId === EIGHT_K_RECEIVER_PRODUCT_ID ? 8000 : 1000,
};
return this.deviceInfo;
}
Expand All @@ -131,23 +136,31 @@ export class PulsarXs1HidClient {
const liftOffDistance = await this.query(QUERY.liftOffDistance).catch(() => null);
const debounce = await this.query(QUERY.debounce).catch(() => null);
const pollingRate = await this.query(QUERY.pollingRate).catch(() => null);
const supportedPollingRates = info.maximumPollingRateHz === 8000 ? EIGHT_K_POLLING_RATES : POLLING_RATES;
const decodedPollingRate = pollingRate ? pulsarXs1DecodePollingRate(pollingRate[7]) : null;
// An unanswered/unknown value is not proof of 1 kHz. Zero is the shared
// MouseStatus sentinel for an unavailable rate (also used by Ajazz/Dareu).
const pollingRateHz = decodedPollingRate !== null && supportedPollingRates.includes(decodedPollingRate)
? decodedPollingRate : 0;
return this.lastStatus = {
brand: "Pulsar",
name: XS1_PRODUCT_NAMES.get(this.device.productId) ?? (this.device.productName || "Pulsar X3"),
name: XS1_PRODUCT_NAMES.get(this.device.productId) ?? (this.device.productName || "Pulsar XS-1 mouse"),
ui: {
family: "pulsar",
hideUnsupportedPollingRates: true,
pollingReadOnly: true,
hideSleepCard: true,
hideSignalCard: true,
forceShowBattery: true,
pollingNote: "The X3 exposes its polling rate through the feature interface, but the reported value is not reliable.",
pollingNote: pollingRateHz > 0
? "The XS-1 polling readout is not hardware-verified. Polling changes are unavailable; check the rate in Pulsar's software."
: "The XS-1 interface did not return a recognized polling rate. Polling changes are unavailable; check the rate in Pulsar's software.",
},
batteryPercent,
batteryState: batteryPercent === null ? "Unknown" : batteryPercent === 100 ? "Full" : "Discharging",
dpi: readUint16LE(dpiReply, DPI_VALUE_OFFSET),
pollingRateHz: pollingRate ? pulsarXs1DecodePollingRate(pollingRate[7]) ?? POLLING_RATES[3] : POLLING_RATES[3],
supportedPollingRates: [...POLLING_RATES],
pollingRateHz,
supportedPollingRates: [...supportedPollingRates],
activeProfile: null,
connectionType: info.connection,
connectionDetail: "XS-1 feature-report interface",
Expand Down Expand Up @@ -254,7 +267,7 @@ export class PulsarXs1HidClient {
await this.delay(RESPONSE_DELAY_MS);
const reply = this.copyDataView(await this.device.receiveFeatureReport(REPORT_ID));
if (reply.length !== PACKET_LENGTH) {
throw new Error(`The Pulsar X3 answered with ${reply.length} bytes instead of ${PACKET_LENGTH}.`);
throw new Error(`The Pulsar XS-1 device answered with ${reply.length} bytes instead of ${PACKET_LENGTH}.`);
}
return reply;
} catch (error) {
Expand All @@ -263,7 +276,7 @@ export class PulsarXs1HidClient {
}
}
throw new Error(
`The Pulsar X3 did not answer ${this.describe(command)} after ${RESPONSE_ATTEMPTS} attempts. ${this.describeError(lastError)}`,
`The Pulsar XS-1 device did not answer ${this.describe(command)} after ${RESPONSE_ATTEMPTS} attempts. ${this.describeError(lastError)}`,
);
}

Expand All @@ -272,7 +285,7 @@ export class PulsarXs1HidClient {
try {
await this.device.sendFeatureReport(REPORT_ID, pulsarXs1EncodeRequest(command));
} catch (error) {
throw new Error(`Chrome could not write the Pulsar X3 feature report. ${this.describeError(error)}`);
throw new Error(`The browser could not write the Pulsar XS-1 feature report. ${this.describeError(error)}`);
}
}

Expand Down
Loading