diff --git a/README.md b/README.md index d412fec0..1c3aa44f 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ checklist. | Incott | `@openmouse/protocol/incott` | | K-snake | `@openmouse/protocol/ksnake` | | Keychron | `@openmouse/protocol/keychron` | -| Lamzu / CRDRAKO / Attack Shark | `@openmouse/protocol/lamzu` | +| Lamzu / CRDRAKO / Attack Shark / LunaFury | `@openmouse/protocol/lamzu` | | Logitech | `@openmouse/protocol/logitech` | | MCHOSE | `@openmouse/protocol/mchose` | | Microsoft | `@openmouse/protocol/microsoft` | @@ -84,6 +84,11 @@ An exported protocol means OpenMouse implements that wire format. It does not claim every mouse from that brand works. When a catalog provides a `verified` field, use it to distinguish hardware-tested support from USB recognition. +LunaFury uses the shared Lamzu/CompX driver at `@openmouse/protocol/lamzu`. +LUNA33 firmware `0.0.26.0` has owner-confirmed cable/8K coverage; TYPE33 and +bottom-button behavior are not hardware-verified. See the +[support and testing notes](docs/lunafury-testing.md) for the verified scope. + The Ninjutso catalog and packet layouts are derived from the JavaScript shipped by the official NinjaForce WebHID panel. They have automated transport and codec coverage, but are not marked as hardware-verified until tested on the diff --git a/docs/lunafury-testing.md b/docs/lunafury-testing.md index f26d9c42..47235079 100644 --- a/docs/lunafury-testing.md +++ b/docs/lunafury-testing.md @@ -1,155 +1,57 @@ -# LunaFury LUNA33 / TYPE33 protocol evidence +# LunaFury support and testing -This integration is based on the public LunaFury WebHID configurator at -. The site and its source map were inspected on -2026-10-05: +Packet definitions follow the public [LunaFury configurator](https://mouse.lunafury.games/) +and its `XviUpdater` implementation. Vendor scripts are not included. -- `/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. +## Devices -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. +Runtime VID: `0x373e`. Bootloader identities are not supported here. -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 | +| Model | Cable PID | 8K receiver PID | DPI stages | | --- | --- | --- | --- | -| 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 +| LUNA33 | `0x0032` | `0x0033` | 1-6 | +| TYPE33 | `0x0054` | `0x0084` | 1-5 | -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 shared Lamzu/CompX driver provides button polling, three onboard profiles, +DPI stage/count/X-Y controls, button mappings, sleep, 8K receiver lighting +and bottom-button power/DPI modes. Button polling is separate from USB polling. +Custom DPI colors are not exposed. -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. +Bank operations are serialized. Writes verify readback and preserve dormant +DPI slots and reserved bytes. Complete DPI storage supports Games restoration; +it is not a saved or importable Games setting. -`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. +## Hardware verification -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. +The owner verified LUNA33 firmware `0.0.26.0` over cable and 8K: -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. +- Button polling, all three onboard profiles and DPI stage/count/X-Y controls. +- Mouse, scroll, DPI, media and keyboard mappings. +- Sleep/Never, reconnect readback and all four 8K receiver light modes. + Cable connections do not expose receiver lighting. -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. +TYPE33 and bottom-button behavior are not hardware-verified. +Games apply/restore has automated coverage only. -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. +## Automated checks -## Required hardware check +Build, package checks and all 2,474 protocol tests pass, including both +exhaustive registry checks. The 162 Lamzu driver/codec tests also pass with +real timers. Coverage includes the four runtime PIDs, bank isolation, DPI storage, +write/read failures and retry recovery. Runtime audit reports no vulnerabilities; +development and release dependencies still have audit findings. -Connect each available cable/receiver identity in Chromium and confirm: +The paired app passes its build, 511 tests, bundle checks and audit with the local +protocol package. Games and manual Apply tests cover complete restoration, +untrusted status, write ordering and retained edits after failure. +Synthetic HID and Bridge callbacks do not replace hardware testing. -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. +## Integration -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. +Merge and publish the protocol before upgrading the app's exact npm dependency. +After pinning the published version, the app's `CI / check` job runs `npm ci`, +`npm run build`, `npm test`, `npm run size` and `npm audit --audit-level=high` +against registry-only dependencies. Local paired-package checks do not replace +this gate. +Both branches require upstream CI before merge. +See the [application setup](https://github.com/sekiranunn/openmouse/blob/codex/lunafury-onboard-controls/docs/lunafury-development.md). diff --git a/src/drivers/lamzu/hid.test.ts b/src/drivers/lamzu/hid.test.ts index cd9b7f55..c4250066 100644 --- a/src/drivers/lamzu/hid.test.ts +++ b/src/drivers/lamzu/hid.test.ts @@ -302,7 +302,9 @@ test("LunaFury LUNA33 and TYPE33 use the CompX driver with their own brand", asy } else { const dongleFirmware = sent.filter((packet) => packet[2] === 0x00 && packet[4] === 0x00 && packet[5] === 0x81); - const mouseRequests = sent.filter((packet) => !dongleFirmware.includes(packet)); + const lightReads = sent.filter((packet) => packet[4] === 2 && packet[5] === 0x80); + assert.ok(lightReads.every((packet) => packet[2] === (productId === 0x0084 ? 1 : 0))); + const mouseRequests = sent.filter((packet) => !dongleFirmware.includes(packet) && !lightReads.includes(packet)); assert.equal(dongleFirmware.length, 1); assert.ok(mouseRequests.length > 0); assert.ok(mouseRequests.every((packet) => packet[2] === 0x02)); diff --git a/src/drivers/lamzu/hid.ts b/src/drivers/lamzu/hid.ts index 1f16c1c7..415c65e7 100644 --- a/src/drivers/lamzu/hid.ts +++ b/src/drivers/lamzu/hid.ts @@ -19,10 +19,42 @@ import { lunafuryDecodeLightning, lunafuryDecodeButtonDebounce, lunafuryDecodeWheelGuard, + LUNAFURY_ONBOARD_READ, + LUNAFURY_ONBOARD_WRITE, + LUNAFURY_DPI_DEFAULTS, + lunafuryDecodeButtonRate, + lunafuryDecodeSeparateAxes, + lunafuryDecodeDpiStages, + lunafuryDecodeDpiStorage, + lunafuryWriteDpiStorage, + type LunaFuryDpiStorage, + lunafuryValidateDpi, + lunafuryWriteDpiStages, + LUNAFURY_BUTTON_IDS, + LUNAFURY_BUTTON_ACTIONS, + lunafuryButtonActionWritable, + lunafuryDecodeButton, + lunafuryDecodeButtonAction, + lunafuryIsMappedButton, + lunafuryReadButton, + lunafuryWriteButton, + type LunaFuryButtonPollingRate, type LunaFuryButton, type LunaFuryLightningMode, type LunaFurySettings, type LunaFuryWheelGuard, + type LunaFuryReceiverLightMode, + LUNAFURY_SLEEP_OPTIONS, + lunafuryEncodeSleep, + lunafuryDecodeSleep, + lunafuryReceiverLightTarget, + lunafuryReadReceiverLight, + lunafuryWriteReceiverLight, + lunafuryDecodeReceiverLight, + lunafuryReadBottomButton, + lunafuryWriteBottomButton, + lunafuryDecodeBottomButton, + type LunaFuryBottomButtonMode, MAGNETIC_CALIBRATION_ERRORS, MAGNETIC_CALIBRATION_STATE, MAGNETIC_CALIBRATION_STEPS, @@ -152,6 +184,7 @@ export class LamzuHidClient { private notifier: HIDDevice | null = null; private notifyListener: ((event: HIDInputReportEvent) => void) | null = null; private activeProfile = 1; + private onboardQueue: Promise = Promise.resolve(); readonly device: HIDDevice; @@ -244,6 +277,7 @@ export class LamzuHidClient { } getSleepOptions(): readonly number[] { + if (this.deviceBrand() === "LunaFury") return LUNAFURY_SLEEP_OPTIONS; return this.profile()?.sleepOptions ?? SLEEP_SECONDS; } @@ -270,6 +304,12 @@ export class LamzuHidClient { } async readStatus(live = false): Promise { + // A status read must not interleave a bank switch or repopulate its old cache. + return this.deviceBrand() === "LunaFury" + ? this.onboardOperation(() => this.readStatusNow(live)) : this.readStatusNow(live); + } + + private async readStatusNow(live = false): Promise { await this.open(); if (live && this.lastStatus) return await this.readLiveStatus(this.lastStatus); const wireless = this.isWireless(); @@ -284,15 +324,21 @@ export class LamzuHidClient { const activeProfileReply = await this.request(READ.activeProfile).catch(() => null); const profile = activeProfileReply ? Math.max(1, activeProfileReply[0]) : 1; this.activeProfile = profile; - const sleepTimeout = await this.request(PROFILE_READ.sleepTimeout(profile)).catch(() => null); + const luna = this.deviceBrand() === "LunaFury"; + const sleepTimeout = await (luna ? this.readLunaFury(PROFILE_READ.sleepTimeout(profile)) + : 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, this.maxDpiStages()))); - const activeStage = this.stageIndex((await this.request(PROFILE_READ.activeStage(profile)))[1], stages.length); + const { raw: dpiRaw, stages } = await this.readDpiTable(profile); + const activeStage = luna + ? await this.readLunaFuryStage(profile, stages.length) + : 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)); - const separateAxes = this.profile()?.separateDpiAxes === false + const separateAxesUnsupported = this.profile()?.separateDpiAxes === false; + const separateAxes = separateAxesUnsupported ? null - : await this.request(PROFILE_READ.separateAxes(profile)).catch(() => null); + : await (luna ? this.readLunaFury(LUNAFURY_ONBOARD_READ.separateAxes(profile)) + : this.request(PROFILE_READ.separateAxes(profile))).catch(() => null); const angleSnapping = await this.request(PROFILE_READ.angleSnapping(profile)).catch(() => null); const motionSync = await this.request(PROFILE_READ.motionSync(profile)).catch(() => null); const competitiveMode = await this.request(PROFILE_READ.competitiveMode(profile)).catch(() => null); @@ -305,6 +351,7 @@ export class LamzuHidClient { : null; const lunafury = this.deviceBrand() === "LunaFury" ? await this.readLunaFurySettings(profile) : undefined; + const buttons = luna ? await this.readLunaFuryButtons(profile) : undefined; const stage = stages[activeStage]; if (!stage) throw new Error("The mouse did not report any DPI stages."); const magneticButtons = this.profile()?.magnetic ? await this.readMagnetic(profile).catch(() => undefined) : undefined; @@ -316,21 +363,29 @@ export class LamzuHidClient { family: this.profile()?.uiFamily ?? "lamzu", hideUnsupportedPollingRates: true, forceShowBattery: true, + ...(luna ? { dpiStageEditor: { maxStages: this.maxDpiStages(), countEditable: true, + minDpi: 50, maxDpi: this.maxDpi(), stepDpi: DPI_STEP } } : {}), }, batteryPercent: battery[1] <= 100 ? battery[1] : null, batteryState: battery[0] === 1 ? "Charging" : "Discharging", dpi: stage.x, dpiY: stage.y, - supportsSeparateDpiAxes: separateAxes ? separateAxes[1] === 1 : false, + supportsSeparateDpiAxes: luna + ? (separateAxesUnsupported ? false + : separateAxes === null ? undefined : lunafuryDecodeSeparateAxes(separateAxes) !== undefined) + : separateAxes ? separateAxes[1] === 1 : false, + ...(luna ? { dpiStages: stages.map((item) => item.x), dpiStagesY: stages.map((item) => item.y), activeDpiStage: activeStage } : {}), pollingRateHz: this.decodePollingRate(pollingRate[1]), supportedPollingRates: this.getSupportedPollingRates(), activeProfile: activeProfileReply ? activeProfileReply[0] : null, + ...(luna && activeProfileReply && activeProfileReply[0] >= 1 && activeProfileReply[0] <= 3 ? { profileCount: 3 } : {}), angleSnapping: angleSnapping ? angleSnapping[1] === 1 : null, motionSync: motionSync ? motionSync[1] === 1 : null, 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 } : {}), + ...(lunafury ? { lunafury: { ...lunafury.settings, dpiStorage: lunafuryDecodeDpiStorage(dpiRaw, this.maxDpiStages()) }, angleTuning: lunafury.angle } : {}), + ...buttons, dongleLedEnabled: dongleLed ? dongleLed[1] === 1 : null, connectionType: wireless ? "Wireless" : "Wired", connectionDetail: wireless ? "2.4 GHz receiver" : "Wired USB", @@ -344,10 +399,21 @@ export class LamzuHidClient { } private async readLiveStatus(previous: MouseStatus): Promise { + let dpi: Partial = {}; + if (this.deviceBrand() === "LunaFury") { + const profile = await this.currentProfile(); + if (profile !== previous.activeProfile) return this.readStatusNow(); + const { raw, stages } = await this.readDpiTable(profile); + const active = await this.readLunaFuryStage(profile, stages.length); + dpi = { dpiStages: stages.map((stage) => stage.x), dpiStagesY: stages.map((stage) => stage.y), + activeDpiStage: active, dpi: stages[active].x, dpiY: stages[active].y, + lunafury: { ...previous.lunafury, dpiStorage: lunafuryDecodeDpiStorage(raw, this.maxDpiStages()) } }; + } const battery = await this.request(READ.battery); const pollingRate = await this.request(PROFILE_READ.pollingRate(this.activeProfile)); return this.lastStatus = { ...previous, + ...dpi, batteryPercent: battery[1] <= 100 ? battery[1] : null, batteryState: battery[0] === 1 ? "Charging" : "Discharging", pollingRateHz: this.decodePollingRate(pollingRate[1]), @@ -378,7 +444,259 @@ export class LamzuHidClient { 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 }; + const buttonPollingRateHz = lunafuryDecodeButtonRate(await optional(LUNAFURY_ONBOARD_READ.buttonRate(profile))); + const separateDpiAxes = lunafuryDecodeSeparateAxes(await optional(LUNAFURY_ONBOARD_READ.separateAxes(profile))); + const receiverLightMode = lunafuryReceiverLightTarget(this.device.productId) !== undefined + ? lunafuryDecodeReceiverLight(await this.readReceiverLight(2).catch(() => null)) : undefined; + const bottomButtonMode = lunafuryDecodeBottomButton(await this.readLunaFuryBottomButton(profile, 2).catch(() => null), profile); + return { settings: { lightningMode, leftDebounceMs, rightDebounceMs, middleDebounceMs, wheelGuard, + ...(bottomButtonMode !== undefined ? { bottomButtonMode } : {}), + ...(receiverLightMode !== undefined ? { receiverLightMode } : {}), + ...(buttonPollingRateHz !== undefined ? { buttonPollingRateHz, supportedButtonPollingRates: this.getLunaFuryButtonPollingRates() } : {}), + ...(separateDpiAxes !== undefined ? { separateDpiAxes } : {}), + }, angle }; + } + + private readLunaFuryBottomButton(profile: number, attempts?: number): Promise { + return this.request({ ...lunafuryReadBottomButton(profile), attempts, + matchesReply: (reply) => reply[0] === profile && reply[1] === 0x14 && reply[2] === 0 }); + } + + async setLunaFuryBottomButtonMode(mode: LunaFuryBottomButtonMode): Promise { + this.requireLunaFury(); + lunafuryWriteBottomButton(1, mode); // Validate before sending any request. + return this.onboardOperation(async () => { + const profile = await this.currentProfile(); + const previous = lunafuryDecodeBottomButton(await this.readLunaFuryBottomButton(profile), profile); + if (previous === undefined) throw new Error("The bottom-button binding is unsupported or unknown."); + await this.request(lunafuryWriteBottomButton(profile, mode)); + const confirmed = lunafuryDecodeBottomButton(await this.readLunaFuryBottomButton(profile), profile); + if (confirmed !== mode) throw new Error("The mouse did not confirm the bottom-button mode."); + this.patchLunaFury({ bottomButtonMode: confirmed }); + return confirmed; + }); + } + + getLunaFuryButtonPollingRates(): LunaFuryButtonPollingRate[] { + this.requireLunaFury(); + return this.device.productId === 0x0032 ? [1000] : [1000, 2000, 4000, 8000]; + } + + private async readLunaFuryButton(profile: number, button: string, attempts?: number) { + const spec = lunafuryReadButton(profile, button); + const payload = await this.request({ ...spec, attempts, + matchesReply: (reply) => reply[0] === profile && reply[1] === spec.args[1] && reply[2] === 0 }); + return lunafuryDecodeButton(payload, profile, button); + } + + private async readLunaFuryButtons(profile: number): Promise> { + const buttonMappings: Record = {}; + const fixedButtons = ["Left"]; + for (const button of Object.keys(LUNAFURY_BUTTON_IDS)) { + const binding = await this.readLunaFuryButton(profile, button, 2).catch(() => null); + if (!binding) continue; // An unsupported remapping read must not hide basic settings. + const action = lunafuryDecodeButtonAction(binding); + buttonMappings[button] = action; + if (button !== "Left" && !lunafuryButtonActionWritable(action)) fixedButtons.push(button); + } + return Object.keys(buttonMappings).length + ? { buttonMappings, fixedButtons, buttonOptions: [...LUNAFURY_BUTTON_ACTIONS] } : {}; + } + + async setButtonMapping(button: string, action: string): Promise { + this.requireLunaFury(); + if (!lunafuryIsMappedButton(button) || button === "Left") throw new Error("This LunaFury button is protected."); + // Validate before issuing even a read. Macro/unknown commands never become writes. + lunafuryWriteButton(1, button, action); + return this.onboardOperation(async () => { + const profile = await this.currentProfile(); + const previous = await this.readLunaFuryButton(profile, button); + if (!lunafuryButtonActionWritable(lunafuryDecodeButtonAction(previous))) { + throw new Error("Existing macros and unknown assignments are read-only."); + } + await this.request(lunafuryWriteButton(profile, button, action)); + const confirmed = lunafuryDecodeButtonAction(await this.readLunaFuryButton(profile, button)); + if (confirmed !== action) throw new Error("The mouse did not confirm the button assignment."); + if (this.lastStatus?.buttonMappings) this.patch({ buttonMappings: { ...this.lastStatus.buttonMappings, [button]: confirmed } }); + return confirmed; + }); + } + + async setLunaFuryButtonPollingRate(hertz: LunaFuryButtonPollingRate): Promise { + this.requireLunaFury(); + if (!this.getLunaFuryButtonPollingRates().includes(hertz)) throw new Error(`This mouse does not support ${hertz} Hz button polling.`); + return this.onboardOperation(async () => { + const profile = await this.currentProfile(); + await this.request(LUNAFURY_ONBOARD_WRITE.buttonRate(profile, hertz)); + const confirmed = lunafuryDecodeButtonRate(await this.readLunaFury(LUNAFURY_ONBOARD_READ.buttonRate(profile))); + if (confirmed !== hertz) throw new Error("The mouse did not confirm the button polling rate."); + this.patchLunaFury({ buttonPollingRateHz: confirmed }); + return confirmed; + }); + } + + /** Serialize multi-report edits so a profile switch cannot split an edit. */ + private onboardOperation(operation: () => Promise): Promise { + const run = this.onboardQueue.then(operation, operation); + this.onboardQueue = run.catch(() => { this.lastStatus = null; }); + return run; + } + + /** Older shared setters join LunaFury's queue without changing other brands. */ + private profileOperation(operation: (profile: number) => Promise): Promise { + const run = async () => operation(await this.currentProfile()); + return this.deviceBrand() === "LunaFury" ? this.onboardOperation(run) : run(); + } + + private async readDpiTable(profile: number): Promise<{ raw: Uint8Array; stages: LamzuDpiStage[] }> { + const luna = this.deviceBrand() === "LunaFury"; + const raw = luna ? await this.readLunaFury({ ...LUNAFURY_ONBOARD_READ.dpiStages(profile, this.maxDpiStages()), full: true }) + : await this.request(PROFILE_READ.dpiStages(profile, this.maxDpiStages())); + return { raw, stages: luna ? lunafuryDecodeDpiStages(raw, this.maxDpiStages()) : this.decodeDpiStages(raw) }; + } + + private async readLunaFuryStage(profile: number, count: number): Promise { + const reply = await this.readLunaFury(LUNAFURY_ONBOARD_READ.activeStage(profile)); + if (reply.length < 2 || reply[1] < 1 || reply[1] > count) throw new Error("The mouse reported an invalid active DPI stage."); + return reply[1] - 1; + } + + private patchDpiTable(stages: readonly LamzuDpiStage[], active: number, raw: Uint8Array): void { + this.patch({ dpiStages: stages.map((stage) => stage.x), dpiStagesY: stages.map((stage) => stage.y), + activeDpiStage: active, dpi: stages[active].x, dpiY: stages[active].y }); + this.patchLunaFury({ dpiStorage: lunafuryDecodeDpiStorage(raw, this.maxDpiStages()) }); + } + + private async writeLunaFuryDpiTable(profile: number, raw: Uint8Array, stages: LamzuDpiStage[], active: number): Promise { + await this.request(lunafuryWriteDpiStages(profile, stages, raw, this.maxDpiStages())); + const { raw: confirmedRaw, stages: confirmed } = await this.readDpiTable(profile); + if (JSON.stringify(confirmed) !== JSON.stringify(stages)) throw new Error("The mouse did not confirm the complete DPI table."); + if (await this.readLunaFuryStage(profile, confirmed.length) !== active) throw new Error("The mouse did not keep the active DPI stage."); + this.patchDpiTable(confirmed, active, confirmedRaw); + } + + /** Restore enabled count and dormant slots in one bank-scoped transaction. */ + async setLunaFuryDpiStorage(storage: LunaFuryDpiStorage): Promise { + this.requireLunaFury(); + const saved = structuredClone(storage); + return this.onboardOperation(async () => { + const profile = await this.currentProfile(); + if (saved.profile !== profile) throw new Error("DPI backup belongs to another onboard profile."); + const { raw, stages } = await this.readDpiTable(profile); + const write = lunafuryWriteDpiStorage(saved, raw, this.maxDpiStages()); + let active = await this.readLunaFuryStage(profile, stages.length); + if (active >= saved.count) { + active = saved.count - 1; + await this.request(LUNAFURY_ONBOARD_WRITE.activeStage(profile, active, stages.length)); + if (await this.readLunaFuryStage(profile, stages.length) !== active) throw new Error("The mouse did not confirm the active DPI stage."); + } + await this.request(write); + const { raw: confirmedRaw, stages: confirmed } = await this.readDpiTable(profile); + const stored = lunafuryDecodeDpiStorage(confirmedRaw, this.maxDpiStages()); + if (!stored || stored.count !== saved.count || stored.stages.some((stage, index) => stage.x !== saved.stages[index].x || stage.y !== saved.stages[index].y) + || raw.slice(2 + this.maxDpiStages() * 4, 26).some((byte, index) => byte !== confirmedRaw[2 + this.maxDpiStages() * 4 + index])) { + throw new Error("The mouse did not confirm the complete stored DPI table."); + } + if (await this.readLunaFuryStage(profile, confirmed.length) !== active) throw new Error("The mouse did not keep the active DPI stage."); + this.patchDpiTable(confirmed, active, confirmedRaw); + return saved.count; + }); + } + + async setProfile(profile: number): Promise { + this.requireLunaFury(); + const write = LUNAFURY_ONBOARD_WRITE.profile(profile); + return this.onboardOperation(async () => { + await this.request(write); + const confirmed = (await this.request(LUNAFURY_ONBOARD_READ.profile()))[0]; + if (confirmed !== profile) throw new Error("The mouse did not confirm the onboard profile."); + this.activeProfile = profile; + this.lastStatus = null; // DPI, flags, latencies and rates all belong to the newly selected bank. + return confirmed; + }); + } + + async setActiveDpiStage(stage: number): Promise { + this.requireLunaFury(); + if (!Number.isInteger(stage) || stage < 0 || stage >= this.maxDpiStages()) throw new Error("Invalid DPI stage index."); + return this.onboardOperation(async () => { + const profile = await this.currentProfile(); + const { raw, stages } = await this.readDpiTable(profile); + await this.request(LUNAFURY_ONBOARD_WRITE.activeStage(profile, stage, stages.length)); + if (await this.readLunaFuryStage(profile, stages.length) !== stage) throw new Error("The mouse did not confirm the active DPI stage."); + this.patchDpiTable(stages, stage, raw); + return stage; + }); + } + + async setDpiStageCount(count: number): Promise { + this.requireLunaFury(); + if (!Number.isInteger(count) || count < 1 || count > this.maxDpiStages()) throw new Error("Invalid DPI stage count."); + return this.onboardOperation(async () => { + const profile = await this.currentProfile(); + const { raw, stages } = await this.readDpiTable(profile); + if (!lunafuryDecodeDpiStorage(raw, this.maxDpiStages())) throw new Error("Cannot change stage count without complete stored DPI data."); + let active = await this.readLunaFuryStage(profile, stages.length); + if (active >= count) { + active = count - 1; + await this.request(LUNAFURY_ONBOARD_WRITE.activeStage(profile, active, stages.length)); + if (await this.readLunaFuryStage(profile, stages.length) !== active) throw new Error("The mouse did not confirm the active DPI stage."); + } + const next = stages.slice(0, count); + while (next.length < count) { + const offset = 2 + next.length * 4; + const x = (raw[offset] << 8) | raw[offset + 1]; + const y = (raw[offset + 2] << 8) | raw[offset + 3]; + const valid = (value: number) => value >= 50 && value <= this.maxDpi() && value % DPI_STEP === 0; + const fallback = LUNAFURY_DPI_DEFAULTS[next.length]; + next.push(valid(x) && valid(y) ? { x, y } : { x: fallback, y: fallback }); + } + await this.writeLunaFuryDpiTable(profile, raw, next, active); + return count; + }); + } + + async setDpiStageValue(stage: number, dpi: number): Promise { + return this.setLunaFuryStageAxes(stage, dpi); + } + + async setLunaFuryDpiStageAxes(stage: number, dpi: number, dpiY: number): Promise { + return this.setLunaFuryStageAxes(stage, dpi, dpiY); + } + + private async setLunaFuryStageAxes(stage: number | undefined, dpi: number, dpiY?: number): Promise { + this.requireLunaFury(); + lunafuryValidateDpi(dpi); + if (dpiY !== undefined) lunafuryValidateDpi(dpiY); + if (stage !== undefined && (!Number.isInteger(stage) || stage < 0 || stage >= this.maxDpiStages())) throw new Error("Invalid DPI stage index."); + return this.onboardOperation(async () => { + const profile = await this.currentProfile(); + const { raw, stages } = await this.readDpiTable(profile); + const active = await this.readLunaFuryStage(profile, stages.length); + const index = stage ?? active; + if (!stages[index]) throw new Error("This DPI stage is not enabled."); + const separate = dpiY === undefined ? lunafuryDecodeSeparateAxes( + await this.readLunaFury(LUNAFURY_ONBOARD_READ.separateAxes(profile)).catch(() => null), + ) : undefined; + // Axis lock controls sensor behaviour, not whether the Y value is + // stored. Explicit X/Y writes also restore dormant Y values in Games. + stages[index] = { x: dpi, y: dpiY ?? (separate === false ? dpi : stages[index].y) }; + await this.writeLunaFuryDpiTable(profile, raw, stages, active); + return dpi; + }); + } + + async setLunaFurySeparateDpiAxes(enabled: boolean): Promise { + this.requireLunaFury(); + LUNAFURY_ONBOARD_WRITE.separateAxes(1, enabled); + return this.onboardOperation(async () => { + const profile = await this.currentProfile(); + await this.request(LUNAFURY_ONBOARD_WRITE.separateAxes(profile, enabled)); + const confirmed = lunafuryDecodeSeparateAxes(await this.readLunaFury(LUNAFURY_ONBOARD_READ.separateAxes(profile))); + if (confirmed !== enabled) throw new Error("The mouse did not confirm the DPI axis mode."); + this.patchLunaFury({ separateDpiAxes: enabled }); + return enabled; + }); } private patchLunaFury(changes: Partial): void { @@ -387,44 +705,53 @@ export class LamzuHidClient { 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.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; + LUNAFURY_WRITE.angle(1, degrees); + return this.profileOperation(async (profile) => { + await this.request(LUNAFURY_WRITE.angle(profile, degrees)); + 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; + }); } 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.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; + LUNAFURY_WRITE.lightning(1, mode); + return this.profileOperation(async (profile) => { + await this.request(LUNAFURY_WRITE.lightning(profile, mode)); + 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; + }); } 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.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; + LUNAFURY_WRITE.buttonDebounce(1, button, milliseconds); + return this.profileOperation(async (profile) => { + await this.request(LUNAFURY_WRITE.buttonDebounce(profile, button, milliseconds)); + 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; + }); } 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.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."); - } - this.patchLunaFury({ wheelGuard: confirmed }); - return confirmed; + LUNAFURY_WRITE.wheelGuard(1, guard); + const wanted = { ...guard }; + return this.profileOperation(async (profile) => { + await this.request(LUNAFURY_WRITE.wheelGuard(profile, wanted)); + const confirmed = lunafuryDecodeWheelGuard(await this.readLunaFury(LUNAFURY_READ.wheelGuard(profile))); + if (!confirmed || confirmed.enabled !== wanted.enabled || confirmed.windowMs !== wanted.windowMs) { + throw new Error("The mouse did not confirm the wheel guard settings."); + } + this.patchLunaFury({ wheelGuard: confirmed }); + return confirmed; + }); } async setPollingRate(pollingRateHz: number): Promise { @@ -432,27 +759,31 @@ export class LamzuHidClient { if (!encoded || !this.getSupportedPollingRates().includes(pollingRateHz)) { throw new Error(`This mouse does not support ${pollingRateHz} Hz.`); } - const profile = await this.currentProfile(); - await this.write(PAGE.profile, WRITE.pollingRate, profile, [encoded[0]]); - const confirmed = this.decodePollingRate((await this.request(PROFILE_READ.pollingRate(profile)))[1]); - if (confirmed !== pollingRateHz) { - throw new Error(`The mouse kept ${confirmed} Hz instead of ${pollingRateHz} Hz.`); - } - this.patch({ pollingRateHz: confirmed }); - return confirmed; + return this.profileOperation(async (profile) => { + await this.write(PAGE.profile, WRITE.pollingRate, profile, [encoded[0]]); + const reply = await (this.deviceBrand() === "LunaFury" ? this.readLunaFury(PROFILE_READ.pollingRate(profile)) : this.request(PROFILE_READ.pollingRate(profile))); + const confirmed = this.decodePollingRate(reply[1]); + if (confirmed !== pollingRateHz) { + throw new Error(`The mouse kept ${confirmed} Hz instead of ${pollingRateHz} Hz.`); + } + this.patch({ pollingRateHz: confirmed }); + return confirmed; + }); } async setLiftOffDistance(value: LiftOffDistance): Promise { const encoded = LIFT_OFF_DISTANCES.find(([, name]) => name === value); if (!encoded) throw new Error(`This mouse does not support a ${value.toLowerCase()} lift-off distance.`); - const profile = await this.currentProfile(); - await this.write(PAGE.profile, WRITE.liftOffDistance, profile, [encoded[0]]); - const confirmed = this.decodeLiftOffDistance((await this.request(PROFILE_READ.liftOffDistance(profile)))[1]); - 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; + return this.profileOperation(async (profile) => { + await this.write(PAGE.profile, WRITE.liftOffDistance, profile, [encoded[0]]); + const reply = await (this.deviceBrand() === "LunaFury" ? this.readLunaFury(PROFILE_READ.liftOffDistance(profile)) : this.request(PROFILE_READ.liftOffDistance(profile))); + const confirmed = this.decodeLiftOffDistance(reply[1]); + 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 setAngleSnapping(enabled: boolean): Promise { @@ -478,14 +809,15 @@ export class LamzuHidClient { async setDongleLed(enabled: boolean): Promise { if (!this.profile()?.dongleLed) throw new Error("This receiver has no LED control."); - const profile = await this.currentProfile(); - await this.request({ - target: TARGET.dongle, page: PAGE.dongle, command: WRITE.dongleLed, length: 0x02, args: [profile, enabled ? 1 : 0], + return this.profileOperation(async (profile) => { + await this.request({ + target: TARGET.dongle, page: PAGE.dongle, command: WRITE.dongleLed, length: 0x02, args: [profile, enabled ? 1 : 0], + }); + const confirmed = (await this.request(PROFILE_READ.dongleLed(profile)))[1] === 1; + if (confirmed !== enabled) throw new Error(`The receiver left its LED ${confirmed ? "on" : "off"}.`); + this.patch({ dongleLedEnabled: confirmed }); + return confirmed; }); - const confirmed = (await this.request(PROFILE_READ.dongleLed(profile)))[1] === 1; - if (confirmed !== enabled) throw new Error(`The receiver left its LED ${confirmed ? "on" : "off"}.`); - this.patch({ dongleLedEnabled: confirmed }); - return confirmed; } private async setFlag( @@ -495,31 +827,46 @@ export class LamzuHidClient { field: "angleSnapping" | "motionSync" | "performanceMode" | "hyperMode" | "rippleControl", label: string, ): Promise { - const profile = await this.currentProfile(); - await this.write(PAGE.profile, command, profile, [enabled ? 1 : 0]); - const confirmed = (await this.request(read(profile)))[1] === 1; - if (confirmed !== enabled) { - throw new Error(`The mouse left ${label} ${confirmed ? "on" : "off"}.`); - } - this.patch({ [field]: confirmed }); - return confirmed; + return this.profileOperation(async (profile) => { + await this.write(PAGE.profile, command, profile, [enabled ? 1 : 0]); + const reply = await (this.deviceBrand() === "LunaFury" ? this.readLunaFury(read(profile)) : this.request(read(profile))); + const confirmed = reply[1] === 1; + if (confirmed !== enabled) { + throw new Error(`The mouse left ${label} ${confirmed ? "on" : "off"}.`); + } + this.patch({ [field]: confirmed }); + return confirmed; + }); } async setDebounceTime(milliseconds: number): Promise { 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 profile = await this.currentProfile(); - await this.write(PAGE.device, WRITE.debounce, profile, [milliseconds]); - const confirmed = (await this.request(PROFILE_READ.debounce(profile)))[1]; - if (confirmed !== milliseconds) { - throw new Error(`The mouse kept ${confirmed} ms of debounce instead of ${milliseconds} ms.`); - } - this.patch({ debounceMs: confirmed }); - return confirmed; + return this.profileOperation(async (profile) => { + await this.write(PAGE.device, WRITE.debounce, profile, [milliseconds]); + const reply = await (this.deviceBrand() === "LunaFury" ? this.readLunaFury(PROFILE_READ.debounce(profile)) : this.request(PROFILE_READ.debounce(profile))); + const confirmed = reply[1]; + 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 { + if (this.deviceBrand() === "LunaFury") { + const encoded = lunafuryEncodeSleep(seconds); + return this.onboardOperation(async () => { + const profile = await this.currentProfile(); + await this.write(PAGE.device, WRITE.sleepTimeout, profile, [encoded >> 8, encoded & 0xff]); + const confirmed = lunafuryDecodeSleep(await this.readLunaFury(PROFILE_READ.sleepTimeout(profile))); + if (confirmed !== seconds) throw new Error("The mouse did not confirm the sleep timeout."); + this.patch({ sleepTimeout: confirmed }); + return confirmed; + }); + } if (!Number.isInteger(seconds) || seconds < 1 || seconds > SLEEP_MAX_SECONDS) { throw new Error(`The sleep timeout must be a whole number of seconds between 1 and ${SLEEP_MAX_SECONDS}.`); } @@ -535,6 +882,7 @@ export class LamzuHidClient { } async setDpi(dpi: number, dpiY: number = dpi): Promise { + if (this.deviceBrand() === "LunaFury") return this.setLunaFuryStageAxes(undefined, dpi, dpiY); const ceiling = this.maxDpi(); for (const value of [dpi, dpiY]) { if (!Number.isInteger(value) || value < DPI_STEP || value > ceiling || value % DPI_STEP !== 0) { @@ -684,6 +1032,10 @@ export class LamzuHidClient { private async currentProfile(): Promise { const reply = await this.request(READ.activeProfile); + if (this.deviceBrand() === "LunaFury" && (reply.length < 1 || reply[0] < 1 || reply[0] > 3)) { + throw new Error("The mouse did not report a valid LunaFury profile."); + } + if (this.deviceBrand() === "LunaFury" && this.lastStatus && this.lastStatus.activeProfile !== reply[0]) this.lastStatus = null; this.activeProfile = Math.max(1, reply[0]); return this.activeProfile; } @@ -750,9 +1102,31 @@ export class LamzuHidClient { } private decodeSleepTimeout(payload: Uint8Array | null): number | null { + if (this.deviceBrand() === "LunaFury") return lunafuryDecodeSleep(payload); return compaxDecodeSleep(payload, SLEEP_DISABLED_MIN); } + private readReceiverLight(attempts?: number): Promise { + return this.request({ ...lunafuryReadReceiverLight(this.device.productId), attempts, + matchesReply: (payload) => payload.length >= 3 && payload[0] === 1 }); + } + + async setLunaFuryReceiverLightMode(mode: LunaFuryReceiverLightMode): Promise { + this.requireLunaFury(); + const spec = lunafuryWriteReceiverLight(this.device.productId, mode); + return this.onboardOperation(async () => { + // Read before writing: unsupported firmware must not receive a light write. + if (lunafuryDecodeReceiverLight(await this.readReceiverLight()) === undefined) { + throw new Error("The receiver did not report a supported light mode."); + } + await this.request(spec); + const confirmed = lunafuryDecodeReceiverLight(await this.readReceiverLight()); + if (confirmed !== mode) throw new Error("The receiver did not confirm the light mode."); + this.patchLunaFury({ receiverLightMode: confirmed }); + return confirmed; + }); + } + private decodeFirmware(label: string, payload: Uint8Array | null): string { return compaxDecodeFirmware(label, payload); } diff --git a/src/drivers/lamzu/lunafury-bottom-button.test.ts b/src/drivers/lamzu/lunafury-bottom-button.test.ts new file mode 100644 index 00000000..002b1609 --- /dev/null +++ b/src/drivers/lamzu/lunafury-bottom-button.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { LamzuHidClient } from "./hid.ts"; +import { lunafuryDecodeBottomButton, lunafuryReadBottomButton, lunafuryWriteBottomButton } from "../../lamzu/lunafury-power.ts"; + +(globalThis as { window?: { setTimeout: typeof setTimeout } }).window ??= { setTimeout }; + +type Options = { ignore?: boolean; stale?: "profile" | "button"; unsupported?: boolean; raw?: number[]; truncate?: boolean }; +function fixture(productId = 0x33, options: Options = {}) { + let active = 2, reads = 0; + const banks = [[0x15, 1], [7, 1, 6], [0x15, 1]]; + const sent: Uint8Array[] = []; + const device = { + vendorId: 0x373e, productId, opened: true, collections: [], open: async () => {}, close: async () => {}, + sendFeatureReport: async (_id: number, packet: Uint8Array) => { + const p = new Uint8Array(packet); sent.push(p); reads = 0; + if (p[4] === 0 && p[5] === 5) active = p[6]; + if (p[4] === 3 && p[5] === 0 && p[7] === 0x14 && !options.ignore) { + banks[p[6] - 1] = Array.from(p.slice(9, 6 + p[3])); + } + }, + receiveFeatureReport: async () => { + const p = sent.at(-1)!, key = `${p[4]}:${p[5]}`, profile = p[6]; + const bottom = key === "3:128" && p[7] === 0x14; + let payload = [profile, 0]; + if (key === "0:129") payload = [0, 0, 26, 0]; + if (key === "0:131") payload = [0, 90]; + if (key === "0:133") payload = [active]; + if (key === "1:129") payload = [profile, 1, 3, 32, 3, 32]; + if (["1:130", "1:128", "1:136"].includes(key)) payload = [profile, 1]; + if (key === "3:128") payload = bottom + ? [profile, 0x14, 0, ...(options.raw ?? banks[profile - 1])] + : [profile, p[7], 0, ...(p[7] === 5 ? [16, 3, 1, 2, 3] : [1, 1, p[7]])]; + if (bottom && options.truncate) payload = payload.slice(0, 4); + if (bottom && options.stale && reads++ === 0) { + if (options.stale === "profile") payload[0] = profile === 1 ? 2 : 1; + else payload[1] = 1; + } + const reply = new Uint8Array(64); + reply[0] = bottom && options.unsupported ? 0xa2 : 0xa1; + reply[3] = payload.length; reply[4] = p[4]; reply[5] = p[5]; reply.set(payload, 6); + const offset = new Uint8Array(70); offset.set(reply, 3); + return new DataView(offset.buffer, 3, 64); + }, + } as unknown as HIDDevice; + return { client: new LamzuHidClient(device), sent, banks }; +} + +test("bottom-button requests preserve both official layouts and reject invalid input", () => { + assert.deepEqual(lunafuryReadBottomButton(3), { target: 2, page: 3, command: 128, length: 6, args: [3, 20] }); + assert.deepEqual(lunafuryWriteBottomButton(2, 1), { target: 2, page: 3, command: 0, length: 5, args: [2, 20, 0, 21, 1] }); + assert.deepEqual(lunafuryWriteBottomButton(2, 2), { target: 2, page: 3, command: 0, length: 6, args: [2, 20, 0, 7, 1, 6] }); + assert.equal(lunafuryDecodeBottomButton(Uint8Array.of(2, 20, 0, 21, 1), 2), 1); + assert.equal(lunafuryDecodeBottomButton(Uint8Array.of(2, 20, 0, 21, 1, 0), 2), 1); + assert.equal(lunafuryDecodeBottomButton(Uint8Array.of(2, 20, 0, 7, 1, 6), 2), 2); + for (const mode of [0, 3, 1.5, NaN]) assert.throws(() => lunafuryWriteBottomButton(2, mode as 1)); + for (const profile of [0, 4, 1.5, NaN]) assert.throws(() => lunafuryReadBottomButton(profile)); + for (const payload of [null, [2, 20, 0, 7, 1], [1, 20, 0, 7, 1, 6], [2, 4, 0, 21, 1], + [2, 20, 1, 21, 1], [2, 20, 0, 21, 2], [2, 20, 0, 21, 1, 7], [2, 20, 0, 7, 1, 2], [2, 20, 0, 7, 1, 6, 1]]) { + assert.equal(lunafuryDecodeBottomButton(payload ? Uint8Array.from(payload) : null, 2), undefined); + } +}); + +for (const [pid, target] of [[0x32, 0], [0x33, 2], [0x54, 0], [0x84, 2]] as const) { + test(`bottom-button mode ${pid.toString(16)} reads, writes and restores only the active bank`, async () => { + const { client, sent, banks } = fixture(pid, { stale: "profile" }); + const status = await client.readStatus(); + assert.equal(status.lunafury?.bottomButtonMode, 2); + assert.equal(status.buttonMappings?.Forward, "Macro:16:1,2,3"); + assert.equal(await client.setLunaFuryBottomButtonMode(1), 1); + assert.equal((await client.readStatus(true)).lunafury?.bottomButtonMode, 1); + assert.equal(await client.setLunaFuryBottomButtonMode(2), 2); + assert.deepEqual(banks, [[21, 1], [7, 1, 6], [21, 1]]); + const writes = sent.filter((p) => p[4] === 3 && p[5] === 0); + assert.deepEqual(writes.map((p) => Array.from(p.slice(2, 6 + p[3]))), + [[target, 5, 3, 0, 2, 20, 0, 21, 1], [target, 6, 3, 0, 2, 20, 0, 7, 1, 6]]); + assert.ok(sent.filter((p) => p[4] === 3 && p[5] === 128 && p[7] === 20).every((p) => p[3] === 6 && p[8] === 0)); + }); +} + +test("stale ordinary-button replies cannot confirm a bottom-button operation", async () => { + const { client } = fixture(0x33, { stale: "button" }); + assert.equal((await client.readStatus()).lunafury?.bottomButtonMode, 2); + assert.equal(await client.setLunaFuryBottomButtonMode(1), 1); +}); + +test("unsupported or unfamiliar bottom bindings are hidden and never overwritten", async () => { + for (const options of [{ unsupported: true }, { truncate: true }, { raw: [16, 1, 7] }, { raw: [7, 1, 2] }]) { + const { client, sent } = fixture(0x33, options); + assert.equal((await client.readStatus()).lunafury?.bottomButtonMode, undefined); + await assert.rejects(client.setLunaFuryBottomButtonMode(1)); + assert.equal(sent.some((p) => p[4] === 3 && p[5] === 0), false); + } +}); + +test("ignored bottom-button writes do not report success", async () => { + await assert.rejects(fixture(0x33, { ignore: true }).client.setLunaFuryBottomButtonMode(1), /did not confirm/); +}); + +test("bottom edits cannot be split by an onboard profile switch", async () => { + const { client, banks } = fixture(); + await Promise.all([client.setLunaFuryBottomButtonMode(1), client.setProfile(3), client.setLunaFuryBottomButtonMode(2)]); + assert.deepEqual(banks, [[21, 1], [21, 1], [7, 1, 6]]); +}); + +test("invalid modes and other brands cannot issue bottom-button requests", async () => { + const { client, sent } = fixture(); + await assert.rejects(client.setLunaFuryBottomButtonMode(0 as 1)); + assert.equal(sent.length, 0); + const other = fixture(0x1d); + await assert.rejects(other.client.setLunaFuryBottomButtonMode(1)); + assert.equal(other.sent.length, 0); +}); diff --git a/src/drivers/lamzu/lunafury-buttons.test.ts b/src/drivers/lamzu/lunafury-buttons.test.ts new file mode 100644 index 00000000..b27e5d12 --- /dev/null +++ b/src/drivers/lamzu/lunafury-buttons.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { LamzuHidClient } from "./hid.ts"; + +(globalThis as { window?: { setTimeout: typeof setTimeout } }).window ??= { setTimeout }; + +function fixture(productId = 0x33, options: { ignore?: boolean; stale?: boolean; unsupported?: boolean; malformed?: boolean } = {}) { + let active = 1, reads = 0; + const banks = Array.from({ length: 3 }, () => new Map([ + [1, [1, 1, 1]], [2, [1, 1, 2]], [3, [1, 1, 3]], [4, [1, 1, 4]], [5, [16, 3, 1, 2, 3]], + ])); + const sent: Uint8Array[] = []; + const device = { + vendorId: 0x373e, productId, opened: true, collections: [], open: async () => {}, close: async () => {}, + sendFeatureReport: async (_id: number, packet: Uint8Array) => { + const p = new Uint8Array(packet); sent.push(p); reads = 0; + if (p[4] === 0 && p[5] === 5) active = p[6]; + if (p[4] === 3 && p[5] === 0 && !options.ignore) banks[p[6] - 1].set(p[7], Array.from(p.slice(9, 11 + p[10]))); + }, + receiveFeatureReport: async () => { + const p = sent.at(-1)!, key = `${p[4]}:${p[5]}`, profile = p[6]; + let payload = [profile, 0]; + if (key === "0:129") payload = [0, 0, 26, 0]; + if (key === "0:131") payload = [0, 90]; + if (key === "0:133") payload = [active]; + if (key === "1:129") payload = [profile, 1, 3, 32, 3, 32]; + if (key === "1:130" || key === "1:128" || key === "1:136") payload = [profile, 1]; + if (key === "3:128") payload = [profile, p[7], 0, ...banks[profile - 1].get(p[7])!]; + const reply = new Uint8Array(64); reply[0] = options.unsupported && p[4] === 3 ? 0xa2 : 0xa1; + reply[3] = payload.length; reply[4] = p[4]; reply[5] = p[5]; reply.set(payload, 6); + if (key === "3:128" && options.stale) { + if (reads === 0) reply[6] = profile === 1 ? 2 : 1; + if (reads === 1) reply[7] = p[7] === 1 ? 2 : 1; + reads++; + } + if (key === "3:128" && options.malformed) reply[10] = 11; + const offset = new Uint8Array(70); offset.set(reply, 3); + return new DataView(offset.buffer, 3, 64); + }, + } as unknown as HIDDevice; + return { client: new LamzuHidClient(device), sent, banks }; +} + +for (const [pid, target] of [[0x32, 0], [0x33, 2], [0x54, 0], [0x84, 2]] as const) { + test(`button mappings ${pid.toString(16)} use the active bank and correct transport target`, async () => { + const { client, banks, sent } = fixture(pid); + let status = await client.readStatus(); + assert.deepEqual(status.buttonMappings, { Left: "Left Click", Right: "Right Click", Middle: "Middle Click", Back: "Back", Forward: "Macro:16:1,2,3" }); + assert.deepEqual(status.fixedButtons, ["Left", "Forward"]); + await client.setProfile(3); + await client.setButtonMapping("Right", "Keyboard:129:4"); + await client.setButtonMapping("Middle", "DPI Cycle"); + status = await client.readStatus(true); + assert.equal(status.buttonMappings?.Right, "Keyboard:129:4"); + assert.equal(status.buttonMappings?.Middle, "DPI Cycle"); + assert.deepEqual(banks[0].get(2), [1, 1, 2]); + assert.deepEqual(banks[2].get(5), [16, 3, 1, 2, 3]); + const writes = sent.filter((p) => p[4] === 3 && p[5] === 0); + assert.ok(writes.every((p) => p[2] === target && p[6] === 3)); + assert.deepEqual(Array.from(writes[0].slice(2, 13)), [target, 7, 3, 0, 3, 2, 0, 4, 2, 129, 4]); + }); +} + +test("mapping readback ignores stale profile and physical-button replies", async () => { + const { client } = fixture(0x33, { stale: true }); + assert.equal(await client.setButtonMapping("Back", "Scroll Up"), "Scroll Up"); +}); + +test("ignored mapping writes fail and a later edit still works", async () => { + const options = { ignore: true }, { client } = fixture(0x33, options); + await assert.rejects(client.setButtonMapping("Right", "Disabled"), /did not confirm/); + options.ignore = false; + assert.equal(await client.setButtonMapping("Right", "Disabled"), "Disabled"); +}); + +test("left, receiver buttons, invalid actions and other CompX brands cannot be written", async () => { + const { client, sent } = fixture(); + for (const [button, action] of [["Left", "Disabled"], ["Receiver", "Back"], ["Right", "Macro:16:1"]]) { + await assert.rejects(client.setButtonMapping(button, action)); + } + assert.equal(sent.length, 0); + const other = fixture(0x1c); + await assert.rejects(other.client.setButtonMapping("Right", "Back"), /only available/); + assert.equal(other.sent.length, 0); +}); + +test("an existing macro is preserved even when the caller requests a supported replacement", async () => { + const { client, sent, banks } = fixture(); + await assert.rejects(client.setButtonMapping("Forward", "DPI Up"), /read-only/); + assert.deepEqual(banks[0].get(5), [16, 3, 1, 2, 3]); + assert.equal(sent.some((p) => p[4] === 3 && p[5] === 0), false); +}); + +test("unsupported or malformed mapping reads keep basic settings usable", async () => { + for (const options of [{ unsupported: true }, { malformed: true }]) { + const status = await fixture(0x33, options).client.readStatus(); + assert.equal(status.dpi, 800); + assert.equal(status.buttonMappings, undefined); + assert.equal(status.buttonOptions, undefined); + } +}); + +test("mapping edits cannot be split by a concurrent profile switch", async () => { + const { client, banks, sent } = fixture(); + await Promise.all([client.setButtonMapping("Back", "Mute"), client.setProfile(2), client.setButtonMapping("Back", "DPI Down")]); + assert.deepEqual(banks[0].get(4), [5, 2, 0, 226]); + assert.deepEqual(banks[1].get(4), [7, 1, 2]); + assert.deepEqual(sent.filter((p) => p[4] === 3 && p[5] === 0).map((p) => p[6]), [1, 2]); +}); diff --git a/src/drivers/lamzu/lunafury-onboard.test.ts b/src/drivers/lamzu/lunafury-onboard.test.ts new file mode 100644 index 00000000..526b0cc2 --- /dev/null +++ b/src/drivers/lamzu/lunafury-onboard.test.ts @@ -0,0 +1,355 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { LamzuHidClient } from "./hid.ts"; + +(globalThis as { window?: { setTimeout: typeof setTimeout } }).window ??= { setTimeout }; + +function fixture(productId = 0x0033, options: { ignore?: string; stale?: string; fail?: string; unsupported?: boolean; truncate?: boolean } = {}) { + let activeProfile = 1; + const banks = Array.from({ length: 3 }, (_, index) => ({ + count: 3, active: 2, axes: true, scan: 1, usb: 1, lod: 1, debounce: 0, + flags: {} as Record, angle: 0, lightning: 0, + latency: [0, 0, 0, 3], wheel: false, window: 100, + table: [400 + index * 50, 800, 800 + index * 50, 1600, 1600 + index * 50, 3200, 3200, 6400, 6400, 6400, 30000, 30000], + })); + const sent: Uint8Array[] = []; + const writes: Array<{ key: string; profile: number; activeProfile: number }> = []; + let attempts = 0; + const device = { + vendorId: 0x373e, productId, productName: "LunaFury", opened: true, + collections: [], open: async () => {}, close: async () => {}, + sendFeatureReport: async (_id: number, packet: Uint8Array) => { + const copy = new Uint8Array(packet); sent.push(copy); attempts = 0; + const key = `${copy[4]}:${copy[5]}`; + if (copy[5] < 0x80 && key !== "0:5") writes.push({ key, profile: copy[6], activeProfile }); + if (options.ignore === key) return; + const bank = banks[copy[6] - 1]; + if (key === "0:5") activeProfile = copy[6]; + if (!bank) return; + if (key === "0:31") bank.scan = copy[7]; + if (key === "1:0") bank.usb = copy[7]; + if (key === "1:8") bank.lod = copy[7]; + if (key === "0:8") bank.debounce = copy[7]; + if (copy[4] === 1 && [4, 9, 0x0a, 0x0b, 0x13].includes(copy[5])) bank.flags[copy[5]] = copy[7]; + if (key === "1:20") bank.angle = copy[7] > 127 ? copy[7] - 256 : copy[7]; + if (key === "0:24") bank.lightning = copy[7]; + if (key === "0:18") bank.latency[copy[8]] = (copy[9] << 8) | copy[10]; + if (key === "0:25") { bank.wheel = copy[7] === 1; bank.window = (copy[8] << 8) | copy[9]; } + if (key === "1:2") bank.active = copy[7]; + if (key === "1:13") bank.axes = copy[7] === 1; + if (key === "1:1") { + bank.count = copy[7]; + bank.table = Array.from({ length: 12 }, (_, index) => (copy[8 + index * 2] << 8) | copy[9 + index * 2]); + } + }, + receiveFeatureReport: async () => { + const packet = sent.at(-1)!; + const key = `${packet[4]}:${packet[5]}`; + if (options.fail === key) throw new Error("Injected feature-report read failure."); + const profile = packet[6]; + const bank = banks[profile - 1] ?? banks[activeProfile - 1]; + let payload = [profile, 0]; + if (key === "0:129") payload = [0, 0, 26, 0]; + if (key === "0:131") payload = [0, 90]; + if (key === "0:133") payload = [activeProfile]; + if (key === "0:159") payload = [profile, bank.scan]; + if (key === "1:129") payload = [profile, bank.count, ...bank.table.flatMap((value) => [value >> 8, value & 255])]; + if (key === "1:130") payload = [profile, bank.active]; + if (key === "1:141") payload = [profile, +bank.axes]; + if (key === "1:128") payload = [profile, bank.usb]; + if (key === "1:136") payload = [profile, bank.lod]; + if (key === "0:136") payload = [profile, bank.debounce]; + if (packet[4] === 1 && [0x84, 0x89, 0x8a, 0x8b, 0x93].includes(packet[5])) payload = [profile, bank.flags[packet[5] - 0x80] ?? 0]; + if (key === "1:148") payload = [profile, bank.angle & 255]; + if (key === "0:152") payload = [profile, bank.lightning, 0, 0]; + if (key === "0:146") payload = [profile, 0, packet[8], 0, bank.latency[packet[8]], ...Array(14).fill(0)]; + if (key === "0:153") payload = [profile, +bank.wheel, bank.window >> 8, bank.window & 255]; + if (key === "0:135") payload = [profile, 1, 44]; + const reply = new Uint8Array(64); + reply[0] = options.unsupported && ["0:159", "1:141"].includes(key) ? 0xa2 : 0xa1; + reply[4] = packet[4]; reply[5] = packet[5]; reply[3] = payload.length; + reply.set(payload, 6); + if (options.stale === key && attempts++ === 0) reply[6] = profile === 1 ? 2 : 1; + if (options.truncate && key === "1:129") return new DataView(reply.slice(0, 10).buffer); + const buffer = new Uint8Array(70); buffer.set(reply, 3); + return new DataView(buffer.buffer, 3, 64); + }, + } as unknown as HIDDevice; + return { client: new LamzuHidClient(device), sent, banks, device, writes }; +} + +test("LunaFury no longer exposes custom colors or sends color-table reads", async () => { + const { client, sent } = fixture(); + assert.equal("setDpiStageColor" in client, false); + assert.equal((await client.readStatus()).dpiStageColors, undefined); + await client.setDpiStageCount(4); + assert.equal(sent.some((packet) => packet[4] === 2 && [1, 0x81].includes(packet[5])), false); +}); + +for (const [pid, target, max] of [[0x32, 0, 6], [0x33, 2, 6], [0x54, 0, 5], [0x84, 2, 5]] as const) { + test(`LunaFury ${pid.toString(16)} exposes model-specific onboard limits`, async () => { + const { client } = fixture(pid); + const status = await client.readStatus(); + assert.equal(status.profileCount, 3); + assert.equal(status.ui?.dpiStageEditor?.maxStages, max); + assert.equal(status.ui?.dpiStageEditor?.countEditable, true); + assert.deepEqual(status.dpiStages, [400, 800, 1600]); + assert.deepEqual(status.dpiStagesY, [800, 1600, 3200]); + assert.equal(status.activeDpiStage, 1); + assert.equal(status.supportsSeparateDpiAxes, true); + assert.equal(status.lunafury?.separateDpiAxes, true); + assert.deepEqual(status.lunafury?.supportedButtonPollingRates, pid === 0x32 ? [1000] : [1000, 2000, 4000, 8000]); + }); + + test(`LunaFury ${pid.toString(16)} edits both axes without touching other stages`, async () => { + const { client, banks, sent } = fixture(pid); + await client.readStatus(); + const previous = [...banks[0].table]; + assert.equal(await client.setDpiStageValue(0, 1200), 1200); + assert.deepEqual(banks[0].table, [1200, previous[1], ...previous.slice(2)]); + await client.setLunaFuryDpiStageAxes(0, 1200, 2400); + assert.equal(banks[0].table[1], 2400); + await client.setActiveDpiStage(0); + const status = await client.readStatus(true); + assert.equal(status.dpi, 1200); assert.equal(status.dpiY, 2400); + const writes = sent.filter((packet) => packet[5] < 0x80); + assert.ok(writes.every((packet) => packet[2] === target && packet[6] === 1)); + assert.equal(writes.find((packet) => packet[5] === 1)?.[3], 26); + }); + + test(`LunaFury ${pid.toString(16)} changes count and clamps active stage before shrinking`, async () => { + const { client, banks, sent } = fixture(pid); + await client.setActiveDpiStage(2); + const previous = [...banks[0].table]; + await client.setDpiStageCount(1); + assert.equal(banks[0].active, 1); assert.equal(banks[0].count, 1); + assert.deepEqual(banks[0].table, previous, "dormant stage values survive shrinking"); + await client.setDpiStageCount(max); + assert.equal(banks[0].count, max); + assert.deepEqual(banks[0].table, previous); + const writes = sent.filter((packet) => packet[4] === 1 && packet[5] < 0x80); + assert.deepEqual(writes.map((packet) => packet[5]), [2, 2, 1, 1]); + }); + + test(`LunaFury ${pid.toString(16)} switches banks and discards old live cache`, async () => { + const { client, banks, sent } = fixture(pid); + await client.readStatus(); + assert.equal(await client.setProfile(3), 3); + const status = await client.readStatus(true); + assert.equal(status.activeProfile, 3); assert.equal(status.dpi, 900); + await client.setDpiStageValue(1, 2000); + assert.equal(banks[0].table[2], 800); assert.equal(banks[2].table[2], 2000); + assert.ok(sent.filter((packet) => packet[5] === 5 || packet[5] === 1).every((packet) => packet[2] === target && packet[6] === 3)); + }); +} + +test("independent button polling does not change USB report frequency", async () => { + const { client, banks } = fixture(); + await client.readStatus(); + for (const hz of [1000, 2000, 4000, 8000] as const) assert.equal(await client.setLunaFuryButtonPollingRate(hz), hz); + assert.equal(banks[0].usb, 1); + assert.equal((await client.readStatus(true)).lunafury?.buttonPollingRateHz, 8000); + const wired = fixture(0x32); + await assert.rejects(wired.client.setLunaFuryButtonPollingRate(8000), /does not support/); + assert.equal(wired.sent.length, 0); +}); + +test("new setters fail on ignored writes and stale profile replies cannot confirm them", async () => { + for (const [ignore, operation] of [ + ["0:5", (client: LamzuHidClient) => client.setProfile(3)], + ["0:31", (client: LamzuHidClient) => client.setLunaFuryButtonPollingRate(8000)], + ["1:2", (client: LamzuHidClient) => client.setActiveDpiStage(0)], + ["1:13", (client: LamzuHidClient) => client.setLunaFurySeparateDpiAxes(false)], + ["1:1", (client: LamzuHidClient) => client.setDpiStageValue(0, 1200)], + ["1:1", (client: LamzuHidClient) => client.setDpiStageCount(4)], + ] as const) { + const { client } = fixture(0x33, { ignore, stale: ignore === "1:1" ? "1:129" : undefined }); + await assert.rejects(operation(client), /did not confirm/); + } +}); + +test("axis lock, invalid values and disabled stages cannot write arbitrary data", async () => { + const { client, sent, banks } = fixture(); + await client.setLunaFurySeparateDpiAxes(false); + await client.setDpiStageValue(0, 1250); + assert.deepEqual(banks[0].table.slice(0, 2), [1250, 1250]); + const before = sent.filter((packet) => packet[5] < 0x80).length; + for (const operation of [() => client.setProfile(4), () => client.setDpiStageCount(7), + () => client.setDpiStageValue(0, 1234), () => client.setDpiStageValue(5, 800)]) await assert.rejects(operation()); + assert.equal(sent.filter((packet) => packet[5] < 0x80).length, before); + await client.setLunaFuryDpiStageAxes(0, 800, 1600); + assert.deepEqual(banks[0].table.slice(0, 2), [800, 1600], "axis lock must not prevent restoring dormant Y values"); +}); + +test("multi-report stage edits and profile switches are serialized", async () => { + const { client, banks } = fixture(); + await Promise.all([client.setDpiStageValue(0, 2000), client.setProfile(2), client.setDpiStageValue(0, 3000)]); + assert.equal(banks[0].table[0], 2000); assert.equal(banks[1].table[0], 3000); +}); + +test("unsupported optional features stay absent and truncated DPI tables fail safely", async () => { + const { client } = fixture(0x33, { unsupported: true }); + const status = await client.readStatus(); + assert.equal(status.lunafury?.buttonPollingRateHz, undefined); + assert.equal(status.lunafury?.separateDpiAxes, undefined); + assert.equal(status.supportsSeparateDpiAxes, undefined); + await client.setDpi(1200); + assert.equal((await client.readStatus()).dpi, 1200, "an unsupported axis toggle must not block the existing DPI control"); + await assert.rejects(fixture(0x33, { truncate: true }).client.setDpiStageValue(0, 1600), /Truncated/); +}); + +for (const pid of [0x32, 0x33, 0x54, 0x84]) { + test(`LunaFury ${pid.toString(16)} keeps axis-read failures unknown and recovers on a full reread`, async () => { + const options: { fail?: string } = { fail: "1:141" }; + const { client, banks, sent } = fixture(pid, options); + const failed = await client.readStatus(); + assert.equal(failed.supportsSeparateDpiAxes, undefined); + assert.equal(failed.lunafury?.separateDpiAxes, undefined); + assert.deepEqual(failed.dpiStages, [400, 800, 1600]); + assert.deepEqual(failed.dpiStagesY, [800, 1600, 3200]); + assert.ok(sent.some(packet => packet[4] === 1 && packet[5] === 0x8d)); + + options.fail = undefined; + banks[0].axes = false; + const recovered = await client.readStatus(); + assert.equal(recovered.supportsSeparateDpiAxes, true, "disabled X/Y is still a supported control"); + assert.equal(recovered.lunafury?.separateDpiAxes, false); + assert.deepEqual(recovered.dpiStagesY, failed.dpiStagesY); + assert.ok(sent.every(packet => packet[5] >= 0x80), "capability reads must not write the device"); + }); +} + +test("live status detects a profile switched outside this client", async () => { + const { client, device } = fixture(); + await client.readStatus(); + const other = new LamzuHidClient(device); + await other.setProfile(3); + const status = await client.readStatus(true); + assert.equal(status.activeProfile, 3); + assert.deepEqual(status.dpiStages, [500, 900, 1700]); +}); + +for (const pid of [0x32, 0x33, 0x54, 0x84]) { + test(`LunaFury ${pid.toString(16)} live refresh reads physical DPI switches and changed tables`, async () => { + const { client, banks, sent } = fixture(pid); + await client.readStatus(); + banks[0].active = 3; + let live = await client.readStatus(true); + assert.equal(live.activeDpiStage, 2); + assert.equal(live.dpi, 1600); assert.equal(live.dpiY, 3200); + banks[0].count = 4; banks[0].active = 4; + banks[0].table[6] = 2000; banks[0].table[7] = 4000; + live = await client.readStatus(true); + assert.equal(live.activeDpiStage, 3); + assert.equal(live.dpi, 2000); assert.equal(live.dpiY, 4000); + assert.deepEqual(live.dpiStages, [400, 800, 1600, 2000]); + assert.deepEqual(live.dpiStagesY, [800, 1600, 3200, 4000]); + assert.equal(sent.some((packet) => packet[5] < 0x80), false, "refresh must not write the device"); + }); +} + +const oldSetters = [ + ["USB polling", (client: LamzuHidClient) => client.setPollingRate(1000)], + ["LOD", (client: LamzuHidClient) => client.setLiftOffDistance("High")], + ["angle snap", (client: LamzuHidClient) => client.setAngleSnapping(true)], + ["motion sync", (client: LamzuHidClient) => client.setMotionSync(true)], + ["tracking", (client: LamzuHidClient) => client.setPerformanceMode(true)], + ["competitive", (client: LamzuHidClient) => client.setHyperMode(true)], + ["ripple", (client: LamzuHidClient) => client.setRippleControl(true)], + ["global debounce", (client: LamzuHidClient) => client.setDebounceTime(5)], + ["sensor angle", (client: LamzuHidClient) => client.setAngleTuning(-15)], + ["Lightning Trigger", (client: LamzuHidClient) => client.setLunaFuryLightningMode(1)], + ["button debounce", (client: LamzuHidClient) => client.setLunaFuryButtonDebounce("middle", 8)], + ["wheel guard", (client: LamzuHidClient) => client.setLunaFuryWheelGuard({ enabled: true, windowMs: 200 })], +] as const; + +for (const [name, operation] of oldSetters) { + test(`${name} shares the LunaFury bank queue with switches, new edits and live reads`, async () => { + const { client, writes } = fixture(); + await client.readStatus(); + const [, , , , live] = await Promise.all([ + operation(client), client.setProfile(2), client.setDpiStageValue(0, 2000), operation(client), client.readStatus(true), + ]); + assert.deepEqual(writes.map(({ profile, activeProfile }) => [profile, activeProfile]), [[1, 1], [2, 2], [2, 2]]); + assert.equal(live.activeProfile, 2, "a queued live read must not restore the previous bank cache"); + assert.equal(live.dpiStages?.[0], 2000); + // Also cover the reverse ordering: a switch invoked first owns the next edit. + await Promise.all([client.setProfile(3), operation(client)]); + assert.deepEqual(writes.at(-1)?.profile, 3); + assert.equal(writes.at(-1)?.activeProfile, 3); + }); +} + +test("a rejected old setter does not stall later bank operations", async () => { + const { client, writes } = fixture(0x33, { ignore: "1:0" }); + const results = await Promise.allSettled([client.setPollingRate(2000), client.setProfile(2), client.setDpiStageValue(0, 2000)]); + assert.equal(results[0].status, "rejected"); + assert.equal(results[1].status, "fulfilled"); assert.equal(results[2].status, "fulfilled"); + assert.ok(writes.every(({ profile, activeProfile }) => profile === activeProfile)); + assert.equal((await client.readStatus(true)).activeProfile, 2); +}); + +test("new controls are gated to LunaFury and do not expose generic Lamzu banks", async () => { + const { device, sent } = fixture(); + Object.defineProperty(device, "productId", { value: 0x001d }); + const client = new LamzuHidClient(device); + await assert.rejects(client.setProfile(2), /only available on LunaFury/); + await assert.rejects(client.setDpiStageCount(4), /only available on LunaFury/); + await assert.rejects(client.setLunaFuryButtonPollingRate(8000), /only available on LunaFury/); + assert.equal(sent.length, 0); +}); + +for (const pid of [0x32, 0x33, 0x54, 0x84]) { + test(`LunaFury ${pid.toString(16)} restores all stored slots without activating dormant reserved values`, async () => { + const { client, banks } = fixture(pid); + // Invalid as enabled DPI, but legitimate opaque storage while disabled. + banks[0].table[8] = 0; banks[0].table[9] = 65535; + const original = [...banks[0].table]; + const saved = (await client.readStatus()).lunafury!.dpiStorage!; + assert.equal(saved.stages.length, pid === 0x32 || pid === 0x33 ? 6 : 5); + await client.setDpiStageCount(4); + await client.setLunaFuryDpiStageAxes(3, 2000, 4000); + await client.setActiveDpiStage(3); + await client.setLunaFuryDpiStorage(saved); + assert.equal(banks[0].count, 3); assert.equal(banks[0].active, 3); + assert.deepEqual(banks[0].table, original); + assert.deepEqual((await client.readStatus(true)).lunafury!.dpiStorage, saved); + await client.setDpiStageCount(4); + const reenabled = await client.readStatus(true); + assert.equal(reenabled.dpiStages![3], 3200); assert.equal(reenabled.dpiStagesY![3], 6400); + assert.deepEqual(banks[0].table, original); + }); +} + +test("stored DPI restores reject ignored writes and malformed or wrong-bank backups", async () => { + const { client, banks, writes } = fixture(0x33, { ignore: "1:1" }); + const saved = (await client.readStatus()).lunafury!.dpiStorage!; + banks[0].table[6] = 2000; + await assert.rejects(client.setLunaFuryDpiStorage(saved), /complete stored/); + for (const invalid of [{ ...saved, count: 0 }, { ...saved, stages: saved.stages.slice(0, 3) }, + { ...saved, stages: saved.stages.map((stage, index) => index === 0 ? { ...stage, x: 51 } : stage) }]) { + const count = writes.length; + await assert.rejects(client.setLunaFuryDpiStorage(invalid)); + assert.equal(writes.length, count); + } + await client.setProfile(2); + const count = writes.length; + await assert.rejects(client.setLunaFuryDpiStorage(saved), /another onboard/); + assert.equal(writes.length, count); + await client.setProfile(1); + assert.equal((await client.readStatus(true)).activeProfile, 1, "a rejected restore must not stall the queue"); +}); + +test("stored DPI backups are cloned before waiting for bank operations", async () => { + const { client, banks, writes } = fixture(); + const saved = (await client.readStatus()).lunafury!.dpiStorage!; + const pending = client.setLunaFuryDpiStorage(saved); + saved.stages[0].x = 2000; + await pending; + assert.equal(banks[0].table[0], 400); + const [, result] = await Promise.allSettled([client.setProfile(2), client.setLunaFuryDpiStorage(saved)]); + assert.equal(result.status, "rejected"); + assert.ok(writes.every(write => write.profile === write.activeProfile)); + Object.defineProperty(client.device, "productId", { value: 0x001d }); + await assert.rejects(client.setLunaFuryDpiStorage(saved), /only available on LunaFury/); +}); diff --git a/src/drivers/lamzu/lunafury-power.test.ts b/src/drivers/lamzu/lunafury-power.test.ts new file mode 100644 index 00000000..6e0d4151 --- /dev/null +++ b/src/drivers/lamzu/lunafury-power.test.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { LamzuHidClient } from "./hid.ts"; +import { lunafuryDecodeReceiverLight, lunafuryDecodeSleep, lunafuryEncodeSleep, + lunafuryWriteReceiverLight } from "../../lamzu/lunafury-power.ts"; + +(globalThis as { window?: { setTimeout: typeof setTimeout } }).window ??= { setTimeout }; + +function fixture(productId = 0x33, options: { ignore?: string; stale?: string; light?: number; unsupported?: boolean; truncate?: boolean } = {}) { + let active = 2, light = options.light ?? 2, attempts = 0; + const sleeps = [60, 300, 1800]; + const sent: Uint8Array[] = []; + const device = { + vendorId: 0x373e, productId, opened: true, collections: [], + open: async () => {}, close: async () => {}, + sendFeatureReport: async (_id: number, packet: Uint8Array) => { + const copy = new Uint8Array(packet); sent.push(copy); attempts = 0; + const key = `${copy[4]}:${copy[5]}`; + if (key === options.ignore) return; + if (key === "0:5") active = copy[6]; + if (key === "0:7") sleeps[copy[6] - 1] = (copy[7] << 8) | copy[8]; + if (key === "2:0") light = copy[8]; + }, + receiveFeatureReport: async () => { + const packet = sent.at(-1)!; + const key = `${packet[4]}:${packet[5]}`, profile = packet[6]; + let payload = [profile, 0]; + if (key === "0:129") payload = [0, 0, 26, 0]; + if (key === "0:131") payload = [0, 90]; + if (key === "0:133") payload = [active]; + if (key === "1:129") payload = [profile, 1, 3, 32, 3, 32]; + if (["1:130", "1:128", "1:136"].includes(key)) payload = [profile, 1]; + if (key === "0:135") payload = [profile, sleeps[profile - 1] >> 8, sleeps[profile - 1] & 255]; + if (key === "2:128") payload = [1, 0, light, 0, 0, 0]; + if (options.truncate && ["0:135", "2:128"].includes(key)) payload = payload.slice(0, 2); + if (options.stale === key && attempts++ === 0) payload[0] = key === "2:128" ? 2 : profile === 1 ? 2 : 1; + const reply = new Uint8Array(64); + reply[0] = options.unsupported && key === "2:128" ? 0xa2 : 0xa1; + reply[3] = payload.length; reply[4] = packet[4]; reply[5] = packet[5]; reply.set(payload, 6); + const buffer = new Uint8Array(70); buffer.set(reply, 3); + return new DataView(buffer.buffer, 3, 64); + }, + } as unknown as HIDDevice; + return { client: new LamzuHidClient(device), device, sent, sleeps }; +} + +test("sleep codec preserves Never without accepting reserved or malformed values", () => { + assert.equal(lunafuryEncodeSleep(0), 0xffff); + assert.equal(lunafuryDecodeSleep(Uint8Array.of(2, 255, 255)), 0); + assert.equal(lunafuryDecodeSleep(Uint8Array.of(2, 1, 44)), 300); + for (const payload of [null, Uint8Array.of(2, 0), Uint8Array.of(2, 0, 0), Uint8Array.of(2, 255, 0)]) { + assert.equal(lunafuryDecodeSleep(payload), null); + } + for (const invalid of [-1, 1.5, 65535, NaN, Infinity]) assert.throws(() => lunafuryEncodeSleep(invalid)); +}); + +for (const [pid, target] of [[0x32, 0], [0x33, 2], [0x54, 0], [0x84, 2]] as const) { + test(`LunaFury ${pid.toString(16)} sets Never and restores the active bank timeout`, async () => { + const { client, sent, sleeps } = fixture(pid, { stale: "0:135" }); + assert.equal(client.getSleepOptions()[0], 0); + assert.equal(client.getSleepOptions().at(-1), 1800); + assert.equal((await client.readStatus()).sleepTimeout, 300); + assert.equal(await client.setSleepTimeout(0), 0); + assert.equal((await client.readStatus(true)).sleepTimeout, 0); + assert.deepEqual(sleeps, [60, 65535, 1800]); + assert.equal(await client.setSleepTimeout(300), 300); + const writes = sent.filter((p) => p[4] === 0 && p[5] === 7); + assert.deepEqual(writes.map((p) => Array.from(p.slice(6, 9))), [[2, 255, 255], [2, 1, 44]]); + assert.ok(writes.every((p) => p[2] === target && p[3] === 3)); + }); +} + +for (const [pid, target] of [[0x33, 0], [0x84, 1]] as const) { + test(`LunaFury ${pid.toString(16)} addresses receiver lighting independently of the bank`, async () => { + const { client, sent, sleeps } = fixture(pid, { stale: "2:128" }); + assert.equal((await client.readStatus()).lunafury?.receiverLightMode, 2); + for (const mode of [6, 11, 0, 2] as const) assert.equal(await client.setLunaFuryReceiverLightMode(mode), mode); + await client.setProfile(3); + assert.equal((await client.readStatus()).lunafury?.receiverLightMode, 2); + const packets = sent.filter((p) => p[4] === 2 && (p[5] === 0 || p[5] === 0x80)); + assert.ok(packets.every((p) => p[2] === target && p[3] === 6 && p[6] === 1)); + assert.deepEqual(packets.filter((p) => p[5] === 0).map((p) => Array.from(p.slice(6, 12))), + [[1, 0, 6, 0, 0, 0], [1, 0, 11, 0, 0, 0], [1, 0, 0, 0, 0, 0], [1, 0, 2, 0, 0, 0]]); + assert.deepEqual(sleeps, [60, 300, 1800]); + }); +} + +test("wired and non-LunaFury devices cannot write receiver lighting or Never", async () => { + for (const pid of [0x32, 0x54, 0x1d]) { + const { client, sent } = fixture(pid); + await assert.rejects(client.setLunaFuryReceiverLightMode(2)); + assert.equal(sent.length, 0); + if (pid === 0x1d) { + assert.ok(!client.getSleepOptions().includes(0)); + await assert.rejects(client.setSleepTimeout(0)); + } else { + assert.equal((await client.readStatus()).lunafury?.receiverLightMode, undefined); + assert.ok(!sent.some((p) => p[4] === 2 && (p[5] === 0 || p[5] === 0x80))); + } + } +}); + +test("ignored writes are not reported as success", async () => { + await assert.rejects(fixture(0x33, { ignore: "0:7" }).client.setSleepTimeout(0), /did not confirm/); + await assert.rejects(fixture(0x33, { ignore: "2:0" }).client.setLunaFuryReceiverLightMode(6), /did not confirm/); +}); + +test("unknown, unsupported and truncated receiver modes stay hidden and cannot write", async () => { + for (const options of [{ light: 99 }, { unsupported: true }, { truncate: true }]) { + const { client, sent } = fixture(0x33, options); + assert.equal((await client.readStatus()).lunafury?.receiverLightMode, undefined); + await assert.rejects(client.setLunaFuryReceiverLightMode(6)); + assert.ok(!sent.some((p) => p[4] === 2 && p[5] === 0)); + } + assert.equal(lunafuryDecodeReceiverLight(Uint8Array.of(2, 0, 2)), undefined); + assert.throws(() => lunafuryWriteReceiverLight(0x33, 3 as 2)); +}); + +test("sleep edits cannot be split by a profile switch", async () => { + const { client, sleeps } = fixture(); + await Promise.all([client.setSleepTimeout(0), client.setProfile(3), client.setSleepTimeout(120)]); + assert.deepEqual(sleeps, [60, 65535, 120]); +}); diff --git a/src/drivers/lamzu/lunafury.test.ts b/src/drivers/lamzu/lunafury.test.ts index d602a4d1..29b8fc68 100644 --- a/src/drivers/lamzu/lunafury.test.ts +++ b/src/drivers/lamzu/lunafury.test.ts @@ -6,6 +6,12 @@ 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 dpiStorageFor(productId = 0x0033) { + return { profile: 3, count: 1, + stages: Array.from({ length: productId === 0x0054 || productId === 0x0084 ? 5 : 6 }, + (_, index) => ({ x: index === 0 ? 800 : 0, y: index === 0 ? 800 : 0 })) }; +} + function fakeLunaFury(productId = 0x0033, options: { unsupported?: boolean; ignoreWrites?: boolean; @@ -37,15 +43,17 @@ function fakeLunaFury(productId = 0x0033, options: { 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; + // This fixture covers the original extras; onboard controls have a + // separate fixture with independent profiles, axes and stage tables. + reply[0] = key === "1:141" || key === "0:159" || 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:129") payload = [profile, 1, 3, 32, 3, 32, ...Array(20).fill(0)]; + if (key === "1:128" || key === "1:130" || 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)]; @@ -68,6 +76,7 @@ for (const [pid, target] of [[0x0032, 0], [0x0033, 2], [0x0054, 0], [0x0084, 2]] assert.equal(status.brand, "LunaFury"); assert.equal(status.angleTuning, -12); assert.deepEqual(status.lunafury, { + dpiStorage: dpiStorageFor(pid), lightningMode: 1, leftDebounceMs: 3, rightDebounceMs: 7, middleDebounceMs: 10, wheelGuard: { enabled: true, windowMs: 200 }, }); @@ -97,6 +106,7 @@ for (const [pid, target] of [[0x0032, 0], [0x0033, 2], [0x0054, 0], [0x0084, 2]] const status = await client.readStatus(); assert.equal(status.angleTuning, -30); assert.deepEqual(status.lunafury, { + dpiStorage: dpiStorageFor(pid), lightningMode: 2, leftDebounceMs: 15, rightDebounceMs: 0, middleDebounceMs: 1, wheelGuard: { enabled: false, windowMs: 100 }, }); @@ -116,6 +126,7 @@ for (const [pid, target] of [[0x0032, 0], [0x0033, 2], [0x0054, 0], [0x0084, 2]] state.latency[2] = 8; const status = await new LamzuHidClient(device).readStatus(); assert.deepEqual(status.lunafury, { + dpiStorage: dpiStorageFor(pid), lightningMode: 1, leftDebounceMs: 3, rightDebounceMs: 8, middleDebounceMs: 10, wheelGuard: { enabled: true, windowMs: 200 }, }); @@ -139,6 +150,7 @@ for (const [pid, target] of [[0x0032, 0], [0x0033, 2], [0x0054, 0], [0x0084, 2]] const status = await new LamzuHidClient(device).readStatus(); assert.equal(status.angleTuning, -12); assert.deepEqual(status.lunafury, { + dpiStorage: dpiStorageFor(pid), lightningMode: 1, leftDebounceMs: 3, rightDebounceMs: 7, middleDebounceMs: 10, wheelGuard: { enabled: true, windowMs: 200 }, }); @@ -172,6 +184,7 @@ test("LunaFury readback waits past stale profiles and button selectors", async ( const status = await client.readStatus(true); assert.equal(status.angleTuning, 15); assert.deepEqual(status.lunafury, { + dpiStorage: dpiStorageFor(), lightningMode: 2, leftDebounceMs: 4, rightDebounceMs: 8, middleDebounceMs: 12, wheelGuard: { enabled: false, windowMs: 100 }, }); @@ -202,7 +215,9 @@ test("LunaFury exhausted stale replies stay absent and bounded", async () => { 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)); + const { dpiStorage, ...controls } = status.lunafury!; + assert.deepEqual(dpiStorage, dpiStorageFor()); + assert.ok(Object.values(controls).every((value) => value === undefined)); assert.equal(receives, 12, "each optional read keeps its two-attempt limit"); }); @@ -224,7 +239,9 @@ test("unsupported LunaFury controls do not break the base device status", async 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)); + const { dpiStorage, ...controls } = status.lunafury!; + assert.deepEqual(dpiStorage, dpiStorageFor()); + assert.ok(Object.values(controls).every((value) => value === undefined)); }); test("LunaFury keeps only explicitly reported EL/ES color suffixes", () => { diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 04abad8b..ffcc6d06 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -356,6 +356,8 @@ export interface MouseStatus { motionSync?: boolean | null; /** On-device DPI stages, where supported (Teevolution, Ninjutso, …). */ dpiStages?: number[]; + /** Per-stage Y DPI on devices that support independently stored axes. */ + dpiStagesY?: number[]; /** RGB colour for each DPI stage, as lowercase #rrggbb. */ dpiStageColors?: string[]; /** Active DPI stage index into `dpiStages` (0-based). */ diff --git a/src/lamzu/index.ts b/src/lamzu/index.ts index 94b702f5..9821cc7a 100644 --- a/src/lamzu/index.ts +++ b/src/lamzu/index.ts @@ -1,6 +1,9 @@ export * from "../compx/codec.js"; export * from "./atlantis.js"; export * from "./lunafury.js"; +export * from "./lunafury-power.js"; +export * from "./lunafury-onboard.js"; +export * from "./lunafury-buttons.js"; export * from "./magnetic.js"; export interface LamzuProduct { model: string; diff --git a/src/lamzu/lunafury-buttons.test.ts b/src/lamzu/lunafury-buttons.test.ts new file mode 100644 index 00000000..a6d85cb5 --- /dev/null +++ b/src/lamzu/lunafury-buttons.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { compaxEncodeRequest } from "../compx/codec.js"; +import { LUNAFURY_BUTTON_ACTIONS, lunafuryButtonActionWritable, lunafuryDecodeButton, + lunafuryDecodeButtonAction, lunafuryEncodeButtonAction, lunafuryKeyboardAction, + lunafuryParseKeyboardAction, lunafuryReadButton, lunafuryWriteButton } from "./lunafury-buttons.js"; + +test("button requests use the vendor page, selectors, length and data order", () => { + assert.deepEqual(Array.from(compaxEncodeRequest(lunafuryReadButton(3, "Forward")).slice(2, 21)), + [2, 15, 3, 128, 3, 5, 0, 255, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + assert.deepEqual(lunafuryWriteButton(2, "Back", "Keyboard:129:4"), + { target: 2, page: 3, command: 0, length: 7, args: [2, 4, 0, 4, 2, 129, 4] }); + assert.deepEqual(lunafuryEncodeButtonAction("Calculator"), { functionId: 5, data: [1, 146] }); + assert.deepEqual(lunafuryEncodeButtonAction("DPI Cycle"), { functionId: 7, data: [6] }); +}); + +test("actions absent from the official menu are not offered or writable", () => { + for (const action of ["Left Double Click", "Browser Refresh", "Open Browser"]) { + assert.equal(LUNAFURY_BUTTON_ACTIONS.includes(action), false); + assert.equal(lunafuryButtonActionWritable(action), false); + assert.throws(() => lunafuryEncodeButtonAction(action)); + } + assert.equal(lunafuryDecodeButtonAction({ functionId: 2, data: [1, 1, 2, 0, 100] }), "Unknown:2:1,1,2,0,100"); +}); + +test("every offered action round-trips without sharing mutable bytes", () => { + for (const action of LUNAFURY_BUTTON_ACTIONS) { + const binding = lunafuryEncodeButtonAction(action); + assert.equal(lunafuryDecodeButtonAction(binding), action); + assert.equal(lunafuryButtonActionWritable(action), true); + (binding.data as number[]).push(255); + assert.equal(lunafuryDecodeButtonAction(lunafuryEncodeButtonAction(action)), action); + } + for (const mask of [0, 1, 15, 16, 128, 255]) { + const action = lunafuryKeyboardAction(mask, 99); + assert.equal(lunafuryDecodeButtonAction(lunafuryEncodeButtonAction(action)), action); + assert.deepEqual(lunafuryParseKeyboardAction(action), { modifiers: mask, usage: 99 }); + } +}); + +test("reject malformed actions, unsupported physical buttons and profile ranges", () => { + for (const action of ["Default", "Macro:16:1", "Unknown:255:", "__proto__", "Keyboard:01:4", + "Keyboard:256:4", "Keyboard:-1:4", "Keyboard:0:0", "Keyboard:0:50", "Keyboard:0:227", "Keyboard:0:100"]) { + assert.equal(lunafuryButtonActionWritable(action), false); + assert.throws(() => lunafuryWriteButton(1, "Right", action)); + } + for (const profile of [0, 4, 1.5, NaN]) assert.throws(() => lunafuryReadButton(profile, "Left")); + for (const button of ["DPI", "Receiver", "__proto__"]) assert.throws(() => lunafuryReadButton(1, button)); +}); + +test("button replies validate scope and declared data length while ignoring padding", () => { + const raw = Uint8Array.from([2, 3, 0, 4, 2, 32, 40, 255, 255]); + assert.deepEqual(lunafuryDecodeButton(raw, 2, "Middle"), { functionId: 4, data: [32, 40] }); + for (const payload of [[1, 3, 0, 4, 2, 32, 40], [2, 2, 0, 4, 2, 32, 40], + [2, 3, 1, 4, 2, 32, 40], [2, 3, 0, 4, 2, 32], [2, 3, 0, 4, 11], [2, 3]]) { + assert.throws(() => lunafuryDecodeButton(Uint8Array.from(payload), 2, "Middle")); + } + for (const binding of [{ functionId: 16, data: [3, 2, 1] }, { functionId: 1, data: [1, 2] }, + { functionId: 4, data: [0, 0] }, { functionId: 255, data: [] }]) { + assert.equal(lunafuryButtonActionWritable(lunafuryDecodeButtonAction(binding)), false); + } +}); diff --git a/src/lamzu/lunafury-buttons.ts b/src/lamzu/lunafury-buttons.ts new file mode 100644 index 00000000..3da2266c --- /dev/null +++ b/src/lamzu/lunafury-buttons.ts @@ -0,0 +1,100 @@ +/** Runtime button assignments from the public LunaFury configurator. + * Function/data values are independent of the CompX transport envelope. + * Macros and unknown functions are deliberately read-only in this codec. + */ +export const LUNAFURY_BUTTON_IDS = { Left: 1, Right: 2, Middle: 3, Back: 4, Forward: 5 } as const; +export type LunaFuryMappedButton = keyof typeof LUNAFURY_BUTTON_IDS; +export interface LunaFuryBinding { functionId: number; data: readonly number[] } + +const ACTIONS: Record = { + Disabled: { functionId: 0, data: [] }, + "Left Click": { functionId: 1, data: [1] }, + "Right Click": { functionId: 1, data: [2] }, + "Middle Click": { functionId: 1, data: [3] }, + Back: { functionId: 1, data: [4] }, + Forward: { functionId: 1, data: [5] }, + "Scroll Up": { functionId: 1, data: [0x10] }, + "Scroll Down": { functionId: 1, data: [0x11] }, + "DPI Cycle": { functionId: 7, data: [6] }, + "DPI Up": { functionId: 7, data: [1] }, + "DPI Down": { functionId: 7, data: [2] }, +}; +for (const [name, usage] of [ + ["Media Player", 0x183], ["Play/Pause", 0xcd], ["Next Track", 0xb5], ["Previous Track", 0xb6], + ["Volume Up", 0xe9], ["Volume Down", 0xea], ["Mute", 0xe2], ["Stop", 0xb7], + ["Calculator", 0x192], ["My Computer", 0x194], ["Browser Home", 0x223], ["Email", 0x18a], + ["Brightness Up", 0x6f], ["Brightness Down", 0x70], +] as const) ACTIONS[name] = { functionId: 5, data: [usage >> 8, usage & 255] }; +export const LUNAFURY_BUTTON_ACTIONS: readonly string[] = Object.freeze(Object.keys(ACTIONS)); + +export function lunafuryIsMappedButton(button: string): button is LunaFuryMappedButton { + return Object.hasOwn(LUNAFURY_BUTTON_IDS, button); +} + +function byte(value: number): boolean { return Number.isInteger(value) && value >= 0 && value <= 255; } +function keyUsage(usage: number): boolean { + return Number.isInteger(usage) && ((usage >= 4 && usage <= 99 && usage !== 50) + || [224, 225, 226, 228, 229, 230].includes(usage)); +} + +/** Canonical, layout-independent HID key with all eight L/R modifier bits. */ +export function lunafuryKeyboardAction(modifiers: number, usage: number): string { + if (!byte(modifiers) || !keyUsage(usage)) throw new Error("Unsupported LunaFury keyboard binding."); + return `Keyboard:${modifiers}:${usage}`; +} + +export function lunafuryParseKeyboardAction(action: string): { modifiers: number; usage: number } | undefined { + const match = /^Keyboard:(0|[1-9]\d{0,2}):(0|[1-9]\d{0,2})$/.exec(action); + if (!match) return undefined; + const modifiers = Number(match[1]), usage = Number(match[2]); + return byte(modifiers) && keyUsage(usage) ? { modifiers, usage } : undefined; +} + +export function lunafuryEncodeButtonAction(action: string): LunaFuryBinding { + if (Object.hasOwn(ACTIONS, action)) return { functionId: ACTIONS[action].functionId, data: [...ACTIONS[action].data] }; + const keyboard = lunafuryParseKeyboardAction(action); + if (keyboard) return { functionId: 4, data: [keyboard.modifiers, keyboard.usage] }; + throw new Error("Unsupported LunaFury button action."); +} + +export function lunafuryButtonActionWritable(action: string): boolean { + return Object.hasOwn(ACTIONS, action) || lunafuryParseKeyboardAction(action) !== undefined; +} + +export function lunafuryDecodeButtonAction(binding: LunaFuryBinding): string { + const { functionId, data } = binding; + const action = Object.entries(ACTIONS).find(([, known]) => known.functionId === functionId + && known.data.length === data.length && known.data.every((value, index) => data[index] === value))?.[0]; + if (action) return action; + if (functionId === 4 && data.length === 2 && byte(data[0]) && keyUsage(data[1])) { + return lunafuryKeyboardAction(data[0], data[1]); + } + // Include the raw bytes so different unsupported assignments never compare equal. + const kind = [0x10, 0x11, 0x12].includes(functionId) ? "Macro" : "Unknown"; + return `${kind}:${functionId}:${data.join(",")}`; +} + +function scope(profile: number, button: string): number { + if (!Number.isInteger(profile) || profile < 1 || profile > 3) throw new Error("Invalid LunaFury profile."); + if (!lunafuryIsMappedButton(button)) throw new Error("Unknown LunaFury physical button."); + return LUNAFURY_BUTTON_IDS[button]; +} + +export function lunafuryReadButton(profile: number, button: string) { + const id = scope(profile, button); + return { target: 2, page: 3, command: 0x80, length: 15, args: [profile, id, 0, 0xff, 10] }; +} + +export function lunafuryWriteButton(profile: number, button: string, action: string) { + const id = scope(profile, button), { functionId, data } = lunafuryEncodeButtonAction(action); + return { target: 2, page: 3, command: 0, length: 5 + data.length, + args: [profile, id, 0, functionId, data.length, ...data] }; +} + +/** Decode only complete, correctly addressed payloads. Padding is not action data. */ +export function lunafuryDecodeButton(payload: Uint8Array, profile: number, button: string): LunaFuryBinding { + const id = scope(profile, button); + if (payload.length < 5 || payload[0] !== profile || payload[1] !== id || payload[2] !== 0 + || payload[4] > 10 || payload.length < 5 + payload[4]) throw new Error("Invalid LunaFury button reply."); + return { functionId: payload[3], data: Array.from(payload.slice(5, 5 + payload[4])) }; +} diff --git a/src/lamzu/lunafury-onboard.test.ts b/src/lamzu/lunafury-onboard.test.ts new file mode 100644 index 00000000..213c91f7 --- /dev/null +++ b/src/lamzu/lunafury-onboard.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { compaxEncodeRequest } from "../compx/codec.ts"; +import { LUNAFURY_ONBOARD_READ as read, LUNAFURY_ONBOARD_WRITE as write, + lunafuryDecodeButtonRate, lunafuryDecodeSeparateAxes, lunafuryDecodeDpiStages, lunafuryWriteDpiStages, + lunafuryDecodeDpiStorage, lunafuryWriteDpiStorage } from "./lunafury-onboard.ts"; + +test("onboard commands use the vendor pages, lengths and one-based wire indices", () => { + for (const [spec, prefix] of [ + [read.profile(), [0, 0, 2, 1, 0, 0x85]], + [write.profile(3), [0, 0, 2, 1, 0, 5, 3]], + [read.buttonRate(2), [0, 0, 2, 2, 0, 0x9f, 2]], + [write.buttonRate(2, 8000), [0, 0, 2, 2, 0, 0x1f, 2, 128]], + [read.dpiStages(3, 5), [0, 0, 2, 10, 1, 0x81, 3, 5]], + [write.activeStage(3, 4, 5), [0, 0, 2, 2, 1, 2, 3, 5]], + [write.separateAxes(3, true), [0, 0, 2, 2, 1, 0x0d, 3, 1]], + ] as const) assert.deepEqual([...compaxEncodeRequest(spec).slice(0, prefix.length)], prefix); +}); + +test("button scan encodings differ from USB polling encodings", () => { + for (const [code, hz] of [[1, 1000], [32, 2000], [64, 4000], [128, 8000]] as const) { + assert.equal(lunafuryDecodeButtonRate(Uint8Array.of(2, code)), hz); + assert.equal(write.buttonRate(2, hz).args[1], code); + } + for (const payload of [null, Uint8Array.of(1), Uint8Array.of(1, 2), Uint8Array.of(1, 16)]) { + assert.equal(lunafuryDecodeButtonRate(payload), undefined); + } + assert.equal(lunafuryDecodeSeparateAxes(Uint8Array.of(1, 0)), false); + assert.equal(lunafuryDecodeSeparateAxes(Uint8Array.of(1, 1)), true); + for (const payload of [null, Uint8Array.of(1), Uint8Array.of(1, 2)]) assert.equal(lunafuryDecodeSeparateAxes(payload), undefined); +}); + +test("DPI table edits preserve dormant stage and trailing bytes", () => { + const previous = Uint8Array.of(3, 1, 3, 32, 6, 64, ...Array.from({ length: 20 }, (_, index) => 100 + index)); + assert.deepEqual(lunafuryDecodeDpiStages(previous, 6), [{ x: 800, y: 1600 }]); + const spec = lunafuryWriteDpiStages(3, [{ x: 1200, y: 2400 }], previous, 6); + assert.equal(spec.length, 26); + assert.deepEqual(spec.args.slice(0, 6), [3, 1, 4, 176, 9, 96]); + assert.deepEqual(spec.args.slice(6), [...previous.slice(6)]); +}); + +test("onboard codecs reject invalid profiles, counts, axes and truncated DPI tables", () => { + for (const id of [0, 4, -1, 1.5, NaN]) { + assert.throws(() => write.profile(id)); + assert.throws(() => read.buttonRate(id)); + } + assert.throws(() => write.buttonRate(1, 125 as 1000)); + assert.throws(() => write.separateAxes(1, 1 as unknown as boolean)); + for (const stage of [-1, 3, 0.5]) assert.throws(() => write.activeStage(1, stage, 3)); + for (const max of [0, 4, 7]) assert.throws(() => read.dpiStages(1, max)); + for (const payload of [new Uint8Array(), Uint8Array.of(1, 0), Uint8Array.of(1, 6), Uint8Array.of(1, 1, 3, 32)]) { + assert.throws(() => lunafuryDecodeDpiStages(payload, 5)); + } + const previous = Uint8Array.of(1, 1, 3, 32, 3, 32); + for (const dpi of [0, 25, 51, 30050, NaN]) assert.throws(() => lunafuryWriteDpiStages(1, [{ x: dpi, y: 800 }], previous, 6)); + assert.throws(() => lunafuryWriteDpiStages(2, [{ x: 800, y: 800 }], previous, 6), /another profile/); +}); + +test("complete storage codec separates enabled count, dormant data and TYPE33 trailing bytes", () => { + const previous = Uint8Array.of(3, 1, 3, 32, 6, 64, ...Array.from({ length: 20 }, (_, index) => 100 + index)); + assert.equal(lunafuryDecodeDpiStorage(previous.slice(0, 6), 6), undefined); + for (const max of [5, 6]) { + const saved = lunafuryDecodeDpiStorage(previous, max)!; + assert.equal(saved.count, 1); assert.equal(saved.stages.length, max); + assert.deepEqual(lunafuryWriteDpiStorage(saved, previous, max).args, [...previous]); + assert.throws(() => lunafuryWriteDpiStorage({ ...saved, profile: 2 }, previous, max), /another profile/); + assert.throws(() => lunafuryWriteDpiStorage({ ...saved, stages: saved.stages.slice(1) }, previous, max), /Incomplete/); + const reserved = { ...saved, stages: saved.stages.map((stage, index) => index === 1 ? { x: 0, y: 65535 } : stage) }; + assert.deepEqual(lunafuryWriteDpiStorage(reserved, previous, max).args.slice(6, 10), [0, 0, 255, 255]); + assert.throws(() => lunafuryWriteDpiStorage({ ...reserved, count: 2 }, previous, max)); + } +}); diff --git a/src/lamzu/lunafury-onboard.ts b/src/lamzu/lunafury-onboard.ts new file mode 100644 index 00000000..e9fad21c --- /dev/null +++ b/src/lamzu/lunafury-onboard.ts @@ -0,0 +1,124 @@ +import type { CompaxDpiStage } from "../compx/codec.js"; + +/** LunaFury runtime controls from the public configurator, inspected 2026-10-07. + * Profiles and wire DPI stages are 1-based; the driver exposes stages 0-based. + */ +export type LunaFuryButtonPollingRate = 1000 | 2000 | 4000 | 8000; +/** Complete stored slots, separate from the enabled stage count. */ +export interface LunaFuryDpiStorage { + profile: number; + count: number; + stages: CompaxDpiStage[]; +} +export const LUNAFURY_BUTTON_RATES = [[1, 1000], [32, 2000], [64, 4000], [128, 8000]] as const; +export const LUNAFURY_DPI_DEFAULTS = [400, 800, 1600, 3200, 6400, 30000] as const; + +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 between ${min} and ${max}.`); +} + +function profile(id: number): void { whole(id, 1, 3, "LunaFury profile"); } +function maximum(count: number): void { if (count !== 5 && count !== 6) throw new Error("LunaFury supports five or six DPI stages."); } +export function lunafuryValidateDpi(value: number): void { + whole(value, 50, 30000, "DPI"); + if (value % 50 !== 0) throw new Error("DPI must use 50 DPI steps."); +} +const request = (page: number, command: number, length: number, args: number[]) => + ({ target: 2, page, command, length, args }); +const scoped = (id: number, page: number, command: number, length = 2, rest: number[] = []) => { + profile(id); + return request(page, command, length, [id, ...rest]); +}; + +export const LUNAFURY_ONBOARD_READ = { + profile: () => request(0, 0x85, 1, []), + buttonRate: (id: number) => scoped(id, 0, 0x9f), + dpiStages: (id: number, maxStages: number) => { + maximum(maxStages); + return scoped(id, 1, 0x81, 10, [maxStages]); + }, + activeStage: (id: number) => scoped(id, 1, 0x82), + separateAxes: (id: number) => scoped(id, 1, 0x8d), +}; + +export const LUNAFURY_ONBOARD_WRITE = { + profile: (id: number) => scoped(id, 0, 0x05, 1), + buttonRate: (id: number, hertz: LunaFuryButtonPollingRate) => { + const code = LUNAFURY_BUTTON_RATES.find(([, rate]) => rate === hertz)?.[0]; + if (code === undefined) throw new Error("Unknown LunaFury button polling rate."); + return scoped(id, 0, 0x1f, 2, [code]); + }, + activeStage: (id: number, stage: number, count: number) => { + whole(count, 1, 6, "DPI stage count"); + whole(stage, 0, count - 1, "DPI stage index"); + return scoped(id, 1, 0x02, 2, [stage + 1]); + }, + separateAxes: (id: number, enabled: boolean) => { + if (typeof enabled !== "boolean") throw new Error("Separate DPI axes needs an on/off value."); + return scoped(id, 1, 0x0d, 2, [+enabled]); + }, +}; + +export function lunafuryDecodeButtonRate(payload: Uint8Array | null): LunaFuryButtonPollingRate | undefined { + return payload && payload.length >= 2 ? LUNAFURY_BUTTON_RATES.find(([code]) => code === payload[1])?.[1] : undefined; +} + +export function lunafuryDecodeSeparateAxes(payload: Uint8Array | null): boolean | undefined { + return payload && payload.length >= 2 && payload[1] <= 1 ? payload[1] === 1 : undefined; +} + +export function lunafuryDecodeDpiStages(payload: Uint8Array, maxStages: number): CompaxDpiStage[] { + maximum(maxStages); + const count = payload[1]; + whole(count, 1, maxStages, "DPI stage count"); + if (payload.length < 2 + count * 4) throw new Error("Truncated LunaFury DPI table."); + return Array.from({ length: count }, (_, index) => { + const offset = 2 + index * 4; + const x = (payload[offset] << 8) | payload[offset + 1]; + const y = (payload[offset + 2] << 8) | payload[offset + 3]; + lunafuryValidateDpi(x); + lunafuryValidateDpi(y); + return { x, y }; + }); +} + +export function lunafuryDecodeDpiStorage(payload: Uint8Array, maxStages: number): LunaFuryDpiStorage | undefined { + lunafuryDecodeDpiStages(payload, maxStages); + if (payload.length < 26) return undefined; + profile(payload[0]); + return { profile: payload[0], count: payload[1], stages: Array.from({ length: maxStages }, (_, index) => { + const offset = 2 + index * 4; + return { x: (payload[offset] << 8) | payload[offset + 1], y: (payload[offset + 2] << 8) | payload[offset + 3] }; + }) }; +} + +/** Dormant slots may contain reserved values; preserve their exact 16-bit storage. */ +export function lunafuryWriteDpiStorage(storage: LunaFuryDpiStorage, previous: Uint8Array, maxStages: number) { + maximum(maxStages); profile(storage.profile); whole(storage.count, 1, maxStages, "DPI stage count"); + if (!Array.isArray(storage.stages) || storage.stages.length !== maxStages) throw new Error("Incomplete LunaFury DPI storage."); + if (!lunafuryDecodeDpiStorage(previous, maxStages) || previous[0] !== storage.profile) throw new Error("DPI storage belongs to another profile or is incomplete."); + const args = Array.from(previous.slice(0, 26)); + args[1] = storage.count; + storage.stages.forEach(({ x, y }, index) => { + whole(x, 0, 65535, "Stored X DPI"); whole(y, 0, 65535, "Stored Y DPI"); + if (index < storage.count) { lunafuryValidateDpi(x); lunafuryValidateDpi(y); } + args.splice(2 + index * 4, 4, x >> 8, x & 255, y >> 8, y & 255); + }); + return request(1, 1, 26, args); +} + +/** Retain dormant slots and trailing vendor bytes in the fixed 26-byte table. */ +export function lunafuryWriteDpiStages(id: number, stages: readonly CompaxDpiStage[], previous: Uint8Array, maxStages: number) { + profile(id); + lunafuryDecodeDpiStages(previous, maxStages); + if (previous[0] !== id) throw new Error("DPI table belongs to another profile."); + whole(stages.length, 1, maxStages, "DPI stage count"); + const args = Array.from({ length: 26 }, (_, index) => previous[index] ?? 0); + args[1] = stages.length; + stages.forEach(({ x, y }, index) => { + lunafuryValidateDpi(x); + lunafuryValidateDpi(y); + args.splice(2 + index * 4, 4, x >> 8, x & 255, y >> 8, y & 255); + }); + return request(1, 1, 26, args); +} diff --git a/src/lamzu/lunafury-power.ts b/src/lamzu/lunafury-power.ts new file mode 100644 index 00000000..41aed0d1 --- /dev/null +++ b/src/lamzu/lunafury-power.ts @@ -0,0 +1,65 @@ +/** LunaFury XviUpdater sleep and receiver-light commands. */ +export type LunaFuryReceiverLightMode = 0 | 2 | 6 | 11; +export type LunaFuryBottomButtonMode = 1 | 2; + +function bottomProfile(profile: number): void { + if (!Number.isInteger(profile) || profile < 1 || profile > 3) throw new Error("Invalid LunaFury profile."); +} + +/** The vendor reads physical button 0x14 using its shorter, six-byte layout. */ +export function lunafuryReadBottomButton(profile: number) { + bottomProfile(profile); + return { target: 2, page: 3, command: 0x80, length: 6, args: [profile, 0x14] }; +} + +export function lunafuryWriteBottomButton(profile: number, mode: LunaFuryBottomButtonMode) { + bottomProfile(profile); + if (mode !== 1 && mode !== 2) throw new Error("Unknown LunaFury bottom-button mode."); + // Preserve the vendor's five-byte power binding; do not use the ordinary + // remapping codec, which would append another byte to this special function. + const args = mode === 1 ? [profile, 0x14, 0, 0x15, 1] : [profile, 0x14, 0, 7, 1, 6]; + return { target: 2, page: 3, command: 0, length: args.length, args }; +} + +export function lunafuryDecodeBottomButton(payload: Uint8Array | null, profile: number): LunaFuryBottomButtonMode | undefined { + bottomProfile(profile); + if (!payload || payload.length < 5 || payload[0] !== profile || payload[1] !== 0x14 + || payload[2] !== 0 || payload[4] !== 1 || payload.slice(6).some((byte) => byte !== 0)) return undefined; + if (payload[3] === 0x15 && (payload.length === 5 || payload[5] === 0)) return 1; + if (payload[3] === 7 && payload.length >= 6 && payload[5] === 6) return 2; + return undefined; +} +export const LUNAFURY_SLEEP_OPTIONS: readonly number[] = [0, ...Array.from({ length: 30 }, (_, i) => (i + 1) * 60)]; + +export function lunafuryEncodeSleep(seconds: number): number { + if (!Number.isInteger(seconds) || seconds < 0 || seconds > 0xfeff) { + throw new Error("LunaFury sleep timeout must be 0 (never) or 1–65279 seconds."); + } + return seconds === 0 ? 0xffff : seconds; +} + +export function lunafuryDecodeSleep(payload: Uint8Array | null): number | null { + if (!payload || payload.length < 3) return null; + const value = (payload[1] << 8) | payload[2]; + return value === 0xffff ? 0 : value > 0 && value <= 0xfeff ? value : null; +} + +export function lunafuryReceiverLightTarget(productId: number): number | undefined { + return productId === 0x0033 ? 0 : productId === 0x0084 ? 1 : undefined; +} + +export function lunafuryReadReceiverLight(productId: number) { + const target = lunafuryReceiverLightTarget(productId); + if (target === undefined) throw new Error("Receiver lighting requires a LunaFury 8K receiver."); + return { target, page: 2, command: 0x80, length: 6, args: [1] }; +} + +export function lunafuryWriteReceiverLight(productId: number, mode: LunaFuryReceiverLightMode) { + if (![0, 2, 6, 11].includes(mode)) throw new Error("Unknown LunaFury receiver light mode."); + return { ...lunafuryReadReceiverLight(productId), command: 0, args: [1, 0, mode] }; +} + +export function lunafuryDecodeReceiverLight(payload: Uint8Array | null): LunaFuryReceiverLightMode | undefined { + return payload && payload.length >= 3 && payload[0] === 1 && [0, 2, 6, 11].includes(payload[2]) + ? payload[2] as LunaFuryReceiverLightMode : undefined; +} diff --git a/src/lamzu/lunafury.ts b/src/lamzu/lunafury.ts index e26e2d5e..c64ceb00 100644 --- a/src/lamzu/lunafury.ts +++ b/src/lamzu/lunafury.ts @@ -6,6 +6,15 @@ export type LunaFuryLightningMode = 0 | 1 | 2; export type LunaFuryButton = "left" | "right" | "middle"; export interface LunaFuryWheelGuard { enabled: boolean; windowMs: number } export interface LunaFurySettings { + /** Read-only backup metadata, not an ordinary per-game control. */ + dpiStorage?: import("./lunafury-onboard.js").LunaFuryDpiStorage; + /** Current onboard bank's bottom-button binding; not a game-profile setting. */ + bottomButtonMode?: import("./lunafury-power.js").LunaFuryBottomButtonMode; + /** Receiver-global, not an onboard or per-game setting. */ + receiverLightMode?: import("./lunafury-power.js").LunaFuryReceiverLightMode; + buttonPollingRateHz?: import("./lunafury-onboard.js").LunaFuryButtonPollingRate; + supportedButtonPollingRates?: readonly import("./lunafury-onboard.js").LunaFuryButtonPollingRate[]; + separateDpiAxes?: boolean; lightningMode?: LunaFuryLightningMode; leftDebounceMs?: number; rightDebounceMs?: number;