diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 8021702..8632b69 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -195,6 +195,10 @@ export interface MouseStatus { napeLayerCount?: number; performanceMode?: boolean | null; hyperMode?: boolean | null; + /** Sensor pinned to its highest frame rate (WLmouse "Turbo Mode"). */ + turboMode?: boolean | null; + /** Whether button chords can change mouse settings without the driver. */ + buttonCombination?: boolean | null; sensorMode?: "Eco" | "High" | "Ultra" | null; sensorModeStored?: 0 | 1 | null; sensorModeEditable?: boolean | null; diff --git a/src/drivers/wlmouse/hid.test.ts b/src/drivers/wlmouse/hid.test.ts index bacf07b..79b7218 100644 --- a/src/drivers/wlmouse/hid.test.ts +++ b/src/drivers/wlmouse/hid.test.ts @@ -11,6 +11,10 @@ function fakeDevice(offset: number, sleepingReplies = 0, activeProfile = 1) { const sent: Uint8Array[] = []; let liftOff = 0x01; let debounce = 0x00; + let stages = [{ x: 1600, y: 1600 }]; + let activeStage = 1; + let angleTuning = 0x00; + let buttonCombination = 0x00; const device = { vendorId: VENDOR_ID.wlmouse, productId: 0xa863, @@ -27,19 +31,38 @@ function fakeDevice(offset: number, sleepingReplies = 0, activeProfile = 1) { reply[offset] = 0xa0; return new DataView(reply.buffer); } + const target = request[2]; const page = request[4]; const command = request[5]; + if (page === 0x01 && command === 0x14) angleTuning = request[7]!; + if (page === 0x03 && command === 0x01) buttonCombination = request[7]!; if (page === 0x01 && command === 0x08) liftOff = request[7]!; if (page === 0x00 && command === 0x08) debounce = request[7]!; - const payload = page === 0x00 && command === 0x85 + if (page === 0x01 && command === 0x02) activeStage = request[7]!; + if (page === 0x01 && command === 0x01) { + stages = Array.from({ length: request[7]! }, (_, index) => ({ + x: (request[8 + index * 4]! << 8) | request[9 + index * 4]!, + y: (request[10 + index * 4]! << 8) | request[11 + index * 4]!, + })); + } + const payload = target === 0x01 && command === 0x8b + ? [0x00, 0x00, 0x00, 0x00, 0xa8, 0x80] + : page === 0x01 && command === 0x94 + ? [0x01, angleTuning] + : page === 0x03 && command === 0x81 + ? [0x01, buttonCombination] + : page === 0x00 && command === 0x85 ? [activeProfile, 0x00] : page === 0x01 && command === 0x88 ? [0x01, liftOff] : page === 0x00 && command === 0x88 ? [0x01, debounce] : page === 0x01 && command === 0x81 - ? [0x01, 0x01, 0x06, 0x40, 0x06, 0x40] - : [0x01, 0x01]; + ? [0x01, stages.length, ...stages.flatMap(({ x, y }) => + [x >> 8 & 0xff, x & 0xff, y >> 8 & 0xff, y & 0xff])] + : page === 0x01 && command === 0x82 + ? [0x01, activeStage] + : [0x01, 0x01]; reply[offset] = 0xa1; reply[3 + offset] = payload.length; reply[4 + offset] = page; @@ -86,3 +109,76 @@ test("profile-scoped commands address the reported active profile", async () => `expected every profile-scoped command to address profile 2, saw:\n` + scoped.map((packet) => [...packet.slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join(" ")).join("\n")); }); + +test("the DPI stage table round-trips through the shared stage editor", async () => { + const client = new WLMouseHidClient(fakeDevice(0).device); + const before = await client.readStatus(); + assert.deepEqual(before.dpiStages, [1600]); + assert.equal(before.activeDpiStage, 0); + assert.equal(before.ui?.dpiStageEditor?.maxStages, 6); + + assert.equal(await client.setDpiStageCount(3), 3); + assert.equal(await client.setDpiStageValue(2, 3200), 3200); + assert.equal(await client.setActiveDpiStage(2), 2); + + const after = await client.readStatus(); + assert.deepEqual(after.dpiStages, [1600, 1600, 3200]); + assert.equal(after.activeDpiStage, 2); + assert.equal(after.dpi, 3200); +}); + +test("editing one stage leaves a separate Y axis alone", async () => { + const { device, sent } = fakeDevice(0); + const client = new WLMouseHidClient(device); + await client.readStatus(); + await client.setDpi(1600, 800); + + await client.setDpiStageValue(0, 3200); + + const written = sent.filter((packet) => packet[4] === 0x01 && packet[5] === 0x01).at(-1)!; + assert.equal((written[8]! << 8) | written[9]!, 3200, "X should follow the edit"); + assert.equal((written[10]! << 8) | written[11]!, 800, "Y should be left where it was"); +}); + +test("a rejected stage count is reported, not silently kept", async () => { + const client = new WLMouseHidClient(fakeDevice(0).device); + await client.readStatus(); + await assert.rejects(() => client.setDpiStageCount(7), /between 1 and 6/); + await assert.rejects(() => client.setDpiStageValue(0, 1601), /not a supported DPI value/); + await assert.rejects(() => client.setActiveDpiStage(4), /does not have a DPI stage 5/); +}); + +test("a negative sensor angle survives the round trip as two's complement", async () => { + const { device, sent } = fakeDevice(0); + const client = new WLMouseHidClient(device); + await client.readStatus(); + + assert.equal(await client.setAngleTuning(-12), -12); + const written = sent.filter((packet) => packet[4] === 0x01 && packet[5] === 0x14).at(-1)!; + assert.equal(written[7], 0xf4, "-12 should go out as 0xf4"); + + assert.equal(await client.setAngleTuning(12), 12); + assert.equal((await client.readStatus()).angleTuning, 12); + await assert.rejects(() => client.setAngleTuning(31), /between -30 and 30/); +}); + +test("button combinations are written on the button page, not the profile page", async () => { + const { device, sent } = fakeDevice(0); + const client = new WLMouseHidClient(device); + await client.readStatus(); + + assert.equal(await client.setButtonCombination(true), true); + const written = sent.filter((packet) => packet[5] === 0x01 && packet[4] === 0x03).at(-1); + assert.ok(written, "expected a write on page 0x03"); + assert.equal(written![6], 0x01, "the profile still addresses the packet"); + assert.equal((await client.readStatus()).buttonCombination, true); +}); + +test("a mouse behind the shared receiver is named after the mouse", async () => { + const { device } = fakeDevice(0); + // The 1K receiver enumerates under its own product id whatever it is paired with. + (device as { productId: number }).productId = 0xa882; + (device as { productName: string }).productName = "WLmouse 1K receiver"; + const status = await new WLMouseHidClient(device).readStatus(); + assert.equal(status.name, "WLmouse Beast Max"); +}); diff --git a/src/drivers/wlmouse/hid.ts b/src/drivers/wlmouse/hid.ts index e12a6fb..af23ab1 100644 --- a/src/drivers/wlmouse/hid.ts +++ b/src/drivers/wlmouse/hid.ts @@ -31,12 +31,16 @@ const DPI_MAX = 30000; const TARGET = { dongle: 0x00, + // The receiver answers for the mouse it is paired with on its own target, + // separate from the pass-through that reaches the mouse itself. + pairing: 0x01, mouse: 0x02, } as const; const PAGE = { device: 0x00, profile: 0x01, + buttons: 0x03, } as const; type LiftOffDistance = NonNullable; @@ -62,6 +66,10 @@ const READ = { serial: { target: TARGET.mouse, page: PAGE.device, command: 0x82, length: 0x02, args: [] }, battery: { target: TARGET.mouse, page: PAGE.device, command: 0x83, length: 0x02, args: [] }, activeProfile: { target: TARGET.mouse, page: PAGE.device, command: 0x85, length: 0x01, args: [] }, + // Product id of the mouse currently paired to the receiver, big-endian in the + // last two payload bytes. The shared 1K dongle enumerates under one product + // id whatever it is paired with, so this is the only way to know the model. + pairedProduct: { target: TARGET.pairing, page: PAGE.device, command: 0x8b, length: 0x06, args: [0x02], attempts: 2 }, } as const satisfies Record; const PROFILE_READ = { @@ -70,7 +78,7 @@ const PROFILE_READ = { debounce: (profile: number): WLMouseRequest => ({ target: TARGET.mouse, page: PAGE.device, command: 0x88, length: 0x02, args: [profile] }), dpiStages: (profile: number): WLMouseRequest => - ({ target: TARGET.mouse, page: PAGE.profile, command: 0x81, length: 0x0a, args: [profile, 0x06] }), + ({ target: TARGET.mouse, page: PAGE.profile, command: 0x81, length: 0x0a, args: [profile, DPI_STAGE_MAX] }), activeStage: (profile: number): WLMouseRequest => ({ target: TARGET.mouse, page: PAGE.profile, command: 0x82, length: 0x02, args: [profile] }), pollingRate: (profile: number): WLMouseRequest => @@ -85,10 +93,19 @@ const PROFILE_READ = { ({ target: TARGET.mouse, page: PAGE.profile, command: 0x89, length: 0x02, args: [profile] }), rippleControl: (profile: number): WLMouseRequest => ({ target: TARGET.mouse, page: PAGE.profile, command: 0x8a, length: 0x02, args: [profile] }), + hyperMode: (profile: number): WLMouseRequest => + ({ target: TARGET.mouse, page: PAGE.profile, command: 0x8b, length: 0x02, args: [profile] }), + turboMode: (profile: number): WLMouseRequest => + ({ target: TARGET.mouse, page: PAGE.profile, command: 0x93, length: 0x02, args: [profile] }), + angleTuning: (profile: number): WLMouseRequest => + ({ target: TARGET.mouse, page: PAGE.profile, command: 0x94, length: 0x02, args: [profile] }), + buttonCombination: (profile: number): WLMouseRequest => + ({ target: TARGET.mouse, page: PAGE.buttons, command: 0x81, length: 0x02, args: [profile] }), } as const; const WRITE = { dpiStages: 0x01, + activeStage: 0x02, pollingRate: 0x00, liftOffDistance: 0x08, sleepTimeout: 0x07, @@ -96,8 +113,14 @@ const WRITE = { angleSnapping: 0x04, motionSync: 0x09, rippleControl: 0x0a, + hyperMode: 0x0b, + turboMode: 0x13, + angleTuning: 0x14, + buttonCombination: 0x01, } as const; +const DPI_STAGE_MAX = 6; +const ANGLE_TUNING_LIMIT = 30; const DEBOUNCE_MAX_MS = 15; const SLEEP_SECONDS: readonly number[] = [30, 60, 120, 300, 600, 1800]; const NOTIFY_REPORT_ID = 4; @@ -115,6 +138,7 @@ export class WLMouseHidClient { private notifier: HIDDevice | null = null; private notifyListener: ((event: HIDInputReportEvent) => void) | null = null; private activeProfile = 1; + private pairedProductId: number | null = null; readonly device: HIDDevice; @@ -184,8 +208,10 @@ export class WLMouseHidClient { } displayName(): string { - const known = WLMOUSE_PRODUCTS.get(this.device.productId); - return known ? `WLmouse ${known.name}` : this.device.productName || "WLmouse"; + const known = WLMOUSE_PRODUCTS.get(this.pairedProductId ?? this.device.productId); + if (!known) return this.device.productName || "WLmouse"; + // The shared-receiver entries already carry the brand in their name. + return known.name.startsWith("WLmouse") ? known.name : `WLmouse ${known.name}`; } getSleepOptions(): readonly number[] { @@ -247,6 +273,13 @@ export class WLMouseHidClient { const angleSnapping = await this.request(PROFILE_READ.angleSnapping(profile)).catch(() => null); const motionSync = await this.request(PROFILE_READ.motionSync(profile)).catch(() => null); const rippleControl = await this.request(PROFILE_READ.rippleControl(profile)).catch(() => null); + // Not every model has these, and one that does not answers `unsupported` + // rather than failing the read, so null here means "no such control". + const hyperMode = await this.request(PROFILE_READ.hyperMode(profile)).catch(() => null); + const turboMode = await this.request(PROFILE_READ.turboMode(profile)).catch(() => null); + const angleTuning = await this.request(PROFILE_READ.angleTuning(profile)).catch(() => null); + const buttonCombination = await this.request(PROFILE_READ.buttonCombination(profile)).catch(() => null); + if (wireless) await this.readPairedProduct(); const stage = stages[activeStage]; if (!stage) throw new Error("The mouse did not report any DPI stages."); return this.lastStatus = { @@ -256,11 +289,20 @@ export class WLMouseHidClient { family: "wlmouse", hideUnsupportedPollingRates: true, forceShowBattery: true, + dpiStageEditor: { + maxStages: DPI_STAGE_MAX, + countEditable: true, + minDpi: DPI_STEP, + maxDpi: DPI_MAX, + stepDpi: DPI_STEP, + }, }, batteryPercent: battery[1] <= 100 ? battery[1] : null, batteryState: battery[0] === 1 ? "Charging" : "Discharging", dpi: stage.x, dpiY: stage.y, + dpiStages: stages.map((entry) => entry.x), + activeDpiStage: activeStage, supportsSeparateDpiAxes: separateAxes ? separateAxes[1] === 1 : false, pollingRateHz: this.decodePollingRate(pollingRate[1]), supportedPollingRates: this.getSupportedPollingRates(), @@ -268,6 +310,10 @@ export class WLMouseHidClient { angleSnapping: angleSnapping ? angleSnapping[1] === 1 : null, motionSync: motionSync ? motionSync[1] === 1 : null, rippleControl: rippleControl ? rippleControl[1] === 1 : null, + hyperMode: hyperMode ? hyperMode[1] === 1 : null, + turboMode: turboMode ? turboMode[1] === 1 : null, + buttonCombination: buttonCombination ? buttonCombination[1] === 1 : null, + angleTuning: angleTuning ? this.decodeAngleTuning(angleTuning[1]) : null, connectionType: wireless ? "Wireless" : "Wired", connectionDetail: wireless ? "2.4 GHz receiver" : "Wired USB", unitId: this.decodeText(serial), @@ -331,15 +377,54 @@ export class WLMouseHidClient { return await this.setFlag(WRITE.rippleControl, PROFILE_READ.rippleControl, enabled, "rippleControl", "ripple control"); } + async setHyperMode(enabled: boolean): Promise { + return await this.setFlag(WRITE.hyperMode, PROFILE_READ.hyperMode, enabled, "hyperMode", "high-speed mode"); + } + + /** + * Turbo mode pins the sensor at 20K FPS. The mouse only runs it with + * high-speed mode on, so a turbo write can read back off until that is set. + */ + async setTurboMode(enabled: boolean): Promise { + return await this.setFlag(WRITE.turboMode, PROFILE_READ.turboMode, enabled, "turboMode", "turbo mode"); + } + + async setButtonCombination(enabled: boolean): Promise { + return await this.setFlag( + WRITE.buttonCombination, + PROFILE_READ.buttonCombination, + enabled, + "buttonCombination", + "button combinations", + PAGE.buttons, + ); + } + + async setAngleTuning(degrees: number): Promise { + if (!Number.isInteger(degrees) || Math.abs(degrees) > ANGLE_TUNING_LIMIT) { + throw new Error(`The sensor angle must be a whole number of degrees between -${ANGLE_TUNING_LIMIT} and ${ANGLE_TUNING_LIMIT}.`); + } + const profile = await this.currentProfile(); + // A negative angle goes on the wire as two's complement in one byte. + await this.write(PAGE.profile, WRITE.angleTuning, profile, [degrees & 0xff]); + const confirmed = this.decodeAngleTuning((await this.request(PROFILE_READ.angleTuning(profile)))[1]); + if (confirmed !== degrees) { + throw new Error(`The mouse kept a ${confirmed}° sensor angle instead of ${degrees}°.`); + } + this.patch({ angleTuning: confirmed }); + return confirmed; + } + private async setFlag( command: number, read: (profile: number) => WLMouseRequest, enabled: boolean, - field: "angleSnapping" | "motionSync" | "rippleControl", + field: "angleSnapping" | "motionSync" | "rippleControl" | "hyperMode" | "turboMode" | "buttonCombination", label: string, + page: number = PAGE.profile, ): Promise { const profile = await this.currentProfile(); - await this.write(PAGE.profile, command, profile, [enabled ? 1 : 0]); + await this.write(page, command, profile, [enabled ? 1 : 0]); const confirmed = (await this.request(read(profile)))[1] === 1; if (confirmed !== enabled) { throw new Error(`The mouse left ${label} ${confirmed ? "on" : "off"}.`); @@ -378,28 +463,99 @@ export class WLMouseHidClient { } async setDpi(dpi: number, dpiY: number = dpi): Promise { - for (const value of [dpi, dpiY]) { - if (!Number.isInteger(value) || value < DPI_STEP || value > DPI_MAX || value % DPI_STEP !== 0) { - throw new Error(`${value.toLocaleString()} is not a supported DPI value.`); - } - } + this.assertDpi(dpi); + this.assertDpi(dpiY); const profile = await this.currentProfile(); - const stages = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile))); + const stages = await this.readStages(profile); const active = this.stageIndex((await this.request(PROFILE_READ.activeStage(profile)))[1], stages.length); if (!stages[active]) throw new Error("The mouse did not report any DPI stages."); stages[active] = { x: dpi, y: dpiY }; - await this.write(PAGE.profile, WRITE.dpiStages, profile, [ - stages.length, - ...stages.flatMap((stage) => [stage.x >> 8 & 0xff, stage.x & 0xff, stage.y >> 8 & 0xff, stage.y & 0xff]), - ]); - const confirmed = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile)))[active]; + const confirmed = (await this.writeStages(profile, stages))[active]; if (!confirmed || confirmed.x !== dpi || confirmed.y !== dpiY) { throw new Error(`The mouse kept ${confirmed ? confirmed.x.toLocaleString() : "an unknown"} DPI instead of ${dpi.toLocaleString()}.`); } - this.patch({ dpi: confirmed.x, dpiY: confirmed.y }); return confirmed.x; } + async setDpiStageValue(stage: number, dpi: number): Promise { + this.assertDpi(dpi); + const profile = await this.currentProfile(); + const stages = await this.readStages(profile); + const entry = stages[stage]; + if (!entry) throw new Error(`This mouse does not have a DPI stage ${stage + 1}.`); + // A Y that already differs is the mouse's separate-axis setting, and the + // shared stage editor only shows one value per stage: changing X must not + // silently flatten it. + stages[stage] = { x: dpi, y: entry.y === entry.x ? dpi : entry.y }; + const confirmed = (await this.writeStages(profile, stages))[stage]; + if (!confirmed || confirmed.x !== dpi) { + throw new Error(`The mouse kept ${confirmed ? confirmed.x.toLocaleString() : "an unknown"} DPI on stage ${stage + 1} instead of ${dpi.toLocaleString()}.`); + } + return confirmed.x; + } + + async setDpiStageCount(count: number): Promise { + if (!Number.isInteger(count) || count < 1 || count > DPI_STAGE_MAX) { + throw new Error(`This mouse holds between 1 and ${DPI_STAGE_MAX} DPI stages.`); + } + const profile = await this.currentProfile(); + const stages = await this.readStages(profile); + const last = stages[stages.length - 1]; + if (!last) throw new Error("The mouse did not report any DPI stages."); + const next = stages.slice(0, count); + while (next.length < count) next.push({ ...last }); + const confirmed = await this.writeStages(profile, next); + if (confirmed.length !== count) { + throw new Error(`The mouse kept ${confirmed.length} DPI stages instead of ${count}.`); + } + return count; + } + + async setActiveDpiStage(stage: number): Promise { + const profile = await this.currentProfile(); + const stages = await this.readStages(profile); + if (!stages[stage]) throw new Error(`This mouse does not have a DPI stage ${stage + 1}.`); + await this.write(PAGE.profile, WRITE.activeStage, profile, [stage + 1]); + const reply = await this.request(PROFILE_READ.activeStage(profile)); + const confirmed = this.stageIndex(reply[1], stages.length); + if (confirmed !== stage) { + throw new Error(`The mouse stayed on DPI stage ${confirmed + 1} instead of ${stage + 1}.`); + } + this.patchStages(stages, confirmed); + return confirmed; + } + + private assertDpi(value: number): void { + if (!Number.isInteger(value) || value < DPI_STEP || value > DPI_MAX || value % DPI_STEP !== 0) { + throw new Error(`${value.toLocaleString()} is not a supported DPI value.`); + } + } + + private async readStages(profile: number): Promise { + return this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile))); + } + + /** Writes the whole table back, since the mouse takes count and stages as one packet. */ + private async writeStages(profile: number, stages: readonly WLMouseDpiStage[]): Promise { + await this.write(PAGE.profile, WRITE.dpiStages, profile, [ + stages.length, + ...stages.flatMap((stage) => [stage.x >> 8 & 0xff, stage.x & 0xff, stage.y >> 8 & 0xff, stage.y & 0xff]), + ]); + const confirmed = await this.readStages(profile); + this.patchStages(confirmed); + return confirmed; + } + + private patchStages(stages: readonly WLMouseDpiStage[], activeStage?: number): void { + const active = Math.min(activeStage ?? this.lastStatus?.activeDpiStage ?? 0, stages.length - 1); + const entry = stages[active]; + this.patch({ + dpiStages: stages.map((stage) => stage.x), + activeDpiStage: Math.max(active, 0), + ...(entry ? { dpi: entry.x, dpiY: entry.y } : {}), + }); + } + private async currentProfile(): Promise { const reply = await this.request(READ.activeProfile); this.activeProfile = Math.max(1, reply[0]); @@ -463,6 +619,23 @@ export class WLMouseHidClient { return compaxDecodeLiftOff(value); } + /** Sensor angle in degrees, sent as a signed byte. */ + private decodeAngleTuning(value: number): number { + return value > 0x7f ? value - 0x100 : value; + } + + /** + * Names the mouse behind a receiver. The 1K dongle enumerates under a single + * product id whichever model it is paired with, so without this every mouse + * on it reports as "WLmouse 1K receiver". + */ + private async readPairedProduct(): Promise { + const reply = await this.once("pairedProduct", () => this.request(READ.pairedProduct).catch(() => null)); + if (!reply || reply.length < 6) return; + const productId = (reply[4]! << 8) | reply[5]!; + if (WLMOUSE_PRODUCTS.has(productId)) this.pairedProductId = productId; + } + private decodeSleepTimeout(payload: Uint8Array | null): number | null { return compaxDecodeSleep(payload, SLEEP_DISABLED_MIN); }