diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index 80f80a0c..f358b02d 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -9,7 +9,7 @@ const BUDGET_BYTES: Record = { // guarded flash editor, verification exporter, upstream Finalmouse driver, // the dedicated Viper Mini protocol driver, and Viper V3 sleep/low-power plus // asymmetric lift-off protocol and controls. Preview fixtures remain dev-only. - ".js": 325_000, + ".js": 340_000, }; const ASSETS = join("dist", "assets"); diff --git a/src/capture-format.test.ts b/src/capture-format.test.ts index 576c306f..8b2287d6 100644 --- a/src/capture-format.test.ts +++ b/src/capture-format.test.ts @@ -7,6 +7,8 @@ import { diffSectors, formatCaptureMarkdown, formatProfileVerificationMarkdown, + formatProfileWriteProbeBackupMarkdown, + formatProfileWriteProbeReportMarkdown, type CaptureExport, } from "./capture-format.ts"; @@ -115,6 +117,27 @@ test("profile verification exports geometry, raw replies, directory and every fo currentProfileReply: new Uint8Array([0x11, 0xff, 0x40, 0x00, 0x02]), directory: new Uint8Array([0x00, 0x02, 0x01, 0x00, 0xff, 0xff, 0xff, 0xff]), directoryCrcValid: true, + dpiCapabilities: { + featureId: 0x2201, + featureIndex: 0x0d, + featureVersion: 1, + kind: "legacy", + replies: [ + { name: "getFeature", bytes: new Uint8Array([0x11, 0xff, 0x00, 0x0d, 0x00, 0x00, 0x01]) }, + { name: "getSensorDpiList", bytes: new Uint8Array([0x11, 0xff, 0x10, 0x00, 0x64, 0xe0, 0x32, 0x0c, 0x80]) }, + ], + decodedValues: [100, 150, 200], + error: null, + }, + reportRateCapabilities: { + featureId: 0x8060, + featureIndex: 0x0e, + featureVersion: 0, + kind: "legacy", + replies: [{ name: "getReportRateList", bytes: new Uint8Array([0x11, 0xff, 0x00, 0x8b]) }], + decodedValues: [125, 250, 500, 1000], + error: null, + }, profiles: [{ sector: 2, enabled: true, @@ -128,6 +151,11 @@ test("profile verification exports geometry, raw replies, directory and every fo assert.match(markdown, /Profile format: 8 · FORMAT 8/); assert.match(markdown, /Sector geometry: 4 × 8 bytes/); assert.match(markdown, /### Directory sector 0/); + assert.match(markdown, /### Device capability replies/); + assert.match(markdown, /DPI values: 100 DPI, 150 DPI, 200 DPI/); + assert.match(markdown, /DPI getSensorDpiList: `11 ff 10 00 64 e0 32 0c 80`/); + assert.match(markdown, /Report rate values: 125 Hz, 250 Hz, 500 Hz, 1000 Hz/); + assert.match(markdown, /Report rate getReportRateList: `11 ff 00 8b`/); assert.match(markdown, /### Profile sector 0x0002/); assert.match(markdown, /"reportRateWireless": 8000/); assert.match(markdown, /no profile flash was written/); @@ -152,3 +180,40 @@ test("verification formatting accepts every recovered profile format", () => { assert.match(markdown, new RegExp(`Profile format: ${profileFormatId} · test`)); } }); + +test("write-probe reports embed the recovery backup and restoration verdict", () => { + const backup = { + formatId: 4, + sector: 1, + sectorSize: 8, + originalMode: "Host" as const, + originalCurrentSector: 0, + directory: new Uint8Array([0, 1, 1, 0, 0, 0, 0xaa, 0xbb]), + profile: new Uint8Array([1, 1, 0, 0, 0, 0, 0x12, 0x34]), + dpiOptions: [50, 100, 150], + reportRates: [125, 250, 500, 1000], + }; + const recovery = formatProfileWriteProbeBackupMarkdown(backup); + assert.match(recovery, /profile-write probe recovery backup/); + assert.match(recovery, /Original profile sector 0x0001/); + assert.match(recovery, /01 01 00 00 00 00 12 34/); + + const report = formatProfileWriteProbeReportMarkdown({ + backup, + steps: [{ + setting: "dpi", + intended: "1000 DPI", + storedExactly: true, + liveConfirmed: true, + restored: true, + error: null, + }], + restored: true, + modeRestored: true, + ok: true, + }); + assert.match(report, /profile-write verification — PASSED/); + assert.match(report, /Live value confirmed: true/); + assert.match(report, /Final profile restored: true/); + assert.match(report, /profile-write probe recovery backup/); +}); diff --git a/src/capture-format.ts b/src/capture-format.ts index 3f458238..b73bdeea 100644 --- a/src/capture-format.ts +++ b/src/capture-format.ts @@ -1,3 +1,8 @@ +import type { + ProfileContentWriteProbeBackup, + ProfileContentWriteProbeReport, +} from "./devices/logitech/hidpp"; + /** * Export format for profile-layout confirmations. * @@ -113,6 +118,18 @@ export interface ProfileVerificationExport { directory: Uint8Array; directoryCrcValid: boolean; profiles: ProfileVerificationProfile[]; + dpiCapabilities?: ProfileVerificationCapability | null; + reportRateCapabilities?: ProfileVerificationCapability | null; +} + +export interface ProfileVerificationCapability { + featureId: number; + featureIndex: number; + featureVersion: number; + kind: "legacy" | "extended"; + replies: Array<{ name: string; bytes: Uint8Array }>; + decodedValues: number[]; + error: string | null; } function hexBlock(bytes: Uint8Array): string { @@ -129,9 +146,82 @@ const hexByte = (value: number): string => `0x${value.toString(16).padStart(2, " const hexWord = (value: number): string => `0x${value.toString(16).padStart(4, "0")}`; +function capabilityLines( + label: string, + capability: ProfileVerificationCapability | null | undefined, + unit: string, +): string[] { + if (capability === undefined) return []; + if (capability === null) return [`- ${label}: not exposed`]; + const lines = [ + `- ${label}: feature ${hexWord(capability.featureId)}, index ${hexByte(capability.featureIndex)}, version ${capability.featureVersion}, ${capability.kind}`, + `- ${label} values: ${capability.decodedValues.length > 0 ? capability.decodedValues.map((value) => `${value} ${unit}`).join(", ") : "none decoded"}`, + ]; + if (capability.error) lines.push(`- ${label} read warning: ${capability.error}`); + for (const reply of capability.replies) { + lines.push(`- ${label} ${reply.name}: \`${[...reply.bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(" ")}\``); + } + return lines; +} + +/** Recovery bundle copied before the first destructive verification write. */ +export function formatProfileWriteProbeBackupMarkdown(backup: ProfileContentWriteProbeBackup): string { + return [ + "## OpenMouse profile-write probe recovery backup", + "", + `- Profile format: ${backup.formatId}`, + `- Sector: ${hexWord(backup.sector)}`, + `- Sector size: ${backup.sectorSize} bytes`, + `- Original mode: ${backup.originalMode}`, + `- Original current sector: ${hexWord(backup.originalCurrentSector)}`, + `- DPI options: ${backup.dpiOptions.join(", ")}`, + `- Report rates: ${backup.reportRates.join(", ")} Hz`, + "", + "### Original directory sector", + "", + "```", + hexBlock(backup.directory), + "```", + "", + `### Original profile sector ${hexWord(backup.sector)}`, + "", + "```", + hexBlock(backup.profile), + "```", + "", + "Keep this text until OpenMouse reports that the original profile and mode were restored.", + ].join("\n"); +} + +export function formatProfileWriteProbeReportMarkdown(report: ProfileContentWriteProbeReport): string { + const verdict = report.ok ? "PASSED" : "FAILED"; + return [ + `## OpenMouse profile-write verification — ${verdict}`, + "", + ...report.steps.flatMap((step) => [ + `### ${step.setting}`, + "", + `- Intended value: ${step.intended}`, + `- Stored exactly: ${step.storedExactly}`, + `- Live value confirmed: ${step.liveConfirmed === null ? "not applicable" : step.liveConfirmed}`, + `- Original restored: ${step.restored}`, + ...(step.error ? [`- Error: ${step.error}`] : []), + "", + ]), + `- Final profile restored: ${report.restored}`, + `- Original mode restored: ${report.modeRestored}`, + "", + formatProfileWriteProbeBackupMarkdown(report.backup), + ].join("\n"); +} + /** Markdown verification bundle suitable for an issue or a test fixture. */ export function formatProfileVerificationMarkdown(capture: ProfileVerificationExport): string { const { info } = capture; + const capabilitySection = [ + ...capabilityLines("DPI", capture.dpiCapabilities, "DPI"), + ...capabilityLines("Report rate", capture.reportRateCapabilities, "Hz"), + ]; const sections = [ "## OpenMouse profile-format verification", "", @@ -150,6 +240,7 @@ export function formatProfileVerificationMarkdown(capture: ProfileVerificationEx `- getInfo: \`${[...capture.infoReply].map((byte) => byte.toString(16).padStart(2, "0")).join(" ")}\``, `- getMode: \`${[...capture.modeReply].map((byte) => byte.toString(16).padStart(2, "0")).join(" ")}\``, `- getCurrentProfile: \`${[...capture.currentProfileReply].map((byte) => byte.toString(16).padStart(2, "0")).join(" ")}\``, + ...(capabilitySection.length > 0 ? ["", "### Device capability replies", "", ...capabilitySection] : []), "", "### Directory sector 0", "", diff --git a/src/capture-panel.ts b/src/capture-panel.ts index a32aeea1..39eb07a3 100644 --- a/src/capture-panel.ts +++ b/src/capture-panel.ts @@ -3,10 +3,16 @@ import { diffSectors, formatCaptureMarkdown, formatProfileVerificationMarkdown, + formatProfileWriteProbeBackupMarkdown, + formatProfileWriteProbeReportMarkdown, type ProfileVerificationExport, type SectorBytes, type SectorDiff, } from "./capture-format"; +import type { + ProfileContentWriteProbeBackup, + ProfileContentWriteProbeReport, +} from "./devices/logitech/hidpp"; import { escapeHtml } from "./ui/dom"; /** @@ -28,13 +34,22 @@ export interface CaptureProfileSource { reproduce(before: Uint8Array, after: Uint8Array): Uint8Array; } +interface CaptureWriteProbe { + supported: boolean; + reason: string; + prepare?(): Promise; + run?(backup: ProfileContentWriteProbeBackup): Promise; +} + interface CaptureContext { device: string | null; profileFormat: string | null; profiles: CaptureProfileSource | null; + /** Null outside Logitech; otherwise the button remains visible. */ + writeProbe: CaptureWriteProbe | null; } -let context: CaptureContext = { device: null, profileFormat: null, profiles: null }; +let context: CaptureContext = { device: null, profileFormat: null, profiles: null, writeProbe: null }; /** Sector -> bytes, taken before the vendor-app change. */ let snapshot: Map | null = null; let diffs: SectorDiff[] = []; @@ -97,6 +112,12 @@ function setCaptureMessage(message: string): void { export function refreshCapturePanel(): void { renderActions(); renderDiffs(); + const probe = document.querySelector("#capture-write-probe"); + if (probe) { + probe.hidden = context.writeProbe === null; + probe.disabled = context.writeProbe?.supported !== true; + probe.title = context.writeProbe?.reason ?? ""; + } } async function takeSnapshot(): Promise { @@ -211,6 +232,34 @@ export function bindCapturePanel(): void { }); }); + document.querySelector("#capture-write-probe")?.addEventListener("click", (event) => { + const button = event.currentTarget as HTMLButtonElement; + const source = context.writeProbe; + if (!source?.supported || !source.prepare || !source.run) return; + button.disabled = true; + setCaptureMessage("Reading and copying the recovery backup…"); + void source.prepare().then(async (backup) => { + await navigator.clipboard.writeText(formatProfileWriteProbeBackupMarkdown(backup)); + const approved = window.confirm( + "Recovery backup copied. This test performs six profile-sector erase/write cycles, temporarily changes the profile name, DPI, and polling rate, then restores the exact original after every step. Do not disconnect or power off the mouse. Run the probe now?", + ); + if (!approved) { + setCaptureMessage("Recovery backup copied. Write probe cancelled before any flash write."); + return; + } + setCaptureMessage("Running write probe. Do not disconnect or power off the mouse…"); + const report = await source.run!(backup); + await navigator.clipboard.writeText(formatProfileWriteProbeReportMarkdown(report)); + setCaptureMessage(report.ok + ? "Write probe passed and the original profile was restored. Report copied." + : `Write probe failed. Recovery report copied; profile restored: ${report.restored}, mode restored: ${report.modeRestored}.`); + }).catch((error) => { + setCaptureMessage(error instanceof Error ? error.message : "Could not run the profile write probe."); + }).finally(() => { + button.disabled = false; + }); + }); + document.querySelector("#capture-reset")?.addEventListener("click", () => { snapshot = null; diffs = []; diff --git a/src/control-template.ts b/src/control-template.ts index 417bfb8b..82fa17ca 100644 --- a/src/control-template.ts +++ b/src/control-template.ts @@ -154,6 +154,7 @@ export function controlTemplate(buildLabel: string): string { Verify a format: copy a read-only bundle containing the memory geometry, full directory, every profile and all CRC results. To map an individual setting, snapshot the profiles, change only that setting in G HUB or Onboard Memory Manager, compare, mark the change and copy the comparison.
+ diff --git a/src/control.ts b/src/control.ts index a3053887..93416bd8 100644 --- a/src/control.ts +++ b/src/control.ts @@ -69,10 +69,13 @@ import { capabilitiesForFormat, clampDpi, describeOffset, + dpiStageCapabilitiesForOptions, reportRatesFor, + reportRatesForDevice, validateProfileName, - validateReportRate, reproduceProfile, + supportsFactoryReset, + supportsProfileWriteProbe, stageLodLevel, validateBunnyHoppingMs, type DpiStageCapabilities, @@ -493,7 +496,7 @@ function showSlotsPreview(): void { supportedPollingRates: [125, 250, 500, 1000, 2000, 4000, 8000], liftOffDistance: "Low", supportedLiftOffDistances: ["Low", "Medium", "High"], - onboardProfileFormat: { id: 7, name: "unnamed (v6 + bunny hopping)", base: "v6", supported: true, verified: true }, + onboardProfileFormat: { id: 7, name: "unnamed (v6 + bunny hopping)", base: "v6", supported: true, verified: true, writable: true }, gamingSurfaceMode: "Auto", lightforceSwitchMode: "Hybrid", activeProfile: 1, @@ -766,15 +769,15 @@ function configureProfileCapture(status: MouseStatus | null): void { const logitechClient = status?.brand === "Logitech" ? activeClient as LogitechHidppClient | null : null; const formatId = status?.onboardProfileFormat?.id ?? null; const captureOpen = document.querySelector("#capture-open"); - if (captureOpen) captureOpen.hidden = logitechClient === null || formatId === null; + if (captureOpen) captureOpen.hidden = logitechClient === null; const resetButton = document.querySelector("#reset-logitech-profiles"); if (resetButton) { - resetButton.hidden = logitechClient === null || formatId === null; - const supported = formatId === 7; - resetButton.disabled = settingInProgress || !supported; + const supported = logitechClient !== null && supportsFactoryReset(formatId); + resetButton.hidden = !supported; + resetButton.disabled = settingInProgress; resetButton.title = supported ? "Permanently restore every onboard profile to Logitech defaults" - : `Factory defaults have not been captured for profile format ${formatId ?? "unknown"}`; + : ""; } setCaptureContext({ device: activeDevice ? describeHidDevice(activeDevice) : status?.name ?? null, @@ -811,6 +814,22 @@ function configureProfileCapture(status: MouseStatus | null): void { reproduce: (before, after) => reproduceProfile(before, after, formatId), } : null, + writeProbe: logitechClient === null + ? null + : supportsProfileWriteProbe(formatId) + ? { + supported: true, + reason: `Run the guarded write probe for profile format ${formatId}`, + prepare: () => logitechClient.prepareProfileContentWriteProbe(), + run: (backup: Parameters[0]) => + logitechClient.runProfileContentWriteProbe(backup), + } + : { + supported: false, + reason: formatId === null + ? "This Logitech mouse does not report an onboard-profile format" + : `The guarded write probe does not support profile format ${formatId}`, + }, }); } @@ -2135,7 +2154,7 @@ function renderBunnyHop(): void { row.hidden = !supported; if (!supported || !active) return; - const locked = lastProfileFormat?.verified !== true; + const locked = lastProfileFormat?.writable !== true; // Show the staged value, so a background refresh cannot snap the control back // to what is still on the device. // A never-written byte counts as off, so the toggle starts in the off state @@ -2180,7 +2199,9 @@ function dpiAxisLockedAt(index: number): boolean { function dpiSlotLimits(): DpiStageCapabilities | null { const format = lastProfileFormat; - return format ? capabilitiesForFormat(format.id).dpiStages : null; + return format + ? dpiStageCapabilitiesForOptions(capabilitiesForFormat(format.id).dpiStages, dpiOptions) + : null; } /** @@ -2195,7 +2216,7 @@ function dpiSlotsAvailable(): boolean { /** True while the flash write sequence for stage tables is still unproven. */ function dpiSlotsLocked(): boolean { - return !PROFILE_DPI_WRITES_ENABLED || lastProfileFormat?.verified !== true; + return !PROFILE_DPI_WRITES_ENABLED || lastProfileFormat?.writable !== true; } /** @@ -2521,16 +2542,31 @@ function renameOnboardProfile(sector: number): void { let stagedProfileRates: { wireless: number | null; wired: number | null } = { wireless: null, wired: null }; const PROFILE_RATE_KEY = "logitech-profile-rate"; +function profileReportRateOptions(link: "wireless" | "wired"): number[] { + const format = lastProfileFormat; + const rates = format ? capabilitiesForFormat(format.id).reportRates : null; + const activeLink = latestDeviceStatus?.connectionType === "Wireless" ? "wireless" : "wired"; + return reportRatesForDevice( + rates, + link, + latestDeviceStatus?.supportedPollingRates ?? [], + activeLink, + (format?.id ?? 6) < 6, + ); +} + function setProfileReportRate(link: "wireless" | "wired", hz: number): void { const entry = editedProfileEntry(); if (!entry || !activeClient) return; - const rates = lastProfileFormat ? capabilitiesForFormat(lastProfileFormat.id).reportRates : null; - const invalid = validateReportRate(hz, rates, link); - if (invalid) { - setText("#polling-note", invalid); + // Formats 1-5 have one shared interval byte. Use the wired slot as the + // canonical staged value rather than pretending they store two rates. + const selectedLink = (lastProfileFormat?.id ?? 6) < 6 ? "wired" : link; + const allowed = profileReportRateOptions(selectedLink); + if (!allowed.includes(hz)) { + setText("#polling-note", `This mouse supports ${allowed.join(", ")} Hz for that profile link.`); return; } - stagedProfileRates = { ...stagedProfileRates, [link]: hz }; + stagedProfileRates = { ...stagedProfileRates, [selectedLink]: hz }; const stored = { wireless: entry.reportRateWireless, wired: entry.reportRateWired }; const wanted = { @@ -2547,8 +2583,8 @@ function setProfileReportRate(link: "wireless" | "wired", hz: number): void { stageChange({ key: PROFILE_RATE_KEY, group: PROFILE_SECTOR_GROUP, - label: `${link === "wired" ? "Wired" : "Wireless"} ${hz.toLocaleString()} Hz`, - command: `Set profile ${link} report rate to ${hz} Hz`, + label: `${selectedLink === "wired" && (lastProfileFormat?.id ?? 6) >= 6 ? "Wired " : selectedLink === "wireless" ? "Wireless " : ""}${hz.toLocaleString()} Hz`, + command: `Set profile ${selectedLink} report rate to ${hz} Hz`, progress: "Writing the report rate to the profile…", // No preview: profile rates are not part of MouseStatus. apply: writeStagedProfileSector, @@ -2574,21 +2610,29 @@ function renderProfileRates(): void { } if (!available || !entry || !rates) return; - const locked = lastProfileFormat?.verified !== true; - for (const link of ["wireless", "wired"] as const) { + const locked = lastProfileFormat?.writable !== true; + const shared = (lastProfileFormat?.id ?? 6) < 6; + const wirelessSlider = document.querySelector("#profile-rate-wireless"); + const wiredSlider = document.querySelector("#profile-rate-wired"); + if (wirelessSlider) wirelessSlider.hidden = shared; + if (wiredSlider) wiredSlider.hidden = false; + const links: Array<"wireless" | "wired"> = shared ? ["wired"] : ["wireless", "wired"]; + for (const link of links) { const value = stagedProfileRates[link] ?? (link === "wired" ? entry.reportRateWired : entry.reportRateWireless); renderRateSlider( document.querySelector(`#profile-rate-${link}`), - reportRatesFor(rates, link), + profileReportRateOptions(link), value, - { label: link === "wired" ? "Wired" : "Wireless", disabled: locked || settingInProgress }, + { label: shared ? "All connections" : link === "wired" ? "Wired" : "Wireless", disabled: locked || settingInProgress }, ); } - setText("#polling-note", `Stored in this profile, one rate per link. Up to ${ - reportRatesFor(rates, "wireless").at(-1)?.toLocaleString()} Hz wireless, ${ - reportRatesFor(rates, "wired").at(-1)?.toLocaleString()} Hz over the cable.`); + setText("#polling-note", shared + ? `Stored in this profile as one shared interval, up to ${profileReportRateOptions("wired").at(-1)?.toLocaleString()} Hz.` + : `Stored in this profile, one rate per link. Up to ${ + reportRatesFor(rates, "wireless").at(-1)?.toLocaleString()} Hz wireless, ${ + reportRatesFor(rates, "wired").at(-1)?.toLocaleString()} Hz over the cable.`); } function renderDpiSlots(): void { @@ -2629,6 +2673,7 @@ function renderDpiSlots(): void { const locked = dpiSlotsLocked(); const levels = lastProfileFormat ? capabilitiesForFormat(lastProfileFormat.id).supportedLods : []; + const profileHasLod = levels.length > 0; // The preset row writes host DPI, so it stands down while slots are shown. const presets = document.querySelector("#dpi-presets"); @@ -2666,7 +2711,7 @@ function renderDpiSlots(): void {
- +
    ${lodOptions}
`; @@ -2700,7 +2745,7 @@ async function reloadOnboardProfiles(): Promise { async function resetLogitechProfiles(): Promise { const client = activeClient; - if (!client || settingInProgress || lastProfileFormat?.id !== 7) return; + if (!client || settingInProgress || !supportsFactoryReset(lastProfileFormat?.id)) return; const stagedWarning = hasPendingChanges() ? "\n\nYour staged, unflashed changes will also be discarded." @@ -2825,6 +2870,7 @@ function renderOnboardProfiles(): void { const hostOpened = editedProfile === "host"; const hostRunning = lastDeviceMode === "Host"; const profileLayoutVerified = lastProfileFormat?.verified === true; + const profileContentsWritable = lastProfileFormat?.writable === true; const profileNameLimit = lastProfileFormat ? capabilitiesForFormat(lastProfileFormat.id).maxNameLength : null; // Host is a live, volatile source rather than a stored profile, so it is // listed apart from them rather than mixed in. @@ -2875,7 +2921,7 @@ function renderOnboardProfiles(): void { ${escapeHtml(detail)} -