diff --git a/src/drivers/pulsar/pulsar-xs1-hid.test.ts b/src/drivers/pulsar/pulsar-xs1-hid.test.ts new file mode 100644 index 0000000..bdb4939 --- /dev/null +++ b/src/drivers/pulsar/pulsar-xs1-hid.test.ts @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PULSAR_VENDOR_ID, pulsarXs1EncodeRequest } from "@openmouse/protocol/pulsar"; +import { createSupportedClient, deviceBrand } from "../registry.ts"; +import { PulsarXs1HidClient } from "./pulsar-xs1-hid.ts"; + +const globals = globalThis as { window?: { setTimeout: typeof setTimeout } }; +globals.window ??= { setTimeout }; + +interface FakeState { + dpi: number; + stage: number; + motionSync: number; + rippleControl: number; + angleSnapping: number; + liftOffDistance: number; + debounce: number; +} + +function fakeDevice(productId: number, productName = "Pulsar X3 Medium 1K Dongle") { + const sent: Uint8Array[] = []; + const state: FakeState = { + dpi: 1600, + stage: 3, + motionSync: 1, + rippleControl: 0, + angleSnapping: 0, + liftOffDistance: 0x0a, + debounce: 3, + }; + const device = { + vendorId: PULSAR_VENDOR_ID, + productId, + productName, + opened: true, + collections: [{ + usagePage: 0xffff, + usage: 1, + type: 1, + children: [], + featureReports: [{ reportId: 0, items: [{ reportSize: 8, reportCount: 64 }] }], + inputReports: [], + outputReports: [], + }], + open: async () => {}, + close: async () => {}, + sendFeatureReport: async (_id: number, data: Uint8Array) => { + const packet = new Uint8Array(data); + sent.push(packet); + if (packet[1] === 0x05 && packet[2] === 0x02) state.dpi = packet[7] | (packet[8] << 8); + if (packet[1] === 0x05 && packet[2] === 0x01) state.stage = packet[7]; + if (packet[1] === 0x07 && packet[2] === 0x05) state.motionSync = packet[7]; + if (packet[1] === 0x07 && packet[2] === 0x03) state.rippleControl = packet[7]; + if (packet[1] === 0x07 && packet[2] === 0x04) state.angleSnapping = packet[7]; + if (packet[1] === 0x07 && packet[2] === 0x02) state.liftOffDistance = packet[8]; + if (packet[1] === 0x04 && packet[2] === 0x03) state.debounce = packet[7]; + }, + receiveFeatureReport: async () => { + const request = sent[sent.length - 1]!; + const reply = new Uint8Array(64); + reply[0] = 0x00; + reply[1] = request[1]; + reply[2] = request[2]; + reply[3] = request[3]; + if (request[1] === 0x08 && request[2] === 0x81) reply[6] = 80; + if (request[1] === 0x08 && request[2] === 0x85) reply[7] = 30; + if (request[1] === 0x05 && request[2] === 0x82) { + reply[7] = state.dpi & 0xff; + reply[8] = state.dpi >> 8; + reply[9] = state.dpi & 0xff; + reply[10] = state.dpi >> 8; + } + if (request[1] === 0x05 && request[2] === 0x81) reply[7] = state.stage; + if (request[1] === 0x01 && request[2] === 0x87) { + reply[6] = 0x16; + reply[7] = 0x10; + } + if (request[1] === 0x07 && request[2] === 0x85) reply[7] = state.motionSync; + if (request[1] === 0x07 && request[2] === 0x83) reply[7] = state.rippleControl; + if (request[1] === 0x07 && request[2] === 0x84) reply[7] = state.angleSnapping; + if (request[1] === 0x07 && request[2] === 0x82) reply[8] = state.liftOffDistance; + if (request[1] === 0x04 && request[2] === 0x83) reply[7] = state.debounce; + return new DataView(reply.buffer); + }, + addEventListener: () => {}, + removeEventListener: () => {}, + } as unknown as HIDDevice; + return { device, sent }; +} + +test("encodes the 64-byte request with a checksum", () => { + const packet = pulsarXs1EncodeRequest([0x08, 0x81, 0x01]); + assert.equal(packet.length, 64); + assert.equal(packet[0], 0x00); + assert.equal(packet[1], 0x08); + assert.equal(packet[2], 0x81); + assert.equal(packet[3], 0x01); + const checksum = packet.slice(0, 62).reduce((sum, byte) => sum + byte, 0) & 0xffff; + assert.equal(packet[62], checksum & 0xff); + assert.equal(packet[63], checksum >> 8); +}); + +test("detects the X3 feature-report control interface", () => { + assert.equal(PulsarXs1HidClient.isSupported(fakeDevice(0x5402).device), true); + assert.equal(PulsarXs1HidClient.isSupported(fakeDevice(0x5403).device), true); + assert.equal(PulsarXs1HidClient.isSupported(fakeDevice(0x3409).device), true); + assert.equal(PulsarXs1HidClient.isSupported(fakeDevice(0x3403).device), false); + assert.equal(PulsarXs1HidClient.isSupported(fakeDevice(0x5405).device), false); +}); + +test("ignores interfaces that also expose the legacy report-8 control", () => { + const device = fakeDevice(0x5402).device; + device.collections = [{ + usagePage: 0xffff, + usage: 1, + type: 1, + children: [], + featureReports: [{ reportId: 0, items: [{ reportSize: 8, reportCount: 64 }] }], + inputReports: [{ reportId: 0x08, items: [] }], + outputReports: [{ reportId: 0x08, items: [] }], + }] as unknown as HIDCollectionInfo[]; + assert.equal(PulsarXs1HidClient.isSupported(device), false); +}); + +test("reads the X3 status from the feature interface", async () => { + const { device, sent } = fakeDevice(0x5402); + const client = new PulsarXs1HidClient(device); + const status = await client.readStatus(); + + assert.equal(status.brand, "Pulsar"); + assert.equal(status.name, "Pulsar X3 Medium 1K Dongle"); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.connectionDetail, "XS-1 feature-report interface"); + assert.equal(status.dpi, 1600); + assert.equal(status.pollingRateHz, 1000); + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000]); + assert.equal(status.motionSync, true); + assert.equal(status.rippleControl, false); + assert.equal(status.angleSnapping, false); + assert.equal(status.debounceMs, 3); + assert.equal(status.liftOffDistance, "Medium"); + assert.equal(status.batteryPercent, 80); + assert.equal(status.batteryState, "Discharging"); + assert.deepEqual(status.firmware, ["Mouse v10.16"]); + assert.equal(status.ui?.family, "pulsar"); + assert.equal(status.ui?.pollingReadOnly, true); + assert.equal(status.ui?.hideSleepCard, true); + assert.equal(status.ui?.hideSignalCard, true); + assert.ok(sent.some((packet) => packet[1] === 0x08 && packet[2] === 0x81)); +}); + +test("reports the wired X3 as wired", async () => { + const status = await new PulsarXs1HidClient(fakeDevice(0x3409).device).readStatus(); + assert.equal(status.connectionType, "Wired"); +}); + +test("writes and confirms the DPI", async () => { + const { device, sent } = fakeDevice(0x5402); + const client = new PulsarXs1HidClient(device); + assert.equal(await client.setDpi(800), 800); + const write = sent.find((packet) => packet[1] === 0x05 && packet[2] === 0x02); + assert.deepEqual([...write!.slice(1, 11)], [0x05, 0x02, 0x05, 0x00, 0x00, 0x01, 0x20, 0x03, 0x20, 0x03]); + assert.equal((await client.readStatus()).dpi, 800); +}); + +test("rejects unsupported DPI values", async () => { + const client = new PulsarXs1HidClient(fakeDevice(0x5402).device); + await assert.rejects(client.setDpi(12345), /not supported/); +}); + +test("writes and confirms Motion Sync", async () => { + const { device, sent } = fakeDevice(0x5402); + const client = new PulsarXs1HidClient(device); + assert.equal(await client.setMotionSync(false), false); + assert.ok(sent.some((packet) => packet[1] === 0x07 && packet[2] === 0x05 && packet[7] === 0)); + assert.equal((await client.readStatus()).motionSync, false); +}); + +test("writes and confirms angle snapping and ripple control", async () => { + const { device } = fakeDevice(0x5402); + const client = new PulsarXs1HidClient(device); + assert.equal(await client.setAngleSnapping(true), true); + assert.equal(await client.setRippleControl(true), true); + const status = await client.readStatus(); + assert.equal(status.angleSnapping, true); + assert.equal(status.rippleControl, true); +}); + +test("writes and confirms the lift-off distance", async () => { + const { device, sent } = fakeDevice(0x5402); + const client = new PulsarXs1HidClient(device); + assert.equal(await client.setLiftOffDistance("Low"), "Low"); + const write = sent.find((packet) => packet[1] === 0x07 && packet[2] === 0x02); + assert.deepEqual([...write!.slice(1, 9)], [0x07, 0x02, 0x03, 0x00, 0x00, 0x01, 0x02, 0x07]); + assert.equal((await client.readStatus()).liftOffDistance, "Low"); +}); + +test("writes and confirms the debounce time", async () => { + const { device, sent } = fakeDevice(0x5402); + const client = new PulsarXs1HidClient(device); + assert.equal(await client.setDebounceTime(4), 4); + const write = sent.find((packet) => packet[1] === 0x04 && packet[2] === 0x03); + assert.deepEqual([...write!.slice(1, 8)], [0x04, 0x03, 0x03, 0x00, 0x00, 0x01, 0x04]); + assert.equal((await client.readStatus()).debounceMs, 4); +}); + +test("fills the checksum on every packet it sends", async () => { + const { device, sent } = fakeDevice(0x5402); + const client = new PulsarXs1HidClient(device); + await client.setDebounceTime(8); + for (const packet of sent) { + const expected = packet.slice(0, 62).reduce((sum, byte) => sum + byte, 0) & 0xffff; + assert.equal(packet[62], expected & 0xff); + assert.equal(packet[63], expected >> 8); + } +}); + +test("the registry creates the X3 client", () => { + const { device } = fakeDevice(0x5402); + const client = createSupportedClient(device); + assert.ok(client instanceof PulsarXs1HidClient); + assert.equal(deviceBrand(client!), "Pulsar"); +}); diff --git a/src/drivers/pulsar/pulsar-xs1-hid.ts b/src/drivers/pulsar/pulsar-xs1-hid.ts new file mode 100644 index 0000000..af0e026 --- /dev/null +++ b/src/drivers/pulsar/pulsar-xs1-hid.ts @@ -0,0 +1,300 @@ +import type { MouseStatus } from "../mouse-types.ts"; +import { + PULSAR_CONFIG_REPORT_ID as CONFIG_REPORT_ID, + PULSAR_VENDOR_ID, + PULSAR_XS1_DEBOUNCE_MAX_MS as DEBOUNCE_MAX_MS, + PULSAR_XS1_FEATURE_REPORT_ID as REPORT_ID, + PULSAR_XS1_PACKET_LENGTH as PACKET_LENGTH, + PULSAR_XS1_POLLING_RATES as POLLING_RATES, + PULSAR_XS1_PRODUCT_IDS, + PULSAR_XS1_WIRELESS_PRODUCT_IDS, + pulsarXs1DecodePollingRate, + pulsarXs1DpiOptions, + pulsarXs1EncodeRequest, + readUint16LE, +} from "@openmouse/protocol/pulsar"; + +export interface PulsarXs1DeviceInfo { + connection: "Wired" | "Wireless"; + maximumPollingRateHz: number; +} + +type LiftOffDistance = NonNullable; + +const RESPONSE_ATTEMPTS = 5; +const RESPONSE_DELAY_MS = 25; +const DPI_VALUE_OFFSET = 7; + +const LIFT_OFF_DISTANCES: ReadonlyArray = [ + [0x07, "Low"], + [0x0a, "Medium"], + [0x14, "High"], +]; + +const QUERY = { + version: [0x01, 0x87, 0x04], + dpi: [0x05, 0x82, 0x05], + stage: [0x05, 0x81, 0x02], + motionSync: [0x07, 0x85, 0x02], + rippleControl: [0x07, 0x83, 0x02], + angleSnapping: [0x07, 0x84, 0x02], + liftOffDistance: [0x07, 0x82, 0x03], + debounce: [0x04, 0x83, 0x03], + battery: [0x08, 0x81, 0x01], + pollingRate: [0x08, 0x85, 0x03], +}; + +const WRITE = { + dpi: (dpi: number): readonly number[] => [ + 0x05, 0x02, 0x05, 0x00, 0x00, 0x01, + dpi & 0xff, dpi >> 8, dpi & 0xff, dpi >> 8, + ], + stage: (stage: number): readonly number[] => [0x05, 0x01, 0x02, 0x00, 0x00, 0x01, stage], + motionSync: (enabled: boolean): readonly number[] => [0x07, 0x05, 0x02, 0x00, 0x00, 0x01, enabled ? 1 : 0], + rippleControl: (enabled: boolean): readonly number[] => [0x07, 0x03, 0x02, 0x00, 0x00, 0x01, enabled ? 1 : 0], + angleSnapping: (enabled: boolean): readonly number[] => [0x07, 0x04, 0x02, 0x00, 0x00, 0x01, enabled ? 1 : 0], + liftOffDistance: (value: number): readonly number[] => [0x07, 0x02, 0x03, 0x00, 0x00, 0x01, 0x02, value], + debounce: (milliseconds: number): readonly number[] => [0x04, 0x03, 0x03, 0x00, 0x00, 0x01, milliseconds], +}; + +export class PulsarXs1HidClient { + private queue: Promise = Promise.resolve(); + private lastStatus: MouseStatus | null = null; + private deviceInfo: PulsarXs1DeviceInfo | null = null; + + readonly device: HIDDevice; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== PULSAR_VENDOR_ID || !PULSAR_XS1_PRODUCT_IDS.has(device.productId)) return false; + const hasXs1Feature = device.collections.some((collection) => + collection.featureReports.some((report) => report.reportId === REPORT_ID)); + const hasLegacyControl = device.collections.some((collection) => + collection.inputReports.some((report) => report.reportId === CONFIG_REPORT_ID) + && collection.outputReports.some((report) => report.reportId === CONFIG_REPORT_ID)); + return hasXs1Feature && !hasLegacyControl; + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + if (this.device.opened) await this.device.close(); + } + + describeCollections(): string { + return this.device.collections.map((collection) => { + const inputIds = collection.inputReports.map((report) => report.reportId); + const outputIds = collection.outputReports.map((report) => report.reportId); + const featureIds = collection.featureReports.map((report) => report.reportId); + return [ + `usage 0x${collection.usagePage.toString(16)}:${collection.usage.toString(16)}`, + `in [${inputIds.join(", ") || "none"}]`, + `out [${outputIds.join(", ") || "none"}]`, + `feature [${featureIds.join(", ") || "none"}]`, + ].join(" ยท "); + }).join(" | ") || "No HID collections reported"; + } + + async readDeviceInfo(): Promise { + await this.open(); + this.deviceInfo = { + connection: PULSAR_XS1_WIRELESS_PRODUCT_IDS.has(this.device.productId) ? "Wireless" : "Wired", + maximumPollingRateHz: 1000, + }; + return this.deviceInfo; + } + + async readStatus(): Promise { + await this.open(); + const info = this.deviceInfo ?? await this.readDeviceInfo(); + const battery = await this.query(QUERY.battery); + const dpiReply = await this.query(QUERY.dpi); + const batteryPercent = battery[6] <= 100 ? battery[6] : null; + const version = await this.query(QUERY.version).catch(() => null); + const motionSync = await this.query(QUERY.motionSync).catch(() => null); + const rippleControl = await this.query(QUERY.rippleControl).catch(() => null); + const angleSnapping = await this.query(QUERY.angleSnapping).catch(() => null); + const liftOffDistance = await this.query(QUERY.liftOffDistance).catch(() => null); + const debounce = await this.query(QUERY.debounce).catch(() => null); + const pollingRate = await this.query(QUERY.pollingRate).catch(() => null); + return this.lastStatus = { + brand: "Pulsar", + name: this.device.productName || "Pulsar X3", + ui: { + family: "pulsar", + hideUnsupportedPollingRates: true, + pollingReadOnly: true, + hideSleepCard: true, + hideSignalCard: true, + forceShowBattery: true, + pollingNote: "The X3 exposes its polling rate through the feature interface, but the reported value is not reliable.", + }, + batteryPercent, + batteryState: batteryPercent === null ? "Unknown" : batteryPercent === 100 ? "Full" : "Discharging", + dpi: readUint16LE(dpiReply, DPI_VALUE_OFFSET), + pollingRateHz: pollingRate ? pulsarXs1DecodePollingRate(pollingRate[7]) ?? POLLING_RATES[3] : POLLING_RATES[3], + supportedPollingRates: [...POLLING_RATES], + activeProfile: null, + connectionType: info.connection, + connectionDetail: "XS-1 feature-report interface", + motionSync: motionSync ? motionSync[7] === 1 : null, + rippleControl: rippleControl ? rippleControl[7] === 1 : null, + angleSnapping: angleSnapping ? angleSnapping[7] === 1 : null, + debounceMs: debounce ? debounce[7] : null, + liftOffDistance: this.decodeLiftOffDistance(liftOffDistance ? liftOffDistance[8] : 0), + firmware: [this.decodeFirmware(version)], + }; + } + + getDpiOptions(): number[] { + return pulsarXs1DpiOptions(); + } + + async setDpi(dpi: number): Promise { + if (!this.getDpiOptions().includes(dpi)) { + throw new Error(`${dpi.toLocaleString()} DPI is not supported by this Pulsar sensor.`); + } + await this.send(WRITE.dpi(dpi)); + const confirmed = await this.query(QUERY.dpi); + const confirmedX = readUint16LE(confirmed, DPI_VALUE_OFFSET); + const confirmedY = readUint16LE(confirmed, DPI_VALUE_OFFSET + 2); + if (confirmedX !== dpi || confirmedY !== dpi) { + throw new Error(`The mouse kept ${confirmedX.toLocaleString()} DPI instead of ${dpi.toLocaleString()}.`); + } + this.patch({ dpi: confirmedX }); + return confirmedX; + } + + async setMotionSync(enabled: boolean): Promise { + return await this.setFlag(WRITE.motionSync, QUERY.motionSync, enabled, "motionSync", "Motion Sync"); + } + + async setRippleControl(enabled: boolean): Promise { + return await this.setFlag(WRITE.rippleControl, QUERY.rippleControl, enabled, "rippleControl", "ripple control"); + } + + async setAngleSnapping(enabled: boolean): Promise { + return await this.setFlag(WRITE.angleSnapping, QUERY.angleSnapping, enabled, "angleSnapping", "angle snapping"); + } + + async setLiftOffDistance(liftOffDistance: LiftOffDistance): Promise { + const encoded = LIFT_OFF_DISTANCES.find(([, name]) => name === liftOffDistance); + if (!encoded) { + throw new Error(`This mouse does not support a ${liftOffDistance.toLowerCase()} lift-off distance.`); + } + await this.send(WRITE.liftOffDistance(encoded[0])); + const confirmed = this.decodeLiftOffDistance((await this.query(QUERY.liftOffDistance))[8]); + if (confirmed !== liftOffDistance) { + throw new Error(`The mouse kept ${confirmed ?? "an unknown"} lift-off distance instead of ${liftOffDistance.toLowerCase()}.`); + } + this.patch({ liftOffDistance: confirmed }); + return confirmed; + } + + async setDebounceTime(debounceMs: number): Promise { + if (!Number.isInteger(debounceMs) || debounceMs < 0 || debounceMs > DEBOUNCE_MAX_MS) { + throw new Error(`This Pulsar model supports a debounce time from 0 to ${DEBOUNCE_MAX_MS} ms.`); + } + await this.send(WRITE.debounce(debounceMs)); + const confirmed = (await this.query(QUERY.debounce))[7]; + if (confirmed !== debounceMs) { + throw new Error(`The mouse kept ${confirmed} ms of debounce instead of ${debounceMs} ms.`); + } + this.patch({ debounceMs: confirmed }); + return confirmed; + } + + private async setFlag( + write: (enabled: boolean) => readonly number[], + read: readonly number[], + enabled: boolean, + field: "motionSync" | "rippleControl" | "angleSnapping", + label: string, + ): Promise { + await this.send(write(enabled)); + const confirmed = (await this.query(read))[7] === 1; + if (confirmed !== enabled) throw new Error(`The mouse left ${label} ${confirmed ? "on" : "off"}.`); + this.patch({ [field]: confirmed }); + return confirmed; + } + + private async query(command: readonly number[]): Promise { + const run = this.queue.then(() => this.exchange(command), () => this.exchange(command)); + this.queue = run.catch(() => undefined); + return await run; + } + + private async send(command: readonly number[]): Promise { + const run = this.queue.then(() => this.write(command), () => this.write(command)); + this.queue = run.catch(() => undefined); + await run; + } + + private async exchange(command: readonly number[]): Promise { + await this.open(); + const packet = pulsarXs1EncodeRequest(command); + let lastError: unknown = null; + for (let attempt = 0; attempt < RESPONSE_ATTEMPTS; attempt += 1) { + try { + await this.device.sendFeatureReport(REPORT_ID, packet); + await this.delay(RESPONSE_DELAY_MS); + const reply = this.copyDataView(await this.device.receiveFeatureReport(REPORT_ID)); + if (reply.length !== PACKET_LENGTH) { + throw new Error(`The Pulsar X3 answered with ${reply.length} bytes instead of ${PACKET_LENGTH}.`); + } + return reply; + } catch (error) { + lastError = error; + await this.delay(RESPONSE_DELAY_MS); + } + } + throw new Error( + `The Pulsar X3 did not answer ${this.describe(command)} after ${RESPONSE_ATTEMPTS} attempts. ${this.describeError(lastError)}`, + ); + } + + private async write(command: readonly number[]): Promise { + await this.open(); + try { + await this.device.sendFeatureReport(REPORT_ID, pulsarXs1EncodeRequest(command)); + } catch (error) { + throw new Error(`Chrome could not write the Pulsar X3 feature report. ${this.describeError(error)}`); + } + } + + private delay(milliseconds: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); + } + + private copyDataView(view: DataView): Uint8Array { + return new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); + } + + private patch(changes: Partial): void { + if (this.lastStatus) this.lastStatus = { ...this.lastStatus, ...changes }; + } + + private decodeLiftOffDistance(value: number): LiftOffDistance | null { + return LIFT_OFF_DISTANCES.find(([encoded]) => encoded === value)?.[1] ?? null; + } + + private decodeFirmware(reply: Uint8Array | null): string { + if (!reply) return "Mouse firmware unavailable"; + const major = reply[7] ?? 0; + const minor = reply[6] ?? 0; + return `Mouse v${major.toString(16).padStart(2, "0")}.${minor.toString(16).padStart(2, "0")}`; + } + + private describe(command: readonly number[]): string { + return `command 0x${command.map((byte) => byte.toString(16).padStart(2, "0")).join(" ")}`; + } + + private describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index ca28740..e388139 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -11,6 +11,7 @@ import { NinjutsoHidClient } from "./ninjutso/hid.ts"; import { OrbitalHidClient } from "./orbital/hid.ts"; import { PulsarHidClient } from "./pulsar/pulsar-hid.ts"; import { PulsarProHidClient } from "./pulsar/pulsar-pro-hid.ts"; +import { PulsarXs1HidClient } from "./pulsar/pulsar-xs1-hid.ts"; import { RazerCobraHidClient } from "./razer/cobra-hid.ts"; import { RazerHidClient } from "./razer/hid.ts"; import { RazerViperHidClient } from "./razer/viper-hid.ts"; @@ -21,7 +22,7 @@ import { VgnF2HidClient } from "./vgn/hid.ts"; import { WLMouseHidClient } from "./wlmouse/hid.ts"; import { ZaunkoenigHidClient } from "./zaunkoenig/hid.ts"; -export type PulsarClient = PulsarHidClient | PulsarProHidClient; +export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient; export interface DeviceDriver { @@ -36,6 +37,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "Finalmouse", supports: (device) => FinalmouseHidClient.isSupported(device), create: (device) => new FinalmouseHidClient(device), score: () => 10 }, { brand: "Endgame Gear", supports: (device) => EggOp1HidClient.isSupported(device), create: (device) => new EggOp1HidClient(device), score: () => 10 }, { brand: "Endgame Gear", supports: eggWeIsSupported, create: eggWeCreate, score: eggWeSupportScore }, + { brand: "Pulsar", supports: (device) => PulsarXs1HidClient.isSupported(device), create: (device) => new PulsarXs1HidClient(device), score: () => 8 }, { brand: "Pulsar", supports: (device) => PulsarProHidClient.isSupported(device), create: (device) => new PulsarProHidClient(device), score: () => 8 }, { brand: "Pulsar", supports: (device) => PulsarHidClient.isSupported(device), create: (device) => new PulsarHidClient(device), score: () => 7 }, { brand: "Teevolution", supports: (device) => TeevolutionHidClient.isSupported(device), create: (device) => new TeevolutionHidClient(device), score: () => 7 }, diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 8034ae3..4560941 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -4,6 +4,7 @@ import { LOGITECH_DIRECT_PRODUCT_IDS, } from "@openmouse/protocol/logitech"; import { RAZER_PRODUCTS, RAZER_PRODUCT_IDS } from "@openmouse/protocol/razer-devices"; +import { PULSAR_XS1_PRODUCT_IDS } from "@openmouse/protocol/pulsar"; import { NINJUTSO_LEGACY_MOUSE_PRODUCT_IDS, NINJUTSO_LEGACY_RECEIVER_PRODUCT_IDS, @@ -55,6 +56,14 @@ export const MODDO_HID_FILTERS: HIDDeviceFilter[] = [ { vendorId: VENDOR_ID.moddo, usagePage: 0xff, usage: 0x02 }, ]; +// The X3 family's control channel is the Sonix XS-1 interface: a single +// 64-byte unnumbered feature report on usage page 0xffff. Request that +// collection directly so the picker lists the control interface instead of the +// mouse's plain pointer interface, which cannot answer feature reports. +export const PULSAR_XS1_HID_FILTERS: HIDDeviceFilter[] = [...PULSAR_XS1_PRODUCT_IDS].map( + (productId) => ({ vendorId: VENDOR_ID.pulsar, productId, usagePage: 0xffff, usage: 0x01 }), +); + // Viper V2/V3 Pro and Mouse Dock Pro expose their control channel as a Generic // Desktop Mouse collection. Limit this broad collection filter to known PIDs so // it cannot also surface unrelated Razer keyboards or the Viper V4 Pro's @@ -213,6 +222,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ })), { vendorId: VENDOR_ID.finalmouse, productId: 0x0100, usagePage: 0xff00, usage: 0x0001 }, { vendorId: VENDOR_ID.pulsar }, + ...PULSAR_XS1_HID_FILTERS, { 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 5efbdb0..b8bc113 100644 --- a/src/pulsar/index.ts +++ b/src/pulsar/index.ts @@ -87,6 +87,49 @@ export function readUint16LE(data: Uint8Array, offset: number): number { return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8); } +// 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([0x3409, 0x3410, 0x5402, 0x5403]); +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; +export const PULSAR_XS1_DPI_MIN = 50; +export const PULSAR_XS1_DPI_MAX = 26000; +export const PULSAR_XS1_DPI_STEP = 50; +export const PULSAR_XS1_DEBOUNCE_MAX_MS = 15; +export const PULSAR_XS1_POLLING_RATES: readonly number[] = [125, 250, 500, 1000]; + +export function pulsarXs1Checksum(packet: Uint8Array): number { + let sum = 0; + for (let index = 0; index < packet.length - 2; index += 1) sum += packet[index] ?? 0; + return sum & 0xffff; +} + +export function pulsarXs1EncodeRequest(command: readonly number[]): Uint8Array { + const packet = new Uint8Array(PULSAR_XS1_PACKET_LENGTH); + packet[0] = 0x00; + for (let index = 0; index < command.length; index += 1) packet[index + 1] = command[index] ?? 0; + const checksum = pulsarXs1Checksum(packet); + packet[62] = checksum & 0xff; + packet[63] = checksum >> 8; + return packet; +} + +export function pulsarXs1DpiOptions(): number[] { + const options: number[] = []; + for (let dpi = PULSAR_XS1_DPI_MIN; dpi <= PULSAR_XS1_DPI_MAX; dpi += PULSAR_XS1_DPI_STEP) options.push(dpi); + return options; +} + +export function pulsarXs1DecodePollingRate(value: number): number | null { + return PULSAR_XS1_POLLING_QUERY_RATES[value] ?? null; +} + +const PULSAR_XS1_POLLING_QUERY_RATES: Readonly> = { + 240: 125, 120: 250, 60: 500, 30: 1000, 15: 2000, 8: 4000, 4: 8000, +}; + export function readUint32LE(data: Uint8Array, offset: number): number { return ((data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8) | ((data[offset + 2] ?? 0) << 16) | ((data[offset + 3] ?? 0) << 24)) >>> 0; }