Skip to content
Draft
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
171 changes: 171 additions & 0 deletions docs/lamzu-inca-testing.md
Original file line number Diff line number Diff line change
@@ -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.
200 changes: 200 additions & 0 deletions src/drivers/lamzu/hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
Expand Down Expand Up @@ -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<string, readonly number[]> = {
"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<string, readonly number[]> = {}) {
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<readonly [number, number, readonly number[]]> = [
[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"]);
});
Loading