From 8dcdd79c731033450e87483d2e67d0fa1d6eaa3b Mon Sep 17 00:00:00 2001 From: Breakdown <169538186+RINZE1@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:27:14 +0800 Subject: [PATCH 1/2] feat: add LunaFury LUNA33 and TYPE33 support --- docs/lunafury-testing.md | 148 ++++++++++++++++++++++++++ src/drivers/lamzu/hid.test.ts | 53 ++++++++- src/drivers/lamzu/hid.ts | 95 +++++++++++++++-- src/drivers/lamzu/lunafury.test.ts | 165 +++++++++++++++++++++++++++++ src/drivers/mouse-types.ts | 4 +- src/drivers/vendors.ts | 7 +- src/lamzu/index.ts | 27 ++++- src/lamzu/lunafury.test.ts | 99 +++++++++++++++++ src/lamzu/lunafury.ts | 90 ++++++++++++++++ 9 files changed, 676 insertions(+), 12 deletions(-) create mode 100644 docs/lunafury-testing.md create mode 100644 src/drivers/lamzu/lunafury.test.ts create mode 100644 src/lamzu/lunafury.test.ts create mode 100644 src/lamzu/lunafury.ts diff --git a/docs/lunafury-testing.md b/docs/lunafury-testing.md new file mode 100644 index 0000000..5c1448a --- /dev/null +++ b/docs/lunafury-testing.md @@ -0,0 +1,148 @@ +# LunaFury LUNA33 / TYPE33 protocol evidence + +This integration is based on the public LunaFury WebHID configurator at +. The site and its source map were inspected on +2026-10-05: + +- `/js/app.14b76f96.js` and `/js/app.14b76f96.js.map` contain the device table + and UI-side command calls. +- `/js/output.xvi3.min.js` contains the `XviUpdater` transport and the concrete + feature-report packets. + +The owner completed full-function testing on LUNA33 with firmware +`0.0.26.0`, over both cable and 8K receiver connections. This verifies the +LUNA33 runtime identities `0x0032` and `0x0033` on that firmware. TYPE33 +identities and packet layouts are source-verified but have not been tested +on hardware. + +The owner has not supplied separate results for power-cycle persistence, +other firmware versions, or native-Bridge end-to-end testing. The stated +Corded/20 kHz behavior has not been independently measured. + +## Runtime identities + +All devices use the shared CompX VID `0x373e`. + +| Model | Connection | PID | Polling rates exposed by LunaFury | +| --- | --- | --- | --- | +| LUNA33 | cable | `0x0032` | 125, 250, 500, 1000 Hz | +| LUNA33 | 8K receiver | `0x0033` | 125, 250, 500, 1000, 2000, 4000, 8000 Hz | +| TYPE33 | cable | `0x0054` | 125, 250, 500, 1000, 2000, 4000, 8000 Hz | +| TYPE33 | 8K receiver | `0x0084` | 125, 250, 500, 1000, 2000, 4000, 8000 Hz | + +The configurator also lists `0xb032`, `0xb033`, `0xb054`, and `0xb084` as +bootloader PIDs. They are intentionally excluded: firmware-update identities +must not be claimed by the normal settings driver. + +Both models advertise a 30,000 DPI ceiling. LUNA33 exposes up to six DPI +stages and TYPE33 up to five. The stage-read request carries that model limit, +so each product profile supplies the correct maximum while the reply still +determines the active stage list at runtime. + +## Protocol match + +The configurator sends report ID 0 with a 64-byte payload. That is the same +CompX/XVI envelope implemented by `src/compx/codec.ts` and +`src/drivers/lamzu/hid.ts`. + +| Setting | Read | Write / encoding | +| --- | --- | --- | +| Firmware | page `0x00`, command `0x81` | read only | +| Battery | page `0x00`, command `0x83` | read only | +| Active profile | page `0x00`, command `0x85` | read only | +| Polling rate | page `0x01`, command `0x80` | command `0x00`; `08/04/02/01/20/40/80` = 125 through 8000 Hz | +| Sleep | page `0x00`, command `0x87` | command `0x07` | +| Debounce | page `0x00`, command `0x88` | command `0x08` | +| Lift-off distance | page `0x01`, command `0x88` | command `0x08`; `87/01/02` = low/medium/high | +| DPI stages | page `0x01`, command `0x81` | command `0x01` | +| Angle snap | page `0x01`, command `0x84` | command `0x04` | +| Motion sync | page `0x01`, command `0x89` | command `0x09` | +| Ripple control | page `0x01`, command `0x8a` | command `0x0a` | +| Competitive Mode / 竞技模式 | page `0x01`, command `0x8b` | command `0x0b`; vendor `setHyperMode`, shared field `hyperMode` | +| Tracking Mode / 追踪模式 | page `0x01`, command `0x93` | command `0x13`; vendor `setTrackingMode`, shared field `performanceMode` | +| Sensor angle | page `0x01`, command `0x94`, length 2 | command `0x14`; signed byte, −30° through +30° | +| Lightning Trigger | page `0x00`, command `0x98`, length 4 | command `0x18`; 0 = off, 1 = left priority, 2 = right priority | +| Per-button latency | page `0x00`, command `0x92`, length 19 | command `0x12`; button IDs 1/2/3 = left/right/middle | +| Wheel anti-mistouch | page `0x00`, command `0x99`, length 4 | command `0x19`; enable byte and big-endian window in milliseconds | + +The cable transport rewrites mouse target `0x02` to target `0x00`. The 2.4 GHz +receiver keeps mouse commands on target `0x02`, while receiver firmware remains +on target `0x00`. These are represented by the product profiles rather than a +forked LunaFury protocol implementation. + +### LunaFury-specific controls + +LunaFury labels follow its official `language.js` and `patternModule.vue`: +`angleSnapping` is 直线修正, `hyperMode` is 竞技模式, and `performanceMode` +is 追踪模式. Motion Sync and Ripple Control use 运动同步 and 波纹控制 in +Chinese. These are display overrides for LunaFury only; commands and labels +for other brands remain unchanged. + +The owner clarified the hardware semantics on 2026-10-05: Competitive Mode +is Corded mode and Tracking Mode locks sensor sampling at 20 kHz. This +description is owner-provided, not an independently measured result. +The UI mirrors the vendor's `v-if="competition"`: tracking is shown only +while competitive mode is confirmed on. Hiding it preserves its stored +value. For staged changes, competitive mode enables before tracking edits +and disables after them; other brands retain their existing ordering. + +`XviUpdater.hidIndex` is 1: received WebHID buffers do not include the report +ID. Decoders use the payload after the six-byte CompX header. Lightning mode +and sensor angle are payload byte 1; button ID is byte 2 and button latency +is bytes 3–4; wheel enable is byte 1 and its window is bytes 2–3. + +Left/right latency uses 0–15 ms in 1 ms steps; middle-button debounce uses +1–30 ms. The 19-byte latency write payload is +`[profile, 0, buttonId, hi, lo, 0, 0, hi, lo, 0, 0, 0, 20, 0, 0, 0, 20, 0, 0]`. +Wheel guard uses 20–200 ms in 20 ms steps. A disabled zero window is a valid +read, not a guessed default; enabling it from that state proposes 100 ms. +Writing a disabled zero window is supported so restoring a Games profile +does not invent a different prior value. + +All added setters read back the value and reject a mismatch. Optional reads +that fail or return invalid values leave their controls absent, while basic +device settings remain available. Only the LunaFury product profiles issue +these extra commands; other CompX brands are unchanged. + +The UI places Lightning Trigger, independent left/right latency, middle +debounce, and wheel guard on the Buttons tab. Sensor angle is in Processing +on the Performance tab. As in the vendor UI, left/right priority disables +the corresponding latency slider. Turning priority off is applied before +staged latency edits, and enabling priority is applied after them. These +settings can also be saved in Games profiles as a partial LunaFury namespace. +Each profile restores only the controls it changed, preserving unrelated +settings and valid zero values. + +## Required hardware check + +Connect each available cable/receiver identity in Chromium and confirm: + +1. OpenMouse labels it `LunaFury LUNA33` or `LunaFury TYPE33`, never Attack + Shark. +2. Firmware, battery, DPI stages, polling, LOD, sleep, debounce, motion sync, + angle snap, and ripple control read successfully. +3. Change one reversible setting at a time, read it back, then restore the + original value. +4. Confirm only the rates in the table are offered for that connection. +5. Confirm the driver refuses bootloader/DFU identities. The shared broad + VID filter may still let Chromium show them in its native picker; do not + select or send settings commands to an upgrade-mode interface. +6. With Lightning Trigger off, test independent left/right latencies and + middle debounce, refresh/reconnect, and compare their values with the + vendor configurator. Test each priority mode separately, restoring the + initial mode afterward. +7. Test sensor angles −1°, 0°, and +1° first. Confirm direction and persistence + on hardware, then restore the original angle. +8. Test wheel guard off and on at 20/100/200 ms. Confirm the actual firmware + behavior, then restore both the original enable state and window. +9. Save a Games profile with a single LunaFury control, apply it, and restore + the prior settings. Confirm unrelated controls remain unchanged, including + the originally disabled zero wheel window if reported by this firmware. +10. Confirm Competitive Mode controls Tracking Mode's visibility, including + staged on/off changes, without silently resetting its stored value. + +Automated coverage includes all four runtime IDs, cable/receiver targeting, +profile 3, full packet fixtures, signed angles, invalid ranges, unsupported +reads, ignored writes, and the other-brand guard. LUNA33 hardware testing +is recorded above; TYPE33 still needs a real-device pass. The owner's +full-function report does not include item-by-item results for this checklist. diff --git a/src/drivers/lamzu/hid.test.ts b/src/drivers/lamzu/hid.test.ts index 78d2943..d73601a 100644 --- a/src/drivers/lamzu/hid.test.ts +++ b/src/drivers/lamzu/hid.test.ts @@ -3,12 +3,14 @@ import test from "node:test"; import { LamzuHidClient } from "./hid.ts"; import { AttackSharkHidClient } from "../attackshark/hid.ts"; -import { deviceBrand } from "../registry.ts"; +import { DEVICE_DRIVERS, deviceBrand } from "../registry.ts"; import { ATTACKSHARK_PRODUCT_IDS, LAMZU_INCA_PRODUCTS, LAMZU_INCA_VENDOR_ID, + LAMZU_PRODUCTS, LAMZU_VENDOR_ID, + LUNAFURY_PRODUCT_IDS, lamzuProduct, } from "@openmouse/protocol/lamzu"; @@ -259,6 +261,55 @@ test("the Attack Shark R6 and R8 run on the CompX driver, not the Attack Shark o } }); +test("the LunaFury catalog contains only the four runtime identities", () => { + assert.deepEqual([...LUNAFURY_PRODUCT_IDS], [0x0032, 0x0033, 0x0054, 0x0084]); + for (const bootloader of [0xb032, 0xb033, 0xb054, 0xb084]) { + assert.equal(LAMZU_PRODUCTS.has(bootloader), false); + } +}); + +test("LunaFury LUNA33 and TYPE33 use the CompX driver with their own brand", async () => { + const cases = [ + [0x0032, "LUNA33", "Wired", 6, [125, 250, 500, 1000]], + [0x0033, "LUNA33", "Wireless", 6, [125, 250, 500, 1000, 2000, 4000, 8000]], + [0x0054, "TYPE33", "Wired", 5, [125, 250, 500, 1000, 2000, 4000, 8000]], + [0x0084, "TYPE33", "Wireless", 5, [125, 250, 500, 1000, 2000, 4000, 8000]], + ] as const; + + for (const [productId, model, connectionType, maxDpiStages, pollingRates] of cases) { + const { device, sent } = fakeR5Ultra(productId); + assert.equal(LamzuHidClient.isSupported(device), true); + assert.equal(AttackSharkHidClient.isSupported(device), false); + assert.deepEqual( + DEVICE_DRIVERS.filter((driver) => driver.supports(device)).map((driver) => driver.brand), + ["Lamzu"], + ); + + const client = new LamzuHidClient(device); + const status = await client.readStatus(); + assert.equal(status.brand, "LunaFury"); + assert.equal(status.name, `LunaFury ${model}`); + assert.equal(status.ui?.family, "lunafury"); + assert.equal(deviceBrand(client), "LunaFury"); + assert.equal(status.connectionType, connectionType); + assert.equal(client.getDpiOptions().at(-1), 30000); + assert.deepEqual(status.supportedPollingRates, pollingRates); + const dpiRead = sent.find((packet) => packet[4] === 0x01 && packet[5] === 0x81); + assert.equal(dpiRead?.[7], maxDpiStages); + + if (connectionType === "Wired") { + assert.ok(sent.every((packet) => packet[2] === 0x00)); + } else { + const dongleFirmware = sent.filter((packet) => + packet[2] === 0x00 && packet[4] === 0x00 && packet[5] === 0x81); + const mouseRequests = sent.filter((packet) => !dongleFirmware.includes(packet)); + assert.equal(dongleFirmware.length, 1); + assert.ok(mouseRequests.length > 0); + assert.ok(mouseRequests.every((packet) => packet[2] === 0x02)); + } + } +}); + /** * 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: diff --git a/src/drivers/lamzu/hid.ts b/src/drivers/lamzu/hid.ts index 19a8dd8..6691d25 100644 --- a/src/drivers/lamzu/hid.ts +++ b/src/drivers/lamzu/hid.ts @@ -13,6 +13,16 @@ import { LAMZU_POLLING_RATES as POLLING_RATES, LAMZU_VENDOR_IDS, lamzuProduct, + LUNAFURY_READ, + LUNAFURY_WRITE, + lunafuryDecodeAngle, + lunafuryDecodeLightning, + lunafuryDecodeButtonDebounce, + lunafuryDecodeWheelGuard, + type LunaFuryButton, + type LunaFuryLightningMode, + type LunaFurySettings, + type LunaFuryWheelGuard, type CompaxDpiStage, type LamzuProduct, } from "@openmouse/protocol/lamzu"; @@ -72,8 +82,8 @@ const PROFILE_READ = { ({ target: TARGET.mouse, page: PAGE.device, command: 0x87, length: 0x03, args: [profile] }), debounce: (profile: number): LamzuRequest => ({ target: TARGET.mouse, page: PAGE.device, command: 0x88, length: 0x02, args: [profile] }), - dpiStages: (profile: number): LamzuRequest => - ({ target: TARGET.mouse, page: PAGE.profile, command: 0x81, length: 0x0a, args: [profile, 0x06] }), + dpiStages: (profile: number, maxStages: number): LamzuRequest => + ({ target: TARGET.mouse, page: PAGE.profile, command: 0x81, length: 0x0a, args: [profile, maxStages] }), activeStage: (profile: number): LamzuRequest => ({ target: TARGET.mouse, page: PAGE.profile, command: 0x82, length: 0x02, args: [profile] }), pollingRate: (profile: number): LamzuRequest => @@ -195,7 +205,10 @@ export class LamzuHidClient { displayName(): string { const known = this.profile(); - return known ? `${this.deviceBrand()} ${known.model}` : this.device.productName || "Lamzu"; + if (!known) return this.device.productName || "Lamzu"; + const color = known.brand === "LunaFury" + ? this.device.productName?.match(/\b(EL|ES)\b/i)?.[1]?.toUpperCase() : undefined; + return `${this.deviceBrand()} ${known.model}${color ? ` ${color}` : ""}`; } deviceBrand(): MouseStatus["brand"] { @@ -206,6 +219,10 @@ export class LamzuHidClient { return this.profile()?.maxDpi ?? DPI_MAX; } + maxDpiStages(): number { + return this.profile()?.maxDpiStages ?? 6; + } + getSleepOptions(): readonly number[] { return this.profile()?.sleepOptions ?? SLEEP_SECONDS; } @@ -249,7 +266,7 @@ export class LamzuHidClient { this.activeProfile = profile; const sleepTimeout = await this.request(PROFILE_READ.sleepTimeout(profile)).catch(() => null); const debounce = await this.request(PROFILE_READ.debounce(profile)).catch(() => null); - const stages = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile))); + const stages = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile, this.maxDpiStages()))); const activeStage = this.stageIndex((await this.request(PROFILE_READ.activeStage(profile)))[1], stages.length); const pollingRate = await this.request(PROFILE_READ.pollingRate(profile)); const liftOffDistance = await this.request(PROFILE_READ.liftOffDistance(profile)); @@ -262,6 +279,8 @@ export class LamzuHidClient { const dongleLed = this.profile()?.dongleLed ? await this.request(PROFILE_READ.dongleLed(profile)).catch(() => null) : null; + const lunafury = this.deviceBrand() === "LunaFury" + ? await this.readLunaFurySettings(profile) : undefined; const stage = stages[activeStage]; if (!stage) throw new Error("The mouse did not report any DPI stages."); return this.lastStatus = { @@ -285,6 +304,7 @@ export class LamzuHidClient { performanceMode: competitiveMode ? competitiveMode[1] === 1 : null, hyperMode: hyperMode ? hyperMode[1] === 1 : null, rippleControl: rippleControl ? rippleControl[1] === 1 : null, + ...(lunafury ? { lunafury: lunafury.settings, angleTuning: lunafury.angle } : {}), dongleLedEnabled: dongleLed ? dongleLed[1] === 1 : null, connectionType: wireless ? "Wireless" : "Wired", connectionDetail: wireless ? "2.4 GHz receiver" : "Wired USB", @@ -308,6 +328,67 @@ export class LamzuHidClient { }; } + private requireLunaFury(): void { + if (this.deviceBrand() !== "LunaFury") throw new Error("This control is only available on LunaFury mice."); + } + + private async readLunaFurySettings(profile: number): Promise<{ settings: LunaFurySettings; angle: number | null }> { + const optional = (spec: LamzuRequest) => this.request({ ...spec, attempts: 2 }).catch(() => null); + const lightningMode = lunafuryDecodeLightning(await optional(LUNAFURY_READ.lightning(profile))); + const leftDebounceMs = lunafuryDecodeButtonDebounce(await optional(LUNAFURY_READ.buttonDebounce(profile, "left")), "left"); + const rightDebounceMs = lunafuryDecodeButtonDebounce(await optional(LUNAFURY_READ.buttonDebounce(profile, "right")), "right"); + const middleDebounceMs = lunafuryDecodeButtonDebounce(await optional(LUNAFURY_READ.buttonDebounce(profile, "middle")), "middle"); + const wheelGuard = lunafuryDecodeWheelGuard(await optional(LUNAFURY_READ.wheelGuard(profile))); + const angle = lunafuryDecodeAngle(await optional(LUNAFURY_READ.angle(profile))); + return { settings: { lightningMode, leftDebounceMs, rightDebounceMs, middleDebounceMs, wheelGuard }, angle }; + } + + private patchLunaFury(changes: Partial): void { + this.patch({ lunafury: { ...this.lastStatus?.lunafury, ...changes } }); + } + + async setAngleTuning(degrees: number): Promise { + this.requireLunaFury(); + const profile = await this.currentProfile(); + await this.request(LUNAFURY_WRITE.angle(profile, degrees)); + const confirmed = lunafuryDecodeAngle(await this.request(LUNAFURY_READ.angle(profile))); + if (confirmed !== degrees) throw new Error(`The mouse did not confirm the ${degrees}° sensor angle.`); + this.patch({ angleTuning: confirmed }); + return confirmed; + } + + async setLunaFuryLightningMode(mode: LunaFuryLightningMode): Promise { + this.requireLunaFury(); + const profile = await this.currentProfile(); + await this.request(LUNAFURY_WRITE.lightning(profile, mode)); + const confirmed = lunafuryDecodeLightning(await this.request(LUNAFURY_READ.lightning(profile))); + if (confirmed !== mode) throw new Error("The mouse did not confirm the Lightning Trigger mode."); + this.patchLunaFury({ lightningMode: confirmed }); + return confirmed; + } + + async setLunaFuryButtonDebounce(button: LunaFuryButton, milliseconds: number): Promise { + this.requireLunaFury(); + const profile = await this.currentProfile(); + await this.request(LUNAFURY_WRITE.buttonDebounce(profile, button, milliseconds)); + const confirmed = lunafuryDecodeButtonDebounce(await this.request(LUNAFURY_READ.buttonDebounce(profile, button)), button); + if (confirmed !== milliseconds) throw new Error(`The mouse did not confirm ${milliseconds} ms latency for the ${button} button.`); + this.patchLunaFury({ [`${button}DebounceMs`]: confirmed }); + return confirmed; + } + + async setLunaFuryWheelGuard(guard: LunaFuryWheelGuard): Promise { + this.requireLunaFury(); + const profile = await this.currentProfile(); + await this.request(LUNAFURY_WRITE.wheelGuard(profile, guard)); + const confirmed = lunafuryDecodeWheelGuard(await this.request(LUNAFURY_READ.wheelGuard(profile))); + if (!confirmed || confirmed.enabled !== guard.enabled || confirmed.windowMs !== guard.windowMs) { + throw new Error("The mouse did not confirm the wheel guard settings."); + } + this.patchLunaFury({ wheelGuard: confirmed }); + return confirmed; + } + async setPollingRate(pollingRateHz: number): Promise { const encoded = POLLING_RATES.find(([, hertz]) => hertz === pollingRateHz); if (!encoded || !this.getSupportedPollingRates().includes(pollingRateHz)) { @@ -422,7 +503,7 @@ export class LamzuHidClient { } } const profile = await this.currentProfile(); - const stages = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile))); + const stages = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile, this.maxDpiStages()))); const active = this.stageIndex((await this.request(PROFILE_READ.activeStage(profile)))[1], stages.length); if (!stages[active]) throw new Error("The mouse did not report any DPI stages."); stages[active] = { x: dpi, y: dpiY }; @@ -430,7 +511,9 @@ export class LamzuHidClient { stages.length, ...stages.flatMap((stage) => [stage.x >> 8 & 0xff, stage.x & 0xff, stage.y >> 8 & 0xff, stage.y & 0xff]), ]); - const confirmed = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile)))[active]; + const confirmed = this.decodeDpiStages( + await this.request(PROFILE_READ.dpiStages(profile, this.maxDpiStages())), + )[active]; if (!confirmed || confirmed.x !== dpi || confirmed.y !== dpiY) { throw new Error(`The mouse kept ${confirmed ? confirmed.x.toLocaleString() : "an unknown"} DPI instead of ${dpi.toLocaleString()}.`); } diff --git a/src/drivers/lamzu/lunafury.test.ts b/src/drivers/lamzu/lunafury.test.ts new file mode 100644 index 0000000..8b9223b --- /dev/null +++ b/src/drivers/lamzu/lunafury.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { LamzuHidClient } from "./hid.ts"; + +const globals = globalThis as { window?: { setTimeout: typeof setTimeout } }; +globals.window ??= { setTimeout }; +const EXTRA_READS = new Set(["1:148", "0:152", "0:146", "0:153"]); + +function fakeLunaFury(productId = 0x0033, options: { unsupported?: boolean; ignoreWrites?: boolean } = {}) { + const sent: Uint8Array[] = []; + const state = { angle: -12, lightning: 1, latency: [0, 3, 7, 10], wheel: true, window: 200 }; + const device = { + vendorId: 0x373e, productId, productName: "LunaFury", opened: true, + collections: [{ usagePage: 0xff00, usage: 1, type: 1, children: [], + featureReports: [{ reportId: 0, items: [{ reportSize: 8, reportCount: 64 }] }], + inputReports: [], outputReports: [] }], + open: async () => {}, close: async () => {}, + addEventListener: () => {}, removeEventListener: () => {}, + sendFeatureReport: async (id: number, packet: Uint8Array) => { + assert.equal(id, 0); + sent.push(new Uint8Array(packet)); + if (options.ignoreWrites) return; + const page = packet[4], command = packet[5]; + if (page === 1 && command === 0x14) state.angle = packet[7]! > 127 ? packet[7]! - 256 : packet[7]!; + if (page === 0 && command === 0x18) state.lightning = packet[7]!; + if (page === 0 && command === 0x12) state.latency[packet[8]!] = (packet[9]! << 8) | packet[10]!; + if (page === 0 && command === 0x19) { state.wheel = packet[7] === 1; state.window = (packet[8]! << 8) | packet[9]!; } + }, + receiveFeatureReport: async () => { + const packet = sent.at(-1)!; + const page = packet[4]!, command = packet[5]!, profile = packet[6]!; + const key = `${page}:${command}`; + const reply = new Uint8Array(64); + reply[0] = options.unsupported && EXTRA_READS.has(key) ? 0xa2 : 0xa1; + reply[4] = page; reply[5] = command; + let payload: number[] = [profile, 0]; + if (key === "0:133") payload = [3]; + if (key === "0:129") payload = [0, 0, 1, 2]; + if (key === "0:131") payload = [0, 80]; + if (key === "0:135") payload = [profile, 1, 44]; + if (key === "1:129") payload = [profile, 1, 3, 32, 3, 32]; + if (key === "1:128" || key === "1:136") payload = [profile, 1]; + if (key === "1:148") payload = [profile, state.angle & 255]; + if (key === "0:152") payload = [profile, state.lightning, 0, 0]; + if (key === "0:146") payload = [profile, 0, packet[8]!, 0, state.latency[packet[8]!]!, ...Array(14).fill(0)]; + if (key === "0:153") payload = [profile, +state.wheel, state.window >> 8, state.window & 255]; + reply[3] = payload.length; reply.set(payload, 6); + // Non-zero byteOffset exercises DataView framing without a report ID. + const buffer = new Uint8Array(70); buffer.set(reply, 3); + return new DataView(buffer.buffer, 3, 64); + }, + } as unknown as HIDDevice; + return { device, sent, state }; +} + +for (const [pid, target] of [[0x0032, 0], [0x0033, 2], [0x0054, 0], [0x0084, 2]] as const) { + test(`LunaFury 0x${pid.toString(16)} reads independent settings on the active profile`, async () => { + const { device, sent } = fakeLunaFury(pid); + const client = new LamzuHidClient(device); + const status = await client.readStatus(); + assert.equal(status.brand, "LunaFury"); + assert.equal(status.angleTuning, -12); + assert.deepEqual(status.lunafury, { + lightningMode: 1, leftDebounceMs: 3, rightDebounceMs: 7, + middleDebounceMs: 10, wheelGuard: { enabled: true, windowMs: 200 }, + }); + const extra = sent.filter((packet) => EXTRA_READS.has(`${packet[4]}:${packet[5]}`)); + assert.equal(extra.length, 6); + assert.ok(extra.every((packet) => packet[2] === target && packet[6] === 3)); + assert.deepEqual(extra.filter((packet) => packet[5] === 0x92).map((packet) => packet[8]), [1, 2, 3]); + const count = sent.length; + await client.readStatus(true); + assert.ok(sent.slice(count).every((packet) => !EXTRA_READS.has(`${packet[4]}:${packet[5]}`)), "live polling must not repeatedly read static controls"); + }); + + test(`LunaFury 0x${pid.toString(16)} writes and verifies all added controls`, async () => { + const { device, sent } = fakeLunaFury(pid); + const client = new LamzuHidClient(device); + await client.readStatus(); + const before = sent.length; + assert.equal(await client.setAngleTuning(-30), -30); + assert.equal(await client.setLunaFuryLightningMode(2), 2); + assert.equal(await client.setLunaFuryButtonDebounce("left", 15), 15); + assert.equal(await client.setLunaFuryButtonDebounce("right", 0), 0); + assert.equal(await client.setLunaFuryButtonDebounce("middle", 1), 1); + assert.deepEqual(await client.setLunaFuryWheelGuard({ enabled: false, windowMs: 100 }), { enabled: false, windowMs: 100 }); + const writes = sent.slice(before).filter((packet) => packet[5]! < 0x80); + assert.equal(writes.length, 6); + assert.ok(writes.every((packet) => packet[2] === target && packet[6] === 3)); + const status = await client.readStatus(); + assert.equal(status.angleTuning, -30); + assert.deepEqual(status.lunafury, { + lightningMode: 2, leftDebounceMs: 15, rightDebounceMs: 0, + middleDebounceMs: 1, wheelGuard: { enabled: false, windowMs: 100 }, + }); + }); +} + +test("unsupported LunaFury controls do not break the base device status", async () => { + const { device } = fakeLunaFury(0x0033, { unsupported: true }); + const status = await new LamzuHidClient(device).readStatus(); + assert.equal(status.dpi, 800); + assert.equal(status.batteryPercent, 80); + assert.equal(status.angleTuning, null); + assert.ok(Object.values(status.lunafury!).every((value) => value === undefined)); +}); + +test("LunaFury keeps only explicitly reported EL/ES color suffixes", () => { + for (const pid of [0x0032, 0x0033, 0x0054, 0x0084]) { + const { device } = fakeLunaFury(pid); + const model = pid === 0x0032 || pid === 0x0033 ? "LUNA33" : "TYPE33"; + for (const suffix of ["EL", "ES", "el", "es"]) { + Object.assign(device, { productName: `LunaFury ${model} ${suffix}` }); + assert.equal(new LamzuHidClient(device).displayName(), `LunaFury ${model} ${suffix.toUpperCase()}`); + } + for (const name of ["LunaFury", "WIRELESS RECEIVER", "ELSE", "SHELL", undefined]) { + Object.assign(device, { productName: name }); + assert.equal(new LamzuHidClient(device).displayName(), `LunaFury ${model}`); + } + } + const { device } = fakeLunaFury(0x006a); + Object.assign(device, { productName: "CRDRAKO KO-ONE ES" }); + assert.equal(new LamzuHidClient(device).displayName(), "CRDRAKO KO-ONE"); +}); + +test("a disabled zero wheel window can be restored exactly", async () => { + const { device, state } = fakeLunaFury(); + state.wheel = false; + state.window = 0; + const client = new LamzuHidClient(device); + const before = (await client.readStatus()).lunafury!.wheelGuard!; + await client.setLunaFuryWheelGuard({ enabled: true, windowMs: 100 }); + assert.deepEqual(await client.setLunaFuryWheelGuard(before), { enabled: false, windowMs: 0 }); + assert.deepEqual((await client.readStatus()).lunafury!.wheelGuard, before); +}); + +test("ignored writes are rejected instead of updating the cached status", async () => { + const { device } = fakeLunaFury(0x0033, { ignoreWrites: true }); + const client = new LamzuHidClient(device); + await client.readStatus(); + await assert.rejects(client.setAngleTuning(10), /did not confirm/); + await assert.rejects(client.setLunaFuryLightningMode(0), /did not confirm/); + await assert.rejects(client.setLunaFuryButtonDebounce("left", 5), /did not confirm/); + await assert.rejects(client.setLunaFuryButtonDebounce("right", 5), /did not confirm/); + await assert.rejects(client.setLunaFuryButtonDebounce("middle", 5), /did not confirm/); + await assert.rejects(client.setLunaFuryWheelGuard({ enabled: false, windowMs: 20 }), /did not confirm/); + const status = await client.readStatus(); + assert.equal(status.angleTuning, -12); + assert.equal(status.lunafury?.lightningMode, 1); + assert.equal(status.lunafury?.leftDebounceMs, 3); + assert.deepEqual(status.lunafury?.wheelGuard, { enabled: true, windowMs: 200 }); +}); + +test("other CompX brands never receive LunaFury commands", async () => { + const { device, sent } = fakeLunaFury(0x006a); + const client = new LamzuHidClient(device); + assert.equal((await client.readStatus()).lunafury, undefined); + const count = sent.length; + await assert.rejects(client.setAngleTuning(1), /only available on LunaFury/); + await assert.rejects(client.setLunaFuryLightningMode(1), /only available on LunaFury/); + await assert.rejects(client.setLunaFuryButtonDebounce("left", 1), /only available on LunaFury/); + await assert.rejects(client.setLunaFuryWheelGuard({ enabled: true, windowMs: 100 }), /only available on LunaFury/); + assert.equal(sent.length, count); + assert.ok(sent.every((packet) => !EXTRA_READS.has(`${packet[4]}:${packet[5]}`))); +}); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 9ccba98..c078e81 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -153,8 +153,10 @@ export interface AtkReceiverInfo { } export interface MouseStatus { - brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master"; + brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master"; name: string; + /** Optional LunaFury controls, populated only after successful device reads. */ + lunafury?: import("../lamzu/lunafury.js").LunaFurySettings; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; batteryPercent: number | null; diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index eb002e9..d45d15c 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -134,6 +134,7 @@ export const VENDOR_ID = { endgameGear: 0x3367, wlmouse: 0x36a7, lamzu: 0x373e, + lunafury: 0x373e, bytech: 0x372e, lamzuInca: LAMZU_INCA_VENDOR_ID, attackshark: 0x373e, @@ -761,9 +762,9 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ { vendorId: VENDOR_ID.vgn }, { vendorId: VENDOR_ID.endgameGear }, { vendorId: VENDOR_ID.wlmouse }, - // 0x373e is the shared CompX ODM vendor id behind Lamzu, CRDRAKO, and - // Attack Shark. The broad filter surfaces all of them; each driver rejects - // interfaces that lack the feature-report-0 control channel. + // 0x373e is the shared CompX ODM vendor id behind Lamzu, CRDRAKO, + // LunaFury, and Attack Shark. The broad filter surfaces all of them; each + // driver rejects interfaces that lack the feature-report-0 control channel. { vendorId: VENDOR_ID.lamzu }, ...LAMZU_INCA_HID_FILTERS, { vendorId: VENDOR_ID.bytech, usagePage: 0xff00, usage: 0x0001 }, diff --git a/src/lamzu/index.ts b/src/lamzu/index.ts index b655bfd..7b69be4 100644 --- a/src/lamzu/index.ts +++ b/src/lamzu/index.ts @@ -1,13 +1,15 @@ export * from "../compx/codec.js"; export * from "./atlantis.js"; +export * from "./lunafury.js"; export interface LamzuProduct { model: string; wireless: boolean; pollingRates: readonly number[]; - brand?: "Lamzu" | "CRDRAKO" | "Attack Shark"; + brand?: "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury"; uiFamily?: string; mouseTarget?: number; maxDpi?: number; + maxDpiStages?: number; sleepOptions?: readonly number[]; /** The receiver answers the dongle LED on/off command (page 0x02, 0x04/0x84). */ dongleLed?: boolean; @@ -17,6 +19,11 @@ const RATES_1K = [125, 250, 500, 1000] as const; const RATES_8K = [500, 1000, 2000, 4000, 8000] as const; const RATES_8K_FULL = [125, 250, 500, 1000, 2000, 4000, 8000] as const; export const CRDRAKO_PRODUCT_IDS = [0x006a, 0x006b] as const; +// LunaFury's public web configurator (mouse.lunafury.games, inspected +// 2026-10-05) names these runtime identities and uses the same 64-byte CompX +// feature-report framing as this driver. Its B032/B033/B054/B084 identities +// are firmware-update bootloaders and are intentionally not catalogued here. +export const LUNAFURY_PRODUCT_IDS = [0x0032, 0x0033, 0x0054, 0x0084] as const; // Attack Shark Core 2.0.7.9 (Config/xvi_models.xlsx) lists all three on the // same CompX platform: wired 125-1000, 8K receiver 125-8000, DPIMax 42000. // Only the R5 Ultra has been seen on hardware. @@ -33,6 +40,24 @@ export const LAMZU_PRODUCTS: ReadonlyMap = new Map([ brand: "CRDRAKO", model: "KO-ONE", wireless: true, pollingRates: RATES_8K_FULL, mouseTarget: 0x02, uiFamily: "crdrako", }], + [0x0032, { + brand: "LunaFury", model: "LUNA33", wireless: false, + pollingRates: RATES_1K, mouseTarget: 0x00, maxDpi: 30000, maxDpiStages: 6, + uiFamily: "lunafury", + }], + [0x0033, { + brand: "LunaFury", model: "LUNA33", wireless: true, + pollingRates: RATES_8K_FULL, maxDpi: 30000, maxDpiStages: 6, uiFamily: "lunafury", + }], + [0x0054, { + brand: "LunaFury", model: "TYPE33", wireless: false, + pollingRates: RATES_8K_FULL, mouseTarget: 0x00, maxDpi: 30000, maxDpiStages: 5, + uiFamily: "lunafury", + }], + [0x0084, { + brand: "LunaFury", model: "TYPE33", wireless: true, + pollingRates: RATES_8K_FULL, maxDpi: 30000, maxDpiStages: 5, uiFamily: "lunafury", + }], [0x0046, { brand: "Attack Shark", model: "R5 Ultra", wireless: false, pollingRates: RATES_1K, maxDpi: 42000, uiFamily: "attack-shark", diff --git a/src/lamzu/lunafury.test.ts b/src/lamzu/lunafury.test.ts new file mode 100644 index 0000000..59c5fce --- /dev/null +++ b/src/lamzu/lunafury.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { compaxEncodeRequest } from "../compx/codec.ts"; +import { + LUNAFURY_READ, LUNAFURY_WRITE, lunafuryDecodeAngle, + lunafuryDecodeLightning, lunafuryDecodeButtonDebounce, lunafuryDecodeWheelGuard, +} from "./lunafury.ts"; + +test("LunaFury read requests preserve page, length, profile and button ID", () => { + const fixtures = [ + [LUNAFURY_READ.angle(3), [0, 0, 2, 2, 1, 0x94, 3]], + [LUNAFURY_READ.lightning(3), [0, 0, 2, 4, 0, 0x98, 3]], + [LUNAFURY_READ.buttonDebounce(3, "left"), [0, 0, 2, 19, 0, 0x92, 3, 0, 1]], + [LUNAFURY_READ.buttonDebounce(3, "right"), [0, 0, 2, 19, 0, 0x92, 3, 0, 2]], + [LUNAFURY_READ.buttonDebounce(3, "middle"), [0, 0, 2, 19, 0, 0x92, 3, 0, 3]], + [LUNAFURY_READ.wheelGuard(3), [0, 0, 2, 4, 0, 0x99, 3]], + ] as const; + for (const [request, prefix] of fixtures) { + const packet = compaxEncodeRequest(request); + assert.equal(packet.length, 64); + assert.deepEqual([...packet.slice(0, prefix.length)], prefix); + assert.ok(packet.slice(prefix.length).every((byte) => byte === 0)); + } +}); + +test("sensor angle uses signed eight-bit degrees, including both boundaries", () => { + for (const degrees of [-30, -12, 0, 12, 30]) { + const packet = compaxEncodeRequest(LUNAFURY_WRITE.angle(3, degrees)); + assert.deepEqual([...packet.slice(0, 8)], [0, 0, 2, 2, 1, 0x14, 3, degrees & 255]); + assert.equal(lunafuryDecodeAngle(Uint8Array.of(3, degrees & 255)), degrees); + } +}); + +test("Lightning Trigger supports off, left priority and right priority", () => { + for (const mode of [0, 1, 2] as const) { + assert.deepEqual([...compaxEncodeRequest(LUNAFURY_WRITE.lightning(3, mode)).slice(0, 10)], + [0, 0, 2, 4, 0, 0x18, 3, mode, 0, 0]); + assert.equal(lunafuryDecodeLightning(Uint8Array.of(3, mode, 0, 0)), mode); + } +}); + +test("per-button latency writes the complete vendor 19-byte timing layout", () => { + for (const [button, id, ms] of [["left", 1, 0], ["right", 2, 15], ["middle", 3, 30]] as const) { + const packet = compaxEncodeRequest(LUNAFURY_WRITE.buttonDebounce(3, button, ms)); + assert.deepEqual([...packet.slice(0, 25)], [0, 0, 2, 19, 0, 0x12, + 3, 0, id, 0, ms, 0, 0, 0, ms, 0, 0, 0, 20, 0, 0, 0, 20, 0, 0]); + assert.equal(lunafuryDecodeButtonDebounce(packet.slice(6, 25), button), ms); + assert.ok(packet.slice(25).every((byte) => byte === 0)); + } +}); + +test("wheel guard keeps the enable bit separate from the big-endian window", () => { + for (const enabled of [false, true]) { + for (const windowMs of [20, 100, 200]) { + const guard = { enabled, windowMs }; + const packet = compaxEncodeRequest(LUNAFURY_WRITE.wheelGuard(3, guard)); + assert.deepEqual([...packet.slice(0, 10)], [0, 0, 2, 4, 0, 0x19, 3, +enabled, 0, windowMs]); + assert.deepEqual(lunafuryDecodeWheelGuard(packet.slice(6, 10)), guard); + } + } + assert.deepEqual(lunafuryDecodeWheelGuard(Uint8Array.of(3, 0, 0, 0)), { enabled: false, windowMs: 0 }); + assert.deepEqual([...compaxEncodeRequest(LUNAFURY_WRITE.wheelGuard(3, { enabled: false, windowMs: 0 })).slice(0, 10)], + [0, 0, 2, 4, 0, 0x19, 3, 0, 0, 0]); +}); + +test("LunaFury requests reject invalid profile bytes instead of wrapping them", () => { + for (const profile of [0, -1, 256, 1.5, NaN, Infinity]) { + assert.throws(() => LUNAFURY_READ.angle(profile), /Profile ID/); + assert.throws(() => LUNAFURY_WRITE.lightning(profile, 1), /Profile ID/); + assert.throws(() => LUNAFURY_WRITE.wheelGuard(profile, { enabled: false, windowMs: 0 }), /Profile ID/); + } +}); + +test("invalid values cannot produce write requests", () => { + for (const value of [-31, 31, 0.5, NaN, Infinity]) assert.throws(() => LUNAFURY_WRITE.angle(1, value)); + for (const mode of [-1, 3, 1.5]) assert.throws(() => LUNAFURY_WRITE.lightning(1, mode as 0)); + for (const value of [-1, 16, 0.5]) assert.throws(() => LUNAFURY_WRITE.buttonDebounce(1, "left", value)); + for (const value of [0, 31, 1.5]) assert.throws(() => LUNAFURY_WRITE.buttonDebounce(1, "middle", value)); + assert.throws(() => LUNAFURY_WRITE.buttonDebounce(1, "toString" as "left", 1)); + for (const windowMs of [0, 10, 21, 201, NaN]) assert.throws(() => LUNAFURY_WRITE.wheelGuard(1, { enabled: true, windowMs })); + assert.throws(() => LUNAFURY_WRITE.wheelGuard(1, { enabled: 1 as unknown as boolean, windowMs: 100 })); +}); + +test("malformed or unsupported responses stay unknown instead of inventing defaults", () => { + for (const payload of [null, new Uint8Array(), Uint8Array.of(1)]) { + assert.equal(lunafuryDecodeAngle(payload), null); + assert.equal(lunafuryDecodeLightning(payload), undefined); + assert.equal(lunafuryDecodeButtonDebounce(payload, "left"), undefined); + assert.equal(lunafuryDecodeWheelGuard(payload), undefined); + } + assert.equal(lunafuryDecodeAngle(Uint8Array.of(1, 31)), null); + assert.equal(lunafuryDecodeLightning(Uint8Array.of(1, 3)), undefined); + assert.equal(lunafuryDecodeButtonDebounce(Uint8Array.of(1, 0, 2, 0, 10), "left"), undefined); + assert.equal(lunafuryDecodeButtonDebounce(Uint8Array.of(1, 0, 1, 1, 0), "left"), undefined); + assert.equal(lunafuryDecodeButtonDebounce(Uint8Array.of(1, 0, 3, 0, 0), "middle"), undefined); + for (const bytes of [[1, 2, 0, 100], [1, 1, 0, 0], [1, 0, 0, 21], [1, 1, 1, 20]]) { + assert.equal(lunafuryDecodeWheelGuard(Uint8Array.from(bytes)), undefined); + } +}); diff --git a/src/lamzu/lunafury.ts b/src/lamzu/lunafury.ts new file mode 100644 index 0000000..e26e2d5 --- /dev/null +++ b/src/lamzu/lunafury.ts @@ -0,0 +1,90 @@ +/** LunaFury's public XviUpdater commands, inspected 2026-10-05. + * Report ID 0 is absent from received WebHID buffers (vendor hidIndex = 1). + * Offsets below are relative to the shared six-byte CompX response header. + */ +export type LunaFuryLightningMode = 0 | 1 | 2; +export type LunaFuryButton = "left" | "right" | "middle"; +export interface LunaFuryWheelGuard { enabled: boolean; windowMs: number } +export interface LunaFurySettings { + lightningMode?: LunaFuryLightningMode; + leftDebounceMs?: number; + rightDebounceMs?: number; + middleDebounceMs?: number; + wheelGuard?: LunaFuryWheelGuard; +} + +const BUTTON_ID = { left: 1, right: 2, middle: 3 } as const; +const request = (page: number, command: number, length: number, args: number[]) => { + whole(args[0], 1, 255, "Profile ID"); + return { target: 0x02, page, command, length, args }; +}; + +export const LUNAFURY_READ = { + angle: (profile: number) => request(0x01, 0x94, 2, [profile]), + lightning: (profile: number) => request(0x00, 0x98, 4, [profile]), + buttonDebounce: (profile: number, button: LunaFuryButton) => + request(0x00, 0x92, 19, [profile, 0, BUTTON_ID[button]]), + wheelGuard: (profile: number) => request(0x00, 0x99, 4, [profile]), +}; + +function whole(value: number, min: number, max: number, label: string): void { + if (!Number.isInteger(value) || value < min || value > max) { + throw new Error(`${label} must be a whole number between ${min} and ${max}.`); + } +} + +export const LUNAFURY_WRITE = { + angle(profile: number, degrees: number) { + whole(degrees, -30, 30, "Sensor angle"); + return request(0x01, 0x14, 2, [profile, degrees & 0xff]); + }, + lightning(profile: number, mode: LunaFuryLightningMode) { + whole(mode, 0, 2, "Lightning Trigger mode"); + return request(0x00, 0x18, 4, [profile, mode]); + }, + buttonDebounce(profile: number, button: LunaFuryButton, milliseconds: number) { + if (!Object.hasOwn(BUTTON_ID, button)) throw new Error("Unknown LunaFury button."); + whole(milliseconds, button === "middle" ? 1 : 0, button === "middle" ? 30 : 15, "Button latency"); + // The vendor writes the same latency into both timing fields, with the + // remaining timing fields fixed at 0/20. Preserve the full 19-byte layout. + return request(0x00, 0x12, 19, [ + profile, 0, BUTTON_ID[button], 0, milliseconds, 0, 0, + 0, milliseconds, 0, 0, 0, 20, 0, 0, 0, 20, 0, 0, + ]); + }, + wheelGuard(profile: number, guard: LunaFuryWheelGuard) { + if (typeof guard.enabled !== "boolean") throw new Error("Wheel guard needs an on/off value."); + // Firmware can report a disabled zero window. It must be writable too + // so applying and restoring a game profile preserves the original value. + if (!guard.enabled && guard.windowMs === 0) return request(0x00, 0x19, 4, [profile, 0, 0, 0]); + whole(guard.windowMs, 20, 200, "Wheel guard window"); + if (guard.windowMs % 20 !== 0) throw new Error("Wheel guard window must use 20 ms steps."); + return request(0x00, 0x19, 4, [profile, guard.enabled ? 1 : 0, 0, guard.windowMs]); + }, +}; + +export function lunafuryDecodeAngle(payload: Uint8Array | null): number | null { + if (!payload || payload.length < 2) return null; + const angle = payload[1] > 127 ? payload[1] - 256 : payload[1]; + return angle >= -30 && angle <= 30 ? angle : null; +} + +export function lunafuryDecodeLightning(payload: Uint8Array | null): LunaFuryLightningMode | undefined { + return payload && payload.length >= 2 && payload[1] <= 2 + ? payload[1] as LunaFuryLightningMode : undefined; +} + +export function lunafuryDecodeButtonDebounce(payload: Uint8Array | null, button: LunaFuryButton): number | undefined { + if (!payload || payload.length < 5 || payload[2] !== BUTTON_ID[button]) return undefined; + const value = (payload[3] << 8) | payload[4]; + return value >= (button === "middle" ? 1 : 0) && value <= (button === "middle" ? 30 : 15) + ? value : undefined; +} + +export function lunafuryDecodeWheelGuard(payload: Uint8Array | null): LunaFuryWheelGuard | undefined { + if (!payload || payload.length < 4 || payload[1] > 1) return undefined; + const windowMs = (payload[2] << 8) | payload[3]; + const enabled = payload[1] === 1; + if (!(windowMs >= 20 && windowMs <= 200 && windowMs % 20 === 0) && !(!enabled && windowMs === 0)) return undefined; + return { enabled, windowMs }; +} From c1e49689f2ebd4573a9be8081c588751291d19a9 Mon Sep 17 00:00:00 2001 From: Breakdown <169538186+RINZE1@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:40:16 +0800 Subject: [PATCH 2/2] fix(lunafury): correlate extension replies with profile and button --- docs/lunafury-testing.md | 7 ++ src/drivers/lamzu/hid.ts | 26 ++++-- src/drivers/lamzu/lunafury.test.ts | 124 ++++++++++++++++++++++++++++- 3 files changed, 150 insertions(+), 7 deletions(-) diff --git a/docs/lunafury-testing.md b/docs/lunafury-testing.md index 5c1448a..f26d9c4 100644 --- a/docs/lunafury-testing.md +++ b/docs/lunafury-testing.md @@ -104,6 +104,13 @@ that fail or return invalid values leave their controls absent, while basic device settings remain available. Only the LunaFury product profiles issue these extra commands; other CompX brands are unchanged. +LunaFury extension reads accept a reply only when its payload echoes the +requested profile; button-latency reads also match the button ID. A successful +reply for another profile or button is ignored while the same request waits +for a matching reply, within its existing attempt limit. This applies to both +initial reads and write readbacks. Generic CompX requests and write +acknowledgments retain their existing matching rules. + The UI places Lightning Trigger, independent left/right latency, middle debounce, and wheel guard on the Buttons tab. Sensor angle is in Processing on the Performance tab. As in the vendor UI, left/right priority disables diff --git a/src/drivers/lamzu/hid.ts b/src/drivers/lamzu/hid.ts index 6691d25..25638fb 100644 --- a/src/drivers/lamzu/hid.ts +++ b/src/drivers/lamzu/hid.ts @@ -68,6 +68,7 @@ interface LamzuRequest { length: number; args: readonly number[]; attempts?: number; + matchesReply?: (payload: Uint8Array) => boolean; } const READ = { @@ -332,8 +333,20 @@ export class LamzuHidClient { if (this.deviceBrand() !== "LunaFury") throw new Error("This control is only available on LunaFury mice."); } + private readLunaFury(spec: LamzuRequest): Promise { + const profile = spec.args[0]; + const button = spec.page === PAGE.device && spec.command === 0x92 ? spec.args[2] : undefined; + // These reads echo their profile and, for latency, the button selector. + // A successful same-command reply can still belong to the previous read. + return this.request({ + ...spec, + matchesReply: (payload) => payload[0] === profile + && (button === undefined || payload[2] === button), + }); + } + private async readLunaFurySettings(profile: number): Promise<{ settings: LunaFurySettings; angle: number | null }> { - const optional = (spec: LamzuRequest) => this.request({ ...spec, attempts: 2 }).catch(() => null); + const optional = (spec: LamzuRequest) => this.readLunaFury({ ...spec, attempts: 2 }).catch(() => null); const lightningMode = lunafuryDecodeLightning(await optional(LUNAFURY_READ.lightning(profile))); const leftDebounceMs = lunafuryDecodeButtonDebounce(await optional(LUNAFURY_READ.buttonDebounce(profile, "left")), "left"); const rightDebounceMs = lunafuryDecodeButtonDebounce(await optional(LUNAFURY_READ.buttonDebounce(profile, "right")), "right"); @@ -351,7 +364,7 @@ export class LamzuHidClient { this.requireLunaFury(); const profile = await this.currentProfile(); await this.request(LUNAFURY_WRITE.angle(profile, degrees)); - const confirmed = lunafuryDecodeAngle(await this.request(LUNAFURY_READ.angle(profile))); + const confirmed = lunafuryDecodeAngle(await this.readLunaFury(LUNAFURY_READ.angle(profile))); if (confirmed !== degrees) throw new Error(`The mouse did not confirm the ${degrees}° sensor angle.`); this.patch({ angleTuning: confirmed }); return confirmed; @@ -361,7 +374,7 @@ export class LamzuHidClient { this.requireLunaFury(); const profile = await this.currentProfile(); await this.request(LUNAFURY_WRITE.lightning(profile, mode)); - const confirmed = lunafuryDecodeLightning(await this.request(LUNAFURY_READ.lightning(profile))); + const confirmed = lunafuryDecodeLightning(await this.readLunaFury(LUNAFURY_READ.lightning(profile))); if (confirmed !== mode) throw new Error("The mouse did not confirm the Lightning Trigger mode."); this.patchLunaFury({ lightningMode: confirmed }); return confirmed; @@ -371,7 +384,7 @@ export class LamzuHidClient { this.requireLunaFury(); const profile = await this.currentProfile(); await this.request(LUNAFURY_WRITE.buttonDebounce(profile, button, milliseconds)); - const confirmed = lunafuryDecodeButtonDebounce(await this.request(LUNAFURY_READ.buttonDebounce(profile, button)), button); + const confirmed = lunafuryDecodeButtonDebounce(await this.readLunaFury(LUNAFURY_READ.buttonDebounce(profile, button)), button); if (confirmed !== milliseconds) throw new Error(`The mouse did not confirm ${milliseconds} ms latency for the ${button} button.`); this.patchLunaFury({ [`${button}DebounceMs`]: confirmed }); return confirmed; @@ -381,7 +394,7 @@ export class LamzuHidClient { this.requireLunaFury(); const profile = await this.currentProfile(); await this.request(LUNAFURY_WRITE.wheelGuard(profile, guard)); - const confirmed = lunafuryDecodeWheelGuard(await this.request(LUNAFURY_READ.wheelGuard(profile))); + const confirmed = lunafuryDecodeWheelGuard(await this.readLunaFury(LUNAFURY_READ.wheelGuard(profile))); if (!confirmed || confirmed.enabled !== guard.enabled || confirmed.windowMs !== guard.windowMs) { throw new Error("The mouse did not confirm the wheel guard settings."); } @@ -560,7 +573,8 @@ export class LamzuHidClient { if (reply[0] === STATUS.unsupported) throw new Error(this.describe(spec, "is not supported by this mouse")); if (reply[0] === STATUS.ok && reply[4] === spec.page && reply[5] === spec.command) { const length = Math.min(reply[3], PACKET_LENGTH - HEADER_LENGTH); - return reply.slice(HEADER_LENGTH, HEADER_LENGTH + length); + const payload = reply.slice(HEADER_LENGTH, HEADER_LENGTH + length); + if (!spec.matchesReply || spec.matchesReply(payload)) return payload; } if (reply[0] !== STATUS.pending && reply[0] !== STATUS.busy && reply[0] !== STATUS.ok) { throw new Error(this.describe(spec, `returned an unexpected status 0x${reply[0].toString(16)}`)); diff --git a/src/drivers/lamzu/lunafury.test.ts b/src/drivers/lamzu/lunafury.test.ts index 8b9223b..d602a4d 100644 --- a/src/drivers/lamzu/lunafury.test.ts +++ b/src/drivers/lamzu/lunafury.test.ts @@ -6,8 +6,13 @@ const globals = globalThis as { window?: { setTimeout: typeof setTimeout } }; globals.window ??= { setTimeout }; const EXTRA_READS = new Set(["1:148", "0:152", "0:146", "0:153"]); -function fakeLunaFury(productId = 0x0033, options: { unsupported?: boolean; ignoreWrites?: boolean } = {}) { +function fakeLunaFury(productId = 0x0033, options: { + unsupported?: boolean; + ignoreWrites?: boolean; + mutateReply?: (packet: Uint8Array, reply: Uint8Array, attempt: number) => void; +} = {}) { const sent: Uint8Array[] = []; + let receiveAttempt = 0; const state = { angle: -12, lightning: 1, latency: [0, 3, 7, 10], wheel: true, window: 200 }; const device = { vendorId: 0x373e, productId, productName: "LunaFury", opened: true, @@ -19,6 +24,7 @@ function fakeLunaFury(productId = 0x0033, options: { unsupported?: boolean; igno sendFeatureReport: async (id: number, packet: Uint8Array) => { assert.equal(id, 0); sent.push(new Uint8Array(packet)); + receiveAttempt = 0; if (options.ignoreWrites) return; const page = packet[4], command = packet[5]; if (page === 1 && command === 0x14) state.angle = packet[7]! > 127 ? packet[7]! - 256 : packet[7]!; @@ -45,6 +51,7 @@ function fakeLunaFury(productId = 0x0033, options: { unsupported?: boolean; igno if (key === "0:146") payload = [profile, 0, packet[8]!, 0, state.latency[packet[8]!]!, ...Array(14).fill(0)]; if (key === "0:153") payload = [profile, +state.wheel, state.window >> 8, state.window & 255]; reply[3] = payload.length; reply.set(payload, 6); + options.mutateReply?.(packet, reply, receiveAttempt++); // Non-zero byteOffset exercises DataView framing without a report ID. const buffer = new Uint8Array(70); buffer.set(reply, 3); return new DataView(buffer.buffer, 3, 64); @@ -94,8 +101,123 @@ for (const [pid, target] of [[0x0032, 0], [0x0033, 2], [0x0054, 0], [0x0084, 2]] middleDebounceMs: 1, wheelGuard: { enabled: false, windowMs: 100 }, }); }); + + test(`LunaFury 0x${pid.toString(16)} waits past stale button replies`, async () => { + const receives: number[][] = []; + const { device, sent, state } = fakeLunaFury(pid, { mutateReply(packet, reply, attempt) { + if (packet[4] !== 0 || packet[5] !== 0x92) return; + const button = packet[8]!; + receives.push([button, attempt]); + if (attempt === 0 && button > 1) { + reply[8] = button - 1; + reply[10] = state.latency[button - 1]!; + } + } }); + state.latency[2] = 8; + const status = await new LamzuHidClient(device).readStatus(); + assert.deepEqual(status.lunafury, { + lightningMode: 1, leftDebounceMs: 3, rightDebounceMs: 8, + middleDebounceMs: 10, wheelGuard: { enabled: true, windowMs: 200 }, + }); + assert.deepEqual(receives, [[1, 0], [2, 0], [2, 1], [3, 0], [3, 1]]); + assert.equal(sent.filter((packet) => packet[5] === 0x92).length, 3, + "ignore stale replies without resending the request"); + }); + + test(`LunaFury 0x${pid.toString(16)} waits past stale profile replies`, async () => { + const receives: string[] = []; + const { device } = fakeLunaFury(pid, { mutateReply(packet, reply, attempt) { + const key = `${packet[4]}:${packet[5]}`; + if (!EXTRA_READS.has(key)) return; + receives.push(`${key}:${attempt}`); + if (attempt !== 0) return; + reply[6] = packet[6]! - 1; + if (key === "0:146") reply[10] = 1; + else reply[7] = 0; + if (key === "0:153") { reply[8] = 0; reply[9] = 20; } + } }); + const status = await new LamzuHidClient(device).readStatus(); + assert.equal(status.angleTuning, -12); + assert.deepEqual(status.lunafury, { + lightningMode: 1, leftDebounceMs: 3, rightDebounceMs: 7, + middleDebounceMs: 10, wheelGuard: { enabled: true, windowMs: 200 }, + }); + assert.equal(receives.length, 12); + assert.equal(receives.filter((key) => key.endsWith(":1")).length, 6); + }); } +test("LunaFury readback waits past stale profiles and button selectors", async () => { + let inject = false; + let readbacks = 0; + const { device, sent } = fakeLunaFury(0x0033, { mutateReply(packet, reply, attempt) { + if (!inject || !EXTRA_READS.has(`${packet[4]}:${packet[5]}`)) return; + readbacks += 1; + if (attempt === 0) reply[6] = packet[6]! - 1; + else if (attempt === 1 && packet[5] === 0x92) reply[8] = packet[8] === 1 ? 2 : 1; + } }); + const client = new LamzuHidClient(device); + await client.readStatus(); + inject = true; + const before = sent.length; + assert.equal(await client.setAngleTuning(15), 15); + assert.equal(await client.setLunaFuryLightningMode(2), 2); + assert.equal(await client.setLunaFuryButtonDebounce("left", 4), 4); + assert.equal(await client.setLunaFuryButtonDebounce("right", 8), 8); + assert.equal(await client.setLunaFuryButtonDebounce("middle", 12), 12); + assert.deepEqual(await client.setLunaFuryWheelGuard({ enabled: false, windowMs: 100 }), + { enabled: false, windowMs: 100 }); + assert.equal(readbacks, 15); + assert.equal(sent.slice(before).filter((packet) => EXTRA_READS.has(`${packet[4]}:${packet[5]}`)).length, 6); + const status = await client.readStatus(true); + assert.equal(status.angleTuning, 15); + assert.deepEqual(status.lunafury, { + lightningMode: 2, leftDebounceMs: 4, rightDebounceMs: 8, + middleDebounceMs: 12, wheelGuard: { enabled: false, windowMs: 100 }, + }); +}); + +test("LunaFury stale readback cannot confirm an ignored write", async () => { + let inject = false; + const { device } = fakeLunaFury(0x0033, { ignoreWrites: true, mutateReply(packet, reply, attempt) { + if (!inject || packet[4] !== 0 || packet[5] !== 0x92 || attempt !== 0) return; + reply[6] = packet[6]! - 1; + reply[10] = 5; + } }); + const client = new LamzuHidClient(device); + await client.readStatus(); + inject = true; + await assert.rejects(client.setLunaFuryButtonDebounce("left", 5), /did not confirm/); + assert.equal((await client.readStatus(true)).lunafury?.leftDebounceMs, 3); +}); + +test("LunaFury exhausted stale replies stay absent and bounded", async () => { + let receives = 0; + const { device } = fakeLunaFury(0x0033, { mutateReply(packet, reply) { + if (!EXTRA_READS.has(`${packet[4]}:${packet[5]}`)) return; + receives += 1; + reply[6] = packet[6]! - 1; + } }); + const status = await new LamzuHidClient(device).readStatus(); + assert.equal(status.dpi, 800); + assert.equal(status.batteryPercent, 80); + assert.equal(status.angleTuning, null); + assert.ok(Object.values(status.lunafury!).every((value) => value === undefined)); + assert.equal(receives, 12, "each optional read keeps its two-attempt limit"); +}); + +test("other CompX profile replies retain their existing matching rules", async () => { + const { device } = fakeLunaFury(0x006a, { mutateReply(packet, reply) { + if (packet[5] === 0x88 || (packet[4] === 1 && packet[5] === 0x80)) reply[6] = 1; + } }); + const status = await new LamzuHidClient(device).readStatus(); + assert.equal(status.brand, "CRDRAKO"); + assert.equal(status.activeProfile, 3); + assert.equal(status.debounceMs, 0); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.lunafury, undefined); +}); + test("unsupported LunaFury controls do not break the base device status", async () => { const { device } = fakeLunaFury(0x0033, { unsupported: true }); const status = await new LamzuHidClient(device).readStatus();