From 873597a8ef110732a291e30a0aea4f38d7827fc7 Mon Sep 17 00:00:00 2001 From: ydw1904 Date: Sat, 5 Sep 2026 10:42:30 +0800 Subject: [PATCH 1/2] 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]); From 89c8f9786d1e1c9acf73e474c7b5924cbbfea889 Mon Sep 17 00:00:00 2001 From: ydw1904 Date: Sat, 5 Sep 2026 11:13:36 +0800 Subject: [PATCH 2/2] feat(wlmouse): add high-speed, turbo, angle tune and button combinations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WLmouse's own configurator exposes four sensor settings this driver did not reach, and names the mouse behind a receiver that the driver could not name. All five commands are read out of the vendor's web tool, which uses the same page-command framing this driver already speaks, so nothing here is guessed: - high-speed mode page 0x01, read 0x8b / write 0x0b - turbo mode page 0x01, read 0x93 / write 0x13 - angle tune page 0x01, read 0x94 / write 0x14, signed byte - button combos page 0x03, read 0x81 / write 0x01 - paired product target 0x01, page 0x00, read 0x8b, arg 0x02 The four settings are optional per model: a mouse without one answers `unsupported` rather than failing the exchange, so the reads catch into null and the status reports "no such control" instead of breaking the connection. The paired-product read matters most for the shared 1K receiver, which enumerates under one product id whatever mouse it is paired with. Without it every mouse on that dongle reports as "WLmouse 1K receiver" — with it the driver names the model, and the app can pick artwork for it. The doubled "WLmouse WLmouse" prefix on the shared-receiver catalogue entries goes too. Turbo needs high-speed mode on before the firmware will hold it, which the read-back reports rather than silently dropping. Verified against a Beast Max on the 1K receiver. Co-Authored-By: Claude Opus 5 --- src/drivers/mouse-types.ts | 4 ++ src/drivers/wlmouse/hid.test.ts | 48 ++++++++++++++- src/drivers/wlmouse/hid.ts | 103 +++++++++++++++++++++++++++++--- 3 files changed, 146 insertions(+), 9 deletions(-) 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 07dd32a..79b7218 100644 --- a/src/drivers/wlmouse/hid.test.ts +++ b/src/drivers/wlmouse/hid.test.ts @@ -13,6 +13,8 @@ function fakeDevice(offset: number, sleepingReplies = 0, activeProfile = 1) { 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, @@ -29,8 +31,11 @@ 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]!; if (page === 0x01 && command === 0x02) activeStage = request[7]!; @@ -40,7 +45,13 @@ function fakeDevice(offset: number, sleepingReplies = 0, activeProfile = 1) { y: (request[10 + index * 4]! << 8) | request[11 + index * 4]!, })); } - const payload = page === 0x00 && command === 0x85 + 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] @@ -136,3 +147,38 @@ test("a rejected stage count is reported, not silently kept", async () => { 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 c933bd2..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 = { @@ -85,14 +93,18 @@ 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, - // 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, @@ -101,9 +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; @@ -121,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; @@ -190,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[] { @@ -253,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 = { @@ -283,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), @@ -346,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"}.`); @@ -549,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); }