Skip to content

feat: onboard profiles (0x8100) support for G-series mice - #459

Open
Stanley5249 wants to merge 21 commits into
AprilNEA:masterfrom
Stanley5249:feat/onboard-profiles
Open

feat: onboard profiles (0x8100) support for G-series mice#459
Stanley5249 wants to merge 21 commits into
AprilNEA:masterfrom
Stanley5249:feat/onboard-profiles

Conversation

@Stanley5249

@Stanley5249 Stanley5249 commented Jul 24, 2026

Copy link
Copy Markdown

Summary

Adds end-to-end support for HID++ 2.0 Onboard Profiles (feature 0x8100), the switch
gaming mice (G502 X LIGHTSPEED, G305, G602, …) use to decide whether they run host software
settings or their own flash-stored profiles. Until now 0x8100 was a name-only row in the
feature registry, so OpenLogi could neither report which mode a mouse was in nor let the
user choose one.

This is a read + mode-control slice: read the memory description, mode, active profile
and profile directory; switch host/onboard mode; select the active onboard profile. It
deliberately does not write flash (no profile editing — fn6–8 are out of scope).

The mode is volatile — a mouse left in host mode comes back onboard after a power cycle —
so the agent re-applies the per-device policy on every (re)connect.

Rewritten 2026-07-26. The original version of this description said DPI writes are
rejected while a mouse is in onboard mode, and justified part of the design with that.
Bench testing disproved it; the real cause was a concurrency bug, now fixed here. See
Corrections.

Changes

  • hidpp: new feature/onboard_profiles module — getDescription, get/set onboard
    mode, get/set current profile, memoryRead, and directory parsing over sector 0.
    Offsets reverse-engineered against Solaar / libratbag and marked as such; strict wire
    validation (unknown mode/enabled bytes → UnsupportedResponse). No flash-write session.
  • hid: IPC-facing ProfilesMode / ProfileEntry / OnboardProfilesInfo types and the
    read/apply verbs. apply_profiles_config skips writes the device already matches and
    tolerates transient post-mode-switch read-back failures (observed on hardware).
    Also: an exchange() lock serializing every device verb — see below.
  • core: per-device [devices."…".onboard_profiles] config (mode, profile),
    config-file-only; Capabilities::onboard_profiles from a 0x8100 feature probe.
  • ipc: set_onboard_profiles / read_onboard_profiles agent methods; the reconnect
    reapply. PROTOCOL_VERSION 10 → 11, wire-format goldens regenerated.
  • gui: a "Profiles" tab (gated on Capabilities::onboard_profiles) — Settings source
    (OpenLogi settings / Onboard memory) and the active onboard-profile selector.
  • cli: openlogi diag profiles — prints the state and runs a mode/profile round-trip
    (--read-only, --leave-onboard).

The agent never switches a device's mode uninvited

An unconfigured 0x8100 device is left in whatever mode it powered on in. The mouse
signals its active profile only by blinking an LED, so a host-mode switch the user did not
ask for discards the profile they picked on the device itself, with nothing on screen and
nothing on the device to explain it. The mode is written only after the user chooses one.

Concurrent HID++ verbs were cross-matching replies

HID++ correlates a reply to its request only by (device index, feature index, function, software id), and every verb here opens by resolving its feature with the same
0x0000 getFeature header at the channel's fixed software id. Two verbs in flight on one
device therefore match each other's replies. Bench-observed on a G502 X:

concurrent pair result
set_profiles_modeset_dpi DPI InvalidArgument, 3/3 — the DPI payload reaches 0x8100's index
set_dpiset_scroll_wheel_mode 0x2121 reported unsupported, though the feature table lists it
get_dpiget_onboard_profiles UnsupportedResponse
get_dpiset_profiles_mode Ok(0) — silently wrong, no error

Run sequentially, all of them succeed. An exchange() lock now spans each verb, taken in
with_route and in every *_on shared-channel fast path; all four cases pass after it.
This predates onboard profiles — the same fan-out existed in the reconnect reapply — but
0x8100 is what made it reproducible.

The reapply still runs the remaining volatile writes as a continuation of the
onboard-profiles apply, for a different reason than originally stated: activating a profile
reloads that profile's DPI out of flash, so a DPI write that raced ahead would be
discarded.

Testing

Hardware: G502 X LIGHTSPEED over a Lightspeed receiver, Windows.

cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all -- --check
cargo run -p openlogi -- diag profiles            # full read + round-trip
cargo run -p openlogi -- diag dpi                 # in both modes
cargo run -p openlogi-gui                          # Profiles tab, toggle + re-read

Verified on hardware:

  • diag profiles reads the description (5 user + 2 ROM profiles, 11 buttons,
    16 × 255 B sectors), mode, active profile, and the directory; the strict 0/1 enabled
    parse holds on real flash. The unit fixture now carries this captured payload.
  • Mode and active-profile round-trips succeed, across an agent restart honouring a GUI-set
    mode = onboard, profile = 2.
  • setCurrentProfile is onboard-mode-only — host mode answers InvalidArgument. Host
    mode also reports active profile 0x0000, since it parks the flash profile.
  • The mode is volatile: a device left in host mode returned to onboard, with sector
    0x0002 active again, after a power cycle.
  • DPI writes are accepted in both modes1600 → 1650 → 1600 round-trips while
    onboard, while in host mode, and immediately after a mode write.
  • The four concurrency cases in the table above, before and after the lock.
  • ROM profiles are counted but not reachable on this device. The description reports 2,
    yet sector 0's directory terminates right after the 5 user entries (reading with the
    corrected profile_count + profile_count_oob bound still returns 5), and
    setCurrentProfile rejects 0x0101/0x0102/0x0103 with InvalidArgument. The
    directory bound was fixed anyway — it is wrong for the format, and a device that does
    list ROM entries would lose them — but is_rom_sector and the GUI's entry.is_rom()
    branch are dead paths on a G502 X, not proof they work.

macOS/Linux GUI build and i18n parity are CI's job — not built on macOS locally.

Corrections

