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
22 changes: 18 additions & 4 deletions src/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
capabilitiesForFormat,
clampDpi,
describeOffset,
layoutForFormat,
reportRatesFor,
validateProfileName,
validateReportRate,
Expand Down Expand Up @@ -2575,7 +2576,15 @@ function renderProfileRates(): void {
if (!available || !entry || !rates) return;

const locked = lastProfileFormat?.verified !== true;
for (const link of ["wireless", "wired"] as const) {
// Base v1 stores a single wired rate; only formats with the field for a link
// get a slider for it, so a LOGAN mouse never offers a wireless stop it
// cannot store.
const layout = lastProfileFormat ? layoutForFormat(lastProfileFormat.id) : null;
const links = (["wireless", "wired"] as const).filter((link) => {
const offset = link === "wired" ? layout?.reportRateWired : layout?.reportRateWireless;
return offset !== null && offset !== undefined;
});
for (const link of links) {
const value = stagedProfileRates[link]
?? (link === "wired" ? entry.reportRateWired : entry.reportRateWireless);
renderRateSlider(
Expand All @@ -2585,10 +2594,15 @@ function renderProfileRates(): void {
{ label: link === "wired" ? "Wired" : "Wireless", disabled: locked || settingInProgress },
);
}
for (const link of ["wireless", "wired"] as const) {
const slider = document.querySelector<HTMLElement>(`#profile-rate-${link}`);
if (slider) slider.hidden = !links.includes(link);
}

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.`);
const ceilings = links
.map((link) => `${reportRatesFor(rates, link).at(-1)?.toLocaleString() ?? "0"} Hz ${link === "wired" ? "over the cable" : "wireless"}`)
.join(" and ");
setText("#polling-note", `Stored in this profile. Up to ${ceilings}.`);
}

function renderDpiSlots(): void {
Expand Down
27 changes: 20 additions & 7 deletions src/devices/logitech/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Supported identifiers:
- `046d:c0a8` — PRO X 2 Superstrike (USB)
- `046d:c07e` — G402 / G402 Hyperion Fury (wired)
- `046d:c08f` — G403 HERO (wired)
- `046d:c08b` — G502 HERO (wired)

## Receiver-attached and Superstrike devices

Expand Down Expand Up @@ -60,20 +61,32 @@ with seven 256-byte sectors, so none of the G402's profile offsets apply to it.
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.
5. Confirm the polling-rate controls are **enabled** and that changing the rate
writes the active onboard profile's report-rate byte (format 2/LOGAN), then
reloads as the new rate. The write is the CRC-checked sector rewrite; run the
write-probe once on this hardware before release.
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.
Persistent polling-rate changes write the profile sector and are implemented for
format 2 (LOGAN); DPI-stage changes still are not (the v1 format has no stage
table). Record the device identifier, protocol version, and any failing setting
in the issue or pull request. Do not use factory reset during initial testing.

## G502 HERO (direct-connect, HID++ device index `0xFF`)

The wired G502 HERO follows the same direct-connect path as the G403 HERO:
legacy `0x2201` DPI and `0x8060` report rate, profile format 2 (LOGAN).

1. Repeat the G403 checklist. The polling-rate controls must be enabled and
write the active onboard profile's report-rate byte, then reload as the new
rate — this is the fix for "cannot click the 1 kHz polling rate option".
2. Confirm the rate actually changed in the OS (e.g. a mouse-rate tester), not
just in the profile read-back, so the reload-on-write behaviour is understood.

## G309 LIGHTSPEED (receiver-attached, Model ID `B03C40B10000`)

Expand Down
25 changes: 19 additions & 6 deletions src/devices/logitech/hidpp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,13 @@ export class LogitechHidppClient {
this.lodCapabilities = capabilitiesForFormat(onboardProfileFormat?.id);
this.supportedLods = [...this.lodCapabilities.supportedLods];
const wired = this.device.productId === 0xc0a8 || this.isDirectConnect;
// A direct-connect mouse keeps its rate in the onboard profile, so it can
// only change once that format is verified and actually carries a
// report-rate field. Anything else stays read-only.
const profileRateWritable = this.isDirectConnect
&& this.profileFormatId !== null
&& describeProfileFormat(this.profileFormatId).verified
&& capabilitiesForFormat(this.profileFormatId).reportRates !== null;

return {
brand: "Logitech",
Expand All @@ -465,10 +472,13 @@ export class LogitechHidppClient {
// Logitech allows Lift-off Distance modification only when Gaming Surface Mode is set to "on" or "auto"
lodRequiresSurface: true,
// Direct-connect mice report their rate but keep the writable copy in
// the onboard profile, so show it without offering to change it.
pollingReadOnly: this.isDirectConnect ? true : undefined,
// the onboard profile. It is editable again once the profile write path
// is available for that format.
pollingReadOnly: this.isDirectConnect && !profileRateWritable ? true : undefined,
pollingNote: this.isDirectConnect
? "This mouse stores its polling rate in the onboard profile, so OpenMouse reads it without changing it."
? profileRateWritable
? "Stored in this mouse's onboard profile — OpenMouse writes it there."
: "This mouse stores its polling rate in the onboard profile, so OpenMouse reads it without changing it."
: undefined,
},
batteryPercent: battery.percent,
Expand Down Expand Up @@ -524,9 +534,12 @@ export class LogitechHidppClient {
async setPollingRate(pollingRateHz: number): Promise<number> {
if (this.isDirectConnect) {
// 0x8060's setter rejects live writes on this generation (HID++ error
// 0x02). The persistent rate lives in the onboard profile, which needs a
// CRC-checked sector rewrite that is not implemented here.
throw new Error("This mouse stores its polling rate in the onboard profile. Change it in Logitech's own software; OpenMouse can only read it.");
// 0x02), and the persistent rate lives in the onboard profile anyway.
// Write the active profile's report-rate byte; encodeReportRate validates
// the value against the format the mouse reported. This costs one sector
// erase/write cycle.
await this.writeActiveProfile({ reportRateWiredHz: pollingRateHz });
return pollingRateHz;
}
if (this.isSuperstrikeDevice && this.device.productId === 0xc0a8 && pollingRateHz > 1000) {
throw new Error("The Superstrike USB connection supports up to 1000 Hz. Use the Lightspeed receiver for higher rates.");
Expand Down
38 changes: 38 additions & 0 deletions src/devices/logitech/onboard-profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
clampBunnyHopMs,
clampDpi,
decodeLiftOffLevel,
describeProfileFormat,
encodeDpiStages,
encodeProfileName,
encodeReportRate,
Expand Down Expand Up @@ -456,6 +457,43 @@ test("each link's report rate is written without disturbing the other", () => {
assert.throws(() => encodeReportRate(SECTOR_3, 7, "wired", 8000), /Wired report rate/);
});

test("base v1 report rates are encoded as milliseconds, not a table index", () => {
// LOGAN stores the USB polling interval in milliseconds, exactly like legacy
// 0x8060: 1 ms is 1000 Hz, 2 ms is 500 Hz. The index encoding (0 = 125 Hz,
// 3 = 1000 Hz) would write 3 for 1000 Hz and read back as 333 Hz.
const sector = new Uint8Array(255).fill(0xff);
sector[0x00] = 0x02; // 500 Hz
applyCrc(sector);

const encoded = encodeReportRate(sector, 2, "wired", 1000);
assert.equal(encoded[0x00], 0x01, "1 ms must encode 1000 Hz on base v1");
assert.equal(decodeOnboardProfile(encoded, 2, { sector: 1, enabled: true }, false).reportRateWired, 1000);
assert.equal(storedCrc(encoded), profileCrc(encoded));

// And back to 500 Hz touches only the rate byte, plus the checksum.
const back = encodeReportRate(encoded, 2, "wired", 500);
assert.equal(back[0x00], 0x02);
for (let index = 1; index < back.length - 2; index += 1) {
assert.equal(back[index], encoded[index], `byte 0x${index.toString(16)}`);
}
});

test("format 2 (LOGAN) advertises a writable wired ceiling and no wireless link", () => {
const rates = capabilitiesForFormat(2).reportRates;
assert.deepEqual(rates, { wirelessMaxHz: 0, wiredMaxHz: 1000 });
assert.deepEqual(reportRatesFor(rates, "wired"), [125, 250, 500, 1000]);
assert.deepEqual(reportRatesFor(rates, "wireless"), []);
assert.equal(validateReportRate(1000, rates, "wired"), null);
assert.match(validateReportRate(2000, rates, "wired") ?? "", /Wired report rate/);

// The report-rate write is the one thing this format is trusted for; the rest
// stays locked so a v1 mouse cannot be pushed into unknown fields.
assert.equal(describeProfileFormat(2).verified, true);
assert.equal(capabilitiesForFormat(2).dpiStages, null);
assert.equal(capabilitiesForFormat(2).maxNameLength, null);
assert.equal(capabilitiesForFormat(2).bunnyHop, false);
});

test("profile names round-trip and are held to the region's size", () => {
const named = encodeProfileName(SECTOR_3, 7, "FPS");
assert.equal(decodeOnboardProfile(named, 7, ENTRY, true).name, "FPS");
Expand Down
28 changes: 26 additions & 2 deletions src/devices/logitech/onboard-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,14 @@ const PROFILE_FORMAT_NAMES: Record<number, string> = {
*
* Add a format here only after a dump from that device decodes sensibly with a
* matching CRC.
*
* Format 2 (LOGAN) is trusted only for its report-rate field: the v1 base was
* recovered from vendor code, the write sequence is the same one format 7
* proved on hardware, and the rate byte decodes back as a polling interval. A
* hardware sanity check on a G502/G403-family device is still required before
* release — see docs/logitech-onboard-profiles.md.
*/
const VERIFIED_FORMATS = new Set([7]);
const VERIFIED_FORMATS = new Set([7, 2]);

export interface ProfileFormat {
id: number;
Expand Down Expand Up @@ -161,6 +167,19 @@ const DEFAULT_FORMAT_CAPABILITIES: ProfileFormatCapabilities = {
};

const FORMAT_CAPABILITIES: Record<number, ProfileFormatCapabilities> = {
// Wired HERO-era mice (G403 HERO, G502 HERO family). Base v1 stores a single
// report-rate byte as the USB polling interval in milliseconds, so only the
// wired link is writable; there is no radio link, no DPI stage table, no name
// region text and no bunny hop on this format. The 1000 Hz ceiling is the
// shared USB cap of that generation.
2: {
supportedLods: ["Medium", "High"],
lodEncoding: LOD_ENCODING,
dpiStages: null,
reportRates: { wirelessMaxHz: 0, wiredMaxHz: 1000 },
maxNameLength: null,
bunnyHop: false,
},
// Pro X Superlight 2. Three levels, counted from one. Five slots, from the
// 5x5 stage table in the registration and confirmed against a real profile.
7: {
Expand Down Expand Up @@ -559,7 +578,12 @@ export function encodeReportRate(
if (offset === null) throw new Error("This profile format has no report-rate setting.");

const result = sector.slice();
result[offset] = REPORT_RATE_HZ.indexOf(hz as (typeof REPORT_RATE_HZ)[number]);
// Base v1 (formats 1-5) stores the USB polling interval in milliseconds, the
// same encoding legacy 0x8060 uses; base v6 and later index the rate table
// 0x8061 shares. Writing the wrong one would read back as another rate.
result[offset] = profileFormatId < 6
? Math.round(1000 / hz)
: REPORT_RATE_HZ.indexOf(hz as (typeof REPORT_RATE_HZ)[number]);
return applyCrc(result);
}

Expand Down