Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build/check-bundle-size.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,7 @@ const BUDGET_BYTES: Record<string, number> = {
// 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");
Expand Down
65 changes: 65 additions & 0 deletions src/capture-format.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,8 @@ import {
diffSectors,
formatCaptureMarkdown,
formatProfileVerificationMarkdown,
formatProfileWriteProbeBackupMarkdown,
formatProfileWriteProbeReportMarkdown,
type CaptureExport,
} from "./capture-format.ts";

Expand DownExpand Up@@ -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,
Expand All@@ -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/);
Expand All@@ -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/);
});
91 changes: 91 additions & 0 deletions src/capture-format.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
import type {
ProfileContentWriteProbeBackup,
ProfileContentWriteProbeReport,
} from "./devices/logitech/hidpp";

/**
* Export format for profile-layout confirmations.
*
Expand DownExpand Up@@ -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 {
Expand All@@ -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",
"",
Expand All@@ -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",
"",
Expand Down
51 changes: 50 additions & 1 deletion src/capture-panel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

/**
Expand All@@ -28,13 +34,22 @@ export interface CaptureProfileSource {
reproduce(before: Uint8Array, after: Uint8Array): Uint8Array;
}

interface CaptureWriteProbe {
supported: boolean;
reason: string;
prepare?(): Promise<ProfileContentWriteProbeBackup>;
run?(backup: ProfileContentWriteProbeBackup): Promise<ProfileContentWriteProbeReport>;
}

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<number, Uint8Array> | null = null;
let diffs: SectorDiff[] = [];
Expand DownExpand Up@@ -97,6 +112,12 @@ function setCaptureMessage(message: string): void {
export function refreshCapturePanel(): void {
renderActions();
renderDiffs();
const probe = document.querySelector<HTMLButtonElement>("#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<void> {
Expand DownExpand Up@@ -211,6 +232,34 @@ export function bindCapturePanel(): void {
});
});

document.querySelector<HTMLButtonElement>("#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<HTMLButtonElement>("#capture-reset")?.addEventListener("click", () => {
snapshot = null;
diffs = [];
Expand Down
1 change: 1 addition & 0 deletions src/control-template.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,6 +154,7 @@ export function controlTemplate(buildLabel: string): string {
<small style="color:#77777c;font-size:.64rem"><strong style="color:#a8a8ae">Verify a format:</strong> 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.</small>
<div style="display:flex;flex-wrap:wrap;gap:.4rem;align-items:center">
<button id="capture-verification" type="button" class="is-primary">Copy verification data</button>
<button id="capture-write-probe" type="button" hidden style="border-color:#7d3038;background:#32181c;color:#ff9ca5">Verify profile writes</button>
<button id="capture-snapshot" type="button">Snapshot profiles</button>
<button id="capture-compare" type="button">Compare</button>
<button id="capture-reset" type="button">Clear</button>
Expand Down
Loading