feat: onboard profiles (0x8100) support for G-series mice - #459
feat: onboard profiles (0x8100) support for G-series mice#459Stanley5249 wants to merge 21 commits into
Conversation
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 SummaryThis PR adds end-to-end HID++ 2.0
Confidence Score: 5/5Safe 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.
|
| 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]
Reviews (3): Last reviewed commit: "fix(hid): bound the profile directory re..." | Re-trigger Greptile
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.
Summary
Adds end-to-end support for HID++ 2.0 Onboard Profiles (feature
0x8100), the switchgaming mice (G502 X LIGHTSPEED, G305, G602, …) use to decide whether they run host software
settings or their own flash-stored profiles. Until now
0x8100was a name-only row in thefeature 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.
Changes
feature/onboard_profilesmodule —getDescription, get/set onboardmode, 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.ProfilesMode/ProfileEntry/OnboardProfilesInfotypes and theread/apply verbs.
apply_profiles_configskips writes the device already matches andtolerates transient post-mode-switch read-back failures (observed on hardware).
Also: an
exchange()lock serializing every device verb — see below.[devices."…".onboard_profiles]config (mode,profile),config-file-only;
Capabilities::onboard_profilesfrom a0x8100feature probe.set_onboard_profiles/read_onboard_profilesagent methods; the reconnectreapply.
PROTOCOL_VERSION10 → 11, wire-format goldens regenerated.Capabilities::onboard_profiles) — Settings source(OpenLogi settings / Onboard memory) and the active onboard-profile selector.
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
0x8100device is left in whatever mode it powered on in. The mousesignals 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 same0x0000 getFeatureheader at the channel's fixed software id. Two verbs in flight on onedevice therefore match each other's replies. Bench-observed on a G502 X:
set_profiles_mode‖set_dpiInvalidArgument, 3/3 — the DPI payload reaches0x8100's indexset_dpi‖set_scroll_wheel_mode0x2121reported unsupported, though the feature table lists itget_dpi‖get_onboard_profilesUnsupportedResponseget_dpi‖set_profiles_modeOk(0)— silently wrong, no errorRun sequentially, all of them succeed. An
exchange()lock now spans each verb, taken inwith_routeand in every*_onshared-channel fast path; all four cases pass after it.This predates onboard profiles — the same fan-out existed in the reconnect reapply — but
0x8100is 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.
Verified on hardware:
diag profilesreads the description (5 user + 2 ROM profiles, 11 buttons,16 × 255 B sectors), mode, active profile, and the directory; the strict 0/1
enabledparse holds on real flash. The unit fixture now carries this captured payload.
mode = onboard, profile = 2.setCurrentProfileis onboard-mode-only — host mode answersInvalidArgument. Hostmode also reports active profile
0x0000, since it parks the flash profile.0x0002active again, after a power cycle.1600 → 1650 → 1600round-trips whileonboard, while in host mode, and immediately after a mode write.
yet sector 0's directory terminates right after the 5 user entries (reading with the
corrected
profile_count + profile_count_oobbound still returns 5), andsetCurrentProfilerejects0x0101/0x0102/0x0103withInvalidArgument. Thedirectory bound was fixed anyway — it is wrong for the format, and a device that does
list ROM entries would lose them — but
is_rom_sectorand the GUI'sentry.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:
InvalidArgumentbehind that claim came from the concurrency bug above — the DPI writewas reaching
0x8100's feature index. The consent policy's stated cost ("configured DPIsilently no-ops on a mouse sitting in onboard mode") therefore does not exist.
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(noHID++ button remapping — the OS hook is the only path) and no
0x807x/0x8081lightingat 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
Win32_System_IOforwindows-sys) and feat(hid): recognise Lightspeed nano receivers (G-series, e.g. G305) #388 (Lightspeed receiverrecognition) — both included here so the branch builds and the hardware test runs. Their
commits carry identical patch-ids and drop out on rebase once those merge.