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
6 changes: 6 additions & 0 deletions docs/finalmouse-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 21 additions & 2 deletions src/drivers/finalmouse/hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -242,12 +250,23 @@ export class FinalmouseHidClient {
}

async setPollingRate(pollingRateHz: number): Promise<number> {
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<boolean> {
await this.write(this.txReport, FINALMOUSE_COMMAND.motionSync, new Uint8Array([enabled ? 1 : 0]));
this.telemetry.motionSync = enabled;
Expand Down
34 changes: 34 additions & 0 deletions src/drivers/finalmouse/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
39 changes: 39 additions & 0 deletions src/drivers/finalmouse/vendors.test.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
});
8 changes: 8 additions & 0 deletions src/drivers/vendors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
Loading