Skip to content
Open
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
67 changes: 67 additions & 0 deletions src/drivers/lamzu/hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,3 +525,70 @@ test("the Inca 8K capture decodes into the settings the mouse was holding", asyn
assert.equal(status.activeProfile, 1);
assert.deepEqual(status.firmware, ["Mouse 0.18", "Dongle 0.18"]);
});

test("the shared Aurora 8K and 1K (nano) receivers are catalogued with their rate ladders", () => {
assert.equal(lamzuProduct(LAMZU_INCA_VENDOR_ID, 0x0001)?.model, "Aurora 8K Receiver");
assert.equal(lamzuProduct(LAMZU_INCA_VENDOR_ID, 0x0003)?.model, "Aurora 1K Receiver");
assert.deepEqual(LAMZU_INCA_PRODUCTS.get(0x0001)?.pollingRates, [500, 1000, 2000, 4000, 8000]);
assert.deepEqual(LAMZU_INCA_PRODUCTS.get(0x0003)?.pollingRates, [125, 250, 500, 1000]);
assert.equal(LAMZU_INCA_PRODUCTS.get(0x0001)?.wireless, true);
assert.equal(LAMZU_INCA_PRODUCTS.get(0x0003)?.wireless, true);
});

test("the Paro Aurora is recognised on the Aurora 8K receiver and discovers the paired mouse", async () => {
const receiver = fakeInca(0x0001, {
"0:0:129": [0x00, 0x00, 0x00, 0x13, 0x00, 0x37, 0xb0, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0],
"1:0:139": [0x02, 0x01, 0x37, 0xb0, 0x00, 0x07], // paired with Paro Aurora (0x0007)
"2:0:129": [0x00, 0x00, 0x00, 0x13, 0x00, 0x07],
});
assert.equal(LamzuHidClient.isSupported(receiver), true);
const client = new LamzuHidClient(receiver);
assert.equal(client.displayName(), "Lamzu Aurora 8K Receiver");

const status = await client.readStatus();
assert.equal(status.brand, "Lamzu");
assert.equal(status.name, "Lamzu Paro Aurora");
assert.equal(status.connectionType, "Wireless");
assert.equal(status.connectionDetail, "2.4 GHz receiver");
assert.deepEqual(status.supportedPollingRates, [500, 1000, 2000, 4000, 8000]);
assert.deepEqual(status.firmware, ["Mouse 0.19", "Dongle 0.19"]);
assert.equal(client.displayName(), "Lamzu Paro Aurora");
});

test("the Paro Aurora is recognised on the Aurora 1K (nano) receiver", async () => {
const receiver = fakeInca(0x0003, {
"0:0:129": [0x00, 0x00, 0x00, 0x13, 0x00, 0x37, 0xb0, 0x00, 0x03, 0, 0, 0, 0, 0, 0, 0],
"1:0:139": [0x02, 0x01, 0x37, 0xb0, 0x00, 0x07], // paired with Paro Aurora (0x0007)
"2:0:129": [0x00, 0x00, 0x00, 0x13, 0x00, 0x07],
"2:1:128": [0x01, 0x01],
});
assert.equal(LamzuHidClient.isSupported(receiver), true);
const client = new LamzuHidClient(receiver);
assert.equal(client.displayName(), "Lamzu Aurora 1K Receiver");

const status = await client.readStatus();
assert.equal(status.brand, "Lamzu");
assert.equal(status.name, "Lamzu Paro Aurora");
assert.equal(status.connectionType, "Wireless");
assert.equal(status.connectionDetail, "2.4 GHz receiver");
assert.equal(status.pollingRateHz, 1000);
assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000]);
assert.equal(client.displayName(), "Lamzu Paro Aurora");
});

test("the Paro Aurora on dedicated 1K (nano) and 8K receivers read model and rates directly", async () => {
const nano = fakeInca(0x000d, { "2:1:128": [0x01, 0x01] });
assert.equal(LamzuHidClient.isSupported(nano), true);
const nanoStatus = await new LamzuHidClient(nano).readStatus();
assert.equal(nanoStatus.name, "Lamzu Paro Aurora");
assert.equal(nanoStatus.connectionType, "Wireless");
assert.deepEqual(nanoStatus.supportedPollingRates, [125, 250, 500, 1000]);

const eightK = fakeInca(0x000e);
assert.equal(LamzuHidClient.isSupported(eightK), true);
const eightKStatus = await new LamzuHidClient(eightK).readStatus();
assert.equal(eightKStatus.name, "Lamzu Paro Aurora");
assert.equal(eightKStatus.connectionType, "Wireless");
assert.deepEqual(eightKStatus.supportedPollingRates, [500, 1000, 2000, 4000, 8000]);
});

42 changes: 41 additions & 1 deletion src/drivers/lamzu/hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
compaxDecodeSleep,
compaxEncodeRequest,
LAMZU_POLLING_RATES as POLLING_RATES,
LAMZU_INCA_VENDOR_ID,
LAMZU_VENDOR_IDS,
lamzuProduct,
LUNAFURY_READ,
Expand Down Expand Up @@ -93,6 +94,7 @@ const NOTIFY_KINDS = new Set([0x03, 0x06, 0x08]);

const TARGET = {
dongle: 0x00,
pairing: 0x01,
mouse: 0x02,
} as const;

Expand Down Expand Up @@ -127,6 +129,7 @@ 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: [] },
pairedProduct: { target: TARGET.pairing, page: PAGE.device, command: 0x8b, length: 0x06, args: [0x02], attempts: 2 },
} as const satisfies Record<string, LamzuRequest>;

