diff --git a/docs/logitech-onboard-profiles.md b/docs/logitech-onboard-profiles.md index d5bc0a0..4134811 100644 --- a/docs/logitech-onboard-profiles.md +++ b/docs/logitech-onboard-profiles.md @@ -719,3 +719,24 @@ time (actuation 5, sensitivity 2 off, haptics 3) while the live values differed and while a live-only apply came back to it after a power cycle. A HITS change is therefore written both ways: the live feature for immediate effect, and `persistAnalogButtonTuning` for the profile, in one sector write. + +## Factory reset and blank-mouse setup (format 8) + +Captured over USB (USBPcap) while G HUB reset every profile on a linked PRO X 3 +SUPERSTRIKE: + +- Each of sectors 1 to 5 received the same 255-byte image, CRC `0x2a38`. It is + byte-identical to the format 8 sector in the tests, including the HITS defaults + (actuation 5, rapid trigger 2 off, haptics 3). +- The directory (sector 0) was written once: `00 01 01 ff | 00 02 00 ff | 00 03 00 ff | + 00 04 00 ff | 00 05 00 ff`, then `ff` to the end, CRC `0x4037`. Sector 1 is + enabled, 2 to 5 disabled. It was byte-identical to the directory before the reset, + so a reset does not change the directory. +- G HUB then selected profile 1 (`setCurrentProfile 00 01`). + +`resetAllOnboardProfiles` uses this image for format 8, so the X3 can be reset from +OpenMouse. `initializeBlankOnboardProfiles` writes the same end state to a mouse +whose directory lists no profiles (a mouse G HUB has never linked): the five +sectors first, the directory last, then profile 1 selected, each read back. The +write sequence is the one captured from a reset; it has not been run against a +never-linked mouse, and G HUB's own first-link sequence was not captured. diff --git a/src/drivers/logitech/hidpp.test.ts b/src/drivers/logitech/hidpp.test.ts index f903fe2..515bfc4 100644 --- a/src/drivers/logitech/hidpp.test.ts +++ b/src/drivers/logitech/hidpp.test.ts @@ -24,6 +24,7 @@ import { isWiredHidppConnection, supportsLiveLiftOffControl, } from "./hidpp.ts"; +import { factoryDirectoryForFormat, factoryProfileForFormat } from "./onboard-profiles.ts"; const G402 = 0xc07e; const G403_HERO = 0xc08f; @@ -600,3 +601,90 @@ test("HITS press stream start replays G HUB's captured arm sequence, stop clears assert.deepEqual(Array.from(short[0].data.slice(3, 6)), [0x01, 0x3c, 0x00]); assert.deepEqual(Array.from(short[1].data.slice(3, 6)), [0x00, 0x00, 0x00]); }); + +/** + * A format 8 mouse whose onboard memory is a map of 255-byte sectors, driven by + * the same HID++ 0x8100 functions the driver uses: info, mode, current profile, + * memory read, and the address/data/end write sequence. Starts blank (every + * sector erased) like a mouse G HUB has never linked. + */ +function onboardMemoryMouse(options: { ignoreWrites?: boolean; profileCount?: number; preload?: Record } = {}) { + const FEATURE_INDEX = 0x21; + const memory = new Map(Object.entries(options.preload ?? {}).map(([sector, bytes]) => [Number(sector), bytes.slice()])); + const state = { mode: 0x01, current: 0x0001, writes: 0 }; + let pending: { sector: number; length: number; bytes: number[] } | null = null; + const { client, device } = harness(0xc54d, { 1: "mouse" }); + const base = hidppResponder({ 1: "mouse" }); + const sectorBytes = (sector: number) => memory.get(sector) ?? new Uint8Array(255).fill(0xff); + device.onRequest = (request) => { + const deviceIndex = request[0]; + const featureId = (request[3] << 8) | request[4]; + if (request[1] === 0x00 && featureId === 0x8100) return successReply(deviceIndex, 0x00, 0x00, [FEATURE_INDEX, 0x00, 0x00]); + if (request[1] !== FEATURE_INDEX) return base(request); + const fn = request[2] >> 4; + const params = Array.from(request.slice(3)); + if (fn === 0) return successReply(deviceIndex, FEATURE_INDEX, 0x00, [0x01, 0x08, 0x00, options.profileCount ?? 0x05, 0x00, 0x06, 0x05, 0x00, 0xff]); + if (fn === 1) { state.mode = params[0]; return successReply(deviceIndex, FEATURE_INDEX, 0x10); } + if (fn === 2) return successReply(deviceIndex, FEATURE_INDEX, 0x20, [state.mode]); + if (fn === 3) { state.current = (params[0] << 8) | params[1]; return successReply(deviceIndex, FEATURE_INDEX, 0x30); } + if (fn === 4) return successReply(deviceIndex, FEATURE_INDEX, 0x40, [state.current >> 8, state.current & 0xff]); + if (fn === 5) { + const sector = (params[0] << 8) | params[1]; + const offset = (params[2] << 8) | params[3]; + return successReply(deviceIndex, FEATURE_INDEX, 0x50, Array.from(sectorBytes(sector).slice(offset, offset + 16))); + } + if (fn === 6) { + pending = { sector: (params[0] << 8) | params[1], length: (params[4] << 8) | params[5], bytes: [] }; + return successReply(deviceIndex, FEATURE_INDEX, 0x60); + } + if (fn === 7 && pending) { pending.bytes.push(...params.slice(0, 16)); return successReply(deviceIndex, FEATURE_INDEX, 0x70); } + if (fn === 8 && pending) { + if (!options.ignoreWrites) memory.set(pending.sector, Uint8Array.from(pending.bytes.slice(0, pending.length))); + state.writes += 1; + pending = null; + return successReply(deviceIndex, FEATURE_INDEX, 0x80); + } + return null; + }; + return { client, memory, state }; +} + +test("initialising a blank format 8 mouse writes the factory sectors, then the directory, then selects profile 1", async () => { + const { client, memory, state } = onboardMemoryMouse(); + await resolveIndex(client); + await client.initializeBlankOnboardProfiles(); + + const factory = factoryProfileForFormat(8, 255); + const directory = factoryDirectoryForFormat(8, 255); + assert.ok(factory && directory); + for (const sector of [1, 2, 3, 4, 5]) assert.deepEqual([...memory.get(sector)!], [...factory], `sector ${sector}`); + assert.deepEqual([...memory.get(0)!], [...directory]); + assert.equal(state.current, 0x0001); + assert.equal(state.mode, 0x01, "left in onboard mode"); + assert.equal(state.writes, 6); +}); + +test("initialising refuses a mouse that already has profiles and writes nothing", async () => { + const directory = factoryDirectoryForFormat(8, 255)!; + const { client, state } = onboardMemoryMouse({ preload: { 0: directory } }); + await resolveIndex(client); + await assert.rejects(client.initializeBlankOnboardProfiles(), /already has onboard profiles/); + assert.equal(state.writes, 0); + assert.equal(state.mode, 0x01); +}); + +test("initialising leaves the directory blank and the mouse in onboard mode when a write does not stick", async () => { + const { client, memory, state } = onboardMemoryMouse({ ignoreWrites: true }); + await resolveIndex(client); + await assert.rejects(client.initializeBlankOnboardProfiles(), /did not confirm/); + assert.equal(memory.has(0), false, "directory never written"); + assert.equal(state.mode, 0x01, "not stranded in host mode"); +}); + +test("initialising refuses a mouse that reports fewer profiles than the captured layout and writes nothing", async () => { + const { client, state } = onboardMemoryMouse({ profileCount: 3 }); + await resolveIndex(client); + await assert.rejects(client.initializeBlankOnboardProfiles(), /reports 3 profiles/); + assert.equal(state.writes, 0); + assert.equal(state.mode, 0x01); +}); diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index a0fc894..2bc434d 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -106,7 +106,9 @@ import { encodeMacroSector, encodeProfileName, encodeReportRate, + factoryDirectoryForFormat, factoryProfileForFormat, + isBlankDirectory, validateDpiStagePlan, type DpiStagePlan, type LogitechButtonAction, @@ -1491,6 +1493,78 @@ export class LogitechHidppClient { } } + /** + * Gives a mouse that has no profiles yet (G HUB has never linked it) the + * factory set, so OpenMouse does not need G HUB to initialise it: every + * factory profile sector, then a directory listing them with the first + * enabled, then profile 1 selected. + * + * WRITES FLASH. Only for a format whose factory sectors and directory were + * captured from G HUB, and only when the directory is truly blank. The + * directory goes last, so a failure part-way leaves the mouse as blank as it + * started instead of pointing at half-written sectors. The sequence was + * captured from G HUB's reset on a linked PRO X 3 SUPERSTRIKE; it has not + * been run against a never-linked mouse. + */ + async initializeBlankOnboardProfiles(): Promise { + await this.open(); + const feature = await this.getFeature(FEATURE.onboardProfiles); + if (!feature.index) { + throw new Error("This Logitech mouse does not expose onboard-profile controls."); + } + + const info = parseProfilesInfo(await this.request(feature.index, PROFILE_FN.getInfo)); + const sectorSize = info.sectorSize > 0 && info.sectorSize <= 1024 ? info.sectorSize : 255; + const factoryProfile = factoryProfileForFormat(info.profileFormatId, sectorSize); + const factoryDirectory = factoryDirectoryForFormat(info.profileFormatId, sectorSize); + if (!factoryProfile || !factoryDirectory) { + throw new Error(`Factory profiles have not been captured for profile format ${info.profileFormatId}.`); + } + if (profileCrc(factoryProfile) !== storedCrc(factoryProfile) || profileCrc(factoryDirectory) !== storedCrc(factoryDirectory)) { + throw new Error("The built-in factory profiles failed their checksum; refusing to write."); + } + + const existing = await this.readProfileSector(feature.index, 0x0000, sectorSize); + if (!isBlankDirectory(existing)) { + throw new Error("This mouse already has onboard profiles; use reset instead."); + } + const sectors = parseDirectory(factoryDirectory).map((entry) => entry.sector); + // The directory layout was captured from one model. Only write it to a mouse + // that itself reports at least that many profiles. + if (info.profileCount < sectors.length) { + throw new Error(`This mouse reports ${info.profileCount} profiles but the captured factory layout has ${sectors.length}; refusing to write it.`); + } + const firstSector = sectors[0]; + + // Host mode keeps the mouse from loading a sector while it is being filled. + await this.setOnboardMode("Host"); + let finished = false; + try { + for (const sector of sectors) { + await this.writeProfileSector(feature.index, sector, factoryProfile); + const confirmed = await this.readProfileSector(feature.index, sector, sectorSize); + if (!confirmed.every((byte, index) => byte === factoryProfile[index])) { + throw new Error(`Profile ${sector} did not confirm its factory image.`); + } + } + await this.writeProfileSector(feature.index, 0x0000, factoryDirectory); + const confirmedDirectory = await this.readProfileSector(feature.index, 0x0000, sectorSize); + if (!confirmedDirectory.every((byte, index) => byte === factoryDirectory[index])) { + throw new Error("The mouse did not confirm the new profile directory."); + } + + await this.request(feature.index, PROFILE_FN.setCurrentProfile, (firstSector >> 8) & 0xff, firstSector & 0xff); + await this.setOnboardMode("Onboard"); + const current = await this.request(feature.index, PROFILE_FN.getCurrentProfile); + if ((((current[3] ?? 0) << 8) | (current[4] ?? 0)) !== firstSector) { + throw new Error("The profiles were created, but the mouse did not select the first one."); + } + finished = true; + } finally { + if (!finished) await this.setOnboardMode("Onboard").catch(() => undefined); + } + } + /** * Sets the bunny-hop timeout on the active profile. * diff --git a/src/drivers/logitech/onboard-profiles.test.ts b/src/drivers/logitech/onboard-profiles.test.ts index 7eebfa3..c3fc9b1 100644 --- a/src/drivers/logitech/onboard-profiles.test.ts +++ b/src/drivers/logitech/onboard-profiles.test.ts @@ -26,7 +26,9 @@ import { decodeAnalogButtons, encodeAnalogButtons, encodeReportRate, + factoryDirectoryForFormat, factoryProfileForFormat, + isBlankDirectory, supportsFactoryReset, reportRateCapabilitiesFor, reportRatesFor, @@ -729,7 +731,7 @@ 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]) { + for (const format of [1, 2, 3, 4, 5, 6, null, undefined]) { assert.equal(supportsFactoryReset(format), false, `format ${format ?? "missing"}`); } const factory = factoryProfileForFormat(7, 255); @@ -737,7 +739,7 @@ test("factory reset image is exact, CRC-valid and limited to captured geometry", assert.deepEqual([...factory], [...SECTOR_2]); assert.equal(profileCrc(factory), storedCrc(factory)); assert.equal(factoryProfileForFormat(7, 256), null); - assert.equal(factoryProfileForFormat(8, 255), null); + assert.equal(factoryProfileForFormat(6, 255), null); }); test("factory reset reproduces erased name, bunny-hop and G-Shift regions", () => { @@ -1323,3 +1325,32 @@ test("HITS in the profile: one button at a time, the on/off bit, and the checks" assert.throws(() => encodeAnalogButtons(SECTOR_1_SUPERSTRIKE, 8, [{ button: 2, actuation: 5, rapidTrigger: 2, haptics: 3 }]), /left and right/); assert.throws(() => encodeAnalogButtons(SECTOR_1_SUPERSTRIKE, 8, [{ button: 0, actuation: 64, rapidTrigger: 2, haptics: 3 }]), /outside/); }); + +test("format 8 factory sector is the captured G HUB reset image", () => { + assert.equal(supportsFactoryReset(8), true); + const factory = factoryProfileForFormat(8, 255); + assert.ok(factory); + assert.deepEqual([...factory], [...SECTOR_1_SUPERSTRIKE]); + assert.equal(profileCrc(factory), storedCrc(factory)); + assert.equal(storedCrc(factory), 0x2a38); + assert.equal(factoryProfileForFormat(8, 256), null); +}); + +test("format 8 factory directory lists sectors 1 to 5 with only the first enabled, CRC 0x4037", () => { + const directory = factoryDirectoryForFormat(8, 255); + assert.ok(directory); + assert.equal(directory.length, 255); + assert.equal(profileCrc(directory), storedCrc(directory)); + assert.equal(storedCrc(directory), 0x4037); + assert.deepEqual([...directory.slice(0, 24)], [0, 1, 1, 255, 0, 2, 0, 255, 0, 3, 0, 255, 0, 4, 0, 255, 0, 5, 0, 255, 255, 255, 255, 255]); + assert.deepEqual(parseDirectory(directory).map((entry) => [entry.sector, entry.enabled]), [[1, true], [2, false], [3, false], [4, false], [5, false]]); + assert.equal(factoryDirectoryForFormat(7, 255), null, "only captured for format 8"); + assert.equal(factoryDirectoryForFormat(8, 256), null); +}); + +test("a directory is blank only when it lists no profiles", () => { + assert.equal(isBlankDirectory(new Uint8Array(255).fill(0xff)), true); + assert.equal(isBlankDirectory(new Uint8Array(255)), true); + assert.equal(isBlankDirectory(factoryDirectoryForFormat(8, 255)!), false); + assert.equal(isBlankDirectory(new Uint8Array(2)), false, "a short read is not proof of blank"); +}); diff --git a/src/drivers/logitech/onboard-profiles.ts b/src/drivers/logitech/onboard-profiles.ts index 6a71437..30ff43a 100644 --- a/src/drivers/logitech/onboard-profiles.ts +++ b/src/drivers/logitech/onboard-profiles.ts @@ -71,7 +71,7 @@ const VERIFIED_FORMATS = new Set([2, 3, 4, 7, 8]); */ const WRITABLE_FORMATS = new Set([2, 3, 4, 7, 8]); const PROFILE_WRITE_PROBE_FORMATS = new Set([2, 3, 4]); -const FACTORY_RESET_FORMATS = new Set([7]); +const FACTORY_RESET_FORMATS = new Set([7, 8]); /** Whether profile-content writes for `profileFormatId` are trusted at all. */ export function isProfileWritable(profileFormatId: number | null | undefined): boolean { @@ -795,15 +795,71 @@ const FACTORY_PROFILE_FORMAT_7 = ` 00 03 00 00 00 00 00 1f 40 32 00 00 03 84 db `; +/** + * Complete factory profile for format 8 (PRO X 3 SUPERSTRIKE), captured over USB + * while G HUB reset every profile: all five profile sectors received this exact + * image, CRC 0x2a38. It includes the HITS defaults (actuation 5, rapid trigger 2 + * off, haptics 3). + */ +const FACTORY_PROFILE_FORMAT_8 = ` + 03 03 00 00 20 03 20 03 02 b0 04 b0 04 02 40 06 + 40 06 02 60 09 60 09 02 80 0c 80 0c 02 00 00 00 + 00 ff 00 ff ff ff 14 08 0c 14 08 0c 3c 00 2c 01 + 80 01 00 01 80 01 00 02 80 01 00 04 80 01 00 08 + 80 01 00 10 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 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 03 00 00 00 00 00 1f 40 32 00 + 00 03 00 00 00 00 00 1f 40 32 00 00 03 2a 38 +`; + +const FACTORY_PROFILES: Record = { + 7: FACTORY_PROFILE_FORMAT_7, + 8: FACTORY_PROFILE_FORMAT_8, +}; + /** Returns a fresh, CRC-valid factory sector only for a captured geometry. */ export function factoryProfileForFormat(profileFormatId: number, sectorSize: number): Uint8Array | null { if (!supportsFactoryReset(profileFormatId) || sectorSize !== 255) return null; return Uint8Array.from( - FACTORY_PROFILE_FORMAT_7.trim().split(/\s+/), + FACTORY_PROFILES[profileFormatId].trim().split(/\s+/), (byte) => Number.parseInt(byte, 16), ); } +/** Profile sectors a format 8 mouse ships with: 1 to 5, as G HUB's reset left them. */ +const FACTORY_DIRECTORY_SECTORS_FORMAT_8 = [1, 2, 3, 4, 5]; + +/** + * The directory (sector 0) of a factory mouse: every profile listed, only the + * first enabled, rest of the sector erased. Captured from G HUB's reset on a + * PRO X 3 SUPERSTRIKE (CRC 0x4037); only captured for format 8. + */ +export function factoryDirectoryForFormat(profileFormatId: number, sectorSize: number): Uint8Array | null { + if (profileFormatId !== 8 || sectorSize !== 255) return null; + const directory = new Uint8Array(sectorSize).fill(0xff); + FACTORY_DIRECTORY_SECTORS_FORMAT_8.forEach((sector, index) => { + directory.set([sector >> 8, sector & 0xff, index === 0 ? 0x01 : 0x00, 0xff], index * 4); + }); + return applyCrc(directory); +} + +/** + * True when a directory sector lists no profiles at all, as on a mouse G HUB + * has never linked. Erased flash reads 0xffff; a first entry of 0x0000 is the + * other terminator parseDirectory stops on. + */ +export function isBlankDirectory(sector: Uint8Array): boolean { + return sector.length >= 4 && parseDirectory(sector).length === 0; +} + /** * Returns a copy of the directory sector with one profile's enabled flag * changed and the checksum recomputed. Every other byte is carried through