diff --git a/src/drivers/pulsar/model-names.ts b/src/drivers/pulsar/model-names.ts new file mode 100644 index 00000000..6f3bfd5d --- /dev/null +++ b/src/drivers/pulsar/model-names.ts @@ -0,0 +1,138 @@ +// Source: https://bbb.pulsar.gg/cMouse/devicename.json (2026-11-12). +// Source SHA-256: b0470ebf70f0ffbb2eed597f92cc381fc5eb5c2d7924560c2969d0fc6debe8e9 +// Keys combine CID and MID: (cid << 8) | mid. Vendor arrays use MID - 1. +// Generic "Pulsar device" placeholders are omitted. Names do not grant support. +export const PULSAR_MODEL_NAMES: ReadonlyMap = new Map([ + [0x5701, "Pulsar X2 CrazyLight Mini"], + [0x5702, "Pulsar X2 CrazyLight Mini"], + [0x5703, "Pulsar X2 CrazyLight Mini"], + [0x5704, "Pulsar X2 CrazyLight Mini"], + [0x5705, "Pulsar X2 CrazyLight Mini"], + [0x5706, "Pulsar X2 CrazyLight Mini"], + [0x5707, "Pulsar TenZ Signature Edition"], + [0x5708, "Pulsar TenZ"], + [0x5709, "Pulsar X2 CrazyLight Mini"], + [0x570a, "Pulsar X2 CrazyLight Mini"], + [0x570b, "Pulsar X VSPO! Aizawa Ema"], + [0x570c, "Pulsar X2 CrazyLight Mini T1 Edition"], + [0x570d, "Pulsar X2 CrazyLight Mini T1 Edition"], + [0x570e, "Pulsar X2 CrazyLight Mini PRX Edition"], + [0x570f, "Pulsar X2 CrazyLight Mini Boardzy Edition"], + [0x5710, "Pulsar X2 CrazyLight Mini Randomfrankp Edition"], + [0x5711, "Pulsar Xlite CrazyLight Mini"], + [0x5712, "Pulsar Xlite CrazyLight Mini"], + [0x5713, "Pulsar X3 CrazyLight Mini"], + [0x5714, "Pulsar X3 CrazyLight Mini"], + [0x5715, "Pulsar X3 LHD CrazyLight Mini"], + [0x5716, "Pulsar X3 LHD CrazyLight Mini"], + [0x5717, "Pulsar X2H CrazyLight Mini"], + [0x5718, "Pulsar X2H CrazyLight Mini"], + [0x5719, "Pulsar X2N CrazyLight Mini"], + [0x571a, "Pulsar X2N CrazyLight Mini"], + [0x571b, "Pulsar X2 CrazyLight Medium"], + [0x571c, "Pulsar X2 Mini Pulsar By You XS-1"], + [0x571d, "Pulsar X2H CrazyLight Medium"], + [0x571e, "Pulsar X2H Medium Pulsar By You XS-1"], + [0x571f, "Pulsar X2A Medium Pulsar By You XS-1"], + [0x5720, "Pulsar X2A Mini Pulsar By You XS-1"], + [0x5721, "Pulsar Xlite Mini Pulsar By You XS-1"], + [0x5722, "Pulsar Xlite CrazyLight Medium"], + [0x5723, "Pulsar Xlite Large Pulsar By You XS-1"], + [0x5724, "Pulsar Lab. X2F"], + [0x5725, "Pulsar TenZ Signature RED"], + [0x5726, "Pulsar JV-X Pro Series"], + [0x5727, "Pulsar Susanto-X Pro Series"], + [0x5728, "Pulsar TenZ Signature Edition"], + [0x5729, "Pulsar X2 CrazyLight 5th Anniversary Edition"], + [0x572a, "Pulsar X2 CrazyLight WildScape Desert Edition"], + [0x572b, "Pulsar X2 CrazyLight Mini Forest"], + [0x572c, "Pulsar X2 CrazyLight Mini WildScape Ocean Edition"], + [0x572d, "Pulsar X VSPO! Asumi Sena"], + [0x572e, "Pulsar X VSPO! Hanabusa Lisa"], + [0x572f, "Pulsar X VSPO! Ichinose Uruha"], + [0x5730, "Pulsar X VSPO! Kaga Sumire"], + [0x5731, "Pulsar X VSPO! Kisaragi Ren"], + [0x5732, "Pulsar X VSPO! Kogara Toto"], + [0x5733, "Pulsar X VSPO! Komori Met"], + [0x5734, "Pulsar X VSPO! Kurumi Noah"], + [0x5735, "Pulsar X VSPO! Kaga Nazuna"], + [0x5736, "Pulsar X VSPO! Nekota Tsuna"], + [0x5737, "Pulsar X VSPO! Kaminari Qpi"], + [0x5738, "Pulsar X VSPO! Sendo Yuuhi"], + [0x5739, "Pulsar X VSPO! Shinomiya Runa"], + [0x573a, "Pulsar X VSPO! Shiranami Ramune"], + [0x573b, "Pulsar X VSPO! Tachibana Hinano"], + [0x573c, "Pulsar X VSPO! Tosaki Mimi"], + [0x573d, "Pulsar X VSPO! Tsumugi Kokage"], + [0x573e, "Pulsar X VSPO! Yakumo Beni"], + [0x573f, "Pulsar X VSPO! Yano Kuromu"], + [0x5740, "Pulsar X VSPO! Yumeno Akari"], + [0x5741, "Pulsar X VSPO! Amayui Moka"], + [0x5742, "Pulsar X VSPO! Choya Hanabi"], + [0x5743, "Pulsar Lab. X2F 5th Anniversary Edition"], + [0x5744, "Pulsar ZywOo The Chosen Mouse Gen.2"], + [0x5745, "Pulsar ZywOo The Chosen Mouse Gen.2"], + [0x5746, "Pulsar X2 CrazyLight Medium"], + [0x5747, "Pulsar Xlite CrazyLight Medium"], + [0x5748, "Pulsar X3 CrazyLight Medium"], + [0x5749, "Pulsar X3 CrazyLight Medium"], + [0x574a, "Pulsar X3 LHD CrazyLight Medium"], + [0x574b, "Pulsar X3 LHD CrazyLight Medium"], + [0x574c, "Pulsar X2H CrazyLight Medium"], + [0x574d, "Pulsar X2N CrazyLight Medium"], + [0x574e, "Pulsar X2N CrazyLight Medium"], + [0x574f, "Pulsar X2 CrazyLight Medium The Finals Edition"], + [0x5750, "Pulsar X2 CrazyLight Medium THE GRAND MAJOR 2025 Edition"], + [0x5751, "Pulsar X2 CrazyLight Medium Bruce Lee 85th Edition"], + [0x5752, "Pulsar X2 CrazyLight Mini Bruce Lee 85th Edition"], + [0x5753, "Pulsar X2 CrazyLight Medium PRX Pacific Gold Edition"], + [0x5754, "Pulsar X2 CrazyLight Medium Pokemon Pikachu Edition"], + [0x5755, "Pulsar X2 CrazyLight Mini Pokemon Pikachu Edition"], + [0x5756, "Pulsar X2 CrazyLight Mini Pokemon Mew-Two FF Edition"], + [0x5757, "Pulsar X2 CrazyLight Medium BlueArchive Hoshino Edition"], + [0x5758, "Pulsar X2 CrazyLight Medium BlueArchive Shiroko Edition"], + [0x5759, "Pulsar X2 CrazyLight Medium BlueArchive Nonomi Edition"], + [0x575a, "Pulsar X2 CrazyLight Medium BlueArchive Hoshino FF Edition"], + [0x575b, "Pulsar eS FS-1 Medium. Wireless Gaming mouse"], + [0x575c, "Pulsar BUZZ-X Pro Series - MOON GRAY"], + [0x575d, "Pulsar STA-X Pro Series - STEALTH BLACK"], + [0x575e, "Pulsar CARPE-X Pro Series - CARP ORANGE"], + [0x575f, "Pulsar X2 CrazyLight Mini Pokemon Mew-Two FF Edition"], + [0x5760, "Pulsar X2 CrazyLight Mini PRX Pacific Gold Edition"], + [0x5761, "Pulsar X2H CrazyLight Mini BadSeedTech Edition"], + [0x5762, "Pulsar X2H CrazyLight Medium BadSeedTech Edition"], + [0x5763, "Pulsar Jinggg-X Pro Series"], + [0x5764, "Pulsar ZywOo The Chosen Mouse Gen.2"], + [0x5765, "Pulsar Feinmann F01 Noctua Edition"], + [0x5766, "Pulsar X3 CrazyLight Desert"], + [0x5767, "Pulsar X3 CrazyLight Medium Desert"], + [0x5768, "Pulsar X3 LHD CrazyLight Medium Desert"], + [0x5769, "Pulsar X2N CrazyLight Mini Ocean"], + [0x576a, "Pulsar X2N CrazyLight Medium Ocean"], + [0x576b, "Pulsar TenZ 2.0 Mini Misty Blue"], + [0x576d, "Pulsar X2 CrazyLight Medium Forest"], + [0x576e, "Pulsar TenZ Signature Edition 2.0 Mini"], + [0x5770, "Pulsar Susanto-X 2.0 Large Black"], + [0x5774, "Pulsar Xlite CrazyLight Rock"], + [0x5775, "Pulsar X2H CrazyLight Volcano"], + [0x5776, "Pulsar Xlite CrazyLight Medium Rock"], + [0x5777, "Pulsar X2H CrazyLight Medium Volcano"], + [0x5778, "Pulsar Susanto-X 2.0 Large Phantom Jungle"], + [0x577a, "Pulsar TenZ Signature Edition 2.0 Medium"], + [0x577b, "Pulsar TenZ 2.0 Medium Misty Blue"], + [0x577c, "Pulsar Susanto-X 2.0 Medium Phantom Jungle"], + [0x577d, "Pulsar Susanto-X 2.0 Medium Black"], + [0x577e, "Pulsar X2F CrazyLight Black"], + [0x577f, "Pulsar eS FS-1 GT Silver"], + [0x5780, "Pulsar eS FS-1 Tournament Red"], + [0x5781, "Pulsar eS FS-1 Tournament Blue"], + [0x5782, "Pulsar X2 CrazyLight Mini Shibuki"], + [0x5783, "Pulsar X2 CrazyLight Mini Yuni"], + [0x5784, "Pulsar X2 CrazyLight Mini Mashiro"], + [0x5785, "Pulsar ZywOo The Chosen Mouse Gen.3 Mini"], + [0x5786, "Pulsar ZywOo The Chosen Mouse Gen.3 Mini"], + [0x5787, "Pulsar ZywOo The Chosen Mouse Gen.3 Medium"], + [0x5788, "Pulsar ZywOo The Chosen Mouse Gen.3 Medium"], + [0x5792, "Pulsar X2 CrazyLight 2 Mini 2026 PRX Edition"], + [0x5793, "Pulsar X2 CrazyLight 2 Medium 2026 PRX Edition"], +]); diff --git a/src/drivers/pulsar/pulsar-hid.test.ts b/src/drivers/pulsar/pulsar-hid.test.ts index 2128024b..3376e121 100644 --- a/src/drivers/pulsar/pulsar-hid.test.ts +++ b/src/drivers/pulsar/pulsar-hid.test.ts @@ -45,11 +45,14 @@ test("rejects devices without the report-8 control collection", () => { }); /** Answers report 8 from a flash image, enough for a write and its read-back. */ -function flashDevice(): HIDDevice & { flash: Uint8Array } { +function flashDevice({ + vendorId = 0x3710, productId = 0x0001, productName = "Pulsar 8K Dongle", cid = 0, mid = 0, +} = {}): HIDDevice & { flash: Uint8Array } { const flash = new Uint8Array(256); let listener: ((event: HIDInputReportEvent) => void) | null = null; const fake = { - ...device(0x3710, 0x0001), + ...device(vendorId, productId), + productName, flash, opened: true, addEventListener: (_: string, fn: (event: HIDInputReportEvent) => void) => { listener = fn; }, @@ -58,6 +61,11 @@ function flashDevice(): HIDDevice & { flash: Uint8Array } { const packet = new Uint8Array(data as Uint8Array); const reply = new Uint8Array(16); reply.set(packet.subarray(0, 5)); + reply[1] = 0; + if (packet[0] === PULSAR_COMMAND.encryptionData) { + reply[9] = cid; + reply[10] = mid; + } const address = (packet[2]! << 8) | packet[3]!; if (packet[0] === PULSAR_COMMAND.deviceOnline) reply[5] = 1; if (packet[0] === PULSAR_COMMAND.readFlashData) reply.set(flash.subarray(address, address + packet[4]!), 5); @@ -68,6 +76,63 @@ function flashDevice(): HIDDevice & { flash: Uint8Array } { return fake as unknown as HIDDevice & { flash: Uint8Array }; } +test("resolves model names and preserves product-name and generic fallbacks", async () => { + for (const { options, expected } of [ + { options: { productId: 0x5406, cid: 0x57, mid: 0x14 }, expected: "Pulsar X3 CrazyLight Mini" }, + { options: { productId: 0x5406, cid: 0x57, mid: 0x11 }, expected: "Pulsar Xlite CrazyLight Mini" }, + { options: { productId: 0x5406, cid: 0x57, mid: 0x12 }, expected: "Pulsar Xlite CrazyLight Mini" }, + { options: { productId: 0x5406, cid: 0x57, mid: 0x22 }, expected: "Pulsar Xlite CrazyLight Medium" }, + { options: { productId: 0x5406, cid: 0x57, mid: 0x47 }, expected: "Pulsar Xlite CrazyLight Medium" }, + { options: { productId: 0x5406, cid: 0x57, mid: 0x48 }, expected: "Pulsar X3 CrazyLight Medium" }, + { options: { productId: 0x5406, cid: 0x57, mid: 0x15 }, expected: "Pulsar X3 LHD CrazyLight Mini" }, + { options: { productId: 0x5406 }, expected: "Pulsar CrazyLight 8K Dongle" }, + { options: { productId: 0x5406, cid: 0x58, mid: 0x14 }, expected: "Pulsar CrazyLight 8K Dongle" }, + { options: { productId: 0x5406, cid: 0x57, mid: 0xff }, expected: "Pulsar CrazyLight 8K Dongle" }, + { options: { productId: 0x5403, cid: 0x57, mid: 0x04 }, expected: "Pulsar X2 CrazyLight Mini" }, + { options: { productId: 0x3414, cid: 0x57, mid: 0x14 }, expected: "Pulsar X3 CrazyLight Mini" }, + { options: { productId: 0x5403 }, expected: "Pulsar 8K Dongle" }, + { options: { vendorId: 0x3554, productId: 0x5406 }, expected: "Pulsar 8K Dongle" }, + { options: { vendorId: 0x3554, cid: 0x57, mid: 0x04 }, expected: "Pulsar 8K Dongle" }, + { options: { cid: 0x57, mid: 0xff }, expected: "Pulsar 8K Dongle" }, + { options: { cid: 0x58, mid: 0x04 }, expected: "Pulsar 8K Dongle" }, + { options: { productName: "Pulsar X2 Wireless" }, expected: "Pulsar X2 Wireless" }, + { options: { productName: "" }, expected: "Pulsar Mouse" }, + ]) { + const status = await new PulsarHidClient(flashDevice(options)).readStatus(); + assert.equal(status.name, expected, JSON.stringify(options)); + } +}); + +test("names other Pulsar families and editions from the handshake on wired and receiver PIDs", async () => { + for (const productId of [0x3506, 0x5406]) { + for (const [mid, name] of [ + [0x07, "Pulsar TenZ Signature Edition"], + [0x17, "Pulsar X2H CrazyLight Mini"], + [0x19, "Pulsar X2N CrazyLight Mini"], + [0x1f, "Pulsar X2A Medium Pulsar By You XS-1"], + [0x24, "Pulsar Lab. X2F"], + [0x26, "Pulsar JV-X Pro Series"], + [0x2d, "Pulsar X VSPO! Asumi Sena"], + [0x44, "Pulsar ZywOo The Chosen Mouse Gen.2"], + [0x65, "Pulsar Feinmann F01 Noctua Edition"], + [0x6b, "Pulsar TenZ 2.0 Mini Misty Blue"], + [0x6d, "Pulsar X2 CrazyLight Medium Forest"], + [0x93, "Pulsar X2 CrazyLight 2 Medium 2026 PRX Edition"], + ] satisfies [number, string][]) { + const fake = flashDevice({ productId, cid: 0x57, mid }); + assert.equal(PulsarHidClient.isSupported(fake), true); + assert.equal((await new PulsarHidClient(fake).readStatus()).name, name); + } + } +}); + +test("unnamed vendor placeholders and out-of-range MIDs retain the fallback", async () => { + for (const mid of [0x00, 0x6c, 0x6f, 0x89, 0x95, 0x96, 0xff]) { + const fake = flashDevice({ productId: 0x5406, cid: 0x57, mid }); + assert.equal((await new PulsarHidClient(fake).readStatus()).name, "Pulsar CrazyLight 8K Dongle"); + } +}); + test("setButtonMapping writes one key record at 96 + 4 * slot", async () => { const fake = flashDevice(); [[KEY.mouse, 0x0100], [KEY.mouse, 0x0200], [KEY.mouse, 0x0400], [KEY.mouse, 0x0800], [KEY.mouse, 0x1000], [KEY.dpi, 0x0100]] diff --git a/src/drivers/pulsar/pulsar-hid.ts b/src/drivers/pulsar/pulsar-hid.ts index 629a561c..2fa627c7 100644 --- a/src/drivers/pulsar/pulsar-hid.ts +++ b/src/drivers/pulsar/pulsar-hid.ts @@ -1,4 +1,5 @@ import type { MouseStatus } from "../mouse-types.ts"; +import { PULSAR_MODEL_NAMES } from "./model-names.ts"; import { PULSAR_COMMAND as COMMAND, PULSAR_CONFIG_PACKET_LENGTH as CONFIG_PACKET_LENGTH, @@ -154,9 +155,14 @@ export class PulsarHidClient { const currentDpi = Math.min(flash[FLASH.currentDpi] ?? 0, 7); const dpi = this.decodeDpi(flash.slice(FLASH.dpiValues + currentDpi * 4, FLASH.dpiValues + currentDpi * 4 + 4)) ?? 800; const lodValue = flash[FLASH.liftOffDistance]; + const modelName = this.device.vendorId === PULSAR_VENDOR_ID + ? PULSAR_MODEL_NAMES.get((info.cid << 8) | info.mid) + ?? (this.device.productId === 0x5406 ? "Pulsar CrazyLight 8K Dongle" : null) + : null; + return { brand: "Pulsar", - name: info.cid === 0x57 && info.mid === 0x04 ? "Pulsar X2 CrazyLight" : (this.device.productName || "Pulsar Mouse"), + name: modelName ?? (this.device.productName || "Pulsar Mouse"), ui: { family: "pulsar", hideUnsupportedPollingRates: true }, batteryPercent: battery[5] <= 100 ? battery[5] : null, batteryState: battery[6] === 1 ? "Charging" : "Discharging", diff --git a/src/drivers/pulsar/pulsar-pro-hid.test.ts b/src/drivers/pulsar/pulsar-pro-hid.test.ts index f4db6fe7..16417fed 100644 --- a/src/drivers/pulsar/pulsar-pro-hid.test.ts +++ b/src/drivers/pulsar/pulsar-pro-hid.test.ts @@ -3,6 +3,8 @@ import test from "node:test"; Object.assign(globalThis, { window: globalThis }); +import { createSupportedClient } from "../registry.ts"; + const { PulsarProHidClient } = await import("./pulsar-pro-hid.ts"); class FakePulsarProDevice { @@ -29,6 +31,7 @@ class FakePulsarProDevice { async sendReport(_reportId: number, data: BufferSource): Promise { this.sends += 1; const view = data as ArrayBufferView; + assert.equal(view.byteLength, 64, "wireless capture uses 64-byte transfers too"); const command = new Uint8Array(view.buffer, view.byteOffset, view.byteLength)[0] ?? 0; const response = new Uint8Array(64); response[0] = 0xa6; @@ -64,3 +67,244 @@ test("a command reopens a receiver that closed without an explicit client close" assert.equal(device.opens, 1); assert.equal(device.sends, 1); }); + +function wiredDevice({ + productId = 0x7401, + dpiConfiguration = new Uint8Array(Buffer.from("0602900190012003200340064006800c800c0019001900320032", "hex")), + ignoreDpiWrites = false, +} = {}) { + const sent: Uint8Array[] = []; + const values = new Map([ + [0xb1, dpiConfiguration], + [0xb2, new Uint8Array([0xe8, 0x03])], + [0xb3, new Uint8Array([1])], + [0xb4, new Uint8Array([80])], + [0xb5, new Uint8Array([1])], + [0xb6, new Uint8Array([1])], + [0xb7, new Uint8Array([1])], + [0xc4, new Uint8Array([1])], + [0xc3, new Uint8Array()], + ]); + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const device = { + vendorId: 0x3710, + productId, + opened: true, + collections: [{ + usagePage: 0xff00, usage: 1, type: 1, children: [], + inputReports: [{ reportId: 0, items: [{ reportSize: 8, reportCount: 64 }] }], + outputReports: [{ reportId: 0, items: [{ reportSize: 8, reportCount: 64 }] }], + featureReports: [], + }], + open: async () => {}, close: async () => {}, + addEventListener: (_type: string, callback: typeof listener) => { listener = callback; }, + removeEventListener: () => { listener = null; }, + sendReport: async (reportId: number, data: Uint8Array) => { + assert.equal(reportId, 0); + assert.equal(data.length, 64); + sent.push(data.slice()); + const command = data[0]; + if (!values.has(command)) { + queueMicrotask(() => listener?.({ reportId: 0, data: new DataView(new Uint8Array([0xa6]).buffer) } as HIDInputReportEvent)); + return; + } + if (data[1] === 1 && !(command === 0xb1 && ignoreDpiWrites)) { + values.set(command, data.slice(2, command === 0xb1 ? 4 + data[2] * 4 : command === 0xb2 ? 4 : 3)); + } + const reply = new Uint8Array(64); + reply[0] = command; + reply[1] = data[1]; + reply.set(values.get(command) ?? [], 2); + queueMicrotask(() => listener?.({ reportId: 0, data: new DataView(reply.buffer) } as HIDInputReportEvent)); + }, + } as unknown as HIDDevice; + return { device, sent }; +} + +test("selects the Pro client for wired control and rejects an input-only interface", () => { + const { device } = wiredDevice(); + assert.equal(PulsarProHidClient.isSupported(device), true); + assert.ok(createSupportedClient(device) instanceof PulsarProHidClient); + const pointer = { ...device, collections: [{ ...device.collections[0]!, outputReports: [] }] } as HIDDevice; + assert.equal(PulsarProHidClient.isSupported(pointer), false); + +}); + +test("encodes wired polling writes through 8 kHz and reads them back", async () => { + const { device, sent } = wiredDevice(); + const client = new PulsarProHidClient(device); + const info = await client.readDeviceInfo(); + assert.equal(info.connection, "Wired"); + assert.equal(info.maximumPollingRateHz, 8000); + for (const rate of [2000, 4000, 8000]) { + assert.equal(await client.setPollingRate(rate), rate); + } + assert.deepEqual([...sent[4]!.slice(0, 4)], [0xb2, 1, 0x40, 0x1f]); + await client.close(); +}); + +test("replays captured wired polling and toggle writes without receiver save-allow", async () => { + const { device, sent } = wiredDevice(); + const client = new PulsarProHidClient(device); + await client.open(); + assert.equal(await client.setPollingRate(500), 500); + assert.equal(await client.setPollingRate(1000), 1000); + assert.equal(await client.setMotionSync(false), false); + assert.equal(await client.setRippleControl(false), false); + assert.equal(await client.setAngleSnapping(false), false); + assert.equal(await client.setPerformanceMode(false), false); + // Toggle assignments retain the existing Pro mapping; recalled action + // order does not establish their names from the capture. + // Frames 8259, 8591, 9935, 10575, 11023, 11589, padded to 64 bytes. + const capturedPrefixes = ["b201f401", "b201e803", "b50100", "b60100", "b70100", "c40100"]; + const writes = sent.filter((packet) => packet[1] === 1); + assert.deepEqual(writes.map((packet) => Buffer.from(packet).toString("hex")), + capturedPrefixes.map((prefix) => prefix.padEnd(128, "0"))); + assert.equal(sent.length, 12, "each write is followed only by its readback query"); + await client.close(); +}); + +for (const productId of [0x7401, 0x5405]) { + test(`serializes concurrent DPI updates through readback, PID ${productId.toString(16)}`, async () => { + const { device, sent } = wiredDevice({ productId }); + const client = new PulsarProHidClient(device); + await client.open(); + assert.deepEqual(await Promise.all([ + client.setDpiStageValue(0, 1000), + client.setDpiStageValue(1, 1200), + client.setActiveDpiStage(1), + client.setDpi(1400), + ]), [1000, 1200, 1, 1400]); + const transaction = productId === 0x7401 + ? [[0xb1, 0], [0xb1, 1], [0xb1, 0]] + : [[0xb1, 0], [0xb1, 1], [0xc3, 1], [0xb1, 0]]; + assert.deepEqual(sent.map((packet) => [...packet.slice(0, 2)]), + Array.from({ length: 4 }, () => transaction).flat()); + const status = await client.readStatus(); + assert.deepEqual(status.dpiStages, [1000, 1400, 1600, 3200, 6400, 12800]); + assert.equal(status.activeDpiStage, 1); + assert.equal(status.dpi, 1400); + await client.close(); + }); + + test(`releases the DPI update lock after a failed readback, PID ${productId.toString(16)}`, async () => { + const { device, sent } = wiredDevice({ productId, ignoreDpiWrites: true }); + const client = new PulsarProHidClient(device); + await client.open(); + const failedUpdate = assert.rejects(client.setDpiStageValue(0, 1200), /did not keep 1200 DPI/); + const nextUpdate = client.setActiveDpiStage(2); + await failedUpdate; + assert.equal(await nextUpdate, 2); + assert.equal(sent.filter((packet) => packet[0] === 0xb1 && packet[1] === 1).length, 2); + await client.close(); + }); + + test(`exposes enabled DPI stages and a fixed-count editor, PID ${productId.toString(16)}`, async () => { + for (const dpiConfiguration of [ + new Uint8Array(Buffer.from("0602900190012003200340064006800c800c0019001900320032", "hex")), + new Uint8Array([2, 1, 0x90, 1, 0x90, 1, 0x20, 3, 0x40, 6]), + ]) { + const { device } = wiredDevice({ productId, dpiConfiguration }); + const client = new PulsarProHidClient(device); + const status = await client.readStatus(); + assert.deepEqual(status.dpiStages, dpiConfiguration[0] === 6 ? [400, 800, 1600, 3200, 6400, 12800] : [400, 800]); + assert.equal(status.activeDpiStage, dpiConfiguration[1]); + assert.equal(status.dpi, dpiConfiguration[0] === 6 ? 1600 : 800); + assert.deepEqual(status.firmware, productId === 0x7401 + ? ["Mouse firmware unavailable"] + : ["Mouse firmware unavailable", "Dongle firmware unavailable"]); + assert.deepEqual(status.ui?.dpiStageEditor, { + maxStages: 6, countEditable: false, minDpi: 50, maxDpi: 32000, stepDpi: 50, + }); + if (productId === 0x7401) { + assert.equal(status.ui?.family, "pulsar"); + assert.equal(status.ui?.hideUnsupportedPollingRates, true); + assert.equal(status.ui?.hideSignalCard, true); + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000, 2000, 4000, 8000]); + } + await client.close(); + } + }); + + test(`selects stages using captured B1 packets without changing the table, PID ${productId.toString(16)}`, async () => { + const { device, sent } = wiredDevice({ productId }); + const client = new PulsarProHidClient(device); + await client.open(); + assert.equal(await client.setActiveDpiStage(1), 1); + assert.equal(await client.setActiveDpiStage(2), 2); + // Wired frames 7401/7505 and wireless frames 4863/5255. + const writes = sent.filter((packet) => packet[0] === 0xb1 && packet[1] === 1); + assert.deepEqual(writes.map((packet) => Buffer.from(packet).toString("hex")), [ + "b1010601900190012003200340064006800c800c0019001900320032".padEnd(128, "0"), + "b1010602900190012003200340064006800c800c0019001900320032".padEnd(128, "0"), + ]); + assert.equal(sent.filter((packet) => packet[0] === 0xc3).length, productId === 0x7401 ? 0 : 2); + await client.close(); + }); + + test(`edits an inactive stage on both axes while preserving the active stage and other values, PID ${productId.toString(16)}`, async () => { + const { device, sent } = wiredDevice({ productId }); + const client = new PulsarProHidClient(device); + await client.open(); + assert.equal(await client.setDpiStageValue(1, 1200), 1200); + const write = sent.find((packet) => packet[0] === 0xb1 && packet[1] === 1); + assert.ok(write); + assert.equal(Buffer.from(write).toString("hex"), + "b101060290019001b004b00440064006800c800c0019001900320032".padEnd(128, "0")); + // setDpi still edits the active stage, rather than selecting the matching preset. + assert.equal(await client.setDpi(800), 800); + const writes = sent.filter((packet) => packet[0] === 0xb1 && packet[1] === 1); + assert.equal(Buffer.from(writes[1]!).toString("hex"), + "b101060290019001b004b00420032003800c800c0019001900320032".padEnd(128, "0")); + await client.close(); + }); +} + +test("DPI setters reject invalid indices and values before sending commands", async () => { + const { device, sent } = wiredDevice(); + const client = new PulsarProHidClient(device); + for (const stage of [-1, 6, 1.5, NaN, Infinity]) { + await assert.rejects(client.setActiveDpiStage(stage), RangeError); + await assert.rejects(client.setDpiStageValue(stage, 800), RangeError); + } + for (const dpi of [0, 825, 32050, NaN]) { + await assert.rejects(client.setDpiStageValue(0, dpi), /Unsupported Pulsar Pro DPI/); + } + assert.equal(sent.length, 0); + await client.close(); +}); + +test("DPI setters preserve a shorter cycle and refuse disabled stages", async () => { + const { device, sent } = wiredDevice({ dpiConfiguration: new Uint8Array([2, 0, 0x90, 1, 0x90, 1, 0x20, 3, 0x40, 6]) }); + const client = new PulsarProHidClient(device); + await client.open(); + await assert.rejects(client.setActiveDpiStage(2), /outside.*2-stage cycle/); + await assert.rejects(client.setDpiStageValue(2, 1600), /outside.*2-stage cycle/); + assert.equal(sent.filter((packet) => packet[1] === 1).length, 0); + assert.equal(await client.setActiveDpiStage(1), 1); + const write = sent.find((packet) => packet[1] === 1); + assert.ok(write); + assert.equal(Buffer.from(write).toString("hex"), "b10102019001900120034006".padEnd(128, "0")); + await client.close(); +}); + +test("DPI setters reject malformed device configurations without writing", async () => { + for (const dpiConfiguration of [new Uint8Array([0, 0]), new Uint8Array([7, 0]), new Uint8Array([2, 2])]) { + const { device, sent } = wiredDevice({ dpiConfiguration }); + const client = new PulsarProHidClient(device); + await client.open(); + await assert.rejects(client.setActiveDpiStage(0), /invalid DPI stage configuration/); + await assert.rejects(client.setDpiStageValue(0, 800), /invalid DPI stage configuration/); + assert.equal(sent.filter((packet) => packet[1] === 1).length, 0); + await client.close(); + } +}); + +test("DPI setters detect acknowledged writes that the device did not retain", async () => { + const { device } = wiredDevice({ ignoreDpiWrites: true }); + const client = new PulsarProHidClient(device); + await client.open(); + await assert.rejects(client.setActiveDpiStage(1), /kept DPI stage 2 instead of 1/); + await assert.rejects(client.setDpiStageValue(1, 1200), /did not keep 1200 DPI/); + await client.close(); +}); diff --git a/src/drivers/pulsar/pulsar-pro-hid.ts b/src/drivers/pulsar/pulsar-pro-hid.ts index 7d722863..db792b73 100644 --- a/src/drivers/pulsar/pulsar-pro-hid.ts +++ b/src/drivers/pulsar/pulsar-pro-hid.ts @@ -2,7 +2,8 @@ import type { MouseStatus } from "../mouse-types.ts"; import type { PulsarDeviceInfo } from "./pulsar-hid.ts"; import { PULSAR_PRO_COMMAND as COMMAND, - PULSAR_PRO_PRODUCT_ID as PRO_DONGLE_PRODUCT_ID, + PULSAR_PRO_PRODUCT_IDS, + PULSAR_PRO_WIRED_PRODUCT_IDS, PULSAR_PRO_REPORT_ID as REPORT_ID, PULSAR_PRO_REPORT_LENGTH as REPORT_LENGTH, PULSAR_VENDOR_ID as VENDOR_ID, @@ -13,6 +14,7 @@ import { export class PulsarProHidClient { private commandQueue: Promise = Promise.resolve(); + private dpiUpdateQueue: Promise = Promise.resolve(); private readonly unsupportedQueries = new Set(); private explicitlyClosed = false; private waiter: { @@ -43,14 +45,20 @@ export class PulsarProHidClient { static isSupported(device: HIDDevice): boolean { return device.vendorId === VENDOR_ID - && device.productId === PRO_DONGLE_PRODUCT_ID + && PULSAR_PRO_PRODUCT_IDS.has(device.productId) && device.collections.some((collection) => - collection.usagePage === 1 - && collection.usage === 0 + // The wired capture lacks the HID report descriptor, so do not + // assume the receiver's usage. The known PID and report pair select + // the control interface and exclude the pointer/keyboard interfaces. + (PULSAR_PRO_WIRED_PRODUCT_IDS.has(device.productId) || (collection.usagePage === 1 && collection.usage === 0)) && collection.inputReports.some((report) => report.reportId === REPORT_ID) && collection.outputReports.some((report) => report.reportId === REPORT_ID)); } + private get wired(): boolean { + return PULSAR_PRO_WIRED_PRODUCT_IDS.has(this.device.productId); + } + async open(): Promise { if (!this.device.opened) await this.device.open(); this.explicitlyClosed = false; @@ -67,9 +75,9 @@ export class PulsarProHidClient { return { cid: 0, mid: 0, - type: 3, - dongleType: 5, - connection: "Wireless", + type: this.wired ? 2 : 3, + dongleType: this.wired ? 0xff : 5, + connection: this.wired ? "Wired" : "Wireless", maximumPollingRateHz: 8000, }; } @@ -77,7 +85,7 @@ export class PulsarProHidClient { async readStatus(): Promise { await this.open(); const battery = await this.query(COMMAND.battery); - const dpi = await this.query(COMMAND.dpi); + const dpi = await this.readDpiConfiguration(); const polling = await this.query(COMMAND.polling); const lod = await this.query(COMMAND.lod); const motion = await this.queryOptional(COMMAND.motionSync); @@ -94,9 +102,9 @@ export class PulsarProHidClient { const sleep = await this.queryOptional(COMMAND.powerSaving, 1500); const profile = await this.queryOptional(COMMAND.profile, 1500); const mouseVersion = await this.queryOptional(COMMAND.mouseVersion); - const dongleVersion = await this.queryOptional(COMMAND.dongleVersion); - const rssi = await this.queryOptional(COMMAND.rssi); - const dpiIndex = Math.min(dpi[3] ?? 0, 7); + const dongleVersion = this.wired ? null : await this.queryOptional(COMMAND.dongleVersion); + const rssi = this.wired ? null : await this.queryOptional(COMMAND.rssi); + const dpiIndex = dpi[3]; const dpiOffset = 4 + dpiIndex * 4; const sleepMs = sleep ? readUint32LE(sleep, 2) : 0; return { @@ -105,9 +113,19 @@ export class PulsarProHidClient { batteryPercent: battery[0] === COMMAND.battery ? Math.min(battery[1] ?? 0, 100) : null, batteryState: battery[6] === 1 ? "Charging" : "Discharging", dpi: readUint16LE(dpi, dpiOffset), + dpiStages: Array.from({ length: dpi[2] }, (_, stage) => readUint16LE(dpi, 4 + stage * 4)), + activeDpiStage: dpiIndex, pollingRateHz: readUint16LE(polling, 2), activeProfile: profile ? (profile[2] ?? 0) + 1 : null, - connectionDetail: "PID 0x5405 · Pulsar Pro protocol · Wireless", + connectionType: this.wired ? "Wired" : "Wireless", + connectionDetail: `PID 0x${this.device.productId.toString(16)} · Pulsar Pro protocol · ${this.wired ? "Wired" : "Wireless"}`, + ...(this.wired ? { + supportedPollingRates: [125, 250, 500, 1000, 2000, 4000, 8000], + } : {}), + ui: { + ...(this.wired ? { family: "pulsar", hideUnsupportedPollingRates: true, hideSignalCard: true } : {}), + dpiStageEditor: { maxStages: 6, countEditable: false, minDpi: 50, maxDpi: 32000, stepDpi: 50 }, + }, signalStrength: this.signalLevel(rssi?.[1]), debounceMs: debounce?.[2] ?? null, motionSync: motion ? motion[2] === 1 : null, @@ -126,7 +144,7 @@ export class PulsarProHidClient { liftOffDistance: lod[2] === 1 ? "Low" : lod[2] === 2 ? "Medium" : lod[2] === 3 ? "High" : null, firmware: [ mouseVersion ? this.formatVersion("Mouse", mouseVersion) : "Mouse firmware unavailable", - dongleVersion ? this.formatVersion("Dongle", dongleVersion) : "Dongle firmware unavailable", + ...(this.wired ? [] : [dongleVersion ? this.formatVersion("Dongle", dongleVersion) : "Dongle firmware unavailable"]), ], }; } @@ -139,22 +157,76 @@ export class PulsarProHidClient { async setDpi(value: number): Promise { if (!this.getDpiOptions().includes(value)) throw new Error("Unsupported Pulsar Pro DPI value."); + return await this.withDpiUpdate(async () => { + const current = await this.readDpiConfiguration(); + return await this.writeDpiStageValue(current, current[3], value); + }); + } + + /** Edits both axes of an enabled stage (0-based), preserving the active stage. */ + async setDpiStageValue(stage: number, value: number): Promise { + this.validateDpiStage(stage); + if (!this.getDpiOptions().includes(value)) throw new Error("Unsupported Pulsar Pro DPI value."); + return await this.withDpiUpdate(async () => { + const current = await this.readDpiConfiguration(); + if (stage >= current[2]) throw new RangeError(`DPI stage ${stage} is outside this mouse's ${current[2]}-stage cycle.`); + return await this.writeDpiStageValue(current, stage, value); + }); + } + + /** Selects an enabled stage (0-based) without changing its stored DPI values. */ + async setActiveDpiStage(stage: number): Promise { + this.validateDpiStage(stage); + return await this.withDpiUpdate(async () => { + const current = await this.readDpiConfiguration(); + const stageCount = current[2]; + if (stage >= stageCount) throw new RangeError(`DPI stage ${stage} is outside this mouse's ${stageCount}-stage cycle.`); + await this.command(COMMAND.dpi, new Uint8Array([1, stageCount, stage, ...current.slice(4, 4 + stageCount * 4)])); + await this.applySettings(); + const confirmed = await this.readDpiConfiguration(); + if (confirmed[3] !== stage) throw new Error(`The mouse kept DPI stage ${confirmed[3]} instead of ${stage}.`); + return stage; + }); + } + + private withDpiUpdate(update: () => Promise): Promise { + // Hold a separate queue through readback; command() acquires commandQueue itself. + const result = this.dpiUpdateQueue.then(update); + this.dpiUpdateQueue = result.then(() => undefined, () => undefined); + return result; + } + + private validateDpiStage(stage: number): void { + if (!Number.isInteger(stage) || stage < 0 || stage >= 6) throw new RangeError("DPI stage must be an index from 0 to 5."); + } + + private async readDpiConfiguration(): Promise { const current = await this.query(COMMAND.dpi); - const stageCount = Math.min(current[2] ?? 0, 6); - const activeStage = Math.min(current[3] ?? 0, Math.max(stageCount - 1, 0)); - if (stageCount < 1) throw new Error("The Pulsar Pro mouse reported no DPI stages."); + const stageCount = current[2] ?? 0; + if (stageCount < 1 || stageCount > 6 || current.length < 4 + stageCount * 4 || current[3] >= stageCount) { + throw new Error("The Pulsar Pro mouse reported an invalid DPI stage configuration."); + } + return current; + } + + private async writeDpiStageValue(current: Uint8Array, stage: number, value: number): Promise { + const stageCount = current[2]; + const activeStage = current[3]; const stages = current.slice(4, 4 + stageCount * 4); - const offset = activeStage * 4; + const offset = stage * 4; stages[offset] = value & 0xff; stages[offset + 1] = value >> 8; stages[offset + 2] = value & 0xff; stages[offset + 3] = value >> 8; await this.command(COMMAND.dpi, new Uint8Array([1, stageCount, activeStage, ...stages])); await this.applySettings(); - const confirmed = await this.query(COMMAND.dpi); - const confirmedOffset = 4 + activeStage * 4; + const confirmed = await this.readDpiConfiguration(); + if (confirmed[3] !== activeStage) throw new Error("The mouse changed the active DPI stage while editing its value."); + const confirmedOffset = 4 + stage * 4; const confirmedValue = readUint16LE(confirmed, confirmedOffset); - if (confirmedValue !== value) throw new Error(`The mouse kept ${confirmedValue} DPI instead of ${value} DPI.`); + if (confirmedValue !== value || readUint16LE(confirmed, confirmedOffset + 2) !== value) { + throw new Error(`The mouse did not keep ${value} DPI on both axes of stage ${stage}.`); + } return confirmedValue; } @@ -278,7 +350,8 @@ export class PulsarProHidClient { } private async applySettings(): Promise { - await this.command(COMMAND.saveAllow, new Uint8Array([1])); + // zywoo chosen-x: wired B1/B2/B5/B6/B7/C4 writes take effect directly, without the receiver's C3 save-allow command. + if (!this.wired) await this.command(COMMAND.saveAllow, new Uint8Array([1])); } private async command(command: number, parameters: Uint8Array, timeoutMs = 1500): Promise { diff --git a/src/drivers/pulsar/pulsar-xs1-hid.test.ts b/src/drivers/pulsar/pulsar-xs1-hid.test.ts index 48a3f039..9c040598 100644 --- a/src/drivers/pulsar/pulsar-xs1-hid.test.ts +++ b/src/drivers/pulsar/pulsar-xs1-hid.test.ts @@ -177,6 +177,11 @@ test("reports the wired X3 as wired", async () => { assert.equal(status.connectionType, "Wired"); }); +test("the CrazyLight receiver PID does not name a paired mouse on the feature interface", async () => { + const status = await new PulsarXs1HidClient(fakeDevice(0x5406, "Pulsar X3 CrazyLight Mini").device).readStatus(); + assert.equal(status.name, "Pulsar CrazyLight 8K Dongle"); +}); + 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(); @@ -186,18 +191,20 @@ test("the X2A v3 cable ID is named and stays on the wired 1K capability path", a 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); - } -}); +for (const [productId, name] of [[0x5403, "Pulsar 8K Dongle"], [0x5406, "Pulsar CrazyLight 8K Dongle"]] as const) { + test(`the 8K dongle ${productId.toString(16)} 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(productId, "1K Dongle", { pollingCode: code }).device); + const status = await client.readStatus(); + assert.equal(status.name, name); + 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 }]) { diff --git a/src/drivers/pulsar/pulsar-xs1-hid.ts b/src/drivers/pulsar/pulsar-xs1-hid.ts index 82d3afac..b47ec23d 100644 --- a/src/drivers/pulsar/pulsar-xs1-hid.ts +++ b/src/drivers/pulsar/pulsar-xs1-hid.ts @@ -34,6 +34,7 @@ const XS1_PRODUCT_NAMES: ReadonlyMap = new Map([ [0x3404, "Pulsar X2A v3"], [0x5402, "Pulsar X3 M"], [EIGHT_K_RECEIVER_PRODUCT_ID, "Pulsar 8K Dongle"], + [0x5406, "Pulsar CrazyLight 8K Dongle"], ]); const LIFT_OFF_DISTANCES: ReadonlyArray = [ @@ -118,7 +119,7 @@ export class PulsarXs1HidClient { await this.open(); this.deviceInfo = { connection: PULSAR_XS1_WIRELESS_PRODUCT_IDS.has(this.device.productId) ? "Wireless" : "Wired", - maximumPollingRateHz: this.device.productId === EIGHT_K_RECEIVER_PRODUCT_ID ? 8000 : 1000, + maximumPollingRateHz: this.device.productId === EIGHT_K_RECEIVER_PRODUCT_ID || this.device.productId === 0x5406 ? 8000 : 1000, }; return this.deviceInfo; } diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index 81098207..7fef64ab 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -12,6 +12,7 @@ import { HITSCAN_PRODUCTS, HITSCAN_XVI_PRODUCTS } from "@openmouse/protocol/hits import { ORBITAL_DEVICES } from "@openmouse/protocol/orbital"; import { MCHOSE_V3_PRODUCT_IDS } from "@openmouse/protocol/mchose"; import { KEYCHRON_8K_NORDIC_PRODUCT_IDS } from "@openmouse/protocol/keychron"; +import { PULSAR_PRO_PRODUCT_IDS } from "@openmouse/protocol/pulsar"; import { DELUX_M600_PRO_WIRED_PID, DELUX_M800_MINI_WIRELESS_PID, @@ -141,6 +142,8 @@ function candidateProductIds(): number[] { // The Keychron 8K Nordic driver claims its ids out of the 4K family's // shared collection, so the probe needs them to reach it. ...KEYCHRON_8K_NORDIC_PRODUCT_IDS, + // Pro ids live in the codec package, outside the driver source scan. + ...PULSAR_PRO_PRODUCT_IDS, // The Cooler Master driver matches on an id allowlist, but its only // product id (0x0101) is defined in src/coolermaster/ and appears as no // literal under src/drivers/, so the source scan below would not find it. diff --git a/src/pulsar/index.ts b/src/pulsar/index.ts index 42c42ecc..f8959158 100644 --- a/src/pulsar/index.ts +++ b/src/pulsar/index.ts @@ -16,9 +16,15 @@ export const PULSAR_FLASH = { performanceTime: 183, } as const; -export const PULSAR_PRO_PRODUCT_ID = 0x5405; +export const PULSAR_PRO_WIRED_PRODUCT_IDS: ReadonlySet = new Set([ + 0x7401, // Pulsar ZYWOO The Chosen X +]); +export const PULSAR_PRO_PRODUCT_IDS: ReadonlySet = new Set([ + 0x5405, // Pulsar Pro wireless adapter + ...PULSAR_PRO_WIRED_PRODUCT_IDS, +]); export const PULSAR_PRO_REPORT_ID = 0; -export const PULSAR_PRO_REPORT_LENGTH = 63; +export const PULSAR_PRO_REPORT_LENGTH = 64; export const PULSAR_PRO_COMMAND = { dongleVersion: 0xa0, rssi: 0xa4, dpi: 0xb1, polling: 0xb2, lod: 0xb3, battery: 0xb4, motionSync: 0xb5, ripple: 0xb6, angleSnap: 0xb7, @@ -133,8 +139,8 @@ export function readUint16LE(data: Uint8Array, offset: number): number { // XS-1 protocol used by the X3 family: unnumbered 64-byte feature reports // (report ID 0, which WebHID does not prefix) carrying a 16-bit // little-endian checksum of bytes 0..61 in bytes 62..63. -export const PULSAR_XS1_PRODUCT_IDS: ReadonlySet = new Set([0x3404, 0x3409, 0x3410, 0x5402, 0x5403]); -export const PULSAR_XS1_WIRELESS_PRODUCT_IDS: ReadonlySet = new Set([0x5402, 0x5403]); +export const PULSAR_XS1_PRODUCT_IDS: ReadonlySet = new Set([0x3404, 0x3409, 0x3410, 0x5402, 0x5403, 0x5406]); +export const PULSAR_XS1_WIRELESS_PRODUCT_IDS: ReadonlySet = new Set([0x5402, 0x5403, 0x5406]); export const PULSAR_XS1_FEATURE_REPORT_ID = 0; export const PULSAR_XS1_PACKET_LENGTH = 64; export const PULSAR_XS1_DPI_MIN = 50;