From dd5dd2d8756344434b1d97968c01c7bad99e36e2 Mon Sep 17 00:00:00 2001 From: Joshua Chung <87676426+jchu634@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:30:26 +1100 Subject: [PATCH 1/8] Added pulsar x3 crazylight support --- src/drivers/pulsar/pulsar-hid.test.ts | 28 +++++++++++++++++++++++++-- src/drivers/pulsar/pulsar-hid.ts | 10 +++++++++- src/pulsar/index.ts | 2 +- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/drivers/pulsar/pulsar-hid.test.ts b/src/drivers/pulsar/pulsar-hid.test.ts index 2128024b..caa64702 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,22 @@ 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: 0x5403, cid: 0x57, mid: 0x04 }, expected: "Pulsar X2 CrazyLight" }, + { options: { productId: 0x5403 }, expected: "Pulsar 8K Dongle" }, + { options: { vendorId: 0x3554, productId: 0x5406 }, expected: "Pulsar 8K Dongle" }, + { options: { cid: 0x57, mid: 0x05 }, 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("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..0c6195f2 100644 --- a/src/drivers/pulsar/pulsar-hid.ts +++ b/src/drivers/pulsar/pulsar-hid.ts @@ -154,9 +154,17 @@ 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]; + + let modelName = this.device.productName || "Pulsar Mouse"; + if (this.device.vendorId === PULSAR_VENDOR_ID && this.device.productId === 0x5406) { + modelName = "Pulsar X3 CrazyLight Mini"; + } else if (info.cid === 0x57 && info.mid === 0x04) { + modelName = "Pulsar X2 CrazyLight"; + } + return { brand: "Pulsar", - name: info.cid === 0x57 && info.mid === 0x04 ? "Pulsar X2 CrazyLight" : (this.device.productName || "Pulsar Mouse"), + name: modelName, ui: { family: "pulsar", hideUnsupportedPollingRates: true }, batteryPercent: battery[5] <= 100 ? battery[5] : null, batteryState: battery[6] === 1 ? "Charging" : "Discharging", diff --git a/src/pulsar/index.ts b/src/pulsar/index.ts index 42c42ecc..3ba43e6c 100644 --- a/src/pulsar/index.ts +++ b/src/pulsar/index.ts @@ -133,7 +133,7 @@ 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_PRODUCT_IDS: ReadonlySet = new Set([0x3404, 0x3409, 0x3410, 0x5402, 0x5403, 0x5406]); export const PULSAR_XS1_WIRELESS_PRODUCT_IDS: ReadonlySet = new Set([0x5402, 0x5403]); export const PULSAR_XS1_FEATURE_REPORT_ID = 0; export const PULSAR_XS1_PACKET_LENGTH = 64; From b5a88f9eb45934cc9fef8ce9477efe3a9204e8dc Mon Sep 17 00:00:00 2001 From: Joshua Chung <87676426+jchu634@users.noreply.github.com> Date: Thu, 12 Nov 2026 04:18:52 +1100 Subject: [PATCH 2/8] Added Zywoo the chosen X support --- src/drivers/pulsar/pulsar-pro-hid.test.ts | 86 +++++++++++++++++++++++ src/drivers/pulsar/pulsar-pro-hid.ts | 35 ++++++--- src/pulsar/index.ts | 11 ++- 3 files changed, 118 insertions(+), 14 deletions(-) diff --git a/src/drivers/pulsar/pulsar-pro-hid.test.ts b/src/drivers/pulsar/pulsar-pro-hid.test.ts index f4db6fe7..1db87977 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,86 @@ 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 } = {}) { + const sent: Uint8Array[] = []; + const values = new Map([ + [0xb2, new Uint8Array([0xe8, 0x03])], + [0xb5, new Uint8Array([1])], + [0xb6, new Uint8Array([1])], + [0xb7, new Uint8Array([1])], + [0xc4, new Uint8Array([1])], + ]); + 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]; + assert.ok(values.has(command), `unexpected command 0x${command.toString(16)}`); + if (data[1] === 1) values.set(command, data.slice(2, 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(); +}); diff --git a/src/drivers/pulsar/pulsar-pro-hid.ts b/src/drivers/pulsar/pulsar-pro-hid.ts index 7d722863..8665a27b 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, @@ -43,14 +44,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 +74,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, }; } @@ -94,8 +101,8 @@ 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 dongleVersion = this.wired ? null : await this.queryOptional(COMMAND.dongleVersion); + const rssi = this.wired ? null : await this.queryOptional(COMMAND.rssi); const dpiIndex = Math.min(dpi[3] ?? 0, 7); const dpiOffset = 4 + dpiIndex * 4; const sleepMs = sleep ? readUint32LE(sleep, 2) : 0; @@ -107,7 +114,12 @@ export class PulsarProHidClient { dpi: readUint16LE(dpi, dpiOffset), 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: { family: "pulsar", hideUnsupportedPollingRates: true, hideSignalCard: true }, + } : {}), signalStrength: this.signalLevel(rssi?.[1]), debounceMs: debounce?.[2] ?? null, motionSync: motion ? motion[2] === 1 : null, @@ -278,7 +290,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/pulsar/index.ts b/src/pulsar/index.ts index 3ba43e6c..81d4382d 100644 --- a/src/pulsar/index.ts +++ b/src/pulsar/index.ts @@ -16,9 +16,14 @@ 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 +]); 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, @@ -134,7 +139,7 @@ export function readUint16LE(data: Uint8Array, offset: number): number { // (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, 0x5406]); -export const PULSAR_XS1_WIRELESS_PRODUCT_IDS: ReadonlySet = new Set([0x5402, 0x5403]); +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; From c1440565a337d02abde44cf7c3bb840aed3a30dd Mon Sep 17 00:00:00 2001 From: Joshua Chung <87676426+jchu634@users.noreply.github.com> Date: Thu, 12 Nov 2026 04:55:16 +1100 Subject: [PATCH 3/8] Add safe DPI stage selection and editing for Pulsar Pro --- src/drivers/pulsar/pulsar-pro-hid.test.ts | 96 ++++++++++++++++++++++- src/drivers/pulsar/pulsar-pro-hid.ts | 55 +++++++++++-- 2 files changed, 142 insertions(+), 9 deletions(-) diff --git a/src/drivers/pulsar/pulsar-pro-hid.test.ts b/src/drivers/pulsar/pulsar-pro-hid.test.ts index 1db87977..9f5cc836 100644 --- a/src/drivers/pulsar/pulsar-pro-hid.test.ts +++ b/src/drivers/pulsar/pulsar-pro-hid.test.ts @@ -68,14 +68,20 @@ test("a command reopens a receiver that closed without an explicit client close" assert.equal(device.sends, 1); }); -function wiredDevice({ productId = 0x7401 } = {}) { +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])], [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 = { @@ -97,7 +103,9 @@ function wiredDevice({ productId = 0x7401 } = {}) { sent.push(data.slice()); const command = data[0]; assert.ok(values.has(command), `unexpected command 0x${command.toString(16)}`); - if (data[1] === 1) values.set(command, data.slice(2, command === 0xb2 ? 4 : 3)); + 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]; @@ -150,3 +158,87 @@ test("replays captured wired polling and toggle writes without receiver save-all assert.equal(sent.length, 12, "each write is followed only by its readback query"); await client.close(); }); + +for (const productId of [0x7401, 0x5405]) { + 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 8665a27b..caca4bed 100644 --- a/src/drivers/pulsar/pulsar-pro-hid.ts +++ b/src/drivers/pulsar/pulsar-pro-hid.ts @@ -151,22 +151,63 @@ export class PulsarProHidClient { async setDpi(value: number): Promise { if (!this.getDpiOptions().includes(value)) throw new Error("Unsupported Pulsar Pro DPI value."); + 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."); + 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); + 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 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; } From 1eb80dc0deaf77642c2b98ba3ae8e05069518127 Mon Sep 17 00:00:00 2001 From: Joshua Chung <87676426+jchu634@users.noreply.github.com> Date: Thu, 12 Nov 2026 05:29:53 +1100 Subject: [PATCH 4/8] fix(pulsar): identify CrazyLight 8K dongle and paired models - Map CrazyLight receiver CID/MID pairs to mouse model names - Report the receiver itself on the feature interface --- src/drivers/pulsar/pulsar-hid.test.ts | 15 +++++++-- src/drivers/pulsar/pulsar-hid.ts | 41 ++++++++++++++++++----- src/drivers/pulsar/pulsar-xs1-hid.test.ts | 5 +++ src/drivers/pulsar/pulsar-xs1-hid.ts | 1 + 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/drivers/pulsar/pulsar-hid.test.ts b/src/drivers/pulsar/pulsar-hid.test.ts index caa64702..08aa5eee 100644 --- a/src/drivers/pulsar/pulsar-hid.test.ts +++ b/src/drivers/pulsar/pulsar-hid.test.ts @@ -79,10 +79,21 @@ function flashDevice({ 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: 0x5403, cid: 0x57, mid: 0x04 }, expected: "Pulsar X2 CrazyLight" }, + { 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: { cid: 0x57, mid: 0x05 }, 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" }, diff --git a/src/drivers/pulsar/pulsar-hid.ts b/src/drivers/pulsar/pulsar-hid.ts index 0c6195f2..703a0cda 100644 --- a/src/drivers/pulsar/pulsar-hid.ts +++ b/src/drivers/pulsar/pulsar-hid.ts @@ -42,6 +42,32 @@ const CLAIMED_VGN_PRODUCT_IDS: ReadonlySet = new Set([ ]); const PULSAR_POLLING_RATES = [125, 250, 500, 1000, 2000, 4000, 8000]; +// CID/MID pairs for the CrazyLight 8K dongle (3710:5406) +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"], + [0x5709, "Pulsar X2 CrazyLight Mini"], + [0x570a, "Pulsar X2 CrazyLight Mini"], + [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"], + [0x571b, "Pulsar X2 CrazyLight Medium"], + [0x5722, "Pulsar Xlite CrazyLight Medium"], + [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"], +]); + export interface PulsarReport { timestamp: number; reportId: number; @@ -154,17 +180,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]; - - let modelName = this.device.productName || "Pulsar Mouse"; - if (this.device.vendorId === PULSAR_VENDOR_ID && this.device.productId === 0x5406) { - modelName = "Pulsar X3 CrazyLight Mini"; - } else if (info.cid === 0x57 && info.mid === 0x04) { - modelName = "Pulsar X2 CrazyLight"; - } - + 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: modelName, + 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-xs1-hid.test.ts b/src/drivers/pulsar/pulsar-xs1-hid.test.ts index 48a3f039..b73cfef3 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(); diff --git a/src/drivers/pulsar/pulsar-xs1-hid.ts b/src/drivers/pulsar/pulsar-xs1-hid.ts index 82d3afac..a00841bf 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 = [ From d59656b82fd9f5a592ba903af507613d87dae1a0 Mon Sep 17 00:00:00 2001 From: Joshua Chung <87676426+jchu634@users.noreply.github.com> Date: Thu, 12 Nov 2026 05:38:12 +1100 Subject: [PATCH 5/8] feat(pulsar): add names for more mouse models - Move the model name catalog into a dedicated module - Resolve additional Pulsar families and editions from handshake IDs --- src/drivers/pulsar/model-names.ts | 138 ++++++++++++++++++++++++++ src/drivers/pulsar/pulsar-hid.test.ts | 30 ++++++ src/drivers/pulsar/pulsar-hid.ts | 27 +---- 3 files changed, 169 insertions(+), 26 deletions(-) create mode 100644 src/drivers/pulsar/model-names.ts 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 08aa5eee..3376e121 100644 --- a/src/drivers/pulsar/pulsar-hid.test.ts +++ b/src/drivers/pulsar/pulsar-hid.test.ts @@ -103,6 +103,36 @@ test("resolves model names and preserves product-name and generic fallbacks", as } }); +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 703a0cda..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, @@ -42,32 +43,6 @@ const CLAIMED_VGN_PRODUCT_IDS: ReadonlySet = new Set([ ]); const PULSAR_POLLING_RATES = [125, 250, 500, 1000, 2000, 4000, 8000]; -// CID/MID pairs for the CrazyLight 8K dongle (3710:5406) -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"], - [0x5709, "Pulsar X2 CrazyLight Mini"], - [0x570a, "Pulsar X2 CrazyLight Mini"], - [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"], - [0x571b, "Pulsar X2 CrazyLight Medium"], - [0x5722, "Pulsar Xlite CrazyLight Medium"], - [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"], -]); - export interface PulsarReport { timestamp: number; reportId: number; From 509b4a943b0582c701257168e9a52f6a3a41442e Mon Sep 17 00:00:00 2001 From: Joshua Chung <87676426+jchu634@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:09:43 +1100 Subject: [PATCH 6/8] fix(pulsar): include wired Pro ids in device matching - Add wired Pro product ids to the shared Pulsar Pro set - Include Pro codec ids in driver registry candidate coverage --- src/drivers/registry.test.ts | 3 +++ src/pulsar/index.ts | 1 + 2 files changed, 4 insertions(+) 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 81d4382d..f8959158 100644 --- a/src/pulsar/index.ts +++ b/src/pulsar/index.ts @@ -21,6 +21,7 @@ export const PULSAR_PRO_WIRED_PRODUCT_IDS: ReadonlySet = new Set([ ]); 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 = 64; From aff9c37e08345207ad744b3855e58900e6beee86 Mon Sep 17 00:00:00 2001 From: Joshua Chung <87676426+jchu634@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:25:38 +1100 Subject: [PATCH 7/8] Fixed coderabbit suggestions --- src/drivers/pulsar/pulsar-pro-hid.test.ts | 34 ++++++++++++++++++++++- src/drivers/pulsar/pulsar-pro-hid.ts | 13 ++++++--- src/drivers/pulsar/pulsar-xs1-hid.test.ts | 26 +++++++++-------- src/drivers/pulsar/pulsar-xs1-hid.ts | 2 +- 4 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/drivers/pulsar/pulsar-pro-hid.test.ts b/src/drivers/pulsar/pulsar-pro-hid.test.ts index 9f5cc836..25aafd46 100644 --- a/src/drivers/pulsar/pulsar-pro-hid.test.ts +++ b/src/drivers/pulsar/pulsar-pro-hid.test.ts @@ -77,6 +77,8 @@ function wiredDevice({ 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])], @@ -102,7 +104,10 @@ function wiredDevice({ assert.equal(data.length, 64); sent.push(data.slice()); const command = data[0]; - assert.ok(values.has(command), `unexpected command 0x${command.toString(16)}`); + 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)); } @@ -160,6 +165,33 @@ test("replays captured wired polling and toggle writes without receiver save-all }); for (const productId of [0x7401, 0x5405]) { + 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); diff --git a/src/drivers/pulsar/pulsar-pro-hid.ts b/src/drivers/pulsar/pulsar-pro-hid.ts index caca4bed..16036a16 100644 --- a/src/drivers/pulsar/pulsar-pro-hid.ts +++ b/src/drivers/pulsar/pulsar-pro-hid.ts @@ -84,7 +84,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); @@ -103,7 +103,7 @@ export class PulsarProHidClient { const mouseVersion = await this.queryOptional(COMMAND.mouseVersion); const dongleVersion = this.wired ? null : await this.queryOptional(COMMAND.dongleVersion); const rssi = this.wired ? null : await this.queryOptional(COMMAND.rssi); - const dpiIndex = Math.min(dpi[3] ?? 0, 7); + const dpiIndex = dpi[3]; const dpiOffset = 4 + dpiIndex * 4; const sleepMs = sleep ? readUint32LE(sleep, 2) : 0; return { @@ -112,14 +112,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, 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: { family: "pulsar", hideUnsupportedPollingRates: true, hideSignalCard: true }, } : {}), + 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, @@ -138,7 +143,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"]), ], }; } diff --git a/src/drivers/pulsar/pulsar-xs1-hid.test.ts b/src/drivers/pulsar/pulsar-xs1-hid.test.ts index b73cfef3..9c040598 100644 --- a/src/drivers/pulsar/pulsar-xs1-hid.test.ts +++ b/src/drivers/pulsar/pulsar-xs1-hid.test.ts @@ -191,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 a00841bf..b47ec23d 100644 --- a/src/drivers/pulsar/pulsar-xs1-hid.ts +++ b/src/drivers/pulsar/pulsar-xs1-hid.ts @@ -119,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; } From a5c4c57f5391ae8abf8fbdbddf6a160589beff77 Mon Sep 17 00:00:00 2001 From: Joshua Chung <87676426+jchu634@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:38:35 +1100 Subject: [PATCH 8/8] fix(pulsar): serialize DPI updates through readback - Queue concurrent DPI edits and stage changes until readback completes - Verify failed readbacks release the update queue --- src/drivers/pulsar/pulsar-pro-hid.test.ts | 34 +++++++++++++++++++ src/drivers/pulsar/pulsar-pro-hid.ts | 40 +++++++++++++++-------- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/drivers/pulsar/pulsar-pro-hid.test.ts b/src/drivers/pulsar/pulsar-pro-hid.test.ts index 25aafd46..16417fed 100644 --- a/src/drivers/pulsar/pulsar-pro-hid.test.ts +++ b/src/drivers/pulsar/pulsar-pro-hid.test.ts @@ -165,6 +165,40 @@ test("replays captured wired polling and toggle writes without receiver save-all }); 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")), diff --git a/src/drivers/pulsar/pulsar-pro-hid.ts b/src/drivers/pulsar/pulsar-pro-hid.ts index 16036a16..db792b73 100644 --- a/src/drivers/pulsar/pulsar-pro-hid.ts +++ b/src/drivers/pulsar/pulsar-pro-hid.ts @@ -14,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: { @@ -156,30 +157,43 @@ export class PulsarProHidClient { async setDpi(value: number): Promise { if (!this.getDpiOptions().includes(value)) throw new Error("Unsupported Pulsar Pro DPI value."); - const current = await this.readDpiConfiguration(); - return await this.writeDpiStageValue(current, current[3], 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."); - 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); + 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); - 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; + 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 {