diff --git a/docs/finalmouse-testing.md b/docs/finalmouse-testing.md index 4b3b5772..724e50c2 100644 --- a/docs/finalmouse-testing.md +++ b/docs/finalmouse-testing.md @@ -31,6 +31,12 @@ was available, so every SLX path below is **unverified on hardware**: capture/live-stream cmds 27-30 are intentionally not wired to any UI. - PerfectPolling: display-only in xpanel (fixed 250 µs end-to-end claim); there is no writable command, so OpenMouse shows an info card. +- Polling rate: the Starlight X has none to set — PerfectPolling runs a fixed + pipeline. The driver still surfaces the rate it reports for telemetry, but + advertises no selectable rates (`supportedPollingRates: []`), refuses + `setPollingRate`, and sets `pollingReadOnly` so the app hides the selector + (`hideMotionSync` likewise, since Motion Sync is not user-facing there). + Ultralight X keeps the full 500-8000 Hz ladder and its Motion Sync toggle. - LOD custom: PAW cmd 25. Presets send raw 2 (= 1 mm) / raw 3 (= 2 mm); the custom slider sends raw 7-17 (= 0.7-1.7 mm). Surfaced through the shared `liftOffScale` slider plus the `setLiftOffScale` driver hook. diff --git a/src/drivers/finalmouse/hid.ts b/src/drivers/finalmouse/hid.ts index c1f1cb33..baa15647 100644 --- a/src/drivers/finalmouse/hid.ts +++ b/src/drivers/finalmouse/hid.ts @@ -157,13 +157,21 @@ export class FinalmouseHidClient { defaultDisplayName: slx ? FINALMOUSE_SLX_DISPLAY_NAME : FINALMOUSE_DISPLAY_NAME, hideUnsupportedPollingRates: true, forceShowBattery: true, + // Starlight X runs PerfectPolling: a fixed-rate pipeline with no + // selectable polling rate and no user-facing Motion Sync, so the app + // must not offer either as if they were adjustable. Ultralight X + // exposes both. + pollingReadOnly: slx ? true : undefined, + hideMotionSync: slx ? true : undefined, }, batteryPercent: state.batterySoc ?? state.batteryPercent ?? null, batteryVoltageMv: state.batteryVoltageMv ?? null, batteryState: state.chargingState === 1 ? "Charging" : state.chargingState === undefined ? "Unknown" : "Discharging", dpi: state.dpi, pollingRateHz: state.pollingRateHz, - supportedPollingRates: [...POLLING_RATES], + // Starlight X: PerfectPolling is fixed, so it advertises no selectable + // rates. Ultralight X lists the whole ladder. + supportedPollingRates: [...this.supportedPollingRates], activeProfile: null, unitId: state.mouseSerial ?? null, connectionType: "Wireless", @@ -242,12 +250,23 @@ export class FinalmouseHidClient { } async setPollingRate(pollingRateHz: number): Promise { - if (!(POLLING_RATES as readonly number[]).includes(pollingRateHz)) throw new Error("Unsupported Finalmouse UltralightX polling rate."); + const supported: readonly number[] = this.supportedPollingRates; + if (!supported.includes(pollingRateHz)) throw new Error("This Finalmouse dongle has no adjustable polling rate."); await this.writeU16(FINALMOUSE_COMMAND.pollingRate, pollingRateHz); this.telemetry.pollingRateHz = pollingRateHz; return pollingRateHz; } + /** + * Rates this dongle accepts. The Ultralight X exposes the whole ladder; the + * Starlight X's PerfectPolling pipeline is fixed, so it offers none. The + * reported `pollingRateHz` is still surfaced for telemetry, but the app + * hides the selector (see `pollingReadOnly`). + */ + private get supportedPollingRates(): readonly number[] { + return this.isSlx ? [] : POLLING_RATES; + } + async setMotionSync(enabled: boolean): Promise { await this.write(this.txReport, FINALMOUSE_COMMAND.motionSync, new Uint8Array([enabled ? 1 : 0])); this.telemetry.motionSync = enabled; diff --git a/src/drivers/finalmouse/protocol.test.ts b/src/drivers/finalmouse/protocol.test.ts index bd879684..9b0e2241 100644 --- a/src/drivers/finalmouse/protocol.test.ts +++ b/src/drivers/finalmouse/protocol.test.ts @@ -271,3 +271,37 @@ test("Finalmouse SLX setters frame TMR, profiles and PAW LOD on report 0x01", as assert.deepEqual(writes[2]!.bytes.slice(0, 4), [3, 0xb9, 1, 2]); assert.deepEqual(writes[3]!.bytes.slice(0, 4), [3, 0x99, 1, 12]); }); + +test("Finalmouse Starlight X exposes a fixed PerfectPolling rate, not a picker", async () => { + // SLX answers the poll-rate read (so readStatus still needs it) but has no + // adjustable rate, so it advertises none and refuses a write; the app then + // hides the selector via `pollingReadOnly` / `hideMotionSync`. + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const device = { + vendorId: 0x361d, + productId: 0x0300, + productName: "Finalmouse SLX", + collections: [{ usagePage: 0xff00, usage: 1 }], + opened: true, + async open() {}, + async close() {}, + addEventListener(_type: string, handler: (event: HIDInputReportEvent) => void) { listener = handler; }, + removeEventListener() {}, + async sendReport() {}, + } as unknown as HIDDevice; + const client = new FinalmouseHidClient(device); + // Attach the input listener before emitting, mirroring a real burst that + // arrives after the driver opens the interface. + await client.open(); + const emit = (command: number, payload: number[]): void => { + listener?.({ reportId: FINALMOUSE_REPORT.slxInput, data: input(command, payload) } as HIDInputReportEvent); + }; + emit(3, [0x40, 0x06]); // DPI 1600 + emit(4, [0x40, 0x1f]); // the fixed rate the SLX reports + + const status = await client.readStatus(); + assert.deepEqual(status.supportedPollingRates, []); + assert.equal(status.ui?.pollingReadOnly, true); + assert.equal(status.ui?.hideMotionSync, true); + await assert.rejects(client.setPollingRate(8000), /polling rate/); +}); diff --git a/src/drivers/finalmouse/vendors.test.ts b/src/drivers/finalmouse/vendors.test.ts new file mode 100644 index 00000000..fbcabb76 --- /dev/null +++ b/src/drivers/finalmouse/vendors.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SUPPORTED_HID_FILTERS, VENDOR_ID } from "../vendors.ts"; +import { FINALMOUSE_SLX_DONGLE_PRODUCT_IDS } from "../../finalmouse/index.ts"; +import { FinalmouseHidClient } from "./hid.ts"; + +// Guards the picker surface: a driver that can speak a device is useless if the +// browser picker never offers it. The ULX dongle and both Starlight X dongles +// must each have a filter, and the SLX filters must stay usage-agnostic (only +// the 0xff00 page is pinned) because their exact usage is not confirmed. +test("every Finalmouse dongle the driver accepts is offered by the picker", () => { + const filtersFor = (productId: number) => SUPPORTED_HID_FILTERS.filter( + (filter) => filter.vendorId === VENDOR_ID.finalmouse && filter.productId === productId, + ); + + assert.ok(filtersFor(0x0100).length > 0, "ULX dongle 0x0100 has no filter"); + for (const productId of FINALMOUSE_SLX_DONGLE_PRODUCT_IDS) { + const filters = filtersFor(productId); + assert.ok(filters.length > 0, `SLX dongle 0x${productId.toString(16)} has no filter`); + assert.ok( + filters.some((filter) => filter.usagePage === 0xff00 && filter.usage === undefined), + `SLX dongle 0x${productId.toString(16)} must match on the 0xff00 page alone`, + ); + } +}); + +test("the Finalmouse picker filters select the dongles the client supports", () => { + for (const productId of [0x0100, ...FINALMOUSE_SLX_DONGLE_PRODUCT_IDS]) { + // Any usage inside the vendor page satisfies the filter; the driver is the + // thing that accepts the device, so the two must agree on the PID set. + const device = { + vendorId: VENDOR_ID.finalmouse, + productId, + collections: [{ usagePage: 0xff00, usage: productId === 0x0100 ? 0x0001 : 0x0009 }], + } as unknown as HIDDevice; + assert.equal(FinalmouseHidClient.isSupported(device), true, `0x${productId.toString(16)} not supported`); + } +}); diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index a7b8f047..3387e201 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -8,6 +8,7 @@ import { } from "../asus/index.ts"; import { ATK_COMPX_PRODUCT_IDS } from "./atk/products.ts"; import { MICROSOFT_PRODUCT_CLASSIC, MICROSOFT_PRODUCT_PRO, MICROSOFT_VENDOR_ID, MICROSOFT_CLASSIC_USAGE_PAGE, MICROSOFT_CLASSIC_USAGE, MICROSOFT_PRO_USAGE_PAGE, MICROSOFT_PRO_USAGE } from "../microsoft/index.ts"; +import { FINALMOUSE_SLX_DONGLE_PRODUCT_IDS } from "../finalmouse/index.ts"; import { INCOTT_PRODUCT_IDS, INCOTT_USAGE_PAGE, INCOTT_VENDOR_ID } from "../incott/index.ts"; import { GLORIOUS_CORE2_PRODUCTS, GLORIOUS_CORE2_USAGE_PAGE, GLORIOUS_CORE2_VENDOR_ID } from "../glorious-core2/index.ts"; import { EGG_WE_HID_FILTERS } from "./endgame/egg-we-control.ts"; @@ -883,6 +884,13 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...CORSAIR_HID_FILTERS, ...CORSAIR_BRAGI_HID_FILTERS, { vendorId: VENDOR_ID.finalmouse, productId: 0x0100, usagePage: 0xff00, usage: 0x0001 }, + // Starlight X dongles share 0xff00 but the exact usage is not pinned down, so + // the picker matches on the page alone (the driver accepts either). + ...FINALMOUSE_SLX_DONGLE_PRODUCT_IDS.map((productId) => ({ + vendorId: VENDOR_ID.finalmouse, + productId, + usagePage: 0xff00, + })), { vendorId: VENDOR_ID.pulsar }, ...PULSAR_XS1_HID_FILTERS, // The Pulsar 4K Wireless Receiver enumerates under the shared Teevolution/VGN