Skip to content

feat(hidpp): write a G-series mouse's onboard startup DPI - #2528

Open
Efllux wants to merge 6 commits into
AprilNEA:masterfrom
Efllux:feat/onboard-default-dpi
Open

Efllux wants to merge 6 commits into
AprilNEA:masterfrom
Efllux:feat/onboard-default-dpi

Conversation

@Efllux

@Efllux Efllux commented Nov 12, 2026 •

Copy link
Copy Markdown

Summary

A G-series mouse wakes from sleep in onboard mode. Until the agent puts it back in host mode and writes the saved DPI, which takes a few seconds after the reconnect, it moves at the startup DPI stored in its onboard profile. If that DPI differs from the configured one, the cursor jumps after every wake-up.

On my PRO X SUPERLIGHT, G HUB had left profile 1 starting at 4950 DPI against a configured 1200. The agent log shows the gap:

06:41:43.604 device reconnected — re-arming capture … reason: PowerSwitchActivated
06:41:45.067 capture session ended unexpectedly, delaying re-arm
06:41:47.405 host mode entered → writing the saved DPI target=Dpi(Value(Dpi(1200)))

The agent can only shorten that gap; the mouse always wakes on its stored profile. This PR adds the ability to change the stored startup DPI, and exposes it through openlogi diag onboard --set-default-dpi. Before this, 0x8100 support was read-only.

Changes

  • openlogi-hidpp (feature/onboard_profiles.rs)
    • Add memoryAddrWrite (fn 6), memoryWrite (fn 7) and memoryWriteEnd (fn 8).
      • write_profile writes one whole sector through the write-through calls, so an abandoned future can't stop a write halfway.
    • Add ProfileSector. It is built only by read_profile, which reads the whole sector and checks its CRC-16/CCITT-FALSE, and it rejects an unknown profile format or an out-of-range default slot.
      • It exposes the five DPI slots and the default slot.
      • set_default_dpi points the default at a slot that already holds the value. Otherwise it replaces the value in the default slot, so the rest of the profile is kept.
      • Every edit reseals the CRC, so a correct write reads back equal.
    • Uses the crc crate (CRC_16_IBM_3740) instead of a hand-rolled CRC.
    • Tests:
      • The CRC check vector.
      • Reading the DPI table, and rejecting a bad CRC or a default slot outside the table.
      • Selecting a slot versus replacing one.
      • Resealing.
      • A fixture: profile 1 read from a PRO X SUPERLIGHT. The edit must change exactly the bytes the device changed (11, 12, 253, 254) and produce the CRC it stored (0x4682).
  • openlogi-core / openlogi-ipc
    • Append HidppOperation::WriteOnboard, with its wire golden (21).
    • Master is already above the released v45, so the note joins the unreleased v46 line and the version number is unchanged.
  • openlogi-device / openlogi-hid: add set_onboard_default_dpi(route, OnboardDefaultDpi { unit, profile, dpi }).
    • It first reads the 0x0003 unit ID and refuses another device on the route, or one with no unit ID (DeviceNotFound), before any 0x8100 request. A receiver slot re-paired after selection keeps its flash.

    • It checks dpi against the sensor's 0x2201 list (DpiUnsupported).

    • It writes nothing when the profile already starts there.

    • After a write it reads the sector back (DpiNotApplied on a mismatch) and re-selects the profile if it is active, so the mouse loads it.

    • Tests use a scripted PRO X SUPERLIGHT serving the measured sector. They cover:

      • the exact start/chunks/end sequence, including the padded last chunk of a 255-byte sector;
      • that only slot 4 and the CRC change, to the 0x4682 the device stored;
      • that a different unit, an unsupported DPI and an unchanged DPI send no flash write.

      Swapping functions 6 and 7 fails them. The measured sector is a single fixture file (onboard_profiles/superlight_profile_1.bin) that both crates read.

  • openlogi-cli: openlogi diag onboard gains --set-default-dpi <DPI> and --profile <N>. The profile defaults to the active one.
    • The write passes the unit ID enumeration read for the chosen device. It holds the agent's instance lock from before selection until the read-back, and refuses while an agent answers.
    • The lock-then-probe sequence moved out of diag hidpp into diag::exclude_agent, guarded by the ast-grep rule cli-diag-agent-exclusion-owner.
    • A child-process test checks that a held agent lock stops the command before it enumerates, and that an admitted command keeps the agent out.
  • docs: USAGE.md describes the option, its refusals, and that the stored value survives power cycles and is not updated by later DPI changes.

Wiring this into the agent, for example storing the configured DPI when it changes, is a separate decision because each write wears flash. I left it out of this PR.

Testing

Full local gate, because Cargo.toml and Cargo.lock changed. Branch sits on master at 9ca7ef0.

Rerun on the review fixes (1bf5df30):

export RUSTFLAGS="-D warnings"
cargo fmt --all -- --check                                   # passed
ast-grep scan                                                # passed (99 rules)
cargo clippy --workspace --all-targets -- -D warnings        # passed
cargo test --workspace --no-fail-fast                        # 4738 passed, 0 failed, 5 ignored
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items \
  --exclude openlogi-ui --exclude openlogi-desktop --exclude openlogi-overlay \
  --exclude openlogi-agent                                   # passed

