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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ checklist.
| Endgame Gear wireless | `@openmouse/protocol/endgame-gear-we` |
| Finalmouse | `@openmouse/protocol/finalmouse` |
| Keychron | `@openmouse/protocol/keychron` |
| Lamzu | `@openmouse/protocol/lamzu` |
| Lamzu / CRDRAKO / Attack Shark | `@openmouse/protocol/lamzu` |
| Logitech | `@openmouse/protocol/logitech` |
| moddoMOUSE | `@openmouse/protocol/moddo` |
| Ninjutso | `@openmouse/protocol/ninjutso` |
Expand Down
8 changes: 6 additions & 2 deletions docs/logitech-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,10 @@ with seven 256-byte sectors, so none of the G402's profile offsets apply to it.
working after OpenMouse writes a DPI value.
7. Reload the page and confirm the DPI written in step 4 is still reported.

RGB lighting (`0x8070`, logo and wheel zones) is deliberately not implemented —
the write packet is unverified and the panel has no Logitech lighting controls.
RGB lighting (`0x8070`) is enumerated at runtime. Each advertised zone gets its
own control (the G502 family commonly reports logo and side/DPI zones), its
current effect is read when supported, and writes use the zone's advertised
effect index. Confirm each zone changes independently and reloads correctly.

Persistent polling-rate changes write the profile sector and are implemented for
format 2 (LOGAN); DPI-stage changes still are not (the v1 format has no stage
Expand All @@ -103,6 +105,8 @@ legacy `0x2201` DPI and `0x8060` report rate, profile format 2 (LOGAN).
rate — this is the fix for "cannot click the 1 kHz polling rate option".
2. Confirm the rate actually changed in the OS (e.g. a mouse-rate tester), not
just in the profile read-back, so the reload-on-write behaviour is understood.
3. Open a stored profile and change normal and G-Shift assignments. Confirm the
physical button follows each layer and that unrelated assignments survive.

## G309 LIGHTSPEED (receiver-attached, Model ID `B03C40B10000`)

Expand Down
2 changes: 1 addition & 1 deletion src/compx/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
export const COMPX_REPORT_ID = 0;
export const COMPX_PACKET_LENGTH = 64;
export const COMPX_HEADER_LENGTH = 6;
export const COMPX_STATUS = { request: 0x00, pending: 0xa0, ok: 0xa1, unsupported: 0xa2 } as const;
export const COMPX_STATUS = { request: 0x00, pending: 0xa0, ok: 0xa1, unsupported: 0xa2, busy: 0xa3 } as const;
export type CompaxLiftOffDistance = "Low" | "Medium" | "High";
export interface CompaxDpiStage { x: number; y: number }
export interface CompaxRequest {
Expand Down
99 changes: 98 additions & 1 deletion src/drivers/lamzu/hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import test from "node:test";

import { LamzuHidClient } from "./hid.ts";
import { deviceBrand } from "../registry.ts";
import { LAMZU_VENDOR_ID } from "@openmouse/protocol/lamzu";
import {
ATTACKSHARK_PRODUCT_IDS,
LAMZU_VENDOR_ID,
} from "@openmouse/protocol/lamzu";

const globals = globalThis as { window?: { setTimeout: typeof setTimeout } };
globals.window ??= { setTimeout };
Expand Down Expand Up @@ -84,3 +87,97 @@ test("CRDRAKO KO-ONE receiver addresses the mouse as target 0x02", async () => {
assert.ok(mouseRequests.length > 0);
assert.ok(mouseRequests.every((packet) => packet[2] === 0x02));
});

function fakeR5Ultra(productId: 0x0046 | 0x0047, busyBatteryReplies = 0) {
const sent: Uint8Array[] = [];
let batterySends = 0;
const device = {
vendorId: LAMZU_VENDOR_ID,
productId,
productName: "R5 Ultra Mouse 2.4G",
opened: true,
collections: [{
usagePage: 0xffff,
usage: 0,
type: 1,
children: [],
featureReports: [{ reportId: 0, items: [{ reportSize: 8, reportCount: 64 }] }],
inputReports: [],
outputReports: [],
}],
open: async () => {},
close: async () => {},
sendFeatureReport: async (_id: number, data: Uint8Array) => void sent.push(new Uint8Array(data)),
receiveFeatureReport: async () => {
const request = sent[sent.length - 1]!;
const page = request[4]!;
const command = request[5]!;
const reply = new Uint8Array(64);
if (page === 0x00 && command === 0x83) {
batterySends += 1;
if (batterySends <= busyBatteryReplies) {
reply[0] = 0xa3;
reply[4] = page;
reply[5] = command;
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];
reply[0] = 0xa1;
reply[3] = payload.length;
reply[4] = page;
reply[5] = command;
reply.set(payload, 6);
return new DataView(reply.buffer);
},
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as HIDDevice;
return { device, sent, batterySends: () => batterySends };
}

test("the Attack Shark R5 Ultra wireless decodes through the shared driver", async () => {
const { device } = fakeR5Ultra(0x0047);
const client = new LamzuHidClient(device);
const status = await client.readStatus();

assert.equal(status.brand, "Attack Shark");
assert.equal(status.name, "Attack Shark R5 Ultra");
assert.equal(status.ui?.family, "attack-shark");
assert.equal(deviceBrand(client), "Attack Shark");
assert.equal(status.connectionType, "Wireless");
assert.equal(status.connectionDetail, "2.4 GHz receiver");
assert.equal(status.batteryPercent, 100);
assert.equal(status.batteryState, "Discharging");
assert.equal(status.dpi, 1600);
assert.equal(status.pollingRateHz, 8000);
assert.deepEqual(status.supportedPollingRates, [500, 1000, 2000, 4000, 8000]);
assert.deepEqual(status.firmware, ["Mouse 1.2", "Dongle 1.2"]);
});

test("the Attack Shark R5 Ultra wired decodes as a 1 kHz wired mouse", async () => {
const { device } = fakeR5Ultra(0x0046);
const status = await new LamzuHidClient(device).readStatus();
assert.equal(status.brand, "Attack Shark");
assert.equal(status.connectionType, "Wired");
assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000]);
});

test("a busy status keeps retrying instead of failing", async () => {
const { device, batterySends } = fakeR5Ultra(0x0047, 2);
const status = await new LamzuHidClient(device).readStatus();
assert.equal(status.batteryPercent, 100);
assert.ok(batterySends() > 2, `expected the battery request to be re-sent, saw ${batterySends()}`);
});

test("the catalog offers the wired and wireless R5 Ultra", () => {
assert.deepEqual([...ATTACKSHARK_PRODUCT_IDS], [0x0046, 0x0047]);
});
2 changes: 1 addition & 1 deletion src/drivers/lamzu/hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ export class LamzuHidClient {
const length = Math.min(reply[3], PACKET_LENGTH - HEADER_LENGTH);
return reply.slice(HEADER_LENGTH, HEADER_LENGTH + length);
}
if (reply[0] !== STATUS.pending && reply[0] !== STATUS.ok) {
if (reply[0] !== STATUS.pending && reply[0] !== STATUS.busy && reply[0] !== STATUS.ok) {
throw new Error(this.describe(spec, `returned an unexpected status 0x${reply[0].toString(16)}`));
}
await this.delay(attempt < QUICK_ATTEMPTS ? RESPONSE_DELAY_MS : WAKE_DELAY_MS);
Expand Down
Loading