diff --git a/docs/lamzu-inca-testing.md b/docs/lamzu-inca-testing.md new file mode 100644 index 0000000..fce1c48 --- /dev/null +++ b/docs/lamzu-inca-testing.md @@ -0,0 +1,171 @@ +# Lamzu Inca 8K — capture notes + +Hardware report from a Lamzu Inca 8K on its 8K wireless receiver, Windows 11. +Serial numbers are redacted. All commands below are reads; no setting on the +device was changed while capturing. + +## Enumeration + +`LAMZU INCA 8K Receiver`, manufacturer `LAMZU`, release `0x0100`. + +**Vendor id `0x37b0` is new** — it is not the shared CompX ODM id `0x373e` +that the existing Lamzu, CRDRAKO, and Attack Shark products enumerate under. +Lamzu's own configurator confirms both are live: its `DeviceInitFilterVID` is +`373E;37B0`. + +Product ids for this model: + +| Product id | Role | Verified | +| --- | --- | --- | +| `0x0009` | Mouse on its cable | On hardware | +| `0x000f` | 1K receiver | Vendor table only | +| `0x0010` | 8K receiver | On hardware | +| `0x000a` | Mouse DFU bootloader — **not a mouse** | Vendor table only | +| `0x0002` | Receiver DFU bootloader — **not a mouse** | Vendor table only | + +The two bootloader ids are the identities the mouse and dongle take while +firmware is being flashed. They do not speak this protocol and are excluded +from the catalog on purpose. + +| Interface | Collection | Usage page | Usage | Feature report ids | Role | +| --- | --- | --- | --- | --- | --- | +| MI_00 | — | 0x0001 | 0x0002 | none | Mouse | +| MI_01 | Col01 | 0x000c | 0x0001 | none | Consumer control | +| MI_01 | Col02 | 0x0001 | 0x0080 | none | System control | +| MI_01 | Col03 | 0x0001 | 0x0006 | none | Keyboard | +| MI_01 | Col04 | 0xffa0 | 0x0001 | none | Vendor — rejects `HidD_SetFeature` | +| MI_01 | Col05 | 0xffff | 0x0001 | none | Vendor — rejects `HidD_SetFeature` | +| **MI_02** | — | **0xffff** | **0x0000** | **0** | **Config channel** | + +The config channel is MI_02. It accepts exactly one feature report buffer +size, 65 bytes: report id 0 plus the 64-byte CompX packet. The two vendor +collections on MI_01 accept no feature report of any length tried +(65/64/33/17/9/5). + +The "feature report ids" column is what `navigator.hid` reports, read back +from both connections with a WebHID enumeration in Chrome on the same Windows +box. It matters because the driver's `isSupported()` decides on exactly that +field, not on whether a collection answers `HidD_SetFeature`: **only +0xffff/0x0000 declares report 0**, so MI_01's 0xffff/0x0001 collection — the +one a usage-page filter cannot exclude — is rejected by the driver rather +than becoming a dead picker entry. + +Chrome on Windows delivers all seven collections on a **single `HIDDevice`**, +for the receiver and for the cable alike, so each connection is one entry in +the browser picker no matter how the filter is written. The usage-page +narrowing therefore earns its keep only on a platform that exposes a device's +interfaces separately. + +## Protocol + +The Inca answers the **existing CompX framing unchanged** — the same +`compaxEncodeRequest` header (`status, _, target, length, page, command`), +report id 0, 64-byte packet, and `0xa1` OK status the Maya X and KO-ONE use. +Mouse target `0x02` and dongle target `0x00` both answer, so no `mouseTarget` +override is needed. + +## Raw read responses + +Profile 1, payload bytes after the 6-byte header. + +| Read | Target | Page | Cmd | Payload | +| --- | --- | --- | --- | --- | +| Dongle firmware | 0x00 | 0x00 | 0x81 | `00 00 00 12 00 37 b0 00 10 00 00 00 00 00 00 00` | +| Mouse firmware | 0x02 | 0x00 | 0x81 | `00 00 00 12 00 09` | +| Battery | 0x02 | 0x00 | 0x83 | `00 64` | +| Active profile | 0x02 | 0x00 | 0x85 | `01` | +| Sleep timeout | 0x02 | 0x00 | 0x87 | `01 00 3c` | +| Debounce | 0x02 | 0x00 | 0x88 | `01 00` | +| Polling rate | 0x02 | 0x01 | 0x80 | `01 40` | +| DPI stages | 0x02 | 0x01 | 0x81 | `01 05 01 90 01 90 07 d0 07 d0 06 40 06 40 0c 80 0c 80 19 00 19 00` | +| Active stage | 0x02 | 0x01 | 0x82 | `01 02` | +| Angle snapping | 0x02 | 0x01 | 0x84 | `01 00` | +| Lift-off distance | 0x02 | 0x01 | 0x88 | `01 01` | +| Motion sync | 0x02 | 0x01 | 0x89 | `01 00` | +| Ripple control | 0x02 | 0x01 | 0x8a | `01 00` | +| Hyper mode | 0x02 | 0x01 | 0x8b | `01 00` | +| Separate axes | 0x02 | 0x01 | 0x8d | `01 00` | +| Competitive mode | 0x02 | 0x01 | 0x93 | `01 00` | + +The dongle firmware payload echoes the vendor and product id at bytes 5-8 +(`37 b0 00 10`), which is a useful sanity check that the reply belongs to this +receiver. + +## Decoding + +Every existing CompX decoder produces a sensible value with no change: + +- `compaxDecodeFirmware` → dongle `0.18`, mouse `0.18`. +- Battery → `0x64` = 100%. +- `compaxDecodePollingRate` with `LAMZU_POLLING_RATES` → `0x40` = 4000 Hz. +- `compaxDecodeDpiStages` → 5 stages, **read directly as big-endian DPI with + no ×50 scaling**: 400, 2000, 1600, 3200, 6400. The ×50 and (raw+1)×50 + readings give absurd values (20000+, 320000), so the direct reading is the + correct one. +- `compaxDecodeLiftOff` → `0x01` = 1 mm = "Medium". +- `compaxDecodeSleep` → `0x003c` = 60 s. +- Debounce → 0 ms. + +Active stage reports `2`. "Confirmed by the owner" below settles this as +1-based — the second stage, 2000 DPI. + +## Wired capture + +Same mouse on its cable, product id `0x0009`, same MI_02 config channel. +The WebHID enumeration shows the cable carrying the identical seven +collections as the receiver, with report 0 again declared only on +0xffff/0x0000. Both target `0x00` and target `0x02` answer identically, so no +`mouseTarget` override is needed for either connection. + +| Read | Payload | Decoded | +| --- | --- | --- | +| Firmware | `00 00 00 12 00 09` | 0.18 | +| Battery | `01 64` | 100%, charging | +| Polling rate | `01 01` | `0x01` = 1000 Hz | +| DPI stages | identical to the wireless capture | 400, 2000, 1600, 3200, 6400 | + +The firmware payload's byte 5 is `0x09` — the mouse's own product id, the same +way the dongle's reply carries `37 b0 00 10`. + +**This settles the rate families.** `LAMZU_POLLING_RATES` encodes 1000 Hz +twice: `0x01` in the 125/250/500/1000 family and `0x10` in the +1000/2000/4000/8000 family. The cable answered `0x01` and the 8K receiver +answered `0x40`, so the two connections genuinely run different families — +`RATES_1K` wired, `RATES_8K` wireless. + +## Cross-check against Lamzu's own configurator + +The Aurora desktop app carries a device table with an `INCA` record. Nothing +was copied from it into the driver; it was read only to confirm values and to +learn the product ids that are not on hand. Every overlapping value agrees +with what the hardware reported: + +| Aurora field | Value | Agrees with | +| --- | --- | --- | +| `PIDWired` | `0009` | The wired capture | +| `PIDWireless4K8K` | `0010` | The receiver capture | +| `DPIMax` | `30000` | The driver's existing default | +| `PollingRateWired` | `125;250;500;1000` | `RATES_1K` | +| `_8KDonglePollingRate` | `500;1000;2000;4000;8000` | `RATES_8K` | +| `LOD` | `0.7;1;2` | The driver's `0x87`/`0x01`/`0x02` → Low/Medium/High table | +| `SleepTimeGrade` | `10s;30s;1min;5min;10min;30min` | The driver's `SLEEP_SECONDS` | +| `DPIMaxStageNum` | `5` | The five stages read back | + +The lift-off and sleep rows are worth noting: the existing decoders were +written for other CompX mice, and the Inca's vendor-declared options land on +them exactly, with no new cases. + +## Confirmed by the owner + +- Active stage reported `2` resolves to the **second** stage, 2000 DPI: the + owner had set that stage to 2000 by hand (its default is 800, and 2000 is + not a preset option). That confirms the reported index is 1-based and that + DPI is read straight off the wire as big-endian, unscaled. +- The mouse was on 4000 Hz on the receiver and 1000 Hz on the cable. + +## Still to confirm + +- The 1K receiver (`0x000f`) has not been seen; its rate list comes from the + vendor table alone. +- Writes are entirely uncaptured. Nothing here was verified by changing a + setting, so every setter this driver inherits is still unproven on an Inca. diff --git a/src/drivers/lamzu/hid.test.ts b/src/drivers/lamzu/hid.test.ts index e4a320e..abd2477 100644 --- a/src/drivers/lamzu/hid.test.ts +++ b/src/drivers/lamzu/hid.test.ts @@ -5,7 +5,10 @@ import { LamzuHidClient } from "./hid.ts"; import { deviceBrand } from "../registry.ts"; import { ATTACKSHARK_PRODUCT_IDS, + LAMZU_INCA_PRODUCTS, + LAMZU_INCA_VENDOR_ID, LAMZU_VENDOR_ID, + lamzuProduct, } from "@openmouse/protocol/lamzu"; const globals = globalThis as { window?: { setTimeout: typeof setTimeout } }; @@ -213,3 +216,200 @@ test("the Attack Shark R5 Ultra addresses the reported active profile", async () test("the catalog offers the wired and wireless R5 Ultra", () => { assert.deepEqual([...ATTACKSHARK_PRODUCT_IDS], [0x0046, 0x0047]); }); + +/** + * Byte-for-byte replay of a real Lamzu Inca 8K on its receiver, captured with + * hidapi on Windows 11 (docs/lamzu-inca-testing.md). Keyed by target:page: + * command, because page 0x00 command 0x88 is debounce while page 0x01 command + * 0x88 is lift-off, and firmware differs only by target. + */ +const INCA_CAPTURE: Record = { + "0:0:129": [0x00, 0x00, 0x00, 0x12, 0x00, 0x37, 0xb0, 0x00, 0x10, 0, 0, 0, 0, 0, 0, 0], + "2:0:129": [0x00, 0x00, 0x00, 0x12, 0x00, 0x09], + "2:0:131": [0x00, 0x64], + "2:0:133": [0x01], + "2:0:135": [0x01, 0x00, 0x3c], + "2:0:136": [0x01, 0x00], + "2:1:128": [0x01, 0x40], + "2:1:129": [ + 0x01, 0x05, 0x01, 0x90, 0x01, 0x90, 0x07, 0xd0, 0x07, 0xd0, 0x06, 0x40, + 0x06, 0x40, 0x0c, 0x80, 0x0c, 0x80, 0x19, 0x00, 0x19, 0x00, + ], + "2:1:130": [0x01, 0x02], + "2:1:132": [0x01, 0x00], + "2:1:136": [0x01, 0x01], + "2:1:137": [0x01, 0x00], + "2:1:138": [0x01, 0x00], + "2:1:139": [0x01, 0x00], + "2:1:141": [0x01, 0x00], + "2:1:147": [0x01, 0x00], +}; + +/** + * `productId` picks the receiver (0x0010) or the cable (0x0009); `overrides` + * carries the few replies that genuinely differed between the two captures. + */ +function fakeInca(productId = 0x0010, overrides: Record = {}) { + const capture = { ...INCA_CAPTURE, ...overrides }; + const sent: Uint8Array[] = []; + return { + vendorId: LAMZU_INCA_VENDOR_ID, + productId, + productName: "LAMZU INCA 8K Receiver", + 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 payload = capture[`${request[2]}:${request[4]}:${request[5]}`]; + const reply = new Uint8Array(64); + if (!payload) { + reply[0] = 0xa2; + return new DataView(reply.buffer); + } + reply[0] = 0xa1; + reply[3] = payload.length; + reply[4] = request[4]!; + reply[5] = request[5]!; + reply.set(payload, 6); + return new DataView(reply.buffer); + }, + } as unknown as HIDDevice; +} + +test("the Inca 8K is recognised under Lamzu's own vendor id", () => { + assert.equal(LamzuHidClient.isSupported(fakeInca()), true); +}); + +/** + * Every collection the Inca exposes, read back with a WebHID enumeration in + * Chrome on Windows — the receiver and the cable report the identical seven, + * and Chrome delivers them on a single HIDDevice + * (docs/lamzu-inca-testing.md). Only the MI_02 config collection declares + * feature report 0. + */ +const INCA_COLLECTIONS: ReadonlyArray = [ + [0x0001, 0x0002, []], + [0x000c, 0x0001, []], + [0x0001, 0x0080, []], + [0x0001, 0x0006, []], + [0xffa0, 0x0001, []], + [0xffff, 0x0001, []], + [0xffff, 0x0000, [0]], +]; + +function incaCollection(usagePage: number, usage: number, featureIds: readonly number[]): HIDCollectionInfo { + return { + usagePage, + usage, + type: 1, + children: [], + inputReports: [], + outputReports: [], + featureReports: featureIds.map((reportId) => ({ reportId, items: [{ reportSize: 8, reportCount: 64 }] })), + } as unknown as HIDCollectionInfo; +} + +function incaDevice(collections: HIDCollectionInfo[], productId = 0x0010): HIDDevice { + return { + vendorId: LAMZU_INCA_VENDOR_ID, + productId, + productName: "LAMZU INCA 8K Receiver", + collections, + } as unknown as HIDDevice; +} + +test("the Inca is recognised across its real seven-collection layout", () => { + const full = incaDevice(INCA_COLLECTIONS.map(([page, usage, ids]) => incaCollection(page, usage, ids))); + assert.equal(LamzuHidClient.isSupported(full), true); +}); + +test("MI_01's vendor collection is never mistaken for the config channel", () => { + // 0xffff/0x0001 passes the WebHID usage-page filter, so it is the one entry + // the filter cannot exclude. The enumeration shows it declaring no feature + // reports at all, so isSupported() drops it; otherwise the picker would gain + // an entry that cannot answer a single command. + assert.equal(LamzuHidClient.isSupported(incaDevice([incaCollection(0xffff, 0x0001, [])])), false); + // The mouse collection on MI_00 is likewise not a control channel. + assert.equal(LamzuHidClient.isSupported(incaDevice([incaCollection(0x0001, 0x0002, [])])), false); +}); + +test("a device on the shared CompX vendor id is not read as an Inca", () => { + const stranger = { ...fakeInca(), vendorId: LAMZU_VENDOR_ID } as unknown as HIDDevice; + // 0x0010 under 0x373e is not a catalogued CompX product. + assert.equal(LamzuHidClient.isSupported(stranger), false); +}); + +test("lamzuProduct resolves nothing for a vendor id this brand does not use", () => { + // The lookup must not fall through to the 0x373e catalog: 0x001c is a Maya X + // only under Lamzu's ODM id, and 0x0046 an R5 Ultra only under the same. + assert.equal(lamzuProduct(0x046d, 0x001c), undefined); + assert.equal(lamzuProduct(0x0000, 0x0046), undefined); + // The two ids this brand does use still resolve. + assert.equal(lamzuProduct(LAMZU_VENDOR_ID, 0x001c)?.model, "Maya X"); + assert.equal(lamzuProduct(LAMZU_INCA_VENDOR_ID, 0x0010)?.model, "Inca 8K"); +}); + +test("each Inca connection gets the rate list that connection actually offers", () => { + // 1000 Hz is encoded 0x01 in the wired family and 0x10 in the wireless one; + // the hardware answered 0x01 on the cable and 0x40 on the 8K receiver. + assert.deepEqual(LAMZU_INCA_PRODUCTS.get(0x0009)?.pollingRates, [125, 250, 500, 1000]); + assert.deepEqual(LAMZU_INCA_PRODUCTS.get(0x000f)?.pollingRates, [125, 250, 500, 1000]); + assert.deepEqual(LAMZU_INCA_PRODUCTS.get(0x0010)?.pollingRates, [500, 1000, 2000, 4000, 8000]); + assert.equal(LAMZU_INCA_PRODUCTS.get(0x0009)?.wireless, false); + assert.equal(LAMZU_INCA_PRODUCTS.get(0x000f)?.wireless, true); + assert.equal(LAMZU_INCA_PRODUCTS.get(0x0010)?.wireless, true); +}); + +test("the Inca's DFU bootloader identities are not offered as mice", () => { + // 0x000a is the mouse's flashing identity and 0x0002 the dongle's; neither + // speaks the config protocol, so a picker entry for them would be dead. + for (const bootloader of [0x000a, 0x0002]) { + assert.equal(LAMZU_INCA_PRODUCTS.has(bootloader), false); + assert.equal(LamzuHidClient.isSupported(fakeInca(bootloader)), false); + } +}); + +test("the wired Inca is recognised and reads as a wired 1000 Hz mouse", async () => { + // On the cable the mouse reported polling 0x01 and a charging battery. + const wired = fakeInca(0x0009, { "2:1:128": [0x01, 0x01], "2:0:131": [0x01, 0x64] }); + assert.equal(LamzuHidClient.isSupported(wired), true); + const status = await new LamzuHidClient(wired).readStatus(); + assert.equal(status.name, "Lamzu Inca 8K"); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.pollingRateHz, 1000); + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000]); + assert.equal(status.batteryState, "Charging"); + // A wired device skips the dongle read, so only the mouse firmware is listed. + assert.deepEqual(status.firmware, ["Mouse 0.18"]); +}); + +test("the Inca 8K capture decodes into the settings the mouse was holding", async () => { + const client = new LamzuHidClient(fakeInca()); + const status = await client.readStatus(); + + assert.equal(status.brand, "Lamzu"); + assert.equal(status.name, "Lamzu Inca 8K"); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.batteryPercent, 100); + assert.equal(status.batteryState, "Discharging"); + // Stage 2 of five, reported 1-based, so the second entry: 0x07d0 = 2000. + assert.equal(status.dpi, 2000); + assert.equal(status.dpiY, 2000); + assert.equal(status.pollingRateHz, 4000); + assert.equal(status.liftOffDistance, "Medium"); + assert.equal(status.debounceMs, 0); + assert.equal(status.sleepTimeout, 60); + assert.equal(status.activeProfile, 1); + assert.deepEqual(status.firmware, ["Mouse 0.18", "Dongle 0.18"]); +}); diff --git a/src/drivers/lamzu/hid.ts b/src/drivers/lamzu/hid.ts index 589c80f..909fb51 100644 --- a/src/drivers/lamzu/hid.ts +++ b/src/drivers/lamzu/hid.ts @@ -11,8 +11,8 @@ import { compaxDecodeSleep, compaxEncodeRequest, LAMZU_POLLING_RATES as POLLING_RATES, - LAMZU_PRODUCTS as PRODUCTS, - LAMZU_VENDOR_ID, + LAMZU_VENDOR_IDS, + lamzuProduct, type CompaxDpiStage, type LamzuProduct, } from "@openmouse/protocol/lamzu"; @@ -128,13 +128,13 @@ export class LamzuHidClient { const search = (collection: HIDCollectionInfo): boolean => collection.featureReports.some((report) => report.reportId === REPORT_ID) || collection.children.some(search); - return device.vendorId === LAMZU_VENDOR_ID - && PRODUCTS.has(device.productId) + return LAMZU_VENDOR_IDS.includes(device.vendorId) + && lamzuProduct(device.vendorId, device.productId) !== undefined && device.collections.some(search); } private profile(): LamzuProduct | undefined { - return PRODUCTS.get(this.device.productId); + return lamzuProduct(this.device.vendorId, this.device.productId); } async open(): Promise { diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 8eb3199..cf599fc 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -1,5 +1,6 @@ import { EGG_WE_HID_FILTERS } from "./endgame/egg-we-control.ts"; import { GWOLVES_PRODUCTS } from "./gwolves/products.ts"; +import { LAMZU_INCA_PRODUCTS, LAMZU_INCA_VENDOR_ID } from "@openmouse/protocol/lamzu"; import { LOGITECH_BOLT_PRODUCT_IDS, LOGITECH_DIRECT_PRODUCT_IDS, @@ -49,6 +50,7 @@ export const VENDOR_ID = { endgameGear: 0x3367, wlmouse: 0x36a7, lamzu: 0x373e, + lamzuInca: LAMZU_INCA_VENDOR_ID, attackshark: 0x373e, logitech: 0x046d, orbital: 0x1915, @@ -377,6 +379,27 @@ export const WALLHACK_HID_FILTERS: HIDDeviceFilter[] = [ [WALLHACK_VENDOR_ID, WALLHACK_KEYBOARD_ALT_VENDOR_ID].map((vendorId) => ({ vendorId, productId, usagePage: WALLHACK_KEYBOARD_USAGE_PAGE, usage: WALLHACK_KEYBOARD_USAGE }))), ]; +/** + * Lamzu's own vendor id carries the Inca 8K. Unlike the broad 0x373e filter + * this one is narrowed to usage page 0xffff, which keeps the mouse, consumer, + * system and keyboard collections on MI_00/MI_01 out of the picker on any + * platform that exposes a device's interfaces as separate HIDDevices. It + * cannot narrow further: the config channel is MI_02, whose usage is 0x0000, + * and a WebHID filter cannot pin a zero usage. + * + * That leaves MI_01's 0xffff/0x01 vendor collection as the only entry this + * filter still admits, and the driver's feature-report-0 check rejects it — a + * WebHID enumeration of both connections shows that collection declaring no + * feature reports at all, while only 0xffff/0x0000 declares report 0. See + * docs/lamzu-inca-testing.md. + * + * On Chrome/Windows the narrowing is moot: all seven collections arrive on a + * single HIDDevice, so the device is one picker entry whatever the filter says. + */ +export const LAMZU_INCA_HID_FILTERS: HIDDeviceFilter[] = [...LAMZU_INCA_PRODUCTS.keys()].map( + (productId) => ({ vendorId: VENDOR_ID.lamzuInca, productId, usagePage: 0xffff }), +); + export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...ZAUNKOENIG_PRODUCT_IDS.map((productId) => ({ vendorId: ZAUNKOENIG_VENDOR_ID, @@ -396,6 +419,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ // Attack Shark. The broad filter surfaces all of them; each driver rejects // interfaces that lack the feature-report-0 control channel. { vendorId: VENDOR_ID.lamzu }, + ...LAMZU_INCA_HID_FILTERS, { vendorId: VENDOR_ID.orbital, usagePage: 0xff0a, usage: 1 }, ...TEEVOLUTION_PRODUCT_IDS.map((productId) => ({ vendorId: VENDOR_ID.teevolution, productId })), ...TEEVOLUTION_PRODUCT_IDS.map((productId) => ({ diff --git a/src/lamzu/index.ts b/src/lamzu/index.ts index b5f3f4b..988dedf 100644 --- a/src/lamzu/index.ts +++ b/src/lamzu/index.ts @@ -36,6 +36,60 @@ export const LAMZU_PRODUCTS: ReadonlyMap = new Map([ pollingRates: RATES_8K, maxDpi: 42000, uiFamily: "attack-shark", }], ]); +/** + * Lamzu's own vendor id, introduced with the Inca 8K. Every earlier Lamzu + * model enumerates under the shared CompX ODM id 0x373e above, which CRDRAKO + * and Attack Shark also use, so the two catalogs are kept apart: the same + * product id means different hardware depending on which vendor id it arrived + * under. The Inca answers the identical CompX framing, so only the lookup + * needed splitting, not the protocol. + */ +export const LAMZU_INCA_VENDOR_ID = 0x37b0; + +export const LAMZU_VENDOR_IDS: readonly number[] = [LAMZU_VENDOR_ID, LAMZU_INCA_VENDOR_ID]; + +/** + * Products under Lamzu's own vendor id. 0x0009 (the mouse on its cable) and + * 0x0010 (the 8K receiver) are confirmed on hardware — see + * docs/lamzu-inca-testing.md. Both answer the mouse on target 0x02, so neither + * needs a `mouseTarget` override. + * + * The rate split shows up in the capture itself: LAMZU_POLLING_RATES encodes + * 1000 Hz twice, 0x01 in the 125/250/500/1000 family and 0x10 in the + * 1000/2000/4000/8000 family, and the cable answered 0x01 where the 8K + * receiver answered 0x40. Lamzu's Aurora configurator agrees — its device + * table gives the Inca `PollingRateWired` 125-1000 and `_8KDonglePollingRate` + * 500-8000 — and its `DPIMax` of 30000 is already the driver default, so no + * `maxDpi` override is needed. + * + * 0x000f is the 1K receiver the Inca can also ship with. It is taken from that + * same Aurora table (`_1KDongle` 000F, `_1KDonglePollingRate` 125-1000) and + * has NOT been exercised on hardware; the protocol is the receiver protocol + * either way, so the risk is a wrong rate list rather than a dead device. + * + * 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. + */ +export const LAMZU_INCA_PRODUCTS: ReadonlyMap = new Map([ + [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 }], +]); + +/** + * Resolves a product against the catalog its vendor id belongs to. A vendor id + * this brand does not use resolves to nothing rather than falling through to + * the 0x373e catalog, so the same product id under a foreign vendor id is never + * mistaken for a Lamzu. + */ +export function lamzuProduct(vendorId: number, productId: number): LamzuProduct | undefined { + if (vendorId === LAMZU_INCA_VENDOR_ID) return LAMZU_INCA_PRODUCTS.get(productId); + if (vendorId === LAMZU_VENDOR_ID) return LAMZU_PRODUCTS.get(productId); + return undefined; +} + export const LAMZU_POLLING_RATES = [ [0x08, 125], [0x04, 250], [0x02, 500], [0x01, 1000], [0x10, 1000], [0x20, 2000], [0x40, 4000], [0x80, 8000],