From 4052d30edb830dc0eaaec3225cd56c881d26c119 Mon Sep 17 00:00:00 2001 From: toriset Date: Wed, 21 Oct 2026 21:06:23 +0300 Subject: [PATCH 1/2] fix(logitech): stop background profile reads from racing profile writes HID++ replies are matched by feature and function only, so a background onboard-profile reload running during a flash took the write's sector reads and failed its checksum check ("The profile failed its checksum; refusing to write"). HITS tuning applied live but never reached the profile, so it reset on the next power cycle. - flash waits for an in-flight profile reload as well as a refresh - the automatic reload no longer starts during a write - the reload button is ignored during a write - the wait limit is raised to 5 s to cover a wireless reread --- src/device/controller.ts | 27 ++++++++++++++++++++------- src/features/profiles/Profiles.tsx | 2 +- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/device/controller.ts b/src/device/controller.ts index 7ce0bbf8..61d099b5 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -253,9 +253,10 @@ export const PULSAR_SLEEP_OPTIONS: ReadonlyArray = [ const BACKGROUND = "Background refresh"; const FLASH_STEP_DELAY_MS = 420; const FLASH_SETTLE_MS = 320; -// Longest a flash waits for an in-flight device refresh before giving up -// instead of busy-waiting forever. -const REFRESH_WAIT_TIMEOUT_MS = 2000; +// Longest a flash waits for an in-flight device read before giving up +// instead of busy-waiting forever. A full onboard-profile reread over a +// wireless link can take a few seconds. +const REFRESH_WAIT_TIMEOUT_MS = 5000; const previewModeEnabled = previewsEnabled(__BUILD_CHANNEL__, import.meta.env.DEV); const previewMode = previewModeEnabled @@ -2192,13 +2193,17 @@ export async function flashPendingChanges(): Promise { emit(); let written = 0; let failure: string | null = null; - if (refreshInProgress) { + // HID++ replies are matched by feature and function only, so a profile read + // still running in the background would take the write's sector reads and + // fail its checksum check. Let every read finish first. + const reading = (): boolean => refreshInProgress || onboardProfilesLoading; + if (reading()) { pendingStatusText = st("ctl.waitRefresh"); readStatus = pendingStatusText; emit(); const refreshDeadline = Date.now() + REFRESH_WAIT_TIMEOUT_MS; - while (refreshInProgress && Date.now() <= refreshDeadline) await wait(25); - if (refreshInProgress) { + while (reading() && Date.now() <= refreshDeadline) await wait(25); + if (reading()) { const timeout = new Error("Timed out waiting for the device refresh."); recordDiagnosticError(timeout, st("ctl.unableFlash")); failure = timeout.message; @@ -2697,7 +2702,9 @@ function applyStatusInner(deviceStatus: MouseStatus, statusKey?: string): void { if (supportsOnboard) { lastDeviceMode = status.deviceMode; lastProfileFormat = status.onboardProfileFormat ?? null; - if (onboardProfiles === null && !onboardProfilesLoading) void reloadOnboardProfiles(); + // A write rereads the profiles itself; starting a read here would interleave + // with its sector reads. + if (onboardProfiles === null && !onboardProfilesLoading && !settingInProgress) void reloadOnboardProfiles(); else syncProfileDerivedState(); } syncEditedNapeLayer(deviceStatus); @@ -4696,6 +4703,12 @@ export function describeProfileEntry(entry: OnboardProfile | null): { name: stri }; } +/** The reload button. Skipped mid-write, which rereads the profiles itself. */ +export function rereadOnboardProfiles(): void { + if (settingInProgress) return; + void reloadOnboardProfiles(); +} + export async function reloadOnboardProfiles(): Promise { const client = logitechClient(); if (!client || onboardProfilesLoading) return false; diff --git a/src/features/profiles/Profiles.tsx b/src/features/profiles/Profiles.tsx index e081e92f..278c1cc8 100644 --- a/src/features/profiles/Profiles.tsx +++ b/src/features/profiles/Profiles.tsx @@ -84,7 +84,7 @@ export function Profiles({ type="button" aria-label={t(locale, "prof.reload")} title={t(locale, "prof.reload")} - onClick={() => void control.reloadOnboardProfiles()} + onClick={() => control.rereadOnboardProfiles()} > From d5833756de6ca3edb1de36a2ca743a59b26e7875 Mon Sep 17 00:00:00 2001 From: toriset Date: Thu, 22 Oct 2026 15:34:46 +0300 Subject: [PATCH 2/2] fix(logitech): retry a deferred profile read when the write ends A status applied mid-write skips the onboard-profile read so it can't interleave with the write's sector reads. If the profiles were still unloaded, nothing retried that read once the write finished, leaving the list empty until the next status change or a manual reload. --- src/device/controller.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/device/controller.ts b/src/device/controller.ts index 61d099b5..4a4f19b3 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -358,6 +358,8 @@ const napeKeymaps = new Map(); const stagedNapeAssignments = new Map(); let keymapReadToken = 0; let onboardProfilesLoading = false; +// A status applied mid-write skips the profile read; the write's end retries it. +let onboardReloadDeferred = false; let lastDeviceMode: MouseStatus["deviceMode"] = "Unknown"; let lastProfileFormat: MouseStatus["onboardProfileFormat"] = null; @@ -2253,8 +2255,11 @@ export async function flashPendingChanges(): Promise { function endDeviceWrite(): void { settingInProgress = false; + const retryProfiles = onboardReloadDeferred && onboardProfiles === null && !onboardProfilesLoading; + onboardReloadDeferred = false; syncProfileDerivedState(); emit(); + if (retryProfiles) void reloadOnboardProfiles(); } function saveInterfacePreferences(): void { @@ -2702,8 +2707,9 @@ function applyStatusInner(deviceStatus: MouseStatus, statusKey?: string): void { if (supportsOnboard) { lastDeviceMode = status.deviceMode; lastProfileFormat = status.onboardProfileFormat ?? null; - // A write rereads the profiles itself; starting a read here would interleave - // with its sector reads. + // A read started mid-write would interleave with the write's sector reads, + // so it waits for endDeviceWrite(). + if (onboardProfiles === null && !onboardProfilesLoading && settingInProgress) onboardReloadDeferred = true; if (onboardProfiles === null && !onboardProfilesLoading && !settingInProgress) void reloadOnboardProfiles(); else syncProfileDerivedState(); }