Two claims in the first version of this PR were wrong, and are fixed in the code and the
docs rather than left standing:

  1. "DPI writes are rejected while the mouse is onboard." They are not. Every
    InvalidArgument behind that claim came from the concurrency bug above — the DPI write
    was reaching 0x8100's feature index. The consent policy's stated cost ("configured DPI
    silently no-ops on a mouse sitting in onboard mode") therefore does not exist.
  2. "On these devices OpenLogi's writes silently did nothing." Overstated. Onboard mode
    accepts host writes; what it does is reload the profile's own values from flash on the
    next profile switch, which is a persistence question, not a blocked write.

Known limitation — host mode is currently a downgrade

On a G502 X, the host mode this PR can enter leaves G7/G8 dead and the LED dark. That
is not the mode's fault: the DPI stage list, the lighting and the button assignments all
lived in the flash profile that host mode parks, and OpenLogi writes the mode byte and
nothing else. G HUB's host mode keeps all of it because it pushes a full configuration the
moment it takes the mode.

The feature table bounds how far this can be closed on this device: no 0x1b04 (no
HID++ button remapping — the OS hook is the only path) and no 0x807x/0x8081 lighting
at all
, so nothing OpenLogi can currently send will light that LED. 0x8110 (button spy)
is the untested lead for host-side DPI staging.

Until that is closed, host mode stays opt-in and is never entered on the user's behalf —
which is what the consent policy above already does.

Notes

螢幕擷取畫面 2026-07-25 024930 螢幕擷取畫面 2026-07-25 024950

Stanley5249 and others added 9 commits July 25, 2026 01:30
Memory description, onboard/host mode, active profile, flash sector
reads and profile-directory parsing. Protocol cross-checked against
libratbag hidpp20.c and Solaar hidpp20.py (official x8100 spec is not
public). The flash write session (functions 6-8) is deliberately not
implemented.
ProfilesMode / ProfileEntry / OnboardProfilesInfo boundary types (serde,
IPC wire format from day one), get_onboard_profiles, set_profiles_mode
and set_active_profile with read-back, and apply_profiles_config — the
agent-facing verb that skips writes the device already matches. Appends
ReadOnboardProfiles / WriteOnboardProfiles to HidppOperation (wire-safe
append; goldens updated with the PROTOCOL_VERSION bump).
OnboardProfiles { mode: host|onboard, profile } persisted per device so
the agent can re-apply it on reconnect (the mode reverts to onboard on a
power cycle). Config-file only; the default policy for an unconfigured
device is host mode so OpenLogi's settings apply.
Appends set_onboard_profiles / read_onboard_profiles to the Agent trait
and Capabilities::onboard_profiles (0x8100) — PROTOCOL_VERSION 10 -> 11,
wire goldens regenerated. The orchestrator applies the configured mode
(default: host, so software settings work on gaming mice) first in the
reconnect reapply path; the mode is RAM-volatile and reverts to onboard
on power cycle. Devices without 0x8100 skip at debug level.
…ng path

Mirrors upstream PR AprilNEA#388 (kiwimaker) rebased onto current master, with the
G502 X LIGHTSPEED receiver (046d:c547) added and hardware-verified on
Windows 11: enumeration, feature dump, and a diag dpi round-trip. Also
chains LIGHTSPEED_PIDS into the Linux receiver-child filter and refreshes
the stale Bolt-only note in the CLI list help.

Co-authored-by: kiwimaker <49567144+kiwimaker@users.noreply.github.com>
Profiles tab gated on Capabilities::onboard_profiles: a settings-source
control (OpenLogi settings vs onboard memory) and an active-profile
selector for the enabled directory entries. Commits persist to
config.toml and apply over IPC with an optimistic cache + confirming
re-read, on the SmartShift panel's lazy-read pattern. New locale keys
seeded with English values across all locales (Crowdin translates).
Reads the 0x8100 description, mode, active profile, and profile
directory, then round-trips the mode and the active profile with
read-back verification and restore. --read-only skips the writes;
--leave-onboard keeps the device in onboard mode for verifying the
agent's reapply. The profile write runs inside the onboard-mode window
because host mode rejects setCurrentProfile (verified on a G502 X).
A G502 X still in onboard mode answered the parallel DPI reapply with
InvalidArgument — the mode switch and the other volatile writes raced
in separate threads. apply_onboard_profiles_in_background now takes the
rest of the reapply as a continuation and runs it after the mode apply
settles; devices without 0x8100 run it immediately as before. Also
tolerate read-back failures after mode/profile writes (the read races
the device's mode transition) like the DPI path, and document that
setCurrentProfile is onboard-mode-only.
@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds end-to-end HID++ 2.0 0x8100 OnboardProfiles support for G-series gaming mice (G502 X LIGHTSPEED, G305, G602, …): read the profile memory description, get/set the onboard/host mode, get/set the active profile sector, and parse the flash directory. It also fixes a pre-existing concurrency bug where concurrent HID++ verbs on one device cross-matched replies, by introducing a global exchange() mutex that serializes every verb.

  • openlogi-hidpp: new OnboardProfilesFeature (0x8100) — wire parsing, getDescription, getOnboardMode/setOnboardMode, getCurrentProfile/setCurrentProfile, and memoryRead+directory parsing; strict validation (enabled ≠ 0/1 → UnsupportedResponse).
  • openlogi-hid: apply_profiles_config / get_onboard_profiles / set_profiles_mode / set_active_profile; the global EXCHANGE Tokio mutex added to with_route and all *_on fast-path functions; reconnect reapply restructured so remaining writes run as a continuation after the onboard-profiles apply.
  • openlogi-agent-core / openlogi-agent: set_onboard_profiles / read_onboard_profiles IPC verbs, PROTOCOL_VERSION bumped to 11, per-device config, Capabilities::onboard_profiles probed from feature table.
  • GUI: "Profiles" tab with source pills and profile selector, lazy read, optimistic writes + confirming re-read; Lightspeed receiver (0xc53f, 0xc547) enumeration on the Unifying protocol path.

Confidence Score: 5/5

Safe to merge — the concurrency fix is correct, the ROM-profile directory fix from the previous review is applied, and the reconnect reapply sequencing is sound.

The exchange lock covers every code path that sends a HID++ packet (with_route, all *_on fast-path helpers, and the hires_wheel *_on variants) without introducing any re-entrancy hazard: *_on_channel helpers are always called with the lock already held and never try to acquire it themselves. The reconnect reapply continuation correctly sequences onboard-profiles before DPI/SmartShift. Directory parsing now passes total_profiles (user + OOB) so ROM entries are no longer silently dropped. Wire-format goldens are regenerated, the protocol version is bumped, and all four previously-failing concurrent-verb combinations are listed as verified on hardware.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs New 0x8100 OnboardProfilesFeature: wire calls for getDescription, get/set mode, get/set active profile, memoryRead, and directory parsing. Non-exhaustive OnboardMode handled conservatively. Directory read correctly passes total_profiles (user + ROM), fixing the previous ROM-profile omission.
crates/openlogi-hid/src/write.rs Adds global EXCHANGE Tokio mutex and exchange() helper; with_route now acquires it before opening the channel, serializing all HID++ verbs on all shared-channel and route-open code paths.
crates/openlogi-hid/src/write/onboard_profiles.rs New module: get_onboard_profiles, set_profiles_mode, set_active_profile, apply_profiles_config, and the shared apply_profiles_config_on_channel. Apply logic skips writes when device already matches, logs non-fatal read-back failures, and is correctly called with EXCHANGE held by every caller path.
crates/openlogi-agent-core/src/orchestrator.rs reapply_volatile_settings restructured: remaining writes bundled into an after closure passed to apply_onboard_profiles_in_background, so they run strictly after the profiles apply.
crates/openlogi-agent-core/src/hardware.rs Adds apply_onboard_profiles_in_background (with RunOnDrop ensuring after runs on panic/timeout), apply_onboard_profiles (IPC path), and read_onboard_profiles. Timeout wraps the entire apply.
crates/openlogi-gui/src/components/profiles_panel.rs New Profiles tab: source pills and profile directory pills. Lazy read, optimistic write + confirming re-read, stale-route guard. keep_profile_for correctly handles the host-mode 0x0000 active profile.
crates/openlogi-gui/src/state.rs Adds profiles_data and profiles_pending_confirm alongside the existing smartshift pattern. commit_onboard_profiles writes IPC + config + optimistic update + queues confirming re-read.
crates/openlogi-hid/src/route.rs Adds LIGHTSPEED_PIDS (0xc53f, 0xc547) and speaks_unifying_protocol(); device_route_for now routes Lightspeed receivers via DeviceRoute::Unifying. Tests verify both PIDs produce Unifying routes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Device reconnect] --> B{onboard_profiles\nconfigured?}
    B -- No --> REST
    B -- Yes --> C[spawn OS thread with tokio rt]
    C --> D[acquire EXCHANGE lock]
    D --> E[open 0x8100 feature]
    E --> F[read current mode fn2]
    F --> G{current == target?}
    G -- yes --> H
    G -- no --> I[setOnboardMode fn1]
    I --> J[read-back mode\nnon-fatal on fail]
    J --> H
    H{Onboard AND\nprofile set?}
    H -- no --> K[release EXCHANGE lock]
    H -- yes --> L[getCurrentProfile fn4]
    L --> M{active == target?}
    M -- yes --> K
    M -- no --> N[setCurrentProfile fn3]
    N --> O[read-back profile\nnon-fatal on fail]
    O --> K
    K --> REST[after closure\nscroll+DPI+SmartShift\nown bg threads]
Loading

Reviews (3): Last reviewed commit: "fix(hid): bound the profile directory re..." | Re-trigger Greptile

Comment thread crates/openlogi-hid/src/write/onboard_profiles.rs
Comment thread crates/openlogi-hid/src/write/onboard_profiles.rs
The 0x0100 ROM flag was open-coded in ProfileEntry::is_rom and again in the
CLI's info printer, while hidpp already exports ROM_SECTOR_FLAG. Route both
through openlogi_hid::is_rom_sector, which takes a bare sector so callers
holding only active_profile can ask too.
diag profiles forces the device into onboard mode before the active-profile
round-trip and restores the original mode at the end. Every `?` and `bail!` in
between returned past that restore, leaving the device in onboard mode — which
is exactly when it matters, since a failed run is when the user is least likely
to notice the mouse stopped honouring host settings.

Split the round-trip and the restore into their own functions so the restore
always runs, and report the round-trip error first when both fail.
apply_onboard_profiles_in_background carries the rest of the reconnect volatile
reapply as its `after` continuation, and called it as the last statement of the
spawned thread. A panic on the way there — runtime build, block_on, logging —
dropped every remaining write with nothing to show for it. Run the continuation
from a drop guard instead, which also covers the runtime-init early return.
The pill labels enumerated the enabled-filtered list, so a disabled slot
renumbered every profile after it and the labels stopped lining up with the
slot numbers G HUB shows. Enumerate the directory, then filter.
configured_onboard_profiles was inserted between configured_wheel_mode's
rustdoc and its signature, so the HiResWheel doc described the wrong function
and the wheel helper was left undocumented.

VPID_PAIRS claimed both new Lightspeed receivers were verified against
hardware, but only the G502 X LIGHTSPEED (0xc547) was on the bench; 0xc53f
comes from upstream hidpp PR AprilNEA#388.
configured_onboard_profiles fell back to host mode for any 0x8100 device with
no onboard_profiles config, so the agent switched a gaming mouse out of onboard
mode on every reconnect without being asked. The mode is user-visible state the
mouse changes on its own — a G502 X boots into onboard mode and reports its
active profile only by blinking an LED — so taking it over uninvited silently
overrode the profile the user picked on the device itself.

Return None when nothing is configured and leave the device in whatever mode it
powered on in. A configured mode is still re-asserted on every reconnect, since
the device does not remember host mode.
The 0x8100 feature spans hidpp, hid, agent-core, gui and cli, and its
shape is
dictated by firmware behaviour that is invisible in the code: the device
boots
into onboard mode and forgets host mode, onboard mode rejects DPI writes
while
host mode rejects setCurrentProfile, and the mouse reports its active
profile
only by blinking an LED.

Map the feature across the five crates, then record each constraint
together
with the code decision it forces — why the volatile reapply is a
continuation
rather than parallel writes, why the continuation runs from a drop
guard, and
why a device nobody configured is left alone. Call out the known gaps as
current
state rather than bugs, and keep the hardware questions listed as
unverified so
they are answered on a device instead of reasoned about.

Sources the Logitech setup guide for the device facts and marks bench
observations as observed, since the 0x8100 spec is not public.
configured_onboard_profiles is private, so its rustdoc and
.claude/rules/onboard-profiles.md address the same readers — the consent
rationale was written out twice. Keep the contract in the rustdoc, the argument
in the rule. Same for the firmware facts already documented at memory_read,
DIRECTORY_END and is_rom_sector, and for RunOnDrop restating its own function
doc.
…ching

HID++ correlates a reply to its request only by (device index, feature
index, function, software id), and every verb opens by resolving its
feature with the same 0x0000 getFeature header at the channel's fixed
software id. Two verbs in flight on one device therefore match each
other's replies.

Bench-observed on a G502 X LIGHTSPEED: set_profiles_mode || set_dpi
failed InvalidArgument 3/3 (the DPI payload reaching 0x8100's index),
set_dpi || set_scroll_wheel_mode reported 0x2121 unsupported though the
feature table lists it, and get_dpi || set_profiles_mode returned Ok(0)
-- silently wrong, no error. Sequentially all of them succeed.

An exchange() lock now spans each verb, taken in with_route and in every
*_on shared-channel fast path. All four cases pass after the change.
The description fixture was hand-invented and disagreed with the real
device on two fields: 3 ROM profiles instead of 2, and 256-byte sectors
instead of 255. The sector size is the one that matters -- it feeds the
`sector_size - 16` read bound, so a rounded-up 256 would hide an
off-by-one there.
"Onboard mode rejects host writes with InvalidArgument" was wrong. On a
G502 X LIGHTSPEED, DPI round-trips 1600->1650->1600 in onboard mode, in
host mode, and immediately after a mode write; the rejection that started
this belonged to the concurrent-request cross-talk fixed one commit back.

The reapply still has to run after the onboard-profiles apply, for the
reason that actually holds: activating a profile reloads that profile's
DPI out of flash, so a write that raced ahead of it would be discarded.

Bench facts added: host mode rejects setCurrentProfile and reports active
profile 0x0000 (the flash profile is parked); the mode is volatile,
confirmed by a host-mode device returning onboard after a power cycle;
host mode leaves G7/G8 dead and the LED dark, because OpenLogi writes the
mode byte and none of what the parked profile supplied. The device
exposes no 0x1b04 and no 0x807x lighting, which bounds how far that can
be closed -- and it is a fresh argument for keeping host mode opt-in,
replacing the "configured DPI silently no-ops" cost that does not exist.
parse_directory stops at max_entries even when the terminator has not
been reached, so passing profile_count alone truncates the directory on
any device that lists its ROM profiles after the user ones.

Bounding by profile_count + profile_count_oob costs nothing -- the read
loop already stops early at the terminator -- and the terminator stays
the real end of the directory.

Reported by Greptile on AprilNEA#459. Its worked example does not reproduce: a
G502 X terminates the directory right after the 5 user entries, and
rejects set_current_profile for 0x0101/0x0102/0x0103 with
InvalidArgument, so ROM profiles there are counted in the description but
neither listed nor selectable. The bound is still wrong in principle, and
a device that does list them would lose entries.
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