From 873597a8ef110732a291e30a0aea4f38d7827fc7 Mon Sep 17 00:00:00 2001 From: ydw1904 Date: Sat, 5 Sep 2026 10:42:30 +0800 Subject: [PATCH] feat(wlmouse): expose the DPI stage table to the shared stage editor The mouse has always sent its whole stage table in one packet and the driver has always decoded it, but only the active stage reached the app, so the panel could edit one DPI value where the vendor software edits six. The control app already ships a generic stage editor for exactly this shape (Keychron and Teevolution use it), so this reports `dpiStages`, `activeDpiStage`, and the `dpiStageEditor` hint, and adds the three setters that editor calls. `setDpiStageCount` and `setDpiStageValue` reuse the write `setDpi` was already making: the count and every stage travel together in one packet, so a value edit is a full table write either way. Editing a stage keeps a Y that already differs from X, because the shared editor shows a single value per stage and must not quietly undo a separate-axis setup. `setActiveDpiStage` writes a command that is not in any capture: it is the `0x82` read with the high bit cleared, which is how every other command in this driver pairs up. That guess is verified by reading the active stage back, and an unknown command comes back `unsupported`, so a wrong byte surfaces as an error rather than a silent no-op. It wants a run on real hardware before anyone trusts it. Co-Authored-By: Claude Opus 5 --- src/drivers/wlmouse/hid.test.ts | 54 ++++++++++++++- src/drivers/wlmouse/hid.ts | 112 ++++++++++++++++++++++++++++---- 2 files changed, 151 insertions(+), 15 deletions(-) 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]);