diff --git a/docs/pulsar-x2a-v3-testing.md b/docs/pulsar-x2a-v3-testing.md new file mode 100644 index 0000000..4c96bcf --- /dev/null +++ b/docs/pulsar-x2a-v3-testing.md @@ -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. diff --git a/src/drivers/pulsar/pulsar-xs1-hid.test.ts b/src/drivers/pulsar/pulsar-xs1-hid.test.ts index f3c19ef..48a3f03 100644 --- a/src/drivers/pulsar/pulsar-xs1-hid.test.ts +++ b/src/drivers/pulsar/pulsar-xs1-hid.test.ts @@ -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, @@ -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; @@ -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); diff --git a/src/drivers/pulsar/pulsar-xs1-hid.ts b/src/drivers/pulsar/pulsar-xs1-hid.ts index 4ef8c0d..82d3afa 100644 --- a/src/drivers/pulsar/pulsar-xs1-hid.ts +++ b/src/drivers/pulsar/pulsar-xs1-hid.ts @@ -24,11 +24,16 @@ type LiftOffDistance = NonNullable; 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 = new Map([ + [0x3404, "Pulsar X2A v3"], [0x5402, "Pulsar X3 M"], + [EIGHT_K_RECEIVER_PRODUCT_ID, "Pulsar 8K Dongle"], ]); const LIFT_OFF_DISTANCES: ReadonlyArray = [ @@ -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; } @@ -131,9 +136,15 @@ 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, @@ -141,13 +152,15 @@ export class PulsarXs1HidClient { 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", @@ -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) { @@ -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)}`, ); } @@ -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)}`); } }