Skip to content
Merged
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
52 changes: 42 additions & 10 deletions src/drivers/lamzu/hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,11 @@ test("CRDRAKO KO-ONE receiver addresses the mouse as target 0x02", async () => {
assert.ok(mouseRequests.every((packet) => packet[2] === 0x02));
});

function fakeR5Ultra(productId: 0x0046 | 0x0047, busyBatteryReplies = 0) {
function fakeR5Ultra(productId: 0x0046 | 0x0047, busyBatteryReplies = 0, activeProfile = 1) {
const sent: Uint8Array[] = [];
let batterySends = 0;
let liftOff = 0x01;
let debounce = 0x00;
const device = {
vendorId: LAMZU_VENDOR_ID,
productId,
Expand All @@ -113,6 +115,8 @@ function fakeR5Ultra(productId: 0x0046 | 0x0047, busyBatteryReplies = 0) {
const page = request[4]!;
const command = request[5]!;
const reply = new Uint8Array(64);
if (page === 0x01 && command === 0x08) liftOff = request[7]!;
if (page === 0x00 && command === 0x08) debounce = request[7]!;
if (page === 0x00 && command === 0x83) {
batterySends += 1;
if (batterySends <= busyBatteryReplies) {
Expand All @@ -122,15 +126,21 @@ function fakeR5Ultra(productId: 0x0046 | 0x0047, busyBatteryReplies = 0) {
return new DataView(reply.buffer);
}
}
const payload = page === 0x01 && command === 0x81
? [0x01, 0x01, 0x06, 0x40, 0x06, 0x40]
: page === 0x01 && command === 0x80
? [0x01, 0x80]
: page === 0x00 && command === 0x83
? [0x00, 0x64]
: page === 0x00 && command === 0x81
? [0x00, 0x00, 0x01, 0x02]
: [0x01, 0x01];
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]
: page === 0x01 && command === 0x80
? [0x01, 0x80]
: page === 0x00 && command === 0x83
? [0x00, 0x64]
: page === 0x00 && command === 0x81
? [0x00, 0x00, 0x01, 0x02]
: [0x01, 0x01];
reply[0] = 0xa1;
reply[3] = payload.length;
reply[4] = page;
Expand Down Expand Up @@ -178,6 +188,28 @@ test("a busy status keeps retrying instead of failing", async () => {
assert.ok(batterySends() > 2, `expected the battery request to be re-sent, saw ${batterySends()}`);
});

test("the Attack Shark R5 Ultra addresses the reported active profile", async () => {
const { device, sent } = fakeR5Ultra(0x0047, 0, 2);
const client = new LamzuHidClient(device);

const status = await client.readStatus();
assert.equal(status.activeProfile, 2);

await client.setPollingRate(8000);
await client.setLiftOffDistance("Low");
await client.setPerformanceMode(true);
await client.setDpi(1600);
await client.setDebounceTime(4);

const isProfileScoped = (packet: Uint8Array): boolean =>
packet[4] === 0x01 || (packet[4] === 0x00 && (packet[5] === 0x87 || packet[5] === 0x88));
const scoped = sent.filter(isProfileScoped);
assert.ok(scoped.length > 0, "expected profile-scoped commands");
assert.ok(scoped.every((packet) => packet[6] === 0x02),
`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 catalog offers the wired and wireless R5 Ultra", () => {
assert.deepEqual([...ATTACKSHARK_PRODUCT_IDS], [0x0046, 0x0047]);
});
132 changes: 82 additions & 50 deletions src/drivers/lamzu/hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ const WAKE_DELAY_MS = 300;
const QUICK_ATTEMPTS = 3;
const SLEEP_DISABLED_MIN = 0xff00;
const SLEEP_MAX_SECONDS = 0xfeff;
const PROFILE = 0x01;
const DPI_STEP = 50;
const DPI_MAX = 30000;
const DEBOUNCE_MAX_MS = 15;
Expand Down Expand Up @@ -65,20 +64,35 @@ const READ = {
dongleFirmware: { target: TARGET.dongle, page: PAGE.device, command: 0x81, length: 0x10, args: [], attempts: 2 },
battery: { target: TARGET.mouse, page: PAGE.device, command: 0x83, length: 0x02, args: [] },
activeProfile: { target: TARGET.mouse, page: PAGE.device, command: 0x85, length: 0x01, args: [] },
sleepTimeout: { target: TARGET.mouse, page: PAGE.device, command: 0x87, length: 0x03, args: [PROFILE] },
debounce: { target: TARGET.mouse, page: PAGE.device, command: 0x88, length: 0x02, args: [PROFILE] },
dpiStages: { target: TARGET.mouse, page: PAGE.profile, command: 0x81, length: 0x0a, args: [PROFILE, 0x06] },
activeStage: { target: TARGET.mouse, page: PAGE.profile, command: 0x82, length: 0x02, args: [PROFILE] },
pollingRate: { target: TARGET.mouse, page: PAGE.profile, command: 0x80, length: 0x02, args: [PROFILE] },
liftOffDistance: { target: TARGET.mouse, page: PAGE.profile, command: 0x88, length: 0x02, args: [PROFILE] },
separateAxes: { target: TARGET.mouse, page: PAGE.profile, command: 0x8d, length: 0x02, args: [PROFILE] },
angleSnapping: { target: TARGET.mouse, page: PAGE.profile, command: 0x84, length: 0x02, args: [PROFILE] },
motionSync: { target: TARGET.mouse, page: PAGE.profile, command: 0x89, length: 0x02, args: [PROFILE] },
competitiveMode: { target: TARGET.mouse, page: PAGE.profile, command: 0x93, length: 0x02, args: [PROFILE] },
hyperMode: { target: TARGET.mouse, page: PAGE.profile, command: 0x8b, length: 0x02, args: [PROFILE] },
rippleControl: { target: TARGET.mouse, page: PAGE.profile, command: 0x8a, length: 0x02, args: [PROFILE] },
} as const satisfies Record<string, LamzuRequest>;

const PROFILE_READ = {
sleepTimeout: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.device, command: 0x87, length: 0x03, args: [profile] }),
debounce: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.device, command: 0x88, length: 0x02, args: [profile] }),
dpiStages: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.profile, command: 0x81, length: 0x0a, args: [profile, 0x06] }),
activeStage: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.profile, command: 0x82, length: 0x02, args: [profile] }),
pollingRate: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.profile, command: 0x80, length: 0x02, args: [profile] }),
liftOffDistance: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.profile, command: 0x88, length: 0x02, args: [profile] }),
separateAxes: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.profile, command: 0x8d, length: 0x02, args: [profile] }),
angleSnapping: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.profile, command: 0x84, length: 0x02, args: [profile] }),
motionSync: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.profile, command: 0x89, length: 0x02, args: [profile] }),
competitiveMode: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.profile, command: 0x93, length: 0x02, args: [profile] }),
hyperMode: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.profile, command: 0x8b, length: 0x02, args: [profile] }),
rippleControl: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.profile, command: 0x8a, length: 0x02, args: [profile] }),
} as const;

const WRITE = {
dpiStages: 0x01,
pollingRate: 0x00,
Expand All @@ -102,6 +116,7 @@ export class LamzuHidClient {
private lastStatus: MouseStatus | null = null;
private notifier: HIDDevice | null = null;
private notifyListener: ((event: HIDInputReportEvent) => void) | null = null;
private activeProfile = 1;

readonly device: HIDDevice;

Expand Down Expand Up @@ -221,19 +236,24 @@ export class LamzuHidClient {
const dongleFirmware = await this.once("dongleFirmware", () =>
wireless ? this.request(READ.dongleFirmware).catch(() => null) : Promise.resolve(null));
const battery = await this.request(READ.battery);
const sleepTimeout = await this.request(READ.sleepTimeout).catch(() => null);
const debounce = await this.request(READ.debounce).catch(() => null);
const stages = this.decodeDpiStages(await this.request(READ.dpiStages));
const activeStage = this.stageIndex((await this.request(READ.activeStage))[1], stages.length);
const pollingRate = await this.request(READ.pollingRate);
const liftOffDistance = await this.request(READ.liftOffDistance);
const separateAxes = await this.request(READ.separateAxes).catch(() => null);
const activeProfile = await this.request(READ.activeProfile).catch(() => null);
const angleSnapping = await this.request(READ.angleSnapping).catch(() => null);
const motionSync = await this.request(READ.motionSync).catch(() => null);
const competitiveMode = await this.request(READ.competitiveMode).catch(() => null);
const hyperMode = await this.request(READ.hyperMode).catch(() => null);
const rippleControl = await this.request(READ.rippleControl).catch(() => null);
// Profile-page commands must address the profile the mouse is actually
// running, not a fixed slot: writing to profile 1 is silently ignored when
// a different onboard profile is active.
const activeProfileReply = await this.request(READ.activeProfile).catch(() => null);
const profile = activeProfileReply ? Math.max(1, activeProfileReply[0]) : 1;
this.activeProfile = profile;
const sleepTimeout = await this.request(PROFILE_READ.sleepTimeout(profile)).catch(() => null);
const debounce = await this.request(PROFILE_READ.debounce(profile)).catch(() => null);
const stages = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile)));
const activeStage = this.stageIndex((await this.request(PROFILE_READ.activeStage(profile)))[1], stages.length);
const pollingRate = await this.request(PROFILE_READ.pollingRate(profile));
const liftOffDistance = await this.request(PROFILE_READ.liftOffDistance(profile));
const separateAxes = await this.request(PROFILE_READ.separateAxes(profile)).catch(() => null);
const angleSnapping = await this.request(PROFILE_READ.angleSnapping(profile)).catch(() => null);
const motionSync = await this.request(PROFILE_READ.motionSync(profile)).catch(() => null);
const competitiveMode = await this.request(PROFILE_READ.competitiveMode(profile)).catch(() => null);
const hyperMode = await this.request(PROFILE_READ.hyperMode(profile)).catch(() => null);
const rippleControl = await this.request(PROFILE_READ.rippleControl(profile)).catch(() => null);
const stage = stages[activeStage];
if (!stage) throw new Error("The mouse did not report any DPI stages.");
return this.lastStatus = {
Expand All @@ -251,7 +271,7 @@ export class LamzuHidClient {
supportsSeparateDpiAxes: separateAxes ? separateAxes[1] === 1 : false,
pollingRateHz: this.decodePollingRate(pollingRate[1]),
supportedPollingRates: this.getSupportedPollingRates(),
activeProfile: activeProfile ? activeProfile[0] : null,
activeProfile: activeProfileReply ? activeProfileReply[0] : null,
angleSnapping: angleSnapping ? angleSnapping[1] === 1 : null,
motionSync: motionSync ? motionSync[1] === 1 : null,
performanceMode: competitiveMode ? competitiveMode[1] === 1 : null,
Expand All @@ -270,7 +290,7 @@ export class LamzuHidClient {

private async readLiveStatus(previous: MouseStatus): Promise<MouseStatus> {
const battery = await this.request(READ.battery);
const pollingRate = await this.request(READ.pollingRate);
const pollingRate = await this.request(PROFILE_READ.pollingRate(this.activeProfile));
return this.lastStatus = {
...previous,
batteryPercent: battery[1] <= 100 ? battery[1] : null,
Expand All @@ -284,8 +304,9 @@ export class LamzuHidClient {
if (!encoded || !this.getSupportedPollingRates().includes(pollingRateHz)) {
throw new Error(`This mouse does not support ${pollingRateHz} Hz.`);
}
await this.write(PAGE.profile, WRITE.pollingRate, [encoded[0]]);
const confirmed = this.decodePollingRate((await this.request(READ.pollingRate))[1]);
const profile = await this.currentProfile();
await this.write(PAGE.profile, WRITE.pollingRate, profile, [encoded[0]]);
const confirmed = this.decodePollingRate((await this.request(PROFILE_READ.pollingRate(profile)))[1]);
if (confirmed !== pollingRateHz) {
throw new Error(`The mouse kept ${confirmed} Hz instead of ${pollingRateHz} Hz.`);
}
Expand All @@ -296,8 +317,9 @@ export class LamzuHidClient {
async setLiftOffDistance(value: LiftOffDistance): Promise<LiftOffDistance> {
const encoded = LIFT_OFF_DISTANCES.find(([, name]) => name === value);
if (!encoded) throw new Error(`This mouse does not support a ${value.toLowerCase()} lift-off distance.`);
await this.write(PAGE.profile, WRITE.liftOffDistance, [encoded[0]]);
const confirmed = this.decodeLiftOffDistance((await this.request(READ.liftOffDistance))[1]);
const profile = await this.currentProfile();
await this.write(PAGE.profile, WRITE.liftOffDistance, profile, [encoded[0]]);
const confirmed = this.decodeLiftOffDistance((await this.request(PROFILE_READ.liftOffDistance(profile)))[1]);
if (confirmed !== value) {
throw new Error(`The mouse kept a ${String(confirmed).toLowerCase()} lift-off distance instead of ${value.toLowerCase()}.`);
}
Expand All @@ -306,34 +328,35 @@ export class LamzuHidClient {
}

async setAngleSnapping(enabled: boolean): Promise<boolean> {
return await this.setFlag(WRITE.angleSnapping, READ.angleSnapping, enabled, "angleSnapping", "angle snapping");
return await this.setFlag(WRITE.angleSnapping, PROFILE_READ.angleSnapping, enabled, "angleSnapping", "angle snapping");
}

async setMotionSync(enabled: boolean): Promise<boolean> {
return await this.setFlag(WRITE.motionSync, READ.motionSync, enabled, "motionSync", "Motion Sync");
return await this.setFlag(WRITE.motionSync, PROFILE_READ.motionSync, enabled, "motionSync", "Motion Sync");
}

async setPerformanceMode(enabled: boolean): Promise<boolean> {
return await this.setFlag(WRITE.competitiveMode, READ.competitiveMode, enabled, "performanceMode", "competitive mode");
return await this.setFlag(WRITE.competitiveMode, PROFILE_READ.competitiveMode, enabled, "performanceMode", "competitive mode");
}

async setHyperMode(enabled: boolean): Promise<boolean> {
return await this.setFlag(WRITE.hyperMode, READ.hyperMode, enabled, "hyperMode", "Hyper mode");
return await this.setFlag(WRITE.hyperMode, PROFILE_READ.hyperMode, enabled, "hyperMode", "Hyper mode");
}

async setRippleControl(enabled: boolean): Promise<boolean> {
return await this.setFlag(WRITE.rippleControl, READ.rippleControl, enabled, "rippleControl", "ripple control");
return await this.setFlag(WRITE.rippleControl, PROFILE_READ.rippleControl, enabled, "rippleControl", "ripple control");
}

private async setFlag(
command: number,
read: LamzuRequest,
read: (profile: number) => LamzuRequest,
enabled: boolean,
field: "angleSnapping" | "motionSync" | "performanceMode" | "hyperMode" | "rippleControl",
label: string,
): Promise<boolean> {
await this.write(PAGE.profile, command, [enabled ? 1 : 0]);
const confirmed = (await this.request(read))[1] === 1;
const profile = await this.currentProfile();
await this.write(PAGE.profile, 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"}.`);
}
Expand All @@ -345,8 +368,9 @@ export class LamzuHidClient {
if (!Number.isInteger(milliseconds) || milliseconds < 0 || milliseconds > DEBOUNCE_MAX_MS) {
throw new Error(`Debounce must be a whole number of milliseconds between 0 and ${DEBOUNCE_MAX_MS}.`);
}
await this.write(PAGE.device, WRITE.debounce, [milliseconds]);
const confirmed = (await this.request(READ.debounce))[1];
const profile = await this.currentProfile();
await this.write(PAGE.device, WRITE.debounce, profile, [milliseconds]);
const confirmed = (await this.request(PROFILE_READ.debounce(profile)))[1];
if (confirmed !== milliseconds) {
throw new Error(`The mouse kept ${confirmed} ms of debounce instead of ${milliseconds} ms.`);
}
Expand All @@ -358,8 +382,9 @@ export class LamzuHidClient {
if (!Number.isInteger(seconds) || seconds < 1 || seconds > SLEEP_MAX_SECONDS) {
throw new Error(`The sleep timeout must be a whole number of seconds between 1 and ${SLEEP_MAX_SECONDS}.`);
}
await this.write(PAGE.device, WRITE.sleepTimeout, [seconds >> 8 & 0xff, seconds & 0xff]);
const reply = await this.request(READ.sleepTimeout);
const profile = await this.currentProfile();
await this.write(PAGE.device, WRITE.sleepTimeout, profile, [seconds >> 8 & 0xff, seconds & 0xff]);
const reply = await this.request(PROFILE_READ.sleepTimeout(profile));
const confirmed = (reply[1] << 8) | reply[2];
if (confirmed !== seconds) {
throw new Error(`The mouse kept a ${confirmed} second sleep timeout instead of ${seconds} seconds.`);
Expand All @@ -375,24 +400,31 @@ export class LamzuHidClient {
throw new Error(`${value.toLocaleString()} is not a supported DPI value.`);
}
}
const stages = this.decodeDpiStages(await this.request(READ.dpiStages));
const active = this.stageIndex((await this.request(READ.activeStage))[1], stages.length);
const profile = await this.currentProfile();
const stages = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(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, [
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(READ.dpiStages))[active];
const confirmed = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile)))[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;
}

private async write(page: number, command: number, values: readonly number[]): Promise<void> {
const args = [PROFILE, ...values];
private async currentProfile(): Promise<number> {
const reply = await this.request(READ.activeProfile);
this.activeProfile = Math.max(1, reply[0]);
return this.activeProfile;
}

private async write(page: number, command: number, profile: number, values: readonly number[]): Promise<void> {
const args = [profile, ...values];
await this.request({ target: TARGET.mouse, page, command, length: args.length, args });
}

Expand Down
Loading