Skip to content
Closed
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
54 changes: 52 additions & 2 deletions src/drivers/wlmouse/hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,15 +33,25 @@ 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
? [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;
Expand Down Expand Up @@ -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/);
});
112 changes: 99 additions & 13 deletions src/drivers/wlmouse/hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -378,28 +393,99 @@ export class WLMouseHidClient {
}

async setDpi(dpi: number, dpiY: number = dpi): Promise<number> {
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<number> {
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<number> {
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<number> {
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<WLMouseDpiStage[]> {
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<WLMouseDpiStage[]> {
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<number> {
const reply = await this.request(READ.activeProfile);
this.activeProfile = Math.max(1, reply[0]);
Expand Down