diff --git a/src/drivers/lamzu/hid.test.ts b/src/drivers/lamzu/hid.test.ts index c4250066..bb76b73f 100644 --- a/src/drivers/lamzu/hid.test.ts +++ b/src/drivers/lamzu/hid.test.ts @@ -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]); +}); + diff --git a/src/drivers/lamzu/hid.ts b/src/drivers/lamzu/hid.ts index 415c65e7..d9d9ffe3 100644 --- a/src/drivers/lamzu/hid.ts +++ b/src/drivers/lamzu/hid.ts @@ -11,6 +11,7 @@ import { compaxDecodeSleep, compaxEncodeRequest, LAMZU_POLLING_RATES as POLLING_RATES, + LAMZU_INCA_VENDOR_ID, LAMZU_VENDOR_IDS, lamzuProduct, LUNAFURY_READ, @@ -93,6 +94,7 @@ const NOTIFY_KINDS = new Set([0x03, 0x06, 0x08]); const TARGET = { dongle: 0x00, + pairing: 0x01, mouse: 0x02, } as const; @@ -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; const PROFILE_READ = { @@ -185,6 +188,8 @@ export class LamzuHidClient { private notifyListener: ((event: HIDInputReportEvent) => void) | null = null; private activeProfile = 1; private onboardQueue: Promise = Promise.resolve(); + private pairedProductId: number | null = null; + private pairedVendorId: number | null = null; readonly device: HIDDevice; @@ -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 { @@ -212,6 +236,8 @@ export class LamzuHidClient { async close(): Promise { 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(); @@ -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); + } 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", () => @@ -1106,6 +1135,17 @@ export class LamzuHidClient { return compaxDecodeSleep(payload, SLEEP_DISABLED_MIN); } + private async readPairedProduct(): Promise { + 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 { return this.request({ ...lunafuryReadReceiverLight(this.device.productId), attempts, matchesReply: (payload) => payload.length >= 3 && payload[0] === 1 }); diff --git a/src/lamzu/index.ts b/src/lamzu/index.ts index 9821cc7a..8f0e0c5b 100644 --- a/src/lamzu/index.ts +++ b/src/lamzu/index.ts @@ -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 = 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 }],