Skip to content
Open
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
39 changes: 39 additions & 0 deletions docs/logitech-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Supported identifiers:
- `046d:c0a8` — PRO X 2 Superstrike (USB)
- `046d:c07e` — G402 / G402 Hyperion Fury (wired)
- `046d:c08f` — G403 HERO (wired)
- `046d:c087` — G703 (wired)
- `046d:c08b` — G502 HERO (wired)
- `046d:c07d` — G502 / G502 Proteus Core (wired)
- `046d:c095` — G502 X PLUS (USB cable; wireless identity `4099` uses its Lightspeed receiver)
Expand Down Expand Up @@ -95,6 +96,44 @@ 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.

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

The original G703 on its USB cable (`046d:c087`) takes the same direct-connect
path as the G403 HERO: legacy Adjustable DPI `0x2201` and legacy Report Rate
`0x8060`. Extended DPI `0x2202` is absent, so the lift-off/sensor card stays
hidden. Identity reports USB transport `C087` and firmware `MPM 14.02` /
`BOT 64.00` / `RQI 04.00`.

Its onboard profile is format `3` (HEAT). A 2026-08-16 guided write probe on
this cable unit passed: name `OM_VERIFY`, default-slot DPI 1000, and 500 Hz
were stored exactly, confirmed live, and restored. Polling-rate and DPI-slot
edits therefore write the active onboard profile. The sensor advertises
50–12,000 DPI in steps of 50 and 125 / 250 / 500 / 1000 Hz. Battery is
reported from the voltage feature (`0x1001`). RGB lighting (`0x8070`)
enumerates Primary and Logo zones as write-only.

A cable capture confirmed Wired USB, 450 DPI, 1000 Hz, charging, active
onboard profile 3, four of five DPI slots in use (450 / 800 / 1600 / 12000).
The Lightspeed dongle was not present; wireless remains untested on this unit.

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 DPI reads back and that a staged DPI write re-reads as the value
sent (`0x2201` function 3, short request).
4. Confirm the polling-rate controls are **enabled** and that changing the rate
writes the active onboard profile's report-rate byte, then reloads as the
new rate.
5. Open a stored profile and change the DPI slot table. Confirm the physical
DPI button cycles the new values and that unused slots stay unused.
6. Confirm the sensor card (lift-off distance) is hidden.
7. Confirm battery percentage and charging state populate while the cable is
attached.
8. Confirm Primary and Logo lighting zones are offered. Writes are
write-only — the current effect is not read back.

Do not use factory reset. Format 3 has no factory-reset image.

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

The wired G502 HERO follows the same direct-connect path as the G403 HERO:
Expand Down
7 changes: 7 additions & 0 deletions src/drivers/logitech/hidpp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {

const G402 = 0xc07e;
const G403_HERO = 0xc08f;
const G703 = 0xc087;
const G502 = 0xc07d;
const G502_X_PLUS = 0xc095;
const G502_X = 0xc099;
Expand Down Expand Up @@ -55,6 +56,12 @@ test("the G502 family direct USB interfaces are recognized", () => {
assert.equal(isDirectConnectProduct(LIGHTSPEED_RECEIVER), false);
});

test("the G703 USB cable interface is recognized as direct-connect", () => {
assert.equal(isDirectConnectProduct(G703), true);
assert.equal(isDirectConnectProduct(G403_HERO), true);
assert.equal(isWiredHidppConnection(G703, { USB: "C087" }, false), true);
});

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");
Expand Down
9 changes: 8 additions & 1 deletion src/drivers/logitech/onboard-profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@ test("decodes all captured G502 LIGHTSPEED format-3 profiles", () => {
test("parses the captured G502 LIGHTSPEED format-3 geometry and directory", () => {
assert.deepEqual(
{ verified: describeProfileFormat(3).verified, writable: describeProfileFormat(3).writable },
{ verified: true, writable: false },
{ verified: true, writable: true },
);
assert.deepEqual(parseProfilesInfo(G502_LIGHTSPEED_INFO_REPLY), {
memoryModelId: 1,
Expand All @@ -593,6 +593,13 @@ test("parses the captured G502 LIGHTSPEED format-3 geometry and directory", () =
]);
});

test("format-3 encoders use the G703-captured scalar grid and shared rate", () => {
const capabilities = capabilitiesForFormat(3);
assert.deepEqual(capabilities.dpiStages, { maxStages: 5, minDpi: 50, maxDpi: 12000, stepDpi: 50 });
assert.deepEqual(reportRatesFor(capabilities.reportRates, "wired"), [125, 250, 500, 1000]);
assert.equal(describeProfileFormat(3).writable, true);
});

test("format-3 probe encoders accept limits collected live from the mouse", () => {
const original = decodeOnboardProfile(G502_LIGHTSPEED_PROFILE_1, 3, { sector: 1, enabled: true }, true);
const stages = original.dpiStages.map((stage) => ({ ...stage }));
Expand Down
13 changes: 12 additions & 1 deletion src/drivers/logitech/onboard-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const PROFILE_FORMAT_NAMES: Record<number, string> = {
* release — see docs/logitech-onboard-profiles.md.
*/
const VERIFIED_FORMATS = new Set([2, 3, 4, 7]);
const WRITABLE_FORMATS = new Set([2, 4, 7]);
const WRITABLE_FORMATS = new Set([2, 3, 4, 7]);
const PROFILE_WRITE_PROBE_FORMATS = new Set([2, 3, 4]);
const FACTORY_RESET_FORMATS = new Set([7]);

Expand Down Expand Up @@ -207,6 +207,17 @@ const FORMAT_CAPABILITIES: Record<number, ProfileFormatCapabilities> = {
maxNameLength: PROFILE_NAME_MAX_CHARS,
bunnyHop: false,
},
// A G703 on format 3 reported four of five scalar slots, 50-12000 DPI in
// steps of 50, and 125/250/500/1000 Hz. Name, default-slot DPI, and wired
// rate writes were applied, confirmed live, and restored.
3: {
supportedLods: [],
lodEncoding: LOD_ENCODING,
dpiStages: { maxStages: 5, minDpi: 50, maxDpi: 12000, 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.
// Its DPI, rate, and name writes were applied, read back live, and restored.
Expand Down
5 changes: 3 additions & 2 deletions src/logitech/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const BOLT_PAIRING_SLOTS = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06] as const;
*
* G Pro / G502 / G403 HERO generation (HID++ 2.0, DPI feature 0x2202):
* - 0xc085 — G Pro (wired, 2017)
* - 0xc087 — G703 (wired)
* - 0xc08b — G502 HERO (wired)
* - 0xc08c — G Pro Hero (wired)
* - 0xc08e — G903 HERO (wired)
Expand All @@ -55,8 +56,8 @@ export const LOGITECH_DIRECT_PRODUCT_IDS = [
0xc084, 0xc089, 0xc092,
// G303 / G402 generation
0xc07d, 0xc07e, 0xc080,
// G Pro / G502 / G403 HERO generation
0xc085, 0xc08b, 0xc08c, 0xc08e, 0xc08f,
// G Pro / G502 / G403 HERO / G703 generation
0xc085, 0xc087, 0xc08b, 0xc08c, 0xc08e, 0xc08f,
// G Pro X Superlight generation
0xc094,
// G502 X generation
Expand Down