For the first revision, a first cargo test --workspace run failed one test this branch does not touch: write::litra::hidpp_tests::a_read_after_a_notification_replaces_an_older_write_reading. It passed on rerun.

After the second review (48468483), the CLI's onboard test module is Unix-only. I reran fmt, ast-grep, Clippy and tests for openlogi-cli/openlogi (198 passed), and cross-ran Clippy with -D warnings --all-targets on hidpp, core, device, hid and ipc for x86_64-pc-windows-gnu and aarch64-unknown-linux-musl. Both passed. openlogi-cli itself could not be cross-compiled for Windows here, because ring needs a MinGW C toolchain, so its Windows build was checked by reading the code only. The clippy (windows) proxy was not run for the same reason.

699c3b5e (test deadlines only) reran the onboard device tests (5 passed), fmt, ast-grep, and openlogi-device Clippy (-D warnings --all-targets) on the host, x86_64-pc-windows-gnu and aarch64-unknown-linux-musl. All passed.

Not run: typos, MSRV, cargo-deny, and the Linux/Windows jobs.

Hardware verification. Tested on macOS 26.6.2 with an original PRO X SUPERLIGHT over its Lightspeed receiver. The agent was stopped during the write.

  • getInfo: profile format 4, sector size 255, 5 profiles.
  • Before the write, profile 1 (sector 0x0001) held slots 400/800/1600/3200/4950 with default slot 4, and its CRC e576 was valid.
  • openlogi diag onboard --device "pro x" --set-default-dpi 1200:
    • Wrote the sector. On re-read only bytes 11–12 (56 13 → b0 04) and the CRC (e576 → 4682, valid) had changed.
    • Rerunning it reports 1200 → 1200 and writes nothing.
  • With the agent stopped, 0x2201 reports a live onboard DPI of 1200.
  • After the review fixes, under the agent's own profile (OPENLOGI_PROFILE=dev):
    • with the agent running, the write was refused on its instance lock;
    • with the agent stopped, the unit-bound write found the same mouse and reported 1200 → 1200 without writing.
  • The first attempt had functions 6 and 7 swapped. The device rejected the first call with InvalidArgument and the sector was unchanged on re-read, so a bad call order does not damage flash.

Refs #886

Add memoryAddrWrite/memoryWrite/memoryWriteEnd and a CRC-checked
ProfileSector that reads and edits the startup DPI of a stored profile.
Every edit reseals the CRC-16/CCITT-FALSE, so a correct write reads back
equal. The regression fixture is a PRO X SUPERLIGHT profile whose
rewrite was verified on the device.
`openlogi diag onboard --set-default-dpi <DPI> [--profile N]` writes the
DPI a gaming mouse runs at in onboard mode. Such a mouse wakes from
sleep in onboard mode, so until the agent re-enters host mode it moves
at its stored startup DPI. Storing the configured DPI there removes that
burst. The write is checked against the sensor's DPI list, read back,
and the active profile is re-selected so the mouse loads it.
@greptile-apps

greptile-apps Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no blocking findings remain.

Summary

The PR adds a command to save a mouse's startup DPI in onboard flash. It checks the chosen mouse's unit ID, excludes the agent during writes, and reads the sector back afterward.

  • A mouse profile can store a new startup DPI without changing its other settings.
  • The onboard diagnostic can set a profile’s startup DPI.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A["Request startup DPI write"] --> B["Hold agent lock and check endpoint"]
  B --> C["Select mouse and retain unit ID"]
  C --> D["Check unit ID and supported DPI"]
  D --> E["Read and check profile sector"]
  E --> F{"DPI already matches?"}
  F -->|Yes| G["Return without writing"]
  F -->|No| H["Edit DPI and reseal CRC"]
  H --> I["Write start, chunks, end"]
  I --> J["Read back and compare"]
  J --> K["Reload profile if active"]
Loading

Reviews (4) · Last reviewed commit: "test(device): bound the scripted onboard..." · Reviewed by Greptile

Review follow-ups for the 0x8100 write:

- The write takes the 0x0003 unit ID enumeration read for the chosen
  device and refuses any other device on the route before it touches
  0x8100, so a receiver slot re-paired after selection keeps its flash.
  A device enumeration read no unit ID for cannot be written.
- `diag onboard --set-default-dpi` holds the agent's instance lock from
  before selection until the write is read back, and refuses while an
  agent answers. The lock-then-probe sequence moves out of `diag hidpp`
  into `diag::exclude_agent`, guarded by an ast-grep rule.
- A scripted PRO X SUPERLIGHT serves the measured profile and checks the
  exact start/chunks/end sequence, the padded last chunk of a 255-byte
  sector, the stored CRC, and that a replaced unit, an unsupported DPI
  and an unchanged one send no flash write. Swapping functions 6 and 7
  fails it. The measured sector is one fixture file both crates read.
Only the test function was unix-gated, so on Windows its imports were
unused, which -D warnings turns into an error.
Each request is already bounded by the channel's response timeout; the
shared helper bounds the whole write too, so a stalled one fails with a
clear message.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant