From 3a1bc8a8effdbccd2e8c65ccd47c386c0c79933c Mon Sep 17 00:00:00 2001 From: Shubhadeep Date: Sat, 8 Aug 2026 19:35:33 +0530 Subject: [PATCH] fix(logitech): write polling rate to onboard profile for direct-connect mice Direct-connect mice (G402/G403 HERO/G502 HERO) store their polling rate in the onboard profile, so the 1 kHz stop was rendered disabled. Route setPollingRate through the CRC-checked profile write for verified formats, encode base-v1 rates as the USB polling interval in milliseconds rather than a table index, and add LOGAN (format 2) report-rate capabilities. Mark format 2 verified for the report-rate field only; DPI stages, names and bunny hop stay locked. --- src/control.ts | 22 +++++++++-- src/devices/logitech/TESTING.md | 27 +++++++++---- src/devices/logitech/hidpp.ts | 25 +++++++++--- src/devices/logitech/onboard-profiles.test.ts | 38 +++++++++++++++++++ src/devices/logitech/onboard-profiles.ts | 28 +++++++++++++- 5 files changed, 121 insertions(+), 19 deletions(-) diff --git a/src/control.ts b/src/control.ts index a3053887..7191635d 100644 --- a/src/control.ts +++ b/src/control.ts @@ -69,6 +69,7 @@ import { capabilitiesForFormat, clampDpi, describeOffset, + layoutForFormat, reportRatesFor, validateProfileName, validateReportRate, @@ -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( @@ -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(`#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 { diff --git a/src/devices/logitech/TESTING.md b/src/devices/logitech/TESTING.md index 4ab450c5..50e807aa 100644 --- a/src/devices/logitech/TESTING.md +++ b/src/devices/logitech/TESTING.md @@ -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 @@ -60,9 +61,10 @@ 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. @@ -70,10 +72,21 @@ with seven 256-byte sectors, so none of the G402's profile offsets apply to it. 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`) diff --git a/src/devices/logitech/hidpp.ts b/src/devices/logitech/hidpp.ts index c68d9deb..ab1ef52d 100644 --- a/src/devices/logitech/hidpp.ts +++ b/src/devices/logitech/hidpp.ts @@ -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", @@ -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, @@ -524,9 +534,12 @@ export class LogitechHidppClient { async setPollingRate(pollingRateHz: number): Promise { 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."); diff --git a/src/devices/logitech/onboard-profiles.test.ts b/src/devices/logitech/onboard-profiles.test.ts index c28aff11..0d09e26a 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, @@ -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"); diff --git a/src/devices/logitech/onboard-profiles.ts b/src/devices/logitech/onboard-profiles.ts index c59ea79c..f1ee844e 100644 --- a/src/devices/logitech/onboard-profiles.ts +++ b/src/devices/logitech/onboard-profiles.ts @@ -46,8 +46,14 @@ const PROFILE_FORMAT_NAMES: Record = { * * 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; @@ -161,6 +167,19 @@ const DEFAULT_FORMAT_CAPABILITIES: ProfileFormatCapabilities = { }; const FORMAT_CAPABILITIES: Record = { + // 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: { @@ -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); }