From e191af245d7b8cd89ce19cb6e95fc144737c8d92 Mon Sep 17 00:00:00 2001 From: angelocore Date: Thu, 6 Aug 2026 19:16:22 +0300 Subject: [PATCH 01/10] Legacy --- src/devices/logitech/onboard-profiles.test.ts | 217 +++++++++++++++++- src/devices/logitech/onboard-profiles.ts | 21 +- 2 files changed, 227 insertions(+), 11 deletions(-) diff --git a/src/devices/logitech/onboard-profiles.test.ts b/src/devices/logitech/onboard-profiles.test.ts index c28aff11..88c4e1f1 100644 --- a/src/devices/logitech/onboard-profiles.test.ts +++ b/src/devices/logitech/onboard-profiles.test.ts @@ -107,6 +107,122 @@ const G402_SECTOR = (() => { return sector; })(); +/** Three format-2 profiles captured read-only from a G502 (PID 0xc332). */ +const G502_SECTORS = [ + bytes(` + 01 00 00 a4 06 00 00 00 00 00 00 00 00 ff ff ff + ff 00 08 00 ff ff ff ff ff ff ff ff ff ff ff ff + 80 01 00 01 80 01 00 02 80 01 00 04 90 00 00 00 + 90 00 00 00 90 00 00 00 80 02 00 09 80 02 00 0b + 90 00 00 00 90 02 00 00 80 02 00 20 ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + 80 01 00 01 80 01 00 02 80 01 00 04 90 00 00 00 + 90 00 00 00 90 00 00 00 80 02 00 09 80 02 00 0b + 90 00 00 00 90 02 00 00 80 02 00 20 ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + 50 00 52 00 4f 00 46 00 49 00 4c 00 45 00 5f 00 + 4e 00 41 00 4d 00 45 00 5f 00 44 00 45 00 46 00 + 41 00 55 00 4c 00 54 00 00 00 00 00 00 00 00 00 + 03 00 00 00 00 00 1f 40 64 00 00 03 00 00 00 00 + 00 1f 40 64 00 00 ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff 40 f7 + `), + bytes(` + 01 00 00 46 05 00 00 00 00 00 00 00 00 ff ff ff + ff 00 22 00 ff ff ff ff ff ff ff ff ff ff ff ff + 80 01 00 01 80 01 00 02 80 01 00 04 90 00 00 00 + 90 00 00 00 90 00 00 00 80 02 00 09 80 02 00 0b + 90 00 00 00 90 02 00 00 80 02 00 20 ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + 80 01 00 01 80 01 00 02 80 01 00 04 90 00 00 00 + 90 00 00 00 90 00 00 00 80 02 00 09 80 02 00 0b + 90 00 00 00 90 02 00 00 80 02 00 20 ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + 50 00 52 00 4f 00 46 00 49 00 4c 00 45 00 5f 00 + 4e 00 41 00 4d 00 45 00 5f 00 44 00 45 00 46 00 + 41 00 55 00 4c 00 54 00 00 00 00 00 00 00 00 00 + 03 00 00 00 00 00 1f 40 64 00 00 03 00 00 00 00 + 00 1f 40 64 00 00 ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff 00 ff 11 38 + `), + bytes(` + 02 00 00 60 09 00 00 00 00 00 00 00 00 ff ff ff + ff 00 03 00 ff ff ff ff ff ff ff ff ff ff ff ff + 80 01 00 01 80 01 00 02 80 01 00 04 80 01 00 08 + 80 01 00 10 90 0b ff ff 80 02 01 06 80 02 01 19 + 90 00 00 00 90 02 ff ff 90 01 ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + 80 01 00 01 80 01 00 02 80 02 01 17 80 02 01 3d + 80 02 03 17 ff ff ff ff 80 02 01 1b 80 02 01 1d + 80 02 01 27 80 02 01 2b 80 02 03 2b ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + 50 00 72 00 6f 00 66 00 69 00 6c 00 65 00 20 00 + 33 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + 01 ff ff ff 00 00 00 00 00 00 00 01 ff ff ff 00 + 00 00 00 00 00 00 ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff 3f c6 + `), +]; + +const G502_INFO_REPLY = bytes("ff 0e 05 01 02 01 03 03 0b 10 01 00 0a 01 00 00 00 00 00"); +const G502_DIRECTORY = (() => { + const sector = new Uint8Array(256).fill(0xff); + sector.set(bytes("00 01 01 00 00 02 00 00 00 03 00 00 ff ff 00 00")); + sector.set([0x03, 0xbc], sector.length - 2); + return sector; +})(); + +/** G502 LIGHTSPEED format-3 profile text exported read-only (PID 0xc08d). */ +const G502_LIGHTSPEED_PROFILE_1 = bytes(` + 01 00 00 58 02 e8 03 dc 05 00 00 00 00 ff ff ff + ff 00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff + 80 01 00 01 80 01 00 02 80 01 00 04 80 01 00 08 + 80 01 00 10 80 02 00 29 90 04 00 00 90 03 00 00 + 90 0c 00 00 90 02 00 00 90 01 00 00 ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + 80 01 00 01 80 01 00 02 80 01 00 04 80 01 00 08 + 80 01 00 10 80 02 00 29 90 04 00 00 90 03 00 00 + 90 0c 00 00 90 02 00 00 90 01 00 00 ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + 50 00 52 00 4f 00 46 00 49 00 4c 00 45 00 5f 00 + 4e 00 41 00 4d 00 45 00 5f 00 44 00 45 00 46 00 + 41 00 55 00 4c 00 54 00 00 00 00 00 00 00 00 00 + 01 fd 76 00 00 00 00 00 00 00 00 01 fd 76 00 00 + 00 00 00 00 00 00 ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff 00 42 3b +`); + +/** Profiles 2-5 were byte-identical in the same format-3 capture. */ +const G502_LIGHTSPEED_FACTORY_PROFILE = bytes(` + 01 01 00 90 01 20 03 40 06 80 0c 00 19 ff ff ff + ff 00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff + 80 01 00 01 80 01 00 02 80 01 00 04 80 01 00 08 + 80 01 00 10 90 07 00 00 90 04 00 00 90 03 00 00 + 90 0c 00 00 90 02 00 00 90 01 00 00 ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + 03 00 00 00 00 00 1f 40 00 00 00 03 00 00 00 00 + 00 1f 40 00 00 00 ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff 3e 23 +`); + +const G502_LIGHTSPEED_INFO_REPLY = bytes( + "ff 09 05 01 03 01 05 01 0b 10 00 ff 0a 04 00 00 00 00 00", +); +const G502_LIGHTSPEED_DIRECTORY = (() => { + const sector = new Uint8Array(255).fill(0xff); + sector.set(bytes("00 01 01 ff 00 02 00 ff 00 03 00 ff 00 04 00 ff 00 05 00 ff")); + sector.set([0x40, 0x37], sector.length - 2); + return sector; +})(); + test("parses getOnboardProfilesInfo", () => { assert.deepEqual(parseProfilesInfo(INFO_REPLY), { memoryModelId: 1, @@ -254,13 +370,112 @@ test("decodes the captured G402 format-1 profile without v6 mojibake", () => { assert.equal(profile.name, null); assert.equal(profile.defaultDpiIndex, 2); assert.deepEqual(profile.dpiStages, [420, 840, 1596, 3192].map((dpi) => ({ x: dpi, y: dpi, lod: 0 }))); - assert.equal(profile.reportRateWireless, null); + assert.equal(profile.reportRateWireless, 1000); assert.equal(profile.reportRateWired, 1000); assert.equal(profile.angleSnapping, false); assert.equal(profile.powerSaveTimeoutSeconds, null); assert.equal(profile.powerOffTimeoutSeconds, null); }); +test("decodes all captured G502 format-2 profiles", () => { + const expected = [ + { name: "PROFILE_NAME_DEFAULT", dpi: 1700, rate: 1000 }, + { name: "PROFILE_NAME_DEFAULT", dpi: 1350, rate: 1000 }, + { name: "Profile 3", dpi: 2400, rate: 500 }, + ]; + + G502_SECTORS.forEach((sector, index) => { + const profile = decodeOnboardProfile( + sector, + 2, + { sector: index + 1, enabled: index === 0 }, + false, + ); + assert.equal(sector.length, 256); + assert.equal(profile.crcValid, true); + assert.equal(profile.name, expected[index].name); + assert.equal(profile.defaultDpiIndex, 0); + assert.deepEqual(profile.dpiStages, [{ x: expected[index].dpi, y: expected[index].dpi, lod: 0 }]); + assert.equal(profile.reportRateWireless, expected[index].rate); + assert.equal(profile.reportRateWired, expected[index].rate); + assert.equal(profile.angleSnapping, false); + }); +}); + +test("parses the captured G502 format-2 geometry and directory", () => { + assert.deepEqual(parseProfilesInfo(G502_INFO_REPLY), { + memoryModelId: 1, + profileFormatId: 2, + profileCount: 3, + sectorCount: 16, + sectorSize: 256, + }); + assert.equal(profileCrc(G502_DIRECTORY), storedCrc(G502_DIRECTORY)); + assert.deepEqual(parseDirectory(G502_DIRECTORY), [ + { sector: 1, enabled: true }, + { sector: 2, enabled: false }, + { sector: 3, enabled: false }, + ]); +}); + +test("decodes all captured G502 LIGHTSPEED format-3 profiles", () => { + const configured = decodeOnboardProfile( + G502_LIGHTSPEED_PROFILE_1, + 3, + { sector: 1, enabled: true }, + true, + ); + // The supplied export prints 256 bytes for this profile despite reporting + // 255-byte sectors, so it is useful for layout decoding but not CRC proof. + assert.equal(G502_LIGHTSPEED_PROFILE_1.length, 256); + assert.equal(configured.name, "PROFILE_NAME_DEFAULT"); + assert.equal(configured.defaultDpiIndex, 0); + assert.deepEqual( + configured.dpiStages, + [600, 1000, 1500].map((dpi) => ({ x: dpi, y: dpi, lod: 0 })), + ); + assert.equal(configured.reportRateWireless, 1000); + assert.equal(configured.reportRateWired, 1000); + assert.equal(configured.isCurrent, true); + + for (let sector = 2; sector <= 5; sector += 1) { + const profile = decodeOnboardProfile( + G502_LIGHTSPEED_FACTORY_PROFILE, + 3, + { sector, enabled: false }, + false, + ); + assert.equal(G502_LIGHTSPEED_FACTORY_PROFILE.length, 255); + assert.equal(profile.name, null); + assert.equal(profile.defaultDpiIndex, 1); + assert.deepEqual( + profile.dpiStages, + [400, 800, 1600, 3200, 6400].map((dpi) => ({ x: dpi, y: dpi, lod: 0 })), + ); + assert.equal(profile.reportRateWireless, 1000); + assert.equal(profile.reportRateWired, 1000); + assert.equal(profile.isCurrent, false); + } +}); + +test("parses the captured G502 LIGHTSPEED format-3 geometry and directory", () => { + assert.deepEqual(parseProfilesInfo(G502_LIGHTSPEED_INFO_REPLY), { + memoryModelId: 1, + profileFormatId: 3, + profileCount: 5, + sectorCount: 16, + sectorSize: 255, + }); + assert.equal(profileCrc(G502_LIGHTSPEED_DIRECTORY), storedCrc(G502_LIGHTSPEED_DIRECTORY)); + assert.deepEqual(parseDirectory(G502_LIGHTSPEED_DIRECTORY), [ + { sector: 1, enabled: true }, + { sector: 2, enabled: false }, + { sector: 3, enabled: false }, + { sector: 4, enabled: false }, + { sector: 5, enabled: false }, + ]); +}); + test("factory reset image is exact, CRC-valid and limited to captured geometry", () => { const factory = factoryProfileForFormat(7, 255); assert.ok(factory); diff --git a/src/devices/logitech/onboard-profiles.ts b/src/devices/logitech/onboard-profiles.ts index c59ea79c..7e0cbf36 100644 --- a/src/devices/logitech/onboard-profiles.ts +++ b/src/devices/logitech/onboard-profiles.ts @@ -272,7 +272,8 @@ interface ProfileLayout { } const LAYOUT_V1: ProfileLayout = { - reportRateWireless: null, + // Formats 1-5 store one shared report interval, regardless of transport. + reportRateWireless: 0x00, reportRateWired: 0x00, dpi: 0x01, angleSnapping: 0x11, @@ -488,7 +489,7 @@ function decodeDpi(bytes: Uint8Array, offset: number): { stages: DpiStage[]; def } /** Formats 1-5 store one little-endian DPI value per slot, without X/Y or LOD. */ -function decodeDpiV1(bytes: Uint8Array, offset: number): { stages: DpiStage[]; defaultIndex: number | null } { +function decodeLegacyDpi(bytes: Uint8Array, offset: number): { stages: DpiStage[]; defaultIndex: number | null } { const rawDefaultIndex = bytes[offset]; const stages: DpiStage[] = []; for (let stage = 0; stage < DPI_STAGE_SLOTS; stage += 1) { @@ -509,7 +510,7 @@ function decodeReportRate(bytes: Uint8Array, offset: number | null): number | nu } /** Formats 1-5 store the USB polling interval in milliseconds. */ -function decodeReportRateV1(bytes: Uint8Array, offset: number | null): number | null { +function decodeLegacyReportRate(bytes: Uint8Array, offset: number | null): number | null { if (offset === null) return null; const intervalMs = bytes[offset]; if (intervalMs === undefined || intervalMs === 0xff || intervalMs === 0) return null; @@ -769,7 +770,7 @@ export function reproduceProfile(before: Uint8Array, after: Uint8Array, profileF const copyRate = (offset: number | null): void => { if (offset === null) return; - const hz = legacyLayout ? decodeReportRateV1(after, offset) : decodeReportRate(after, offset); + const hz = legacyLayout ? decodeLegacyReportRate(after, offset) : decodeReportRate(after, offset); if (hz === null) return; if (legacyLayout) result[offset] = 1000 / hz; else { @@ -781,7 +782,7 @@ export function reproduceProfile(before: Uint8Array, after: Uint8Array, profileF copyRate(layout.reportRateWired); if (layout.dpi !== null) { - const dpi = legacyLayout ? decodeDpiV1(after, layout.dpi) : decodeDpi(after, layout.dpi); + const dpi = legacyLayout ? decodeLegacyDpi(after, layout.dpi) : decodeDpi(after, layout.dpi); if (dpi.defaultIndex !== null) result[layout.dpi] = dpi.defaultIndex; result[layout.dpi + 1] = after[layout.dpi + 1]; // Stages are re-encoded from decoded values, not copied, so their @@ -815,7 +816,7 @@ export function reproduceProfile(before: Uint8Array, after: Uint8Array, profileF if (seconds !== null) writeUint16LE(result, offset, seconds); } - const name = legacyLayout ? null : decodeName(after, layout.profileName); + const name = profileFormatId === 1 ? null : decodeName(after, layout.profileName); if (name !== null) { const encoded = new Uint8Array(0x30); encoded.set(after.slice(layout.profileName, layout.profileName + 0x30).map(() => 0)); @@ -852,7 +853,7 @@ export function decodeOnboardProfile( const legacyLayout = profileFormatId < 6; const dpi = layout.dpi === null ? { stages: [], defaultIndex: null } - : legacyLayout ? decodeDpiV1(bytes, layout.dpi) : decodeDpi(bytes, layout.dpi); + : legacyLayout ? decodeLegacyDpi(bytes, layout.dpi) : decodeDpi(bytes, layout.dpi); const angleSnappingByte = bytes[layout.angleSnapping]; return { @@ -861,14 +862,14 @@ export function decodeOnboardProfile( isCurrent, // The v1 region is byte text, not UTF-16. This G402 capture contains // non-text device data there, so do not manufacture a mojibake name. - name: legacyLayout ? null : decodeName(bytes, layout.profileName), + name: profileFormatId === 1 ? null : decodeName(bytes, layout.profileName), dpiStages: dpi.stages, defaultDpiIndex: dpi.defaultIndex, reportRateWireless: legacyLayout - ? decodeReportRateV1(bytes, layout.reportRateWireless) + ? decodeLegacyReportRate(bytes, layout.reportRateWireless) : decodeReportRate(bytes, layout.reportRateWireless), reportRateWired: legacyLayout - ? decodeReportRateV1(bytes, layout.reportRateWired) + ? decodeLegacyReportRate(bytes, layout.reportRateWired) : decodeReportRate(bytes, layout.reportRateWired), angleSnapping: angleSnappingByte === undefined || angleSnappingByte === 0xff ? null From 0546110e8389857322505fc4bd189e19301f297c Mon Sep 17 00:00:00 2001 From: angelocore Date: Thu, 6 Aug 2026 19:34:42 +0300 Subject: [PATCH 02/10] Verify profile 3 and extended verification data --- src/capture-format.test.ts | 26 +++++ src/capture-format.ts | 35 ++++++ src/devices/logitech/hidpp.ts | 109 ++++++++++++++++++ src/devices/logitech/onboard-profiles.test.ts | 12 +- src/devices/logitech/onboard-profiles.ts | 2 +- 5 files changed, 178 insertions(+), 6 deletions(-) diff --git a/src/capture-format.test.ts b/src/capture-format.test.ts index 576c306f..b56eddcf 100644 --- a/src/capture-format.test.ts +++ b/src/capture-format.test.ts @@ -115,6 +115,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 +149,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/); diff --git a/src/capture-format.ts b/src/capture-format.ts index 3f458238..f5138ea9 100644 --- a/src/capture-format.ts +++ b/src/capture-format.ts @@ -113,6 +113,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 +141,31 @@ 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; +} + /** 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 +184,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/devices/logitech/hidpp.ts b/src/devices/logitech/hidpp.ts index c68d9deb..572802e3 100644 --- a/src/devices/logitech/hidpp.ts +++ b/src/devices/logitech/hidpp.ts @@ -60,6 +60,18 @@ export interface OnboardProfileVerification { directory: Uint8Array; directoryCrcValid: boolean; profiles: OnboardProfile[]; + dpiCapabilities: VerificationCapability | null; + reportRateCapabilities: VerificationCapability | null; +} + +export interface VerificationCapability { + featureId: number; + featureIndex: number; + featureVersion: number; + kind: "legacy" | "extended"; + replies: Array<{ name: string; bytes: Uint8Array }>; + decodedValues: number[]; + error: string | null; } /** @@ -1128,6 +1140,9 @@ export class LogitechHidppClient { profiles.push(decodeOnboardProfile(bytes, info.profileFormatId, entry, entry.sector === currentSector)); } + const dpiCapabilities = await this.readDpiVerificationCapability(); + const reportRateCapabilities = await this.readReportRateVerificationCapability(); + return { info, infoReply, @@ -1139,9 +1154,103 @@ export class LogitechHidppClient { directory, directoryCrcValid: profileCrc(directory) === storedCrc(directory), profiles, + dpiCapabilities, + reportRateCapabilities, }; } + private async verificationFeature( + extendedId: number, + legacyId: number, + ): Promise { + for (const [featureId, kind] of [[extendedId, "extended"], [legacyId, "legacy"]] as const) { + try { + const featureReply = await this.request(0x00, 0x00, featureId >> 8, featureId & 0xff); + const featureIndex = featureReply[3] ?? 0; + if (!featureIndex) continue; + return { + featureId, + featureIndex, + featureVersion: featureReply[6] ?? 0, + kind, + replies: [{ name: "getFeature", bytes: featureReply }], + decodedValues: [], + error: null, + }; + } catch { + // Missing optional features must not prevent the profile dump. + } + } + return null; + } + + private async readDpiVerificationCapability(): Promise { + const resolved = await this.verificationFeature(FEATURE.extendedDpi, FEATURE.adjustableDpi); + if (!resolved) return null; + const result = resolved; + try { + if (result.kind === "legacy") { + result.replies.push({ + name: "getSensorDpiList", + bytes: await this.request(result.featureIndex, 0x10, 0x00), + }); + result.replies.push({ + name: "getSensorDpi", + bytes: await this.request(result.featureIndex, 0x20, 0x00), + }); + } else { + result.replies.push({ + name: "getSensorCapabilities", + bytes: await this.request(result.featureIndex, 0x10, 0x00), + }); + const listBytes: number[] = []; + for (let page = 0; page < 32; page += 1) { + const reply = await this.request(result.featureIndex, 0x20, 0x00, 0x00, page); + result.replies.push({ name: `getSensorDpiList page ${page}`, bytes: reply }); + listBytes.push(...reply.slice(6)); + if (listBytes.some((value, index) => index > 0 && listBytes[index - 1] === 0 && value === 0)) break; + } + result.replies.push({ + name: "getSensorDpi", + bytes: await this.request(result.featureIndex, 0x50), + }); + } + result.decodedValues = await this.getDpiOptions(); + } catch (error) { + result.error = error instanceof Error ? error.message : "Could not read DPI capabilities."; + } + return result; + } + + private async readReportRateVerificationCapability(): Promise { + const resolved = await this.verificationFeature(FEATURE.extendedReportRate, FEATURE.reportRate); + if (!resolved) return null; + const result = resolved; + try { + if (result.kind === "legacy") { + const listReply = await this.request(result.featureIndex, 0x00); + result.replies.push({ name: "getReportRateList", bytes: listReply }); + result.replies.push({ + name: "getReportRate", + bytes: await this.request(result.featureIndex, 0x10), + }); + result.decodedValues = decodeReportRateBitmap(listReply[3] ?? 0); + } else { + const listReply = await this.request(result.featureIndex, 0x10); + result.replies.push({ name: "getSupportedReportRates", bytes: listReply }); + result.replies.push({ + name: "getReportRate", + bytes: await this.request(result.featureIndex, 0x20), + }); + const flags = ((listReply[3] ?? 0) << 8) | (listReply[4] ?? 0); + result.decodedValues = REPORT_RATE_HZ.filter((_rate, index) => (flags & (1 << index)) !== 0); + } + } catch (error) { + result.error = error instanceof Error ? error.message : "Could not read report-rate capabilities."; + } + return result; + } + /** * memoryRead returns 16 bytes per call and rejects a read running past the * end of the sector, so a sector length that is not a multiple of 16 needs its diff --git a/src/devices/logitech/onboard-profiles.test.ts b/src/devices/logitech/onboard-profiles.test.ts index 88c4e1f1..ad27ad09 100644 --- a/src/devices/logitech/onboard-profiles.test.ts +++ b/src/devices/logitech/onboard-profiles.test.ts @@ -14,6 +14,7 @@ import { clampBunnyHopMs, clampDpi, decodeLiftOffLevel, + describeProfileFormat, encodeDpiStages, encodeProfileName, encodeReportRate, @@ -173,7 +174,7 @@ const G502_DIRECTORY = (() => { return sector; })(); -/** G502 LIGHTSPEED format-3 profile text exported read-only (PID 0xc08d). */ +/** G502 LIGHTSPEED format-3 sectors captured read-only (PID 0xc08d). */ const G502_LIGHTSPEED_PROFILE_1 = bytes(` 01 00 00 58 02 e8 03 dc 05 00 00 00 00 ff ff ff ff 00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff @@ -190,7 +191,7 @@ const G502_LIGHTSPEED_PROFILE_1 = bytes(` 41 00 55 00 4c 00 54 00 00 00 00 00 00 00 00 00 01 fd 76 00 00 00 00 00 00 00 00 01 fd 76 00 00 00 00 00 00 00 00 ff ff ff ff ff ff ff ff ff ff - ff ff ff ff ff ff ff ff ff ff ff ff ff 00 42 3b + ff ff ff ff ff ff ff ff ff ff ff ff 00 42 3b `); /** Profiles 2-5 were byte-identical in the same format-3 capture. */ @@ -425,9 +426,8 @@ test("decodes all captured G502 LIGHTSPEED format-3 profiles", () => { { sector: 1, enabled: true }, true, ); - // The supplied export prints 256 bytes for this profile despite reporting - // 255-byte sectors, so it is useful for layout decoding but not CRC proof. - assert.equal(G502_LIGHTSPEED_PROFILE_1.length, 256); + assert.equal(G502_LIGHTSPEED_PROFILE_1.length, 255); + assert.equal(configured.crcValid, true); assert.equal(configured.name, "PROFILE_NAME_DEFAULT"); assert.equal(configured.defaultDpiIndex, 0); assert.deepEqual( @@ -446,6 +446,7 @@ test("decodes all captured G502 LIGHTSPEED format-3 profiles", () => { false, ); assert.equal(G502_LIGHTSPEED_FACTORY_PROFILE.length, 255); + assert.equal(profile.crcValid, true); assert.equal(profile.name, null); assert.equal(profile.defaultDpiIndex, 1); assert.deepEqual( @@ -459,6 +460,7 @@ test("decodes all captured G502 LIGHTSPEED format-3 profiles", () => { }); test("parses the captured G502 LIGHTSPEED format-3 geometry and directory", () => { + assert.equal(describeProfileFormat(3).verified, true); assert.deepEqual(parseProfilesInfo(G502_LIGHTSPEED_INFO_REPLY), { memoryModelId: 1, profileFormatId: 3, diff --git a/src/devices/logitech/onboard-profiles.ts b/src/devices/logitech/onboard-profiles.ts index 7e0cbf36..082f2ee1 100644 --- a/src/devices/logitech/onboard-profiles.ts +++ b/src/devices/logitech/onboard-profiles.ts @@ -47,7 +47,7 @@ const PROFILE_FORMAT_NAMES: Record = { * Add a format here only after a dump from that device decodes sensibly with a * matching CRC. */ -const VERIFIED_FORMATS = new Set([7]); +const VERIFIED_FORMATS = new Set([3, 7]); export interface ProfileFormat { id: number; From 57c65683eaf22330c795e76b3fa29b95ff56d574 Mon Sep 17 00:00:00 2001 From: angelocore Date: Thu, 6 Aug 2026 22:36:35 +0300 Subject: [PATCH 03/10] Verification tool --- src/capture-format.test.ts | 39 +++ src/capture-format.ts | 56 +++++ src/capture-panel.ts | 38 +++ src/control-template.ts | 1 + src/control.ts | 16 +- src/devices/logitech/hidpp.ts | 230 +++++++++++++++++- src/devices/logitech/onboard-profiles.test.ts | 154 +++++++++++- src/devices/logitech/onboard-profiles.ts | 55 ++++- src/devices/mouse-types.ts | 2 + 9 files changed, 575 insertions(+), 16 deletions(-) diff --git a/src/capture-format.test.ts b/src/capture-format.test.ts index b56eddcf..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"; @@ -178,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 f5138ea9..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. * @@ -159,6 +164,57 @@ function capabilityLines( 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; diff --git a/src/capture-panel.ts b/src/capture-panel.ts index a32aeea1..10eafb36 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"; /** @@ -26,6 +32,8 @@ export interface CaptureProfileSource { describeOffset(offset: number): string | null; /** Applies the change with OpenMouse's own encoders, for verification. */ reproduce(before: Uint8Array, after: Uint8Array): Uint8Array; + prepareWriteProbe?(): Promise; + runWriteProbe?(backup: ProfileContentWriteProbeBackup): Promise; } interface CaptureContext { @@ -97,6 +105,8 @@ function setCaptureMessage(message: string): void { export function refreshCapturePanel(): void { renderActions(); renderDiffs(); + const probe = document.querySelector("#capture-write-probe"); + if (probe) probe.hidden = !context.profiles?.prepareWriteProbe || !context.profiles.runWriteProbe; } async function takeSnapshot(): Promise { @@ -211,6 +221,34 @@ export function bindCapturePanel(): void { }); }); + document.querySelector("#capture-write-probe")?.addEventListener("click", (event) => { + const button = event.currentTarget as HTMLButtonElement; + const source = context.profiles; + if (!source?.prepareWriteProbe || !source.runWriteProbe) return; + button.disabled = true; + setCaptureMessage("Reading and copying the recovery backup…"); + void source.prepareWriteProbe().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.runWriteProbe!(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..9c90216e 100644 --- a/src/control.ts +++ b/src/control.ts @@ -493,7 +493,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, @@ -809,6 +809,11 @@ function configureProfileCapture(status: MouseStatus | null): void { }, describeOffset: (offset) => describeOffset(formatId, offset), reproduce: (before, after) => reproduceProfile(before, after, formatId), + ...([2, 3, 4].includes(formatId) ? { + prepareWriteProbe: () => logitechClient.prepareProfileContentWriteProbe(), + runWriteProbe: (backup: Parameters[0]) => + logitechClient.runProfileContentWriteProbe(backup), + } : {}), } : null, }); @@ -2135,7 +2140,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 @@ -2195,7 +2200,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; } /** @@ -2574,7 +2579,7 @@ function renderProfileRates(): void { } if (!available || !entry || !rates) return; - const locked = lastProfileFormat?.verified !== true; + const locked = lastProfileFormat?.writable !== true; for (const link of ["wireless", "wired"] as const) { const value = stagedProfileRates[link] ?? (link === "wired" ? entry.reportRateWired : entry.reportRateWireless); @@ -2825,6 +2830,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 +2881,7 @@ function renderOnboardProfiles(): void { ${escapeHtml(detail)} -
- +
    ${lodOptions}
`; @@ -2717,7 +2719,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." diff --git a/src/devices/logitech/hidpp.test.ts b/src/devices/logitech/hidpp.test.ts index 6dd29e13..6cdb394b 100644 --- a/src/devices/logitech/hidpp.test.ts +++ b/src/devices/logitech/hidpp.test.ts @@ -32,7 +32,7 @@ test("runtime probing alone classifies direct and receiver connections", () => { assert.equal(isDirectConnection(null), false); }); -test("extended DPI does not imply lift-off or gaming-surface controls", () => { +test("extended DPI does not imply lift-off or mode-status controls", () => { assert.equal(supportsLiveLiftOffControl(false, null), false, "0 means no LOD control"); assert.equal(supportsLiveLiftOffControl(true, "Medium"), false, "legacy DPI has no LOD field"); assert.equal(supportsLiveLiftOffControl(false, "Low"), true); diff --git a/src/devices/logitech/hidpp.ts b/src/devices/logitech/hidpp.ts index 4de09909..8c57501b 100644 --- a/src/devices/logitech/hidpp.ts +++ b/src/devices/logitech/hidpp.ts @@ -524,13 +524,17 @@ export class LogitechHidppClient { analogButtonTuning, liftOffDistance, onboardProfileFormat, - // 0x8090 may exist only for LIGHTFORCE. Its otherwise-unused surface - // bits read as zero, which decodes to Auto, so require an actual live LOD - // control before exposing the related gaming-surface setting. + // HID++ exposes no per-field support mask for status1. A power-only + // variant can leave it reserved at zero, which would falsely decode as + // Surface Auto and LightForce Optical. Only expose that control bank when + // the sensor positively reports the related live LOD capability; this is + // deliberately conservative and avoids a product/model exception. gamingSurfaceMode: modeStatus === null || !hasLiveLiftOffControl ? null : decodeModeStatus(modeStatus, MODE_STATUS.gamingSurface), - lightforceSwitchMode: modeStatus === null ? null : decodeModeStatus(modeStatus, MODE_STATUS.lightforce), + lightforceSwitchMode: modeStatus === null || !hasLiveLiftOffControl + ? null + : decodeModeStatus(modeStatus, MODE_STATUS.lightforce), // Some profile formats have different wired and wireless ceilings. The // active transport comes from HID++ identity rather than a USB PID. pollingRateHz: connectionRateCeiling ? Math.min(pollingRateHz, connectionRateCeiling) : pollingRateHz, diff --git a/src/devices/logitech/onboard-profiles.test.ts b/src/devices/logitech/onboard-profiles.test.ts index cea52cb7..a98b9ad5 100644 --- a/src/devices/logitech/onboard-profiles.test.ts +++ b/src/devices/logitech/onboard-profiles.test.ts @@ -19,6 +19,7 @@ import { encodeProfileName, encodeReportRate, factoryProfileForFormat, + supportsFactoryReset, reportRatesFor, validateBunnyHoppingMs, validateProfileName, @@ -600,7 +601,7 @@ test("decodes the captured G102 LIGHTSYNC format-4 profile", () => { test("parses the captured G102 LIGHTSYNC format-4 geometry and directory", () => { assert.deepEqual( { verified: describeProfileFormat(4).verified, writable: describeProfileFormat(4).writable }, - { verified: true, writable: false }, + { verified: true, writable: true }, ); assert.deepEqual(parseProfilesInfo(G102_LIGHTSYNC_INFO_REPLY), { memoryModelId: 1, @@ -640,6 +641,10 @@ test("format-4 encoders prepare reversible scalar DPI, shared-rate and name prob }); test("factory reset image is exact, CRC-valid and limited to captured geometry", () => { + assert.equal(supportsFactoryReset(7), true); + for (const format of [1, 2, 3, 4, 5, 6, 8, null, undefined]) { + assert.equal(supportsFactoryReset(format), false, `format ${format ?? "missing"}`); + } const factory = factoryProfileForFormat(7, 255); assert.ok(factory); assert.deepEqual([...factory], [...SECTOR_2]); diff --git a/src/devices/logitech/onboard-profiles.ts b/src/devices/logitech/onboard-profiles.ts index 582d8621..fd98f745 100644 --- a/src/devices/logitech/onboard-profiles.ts +++ b/src/devices/logitech/onboard-profiles.ts @@ -48,8 +48,9 @@ const PROFILE_FORMAT_NAMES: Record = { * matching CRC. */ const VERIFIED_FORMATS = new Set([2, 3, 4, 7]); -const WRITABLE_FORMATS = new Set([7]); +const WRITABLE_FORMATS = new Set([4, 7]); const PROFILE_WRITE_PROBE_FORMATS = new Set([2, 3, 4]); +const FACTORY_RESET_FORMATS = new Set([7]); /** Whether this format has a reversible guided write probe. */ export function supportsProfileWriteProbe(profileFormatId: number | null | undefined): boolean { @@ -58,6 +59,13 @@ export function supportsProfileWriteProbe(profileFormatId: number | null | undef && PROFILE_WRITE_PROBE_FORMATS.has(profileFormatId); } +/** Whether a complete, byte-for-byte reset image exists for this format. */ +export function supportsFactoryReset(profileFormatId: number | null | undefined): boolean { + return profileFormatId !== null + && profileFormatId !== undefined + && FACTORY_RESET_FORMATS.has(profileFormatId); +} + export interface ProfileFormat { id: number; name: string; @@ -467,7 +475,7 @@ const FACTORY_PROFILE_FORMAT_7 = ` /** Returns a fresh, CRC-valid factory sector only for a captured geometry. */ export function factoryProfileForFormat(profileFormatId: number, sectorSize: number): Uint8Array | null { - if (profileFormatId !== 7 || sectorSize !== 255) return null; + if (!supportsFactoryReset(profileFormatId) || sectorSize !== 255) return null; return Uint8Array.from( FACTORY_PROFILE_FORMAT_7.trim().split(/\s+/), (byte) => Number.parseInt(byte, 16), From dee05ebc0c3943ab977d104e818b9d4886fec0e0 Mon Sep 17 00:00:00 2001 From: angelocore Date: Sat, 8 Aug 2026 20:23:11 +0300 Subject: [PATCH 08/10] . --- src/devices/logitech/TESTING.md | 92 --------------------------------- 1 file changed, 92 deletions(-) delete mode 100644 src/devices/logitech/TESTING.md diff --git a/src/devices/logitech/TESTING.md b/src/devices/logitech/TESTING.md deleted file mode 100644 index 4ab450c5..00000000 --- a/src/devices/logitech/TESTING.md +++ /dev/null @@ -1,92 +0,0 @@ -# Logitech hardware test checklist - -Test in Chrome or Edge over HTTPS. Close Logitech G HUB and Logitech Gaming -Software first — they hold the same vendor interface open and the mouse will -stop answering. Select the vendor collection (`usagePage 0xff00`, `usage -0x0001`), not the plain pointer collection. - -Supported identifiers: - -- `046d:c54d`, `046d:c547` — Lightspeed receivers -- `046d:c539` — HERO-era Lightspeed receiver -- `046d:c0a8` — PRO X 2 Superstrike (USB) -- `046d:c07e` — G402 / G402 Hyperion Fury (wired) -- `046d:c08f` — G403 HERO (wired) - -## Receiver-attached and Superstrike devices - -1. Confirm the model, battery, connection type, DPI, polling rate, and - lift-off distance are read correctly. -2. Change one setting at a time and confirm each write, then reload and confirm - it persisted. - -## G402 (direct-connect, HID++ device index `0xFF`) - -The G402 is addressed as the mouse itself rather than a receiver slot, and it -exposes only the legacy feature set: Adjustable DPI `0x2201` and Report Rate -`0x8060`. It has no lift-off, gaming-surface, battery, or hall-effect controls, -so those cards stay hidden. - -1. Confirm the sidebar and title show the mouse, and that the connection reads - **Wired**. -2. Confirm the firmware list and the HID++ device details section populate. -3. Confirm the DPI presets offer 420 / 840 / 1596 / 3192 — the nearest steps on - the G402's 84-DPI grid — and that the reported DPI matches what Logitech - Gaming Software shows, allowing for its rounding (2436 is shown as "2400"). -4. Stage a DPI change and flash it. The driver writes `0x2201` function 3 as a - short request and re-reads the value; a mismatch is reported as an error - rather than being assumed to have worked. -5. Confirm the sensor card (lift-off distance) is hidden — the G402 has no - `0x2202` feature to drive it. -6. Confirm the polling-rate buttons show the active rate but are **disabled**, - with the note explaining the rate lives in the onboard profile. -7. Confirm the mouse stays in onboard mode: its own DPI-stage buttons must keep - working after OpenMouse writes a DPI value. The driver deliberately does not - switch the G402 into host-control mode. -8. Reload the page and confirm the DPI written in step 4 is still reported. - -## G403 HERO (direct-connect, HID++ device index `0xFF`) - -The G403 HERO reports HID++ 4.2 and takes the same direct-connect path as the -G402: legacy Adjustable DPI `0x2201` and legacy Report Rate `0x8060`, no -`0x2202`, so the lift-off/sensor card stays hidden. Its sensor range is much -wider (100–25,600 DPI in steps of 50) and its onboard profile uses format `2` -with seven 256-byte sectors, so none of the G402's profile offsets apply to it. - -1. Confirm the sidebar and title show the mouse and the connection reads - **Wired**. -2. Confirm the firmware list and the HID++ device details section populate. -3. Confirm the DPI presets offer 400 / 800 / 1600 / 3200 / 6400 / 8000 — all - exact multiples on the 50-DPI grid — and that the reported DPI matches G HUB. -4. Stage a DPI change and flash it. The driver writes `0x2201` function 3 as a - short request and re-reads the value; a mismatch is reported as an error. -5. Confirm the polling-rate buttons show the active rate but are **disabled**. - The reference trace shows `0x8060` advertising 125/250/500/1000 Hz, and its - function 2 is not a verified setter on this generation. -6. Confirm the mouse stays in onboard mode: its own DPI-stage button must keep - working after OpenMouse writes a DPI value. -7. Reload the page and confirm the DPI written in step 4 is still reported. - -RGB lighting (`0x8070`, logo and wheel zones) is deliberately not implemented — -the write packet is unverified and the panel has no Logitech lighting controls. - -Persistent polling-rate and DPI-stage changes need a CRC-checked rewrite of the -1024-byte profile sector and are intentionally not implemented. Record the -device identifier, protocol version, and any failing setting in the issue or -pull request. Do not use factory reset during initial testing. - -## G309 LIGHTSPEED (receiver-attached, Model ID `B03C40B10000`) - -The G309 exposes Extended Adjustable DPI `0x2202` and Mode Status `0x8090`, but -only the power-mode half of Mode Status is meaningful: the status1 byte that -would carry the gaming-surface and LightForce fields is reserved and reads 0. -The `0x2202` sensor likewise reports lift-off level 0, the feature's "no -lift-off control" value. OpenMouse treats both as absent, so those cards stay -hidden. - -1. Confirm the model, battery, connection type, DPI, and polling rate are read - correctly. -2. Confirm the sensor card (lift-off distance), the gaming-surface card, and - the LightForce switch are all hidden. -3. Change the DPI and polling rate and confirm each write persists after a - reload. From cbfa4fd9a6265aa4b612b3ce3ec1169d7e213c50 Mon Sep 17 00:00:00 2001 From: angelocore Date: Sat, 8 Aug 2026 21:30:18 +0300 Subject: [PATCH 09/10] Verify profile 2 writing --- src/control.ts | 56 ++++++++++++----- src/devices/logitech/hidpp.test.ts | 22 ------- src/devices/logitech/hidpp.ts | 61 ++++++++++++++++--- src/devices/logitech/onboard-profiles.test.ts | 33 +++++++++- src/devices/logitech/onboard-profiles.ts | 60 +++++++++++++++++- 5 files changed, 182 insertions(+), 50 deletions(-) diff --git a/src/control.ts b/src/control.ts index 37649fb0..93416bd8 100644 --- a/src/control.ts +++ b/src/control.ts @@ -69,9 +69,10 @@ import { capabilitiesForFormat, clampDpi, describeOffset, + dpiStageCapabilitiesForOptions, reportRatesFor, + reportRatesForDevice, validateProfileName, - validateReportRate, reproduceProfile, supportsFactoryReset, supportsProfileWriteProbe, @@ -2198,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; } /** @@ -2539,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 = { @@ -2565,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, @@ -2593,20 +2611,28 @@ function renderProfileRates(): void { if (!available || !entry || !rates) return; const locked = lastProfileFormat?.writable !== true; - for (const link of ["wireless", "wired"] as const) { + 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 { diff --git a/src/devices/logitech/hidpp.test.ts b/src/devices/logitech/hidpp.test.ts index 6cdb394b..cab4432a 100644 --- a/src/devices/logitech/hidpp.test.ts +++ b/src/devices/logitech/hidpp.test.ts @@ -116,25 +116,3 @@ test("an onboard-only mouse is told how to get itself supported", () => { assert.doesNotMatch(unknown.message, /format (\d|null)/); assert.match(unknown.message, /Copy verification data/); }); - -test("the G309's mode status is power-only and exposes no surface or LightForce controls", () => { - // Model id captured from hardware: 0x8090 V2 with only the power-mode half. - assert.equal(isPowerOnlyModeStatus("B03C40B10000"), true); - // Every other model keeps the status1 fields, and unknown/absent ids must - // not be silently downgraded. - assert.equal(isPowerOnlyModeStatus("B03C40B10001"), false); - assert.equal(isPowerOnlyModeStatus(""), false); - assert.equal(isPowerOnlyModeStatus(null), false); - assert.equal(isPowerOnlyModeStatus(undefined), false); -}); - -test("a sensor without lift-off control advertises no lift-off levels", () => { - // 0x2201 legacy DPI carries no lod byte at all. - assert.equal(hasLiftOffControl(true, null), false); - // 0x2202 byte 0 is the "no lift-off control" value, as on the G309. - assert.equal(hasLiftOffControl(false, 0), false); - assert.equal(hasLiftOffControl(false, null), false); - // The levels 1-4 (Low/Medium/High/Extra high) are driveable. - assert.equal(hasLiftOffControl(false, 1), true); - assert.equal(hasLiftOffControl(false, 2), true); -}); diff --git a/src/devices/logitech/hidpp.ts b/src/devices/logitech/hidpp.ts index 8c57501b..a4e04ef6 100644 --- a/src/devices/logitech/hidpp.ts +++ b/src/devices/logitech/hidpp.ts @@ -27,6 +27,7 @@ import { decodeLiftOffLevel, decodeOnboardProfile, describeProfileFormat, + dpiStageCapabilitiesForOptions, encodeDpiStages, encodeProfileName, encodeReportRate, @@ -37,6 +38,7 @@ import { parseDirectory, parseProfilesInfo, profileCrc, + reportRatesForDevice, setDirectoryEnabled, storedCrc, supportsProfileWriteProbe, @@ -259,6 +261,7 @@ export class LogitechHidppClient { private dpiFeatureResolved: ResolvedFeature | null = null; private rateFeatureResolved: ResolvedFeature | null = null; private reportRateFeatureIndex: number | null = null; + private supportedPollingRatesCache: number[] | null = null; private livePollingRateHz: number | null = null; /** Discovered by resolveDeviceIndex; null until the mouse has answered. */ private resolvedDeviceIndex: number | null = null; @@ -467,9 +470,7 @@ export class LogitechHidppClient { const supportsSeparateDpiAxes = dpiFeature.legacy ? false : await this.readDpiCapabilities(dpiFeature.index); - const supportedPollingRates = reportRateFeature.legacy - ? await this.readLegacyReportRates(reportRateFeature.index) - : await this.readSupportedPollingRates(reportRateFeature.index); + const supportedPollingRates = await this.getSupportedPollingRateOptions(); const pollingRateHz = reportRateFeature.legacy ? await this.readLegacyReportRate(reportRateFeature.index) : await this.readPollingRate(reportRateFeature.index); @@ -480,7 +481,6 @@ export class LogitechHidppClient { : undefined; const modeStatusFeature = await this.getFeature(FEATURE.modeStatus); const modeStatus = modeStatusFeature.index ? await this.readModeStatus(modeStatusFeature.index) : null; - const modeStatusCarriesControls = !isPowerOnlyModeStatus(identity.modelId); // One extra request; the layout it selects is worth surfacing in diagnostics. const onboardProfileFormat = profilesFeature.index ? await this.request(profilesFeature.index, PROFILE_FN.getInfo) @@ -500,6 +500,9 @@ export class LogitechHidppClient { const connectionRateCeiling = rateLimits ? (wired ? rateLimits.wiredMaxHz : rateLimits.wirelessMaxHz) : null; + const effectiveSupportedPollingRates = connectionRateCeiling + ? supportedPollingRates.filter((rate) => rate <= connectionRateCeiling) + : supportedPollingRates; return { brand: "Logitech", @@ -538,9 +541,7 @@ export class LogitechHidppClient { // Some profile formats have different wired and wireless ceilings. The // active transport comes from HID++ identity rather than a USB PID. pollingRateHz: connectionRateCeiling ? Math.min(pollingRateHz, connectionRateCeiling) : pollingRateHz, - supportedPollingRates: connectionRateCeiling - ? supportedPollingRates.filter((rate) => rate <= connectionRateCeiling) - : supportedPollingRates, + supportedPollingRates: effectiveSupportedPollingRates, // Lift-off distance is only reachable through extended DPI (0x2202). On a // mouse that exposes just legacy 0x2201 there is nothing to drive, so // report an empty set rather than offering buttons that can only fail. @@ -972,20 +973,52 @@ export class LogitechHidppClient { } if (values.dpiStages) { + const liveDpiOptions = await this.getDpiOptions(); + const dpiCapabilities = dpiStageCapabilitiesForOptions( + capabilitiesForFormat(formatId).dpiStages, + liveDpiOptions, + ); const invalid = validateDpiStagePlan( values.dpiStages, - capabilitiesForFormat(formatId).dpiStages, + dpiCapabilities, formatId < 6, ); if (invalid) throw new Error(invalid); - updated = encodeDpiStages(updated, formatId, values.dpiStages); + for (const [index, stage] of values.dpiStages.stages.entries()) { + if (!liveDpiOptions.includes(stage.x) || !liveDpiOptions.includes(stage.y)) { + throw new Error(`Slot ${index + 1} uses a DPI value the connected mouse did not advertise.`); + } + } + updated = encodeDpiStages(updated, formatId, values.dpiStages, dpiCapabilities); } // The two links are stored separately, so each is set on its own. if (values.reportRateWirelessHz) { + const liveRates = await this.getSupportedPollingRateOptions(); + const allowed = reportRatesForDevice( + capabilitiesForFormat(formatId).reportRates, + "wireless", + liveRates, + this.wiredConnection ? "wired" : "wireless", + formatId < 6, + ); + if (!allowed.includes(values.reportRateWirelessHz)) { + throw new Error("The connected mouse did not advertise that profile report rate."); + } updated = encodeReportRate(updated, formatId, "wireless", values.reportRateWirelessHz); } if (values.reportRateWiredHz) { + const liveRates = await this.getSupportedPollingRateOptions(); + const allowed = reportRatesForDevice( + capabilitiesForFormat(formatId).reportRates, + "wired", + liveRates, + this.wiredConnection ? "wired" : "wireless", + formatId < 6, + ); + if (!allowed.includes(values.reportRateWiredHz)) { + throw new Error("The connected mouse did not advertise that profile report rate."); + } updated = encodeReportRate(updated, formatId, "wired", values.reportRateWiredHz); } @@ -1673,6 +1706,16 @@ export class LogitechHidppClient { return decodeReportRateBitmap(reply[3] ?? 0); } + private async getSupportedPollingRateOptions(): Promise { + if (this.supportedPollingRatesCache !== null) return this.supportedPollingRatesCache; + const feature = await this.resolveReportRateFeature(); + const rates = feature.legacy + ? await this.readLegacyReportRates(feature.index) + : await this.readSupportedPollingRates(feature.index); + this.supportedPollingRatesCache = rates; + return rates; + } + private async readLegacyReportRate(featureIndex: number): Promise { if (!featureIndex) { throw new Error("This Logitech mouse does not expose report-rate controls."); diff --git a/src/devices/logitech/onboard-profiles.test.ts b/src/devices/logitech/onboard-profiles.test.ts index a98b9ad5..c758ba80 100644 --- a/src/devices/logitech/onboard-profiles.test.ts +++ b/src/devices/logitech/onboard-profiles.test.ts @@ -15,12 +15,14 @@ import { clampDpi, decodeLiftOffLevel, describeProfileFormat, + dpiStageCapabilitiesForOptions, encodeDpiStages, encodeProfileName, encodeReportRate, factoryProfileForFormat, supportsFactoryReset, reportRatesFor, + reportRatesForDevice, validateBunnyHoppingMs, validateProfileName, validateReportRate, @@ -168,6 +170,7 @@ const G502_SECTORS = [ ]; const G502_INFO_REPLY = bytes("ff 0e 05 01 02 01 03 03 0b 10 01 00 0a 01 00 00 00 00 00"); +const G502_HERO_INFO_REPLY = bytes("ff 0c 05 01 02 01 05 05 0b 10 01 00 0a 01 00 00 00 00 00"); const G502_DIRECTORY = (() => { const sector = new Uint8Array(256).fill(0xff); sector.set(bytes("00 01 01 00 00 02 00 00 00 03 00 00 ff ff 00 00")); @@ -446,7 +449,7 @@ test("decodes all captured G502 format-2 profiles", () => { test("parses the captured G502 format-2 geometry and directory", () => { assert.deepEqual( { verified: describeProfileFormat(2).verified, writable: describeProfileFormat(2).writable }, - { verified: true, writable: false }, + { verified: true, writable: true }, ); assert.deepEqual(parseProfilesInfo(G502_INFO_REPLY), { memoryModelId: 1, @@ -455,6 +458,13 @@ test("parses the captured G502 format-2 geometry and directory", () => { sectorCount: 16, sectorSize: 256, }); + assert.deepEqual(parseProfilesInfo(G502_HERO_INFO_REPLY), { + memoryModelId: 1, + profileFormatId: 2, + profileCount: 5, + sectorCount: 16, + sectorSize: 256, + }); assert.equal(profileCrc(G502_DIRECTORY), storedCrc(G502_DIRECTORY)); assert.deepEqual(parseDirectory(G502_DIRECTORY), [ { sector: 1, enabled: true }, @@ -464,6 +474,13 @@ test("parses the captured G502 format-2 geometry and directory", () => { }); test("format-2 probe encoders use limits collected live from the wired G502", () => { + const heroLimits = capabilitiesForFormat(2).dpiStages; + assert.deepEqual(heroLimits, { maxStages: 5, minDpi: 100, maxDpi: 25600, stepDpi: 50 }); + assert.deepEqual( + dpiStageCapabilitiesForOptions(heroLimits, [100, 150, 200, 11950, 12000]), + { maxStages: 5, minDpi: 100, maxDpi: 12000, stepDpi: 50 }, + "the older G502 is narrowed to its live 12K sensor grid", + ); const original = decodeOnboardProfile(G502_SECTORS[0], 2, { sector: 1, enabled: true }, false); const stages = original.dpiStages.map((stage) => ({ ...stage })); stages[original.defaultDpiIndex!] = { x: 1000, y: 1000, lod: 0 }; @@ -801,6 +818,20 @@ test("report-rate ceilings differ per link and per format", () => { assert.equal(validateReportRate(8000, rates, "wireless"), null); assert.match(validateReportRate(8000, rates, "wired") ?? "", /Wired report rate/); assert.equal(validateReportRate(1000, rates, "wired"), null); + assert.deepEqual( + reportRatesForDevice(rates, "wireless", [125, 500, 1000, 2000], "wireless", false), + [125, 500, 1000, 2000], + ); + assert.deepEqual( + reportRatesForDevice(rates, "wired", [125, 500, 1000, 2000], "wireless", false), + [125, 250, 500, 1000], + "the disconnected wired link keeps its captured format ceiling", + ); + assert.deepEqual( + reportRatesForDevice({ wirelessMaxHz: 1000, wiredMaxHz: 1000 }, "wired", [125, 500, 1000], "wired", true), + [125, 500, 1000], + "a shared legacy interval uses the exact live bitmap", + ); // Format 8 reports the same split; this replaces the old Superstrike PID // exception with a format capability. diff --git a/src/devices/logitech/onboard-profiles.ts b/src/devices/logitech/onboard-profiles.ts index fd98f745..edd66140 100644 --- a/src/devices/logitech/onboard-profiles.ts +++ b/src/devices/logitech/onboard-profiles.ts @@ -48,7 +48,7 @@ const PROFILE_FORMAT_NAMES: Record = { * matching CRC. */ const VERIFIED_FORMATS = new Set([2, 3, 4, 7]); -const WRITABLE_FORMATS = new Set([4, 7]); +const WRITABLE_FORMATS = new Set([2, 4, 7]); const PROFILE_WRITE_PROBE_FORMATS = new Set([2, 3, 4]); const FACTORY_RESET_FORMATS = new Set([7]); @@ -190,10 +190,20 @@ const FORMAT_CAPABILITIES: Record = { maxNameLength: null, bunnyHop: false, }, + // LOGAN is shared by G502 generations. The HERO capture advertises the + // widest observed grid; callers narrow it to the connected sensor's live + // DPI list so an older 12K G502 is never offered the HERO's 25.6K ceiling. + 2: { + supportedLods: [], + lodEncoding: LOD_ENCODING, + dpiStages: { maxStages: 5, minDpi: 100, maxDpi: 25600, stepDpi: 50 }, + reportRates: { wirelessMaxHz: 1000, wiredMaxHz: 1000 }, + maxNameLength: PROFILE_NAME_MAX_CHARS, + bunnyHop: false, + }, // A G102 LIGHTSYNC on format 4 reported five scalar slots, 50-8000 DPI in // steps of 50, and 125/250/500/1000 Hz through the capability collector. - // The guided probe uses these reported limits, but the UI remains read-only - // until its DPI, rate, and name writes have been applied and restored. + // Its DPI, rate, and name writes were applied, read back live, and restored. 4: { supportedLods: [], lodEncoding: LOD_ENCODING, @@ -232,6 +242,32 @@ const FORMAT_CAPABILITIES: Record = { }, }; +function greatestCommonDivisor(left: number, right: number): number { + let a = Math.abs(left); + let b = Math.abs(right); + while (b !== 0) [a, b] = [b, a % b]; + return a; +} + +/** Narrows format storage limits to the DPI grid advertised by this sensor. */ +export function dpiStageCapabilitiesForOptions( + formatCapabilities: DpiStageCapabilities | null, + dpiOptions: readonly number[], +): DpiStageCapabilities | null { + if (formatCapabilities === null || dpiOptions.length === 0) return formatCapabilities; + const values = [...new Set(dpiOptions)] + .filter((value) => Number.isInteger(value) && value > 0) + .sort((left, right) => left - right); + if (values.length === 0) return formatCapabilities; + const stepDpi = values.reduce(greatestCommonDivisor); + return { + maxStages: formatCapabilities.maxStages, + minDpi: values[0], + maxDpi: values.at(-1)!, + stepDpi: stepDpi || formatCapabilities.stepDpi, + }; +} + export function capabilitiesForFormat(profileFormatId: number | null | undefined): ProfileFormatCapabilities { if (profileFormatId === null || profileFormatId === undefined) return DEFAULT_FORMAT_CAPABILITIES; return FORMAT_CAPABILITIES[profileFormatId] ?? DEFAULT_FORMAT_CAPABILITIES; @@ -571,6 +607,24 @@ export function reportRatesFor( return REPORT_RATE_HZ.filter((rate) => rate <= ceiling); } +/** + * Intersects format storage support with the rates advertised by the active + * hardware link. Legacy formats share one interval byte, so their live list + * applies to the whole profile; v6 formats retain the captured limit for the + * inactive link because the mouse cannot advertise that link while disconnected. + */ +export function reportRatesForDevice( + capabilities: ReportRateCapabilities | null, + link: "wireless" | "wired", + liveRates: readonly number[], + activeLink: "wireless" | "wired", + sharedInterval: boolean, +): number[] { + const storedRates = reportRatesFor(capabilities, link); + if (!sharedInterval && link !== activeLink) return storedRates; + return storedRates.filter((rate) => liveRates.includes(rate)); +} + export function validateReportRate( hz: number, capabilities: ReportRateCapabilities | null, From dc840fa6bb9f9b6987e8b9d621b1c600547ed799 Mon Sep 17 00:00:00 2001 From: angelocore Date: Sat, 8 Aug 2026 21:57:19 +0300 Subject: [PATCH 10/10] Increase bundle size --- build/check-bundle-size.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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");