diff --git a/src/drivers/wlmouse/hid.test.ts b/src/drivers/wlmouse/hid.test.ts index bacf07b..07dd32a 100644 --- a/src/drivers/wlmouse/hid.test.ts +++ b/src/drivers/wlmouse/hid.test.ts @@ -11,6 +11,8 @@ 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; const device = { vendorId: VENDOR_ID.wlmouse, productId: 0xa863, @@ -31,6 +33,13 @@ function fakeDevice(offset: number, sleepingReplies = 0, activeProfile = 1) { const command = request[5]; if (page === 0x01 && command === 0x08) liftOff = request[7]!; if (page === 0x00 && command === 0x08) debounce = request[7]!; + 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 = page === 0x00 && command === 0x85 ? [activeProfile, 0x00] : page === 0x01 && command === 0x88 @@ -38,8 +47,11 @@ function fakeDevice(offset: number, sleepingReplies = 0, activeProfile = 1) { : 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 +98,41 @@ 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/); +}); diff --git a/src/drivers/wlmouse/hid.ts b/src/drivers/wlmouse/hid.ts index e12a6fb..c933bd2 100644 --- a/src/drivers/wlmouse/hid.ts +++ b/src/drivers/wlmouse/hid.ts @@ -70,7 +70,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 => @@ -89,6 +89,11 @@ const PROFILE_READ = { const WRITE = { dpiStages: 0x01, + // Not seen in a capture: every other command here pairs its 0x8X read with + // the same byte minus the high bit, and `activeStage` reads 0x82. The + // read-back in `setActiveDpiStage` turns a wrong guess into a plain error, + // and the mouse answers an unknown command with `unsupported`. + activeStage: 0x02, pollingRate: 0x00, liftOffDistance: 0x08, sleepTimeout: 0x07, @@ -98,6 +103,7 @@ const WRITE = { rippleControl: 0x0a, } as const; +const DPI_STAGE_MAX = 6; const DEBOUNCE_MAX_MS = 15; const SLEEP_SECONDS: readonly number[] = [30, 60, 120, 300, 600, 1800]; const NOTIFY_REPORT_ID = 4; @@ -256,11 +262,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(), @@ -378,28 +393,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]);