diff --git a/src/drivers/pulsar/protocol.test.ts b/src/drivers/pulsar/protocol.test.ts new file mode 100644 index 0000000..a8e927c --- /dev/null +++ b/src/drivers/pulsar/protocol.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + pulsarDataChecksum, + pulsarDecodeDpi, + pulsarDecodePollingRate, + pulsarEncodeDpi, + pulsarEncodePollingRate, + pulsarPacketChecksum, +} from "@openmouse/protocol/pulsar"; + +test("captured Pulsar 4K receiver DPI stages decode as 50-step values", () => { + // The mouse's own flash slots and the physical DPI button cycle write these + // four-byte stages; they must decode to the real sensitivities. + assert.equal(pulsarDecodeDpi(new Uint8Array([0x0f, 0x0f, 0x00, 0x37])), 800); + assert.equal(pulsarDecodeDpi(new Uint8Array([0x1f, 0x1f, 0x00, 0x17])), 1600); + assert.equal(pulsarDecodeDpi(new Uint8Array([0x3f, 0x3f, 0x00, 0xd7])), 3200); + assert.equal(pulsarDecodeDpi(new Uint8Array([0x13, 0x13, 0x00, 0x2f])), 1000); + assert.equal(pulsarDecodeDpi(new Uint8Array([0x07, 0x07, 0x88, 0xbf])), 26000); +}); + +test("DPI codec rejects corrupt or mismatched stages", () => { + assert.equal(pulsarDecodeDpi(new Uint8Array([0x0f, 0x0f, 0x00, 0x00])), null); + assert.equal(pulsarDecodeDpi(new Uint8Array([0x0f, 0x1f, 0x00, 0x37])), null); + assert.equal(pulsarDecodeDpi(new Uint8Array([0x0f, 0x0f, 0x00])), null); +}); + +test("DPI and polling rate codecs round-trip supported UI values", () => { + const dpis = [50, 400, 800, 1600, 26000]; + const rates = [125, 250, 500, 1000, 2000, 4000, 8000]; + + for (const dpi of dpis) assert.equal(pulsarDecodeDpi(pulsarEncodeDpi(dpi)), dpi); + for (const rate of rates) assert.equal(pulsarDecodePollingRate(pulsarEncodePollingRate(rate)), rate); +}); + +test("encoded DPI stage writes match the captured byte pattern", () => { + assert.deepEqual([...pulsarEncodeDpi(800)], [0x0f, 0x0f, 0x00, 0x37]); + assert.deepEqual([...pulsarEncodeDpi(1600)], [0x1f, 0x1f, 0x00, 0x17]); + assert.deepEqual([...pulsarEncodeDpi(3200)], [0x3f, 0x3f, 0x00, 0xd7]); +}); + +test("packet and data checksums match the shared VGN scheme", () => { + assert.equal(pulsarDataChecksum(new Uint8Array([0x0f, 0x0f, 0x00])), 0x37); + assert.equal(pulsarPacketChecksum(new Uint8Array(16)), (0x55 - 0x08) & 0xff); +}); diff --git a/src/drivers/pulsar/pulsar-hid.test.ts b/src/drivers/pulsar/pulsar-hid.test.ts new file mode 100644 index 0000000..602066a --- /dev/null +++ b/src/drivers/pulsar/pulsar-hid.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PulsarHidClient } from "./pulsar-hid.ts"; + +function device(vendorId: number, productId: number, reportId = 0x08): HIDDevice { + return { + vendorId, + productId, + productName: "Pulsar Mouse", + collections: [{ + usagePage: 0xff00, + usage: 1, + children: [], + featureReports: [], + inputReports: [{ reportId, items: [{ reportCount: 16, reportSize: 8 }] }], + outputReports: [{ reportId, items: [{ reportCount: 16, reportSize: 8 }] }], + }], + } as unknown as HIDDevice; +} + +test("supports Pulsar receivers on the native vendor id", () => { + assert.equal(PulsarHidClient.isSupported(device(0x3710, 0x0001)), true); +}); + +test("supports the Pulsar 4K Wireless Receiver on the shared VGN vendor id", () => { + assert.equal(PulsarHidClient.isSupported(device(0x3554, 0x0002)), true); +}); + +test("does not claim product ids owned by the Teevolution and VGN drivers", () => { + assert.equal(PulsarHidClient.isSupported(device(0x3554, 0xf520)), false); + assert.equal(PulsarHidClient.isSupported(device(0x3554, 0xfb56)), false); +}); + +test("rejects devices without the report-8 control collection", () => { + const wrong = device(0x3554, 0x0002, 0x09); + assert.equal(PulsarHidClient.isSupported(wrong), false); +}); diff --git a/src/drivers/pulsar/pulsar-hid.ts b/src/drivers/pulsar/pulsar-hid.ts index 7bfa4fa..ff902f9 100644 --- a/src/drivers/pulsar/pulsar-hid.ts +++ b/src/drivers/pulsar/pulsar-hid.ts @@ -13,6 +13,18 @@ import { pulsarPacketChecksum, } from "@openmouse/protocol/pulsar"; +// The Pulsar 4K Wireless Receiver is sold as a Pulsar product but enumerates +// under the shared Teevolution/VGN vendor id (0x3554) and speaks the same +// report-8 16-byte protocol as Pulsar receivers. VID 0x3554 is also used by +// the Teevolution and VGN drivers, so only product ids those drivers have not +// already claimed are accepted here. +const VGN_VENDOR_ID = 0x3554; +const CLAIMED_VGN_PRODUCT_IDS: ReadonlySet = new Set([ + 0xf520, 0xf523, 0xf5bb, 0xf522, // Teevolution (Terra Pro family) + 0xfb56, 0xfb57, // VGN Dragonfly F2 Master+ +]); +const PULSAR_POLLING_RATES = [125, 250, 500, 1000, 2000, 4000, 8000]; + export interface PulsarReport { timestamp: number; reportId: number; @@ -56,7 +68,9 @@ export class PulsarHidClient { } static isSupported(device: HIDDevice): boolean { - return device.vendorId === PULSAR_VENDOR_ID + const vendorSupported = device.vendorId === PULSAR_VENDOR_ID + || (device.vendorId === VGN_VENDOR_ID && !CLAIMED_VGN_PRODUCT_IDS.has(device.productId)); + return vendorSupported && device.collections.some((collection) => collection.inputReports.length === 1 && collection.outputReports.length === 1 @@ -116,15 +130,17 @@ export class PulsarHidClient { : null; const rssi = await this.query(COMMAND.getRssi).catch(() => null); const currentDpi = Math.min(flash[FLASH.currentDpi] ?? 0, 7); - const dpi = pulsarDecodeDpi(flash.slice(FLASH.dpiValues + currentDpi * 4, FLASH.dpiValues + currentDpi * 4 + 4)); + const dpi = pulsarDecodeDpi(flash.slice(FLASH.dpiValues + currentDpi * 4, FLASH.dpiValues + currentDpi * 4 + 4)) ?? 800; const lodValue = flash[FLASH.liftOffDistance]; return { brand: "Pulsar", name: info.cid === 0x57 && info.mid === 0x04 ? "Pulsar X2 CrazyLight" : (this.device.productName || "Pulsar Mouse"), + ui: { family: "pulsar", hideUnsupportedPollingRates: true }, batteryPercent: battery[5] <= 100 ? battery[5] : null, batteryState: battery[6] === 1 ? "Charging" : "Discharging", dpi, pollingRateHz: pulsarDecodePollingRate(flash[FLASH.reportRate]), + supportedPollingRates: PULSAR_POLLING_RATES.filter((rate) => rate <= info.maximumPollingRateHz), activeProfile: profile && profile[1] === 0 ? (profile[5] ?? 0) + 1 : null, connectionDetail: `CID 0x${info.cid.toString(16).toUpperCase().padStart(2, "0")} · MID 0x${info.mid.toString(16).toUpperCase().padStart(2, "0")} · Dongle type ${info.dongleType}`, dongleLedEnabled: dongleLed?.enabled ?? null, @@ -149,6 +165,10 @@ export class PulsarHidClient { } async setPollingRate(pollingRateHz: number): Promise { + const info = this.deviceInfo ?? await this.readDeviceInfo(); + if (pollingRateHz > info.maximumPollingRateHz) { + throw new Error(`This connection supports at most ${info.maximumPollingRateHz} Hz.`); + } const encoded = pulsarEncodePollingRate(pollingRateHz); return await this.withDeviceControl(async () => { await this.writeCheckedByte(FLASH.reportRate, encoded); @@ -165,7 +185,7 @@ export class PulsarHidClient { const address = FLASH.dpiValues + Math.min(currentDpi, 7) * 4; await this.writeFlash(address, pulsarEncodeDpi(dpi)); const confirmed = pulsarDecodeDpi(await this.readFlash(address, 4)); - if (confirmed !== dpi) throw new Error(`The mouse kept ${confirmed} DPI instead of ${dpi} DPI.`); + if (confirmed !== dpi) throw new Error(`The mouse kept ${confirmed ?? "an unknown DPI"} instead of ${dpi} DPI.`); return confirmed; }); } diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index 4dbfaa1..59aeb3f 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -162,7 +162,7 @@ test("every product id offered in the picker has a driver", () => { ); }); -const WITHOUT_TESTS = new Set(["lamzu", "pulsar"]); +const WITHOUT_TESTS = new Set(["lamzu"]); function deviceDirectories(): string[] { return readdirSync(DEVICES_DIR, { withFileTypes: true }) diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 4560941..f0aaf66 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -223,6 +223,10 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ { vendorId: VENDOR_ID.finalmouse, productId: 0x0100, usagePage: 0xff00, usage: 0x0001 }, { vendorId: VENDOR_ID.pulsar }, ...PULSAR_XS1_HID_FILTERS, + // The Pulsar 4K Wireless Receiver enumerates under the shared Teevolution/VGN + // vendor id with a Pulsar-specific product id, so the broad VID-only filter + // keeps it visible in the picker; the driver disambiguates by product id. + { vendorId: VENDOR_ID.vgn }, { vendorId: VENDOR_ID.endgameGear }, { vendorId: VENDOR_ID.wlmouse }, // 0x373e is the shared CompX ODM vendor id behind Lamzu, CRDRAKO, and diff --git a/src/pulsar/index.ts b/src/pulsar/index.ts index b8bc113..a7dca49 100644 --- a/src/pulsar/index.ts +++ b/src/pulsar/index.ts @@ -51,35 +51,30 @@ export function pulsarEncodePollingRate(rate: number): number { return encoded; } -export function pulsarDecodeDpi(data: Uint8Array): number { +export function pulsarDecodeDpi(data: Uint8Array): number | null { + if (data.length < 4) return null; + const low = data[0] ?? 0; + const duplicate = data[1] ?? 0; const flags = data[2] ?? 0; - const raw = (data[0] ?? 0) + (((flags & 0x0c) >> 2) << 8); - let dpi = (raw + 1) * 10; - if ((flags & 0x02) !== 0) dpi = dpi * 5 + 10000; - if ((flags & 0x01) !== 0) dpi *= 2; - return dpi; + const checksum = data[3] ?? 0; + if (low !== duplicate || ((low + duplicate + flags + checksum) & 0xff) !== 0x55) return null; + return ((((flags >> 2) & 0x03) << 8) + low + 1) * 50; } export function pulsarEncodeDpi(dpi: number): Uint8Array { - let raw: number; - let dpiEx: number; - if (dpi >= 30100) { raw = (dpi / 2 - 10050) / 50; dpiEx = 0x33; } - else if (dpi >= 10050) { raw = (dpi - 10050) / 50; dpiEx = 0x22; } - else { raw = dpi / 10 - 1; dpiEx = 0; } - const high = raw >> 8; - const result = new Uint8Array(4); - result[0] = raw; - result[1] = raw; - result[2] = (high << 2) | (high << 6) | dpiEx | (dpiEx << 4); - result[3] = pulsarDataChecksum(result.slice(0, 3)); - return result; + if (!Number.isInteger(dpi) || dpi < 50 || dpi > 26000 || dpi % 50 !== 0) { + throw new Error("Pulsar DPI must be 50–26,000 in 50 DPI steps."); + } + const encoded = dpi / 50 - 1; + const low = encoded & 0xff; + const high = (encoded >> 8) & 0x03; + const flags = (high << 2) | (high << 6); + return new Uint8Array([low, low, flags, (0x55 - low - low - flags) & 0xff]); } export function pulsarDpiOptions(): number[] { const options: number[] = []; - for (let dpi = 10; dpi <= 10000; dpi += 10) options.push(dpi); - for (let dpi = 10050; dpi <= 30000; dpi += 50) options.push(dpi); - for (let dpi = 30100; dpi <= 32000; dpi += 100) options.push(dpi); + for (let dpi = 50; dpi <= 26000; dpi += 50) options.push(dpi); return options; }