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
21 changes: 21 additions & 0 deletions docs/logitech-onboard-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
88 changes: 88 additions & 0 deletions src/drivers/logitech/hidpp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
isWiredHidppConnection,
supportsLiveLiftOffControl,
} from "./hidpp.ts";
import { factoryDirectoryForFormat, factoryProfileForFormat } from "./onboard-profiles.ts";

const G402 = 0xc07e;
const G403_HERO = 0xc08f;
Expand Down Expand Up @@ -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<number, Uint8Array> } = {}) {
const FEATURE_INDEX = 0x21;
const memory = new Map<number, Uint8Array>(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);
});
74 changes: 74 additions & 0 deletions src/drivers/logitech/hidpp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ import {
encodeMacroSector,
encodeProfileName,
encodeReportRate,
factoryDirectoryForFormat,
factoryProfileForFormat,
isBlankDirectory,
validateDpiStagePlan,
type DpiStagePlan,
type LogitechButtonAction,
Expand Down Expand Up @@ -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<void> {
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.
*
Expand Down
35 changes: 33 additions & 2 deletions src/drivers/logitech/onboard-profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ import {
decodeAnalogButtons,
encodeAnalogButtons,
encodeReportRate,
factoryDirectoryForFormat,
factoryProfileForFormat,
isBlankDirectory,
supportsFactoryReset,
reportRateCapabilitiesFor,
reportRatesFor,
Expand Down Expand Up @@ -729,15 +731,15 @@ 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);
assert.ok(factory);
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", () => {
Expand Down Expand Up @@ -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");
});
60 changes: 58 additions & 2 deletions src/drivers/logitech/onboard-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<number, string> = {
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
Expand Down
Loading