From 91c8a40eb0a06c3d541f4082fbc5d85a0738093b Mon Sep 17 00:00:00 2001 From: Breakdown <169538186+RINZE1@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:14:06 +0800 Subject: [PATCH 1/9] feat(lunafury): add onboard, remapping, and power controls --- docs/lunafury-testing.md | 136 ++++++++ src/drivers/lamzu/hid.test.ts | 4 +- src/drivers/lamzu/hid.ts | 312 +++++++++++++++++- .../lamzu/lunafury-bottom-button.test.ts | 113 +++++++ src/drivers/lamzu/lunafury-buttons.test.ts | 109 ++++++ src/drivers/lamzu/lunafury-onboard.test.ts | 201 +++++++++++ src/drivers/lamzu/lunafury-power.test.ts | 123 +++++++ src/drivers/lamzu/lunafury.test.ts | 6 +- src/drivers/mouse-types.ts | 2 + src/lamzu/index.ts | 3 + src/lamzu/lunafury-buttons.test.ts | 62 ++++ src/lamzu/lunafury-buttons.ts | 100 ++++++ src/lamzu/lunafury-onboard.test.ts | 56 ++++ src/lamzu/lunafury-onboard.ts | 93 ++++++ src/lamzu/lunafury-power.ts | 65 ++++ src/lamzu/lunafury.ts | 7 + 16 files changed, 1383 insertions(+), 9 deletions(-) create mode 100644 src/drivers/lamzu/lunafury-bottom-button.test.ts create mode 100644 src/drivers/lamzu/lunafury-buttons.test.ts create mode 100644 src/drivers/lamzu/lunafury-onboard.test.ts create mode 100644 src/drivers/lamzu/lunafury-power.test.ts create mode 100644 src/lamzu/lunafury-buttons.test.ts create mode 100644 src/lamzu/lunafury-buttons.ts create mode 100644 src/lamzu/lunafury-onboard.test.ts create mode 100644 src/lamzu/lunafury-onboard.ts create mode 100644 src/lamzu/lunafury-power.ts diff --git a/docs/lunafury-testing.md b/docs/lunafury-testing.md index f26d9c42..979dc389 100644 --- a/docs/lunafury-testing.md +++ b/docs/lunafury-testing.md @@ -15,6 +15,104 @@ LUNA33 runtime identities `0x0032` and `0x0033` on that firmware. TYPE33 identities and packet layouts are source-verified but have not been tested on hardware. +On 2026-10-08 the owner also confirmed the next round on LUNA33, firmware +`0.0.26.0`, over both cable and 8K receiver: independent button polling, +DPI stage management including X/Y axes, and all three onboard profiles +worked normally. The owner subsequently confirmed mouse/scroll/DPI/media +remapping, keyboard single keys and combinations on the same model, firmware +and both connections. The owner further confirmed all four receiver light modes, +absence of lighting controls over cable, sleep/Never over both connections, +bank switching and reconnect readback. The redesigned official-style DPI UI +was confirmed on 2026-10-08 by the owner on LUNA33, firmware 0.0.26.0, +over both cable and 8K: stage selection, value editing, separate axes, stage +count changes and reconnect readback all worked. No separate Games or +power-cycle results were supplied. + +## Bottom-button power / DPI behavior (2026-10-09, hardware unverified) + +At the owner's request, the three added fixed actions (left double click, +browser refresh and open browser) were withdrawn. They are absent from the +official default menu. The previous verified DPI interface is retained. +No hardware binding is automatically restored or overwritten by this rollback; +existing removed assignments become unknown/read-only. + +The current official `settingDialog.vue` exposes two bottom-button modes: +1. Short press: power on/off. +2. Long press: power on/off; short press: DPI. + +`XviUpdater.getDpiPowerMode(profile)` reads target 2, page 3, command 0x80, +length 6, selector `[profile, 0x14]`. Its setter writes: +- Mode 1: length 5, `[profile, 0x14, 0, 0x15, 1]`. +- Mode 2: length 6, `[profile, 0x14, 0, 7, 1, 6]`. + +The special five-byte power binding is not encoded as an ordinary button. +The read validates bank, selector, function and data rather than treating +every unknown function as mode 2 as the vendor UI does. Unsupported, +truncated and unfamiliar bindings stay hidden and cannot be overwritten. +Writes reread the existing binding, run through the onboard operation +queue, and confirm the exact selected mode. Ordinary buttons and other +banks are not written. Other brands cannot call this setter. + +The card is on the Buttons page and uses staged Apply/undo. The setting +belongs to the current onboard bank but is deliberately excluded from +Games and profile imports so automatic game changes cannot alter power +button behavior. Changing the setting configures the binding; it does not +execute a power-off command. Both actual press behavior and persistence +after a power cycle are unverified; TYPE33 remains unverified. + +Test on LUNA33 over cable and 8K with a backup pointing device available: +record each bank's original mode, compare with the official driver, apply +both modes, check physical short/long presses, reconnect/readback and bank +isolation, then restore the originals. Switching the mouse off may +disconnect it; turn it on again and reconnect before continuing. + +## Official-style DPI editor (2026-10-08) + +At the owner's request, the custom DPI color extension was withdrawn. The +LunaFury client no longer exposes a color setter or reads/writes the page-2 +color table. Other brands' color support is unchanged. A regression test +checks that status reads and stage-count edits send no color-table commands. + +The app uses the official driver's fixed index palette, active-stage X/Y +sliders, stage-value inputs and an explicit stage-count selector. Clicking a +tile or focusing an input selects that stage. Numeric inputs clamp to +50–30000 and round down in 50-DPI steps. The existing staged Apply workflow, +serialized bank operations and readback-confirmed writes are preserved. +Stage-count changes retain dormant device values as before. + +Hardware retest: verify tile selection, value inputs, current-stage sliders, +linked/separate axes, count shrink/expansion, undo/Apply and reconnect in +each onboard bank over cable and 8K. Fixed color tiles are display markers, +not device color readback. No firmware/reset actions are included. + +## Receiver lighting and Never auto-sleep (2026-10-08) + +Re-inspection of the same vendor bundles found `SetLightEffect(1, mode)` and +`GetLightEffect(1)`. These address the receiver, not the active onboard bank: +target 0 for LUNA33 PID `0x0033`, target 1 for TYPE33 PID `0x0084`. +Page 2, command `0x80` reads six bytes with zone argument 1; command 0 writes +`[1, 0, mode, 0, 0, 0]`. Mode is payload byte 2: 2 = Mixed, 6 = Battery, +11 = DPI, 0 = Off. Wired identities never receive these commands. Unknown, +unsupported and truncated reads do not expose a control. Writes require a +supported pre-read and exact readback; stale zone replies are filtered. +This receiver-global setting is deliberately excluded from Games profiles. + +Vendor `getSleepTime(profile)` reads page 0 command `0x87`; `setSleepTime` +writes command 7, three bytes `[profile, secondsHigh, secondsLow]`. The UI +offers 1–30 minutes, converting zero minutes to `0xffff` for Never. OpenMouse +represents Never as zero seconds and preserves custom normal timeouts; zero +wire values and other reserved values are not guessed. Only LunaFury uses this +conversion. Sleep edits share the onboard-operation queue with profile +switches and filter replies by profile before verifying the decoded timeout. + +Hardware checklist: record the current receiver mode and each bank's timeout. +On the 8K receiver, apply Mixed/Battery/DPI/Off individually, inspect the LED, +and reconnect to verify readback. Cable connections must not show lighting. +For each connection, set a short sleep timeout, confirm actual idle sleep, +then choose Never and wait beyond that timeout without using the mouse. +Switch banks to check isolation, restore original values, and compare with +the vendor configurator (never run both configurators concurrently). + 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. @@ -122,6 +220,44 @@ settings and valid zero values. ## Required hardware check +### Onboard controls and button remapping + +The public configurator's runtime controls use profile IDs 1–3. Button scan +rate is page 0, read `0x9f` / write `0x1f`, with codes 1/32/64/128 for +1000/2000/4000/8000 Hz. LUNA33 cable offers only 1000 Hz. DPI stages use +page 1, read `0x81` / write `0x01`, with a 26-byte table; active stage is +read `0x82` / write `0x02`. Separate axes use `0x8d` / `0x0d`. Active +profile uses page 0, read `0x85` / write `0x05`. + +Button remapping uses page 3, read `0x80` / write `0x00`. The payload is +`[profile, button, 0, function, dataLength, ...data]`. Reads request +function `0xff`, data length 10 and envelope length 15. Physical IDs 1–5 +are left, right, middle, back and forward. Mouse actions use function 1; +keyboard shortcuts use function 4 with `[modifierMask, HIDusage]`; media +actions use function 5 with a big-endian consumer usage; DPI actions use +function 7. Disabled is function 0 with no data. Receiver buttons, +bottom controls, macros and vendor-specific advanced actions are excluded +from ordinary remapping. Bottom-button behavior has its separate control +described above. + +The owner confirmed ordinary remapping on LUNA33 firmware `0.0.26.0` over +both cable and the 8K receiver, as recorded above. TYPE33 remapping remains +source-derived and synthetically tested only. Left is protected. Macros and +unknown assignments remain read-only; no macro +storage or unrelated button is rewritten. Each write addresses the active +bank, reads the prior assignment, and checks the complete function/data +readback. Reads match both profile and physical button. Profile switching +and remapping share the serialized onboard-operation queue. Games profiles +capture and restore only the supported buttons they touched. + +For hardware testing, record the current bank and assignments. Change right, +middle or a side button to a reversible mouse action first, then test scroll, +DPI, a single key, a shortcut and a media action. Check the actual output, +refresh/reconnect readback and bank isolation, then restore the originals. +Check that a macro configured in the official driver stays unchanged. +Games apply/restore needs a separate pass; a successful readback alone does +not prove that the bound key or action fires correctly. + Connect each available cable/receiver identity in Chromium and confirm: 1. OpenMouse labels it `LunaFury LUNA33` or `LunaFury TYPE33`, never Attack 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 33539d08..c9e649df 100644 --- a/src/drivers/lamzu/hid.ts +++ b/src/drivers/lamzu/hid.ts @@ -19,10 +19,39 @@ import { lunafuryDecodeLightning, lunafuryDecodeButtonDebounce, lunafuryDecodeWheelGuard, + LUNAFURY_ONBOARD_READ, + LUNAFURY_ONBOARD_WRITE, + LUNAFURY_DPI_DEFAULTS, + lunafuryDecodeButtonRate, + lunafuryDecodeSeparateAxes, + lunafuryDecodeDpiStages, + 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 +181,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 +274,7 @@ export class LamzuHidClient { } getSleepOptions(): readonly number[] { + if (this.deviceBrand() === "LunaFury") return LUNAFURY_SLEEP_OPTIONS; return this.profile()?.sleepOptions ?? SLEEP_SECONDS; } @@ -284,13 +315,18 @@ 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 { 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 = await this.request(PROFILE_READ.separateAxes(profile)).catch(() => null); + const separateAxes = 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); @@ -301,6 +337,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; @@ -312,21 +349,26 @@ 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 ? 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 } : {}), + ...buttons, dongleLedEnabled: dongleLed ? dongleLed[1] === 1 : null, connectionType: wireless ? "Wireless" : "Wired", connectionDetail: wireless ? "2.4 GHz receiver" : "Wired USB", @@ -340,6 +382,10 @@ export class LamzuHidClient { } private async readLiveStatus(previous: MouseStatus): Promise { + if (this.deviceBrand() === "LunaFury") { + const profile = await this.currentProfile(); + if (profile !== previous.activeProfile) return this.readStatus(); + } const battery = await this.request(READ.battery); const pollingRate = await this.request(PROFILE_READ.pollingRate(this.activeProfile)); return this.lastStatus = { @@ -374,7 +420,223 @@ 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; + } + + 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): 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 }); + } + + private async writeLunaFuryDpiTable(profile: number, raw: Uint8Array, stages: LamzuDpiStage[], active: number): Promise { + await this.request(lunafuryWriteDpiStages(profile, stages, raw, this.maxDpiStages())); + const confirmed = (await this.readDpiTable(profile)).stages; + 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); + } + + 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 { 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); + 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); + 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 { @@ -515,6 +777,17 @@ export class LamzuHidClient { } 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}.`); } @@ -530,6 +803,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) { @@ -679,6 +953,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; } @@ -745,9 +1023,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..0201550c --- /dev/null +++ b/src/drivers/lamzu/lunafury-onboard.test.ts @@ -0,0 +1,201 @@ +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; unsupported?: boolean; truncate?: boolean } = {}) { + let activeProfile = 1; + const banks = Array.from({ length: 3 }, (_, index) => ({ + count: 3, active: 2, axes: true, scan: 1, usb: 1, + table: [400 + index * 50, 800, 800 + index * 50, 1600, 1600 + index * 50, 3200, 3200, 6400, 6400, 6400, 30000, 30000], + })); + const sent: Uint8Array[] = []; + 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 (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: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]}`; + 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, 1]; + 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 }; +} + +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, false); + 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/); +}); + +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]); +}); + +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); +}); 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..8e94a61a 100644 --- a/src/drivers/lamzu/lunafury.test.ts +++ b/src/drivers/lamzu/lunafury.test.ts @@ -37,7 +37,9 @@ 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]; @@ -45,7 +47,7 @@ function fakeLunaFury(productId = 0x0033, options: { 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: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)]; diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index ddc7bcef..ca0efee4 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -307,6 +307,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 05ebd291..23ad1a0e 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..bcf26d8a --- /dev/null +++ b/src/lamzu/lunafury-onboard.test.ts @@ -0,0 +1,56 @@ +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 } 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/); +}); diff --git a/src/lamzu/lunafury-onboard.ts b/src/lamzu/lunafury-onboard.ts new file mode 100644 index 00000000..223a138d --- /dev/null +++ b/src/lamzu/lunafury-onboard.ts @@ -0,0 +1,93 @@ +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; +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 }; + }); +} + +/** 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..6ef21ac3 100644 --- a/src/lamzu/lunafury.ts +++ b/src/lamzu/lunafury.ts @@ -6,6 +6,13 @@ export type LunaFuryLightningMode = 0 | 1 | 2; export type LunaFuryButton = "left" | "right" | "middle"; export interface LunaFuryWheelGuard { enabled: boolean; windowMs: number } export interface LunaFurySettings { + /** 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; From a734b9580f35ba6b5de98c979210b6c08bf55920 Mon Sep 17 00:00:00 2001 From: Breakdown <169538186+RINZE1@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:47:53 +0800 Subject: [PATCH 2/9] fix(lunafury): serialize bank operations and refresh live DPI --- docs/lunafury-testing.md | 20 +++ src/drivers/lamzu/hid.ts | 168 +++++++++++++-------- src/drivers/lamzu/lunafury-onboard.test.ts | 83 +++++++++- 3 files changed, 202 insertions(+), 69 deletions(-) diff --git a/docs/lunafury-testing.md b/docs/lunafury-testing.md index 979dc389..977f0ee6 100644 --- a/docs/lunafury-testing.md +++ b/docs/lunafury-testing.md @@ -28,6 +28,26 @@ over both cable and 8K: stage selection, value editing, separate axes, stage count changes and reconnect readback all worked. No separate Games or power-cycle results were supplied. +## Live DPI and bank-operation regressions (2026-10-09) + +LunaFury live status now reads the current bank's DPI table and active stage, +updating both axes instead of reusing the previous selected-stage cache. +Physical DPI switches and changes to stage count/values are covered on all +four runtime identities with synthetic HID fixtures. Refresh sends no writes +and does not repeatedly read the static LunaFury extension controls. + +The older USB polling, LOD, flags, global debounce, sensor angle, Lightning +Trigger, per-button latency and wheel-guard setters now share the onboard +queue with bank switching and the newer setters. Their complete bank lookup, +write and readback remain together. Status reads also join the queue, so a +concurrent refresh cannot repopulate a switched bank's old cache. Shared +setters retain their previous execution path for other brands. + +Regression tests interleave each older setter with profile switches, new +DPI edits and live reads in both call orders, record the active bank at each +write, and check that a failed operation does not stall later work. These +are automated results, not new hardware validation. + ## Bottom-button power / DPI behavior (2026-10-09, hardware unverified) At the owner's request, the three added fixed actions (left double click, diff --git a/src/drivers/lamzu/hid.ts b/src/drivers/lamzu/hid.ts index c9e649df..4bb75897 100644 --- a/src/drivers/lamzu/hid.ts +++ b/src/drivers/lamzu/hid.ts @@ -301,6 +301,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(); @@ -382,14 +388,20 @@ 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.readStatus(); + if (profile !== previous.activeProfile) return this.readStatusNow(); + const { 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 }; } 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]), @@ -518,6 +530,12 @@ export class LamzuHidClient { 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 }) @@ -645,44 +663,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 { @@ -690,27 +717,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 { @@ -735,14 +766,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( @@ -752,28 +784,32 @@ 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 { diff --git a/src/drivers/lamzu/lunafury-onboard.test.ts b/src/drivers/lamzu/lunafury-onboard.test.ts index 0201550c..52f705cc 100644 --- a/src/drivers/lamzu/lunafury-onboard.test.ts +++ b/src/drivers/lamzu/lunafury-onboard.test.ts @@ -7,10 +7,13 @@ import { LamzuHidClient } from "./hid.ts"; function fixture(productId = 0x0033, options: { ignore?: string; stale?: 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, + 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, @@ -18,12 +21,20 @@ function fixture(productId = 0x0033, options: { ignore?: string; stale?: string; 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") { @@ -45,7 +56,13 @@ function fixture(productId = 0x0033, options: { ignore?: string; stale?: string; 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, 1]; + 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; @@ -57,7 +74,7 @@ function fixture(productId = 0x0033, options: { ignore?: string; stale?: string; return new DataView(buffer.buffer, 3, 64); }, } as unknown as HIDDevice; - return { client: new LamzuHidClient(device), sent, banks, device }; + return { client: new LamzuHidClient(device), sent, banks, device, writes }; } test("LunaFury no longer exposes custom colors or sends color-table reads", async () => { @@ -190,6 +207,66 @@ test("live status detects a profile switched outside this client", async () => { 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 }); From a286c827b9523827eb8ceb76c17b5937cc1bd2d0 Mon Sep 17 00:00:00 2001 From: Breakdown <169538186+RINZE1@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:04:48 +0800 Subject: [PATCH 3/9] fix(lunafury): restore all DPI slots in Games backups Add bank-scoped complete DPI storage backups and verified restoration, including dormant slots. Cover invalid backups, reserved values and queue safety, and update the hardware and release documentation. --- README.md | 9 +- docs/lunafury-testing.md | 163 +++++++++++++-------- src/drivers/lamzu/hid.ts | 52 +++++-- src/drivers/lamzu/lunafury-onboard.test.ts | 55 +++++++ src/drivers/lamzu/lunafury.test.ts | 21 ++- src/lamzu/lunafury-onboard.test.ts | 18 ++- src/lamzu/lunafury-onboard.ts | 31 ++++ src/lamzu/lunafury.ts | 2 + 8 files changed, 275 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 3af5fa11..2559b350 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,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` | @@ -81,6 +81,13 @@ 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 LUNA33 and TYPE33 use the shared Lamzu/CompX driver and the +`@openmouse/protocol/lamzu` entry point. The owner tested LUNA33 firmware +`0.0.26.0` over cable and the 8K receiver. TYPE33 and the new bottom-button +power/DPI behavior still need hardware testing. See +[docs/lunafury-testing.md](docs/lunafury-testing.md) for packet evidence, +the verified feature list and the hardware checklist. + 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 977f0ee6..24438da5 100644 --- a/docs/lunafury-testing.md +++ b/docs/lunafury-testing.md @@ -1,4 +1,4 @@ -# LunaFury LUNA33 / TYPE33 protocol evidence +# LunaFury LUNA33 / TYPE33 evidence and testing This integration is based on the public LunaFury WebHID configurator at . The site and its source map were inspected on @@ -9,24 +9,64 @@ This integration is based on the public LunaFury WebHID configurator at - `/js/output.xvi3.min.js` contains the `XviUpdater` transport and the concrete feature-report packets. -The owner completed full-function testing on LUNA33 with firmware -`0.0.26.0`, over both cable and 8K receiver connections. This verifies the -LUNA33 runtime identities `0x0032` and `0x0033` on that firmware. TYPE33 -identities and packet layouts are source-verified but have not been tested -on hardware. - -On 2026-10-08 the owner also confirmed the next round on LUNA33, firmware -`0.0.26.0`, over both cable and 8K receiver: independent button polling, -DPI stage management including X/Y axes, and all three onboard profiles -worked normally. The owner subsequently confirmed mouse/scroll/DPI/media -remapping, keyboard single keys and combinations on the same model, firmware -and both connections. The owner further confirmed all four receiver light modes, -absence of lighting controls over cable, sleep/Never over both connections, -bank switching and reconnect readback. The redesigned official-style DPI UI -was confirmed on 2026-10-08 by the owner on LUNA33, firmware 0.0.26.0, -over both cable and 8K: stage selection, value editing, separate axes, stage -count changes and reconnect readback all worked. No separate Games or -power-cycle results were supplied. +## Hardware results + +The owner confirmed full-function testing of the original LUNA33 support +with firmware `0.0.26.0`, over cable and the 8K receiver. This verifies the +runtime identities `0x0032` and `0x0033` on that firmware. They confirmed +the later feature rounds on the same model, firmware and connections: + +| Feature | Owner-reported result | +| --- | --- | +| Independent button polling and all three onboard profiles | Normal over cable and 8K | +| DPI stage selection, value editing, separate X/Y and stage count | Normal over cable and 8K, including the revised DPI UI | +| Mouse, scroll, DPI and media remapping | Normal over cable and 8K | +| Keyboard single keys and combinations | Normal over cable and 8K | +| Sleep timeout and Never | Normal over cable and 8K | +| Mixed, Battery, DPI and Off receiver lighting | Normal over 8K; lighting control absent over cable | +| Bank switching and reconnect readback | Normal over cable and 8K | + +The button-polling/onboard round and the revised DPI editor were confirmed +on 2026-10-08. These are the owner's reports, not independent hardware +measurements. TYPE33 has source and synthetic-test coverage only. The new +bottom-button power/DPI modes still need hardware testing, and no separate +Games or power-cycle results were supplied. + +## Automated validation + +The protocol build and all 151 Lamzu driver/codec tests pass. A broader +regression run on 2026-10-10 passed 2,252 tests with two registry exhaustive +checks excluded: `the probe matrix can trigger every driver` and +`no device can be claimed by more than one driver`. This is not a full +protocol CI result; both checks still need to run before merge. + +The paired OpenMouse build and all 334 application tests pass with the local +protocol tarball, including 15 controller integration tests. The app still +pins the official `0.27.0` package, which lacks these APIs. See the +[application development guide](https://github.com/sekiranunn/openmouse/blob/codex/lunafury-onboard-controls/docs/lunafury-development.md) +for the paired setup and protocol-first release order. + +## Complete DPI restore regressions (2026-10-10) + +The enabled DPI arrays omit dormant slots. Status now also exposes +`lunafury.dpiStorage`, containing the active bank, enabled count and all six +LUNA33 or five TYPE33 X/Y slots. `setLunaFuryDpiStorage` restores that storage +through the same bank-operation queue. It clones the backup before queuing, +rejects another bank, validates enabled values, and preserves raw 16-bit +values in dormant slots. TYPE33's remaining four table bytes stay unchanged. +Readback checks the count, every stored slot, trailing bytes and active stage. + +The companion app keeps this backup in memory only; it is not an editable +Games field and is excluded from saved snapshots and imports. Games count +expansion followed by edits, restore and re-enabling the fourth slot is +covered on all four runtime PIDs. Tests check the complete underlying table, +not just the visible arrays. Additional tests cover reserved dormant values, +ignored writes, malformed backups, bank switches and queue recovery. + +The application also resolves lower-priority legacy scalars before merging +a new game's table snapshot. Both switch directions and restoration of the +accumulated originals are covered through the real controller path. These +are synthetic regressions; no new hardware or Bridge-process result is claimed. ## Live DPI and bank-operation regressions (2026-10-09) @@ -43,32 +83,34 @@ write and readback remain together. Status reads also join the queue, so a concurrent refresh cannot repopulate a switched bank's old cache. Shared setters retain their previous execution path for other brands. -Regression tests interleave each older setter with profile switches, new -DPI edits and live reads in both call orders, record the active bank at each -write, and check that a failed operation does not stall later work. These -are automated results, not new hardware validation. +Regression tests exercise older setters with profile switches, plus mixed +old/new setters and live reads in both call orders. They record the active +bank at each write and check that a failed operation does not stall later +work. These tests use synthetic devices. ## Bottom-button power / DPI behavior (2026-10-09, hardware unverified) -At the owner's request, the three added fixed actions (left double click, -browser refresh and open browser) were withdrawn. They are absent from the -official default menu. The previous verified DPI interface is retained. -No hardware binding is automatically restored or overwritten by this rollback; -existing removed assignments become unknown/read-only. +The fixed actions for left double click, browser refresh and open browser +were withdrawn because they are absent from the official default menu. +The previous verified DPI interface is retained. Removing those actions +does not rewrite a hardware binding; existing assignments using them remain +unknown/read-only. + +The official `settingDialog.vue` exposes two bottom-button modes: -The current official `settingDialog.vue` exposes two bottom-button modes: 1. Short press: power on/off. 2. Long press: power on/off; short press: DPI. `XviUpdater.getDpiPowerMode(profile)` reads target 2, page 3, command 0x80, length 6, selector `[profile, 0x14]`. Its setter writes: + - Mode 1: length 5, `[profile, 0x14, 0, 0x15, 1]`. - Mode 2: length 6, `[profile, 0x14, 0, 7, 1, 6]`. -The special five-byte power binding is not encoded as an ordinary button. -The read validates bank, selector, function and data rather than treating -every unknown function as mode 2 as the vendor UI does. Unsupported, -truncated and unfamiliar bindings stay hidden and cannot be overwritten. +The power binding has a special five-byte layout. The decoder validates +bank, selector, function and data. The vendor UI treats unknown functions +as mode 2; this driver hides unsupported, truncated and unfamiliar bindings +and refuses to overwrite them. Writes reread the existing binding, run through the onboard operation queue, and confirm the exact selected mode. Ordinary buttons and other banks are not written. Other brands cannot call this setter. @@ -88,17 +130,17 @@ disconnect it; turn it on again and reconnect before continuing. ## Official-style DPI editor (2026-10-08) -At the owner's request, the custom DPI color extension was withdrawn. The -LunaFury client no longer exposes a color setter or reads/writes the page-2 -color table. Other brands' color support is unchanged. A regression test -checks that status reads and stage-count edits send no color-table commands. +The custom DPI color extension was withdrawn. The LunaFury client has no +color setter and does not read or write the page-2 color table. Other brands' +color support is unchanged. A regression test checks that status reads and +stage-count edits send no color-table commands. The app uses the official driver's fixed index palette, active-stage X/Y sliders, stage-value inputs and an explicit stage-count selector. Clicking a tile or focusing an input selects that stage. Numeric inputs clamp to -50–30000 and round down in 50-DPI steps. The existing staged Apply workflow, -serialized bank operations and readback-confirmed writes are preserved. -Stage-count changes retain dormant device values as before. +50 through 30000 and round down in 50-DPI steps. The existing staged Apply +workflow, serialized bank operations and readback-confirmed writes are +preserved. Stage-count changes retain dormant device values as before. Hardware retest: verify tile selection, value inputs, current-stage sliders, linked/separate axes, count shrink/expansion, undo/Apply and reconnect in @@ -119,11 +161,12 @@ This receiver-global setting is deliberately excluded from Games profiles. Vendor `getSleepTime(profile)` reads page 0 command `0x87`; `setSleepTime` writes command 7, three bytes `[profile, secondsHigh, secondsLow]`. The UI -offers 1–30 minutes, converting zero minutes to `0xffff` for Never. OpenMouse -represents Never as zero seconds and preserves custom normal timeouts; zero -wire values and other reserved values are not guessed. Only LunaFury uses this -conversion. Sleep edits share the onboard-operation queue with profile -switches and filter replies by profile before verifying the decoded timeout. +offers 1 through 30 minutes, converting zero minutes to `0xffff` for Never. +OpenMouse represents Never as zero seconds and preserves custom normal +timeouts; zero wire values and other reserved values are not guessed. Only +LunaFury uses this conversion. Sleep edits share the onboard-operation queue +with profile switches and filter replies by profile before verifying the +decoded timeout. Hardware checklist: record the current receiver mode and each bank's timeout. On the 8K receiver, apply Mixed/Battery/DPI/Off individually, inspect the LED, @@ -133,10 +176,6 @@ then choose Never and wait beyond that timeout without using the mouse. Switch banks to check isolation, restore original values, and compare with the vendor configurator (never run both configurators concurrently). -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`. @@ -167,7 +206,7 @@ CompX/XVI envelope implemented by `src/compx/codec.ts` and | --- | --- | --- | | Firmware | page `0x00`, command `0x81` | read only | | Battery | page `0x00`, command `0x83` | read only | -| Active profile | page `0x00`, command `0x85` | read only | +| Active profile | page `0x00`, command `0x85` | writable; see onboard controls below | | 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` | @@ -207,13 +246,13 @@ and disables after them; other brands retain their existing ordering. `XviUpdater.hidIndex` is 1: received WebHID buffers do not include the report ID. Decoders use the payload after the six-byte CompX header. Lightning mode and sensor angle are payload byte 1; button ID is byte 2 and button latency -is bytes 3–4; wheel enable is byte 1 and its window is bytes 2–3. +is bytes 3 and 4; wheel enable is byte 1 and its window is bytes 2 and 3. -Left/right latency uses 0–15 ms in 1 ms steps; middle-button debounce uses -1–30 ms. The 19-byte latency write payload is +Left/right latency uses 0 through 15 ms in 1 ms steps; middle-button debounce +uses 1 through 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. +Wheel guard uses 20 through 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. @@ -238,12 +277,12 @@ settings can also be saved in Games profiles as a partial LunaFury namespace. Each profile restores only the controls it changed, preserving unrelated settings and valid zero values. -## Required hardware check +## Hardware checklist ### Onboard controls and button remapping -The public configurator's runtime controls use profile IDs 1–3. Button scan -rate is page 0, read `0x9f` / write `0x1f`, with codes 1/32/64/128 for +The public configurator's runtime controls use profile IDs 1 through 3. +Button scan rate is page 0, read `0x9f` / write `0x1f`, with codes 1/32/64/128 for 1000/2000/4000/8000 Hz. LUNA33 cable offers only 1000 Hz. DPI stages use page 1, read `0x81` / write `0x01`, with a 26-byte table; active stage is read `0x82` / write `0x02`. Separate axes use `0x8d` / `0x0d`. Active @@ -251,7 +290,7 @@ profile uses page 0, read `0x85` / write `0x05`. Button remapping uses page 3, read `0x80` / write `0x00`. The payload is `[profile, button, 0, function, dataLength, ...data]`. Reads request -function `0xff`, data length 10 and envelope length 15. Physical IDs 1–5 +function `0xff`, data length 10 and envelope length 15. Physical IDs 1 through 5 are left, right, middle, back and forward. Mouse actions use function 1; keyboard shortcuts use function 4 with `[modifierMask, HIDusage]`; media actions use function 5 with a big-endian consumer usage; DPI actions use @@ -263,9 +302,9 @@ described above. The owner confirmed ordinary remapping on LUNA33 firmware `0.0.26.0` over both cable and the 8K receiver, as recorded above. TYPE33 remapping remains source-derived and synthetically tested only. Left is protected. Macros and -unknown assignments remain read-only; no macro -storage or unrelated button is rewritten. Each write addresses the active -bank, reads the prior assignment, and checks the complete function/data +unknown assignments remain read-only; no macro storage or unrelated button +is rewritten. Each write addresses the active bank, reads the prior +assignment, and checks the complete function/data readback. Reads match both profile and physical button. Profile switching and remapping share the serialized onboard-operation queue. Games profiles capture and restore only the supported buttons they touched. diff --git a/src/drivers/lamzu/hid.ts b/src/drivers/lamzu/hid.ts index 4bb75897..4fbbbee4 100644 --- a/src/drivers/lamzu/hid.ts +++ b/src/drivers/lamzu/hid.ts @@ -25,6 +25,9 @@ import { lunafuryDecodeButtonRate, lunafuryDecodeSeparateAxes, lunafuryDecodeDpiStages, + lunafuryDecodeDpiStorage, + lunafuryWriteDpiStorage, + type LunaFuryDpiStorage, lunafuryValidateDpi, lunafuryWriteDpiStages, LUNAFURY_BUTTON_IDS, @@ -325,7 +328,7 @@ export class LamzuHidClient { 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 } = await this.readDpiTable(profile); + 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); @@ -373,7 +376,7 @@ export class LamzuHidClient { performanceMode: competitiveMode ? competitiveMode[1] === 1 : null, hyperMode: hyperMode ? hyperMode[1] === 1 : null, rippleControl: rippleControl ? rippleControl[1] === 1 : null, - ...(lunafury ? { lunafury: lunafury.settings, angleTuning: lunafury.angle } : {}), + ...(lunafury ? { lunafury: { ...lunafury.settings, dpiStorage: lunafuryDecodeDpiStorage(dpiRaw, this.maxDpiStages()) }, angleTuning: lunafury.angle } : {}), ...buttons, dongleLedEnabled: dongleLed ? dongleLed[1] === 1 : null, connectionType: wireless ? "Wireless" : "Wired", @@ -392,10 +395,11 @@ export class LamzuHidClient { if (this.deviceBrand() === "LunaFury") { const profile = await this.currentProfile(); if (profile !== previous.activeProfile) return this.readStatusNow(); - const { stages } = await this.readDpiTable(profile); + 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 }; + 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)); @@ -549,17 +553,46 @@ export class LamzuHidClient { return reply[1] - 1; } - private patchDpiTable(stages: readonly LamzuDpiStage[], active: number): void { + 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 confirmed = (await this.readDpiTable(profile)).stages; + 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); + 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 { @@ -580,10 +613,10 @@ export class LamzuHidClient { 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 { stages } = await this.readDpiTable(profile); + 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); + this.patchDpiTable(stages, stage, raw); return stage; }); } @@ -594,6 +627,7 @@ export class LamzuHidClient { 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; diff --git a/src/drivers/lamzu/lunafury-onboard.test.ts b/src/drivers/lamzu/lunafury-onboard.test.ts index 52f705cc..4f6a9210 100644 --- a/src/drivers/lamzu/lunafury-onboard.test.ts +++ b/src/drivers/lamzu/lunafury-onboard.test.ts @@ -276,3 +276,58 @@ test("new controls are gated to LunaFury and do not expose generic Lamzu banks", 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.test.ts b/src/drivers/lamzu/lunafury.test.ts index 8e94a61a..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; @@ -46,7 +52,7 @@ function fakeLunaFury(productId = 0x0033, options: { 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: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]; @@ -70,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 }, }); @@ -99,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 }, }); @@ -118,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 }, }); @@ -141,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 }, }); @@ -174,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 }, }); @@ -204,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"); }); @@ -226,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/lamzu/lunafury-onboard.test.ts b/src/lamzu/lunafury-onboard.test.ts index bcf26d8a..213c91f7 100644 --- a/src/lamzu/lunafury-onboard.test.ts +++ b/src/lamzu/lunafury-onboard.test.ts @@ -2,7 +2,8 @@ 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 } from "./lunafury-onboard.ts"; + 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 [ @@ -54,3 +55,18 @@ test("onboard codecs reject invalid profiles, counts, axes and truncated DPI tab 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 index 223a138d..e9fad21c 100644 --- a/src/lamzu/lunafury-onboard.ts +++ b/src/lamzu/lunafury-onboard.ts @@ -4,6 +4,12 @@ import type { CompaxDpiStage } from "../compx/codec.js"; * 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; @@ -76,6 +82,31 @@ export function lunafuryDecodeDpiStages(payload: Uint8Array, maxStages: number): }); } +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); diff --git a/src/lamzu/lunafury.ts b/src/lamzu/lunafury.ts index 6ef21ac3..c64ceb00 100644 --- a/src/lamzu/lunafury.ts +++ b/src/lamzu/lunafury.ts @@ -6,6 +6,8 @@ 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. */ From a6e8ab9f71e72ef34e6d55e8ec73ddc471b52181 Mon Sep 17 00:00:00 2001 From: Breakdown <169538186+RINZE1@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:44:33 +0800 Subject: [PATCH 4/9] docs(lunafury): record Games switch composition regressions Document the companion app final-table composition and twelve switch regressions, and update paired application validation to 349 tests. Protocol source and hardware-verification claims are unchanged. --- docs/lunafury-testing.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/lunafury-testing.md b/docs/lunafury-testing.md index 24438da5..e43d98b9 100644 --- a/docs/lunafury-testing.md +++ b/docs/lunafury-testing.md @@ -40,8 +40,8 @@ checks excluded: `the probe matrix can trigger every driver` and `no device can be claimed by more than one driver`. This is not a full protocol CI result; both checks still need to run before merge. -The paired OpenMouse build and all 334 application tests pass with the local -protocol tarball, including 15 controller integration tests. The app still +The paired OpenMouse build and all 349 application tests pass with the local +protocol tarball, including 27 controller integration tests. The app still pins the official `0.27.0` package, which lacks these APIs. See the [application development guide](https://github.com/sekiranunn/openmouse/blob/codex/lunafury-onboard-controls/docs/lunafury-development.md) for the paired setup and protocol-first release order. @@ -68,6 +68,25 @@ a new game's table snapshot. Both switch directions and restoration of the accumulated originals are covered through the real controller path. These are synthetic regressions; no new hardware or Bridge-process result is claimed. +### Games switch composition + +The app now merges the complete backup with the next game's target before +calling `setLunaFuryDpiStorage`. Enabled count and all stored X/Y slots are +written together, followed by explicit stage selection if needed. There is +no intermediate restore to the old count. This avoids deduplicating required +writes against the previous game or sorting slot edits before expansion. +An unchanged final table is not rewritten; dormant slots still come from +the original backup, and the driver preserves TYPE33's trailing bytes. + +The controller suite includes three counterexamples on all four PIDs: +shared edited DPI with a different selection, four-to-four with a new fourth +slot, and maximum-count-to-four. Maximum count is six on LUNA33 and five on +TYPE33. All 12 failed before the app fix and pass with it. The tests also +restore the accumulated originals. Protocol source and packet definitions +are unchanged in this round; its build, 151 focused tests, package dry-run +and runtime audit were rerun. The 2,252-test broader result above comes from +the earlier run. No additional hardware result is claimed. + ## Live DPI and bank-operation regressions (2026-10-09) LunaFury live status now reads the current bank's DPI table and active stage, From b89365b83bb643125dd9c0b096ab78ac01d25551 Mon Sep 17 00:00:00 2001 From: Breakdown <169538186+RINZE1@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:09:41 +0800 Subject: [PATCH 5/9] docs(lunafury): record Games failure and retry coverage --- docs/lunafury-testing.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/lunafury-testing.md b/docs/lunafury-testing.md index e43d98b9..3dce078e 100644 --- a/docs/lunafury-testing.md +++ b/docs/lunafury-testing.md @@ -40,8 +40,8 @@ checks excluded: `the probe matrix can trigger every driver` and `no device can be claimed by more than one driver`. This is not a full protocol CI result; both checks still need to run before merge. -The paired OpenMouse build and all 349 application tests pass with the local -protocol tarball, including 27 controller integration tests. The app still +The paired OpenMouse build and all 367 application tests pass with the local +protocol tarball, including 45 controller/Bridge integration tests. The app still pins the official `0.27.0` package, which lacks these APIs. See the [application development guide](https://github.com/sekiranunn/openmouse/blob/codex/lunafury-onboard-controls/docs/lunafury-development.md) for the paired setup and protocol-first release order. @@ -87,6 +87,26 @@ are unchanged in this round; its build, 151 focused tests, package dry-run and runtime audit were rerun. The 2,252-test broader result above comes from the earlier run. No additional hardware result is claimed. +## Companion Games failure handling (2026-10-11) + +The driver's rejected-write errors now reach the consuming app's Games API +as `failed`, rather than `written`. The app also reports a failed final +status read and restores unrelated user staging in a `finally` block. Bridge +does not record a successful signature or notify success for failed writes. +It retains the accumulated original settings through failed and partial +applications/restores, then retries on a later status poll. + +The app adds 18 integration cases, including ignored complete-table writes +and restore retries on all four PIDs. Six run the actual Bridge hook callback +with synthetic refs and a synthetic status subscription, alongside the real +controller and driver. They check partial writes, retained backups, signature +invalidation, retries and success-notification gating. No DOM renderer, +native Bridge process or real device is used by these tests. + +Protocol code and packets are unchanged. Build, all 151 focused tests, +package dry-run and runtime audit were rerun; the earlier 2,252-test result +and full-CI exclusions above remain historical, not a new full protocol pass. + ## Live DPI and bank-operation regressions (2026-10-09) LunaFury live status now reads the current bank's DPI table and active stage, From 8511b5810009d8968a6a77a48d5ce6a8e7014b64 Mon Sep 17 00:00:00 2001 From: Breakdown <169538186+RINZE1@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:45:31 +0800 Subject: [PATCH 6/9] docs(lunafury): record unconfirmed cache recovery tests --- docs/lunafury-testing.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/lunafury-testing.md b/docs/lunafury-testing.md index 3dce078e..5445cbf1 100644 --- a/docs/lunafury-testing.md +++ b/docs/lunafury-testing.md @@ -107,6 +107,31 @@ Protocol code and packets are unchanged. Build, all 151 focused tests, package dry-run and runtime audit were rerun; the earlier 2,252-test result and full-CI exclusions above remain historical, not a new full protocol pass. +### Unconfirmed application status cache + +The companion app now marks its displayed status untrusted when the final +post-write read fails. Before Games planning, backup capture or no-op +confirmation, it requires a successful fresh read. A further refresh failure +returns `failed` without a speculative write; Bridge retains the originals +and retry state. A successful background read restores trust even when the +returned JSON is identical. Already-landed writes can then be confirmed +without rewriting them. + +Seventeen new application integration cases include both stale-cache +counterexamples on all four runtime PIDs and sustained foreground/background +read failures in both directions. They also cover pending user edits, +manual no-op handling, generic scalar-DPI control and overlapping recovery. +Sixteen cases failed on the previous app commit before the fix; same-JSON +background recovery was an already-passing control. The paired build and +all 384 app tests now pass, including 62 controller/Bridge cases; bundle +checks and application audit also pass. +The tests use synthetic devices and hook subscriptions, not a native Bridge +process or new hardware testing. + +No protocol source or API change was needed. The protocol build, all 151 +focused tests, package dry-run and runtime audit passed again. This does not +replace the outstanding full protocol CI or registry-only app validation. + ## Live DPI and bank-operation regressions (2026-10-09) LunaFury live status now reads the current bank's DPI table and active stage, From f1e6f54f5c8a6b0611ee1979a018601ea17c320c Mon Sep 17 00:00:00 2001 From: Breakdown <169538186+RINZE1@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:48:06 +0800 Subject: [PATCH 7/9] docs(lunafury): record manual Apply recovery regressions --- docs/lunafury-testing.md | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/lunafury-testing.md b/docs/lunafury-testing.md index 5445cbf1..6f1ca43f 100644 --- a/docs/lunafury-testing.md +++ b/docs/lunafury-testing.md @@ -40,8 +40,8 @@ checks excluded: `the probe matrix can trigger every driver` and `no device can be claimed by more than one driver`. This is not a full protocol CI result; both checks still need to run before merge. -The paired OpenMouse build and all 367 application tests pass with the local -protocol tarball, including 45 controller/Bridge integration tests. The app still +The paired OpenMouse build and all 420 application tests pass with the local +protocol tarball, including 95 controller/Bridge integration tests. The app still pins the official `0.27.0` package, which lacks these APIs. See the [application development guide](https://github.com/sekiranunn/openmouse/blob/codex/lunafury-onboard-controls/docs/lunafury-development.md) for the paired setup and protocol-first release order. @@ -122,9 +122,9 @@ counterexamples on all four runtime PIDs and sustained foreground/background read failures in both directions. They also cover pending user edits, manual no-op handling, generic scalar-DPI control and overlapping recovery. Sixteen cases failed on the previous app commit before the fix; same-JSON -background recovery was an already-passing control. The paired build and -all 384 app tests now pass, including 62 controller/Bridge cases; bundle -checks and application audit also pass. +background recovery was an already-passing control. At the end of that +round, the paired build and all 384 app tests passed, including 62 +controller/Bridge cases; bundle checks and application audit also passed. The tests use synthetic devices and hook subscriptions, not a native Bridge process or new hardware testing. @@ -132,6 +132,31 @@ No protocol source or API change was needed. The protocol build, all 151 focused tests, package dry-run and runtime audit passed again. This does not replace the outstanding full protocol CI or registry-only app validation. +### Companion manual Apply ordering (2026-10-12) + +The app also recovers untrusted status before executing nonempty manual +Apply. Pending DPI count priority is evaluated from the confirmed enabled +count at execution time, including after background recovery or a Games +stash/restore. Expansion runs before dependent slot edits and reduction +after them. A failed preflight retains staging without writing settings or +automatically retrying that failed attempt. Count restaging removes only +edits and selections outside the final requested count and is blocked while +Apply holds the write guard. + +Thirty-three application integration cases and three ordering unit tests +cover all four runtime PIDs, both count directions, sustained and overlapping +read failures, partial writes, retries, Games-carried manual staging, +consecutive count edits and generic/client-only stand-ins. The 32 fault/order +integration cases and three unit cases fail against app `1b75133` before the +fix. The owner's separate six-to-three-to-four reproduction now succeeds, +with a subsequent Apply returning `unchanged`. + +The paired build and all 420 app tests pass, including 95 controller/Bridge +cases; bundle checks and audit pass. This round changes no protocol source, +API or packets. Protocol build, 151 focused tests, package dry-run and runtime +audit were rerun successfully. No new hardware or native-Bridge result is +claimed, and the earlier full-CI exclusions and release dependency remain. + ## Live DPI and bank-operation regressions (2026-10-09) LunaFury live status now reads the current bank's DPI table and active stage, From aa0b76714da4542719ecf5df5350c16a9529d6ad Mon Sep 17 00:00:00 2001 From: Breakdown <169538186+RINZE1@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:03:21 +0800 Subject: [PATCH 8/9] docs(lunafury): simplify support and verification notes --- README.md | 10 +- docs/lunafury-testing.md | 459 +++------------------------------------ 2 files changed, 40 insertions(+), 429 deletions(-) diff --git a/README.md b/README.md index 2559b350..66e3a62e 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,10 @@ 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 LUNA33 and TYPE33 use the shared Lamzu/CompX driver and the -`@openmouse/protocol/lamzu` entry point. The owner tested LUNA33 firmware -`0.0.26.0` over cable and the 8K receiver. TYPE33 and the new bottom-button -power/DPI behavior still need hardware testing. See -[docs/lunafury-testing.md](docs/lunafury-testing.md) for packet evidence, -the verified feature list and the hardware checklist. +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 diff --git a/docs/lunafury-testing.md b/docs/lunafury-testing.md index 6f1ca43f..ac13f7ff 100644 --- a/docs/lunafury-testing.md +++ b/docs/lunafury-testing.md @@ -1,439 +1,52 @@ -# LunaFury LUNA33 / TYPE33 evidence and testing +# 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 -## Hardware results +Runtime VID: `0x373e`. Bootloader identities are not supported here. -The owner confirmed full-function testing of the original LUNA33 support -with firmware `0.0.26.0`, over cable and the 8K receiver. This verifies the -runtime identities `0x0032` and `0x0033` on that firmware. They confirmed -the later feature rounds on the same model, firmware and connections: - -| Feature | Owner-reported result | -| --- | --- | -| Independent button polling and all three onboard profiles | Normal over cable and 8K | -| DPI stage selection, value editing, separate X/Y and stage count | Normal over cable and 8K, including the revised DPI UI | -| Mouse, scroll, DPI and media remapping | Normal over cable and 8K | -| Keyboard single keys and combinations | Normal over cable and 8K | -| Sleep timeout and Never | Normal over cable and 8K | -| Mixed, Battery, DPI and Off receiver lighting | Normal over 8K; lighting control absent over cable | -| Bank switching and reconnect readback | Normal over cable and 8K | - -The button-polling/onboard round and the revised DPI editor were confirmed -on 2026-10-08. These are the owner's reports, not independent hardware -measurements. TYPE33 has source and synthetic-test coverage only. The new -bottom-button power/DPI modes still need hardware testing, and no separate -Games or power-cycle results were supplied. - -## Automated validation - -The protocol build and all 151 Lamzu driver/codec tests pass. A broader -regression run on 2026-10-10 passed 2,252 tests with two registry exhaustive -checks excluded: `the probe matrix can trigger every driver` and -`no device can be claimed by more than one driver`. This is not a full -protocol CI result; both checks still need to run before merge. - -The paired OpenMouse build and all 420 application tests pass with the local -protocol tarball, including 95 controller/Bridge integration tests. The app still -pins the official `0.27.0` package, which lacks these APIs. See the -[application development guide](https://github.com/sekiranunn/openmouse/blob/codex/lunafury-onboard-controls/docs/lunafury-development.md) -for the paired setup and protocol-first release order. - -## Complete DPI restore regressions (2026-10-10) - -The enabled DPI arrays omit dormant slots. Status now also exposes -`lunafury.dpiStorage`, containing the active bank, enabled count and all six -LUNA33 or five TYPE33 X/Y slots. `setLunaFuryDpiStorage` restores that storage -through the same bank-operation queue. It clones the backup before queuing, -rejects another bank, validates enabled values, and preserves raw 16-bit -values in dormant slots. TYPE33's remaining four table bytes stay unchanged. -Readback checks the count, every stored slot, trailing bytes and active stage. - -The companion app keeps this backup in memory only; it is not an editable -Games field and is excluded from saved snapshots and imports. Games count -expansion followed by edits, restore and re-enabling the fourth slot is -covered on all four runtime PIDs. Tests check the complete underlying table, -not just the visible arrays. Additional tests cover reserved dormant values, -ignored writes, malformed backups, bank switches and queue recovery. - -The application also resolves lower-priority legacy scalars before merging -a new game's table snapshot. Both switch directions and restoration of the -accumulated originals are covered through the real controller path. These -are synthetic regressions; no new hardware or Bridge-process result is claimed. - -### Games switch composition - -The app now merges the complete backup with the next game's target before -calling `setLunaFuryDpiStorage`. Enabled count and all stored X/Y slots are -written together, followed by explicit stage selection if needed. There is -no intermediate restore to the old count. This avoids deduplicating required -writes against the previous game or sorting slot edits before expansion. -An unchanged final table is not rewritten; dormant slots still come from -the original backup, and the driver preserves TYPE33's trailing bytes. - -The controller suite includes three counterexamples on all four PIDs: -shared edited DPI with a different selection, four-to-four with a new fourth -slot, and maximum-count-to-four. Maximum count is six on LUNA33 and five on -TYPE33. All 12 failed before the app fix and pass with it. The tests also -restore the accumulated originals. Protocol source and packet definitions -are unchanged in this round; its build, 151 focused tests, package dry-run -and runtime audit were rerun. The 2,252-test broader result above comes from -the earlier run. No additional hardware result is claimed. - -## Companion Games failure handling (2026-10-11) - -The driver's rejected-write errors now reach the consuming app's Games API -as `failed`, rather than `written`. The app also reports a failed final -status read and restores unrelated user staging in a `finally` block. Bridge -does not record a successful signature or notify success for failed writes. -It retains the accumulated original settings through failed and partial -applications/restores, then retries on a later status poll. - -The app adds 18 integration cases, including ignored complete-table writes -and restore retries on all four PIDs. Six run the actual Bridge hook callback -with synthetic refs and a synthetic status subscription, alongside the real -controller and driver. They check partial writes, retained backups, signature -invalidation, retries and success-notification gating. No DOM renderer, -native Bridge process or real device is used by these tests. - -Protocol code and packets are unchanged. Build, all 151 focused tests, -package dry-run and runtime audit were rerun; the earlier 2,252-test result -and full-CI exclusions above remain historical, not a new full protocol pass. - -### Unconfirmed application status cache - -The companion app now marks its displayed status untrusted when the final -post-write read fails. Before Games planning, backup capture or no-op -confirmation, it requires a successful fresh read. A further refresh failure -returns `failed` without a speculative write; Bridge retains the originals -and retry state. A successful background read restores trust even when the -returned JSON is identical. Already-landed writes can then be confirmed -without rewriting them. - -Seventeen new application integration cases include both stale-cache -counterexamples on all four runtime PIDs and sustained foreground/background -read failures in both directions. They also cover pending user edits, -manual no-op handling, generic scalar-DPI control and overlapping recovery. -Sixteen cases failed on the previous app commit before the fix; same-JSON -background recovery was an already-passing control. At the end of that -round, the paired build and all 384 app tests passed, including 62 -controller/Bridge cases; bundle checks and application audit also passed. -The tests use synthetic devices and hook subscriptions, not a native Bridge -process or new hardware testing. - -No protocol source or API change was needed. The protocol build, all 151 -focused tests, package dry-run and runtime audit passed again. This does not -replace the outstanding full protocol CI or registry-only app validation. - -### Companion manual Apply ordering (2026-10-12) - -The app also recovers untrusted status before executing nonempty manual -Apply. Pending DPI count priority is evaluated from the confirmed enabled -count at execution time, including after background recovery or a Games -stash/restore. Expansion runs before dependent slot edits and reduction -after them. A failed preflight retains staging without writing settings or -automatically retrying that failed attempt. Count restaging removes only -edits and selections outside the final requested count and is blocked while -Apply holds the write guard. - -Thirty-three application integration cases and three ordering unit tests -cover all four runtime PIDs, both count directions, sustained and overlapping -read failures, partial writes, retries, Games-carried manual staging, -consecutive count edits and generic/client-only stand-ins. The 32 fault/order -integration cases and three unit cases fail against app `1b75133` before the -fix. The owner's separate six-to-three-to-four reproduction now succeeds, -with a subsequent Apply returning `unchanged`. - -The paired build and all 420 app tests pass, including 95 controller/Bridge -cases; bundle checks and audit pass. This round changes no protocol source, -API or packets. Protocol build, 151 focused tests, package dry-run and runtime -audit were rerun successfully. No new hardware or native-Bridge result is -claimed, and the earlier full-CI exclusions and release dependency remain. - -## Live DPI and bank-operation regressions (2026-10-09) - -LunaFury live status now reads the current bank's DPI table and active stage, -updating both axes instead of reusing the previous selected-stage cache. -Physical DPI switches and changes to stage count/values are covered on all -four runtime identities with synthetic HID fixtures. Refresh sends no writes -and does not repeatedly read the static LunaFury extension controls. - -The older USB polling, LOD, flags, global debounce, sensor angle, Lightning -Trigger, per-button latency and wheel-guard setters now share the onboard -queue with bank switching and the newer setters. Their complete bank lookup, -write and readback remain together. Status reads also join the queue, so a -concurrent refresh cannot repopulate a switched bank's old cache. Shared -setters retain their previous execution path for other brands. - -Regression tests exercise older setters with profile switches, plus mixed -old/new setters and live reads in both call orders. They record the active -bank at each write and check that a failed operation does not stall later -work. These tests use synthetic devices. - -## Bottom-button power / DPI behavior (2026-10-09, hardware unverified) - -The fixed actions for left double click, browser refresh and open browser -were withdrawn because they are absent from the official default menu. -The previous verified DPI interface is retained. Removing those actions -does not rewrite a hardware binding; existing assignments using them remain -unknown/read-only. - -The official `settingDialog.vue` exposes two bottom-button modes: - -1. Short press: power on/off. -2. Long press: power on/off; short press: DPI. - -`XviUpdater.getDpiPowerMode(profile)` reads target 2, page 3, command 0x80, -length 6, selector `[profile, 0x14]`. Its setter writes: - -- Mode 1: length 5, `[profile, 0x14, 0, 0x15, 1]`. -- Mode 2: length 6, `[profile, 0x14, 0, 7, 1, 6]`. - -The power binding has a special five-byte layout. The decoder validates -bank, selector, function and data. The vendor UI treats unknown functions -as mode 2; this driver hides unsupported, truncated and unfamiliar bindings -and refuses to overwrite them. -Writes reread the existing binding, run through the onboard operation -queue, and confirm the exact selected mode. Ordinary buttons and other -banks are not written. Other brands cannot call this setter. - -The card is on the Buttons page and uses staged Apply/undo. The setting -belongs to the current onboard bank but is deliberately excluded from -Games and profile imports so automatic game changes cannot alter power -button behavior. Changing the setting configures the binding; it does not -execute a power-off command. Both actual press behavior and persistence -after a power cycle are unverified; TYPE33 remains unverified. - -Test on LUNA33 over cable and 8K with a backup pointing device available: -record each bank's original mode, compare with the official driver, apply -both modes, check physical short/long presses, reconnect/readback and bank -isolation, then restore the originals. Switching the mouse off may -disconnect it; turn it on again and reconnect before continuing. - -## Official-style DPI editor (2026-10-08) - -The custom DPI color extension was withdrawn. The LunaFury client has no -color setter and does not read or write the page-2 color table. Other brands' -color support is unchanged. A regression test checks that status reads and -stage-count edits send no color-table commands. - -The app uses the official driver's fixed index palette, active-stage X/Y -sliders, stage-value inputs and an explicit stage-count selector. Clicking a -tile or focusing an input selects that stage. Numeric inputs clamp to -50 through 30000 and round down in 50-DPI steps. The existing staged Apply -workflow, serialized bank operations and readback-confirmed writes are -preserved. Stage-count changes retain dormant device values as before. - -Hardware retest: verify tile selection, value inputs, current-stage sliders, -linked/separate axes, count shrink/expansion, undo/Apply and reconnect in -each onboard bank over cable and 8K. Fixed color tiles are display markers, -not device color readback. No firmware/reset actions are included. - -## Receiver lighting and Never auto-sleep (2026-10-08) - -Re-inspection of the same vendor bundles found `SetLightEffect(1, mode)` and -`GetLightEffect(1)`. These address the receiver, not the active onboard bank: -target 0 for LUNA33 PID `0x0033`, target 1 for TYPE33 PID `0x0084`. -Page 2, command `0x80` reads six bytes with zone argument 1; command 0 writes -`[1, 0, mode, 0, 0, 0]`. Mode is payload byte 2: 2 = Mixed, 6 = Battery, -11 = DPI, 0 = Off. Wired identities never receive these commands. Unknown, -unsupported and truncated reads do not expose a control. Writes require a -supported pre-read and exact readback; stale zone replies are filtered. -This receiver-global setting is deliberately excluded from Games profiles. - -Vendor `getSleepTime(profile)` reads page 0 command `0x87`; `setSleepTime` -writes command 7, three bytes `[profile, secondsHigh, secondsLow]`. The UI -offers 1 through 30 minutes, converting zero minutes to `0xffff` for Never. -OpenMouse represents Never as zero seconds and preserves custom normal -timeouts; zero wire values and other reserved values are not guessed. Only -LunaFury uses this conversion. Sleep edits share the onboard-operation queue -with profile switches and filter replies by profile before verifying the -decoded timeout. - -Hardware checklist: record the current receiver mode and each bank's timeout. -On the 8K receiver, apply Mixed/Battery/DPI/Off individually, inspect the LED, -and reconnect to verify readback. Cable connections must not show lighting. -For each connection, set a short sleep timeout, confirm actual idle sleep, -then choose Never and wait beyond that timeout without using the mouse. -Switch banks to check isolation, restore original values, and compare with -the vendor configurator (never run both configurators concurrently). - -## 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` | writable; see onboard controls below | -| Polling rate | page `0x01`, command `0x80` | command `0x00`; `08/04/02/01/20/40/80` = 125 through 8000 Hz | -| Sleep | page `0x00`, command `0x87` | command `0x07` | -| Debounce | page `0x00`, command `0x88` | command `0x08` | -| Lift-off distance | page `0x01`, command `0x88` | command `0x08`; `87/01/02` = low/medium/high | -| DPI stages | page `0x01`, command `0x81` | command `0x01` | -| Angle snap | page `0x01`, command `0x84` | command `0x04` | -| Motion sync | page `0x01`, command `0x89` | command `0x09` | -| Ripple control | page `0x01`, command `0x8a` | command `0x0a` | -| Competitive Mode / 竞技模式 | page `0x01`, command `0x8b` | command `0x0b`; vendor `setHyperMode`, shared field `hyperMode` | -| Tracking Mode / 追踪模式 | page `0x01`, command `0x93` | command `0x13`; vendor `setTrackingMode`, shared field `performanceMode` | -| Sensor angle | page `0x01`, command `0x94`, length 2 | command `0x14`; signed byte, −30° through +30° | -| Lightning Trigger | page `0x00`, command `0x98`, length 4 | command `0x18`; 0 = off, 1 = left priority, 2 = right priority | -| Per-button latency | page `0x00`, command `0x92`, length 19 | command `0x12`; button IDs 1/2/3 = left/right/middle | -| Wheel anti-mistouch | page `0x00`, command `0x99`, length 4 | command `0x19`; enable byte and big-endian window in milliseconds | - -The cable transport rewrites mouse target `0x02` to target `0x00`. The 2.4 GHz -receiver keeps mouse commands on target `0x02`, while receiver firmware remains -on target `0x00`. These are represented by the product profiles rather than a -forked LunaFury protocol implementation. - -### LunaFury-specific controls - -LunaFury labels follow its official `language.js` and `patternModule.vue`: -`angleSnapping` is 直线修正, `hyperMode` is 竞技模式, and `performanceMode` -is 追踪模式. Motion Sync and Ripple Control use 运动同步 and 波纹控制 in -Chinese. These are display overrides for LunaFury only; commands and labels -for other brands remain unchanged. - -The owner clarified the hardware semantics on 2026-10-05: Competitive Mode -is Corded mode and Tracking Mode locks sensor sampling at 20 kHz. This -description is owner-provided, not an independently measured result. -The UI mirrors the vendor's `v-if="competition"`: tracking is shown only -while competitive mode is confirmed on. Hiding it preserves its stored -value. For staged changes, competitive mode enables before tracking edits -and disables after them; other brands retain their existing ordering. - -`XviUpdater.hidIndex` is 1: received WebHID buffers do not include the report -ID. Decoders use the payload after the six-byte CompX header. Lightning mode -and sensor angle are payload byte 1; button ID is byte 2 and button latency -is bytes 3 and 4; wheel enable is byte 1 and its window is bytes 2 and 3. - -Left/right latency uses 0 through 15 ms in 1 ms steps; middle-button debounce -uses 1 through 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 through 200 ms in 20 ms steps. A disabled zero window is -a valid read, not a guessed default; enabling it from that state proposes 100 ms. -Writing a disabled zero window is supported so restoring a Games profile -does not invent a different prior value. - -All added setters read back the value and reject a mismatch. Optional reads -that fail or return invalid values leave their controls absent, while basic -device settings remain available. Only the LunaFury product profiles issue -these extra commands; other CompX brands are unchanged. +| LUNA33 | `0x0032` | `0x0033` | 1-6 | +| TYPE33 | `0x0054` | `0x0084` | 1-5 | -LunaFury extension reads accept a reply only when its payload echoes the -requested profile; button-latency reads also match the button ID. A successful -reply for another profile or button is ignored while the same request waits -for a matching reply, within its existing attempt limit. This applies to both -initial reads and write readbacks. Generic CompX requests and write -acknowledgments retain their existing matching rules. +The 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 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. +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. -## Hardware checklist +## Hardware verification -### Onboard controls and button remapping +The owner verified LUNA33 firmware `0.0.26.0` over cable and 8K: -The public configurator's runtime controls use profile IDs 1 through 3. -Button scan rate is page 0, read `0x9f` / write `0x1f`, with codes 1/32/64/128 for -1000/2000/4000/8000 Hz. LUNA33 cable offers only 1000 Hz. DPI stages use -page 1, read `0x81` / write `0x01`, with a 26-byte table; active stage is -read `0x82` / write `0x02`. Separate axes use `0x8d` / `0x0d`. Active -profile uses page 0, read `0x85` / write `0x05`. +- 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. -Button remapping uses page 3, read `0x80` / write `0x00`. The payload is -`[profile, button, 0, function, dataLength, ...data]`. Reads request -function `0xff`, data length 10 and envelope length 15. Physical IDs 1 through 5 -are left, right, middle, back and forward. Mouse actions use function 1; -keyboard shortcuts use function 4 with `[modifierMask, HIDusage]`; media -actions use function 5 with a big-endian consumer usage; DPI actions use -function 7. Disabled is function 0 with no data. Receiver buttons, -bottom controls, macros and vendor-specific advanced actions are excluded -from ordinary remapping. Bottom-button behavior has its separate control -described above. +TYPE33 and bottom-button behavior are not hardware-verified. +Games apply/restore has automated coverage only. -The owner confirmed ordinary remapping on LUNA33 firmware `0.0.26.0` over -both cable and the 8K receiver, as recorded above. TYPE33 remapping remains -source-derived and synthetically tested only. Left is protected. Macros and -unknown assignments remain read-only; no macro storage or unrelated button -is rewritten. Each write addresses the active bank, reads the prior -assignment, and checks the complete function/data -readback. Reads match both profile and physical button. Profile switching -and remapping share the serialized onboard-operation queue. Games profiles -capture and restore only the supported buttons they touched. +## Automated checks -For hardware testing, record the current bank and assignments. Change right, -middle or a side button to a reversible mouse action first, then test scroll, -DPI, a single key, a shortcut and a media action. Check the actual output, -refresh/reconnect readback and bank isolation, then restore the originals. -Check that a macro configured in the official driver stays unchanged. -Games apply/restore needs a separate pass; a successful readback alone does -not prove that the bound key or action fires correctly. +Build, package checks and all 151 Lamzu driver/codec tests pass. +Coverage includes the four runtime PIDs, bank isolation, DPI storage, +write/read failures and retry recovery. Runtime audit reports no vulnerabilities. +These focused results do not establish a full protocol CI pass. -Connect each available cable/receiver identity in Chromium and confirm: +The paired app passes its build, 420 tests and bundle checks 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. +Both branches require upstream synchronization and full CI before merge. +See the [application setup](https://github.com/sekiranunn/openmouse/blob/codex/lunafury-onboard-controls/docs/lunafury-development.md). From 6e711d5e9bceafb0d4fe17749f0cb7fb5848c824 Mon Sep 17 00:00:00 2001 From: Breakdown <169538186+RINZE1@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:38:57 +0800 Subject: [PATCH 9/9] fix: preserve unknown LunaFury axis capability after read failures --- docs/lunafury-testing.md | 8 +++++-- src/drivers/lamzu/hid.ts | 8 +++++-- src/drivers/lamzu/lunafury-onboard.test.ts | 26 ++++++++++++++++++++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/docs/lunafury-testing.md b/docs/lunafury-testing.md index c4ef3ea3..47235079 100644 --- a/docs/lunafury-testing.md +++ b/docs/lunafury-testing.md @@ -35,8 +35,8 @@ Games apply/restore has automated coverage only. ## Automated checks -Build, package checks and all 2,470 protocol tests pass, including both -exhaustive registry checks. The 158 Lamzu driver/codec tests also pass with +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. @@ -49,5 +49,9 @@ Synthetic HID and Bridge callbacks do not replace hardware testing. ## Integration 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.ts b/src/drivers/lamzu/hid.ts index 321e8cc1..415c65e7 100644 --- a/src/drivers/lamzu/hid.ts +++ b/src/drivers/lamzu/hid.ts @@ -334,7 +334,8 @@ export class LamzuHidClient { : 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 (luna ? this.readLunaFury(LUNAFURY_ONBOARD_READ.separateAxes(profile)) : this.request(PROFILE_READ.separateAxes(profile))).catch(() => null); @@ -369,7 +370,10 @@ export class LamzuHidClient { batteryState: battery[0] === 1 ? "Charging" : "Discharging", dpi: stage.x, dpiY: stage.y, - supportsSeparateDpiAxes: luna ? lunafuryDecodeSeparateAxes(separateAxes) !== undefined : 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(), diff --git a/src/drivers/lamzu/lunafury-onboard.test.ts b/src/drivers/lamzu/lunafury-onboard.test.ts index 4f6a9210..526b0cc2 100644 --- a/src/drivers/lamzu/lunafury-onboard.test.ts +++ b/src/drivers/lamzu/lunafury-onboard.test.ts @@ -4,7 +4,7 @@ import { LamzuHidClient } from "./hid.ts"; (globalThis as { window?: { setTimeout: typeof setTimeout } }).window ??= { setTimeout }; -function fixture(productId = 0x0033, options: { ignore?: string; stale?: string; unsupported?: boolean; truncate?: boolean } = {}) { +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, @@ -45,6 +45,7 @@ function fixture(productId = 0x0033, options: { ignore?: string; stale?: string; 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]; @@ -191,12 +192,33 @@ test("unsupported optional features stay absent and truncated DPI tables fail sa const status = await client.readStatus(); assert.equal(status.lunafury?.buttonPollingRateHz, undefined); assert.equal(status.lunafury?.separateDpiAxes, undefined); - assert.equal(status.supportsSeparateDpiAxes, false); + 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();