diff --git a/docs/lamzu-atlantis-testing.md b/docs/lamzu-atlantis-testing.md new file mode 100644 index 0000000..cbebfaf --- /dev/null +++ b/docs/lamzu-atlantis-testing.md @@ -0,0 +1,204 @@ +# Lamzu Atlantis (0x3554) — capture notes + +Hardware report from a **Lamzu Atlantis Mini 4K**, firmware `1.24`, on its +cable, Windows 11. Serial numbers are redacted. Reads were captured first; the +setter pass at the end changed one value at a time and restored each. + +## Enumeration + +`LAMZU Atlantis Pro`, manufacturer `compx`, release `0x0124` (BCD — firmware +1.24, the version Lamzu's download page lists for "ATLANTIS MINI PRO/4K"). +The 4K receiver enumerates separately as `LAMZU 4K Receiver`, release `0x0128`, +matching that page's "4K DONGLE 1.28". + +Vendor id `0x3554` is CompX's shared ODM id — the same one the Pulsar 4K +receiver, VGN and Teevolution units already in this repository use, and the +one `ATK_COMPX_PRODUCT_IDS` covers for VXE. It is **not** either of the Lamzu +ids the other driver handles (`0x373e`, `0x37b0`). + +| Product id | Role | Verified | +| --- | --- | --- | +| `0xf50f` | Mouse on its cable | On hardware | +| `0xf50d` | 1K receiver | Vendor table only | +| `0xf510` | 4K receiver | Vendor table only | +| `0xf517` | Receiver | Vendor table only | + +Six models share these ids: Atlantis OG V2, Atlantis Mini, Atlantis Mini Pro, +Thorn, Maya and Paro of this generation. Nothing on the wire separates them — +same product id, same USB product string, same firmware version — and Lamzu's +own Windows configurator resolves this by asking the user to pick the model +from a list. The catalog therefore names the family, `Lamzu Atlantis`. + +| Interface | Collection | Usage page | Usage | Report 8 | Role | +| --- | --- | --- | --- | --- | --- | +| MI_00 | — | 0x0001 | 0x0006 | no | Keyboard | +| MI_01 | Col01 | 0xff05 | 0x0000 | no | Vendor — rejects writes | +| MI_01 | Col02 | 0xff03 | 0x0000 | no | Vendor — rejects writes | +| MI_01 | Col03 | 0x000c | 0x0001 | no | Consumer control | +| MI_01 | Col04 | 0x0001 | 0x0080 | no | System control | +| **MI_01** | **Col05** | **0xff02** | **0x0002** | **yes** | **Config channel** | +| MI_01 | Col06 | 0xff04 | 0x0002 | no | Vendor — feature report 6 only | +| MI_02 | — | 0x0001 | 0x0002 | no | Mouse | + +Chrome's own view of the same mouse, from `navigator.hid.getDevices()`, is +what the driver's `isSupported` actually gates on. It differs from the +platform view above — Windows exposes no report ids for several collections +that Chrome does — and it confirms report 8 is declared in both directions on +the config collection: + +| Usage page | Usage | Input | Output | Feature | +| --- | --- | --- | --- | --- | +| 0xff05 | 0x00 | 16 | — | — | +| 0xff03 | 0x00 | 2 | — | — | +| 0x000c | 0x01 | 5 | — | — | +| 0x0001 | 0x80 | — | — | — | +| **0xff02** | **0x02** | **8** | **8** | — | +| 0xff04 | 0x02 | — | — | 6 | + +The config channel is the `0xff02`/`0x0002` collection, which matches the +`Interfaceid=1` in the shipped `Config.ini` of Lamzu's Windows app. Every +other vendor collection rejects `WriteFile` with `Incorrect function`. + +The `0xff04` collection is a red herring worth recording: it answers +`HidD_GetFeature` on report id 6 with a 32-byte snapshot and accepts +`HidD_SetFeature`, but it does not speak this protocol. Feature reports are +the wrong channel entirely here — the config protocol uses interrupt +output/input reports. + +## Protocol + +Report id 8, 17 bytes on the wire (16 to WebHID, which supplies the id). + +``` + byte 0 command + byte 1 status, 0 on success + bytes 2..3 flash address, big endian + byte 4 payload length + bytes 5..14 payload + byte 15 checksum +``` + +This is the CompX report-8 protocol this repository already implements for +Pulsar's 4K receiver (`src/pulsar/index.ts`), byte for byte: the same +`pulsarPacketChecksum`, the same command ids, the same flash offsets, and the +same 50-step `pulsarVgnDecodeDpi` encoding. The driver imports those rather +than restating them. + +Commands answered by the mouse over the cable: + +| Command | Name | Reply | +| --- | --- | --- | +| `0x04` | Battery | `64 01 10 82` — 100%, charging, 4,226 mV | +| `0x07` | Write flash | echoes the written field | +| `0x08` | Read flash | up to 10 bytes from an address | +| `0x0e` | Active profile | `00` — 0-based on the wire, 1-based in Lamzu's UI | +| `0x0f` | Set active profile | verified by reading `0x0e` back | +| `0x12` | Firmware version | `01 24` — v1.24 | +| `0x15`, `0x1d`, `0x2b` | Dongle RGB, dongle version, RSSI | status 1 (rejected over the cable) | + +Two details that cost time and are easy to get wrong: + +- **The battery reply lies about its length.** Byte 4 says `0x02` while four + bytes follow: percent, charging flag, then the millivolts. Decoding by the + declared length silently drops the voltage. +- **The percent byte is authoritative.** `lamzu-cfg` derives a percentage + linearly from the millivolts between 3,050 and 4,200 mV. At 4,239 mV that + estimate reads 100% where the mouse's own byte, and Lamzu's configurator, + both said 95%. + +### Flash fields + +Every offset below was read on hardware. The evidence behind each is not +equal, so the last column says what it actually is: + +- **vendor UI** — the value was displayed by Lamzu's configurator for this + mouse at the same moment, so both the address and its meaning are confirmed. +- **UI diff** — the byte was watched changing as that setting was changed in + the vendor UI, which is the strongest evidence here. +- **round-trip** — the field was written and read back through this driver. + That proves the address is writable and stable; it does **not** independently + confirm the label, which comes from lamzu-cfg's map. + +| Address | Field | Encoding | Evidence | +| --- | --- | --- | --- | +| 0 | Polling rate | see below | UI diff | +| 2 | DPI stage count | 1-8 | vendor UI (5 stages) | +| 4 | Active DPI stage | 0-based | vendor UI | +| 10 | Lift-off distance | `1` = 1 mm, `2` = 2 mm | vendor UI | +| 12 + 4n | DPI stage n | `x, y, flags, checksum` | vendor UI | +| 44 + 4n | DPI stage n colour | `r, g, b, checksum` | vendor UI | +| 96 | Button actions | 4 bytes per button | vendor UI (bottom button = DPI Loop) | +| 169 | Debounce | milliseconds, 0-15 | vendor UI | +| 171 | Motion sync | 0/1 | vendor UI | +| 173 | Sleep timeout | **units of ten seconds** | UI diff | +| 175 | Angle snapping | 0/1 | vendor UI | +| 177 | Ripple control | 0/1 | round-trip | +| 181 | Competition mode | 0/1 | vendor UI | +| 183 | Competition timeout | units of ten seconds | round-trip; the unit is inferred from 173 | +| 185 | High performance | 0/1 | round-trip | + +A DPI stage stores x and y separately, and the flags byte carries each axis's +high bits — 2-3 for x, 6-7 for y. Reads decode both axes; `pulsarVgnDecodeDpi` +cannot be used for them, because it returns null unless the two axis bytes are +identical and would report a Lamzu configured with separate axes as corrupt. +Writes go through the shared `pulsarVgnEncodeDpi`, which writes one value to +both axes, so a per-axis stage is flattened the first time this driver changes +it; asymmetric writes are not attempted without hardware to confirm them. + +A field is stored with a trailing checksum byte, so the value bytes and that +byte together sum to `0x55`. Reads ask for one byte more than the field is +wide and reject the value if that sum is wrong. + +Address 173 is not in `lamzu-cfg`'s map. It was found by diffing the flash +image across a change made in Lamzu's own configurator: + +| Sleep setting in the vendor UI | Byte at 173 | +| --- | --- | +| 1 minute | `0x06` | +| 10 seconds | `0x01` | + +Addresses 6, 8 and 76-94 hold values this driver does not read and are +deliberately left unmapped rather than guessed at. + +### Polling rate + +The rate byte carries the same double encoding of 1,000 Hz that the other +Lamzu generations use, so `pulsarDecodePollingRate` is wrong for these units — +it reads `0x10` as 2,000 Hz. + +| Byte | Rate | Evidence | +| --- | --- | --- | +| `0x08`, `0x04`, `0x02`, `0x01` | 125, 250, 500, 1000 | Vendor UI wrote `0x02` when set to 500 Hz | +| `0x10` | 1000 | Read from the profile with the vendor UI showing 1000 Hz | +| `0x20`, `0x40`, `0x80` | 2000, 4000, 8000 | Receiver family, from the shared Lamzu table | + +The wired path offers 125-1000 Hz; the 4K receiver's list is from Lamzu's +device table and has not been exercised. + +## What was verified on hardware + +Read, against Lamzu's configurator open on the same mouse: name, firmware +v1.24, battery percent and voltage, charging state, profile, all five DPI +stages and their colours, active stage, polling rate, lift-off, debounce, +sleep timeout, motion sync, angle snapping, ripple control, competition mode, +high performance. + +Written, one at a time, each restored afterwards, each confirmed by reading +the field back: polling rate (500 → 125 → 500), lift-off (Low → Medium → Low), +debounce (4 → 6 → 4 ms), sleep (60 → 30 → 60 s), motion sync, angle snapping, +ripple control, competition mode, high performance, active DPI stage +(2 → 0 → 2), DPI stage value (2000 → 1600 → 2000), profile (1 → 2 → 1). A full +re-read afterwards showed no drift in any other field. + +Onboard profiles were probed by writing each index and reading it back: +0 through 3 are accepted, 4 and above are rejected with status 1 and leave the +mouse where it was. Hence four profiles, and the mouse was restored to its +original one afterwards. + +Not exercised: the three receiver product ids, button remapping, macros, and +the pairing and factory-reset controls the vendor app exposes. DPI above +12,800 — where the stage count no longer fits one byte and the high bits ride +in the flags byte — is covered by a unit test round trip but was never set on +this hardware; the stages exercised were 1,600 and 2,000. No command id +outside the table above was sent: the write path on this firmware includes a +factory reset, so unknown ids were not probed. diff --git a/src/drivers/lamzu-atlantis/client.test.ts b/src/drivers/lamzu-atlantis/client.test.ts new file mode 100644 index 0000000..91b3ef1 --- /dev/null +++ b/src/drivers/lamzu-atlantis/client.test.ts @@ -0,0 +1,362 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + LAMZU_ATLANTIS_COMMAND as COMMAND, + LAMZU_ATLANTIS_FLASH as FLASH, + LAMZU_ATLANTIS_USAGE, + LAMZU_ATLANTIS_USAGE_PAGE, + LAMZU_ATLANTIS_VENDOR_ID, + LAMZU_ATLANTIS_WRITE_ACTIVE_PROFILE as WRITE_ACTIVE_PROFILE, + lamzuAtlantisSealField, +} from "@openmouse/protocol/lamzu"; +import { pulsarVgnEncodeDpi } from "@openmouse/protocol/pulsar"; +import { LamzuAtlantisHidClient } from "./hid.ts"; + +const REPORT_ID = 8; +const PACKET = 16; + +/** + * A mouse that keeps real flash state and answers report 8 the way the + * hardware does, so a setter's write and its read-back go through the same + * bytes the device would see. Replies are delivered asynchronously, which is + * what makes the interleaving and lifecycle tests meaningful. + */ +class FakeAtlantis extends EventTarget { + readonly vendorId = LAMZU_ATLANTIS_VENDOR_ID; + readonly productId: number; + readonly productName = "LAMZU Atlantis Pro"; + readonly collections = [{ + usagePage: LAMZU_ATLANTIS_USAGE_PAGE, + usage: LAMZU_ATLANTIS_USAGE, + children: [], + inputReports: [{ reportId: REPORT_ID }], + outputReports: [{ reportId: REPORT_ID }], + featureReports: [], + }] as unknown as HIDCollectionInfo[]; + + opened = false; + flash = new Uint8Array(256); + profile = 0; + sent: Uint8Array[] = []; + opens = 0; + /** Set to drop replies, so a caller's timeout path can be exercised. */ + mute = false; + /** Adds a leading byte to the input DataView, as a real event can. */ + offsetReplies = false; + /** Replies queued while held, released by releaseReplies(). */ + private held: Array<() => void> = []; + holdReplies = false; + /** Resolves once the device has received a report. */ + reportSeen: Promise; + private announceReport!: () => void; + + releaseReplies(): void { + this.holdReplies = false; + const queued = this.held; + this.held = []; + for (const deliver of queued) deliver(); + } + + constructor(productId = 0xf50f) { + super(); + this.reportSeen = new Promise((resolve) => { this.announceReport = resolve; }); + this.productId = productId; + this.writeField(FLASH.reportRate, [0x02]); + this.writeField(FLASH.dpiStageCount, [2]); + this.writeField(FLASH.currentDpi, [0]); + this.writeField(FLASH.liftOffDistance, [1]); + this.writeField(FLASH.debounceTime, [4]); + this.writeField(FLASH.sleepTime, [6]); + this.writeField(FLASH.motionSync, [1]); + this.writeField(FLASH.angleSnapping, [0]); + this.writeField(FLASH.rippleControl, [0]); + this.writeField(FLASH.performanceState, [1]); + this.writeField(FLASH.highPerformance, [0]); + this.flash.set(pulsarVgnEncodeDpi(400), FLASH.dpiValues); + this.flash.set(pulsarVgnEncodeDpi(1600), FLASH.dpiValues + 4); + this.writeField(FLASH.dpiStageColors, [0xff, 0x00, 0x00]); + this.writeField(FLASH.dpiStageColors + 4, [0x00, 0xff, 0x00]); + } + + writeField(address: number, values: readonly number[]): void { + this.flash.set(lamzuAtlantisSealField([...values]), address); + } + + async open(): Promise { + this.opens += 1; + this.opened = true; + } + + async close(): Promise { + this.opened = false; + } + + async sendReport(reportId: number, data: BufferSource): Promise { + assert.equal(reportId, REPORT_ID, "the report id travels separately from the body"); + const body = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : new Uint8Array(data as ArrayBuffer); + assert.equal(body.length, PACKET, "the body excludes the report id"); + this.sent.push(Uint8Array.from(body)); + this.announceReport(); + if (this.mute) return; + + const reply = new Uint8Array(PACKET); + reply.set(body.subarray(0, 5)); + const command = body[0]!; + const address = (body[2]! << 8) | body[3]!; + const length = body[4]!; + if (command === COMMAND.readFlashData) { + reply.set(this.flash.subarray(address, address + length), 5); + } else if (command === COMMAND.writeFlashData) { + this.flash.set(body.subarray(5, 5 + length), address); + } else if (command === COMMAND.getCurrentConfig) { + reply[4] = 1; + reply[5] = this.profile; + } else if (command === WRITE_ACTIVE_PROFILE) { + if (body[5]! > 3) reply[1] = 1; + else this.profile = body[5]!; + } else if (command === COMMAND.batteryLevel) { + reply[4] = 2; + reply.set([95, 1, 0x10, 0x82], 5); + } else if (command === COMMAND.readVersionId) { + reply[4] = 2; + reply.set([1, 0x24], 5); + } else { + reply[1] = 1; + } + let sum = REPORT_ID; + for (let i = 0; i < PACKET - 1; i += 1) sum += reply[i]!; + reply[PACKET - 1] = (0x55 - (sum & 0xff)) & 0xff; + + const deliver = () => { + const buffer = this.offsetReplies + ? new Uint8Array([0xaa, ...reply]).buffer.slice(0) + : reply.buffer.slice(0); + const view = this.offsetReplies + ? new DataView(buffer, 1, PACKET) + : new DataView(buffer, 0, PACKET); + this.dispatchEvent(Object.assign(new Event("inputreport"), { reportId: REPORT_ID, data: view })); + }; + if (this.holdReplies) this.held.push(deliver); + else queueMicrotask(deliver); + } +} + +const clientFor = (device: FakeAtlantis) => + new LamzuAtlantisHidClient(device as unknown as HIDDevice); + +test("a full status read comes back from real flash bytes", async () => { + const device = new FakeAtlantis(); + const client = clientFor(device); + const status = await client.readStatus(); + assert.equal(status.brand, "Lamzu"); + assert.equal(status.name, "Lamzu Atlantis"); + assert.deepEqual(status.dpiStages, [400, 1600]); + assert.deepEqual(status.dpiStageColors, ["#ff0000", "#00ff00"]); + assert.equal(status.pollingRateHz, 500); + assert.equal(status.liftOffDistance, "Low"); + assert.equal(status.debounceMs, 4); + assert.equal(status.sleepTimeout, 60); + assert.equal(status.batteryPercent, 95); + assert.equal(status.batteryVoltageMv, 4226); + assert.equal(status.activeProfile, 1); + assert.deepEqual(status.firmware, ["Mouse v1.24"]); + await client.close(); +}); + +test("a reply whose DataView has a non-zero offset is still decoded", async () => { + const device = new FakeAtlantis(); + device.offsetReplies = true; + const client = clientFor(device); + assert.equal((await client.readStatus()).pollingRateHz, 500); + await client.close(); +}); + +test("setters land in flash and read back through the device", async () => { + const device = new FakeAtlantis(); + const client = clientFor(device); + await client.readStatus(); + + assert.equal(await client.setDebounceTime(9), 9); + assert.equal(device.flash[FLASH.debounceTime], 9); + assert.equal(await client.setSleepTimeout(30), 30); + assert.equal(device.flash[FLASH.sleepTime], 3, "the byte counts ten-second units"); + assert.equal(await client.setPollingRate(1000), 1000); + assert.equal(device.flash[FLASH.reportRate], 0x01, "the cable uses the wired encoding of 1000 Hz"); + assert.equal(await client.setLiftOffDistance("Medium"), "Medium"); + assert.equal(await client.setAngleSnapping(true), true); + assert.equal(device.flash[FLASH.angleSnapping], 1); + assert.equal(await client.setDpiStageValue(1, 3200), 3200); + assert.deepEqual((await client.readStatus()).dpiStages, [400, 3200]); + await client.close(); +}); + +test("the 4K receiver writes the receiver encoding of 1,000 Hz", async () => { + const device = new FakeAtlantis(0xf510); + const client = clientFor(device); + await client.readStatus(); + assert.equal(await client.setPollingRate(1000), 1000); + assert.equal(device.flash[FLASH.reportRate], 0x10); + await client.close(); +}); + +test("an out-of-range stage index never reaches the device", async () => { + const device = new FakeAtlantis(); + const client = clientFor(device); + await client.readStatus(); + const before = Uint8Array.from(device.flash); + const sent = device.sent.length; + + // -8 would scale to the DPI stages at address 12, and 13 to the button + // actions at 96, with the colour reading back cleanly from either. + await assert.rejects(() => client.setDpiStageColor(-8, "#010203"), /no DPI stage/); + await assert.rejects(() => client.setDpiStageColor(13, "#010203"), /no DPI stage/); + await assert.rejects(() => client.setDpiStageColor(0.5, "#010203"), /no DPI stage/); + await assert.rejects(() => client.setDpiStageValue(99, 800), /no DPI stage/); + await assert.rejects(() => client.setProfile(9), /profiles 1 to 4/); + + assert.deepEqual(device.flash, before, "no flash byte moved"); + assert.equal(device.sent.length, sent, "no report was sent"); + await client.close(); +}); + +test("a setter that arrives mid-operation waits its turn", async () => { + const device = new FakeAtlantis(); + const client = clientFor(device); + await client.readStatus(); + + // The second setter must start only once the first is genuinely in flight, + // with its reply withheld. Starting both synchronously proves nothing: they + // queue before the first has sent anything. + device.holdReplies = true; + const first = client.setDebounceTime(4); + await device.reportSeen; + const second = client.setDebounceTime(6); + await Promise.resolve(); + device.releaseReplies(); + + assert.equal(await first, 4, "the first setter verifies its own write, not the second's"); + assert.equal(await second, 6); + assert.equal(device.flash[FLASH.debounceTime], 6); + await client.close(); +}); + +test("a read arriving mid-setter cannot steal the in-flight reply", async () => { + const device = new FakeAtlantis(); + const client = clientFor(device); + await client.readStatus(); + + device.holdReplies = true; + const write = client.setPollingRate(125); + await device.reportSeen; + const read = client.readStatus(); + await Promise.resolve(); + device.releaseReplies(); + + assert.equal(await write, 125); + assert.equal((await read).pollingRateHz, 125); + await client.close(); +}); + +test("both DPI axes stay consistent across a stage write and a stage switch", async () => { + const device = new FakeAtlantis(); + const client = clientFor(device); + const initial = await client.readStatus(); + assert.equal(initial.dpi, 400); + assert.equal(initial.dpiY, 400); + + // The encoder writes one value to both axes, so the cached Y has to follow. + await client.setDpiStageValue(0, 800); + const written = await client.readStatus(true); + assert.equal(written.dpi, 800); + assert.equal(written.dpiY, 800, "Y moved with X"); + + await client.setActiveDpiStage(1); + const switched = await client.readStatus(true); + assert.equal(switched.dpi, 1600); + assert.equal(switched.dpiY, 1600, "Y belongs to the newly selected stage"); + await client.close(); +}); + +test("switching profiles drops the settings cached for the old one", async () => { + const device = new FakeAtlantis(); + const client = clientFor(device); + await client.readStatus(); + await client.setActiveDpiStage(1); + + // Profile 2 has different stages and sits on stage 0. + await client.setProfile(2); + device.writeField(FLASH.currentDpi, [0]); + device.flash.set(pulsarVgnEncodeDpi(800), FLASH.dpiValues); + + // setDpi must not reuse the old profile's active stage (1). + await client.setDpi(2400); + assert.equal((await client.readStatus()).dpiStages?.[0], 2400); + await client.close(); +}); + +test("changing the stage count invalidates the cached list in both directions", async () => { + const device = new FakeAtlantis(); + const client = clientFor(device); + await client.readStatus(); + + device.flash.set(pulsarVgnEncodeDpi(6400), FLASH.dpiValues + 8); + device.writeField(FLASH.dpiStageColors + 8, [0x00, 0x00, 0xff]); + assert.equal(await client.setDpiStageCount(3), 3); + const grown = await client.readStatus(true); + assert.deepEqual(grown.dpiStages, [400, 1600, 6400], "the new stage is loaded, not left missing"); + assert.deepEqual(grown.dpiStageColors, ["#ff0000", "#00ff00", "#0000ff"]); + + await client.setActiveDpiStage(2); + assert.equal(await client.setDpiStageCount(1), 1); + const shrunk = await client.readStatus(true); + assert.deepEqual(shrunk.dpiStages, [400]); + assert.equal(shrunk.activeDpiStage, 0, "the active stage cannot point past the list"); + await client.close(); +}); + +test("closing settles a request already on the wire", async () => { + const device = new FakeAtlantis(); + const client = clientFor(device); + await client.readStatus(); + + // Wait until the device has actually received a report, so close() lands on + // an exchange waiting for a reply rather than on one that has not sent yet. + device.mute = true; + const started = Date.now(); + const pending = client.readStatus().then(() => "resolved", (error: Error) => error.message); + await device.reportSeen; + await client.close(); + + const outcome = await pending; + assert.match(String(outcome), /closed/, "cancelled, not left to time out"); + assert.ok(Date.now() - started < 600, "close did not wait for the response timeout"); + assert.equal(device.opened, false); +}); + +test("closing and reopening leaves the client usable", async () => { + const device = new FakeAtlantis(); + const client = clientFor(device); + await client.readStatus(); + + // Overlapping these let the reopen memoize a resolved open while the close + // was still about to remove the listener, wedging every later open. + const closing = client.close(); + const reopening = client.open(); + await Promise.all([closing, reopening]); + + await client.open(); + assert.equal(device.opened, true, "the device is open again"); + assert.equal((await client.readStatus()).pollingRateHz, 500, "and still answers"); + await client.close(); +}); + +test("open is not attempted twice by concurrent reads", async () => { + const device = new FakeAtlantis(); + const client = clientFor(device); + await Promise.all([client.readStatus(), client.readStatus()]); + assert.equal(device.opens, 1, "a second open() would reject in Chrome"); + await client.close(); +}); diff --git a/src/drivers/lamzu-atlantis/hid.test.ts b/src/drivers/lamzu-atlantis/hid.test.ts new file mode 100644 index 0000000..11a3b26 --- /dev/null +++ b/src/drivers/lamzu-atlantis/hid.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + LAMZU_ATLANTIS_USAGE, + LAMZU_ATLANTIS_USAGE_PAGE, + LAMZU_ATLANTIS_VENDOR_ID, +} from "@openmouse/protocol/lamzu"; +import { LamzuAtlantisHidClient } from "./hid.ts"; + +function collection( + usagePage: number, + usage: number, + options: { input?: number[]; output?: number[]; feature?: number[]; children?: HIDCollectionInfo[] } = {}, +): HIDCollectionInfo { + return { + usagePage, + usage, + type: 1, + children: options.children ?? [], + inputReports: (options.input ?? []).map((reportId) => ({ reportId })), + outputReports: (options.output ?? []).map((reportId) => ({ reportId })), + featureReports: (options.feature ?? []).map((reportId) => ({ reportId })), + } as unknown as HIDCollectionInfo; +} + +/** The collection shape a wired Atlantis presents: five vendor collections + * alongside the keyboard, consumer and system-control ones, of which only + * 0xff02 usage 2 carries report 8. */ +const ATLANTIS_COLLECTIONS: HIDCollectionInfo[] = [ + collection(0x0001, 0x0006), + collection(0xff05, 0x0000), + collection(0xff03, 0x0000), + collection(0x000c, 0x0001), + collection(0x0001, 0x0080), + collection(LAMZU_ATLANTIS_USAGE_PAGE, LAMZU_ATLANTIS_USAGE, { input: [8], output: [8] }), + collection(0xff04, 0x0002, { feature: [6] }), + collection(0x0001, 0x0002), +]; + +const device = ( + vendorId: number, + productId: number, + collections: HIDCollectionInfo[] = ATLANTIS_COLLECTIONS, +): HIDDevice => ({ vendorId, productId, productName: "LAMZU Atlantis Pro", collections } as unknown as HIDDevice); + +test("the wired mouse and every catalogued receiver are claimed", () => { + for (const productId of [0xf50f, 0xf50d, 0xf510, 0xf517]) { + assert.ok(LamzuAtlantisHidClient.isSupported(device(LAMZU_ATLANTIS_VENDOR_ID, productId))); + } +}); + +test("other devices on CompX's shared vendor id are left alone", () => { + // VXE R1 SE+ transports, a Teevolution Terra Pro, a VGN Dragonfly F2 Master+ + // and Pulsar's own 4K receiver all enumerate under 0x3554. + for (const productId of [0xf58e, 0xf58f, 0xf520, 0xfb56, 0x0002]) { + assert.equal(LamzuAtlantisHidClient.isSupported(device(LAMZU_ATLANTIS_VENDOR_ID, productId)), false); + } +}); + +test("a Lamzu product id under a different vendor id is not claimed", () => { + // 0x373e and 0x37b0 are the other two Lamzu generations, which speak the + // feature-report protocol instead. + assert.equal(LamzuAtlantisHidClient.isSupported(device(0x373e, 0xf50f)), false); + assert.equal(LamzuAtlantisHidClient.isSupported(device(0x37b0, 0xf50f)), false); +}); + +test("the config collection must actually carry report 8", () => { + const withoutReport8 = [ + collection(LAMZU_ATLANTIS_USAGE_PAGE, LAMZU_ATLANTIS_USAGE, { input: [6], output: [6] }), + ]; + assert.equal(LamzuAtlantisHidClient.isSupported(device(LAMZU_ATLANTIS_VENDOR_ID, 0xf50f, withoutReport8)), false); + + // Report 8 on some other collection is not the config channel either. + const wrongCollection = [collection(0xff04, 0x0002, { input: [8], output: [8] })]; + assert.equal(LamzuAtlantisHidClient.isSupported(device(LAMZU_ATLANTIS_VENDOR_ID, 0xf50f, wrongCollection)), false); + + assert.equal(LamzuAtlantisHidClient.isSupported(device(LAMZU_ATLANTIS_VENDOR_ID, 0xf50f, [])), false); +}); + +test("a nested config collection is found", () => { + const nested = [ + collection(0x0001, 0x0002, { + children: [collection(LAMZU_ATLANTIS_USAGE_PAGE, LAMZU_ATLANTIS_USAGE, { input: [8], output: [8] })], + }), + ]; + assert.ok(LamzuAtlantisHidClient.isSupported(device(LAMZU_ATLANTIS_VENDOR_ID, 0xf50f, nested))); +}); + +test("the receivers report themselves as wireless and the cable does not", () => { + assert.equal(new LamzuAtlantisHidClient(device(LAMZU_ATLANTIS_VENDOR_ID, 0xf50f)).isWireless(), false); + assert.equal(new LamzuAtlantisHidClient(device(LAMZU_ATLANTIS_VENDOR_ID, 0xf510)).isWireless(), true); +}); + +test("the family name is used rather than the shared USB product string", () => { + // Six models ship this product string, so it must not become the name. + const client = new LamzuAtlantisHidClient(device(LAMZU_ATLANTIS_VENDOR_ID, 0xf50f)); + assert.equal(client.displayName(), "Lamzu Atlantis"); + assert.equal(client.deviceBrand(), "Lamzu"); +}); + +test("the wired path offers no rate the cable cannot carry", () => { + const wired = new LamzuAtlantisHidClient(device(LAMZU_ATLANTIS_VENDOR_ID, 0xf50f)); + assert.deepEqual(wired.getSupportedPollingRates(), [125, 250, 500, 1000]); + const receiver = new LamzuAtlantisHidClient(device(LAMZU_ATLANTIS_VENDOR_ID, 0xf510)); + assert.deepEqual(receiver.getSupportedPollingRates(), [500, 1000, 2000, 4000]); +}); diff --git a/src/drivers/lamzu-atlantis/hid.ts b/src/drivers/lamzu-atlantis/hid.ts new file mode 100644 index 0000000..79bf441 --- /dev/null +++ b/src/drivers/lamzu-atlantis/hid.ts @@ -0,0 +1,657 @@ +import type { MouseStatus } from "../mouse-types.ts"; +import { + LAMZU_ATLANTIS_COMMAND as COMMAND, + LAMZU_ATLANTIS_FLASH as FLASH, + LAMZU_ATLANTIS_MAX_DPI as DPI_MAX, + LAMZU_ATLANTIS_MAX_DPI_STAGES as MAX_STAGES, + LAMZU_ATLANTIS_MAX_PAYLOAD as MAX_PAYLOAD, + LAMZU_ATLANTIS_MAX_TIMER_SECONDS as MAX_TIMER_SECONDS, + LAMZU_ATLANTIS_MIN_DPI as DPI_MIN, + LAMZU_ATLANTIS_PROFILE_COUNT as PROFILE_COUNT, + LAMZU_ATLANTIS_REPORT_ID as REPORT_ID, + LAMZU_ATLANTIS_SLEEP_OPTIONS as SLEEP_OPTIONS, + LAMZU_ATLANTIS_STAGE_STRIDE as STAGE_STRIDE, + LAMZU_ATLANTIS_DPI_STEP as DPI_STEP, + LAMZU_ATLANTIS_TIMER_STEP_SECONDS as TIMER_STEP_SECONDS, + LAMZU_ATLANTIS_USAGE as CONFIG_USAGE, + LAMZU_ATLANTIS_USAGE_PAGE as CONFIG_USAGE_PAGE, + LAMZU_ATLANTIS_WRITE_ACTIVE_PROFILE as WRITE_ACTIVE_PROFILE, + lamzuAtlantisDecodeBattery, + lamzuAtlantisDecodeDpiStage, + lamzuAtlantisDecodeFirmware, + lamzuAtlantisDecodeLiftOffDistance, + lamzuAtlantisDecodePollingRate, + lamzuAtlantisDecodeReply, + lamzuAtlantisEncodeLiftOffDistance, + lamzuAtlantisEncodePollingRate, + lamzuAtlantisEncodeRequest, + lamzuAtlantisFieldIsIntact, + lamzuAtlantisProduct, + lamzuAtlantisSealField, + type LamzuAtlantisProduct, +} from "@openmouse/protocol/lamzu"; +import { pulsarVgnEncodeDpi } from "@openmouse/protocol/pulsar"; + +const RESPONSE_TIMEOUT_MS = 600; +const RESPONSE_ATTEMPTS = 3; +const DEBOUNCE_MAX_MS = 15; + +type LiftOffDistance = NonNullable; + +/** + * Lamzu's Atlantis generation (0x3554), which speaks CompX's report-8 + * interrupt protocol rather than the feature-report page/command protocol the + * 0x373e and 0x37b0 Lamzu models use. Settings are byte fields in a flash + * image, so every setter here writes one field and reads it back. + * + * The framing and the 50-step DPI encoding are shared with the Pulsar 4K + * receiver and the VGN and Teevolution units on the same vendor id, and are + * imported rather than reimplemented. What is Lamzu-specific is the identity, + * the lift-off encoding, the polling-rate table and the DPI-stage fields — + * see `../../lamzu/atlantis.ts`. + */ +export class LamzuAtlantisHidClient { + readonly canDisableSleep = false; + + readonly device: HIDDevice; + + private queue: Promise = Promise.resolve(); + private lifecycleQueue: Promise = Promise.resolve(); + private pending: ((body: Uint8Array) => void) | null = null; + private abortPending: (() => void) | null = null; + private listener: ((event: HIDInputReportEvent) => void) | null = null; + private opening: Promise | null = null; + /** Set by close(), so work already queued gives up instead of reopening. */ + private closed = false; + private lastStatus: MouseStatus | null = null; + private firmware: string | null = null; + /** + * Y values per stage. MouseStatus carries dpiY only for the active stage, so + * without this a stage switch would report the new stage's X beside the old + * stage's Y. + */ + private stagesY: number[] = []; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + const search = (collection: HIDCollectionInfo): boolean => + (collection.usagePage === CONFIG_USAGE_PAGE + && collection.usage === CONFIG_USAGE + && collection.outputReports.some((report) => report.reportId === REPORT_ID)) + || collection.children.some(search); + return lamzuAtlantisProduct(device.vendorId, device.productId) !== undefined + && device.collections.some(search); + } + + private product(): LamzuAtlantisProduct | undefined { + return lamzuAtlantisProduct(this.device.vendorId, this.device.productId); + } + + /** + * Memoized: two concurrent reads would otherwise both see a closed device, + * and the second `device.open()` rejects while both callers go on to attach + * their own listener, of which `close()` removes one. + */ + async open(): Promise { + await this.lifecycle(async () => { + this.closed = false; + await this.ensureOpen(); + }); + } + + /** + * Opens and closes take turns. Overlapping them lets a reopen inspect the + * device while a close is still mid-flight: it finds the device open and the + * listener installed, memoizes a resolved promise, and then the close + * removes that listener — leaving every later open awaiting a promise that + * will never reinstall anything. + */ + private async lifecycle(operation: () => Promise): Promise { + const run = this.lifecycleQueue.then(operation, operation); + this.lifecycleQueue = run.catch(() => undefined); + return await run; + } + + /** + * Opens without clearing `closed`, so an exchange that was already queued + * when close() ran cannot quietly reopen the device behind the caller. + */ + private async ensureOpen(): Promise { + this.opening ??= this.openOnce().catch((error: unknown) => { + this.opening = null; + throw error; + }); + await this.opening; + } + + private async openOnce(): Promise { + if (!this.device.opened) await this.device.open(); + if (this.listener) return; + this.listener = (event: HIDInputReportEvent) => { + if (event.reportId !== REPORT_ID || !this.pending) return; + const view = event.data; + this.pending(new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength))); + }; + this.device.addEventListener("inputreport", this.listener); + } + + /** + * Closing has to cancel, not just tidy up. An exchange waiting on a reply is + * settled here rather than left to time out and retry, and the generation + * bump makes anything still queued fail instead of reopening the device + * behind the caller's back. + */ + async close(): Promise { + await this.lifecycle(() => this.closeOnce()); + } + + private async closeOnce(): Promise { + this.closed = true; + this.lastStatus = null; + this.pending = null; + const abort = this.abortPending; + this.abortPending = null; + abort?.(); + const opening = this.opening; + this.opening = null; + // A close that lands mid-open must wait for that open to finish, or its + // listener is installed after this has already removed one. + await opening?.catch(() => undefined); + if (this.listener) this.device.removeEventListener("inputreport", this.listener); + this.listener = null; + if (this.device.opened) await this.device.close(); + } + + /** + * No push channel is known on this generation: the only report the config + * collection carries is 8, which is the request/reply channel, and nothing + * unsolicited was seen arriving on it while settings were changed in Lamzu's + * own configurator. Returning false leaves the app on its polling path, + * which is the honest answer until a notification report turns up. + */ + async startNotifications(): Promise { + return false; + } + + displayName(): string { + const known = this.product(); + return known ? `Lamzu ${known.model}` : this.device.productName || "Lamzu"; + } + + deviceBrand(): MouseStatus["brand"] { + return "Lamzu"; + } + + maxDpi(): number { + return DPI_MAX; + } + + getDebounceMaxMs(): number { + return DEBOUNCE_MAX_MS; + } + + getSleepOptions(): readonly number[] { + return SLEEP_OPTIONS; + } + + getSupportedPollingRates(): number[] { + return [...(this.product()?.pollingRates ?? [125, 250, 500, 1000])]; + } + + getDpiOptions(): number[] { + const options: number[] = []; + for (let dpi = DPI_MIN; dpi <= DPI_MAX; dpi += DPI_STEP) options.push(dpi); + return options; + } + + isWireless(): boolean { + const known = this.product(); + if (known) return known.wireless; + return /receiver|dongle/i.test(this.device.productName || ""); + } + + async readStatus(live = false): Promise { + await this.open(); + return await this.transaction(async () => { + if (live && this.lastStatus) return await this.readLiveStatus(this.lastStatus); + + const battery = lamzuAtlantisDecodeBattery(await this.request(COMMAND.batteryLevel)); + const activeProfile = (await this.request(COMMAND.getCurrentConfig))[0] ?? 0; + if (this.firmware === null) { + this.firmware = lamzuAtlantisDecodeFirmware("Mouse", await this.request(COMMAND.readVersionId)) + ?? "Mouse firmware unavailable"; + } + + const pollingRaw = (await this.readField(FLASH.reportRate, 1))[0] ?? 0; + const stageCount = Math.min((await this.readField(FLASH.dpiStageCount, 1))[0] ?? 1, MAX_STAGES); + const stageIndex = Math.min((await this.readField(FLASH.currentDpi, 1))[0] ?? 0, Math.max(stageCount - 1, 0)); + + const stages: number[] = []; + const stagesY: number[] = []; + const colors: string[] = []; + for (let stage = 0; stage < stageCount; stage += 1) { + const address = FLASH.dpiValues + stage * STAGE_STRIDE; + const decoded = lamzuAtlantisDecodeDpiStage(await this.readRaw(address, STAGE_STRIDE)); + // A stage carries its own checksum, so a failed decode means a corrupt + // read. Reporting a plausible-looking 50 DPI instead would be a lie. + if (!decoded) throw new Error(`The mouse returned a corrupt DPI stage from address ${address}.`); + stages.push(decoded.x); + stagesY.push(decoded.y); + const color = await this.readField(FLASH.dpiStageColors + stage * STAGE_STRIDE, 3); + colors.push(`#${[...color].map((value) => value.toString(16).padStart(2, "0")).join("")}`); + } + + const liftOffRaw = (await this.readField(FLASH.liftOffDistance, 1))[0] ?? 0; + const debounceMs = (await this.readField(FLASH.debounceTime, 1))[0] ?? 0; + const sleepRaw = (await this.readField(FLASH.sleepTime, 1))[0] ?? 0; + const motionSync = (await this.readField(FLASH.motionSync, 1))[0] === 1; + const angleSnapping = (await this.readField(FLASH.angleSnapping, 1))[0] === 1; + const rippleControl = (await this.readField(FLASH.rippleControl, 1))[0] === 1; + const performanceMode = (await this.readField(FLASH.performanceState, 1))[0] === 1; + const hyperMode = (await this.readField(FLASH.highPerformance, 1))[0] === 1; + + const wireless = this.isWireless(); + this.stagesY = stagesY; + return this.lastStatus = { + brand: "Lamzu", + name: this.displayName(), + ui: { + family: "lamzu-atlantis", + forceShowBattery: true, + hideUnsupportedPollingRates: true, + hideSignalCard: true, + showAdvancedSection: true, + dpiStageEditor: { + maxStages: MAX_STAGES, + countEditable: true, + minDpi: DPI_MIN, + maxDpi: DPI_MAX, + stepDpi: DPI_STEP, + }, + }, + batteryPercent: battery.percent, + batteryVoltageMv: battery.millivolts, + batteryState: battery.charging ? "Charging" : "Discharging", + dpi: stages[stageIndex] ?? stages[0] ?? DPI_MIN, + dpiY: stagesY[stageIndex] ?? stagesY[0] ?? DPI_MIN, + dpiStages: stages, + dpiStageColors: colors, + activeDpiStage: stageIndex, + pollingRateHz: lamzuAtlantisDecodePollingRate(pollingRaw) ?? this.getSupportedPollingRates()[0] ?? 1000, + supportedPollingRates: this.getSupportedPollingRates(), + // The profile byte is 0-based on the wire and 1-based in Lamzu's UI. + activeProfile: activeProfile + 1, + profileCount: PROFILE_COUNT, + connectionType: wireless ? "Wireless" : "Wired", + connectionDetail: wireless ? "2.4 GHz receiver" : "Wired USB", + debounceMs, + sleepTimeout: sleepRaw > 0 ? sleepRaw * TIMER_STEP_SECONDS : null, + liftOffDistance: lamzuAtlantisDecodeLiftOffDistance(liftOffRaw), + motionSync, + angleSnapping, + rippleControl, + performanceMode, + hyperMode, + firmware: [this.firmware], + }; + }); + } + + private async readLiveStatus(previous: MouseStatus): Promise { + const battery = lamzuAtlantisDecodeBattery(await this.request(COMMAND.batteryLevel)); + const pollingRaw = (await this.readField(FLASH.reportRate, 1))[0] ?? 0; + return this.lastStatus = { + ...previous, + batteryPercent: battery.percent, + batteryVoltageMv: battery.millivolts, + batteryState: battery.charging ? "Charging" : "Discharging", + pollingRateHz: lamzuAtlantisDecodePollingRate(pollingRaw) ?? previous.pollingRateHz, + }; + } + + async setPollingRate(pollingRateHz: number): Promise { + return await this.transaction(async () => { + const supported = this.getSupportedPollingRates(); + const encoded = lamzuAtlantisEncodePollingRate(pollingRateHz, this.product()?.rateFamily ?? "wired"); + if (encoded === null || !supported.includes(pollingRateHz)) { + throw new Error(`This mouse does not support ${pollingRateHz} Hz.`); + } + const confirmed = lamzuAtlantisDecodePollingRate(await this.writeByte(FLASH.reportRate, encoded)); + if (confirmed !== pollingRateHz) { + throw new Error(`The mouse kept ${confirmed ?? "an unknown rate"} instead of ${pollingRateHz} Hz.`); + } + this.patch({ pollingRateHz: confirmed }); + return confirmed; + }); + } + + async setLiftOffDistance(value: LiftOffDistance): Promise { + return await this.transaction(async () => { + const encoded = lamzuAtlantisEncodeLiftOffDistance(value); + if (encoded === null) { + throw new Error(`This mouse does not support a ${value.toLowerCase()} lift-off distance.`); + } + const confirmed = lamzuAtlantisDecodeLiftOffDistance(await this.writeByte(FLASH.liftOffDistance, encoded)); + if (confirmed !== value) { + throw new Error(`The mouse kept a ${String(confirmed).toLowerCase()} lift-off distance instead of ${value.toLowerCase()}.`); + } + this.patch({ liftOffDistance: confirmed }); + return confirmed; + }); + } + + async setDebounceTime(milliseconds: number): Promise { + return await this.transaction(async () => { + if (!Number.isInteger(milliseconds) || milliseconds < 0 || milliseconds > DEBOUNCE_MAX_MS) { + throw new Error(`Debounce must be a whole number of milliseconds between 0 and ${DEBOUNCE_MAX_MS}.`); + } + const confirmed = await this.writeByte(FLASH.debounceTime, milliseconds); + if (confirmed !== milliseconds) { + throw new Error(`The mouse kept ${confirmed} ms of debounce instead of ${milliseconds} ms.`); + } + this.patch({ debounceMs: confirmed }); + return confirmed; + }); + } + + async setSleepTimeout(seconds: number): Promise { + return await this.transaction(async () => { + if (!Number.isInteger(seconds) + || seconds < TIMER_STEP_SECONDS + || seconds > MAX_TIMER_SECONDS + || seconds % TIMER_STEP_SECONDS !== 0) { + throw new Error(`The sleep timeout must be a whole number of ${TIMER_STEP_SECONDS}-second steps up to ${MAX_TIMER_SECONDS} seconds.`); + } + const confirmed = (await this.writeByte(FLASH.sleepTime, seconds / TIMER_STEP_SECONDS)) * TIMER_STEP_SECONDS; + if (confirmed !== seconds) { + throw new Error(`The mouse kept a ${confirmed} second sleep timeout instead of ${seconds} seconds.`); + } + this.patch({ sleepTimeout: confirmed }); + return confirmed; + }); + } + + async setMotionSync(enabled: boolean): Promise { + return await this.setFlag(FLASH.motionSync, enabled, "motionSync", "Motion Sync"); + } + + async setAngleSnapping(enabled: boolean): Promise { + return await this.setFlag(FLASH.angleSnapping, enabled, "angleSnapping", "angle snapping"); + } + + async setRippleControl(enabled: boolean): Promise { + return await this.setFlag(FLASH.rippleControl, enabled, "rippleControl", "ripple control"); + } + + async setPerformanceMode(enabled: boolean): Promise { + return await this.setFlag(FLASH.performanceState, enabled, "performanceMode", "competition mode"); + } + + async setHyperMode(enabled: boolean): Promise { + return await this.setFlag(FLASH.highPerformance, enabled, "hyperMode", "high performance"); + } + + async setDpi(dpi: number): Promise { + return await this.transaction(async () => { + const stage = this.lastStatus?.activeDpiStage + ?? (await this.readField(FLASH.currentDpi, 1))[0] + ?? 0; + return await this.writeStage(stage, dpi); + }); + } + + async setDpiStageValue(stage: number, dpi: number): Promise { + return await this.transaction(() => this.writeStage(stage, dpi)); + } + + private async writeStage(stage: number, dpi: number): Promise { + if (!Number.isInteger(stage) || stage < 0 || stage >= MAX_STAGES) { + throw new Error(`This mouse has no DPI stage ${stage + 1}.`); + } + const address = FLASH.dpiValues + stage * STAGE_STRIDE; + // pulsarVgnEncodeDpi returns the stage's four bytes with its checksum + // already in place, so this writes them as-is rather than re-sealing. + // It writes one value to both axes, so a stage Lamzu's own app had set to + // separate x and y is flattened here; asymmetric writes are not attempted + // without hardware to confirm the flags layout for them. + await this.write(address, [...pulsarVgnEncodeDpi(dpi)]); + const stored = lamzuAtlantisDecodeDpiStage(await this.readRaw(address, STAGE_STRIDE)); + const confirmed = stored?.x ?? null; + if (confirmed !== dpi) { + throw new Error(`The mouse kept ${confirmed?.toLocaleString() ?? "an unknown DPI"} instead of ${dpi.toLocaleString()}.`); + } + const stages = this.lastStatus?.dpiStages?.slice(); + if (stages && stage < stages.length) stages[stage] = confirmed; + // The encoder wrote one value to both axes, so Y moved with X. + if (stage < this.stagesY.length) this.stagesY[stage] = confirmed; + this.patch({ + ...(stages ? { dpiStages: stages } : {}), + ...(this.lastStatus?.activeDpiStage === stage ? { dpi: confirmed, dpiY: confirmed } : {}), + }); + return confirmed; + } + + async setActiveDpiStage(stage: number): Promise { + return await this.transaction(async () => { + const count = (await this.readField(FLASH.dpiStageCount, 1))[0] ?? 1; + if (!Number.isInteger(stage) || stage < 0 || stage >= count) { + throw new Error(`This mouse has no DPI stage ${stage + 1}.`); + } + const confirmed = await this.writeByte(FLASH.currentDpi, stage); + if (confirmed !== stage) { + throw new Error(`The mouse stayed on DPI stage ${confirmed + 1} instead of ${stage + 1}.`); + } + // Both axes belong to the newly selected stage; carrying the old Y over + // would report this stage's X beside the previous stage's Y. + this.patch({ + activeDpiStage: confirmed, + ...(this.lastStatus?.dpiStages?.[confirmed] !== undefined + ? { dpi: this.lastStatus.dpiStages[confirmed] } + : {}), + ...(this.stagesY[confirmed] !== undefined ? { dpiY: this.stagesY[confirmed] } : {}), + }); + return confirmed; + }); + } + + async setDpiStageCount(count: number): Promise { + return await this.transaction(async () => { + if (!Number.isInteger(count) || count < 1 || count > MAX_STAGES) { + throw new Error(`This mouse supports between 1 and ${MAX_STAGES} DPI stages.`); + } + // Dropped before the write, not after: if the verification read fails + // the mouse has still changed, and a cache kept through that failure + // would describe a stage list that no longer exists. + this.lastStatus = null; + this.stagesY = []; + const confirmed = await this.writeByte(FLASH.dpiStageCount, count); + if (confirmed !== count) { + throw new Error(`The mouse kept ${confirmed} DPI stages instead of ${count}.`); + } + // Dropping stages can strand the active index past the end of the list. + const active = (await this.readField(FLASH.currentDpi, 1))[0] ?? 0; + if (active >= confirmed) await this.writeByte(FLASH.currentDpi, confirmed - 1); + return confirmed; + }); + } + + async setDpiStageColor(stage: number, color: string): Promise { + return await this.transaction(async () => { + // Without this bound the stage index scales straight into a flash address: + // stage -8 lands on the DPI stages at 12, stage 13 on the button actions + // at 96, and the colour reads back cleanly from wherever it landed. + if (!Number.isInteger(stage) || stage < 0 || stage >= MAX_STAGES) { + throw new Error(`This mouse has no DPI stage ${stage + 1}.`); + } + const match = /^#?([0-9a-f]{6})$/i.exec(color.trim()); + if (!match) throw new Error(`${color} is not a #rrggbb colour.`); + const wanted = match[1]!.toLowerCase(); + const address = FLASH.dpiStageColors + stage * STAGE_STRIDE; + await this.writeField(address, [0, 2, 4].map((offset) => Number.parseInt(wanted.slice(offset, offset + 2), 16))); + const stored = await this.readField(address, 3); + const confirmed = `#${[...stored].map((value) => value.toString(16).padStart(2, "0")).join("")}`; + if (confirmed !== `#${wanted}`) throw new Error(`The mouse kept ${confirmed} instead of #${wanted}.`); + const colors = this.lastStatus?.dpiStageColors?.slice(); + if (colors && stage < colors.length) colors[stage] = confirmed; + if (colors) this.patch({ dpiStageColors: colors }); + return confirmed; + }); + } + + async setProfile(profile: number): Promise { + return await this.transaction(async () => { + if (!Number.isInteger(profile) || profile < 1 || profile > PROFILE_COUNT) { + throw new Error(`This mouse has profiles 1 to ${PROFILE_COUNT}.`); + } + // Every cached field — DPI stages, colours, active stage, the toggles — + // describes the profile we are leaving, and setDpi trusts the cached + // active stage. Dropped before the write, so a failed verification read + // cannot leave the old profile's settings looking current. + this.lastStatus = null; + this.stagesY = []; + await this.request(WRITE_ACTIVE_PROFILE, 0, [profile - 1]); + const confirmed = ((await this.request(COMMAND.getCurrentConfig))[0] ?? 0) + 1; + if (confirmed !== profile) { + throw new Error(`The mouse stayed on profile ${confirmed} instead of ${profile}.`); + } + return confirmed; + }); + } + + private async setFlag( + address: number, + enabled: boolean, + field: "motionSync" | "angleSnapping" | "rippleControl" | "performanceMode" | "hyperMode", + label: string, + ): Promise { + return await this.transaction(async () => { + const confirmed = (await this.writeByte(address, enabled ? 1 : 0)) === 1; + if (confirmed !== enabled) throw new Error(`The mouse left ${label} ${confirmed ? "on" : "off"}.`); + this.patch({ [field]: confirmed }); + return confirmed; + }); + } + + private patch(changes: Partial): void { + if (this.lastStatus) this.lastStatus = { ...this.lastStatus, ...changes }; + } + + /** + * Writes one field byte and returns what the mouse reports afterwards. The + * write and its read-back are one transaction: interleaved with another + * setter for the same field, this would otherwise read the other value back + * and report a failure the mouse never made. + */ + private async writeByte(address: number, value: number): Promise { + await this.writeField(address, [value]); + return (await this.readField(address, 1))[0] ?? 0; + } + + private async writeField(address: number, values: readonly number[]): Promise { + await this.write(address, lamzuAtlantisSealField([...values])); + } + + private async write(address: number, bytes: readonly number[]): Promise { + await this.exchange(COMMAND.writeFlashData, address, bytes); + } + + /** + * Reads a flash field together with the checksum byte stored after it, and + * refuses the value unless the pair checksums out. + */ + private async readField(address: number, length: number): Promise { + const field = await this.readRaw(address, length + 1); + if (!lamzuAtlantisFieldIsIntact(field)) { + throw new Error(`The mouse returned a corrupt value from address ${address}.`); + } + return field.subarray(0, length); + } + + private async readRaw(address: number, length: number): Promise { + if (length > MAX_PAYLOAD) throw new Error("A CompX flash read spans at most 10 bytes."); + const payload = await this.exchange(COMMAND.readFlashData, address, new Array(length).fill(0)); + return payload.subarray(0, length); + } + + /** + * Serializes a whole public operation, not a single packet. + * + * Queueing per exchange is not enough: a setter is a write followed by a + * read-back, and two concurrent setters for the same field would interleave + * as write(a), write(b), read(b), read(b) — the first setter then throws + * about a value the mouse did accept. + * + * Every public method holds this exactly once and works through the + * unqueued helpers below it, so there is no reentrancy to detect. An + * "am I nested?" flag cannot work here: it says only that *someone* owns + * the lock, so an unrelated caller arriving mid-operation would read it as + * nesting, run inline, and overwrite the in-flight exchange's reply + * callback. + */ + private async transaction(operation: () => Promise): Promise { + const run = this.queue.then(operation, operation); + this.queue = run.catch(() => undefined); + return await run; + } + + /** Unqueued: the caller already holds the transaction lock. */ + private async request(command: number, address = 0, payload: readonly number[] = []): Promise { + return await this.exchange(command, address, payload); + } + + private async exchange(command: number, address: number, payload: readonly number[]): Promise { + if (this.closed) throw new Error("The connection to the mouse was closed."); + await this.ensureOpen(); + const request = lamzuAtlantisEncodeRequest({ command, address, payload }); + for (let attempt = 0; attempt < RESPONSE_ATTEMPTS; attempt += 1) { + if (this.closed) throw new Error("The connection to the mouse was closed."); + const reply = await this.sendAndWait(request, command, address); + if (this.closed) throw new Error("The connection to the mouse was closed."); + if (!reply) continue; + if (reply.error !== 0) { + throw new Error(`Command 0x${command.toString(16).padStart(2, "0")} failed with status ${reply.error}.`); + } + return reply.payload; + } + throw new Error( + `Command 0x${command.toString(16).padStart(2, "0")} got no answer — the mouse may be asleep or out of range.`, + ); + } + + /** + * A reply that does not match is skipped rather than treated as a failure. + * + * Matching on the command alone is not enough: every flash access shares + * command 0x08 (or 0x07), so after a timed-out attempt a late reply would + * satisfy the *next* request for a different address — a read of the + * debounce byte could return the motion-sync byte, and a setter's read-back + * would then verify against the wrong field. Every reply echoes the address + * it was asked for, so flash replies are matched on it too. + */ + private async sendAndWait(request: Uint8Array, command: number, address: number) { + const addressed = command === COMMAND.readFlashData || command === COMMAND.writeFlashData; + return await new Promise>((resolve) => { + let settled = false; + const finish = (value: ReturnType) => { + if (settled) return; + settled = true; + this.pending = null; + this.abortPending = null; + globalThis.clearTimeout(timer); + resolve(value); + }; + const timer = globalThis.setTimeout(() => finish(null), RESPONSE_TIMEOUT_MS); + this.abortPending = () => finish(null); + this.pending = (body) => { + const reply = lamzuAtlantisDecodeReply(body); + if (!reply || reply.command !== command) return; + if (addressed && reply.address !== address) return; + finish(reply); + }; + this.device.sendReport(REPORT_ID, request).catch(() => finish(null)); + }); + } +} diff --git a/src/drivers/lamzu-atlantis/protocol.test.ts b/src/drivers/lamzu-atlantis/protocol.test.ts new file mode 100644 index 0000000..10d2209 --- /dev/null +++ b/src/drivers/lamzu-atlantis/protocol.test.ts @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + LAMZU_ATLANTIS_COMMAND as COMMAND, + LAMZU_ATLANTIS_FLASH as FLASH, + LAMZU_ATLANTIS_PRODUCTS, + LAMZU_ATLANTIS_TIMER_STEP_SECONDS, + LAMZU_ATLANTIS_VENDOR_ID, + LAMZU_ATLANTIS_WRITE_ACTIVE_PROFILE, + lamzuAtlantisDecodeBattery, + lamzuAtlantisDecodeDpiStage, + lamzuAtlantisDecodeFirmware, + lamzuAtlantisDecodeLiftOffDistance, + lamzuAtlantisDecodePollingRate, + lamzuAtlantisDecodeReply, + lamzuAtlantisEncodeLiftOffDistance, + lamzuAtlantisEncodePollingRate, + lamzuAtlantisEncodeRequest, + lamzuAtlantisFieldIsIntact, + lamzuAtlantisProduct, + lamzuAtlantisSealField, +} from "@openmouse/protocol/lamzu"; +import { pulsarVgnDecodeDpi, pulsarVgnEncodeDpi } from "@openmouse/protocol/pulsar"; + +const bytes = (text: string): Uint8Array => + new Uint8Array(text.trim().split(/\s+/).map((value) => Number.parseInt(value, 16))); + +// Every frame below was captured from a Lamzu Atlantis Mini 4K on firmware +// 1.24, wired (0x3554:0xf50f), on the usage page 0xff02 usage 2 collection. +const CAPTURE = { + batteryRequest: bytes("04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 49"), + batteryReply: bytes("04 00 00 00 02 64 01 10 82 00 00 00 00 00 00 50"), + firmwareRequest: bytes("12 00 00 00 00 00 00 00 00 00 00 00 00 00 00 3b"), + firmwareReply: bytes("12 00 00 00 02 01 24 00 00 00 00 00 00 00 00 14"), + profileReply: bytes("0e 00 00 00 01 00 00 00 00 00 00 00 00 00 00 3e"), + sleepRequest: bytes("08 00 00 ad 02 00 00 00 00 00 00 00 00 00 00 96"), + sleepReply: bytes("08 00 00 ad 02 06 4f 00 00 00 00 00 00 00 00 41"), + stageRequest: bytes("08 00 00 0c 04 00 00 00 00 00 00 00 00 00 00 35"), + stageReply: bytes("08 00 00 0c 04 07 07 00 47 00 00 00 00 00 00 e0"), + colorReply: bytes("08 00 00 34 04 00 ff 00 56 00 00 00 00 00 00 b8"), + rateReply: bytes("08 00 00 00 02 02 53 00 00 00 00 00 00 00 00 ee"), +}; + +test("requests encode to the bytes the mouse was sent", () => { + assert.deepEqual(lamzuAtlantisEncodeRequest({ command: COMMAND.batteryLevel }), CAPTURE.batteryRequest); + assert.deepEqual(lamzuAtlantisEncodeRequest({ command: COMMAND.readVersionId }), CAPTURE.firmwareRequest); + assert.deepEqual( + lamzuAtlantisEncodeRequest({ command: COMMAND.readFlashData, address: FLASH.sleepTime, payload: [0, 0] }), + CAPTURE.sleepRequest, + ); + assert.deepEqual( + lamzuAtlantisEncodeRequest({ command: COMMAND.readFlashData, address: FLASH.dpiValues, payload: [0, 0, 0, 0] }), + CAPTURE.stageRequest, + ); +}); + +test("a request refuses more payload than a frame holds", () => { + assert.throws( + () => lamzuAtlantisEncodeRequest({ command: COMMAND.writeFlashData, address: 0, payload: new Array(11).fill(0) }), + /at most 10 payload bytes/, + ); +}); + +test("replies decode, and a corrupted or truncated frame decodes to null", () => { + const reply = lamzuAtlantisDecodeReply(CAPTURE.sleepReply); + assert.equal(reply?.command, COMMAND.readFlashData); + assert.equal(reply?.error, 0); + assert.equal(reply?.address, FLASH.sleepTime); + assert.deepEqual(reply?.payload.subarray(0, 2), bytes("06 4f")); + + const corrupted = Uint8Array.from(CAPTURE.sleepReply); + corrupted[15] ^= 0xff; + assert.equal(lamzuAtlantisDecodeReply(corrupted), null); + assert.equal(lamzuAtlantisDecodeReply(CAPTURE.sleepReply.subarray(0, 9)), null); +}); + +test("the battery reply carries more bytes than it declares", () => { + const reply = lamzuAtlantisDecodeReply(CAPTURE.batteryReply); + // The mouse says 2 while sending percent, charging flag and two more bytes + // of millivolts; decoding must not trust that length. + assert.equal(reply?.declaredLength, 2); + const battery = lamzuAtlantisDecodeBattery(reply!.payload); + assert.deepEqual(battery, { percent: 100, millivolts: 4226, charging: true }); +}); + +test("a battery reading above 100% is reported as unknown rather than clamped", () => { + assert.equal(lamzuAtlantisDecodeBattery(bytes("ff 00 10 82")).percent, null); + assert.deepEqual(lamzuAtlantisDecodeBattery(bytes("64 00")), { percent: null, millivolts: null, charging: false }); +}); + +test("the firmware reply decodes to the version Lamzu ships for this model", () => { + const reply = lamzuAtlantisDecodeReply(CAPTURE.firmwareReply); + assert.equal(lamzuAtlantisDecodeFirmware("Mouse", reply!.payload), "Mouse v1.24"); + assert.equal(lamzuAtlantisDecodeFirmware("Mouse", new Uint8Array([1])), null); +}); + +test("the active profile is 0-based on the wire", () => { + const reply = lamzuAtlantisDecodeReply(CAPTURE.profileReply); + assert.equal(reply?.payload[0], 0); +}); + +test("flash fields carry a trailing checksum that must sum to 0x55", () => { + const sleep = lamzuAtlantisDecodeReply(CAPTURE.sleepReply)!.payload.subarray(0, 2); + assert.ok(lamzuAtlantisFieldIsIntact(sleep)); + assert.equal(sleep[0]! * LAMZU_ATLANTIS_TIMER_STEP_SECONDS, 60); + + const corrupted = Uint8Array.from(sleep); + corrupted[1] ^= 0x01; + assert.equal(lamzuAtlantisFieldIsIntact(corrupted), false); + + // Sealing reproduces the checksum the mouse itself stored. + assert.deepEqual(lamzuAtlantisSealField([0x06]), [0x06, 0x4f]); + assert.deepEqual(lamzuAtlantisSealField([0x00, 0xff, 0x00]), [0x00, 0xff, 0x00, 0x56]); + assert.ok(lamzuAtlantisFieldIsIntact(new Uint8Array(lamzuAtlantisSealField([0x04])))); +}); + +test("DPI stages decode with the shared CompX 50-step encoding", () => { + const stage = lamzuAtlantisDecodeReply(CAPTURE.stageReply)!.payload.subarray(0, 4); + assert.equal(pulsarVgnDecodeDpi(stage), 400); + assert.deepEqual(pulsarVgnEncodeDpi(400), stage); + // Above 12,800 the count no longer fits one byte and rides in the flags. + assert.equal(pulsarVgnDecodeDpi(pulsarVgnEncodeDpi(26000)), 26000); +}); + +test("a DPI stage colour decodes from the same four-byte field shape", () => { + const color = lamzuAtlantisDecodeReply(CAPTURE.colorReply)!.payload.subarray(0, 3); + assert.deepEqual([...color], [0x00, 0xff, 0x00]); + assert.ok(lamzuAtlantisFieldIsIntact(lamzuAtlantisDecodeReply(CAPTURE.colorReply)!.payload.subarray(0, 4))); +}); + +test("polling rates decode from both encodings of 1,000 Hz", () => { + const rate = lamzuAtlantisDecodeReply(CAPTURE.rateReply)!.payload[0]!; + assert.equal(lamzuAtlantisDecodePollingRate(rate), 500); + assert.equal(lamzuAtlantisDecodePollingRate(0x01), 1000); + assert.equal(lamzuAtlantisDecodePollingRate(0x10), 1000); + assert.equal(lamzuAtlantisDecodePollingRate(0x80), 8000); + assert.equal(lamzuAtlantisDecodePollingRate(0x7f), null); +}); + +test("encoding 1,000 Hz follows the transport's declared family", () => { + assert.equal(lamzuAtlantisEncodePollingRate(1000, "wired"), 0x01); + assert.equal(lamzuAtlantisEncodePollingRate(1000, "receiver"), 0x10); + assert.equal(lamzuAtlantisEncodePollingRate(500, "wired"), 0x02); + assert.equal(lamzuAtlantisEncodePollingRate(4000, "receiver"), 0x40); + assert.equal(lamzuAtlantisEncodePollingRate(3000, "receiver"), null); +}); + +test("the rate family is declared per product, not guessed from the rate list", () => { + // The 1K receiver tops out at 1,000 Hz exactly like the cable, so a ceiling + // test would quietly decide its encoding. It is wireless and unverified. + const oneK = LAMZU_ATLANTIS_PRODUCTS.get(0xf50d)!; + assert.equal(oneK.wireless, true); + assert.equal(oneK.verified, false); + assert.equal(oneK.rateFamily, "wired"); + assert.equal(LAMZU_ATLANTIS_PRODUCTS.get(0xf510)!.rateFamily, "receiver"); +}); + +test("a DPI stage decodes both axes, including one Lamzu set separately", () => { + // 07 07 00 47 is the captured 400 DPI stage; x and y match there. + const symmetric = lamzuAtlantisDecodeDpiStage(bytes("07 07 00 47")); + assert.deepEqual(symmetric, { x: 400, y: 400 }); + + // Separate axes are a valid, correctly checksummed field. pulsarVgnDecodeDpi + // returns null for these because the Pulsar driver only writes axes in + // lockstep, which would make a perfectly good stage read as corrupt. + const asymmetric = lamzuAtlantisSealField([0x07, 0x0f, 0x00]); + assert.equal(pulsarVgnDecodeDpi(new Uint8Array(asymmetric)), null); + assert.deepEqual(lamzuAtlantisDecodeDpiStage(new Uint8Array(asymmetric)), { x: 400, y: 800 }); + + // The flags byte carries each axis's high bits: 2-3 for x, 6-7 for y. + const high = lamzuAtlantisSealField([0xff, 0xff, (1 << 2) | (1 << 6)]); + assert.deepEqual(lamzuAtlantisDecodeDpiStage(new Uint8Array(high)), { x: 25600, y: 25600 }); + + // A corrupt field stays null rather than decoding to something plausible. + assert.equal(lamzuAtlantisDecodeDpiStage(bytes("07 07 00 48")), null); + assert.equal(lamzuAtlantisDecodeDpiStage(bytes("07 07 00")), null); +}); + +test("lift-off is 1 mm or 2 mm on this generation, not Pulsar's three stops", () => { + assert.equal(lamzuAtlantisDecodeLiftOffDistance(0x01), "Low"); + assert.equal(lamzuAtlantisDecodeLiftOffDistance(0x02), "Medium"); + assert.equal(lamzuAtlantisDecodeLiftOffDistance(0x03), null); + assert.equal(lamzuAtlantisEncodeLiftOffDistance("Low"), 0x01); + assert.equal(lamzuAtlantisEncodeLiftOffDistance("High"), null); +}); + +test("the catalog answers only for Lamzu's vendor id", () => { + assert.equal(lamzuAtlantisProduct(LAMZU_ATLANTIS_VENDOR_ID, 0xf50f)?.wireless, false); + assert.equal(lamzuAtlantisProduct(LAMZU_ATLANTIS_VENDOR_ID, 0xf510)?.wireless, true); + // 0xf58f is a VXE transport on the same shared CompX vendor id. + assert.equal(lamzuAtlantisProduct(LAMZU_ATLANTIS_VENDOR_ID, 0xf58f), undefined); + assert.equal(lamzuAtlantisProduct(0x373e, 0xf50f), undefined); +}); + +test("the profile write command is the counterpart of the profile read", () => { + assert.equal(LAMZU_ATLANTIS_WRITE_ACTIVE_PROFILE, COMMAND.getCurrentConfig + 1); +}); diff --git a/src/drivers/pulsar/pulsar-hid.ts b/src/drivers/pulsar/pulsar-hid.ts index bae1b6a..c4fa69a 100644 --- a/src/drivers/pulsar/pulsar-hid.ts +++ b/src/drivers/pulsar/pulsar-hid.ts @@ -16,6 +16,7 @@ import { pulsarVgnEncodeDpi, } from "@openmouse/protocol/pulsar"; import { ATK_COMPX_PRODUCT_IDS } from "../atk/products.ts"; +import { LAMZU_ATLANTIS_PRODUCTS } from "@openmouse/protocol/lamzu"; // The Pulsar 4K Wireless Receiver is sold as a Pulsar product but enumerates // under the shared Teevolution/VGN vendor id (0x3554) and speaks the same @@ -27,6 +28,7 @@ const CLAIMED_VGN_PRODUCT_IDS: ReadonlySet = new Set([ 0xf520, 0xf523, 0xf5bb, 0xf522, // Teevolution (Terra Pro family) 0xfb56, 0xfb57, // VGN Dragonfly F2 Master+ ...ATK_COMPX_PRODUCT_IDS, // VXE wired units + ...LAMZU_ATLANTIS_PRODUCTS.keys(), // Lamzu Atlantis generation ]); const PULSAR_POLLING_RATES = [125, 250, 500, 1000, 2000, 4000, 8000]; diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index 5f90582..69473a5 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; import { DEVICE_DRIVERS } from "./registry.ts"; import { SUPPORTED_HID_FILTERS, VENDOR_ID } from "./vendors.ts"; -import { LAMZU_PRODUCTS } from "@openmouse/protocol/lamzu"; +import { LAMZU_ATLANTIS_PRODUCTS, LAMZU_PRODUCTS } from "@openmouse/protocol/lamzu"; import { ORBITAL_DEVICES } from "@openmouse/protocol/orbital"; const DEVICES_DIR = dirname(fileURLToPath(import.meta.url)); @@ -67,6 +67,7 @@ function candidateProductIds(): number[] { 0x1234, 0xffff, ...LAMZU_PRODUCTS.keys(), + ...LAMZU_ATLANTIS_PRODUCTS.keys(), ...ORBITAL_DEVICES.keys(), ]); for (const filter of SUPPORTED_HID_FILTERS) { diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index bb9f7ea..e019d81 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -8,6 +8,7 @@ import { eggWeCreate, eggWeIsSupported, eggWeSupportScore, isEggWeClient, type E import { FinalmouseHidClient } from "./finalmouse/hid.ts"; import { KeychronM6HidClient } from "./keychron/m6-hid.ts"; import { KeychronNapeHidClient } from "./keychron/nape-hid.ts"; +import { LamzuAtlantisHidClient } from "./lamzu-atlantis/hid.ts"; import { LamzuHidClient } from "./lamzu/hid.ts"; import { LogitechHidppClient } from "./logitech/hidpp.ts"; import { ModdoHidClient } from "./moddo/hid.ts"; @@ -49,7 +50,7 @@ import { KsnakeHidClient } from "./ksnake/hid.ts"; import { MicrosoftHidClient } from "./microsoft/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | KeychronM6HidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | KsnakeHidClient | MicrosoftHidClient; +export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | KeychronM6HidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | KsnakeHidClient | MicrosoftHidClient; export interface DeviceDriver { brand: string; @@ -72,6 +73,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "Logitech", supports: (device) => LogitechHidppClient.isSupported(device), create: (device) => new LogitechHidppClient(device), score: (device) => LogitechHidppClient.supportScore(device) }, { brand: "WLMouse", supports: (device) => WLMouseHidClient.isSupported(device), create: (device) => new WLMouseHidClient(device), score: () => 5 }, { brand: "Lamzu", supports: (device) => LamzuHidClient.isSupported(device), create: (device) => new LamzuHidClient(device), score: () => 5 }, + { brand: "Lamzu", supports: (device) => LamzuAtlantisHidClient.isSupported(device), create: (device) => new LamzuAtlantisHidClient(device), score: () => 5 }, { brand: "moddoMOUSE", supports: (device) => ModdoHidClient.isSupported(device), create: (device) => new ModdoHidClient(device), score: () => 5 }, { brand: "Ninjutso", supports: (device) => NinjutsoHidClient.isSupported(device), create: (device) => new NinjutsoHidClient(device), score: () => 7 }, { brand: "Orbital", supports: (device) => OrbitalHidClient.isSupported(device), create: (device) => new OrbitalHidClient(device), score: () => 6 }, diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index f000657..751325a 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -479,6 +479,8 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ // The Pulsar 4K Wireless Receiver enumerates under the shared Teevolution/VGN // vendor id with a Pulsar-specific product id, so the broad VID-only filter // keeps it visible in the picker; the driver disambiguates by product id. + // Lamzu's Atlantis generation lands here too — 0x3554 is CompX's ODM id, and + // this filter already surfaces it, so it needs no entry of its own. { vendorId: VENDOR_ID.vgn }, { vendorId: VENDOR_ID.endgameGear }, { vendorId: VENDOR_ID.wlmouse }, diff --git a/src/lamzu/atlantis.ts b/src/lamzu/atlantis.ts new file mode 100644 index 0000000..86fdd28 --- /dev/null +++ b/src/lamzu/atlantis.ts @@ -0,0 +1,343 @@ +import { + PULSAR_COMMAND, + PULSAR_CONFIG_PACKET_LENGTH, + PULSAR_CONFIG_REPORT_ID, + PULSAR_FLASH, + pulsarPacketChecksum, +} from "../pulsar/index.js"; + +/** + * Lamzu's "Atlantis" generation — CompX vendor id 0x3554. + * + * These mice do not speak the page/command feature-report protocol in + * `../compx/codec.ts` that the 0x373e and 0x37b0 Lamzu models use. They speak + * the *other* CompX stack: report 8, 16-byte interrupt reports, settings in a + * flash image addressed by byte offset — the same wire protocol this package + * already implements for the Pulsar 4K receiver and the VGN and Teevolution + * mice that share vendor id 0x3554. CompX is the ODM for all of them; the + * user's own device reports `manufacturer: "compx"`. + * + * So the framing, command ids, checksum and 50-step DPI encoding are imported + * from the Pulsar entry point rather than restated here. What this module adds + * is only what is genuinely Lamzu: the product catalog, the flash fields + * Lamzu's firmware uses that the Pulsar driver never reads, the lift-off + * encoding (which differs), and the polling-rate table (which differs). + * + * Verified on a Lamzu Atlantis Mini 4K, firmware 1.24, wired 0x3554:0xf50f — + * see docs/lamzu-atlantis-testing.md. Protocol groundwork: LeadSun/lamzu-cfg + * (Apache-2.0/MIT), reverse-engineered from an Atlantis Mini Pro. + */ + +export const LAMZU_ATLANTIS_VENDOR_ID = 0x3554; + +/** The one collection that answers: interface 1, usage page 0xff02, usage 2. */ +export const LAMZU_ATLANTIS_USAGE_PAGE = 0xff02; +export const LAMZU_ATLANTIS_USAGE = 0x02; + +/** Shared CompX report-8 framing, re-stated under Lamzu names for callers. */ +export const LAMZU_ATLANTIS_REPORT_ID = PULSAR_CONFIG_REPORT_ID; +export const LAMZU_ATLANTIS_PACKET_LENGTH = PULSAR_CONFIG_PACKET_LENGTH; +export const LAMZU_ATLANTIS_PAYLOAD_OFFSET = 5; +export const LAMZU_ATLANTIS_MAX_PAYLOAD = 10; + +/** + * The commands are CompX's, not Lamzu's, so the whole Pulsar set is aliased + * here rather than a Lamzu-specific subset. Only six have been exercised on + * Atlantis hardware: 0x04 battery, 0x07/0x08 flash write/read, 0x0e active + * profile, 0x0f set active profile, and 0x12 firmware version (returned + * 0x01 0x24 — v1.24, matching both the USB bcdDevice and the version Lamzu's + * download page lists for this model). The dongle-only commands (0x15, 0x1d, + * 0x2b) answer status 1 over the cable; the rest — `encryptionData`, + * `deviceOnline`, `setDongleRgb` — are inherited names this driver never + * sends and are unverified on this family. + */ +export const LAMZU_ATLANTIS_COMMAND = PULSAR_COMMAND; + +/** + * Switching the active onboard profile. It is the write counterpart of + * `getCurrentConfig` (0x0e) and is absent from `PULSAR_COMMAND` because the + * Pulsar driver only reads the profile. Documented by lamzu-cfg as + * `WriteActiveProfile` and exercised here on an Atlantis Mini 4K. + */ +export const LAMZU_ATLANTIS_WRITE_ACTIVE_PROFILE = 0x0f; + +/** + * Flash offsets. Everything in `PULSAR_FLASH` matched byte for byte on Lamzu + * hardware — same firmware family, same layout — so those are inherited rather + * than re-typed, and only the fields Lamzu's configurator exposes that the + * Pulsar driver never reads are added here. + * + * `sleepTime` deserves a note: the byte is a count of ten-second units, not + * seconds. Lamzu's configurator showed a 1-minute timeout while address 173 + * held 0x06, and 10 seconds while it held 0x01. + */ +export const LAMZU_ATLANTIS_FLASH = { + ...PULSAR_FLASH, + dpiStageCount: 2, + dpiStageColors: 44, + buttonActions: 96, + highPerformance: 185, +} as const; + +export const LAMZU_ATLANTIS_STAGE_STRIDE = 4; +export const LAMZU_ATLANTIS_MAX_DPI_STAGES = 8; + +/** + * Onboard profiles, 1-based in Lamzu's UI and 0-based on the wire. Probed on + * hardware: writing indices 0-3 is accepted and reads back, while 4 and above + * are rejected with status 1 and leave the mouse on its previous profile. + */ +export const LAMZU_ATLANTIS_PROFILE_COUNT = 4; + +/** PAW3395: 50-26,000 DPI in 50 DPI steps, per Lamzu's own device table. */ +export const LAMZU_ATLANTIS_DPI_STEP = 50; +export const LAMZU_ATLANTIS_MIN_DPI = 50; +export const LAMZU_ATLANTIS_MAX_DPI = 26000; + +/** + * Both timers this firmware stores — the peak-performance window and the sleep + * timeout — count ten-second units in a single byte. + */ +export const LAMZU_ATLANTIS_TIMER_STEP_SECONDS = 10; +export const LAMZU_ATLANTIS_MAX_TIMER_SECONDS = 0xff * LAMZU_ATLANTIS_TIMER_STEP_SECONDS; + +/** Sleep timeouts Lamzu's own configurator offers, in seconds. */ +export const LAMZU_ATLANTIS_SLEEP_OPTIONS = [10, 30, 60, 300, 600, 1800] as const; + +/** + * Which of the two encodings of 1,000 Hz a transport wants. Stored per product + * rather than derived from the rate ceiling: the 1K receiver tops out at + * 1,000 Hz like the cable does, so a ceiling test would silently decide its + * encoding for it, and no 1K receiver has been on hand to check. + */ +export type LamzuAtlantisRateFamily = "wired" | "receiver"; + +export interface LamzuAtlantisProduct { + model: string; + wireless: boolean; + pollingRates: readonly number[]; + rateFamily: LamzuAtlantisRateFamily; + /** False until this exact product id has been exercised on hardware. */ + verified: boolean; +} + +const RATES_WIRED = [125, 250, 500, 1000] as const; +const RATES_4K = [500, 1000, 2000, 4000] as const; + +/** + * Six models share these ids — Atlantis OG V2, Atlantis Mini, Atlantis Mini + * Pro, Thorn, Maya and Paro of this generation — and nothing on the wire + * separates them: same product id, same USB product string ("LAMZU Atlantis + * Pro" on the cable, "LAMZU 4K Receiver" on the dongle), same firmware + * version. Lamzu's own Windows configurator has the same problem and solves it + * by making the user pick the model from a list, so this catalog names the + * family rather than pretending to identify one model. + * + * 0xf50f (the mouse on its cable) is confirmed on hardware. The three receiver + * ids come from Lamzu's shipped device table and nothing more: no receiver has + * been exercised, so neither their rate lists nor the transport itself is + * established, and they stay `verified: false` until one is. + */ +export const LAMZU_ATLANTIS_PRODUCTS: ReadonlyMap = new Map([ + [0xf50f, { model: "Atlantis", wireless: false, pollingRates: RATES_WIRED, rateFamily: "wired", verified: true }], + [0xf50d, { model: "Atlantis", wireless: true, pollingRates: RATES_WIRED, rateFamily: "wired", verified: false }], + [0xf510, { model: "Atlantis", wireless: true, pollingRates: RATES_4K, rateFamily: "receiver", verified: false }], + [0xf517, { model: "Atlantis", wireless: true, pollingRates: RATES_4K, rateFamily: "receiver", verified: false }], +]); + +/** + * The rate byte uses the encoding the other Lamzu generations use, where + * 1,000 Hz appears twice: 0x01 in the wired 125-1000 family and 0x10 in the + * receiver family. Both were read from the same mouse — 0x10 before Lamzu's + * configurator touched the profile and 0x01 after it wrote 1,000 Hz over the + * cable, with the vendor UI reading "1000Hz" both times — and selecting + * 500 Hz in that UI wrote 0x02. + * + * This is why the Lamzu units need their own table rather than + * `pulsarDecodePollingRate`, which reads 0x10 as 2,000 Hz. + */ +export const LAMZU_ATLANTIS_POLLING_RATES = [ + [0x08, 125], [0x04, 250], [0x02, 500], [0x01, 1000], + [0x10, 1000], [0x20, 2000], [0x40, 4000], [0x80, 8000], +] as const; + +export type LamzuAtlantisLiftOffDistance = "Low" | "Medium"; + +/** + * Only two lift-off heights exist on this generation, 1 mm and 2 mm, and they + * encode as 1 and 2. The Pulsar driver's 3/1/2 → Low/Medium/High mapping would + * report a Lamzu's 1 mm as "Medium". + */ +export const LAMZU_ATLANTIS_LIFT_OFF_DISTANCES = [ + [0x01, "Low"], [0x02, "Medium"], +] as const satisfies ReadonlyArray; + +export interface LamzuAtlantisRequest { + command: number; + address?: number; + payload?: readonly number[]; +} + +export interface LamzuAtlantisReply { + command: number; + error: number; + address: number; + /** + * The full payload window, not a slice of `declaredLength` bytes: the + * battery reply declares 2 while carrying 4 meaningful bytes (percent, + * charging, then the millivolts), so trusting the length byte would drop the + * voltage. Callers slice to what they asked for. + */ + payload: Uint8Array; + declaredLength: number; +} + +/** + * Builds the 16 bytes WebHID sends for report 8. The report id is excluded + * from the buffer but included in the checksum, which is what + * `pulsarPacketChecksum` accounts for. + */ +export function lamzuAtlantisEncodeRequest(spec: LamzuAtlantisRequest): Uint8Array { + const payload = spec.payload ?? []; + if (payload.length > LAMZU_ATLANTIS_MAX_PAYLOAD) { + throw new Error(`A CompX report-8 frame carries at most ${LAMZU_ATLANTIS_MAX_PAYLOAD} payload bytes.`); + } + const address = spec.address ?? 0; + const packet = new Uint8Array(LAMZU_ATLANTIS_PACKET_LENGTH); + packet[0] = spec.command; + packet[2] = (address >> 8) & 0xff; + packet[3] = address & 0xff; + packet[4] = payload.length; + packet.set(payload, LAMZU_ATLANTIS_PAYLOAD_OFFSET); + packet[LAMZU_ATLANTIS_PACKET_LENGTH - 1] = pulsarPacketChecksum(packet); + return packet; +} + +/** + * Decodes an input report body (the 16 bytes WebHID delivers, report id + * excluded). Returns null for a truncated frame or a failed checksum so a + * caller waiting on a reply can skip the unsolicited reports this firmware + * emits rather than throwing on them. + */ +export function lamzuAtlantisDecodeReply(body: Uint8Array): LamzuAtlantisReply | null { + if (body.length < LAMZU_ATLANTIS_PACKET_LENGTH) return null; + const packet = body.subarray(0, LAMZU_ATLANTIS_PACKET_LENGTH); + if (pulsarPacketChecksum(packet) !== packet[LAMZU_ATLANTIS_PACKET_LENGTH - 1]) return null; + return { + command: packet[0] ?? 0, + error: packet[1] ?? 0, + address: ((packet[2] ?? 0) << 8) | (packet[3] ?? 0), + payload: packet.slice( + LAMZU_ATLANTIS_PAYLOAD_OFFSET, + LAMZU_ATLANTIS_PAYLOAD_OFFSET + LAMZU_ATLANTIS_MAX_PAYLOAD, + ), + declaredLength: Math.min(packet[4] ?? 0, LAMZU_ATLANTIS_MAX_PAYLOAD), + }; +} + +/** + * A flash field is stored with a trailing checksum byte, so the value bytes + * and that byte together must sum to 0x55. + */ +export function lamzuAtlantisFieldIsIntact(field: Uint8Array): boolean { + let sum = 0; + for (const byte of field) sum = (sum + byte) & 0xff; + return sum === 0x55; +} + +/** Appends the trailing checksum a flash field is stored with. */ +export function lamzuAtlantisSealField(values: readonly number[]): number[] { + let sum = 0; + for (const value of values) sum = (sum + value) & 0xff; + return [...values, (0x55 - sum) & 0xff]; +} + +export interface LamzuAtlantisDpiStage { + x: number; + y: number; +} + +/** + * Decodes a DPI stage field: `[x, y, flags, checksum]`, where the flags byte + * carries each axis's ninth and tenth bits — bits 2-3 for x, bits 6-7 for y, + * which is how `pulsarVgnEncodeDpi` lays them out. + * + * `pulsarVgnDecodeDpi` cannot be used for reads here: it returns null unless + * the two axis bytes are identical, because the Pulsar driver only ever + * writes them in lockstep. A Lamzu configured with separate axes stores a + * perfectly valid field that would then read as corrupt. + */ +export function lamzuAtlantisDecodeDpiStage(field: Uint8Array): LamzuAtlantisDpiStage | null { + if (field.length < LAMZU_ATLANTIS_STAGE_STRIDE || !lamzuAtlantisFieldIsIntact(field)) return null; + const flags = field[2] ?? 0; + const axis = (low: number, high: number) => (((high & 0x03) << 8) + low + 1) * LAMZU_ATLANTIS_DPI_STEP; + return { + x: axis(field[0] ?? 0, flags >> 2), + y: axis(field[1] ?? 0, flags >> 6), + }; +} + +export function lamzuAtlantisDecodePollingRate(raw: number): number | null { + return LAMZU_ATLANTIS_POLLING_RATES.find(([encoded]) => encoded === raw)?.[1] ?? null; +} + +/** + * Picks the byte for a rate. Only 1,000 Hz is ambiguous, and the transport's + * declared family decides it rather than anything inferred from the rate list. + */ +export function lamzuAtlantisEncodePollingRate( + hertz: number, + family: LamzuAtlantisRateFamily, +): number | null { + const candidates = LAMZU_ATLANTIS_POLLING_RATES.filter(([, rate]) => rate === hertz); + if (candidates.length === 0) return null; + const preferred = family === "receiver" + ? candidates.find(([encoded]) => encoded >= 0x10) + : candidates.find(([encoded]) => encoded <= 0x08); + return (preferred ?? candidates[0])?.[0] ?? null; +} + +export function lamzuAtlantisDecodeLiftOffDistance(raw: number): LamzuAtlantisLiftOffDistance | null { + return LAMZU_ATLANTIS_LIFT_OFF_DISTANCES.find(([encoded]) => encoded === raw)?.[1] ?? null; +} + +export function lamzuAtlantisEncodeLiftOffDistance(value: string): number | null { + return LAMZU_ATLANTIS_LIFT_OFF_DISTANCES.find(([, name]) => name === value)?.[0] ?? null; +} + +export interface LamzuAtlantisBattery { + percent: number | null; + millivolts: number | null; + charging: boolean; +} + +/** + * Battery reply payload: [percent, charging, millivolts high, millivolts low]. + * + * lamzu-cfg reads only the millivolts and derives a percentage linearly + * between 3,050 and 4,200 mV, which disagrees with the mouse while it charges: + * at 4,239 mV that estimate says 100%, where both the reported byte and + * Lamzu's configurator said 95%. The reported byte wins; the voltage is kept + * for display. + */ +export function lamzuAtlantisDecodeBattery(payload: Uint8Array): LamzuAtlantisBattery { + if (payload.length < 4) return { percent: null, millivolts: null, charging: false }; + const percent = payload[0] ?? 0; + const millivolts = ((payload[2] ?? 0) << 8) | (payload[3] ?? 0); + return { + percent: percent <= 100 ? percent : null, + millivolts: millivolts > 0 ? millivolts : null, + charging: (payload[1] ?? 0) === 1, + }; +} + +/** Formats the 0x12 firmware reply the same way the Pulsar driver does. */ +export function lamzuAtlantisDecodeFirmware(label: string, payload: Uint8Array): string | null { + if (payload.length < 2) return null; + return `${label} v${payload[0] ?? 0}.${(payload[1] ?? 0).toString(16).padStart(2, "0")}`; +} + +export function lamzuAtlantisProduct(vendorId: number, productId: number): LamzuAtlantisProduct | undefined { + if (vendorId !== LAMZU_ATLANTIS_VENDOR_ID) return undefined; + return LAMZU_ATLANTIS_PRODUCTS.get(productId); +} diff --git a/src/lamzu/index.ts b/src/lamzu/index.ts index 988dedf..150c2a8 100644 --- a/src/lamzu/index.ts +++ b/src/lamzu/index.ts @@ -1,4 +1,5 @@ export * from "../compx/codec.js"; +export * from "./atlantis.js"; export interface LamzuProduct { model: string; wireless: boolean;