From 0518f955f79a7e1cc17ac26b56fde7ebb241c8ff Mon Sep 17 00:00:00 2001 From: bpavlina <324717332+bpavlina@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:21:39 -0400 Subject: [PATCH] feat(lamzu): add Lamzu Inca 8K under Lamzu's own vendor id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Inca enumerates under vendor id 0x37b0, not the shared CompX ODM id 0x373e that every earlier Lamzu, CRDRAKO, and Attack Shark product uses, so it was invisible to the driver. It answers the existing CompX framing unchanged, so this is a catalog and discovery change rather than a new protocol: only the product lookup needed splitting per vendor id. Confirmed on hardware (Windows 11, hidapi) in both connection modes: - 0x0009, the mouse on its cable - 0x0010, the 8K receiver Both answer on MI_02 (usage page 0xffff, usage 0x0000), report id 0, a 65-byte feature buffer, and both address the mouse as target 0x02, so neither needs a mouseTarget override. Every existing CompX decoder produces a correct value with no new cases: firmware 0.18, battery 100%, five DPI stages read unscaled as big-endian, lift-off 0x01 -> Medium, sleep 60 s. The two connections run different polling-rate families, which the capture shows directly: LAMZU_POLLING_RATES encodes 1000 Hz twice, 0x01 in the 125-1000 family and 0x10 in the 1000-8000 family, and the cable answered 0x01 where the 8K receiver answered 0x40. Wired is therefore RATES_1K and the 8K receiver RATES_8K. 0x000f, the 1K receiver this model can also ship with, is taken from Lamzu's own configurator device table and has not been exercised on hardware. That table also names 0x000a and 0x0002 as the mouse and dongle DFU bootloader identities; they never speak this protocol and are excluded on purpose, with a test to keep them out. The WebHID filter is narrowed to usage page 0xffff, which keeps the mouse, consumer, system and keyboard collections out of the picker on a platform that exposes a device's interfaces separately. A WebHID enumeration of both connections settles the one collection that narrowing cannot exclude: MI_01's 0xffff/0x0001 declares no feature reports at all, while only 0xffff/0x0000 declares report 0, so the driver's feature-report-0 check rejects it rather than offering a dead entry. On Chrome/Windows the point is moot anyway — all seven collections arrive on a single HIDDevice, so each connection is one picker entry however the filter is written. Writes are entirely uncaptured. The inherited setters are unproven on an Inca, so this should be treated as read-only support. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018C96pMdnhGcXhLQhHYkLWc --- docs/lamzu-inca-testing.md | 171 +++++++++++++++++++++++++++++ src/drivers/lamzu/hid.test.ts | 200 ++++++++++++++++++++++++++++++++++ src/drivers/lamzu/hid.ts | 10 +- src/drivers/vendors.ts | 24 ++++ src/lamzu/index.ts | 54 +++++++++ 5 files changed, 454 insertions(+), 5 deletions(-) create mode 100644 docs/lamzu-inca-testing.md 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],