const PROFILE_READ = {
Expand Down Expand Up @@ -185,6 +188,8 @@ export class LamzuHidClient {
private notifyListener: ((event: HIDInputReportEvent) => void) | null = null;
private activeProfile = 1;
private onboardQueue: Promise<unknown> = Promise.resolve();
private pairedProductId: number | null = null;
private pairedVendorId: number | null = null;

readonly device: HIDDevice;

Expand All @@ -202,7 +207,26 @@ export class LamzuHidClient {
}

private profile(): LamzuProduct | undefined {
return lamzuProduct(this.device.vendorId, this.device.productId);
const direct = lamzuProduct(this.device.vendorId, this.device.productId);
if (!direct) return undefined;
if (this.pairedProductId !== null) {
const paired = lamzuProduct(this.pairedVendorId ?? this.device.vendorId, this.pairedProductId);
if (paired) {
return {
...paired,
model: paired.model,
wireless: direct.wireless,
pollingRates: direct.pollingRates,
dongleLed: direct.dongleLed ?? paired.dongleLed,
};
}
}
return direct;
}

private isSharedReceiver(): boolean {
return this.device.vendorId === LAMZU_INCA_VENDOR_ID
&& (this.device.productId === 0x0001 || this.device.productId === 0x0003);
}

async open(): Promise<void> {
Expand All @@ -212,6 +236,8 @@ export class LamzuHidClient {
async close(): Promise<void> {
this.staticReads.clear();
this.lastStatus = null;
this.pairedProductId = null;
this.pairedVendorId = null;
if (this.notifier && this.notifyListener) {
this.notifier.removeEventListener("inputreport", this.notifyListener);
if (this.notifier.opened) await this.notifier.close();
Expand Down Expand Up @@ -313,6 +339,9 @@ export class LamzuHidClient {
await this.open();
if (live && this.lastStatus) return await this.readLiveStatus(this.lastStatus);
const wireless = this.isWireless();
if (this.isSharedReceiver() && this.pairedProductId === null) {
await this.readPairedProduct().catch(() => undefined);
Comment thread
minhdaolesoez marked this conversation as resolved.
}
const firmware = await this.once("firmware", () => this.request(READ.firmware));
if (!firmware) throw new Error("The mouse did not report a firmware version.");
const dongleFirmware = await this.once("dongleFirmware", () =>
Expand Down Expand Up @@ -1106,6 +1135,17 @@ export class LamzuHidClient {
return compaxDecodeSleep(payload, SLEEP_DISABLED_MIN);
}

private async readPairedProduct(): Promise<void> {
const reply = await this.once("pairedProduct", () => this.request(READ.pairedProduct).catch(() => null));
if (!reply || reply.length < 6) return;
const vendorId = (reply[2]! << 8) | reply[3]!;
const productId = (reply[4]! << 8) | reply[5]!;
if (lamzuProduct(vendorId, productId)) {
this.pairedVendorId = vendorId;
this.pairedProductId = productId;
}
}

private readReceiverLight(attempts?: number): Promise<Uint8Array> {
return this.request({ ...lunafuryReadReceiverLight(this.device.productId), attempts,
matchesReply: (payload) => payload.length >= 3 && payload[0] === 1 });
Expand Down
15 changes: 10 additions & 5 deletions src/lamzu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,19 +158,24 @@ export const LAMZU_VENDOR_IDS: readonly number[] = [LAMZU_VENDOR_ID, LAMZU_INCA_
* The Paro Aurora (released mid-2025, PAW3950) is also entered here from that
* table, under the same vendor id and protocol flags as the Inca
* (`IsNewProtocol`: 1, `IsCompx`: 0, `DPIMax` 30000 — the driver default, so
* no `maxDpi` override). Its three ids are `PIDWired` 0x0007 (125-1000),
* `_1KDongle` 0x000d (125-1000) and `_8KDongle` 0x000e (500-8000). None of
* them has been exercised on hardware yet — issue #167 — so a wrong rate list
* is the failure mode, not a dead device.
* no `maxDpi` override). Its wired id is `PIDWired` 0x0007 (125-1000) and its
* dedicated dongle ids are `_1KDongle` 0x000d (125-1000) and `_8KDongle` 0x000e (500-8000).
*
* 0x0001 is the shared "LAMZU Aurora 8K Receiver" (`_8KDongleIDVD` in the Aurora
* device table), verified on hardware. The receiver answers the CompX pairing query
* (target 0x01 page 0x00 cmd 0x8b arg 0x02) to identify which mouse is paired to it.
* 0x0003 is the shared "LAMZU Aurora 1K Receiver" (`_1KDongleIDVD` in that table).
*
* Deliberately absent: 0x000a and 0x0002, which Aurora lists as
* `DeviceBLPID` and `Receiver4K8KBLPID` — the DFU bootloader identities the
* mouse and dongle take while flashing firmware. They never speak this
* protocol and must not be offered in the picker. The Paro's own bootloader
* ids, 0x0008 (`DeviceBLPID`) and 0x0004 (`_1KDongleIDVD`), are excluded for
* ids, 0x0008 (`DeviceBLPID`) and 0x0004 (`Receiver1KBLPID`), are excluded for
* the same reason; its 8K dongle shares 0x0002 with the Inca.
*/
export const LAMZU_INCA_PRODUCTS: ReadonlyMap<number, LamzuProduct> = new Map([
[0x0001, { model: "Aurora 8K Receiver", wireless: true, pollingRates: RATES_8K }],
[0x0003, { model: "Aurora 1K Receiver", wireless: true, pollingRates: RATES_1K }],
[0x0009, { model: "Inca 8K", wireless: false, pollingRates: RATES_1K }],
[0x000f, { model: "Inca 8K", wireless: true, pollingRates: RATES_1K }],
[0x0010, { model: "Inca 8K", wireless: true, pollingRates: RATES_8K }],
Expand Down
Loading