Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/drivers/pulsar/protocol.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
38 changes: 38 additions & 0 deletions src/drivers/pulsar/pulsar-hid.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
26 changes: 23 additions & 3 deletions src/drivers/pulsar/pulsar-hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> = 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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -149,6 +165,10 @@ export class PulsarHidClient {
}

async setPollingRate(pollingRateHz: number): Promise<number> {
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);
Expand All @@ -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;
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/drivers/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
4 changes: 4 additions & 0 deletions src/drivers/vendors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 16 additions & 21 deletions src/pulsar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down