feat: onboard profiles (0x8100) support for G-series mice - #459
feat: onboard profiles (0x8100) support for G-series mice#459Stanley5249 wants to merge 28 commits into
Conversation
Greptile SummaryThis PR adds full read-and-mode-control support for HID++ 2.0
Confidence Score: 4/5Safe to merge after fixing the ROM-sector omission in the diag command; all other paths are well-guarded and hardware-tested. The diag command's profile_round_trip selects a target profile without filtering ROM sectors, while the GUI and config-apply paths both do. Now that get_onboard_profiles passes the total entry count (user + OOB), a device that lists enabled ROM entries in sector 0 would have set_active_profile called on a ROM sector, which the firmware rejects. The rest of the PR is solid. Files Needing Attention: crates/openlogi-cli/src/cmd/diag/profiles.rs — profile_round_trip target selection needs !e.is_rom() to match the GUI guard.
|
| Filename | Overview |
|---|---|
| crates/openlogi-cli/src/cmd/diag/profiles.rs | New diag command reads description/mode/profile/directory and does mode and profile round-trips. profile_round_trip is missing !e.is_rom() filter, so a device with enabled ROM entries in its directory would cause a firmware-rejected InvalidArgument on set_active_profile. |
| crates/openlogi-hid/src/write/onboard_profiles.rs | HID-layer verbs for 0x8100: get_onboard_profiles correctly passes total_profiles (user+OOB) to read_profile_directory; apply_profiles_config_on_channel skips writes when already matching; read-back warnings are non-fatal. |
| crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs | New 0x8100 feature impl: get_description, mode get/set, profile get/set, memory_read, directory parse. Loop termination and bounds are correct; doc comment on read_profile_directory is stale after the total-profiles fix. |
| crates/openlogi-hidpp/src/feature/onboard_profiles/types.rs | Domain types for 0x8100: OnboardMode, ProfilesDescription, ProfileDirectoryEntry, parse_directory. Wire layout matches libratbag; strict enabled-byte validation is correct; tests cover all edge cases. |
| crates/openlogi-agent-core/src/orchestrator.rs | Reconnect reapply now calls apply_onboard_profiles_in_background before the DPI/SmartShift continuation; configured_onboard_profiles correctly gates on capabilities.onboard_profiles; sequencing prevents flash-profile reload from clobbering a DPI write. |
| crates/openlogi-agent-core/src/hardware.rs | apply_onboard_profiles_in_background uses RunOnDrop to guarantee the continuation fires on panic/early-return; integrates with shared-channel fast path correctly. |
| crates/openlogi-hid/src/write.rs | Introduces global EXCHANGE mutex to serialize HID++ verbs; acknowledged as a stopgap (per-device would be better, and gesture.rs bypasses the lock); all *_on shared-channel helpers now take the lock before delegating. |
| crates/openlogi-gui/src/components/profiles_panel.rs | New profiles tab: source toggle and profile pills. ROM entries are correctly excluded from the selector; optimistic write + confirming re-read pattern matches smartshift_panel; keep_profile_for handles host-mode 0x0000 correctly. |
| crates/openlogi-gui/src/state.rs | LazyDeviceData wiring for profiles follows the established smartshift pattern; commit_onboard_profiles correctly gates the profile write on mode==Onboard in apply_profiles_config_on_channel. |
| crates/openlogi-agent-core/tests/wire_format.rs | PROTOCOL_VERSION bumped to 11; golden bytes for new request variants (SetOnboardProfiles, ReadOnboardProfiles), ProfilesMode, OnboardProfilesInfo, and HidppOperation indices are all pinned. |
Sequence Diagram
sequenceDiagram
participant GUI as GUI (profiles_panel)
participant State as AppState
participant IPC as Agent IPC
participant HW as hardware.rs
participant HID as write/onboard_profiles.rs
participant Dev as Device (0x8100)
GUI->>State: commit_onboard_profiles(mode, profile)
State->>State: optimistic update (Ready to new mode)
State->>IPC: SetOnboardProfiles(route, mode, profile)
State->>State: persist to config.toml
State->>State: profiles_pending_confirm.insert(key)
IPC->>HW: apply_onboard_profiles(capture, route, mode, profile)
HW->>HID: apply_profiles_config_on_channel(channel, index, mode, profile)
HID->>Dev: get_onboard_mode() [read current]
Dev-->>HID: current mode
HID->>Dev: set_onboard_mode(mode) [if changed]
HID->>Dev: get_onboard_mode() [read-back]
HID->>Dev: set_current_profile(sector) [if onboard and profile changed]
HID->>Dev: get_current_profile() [read-back]
HID-->>IPC: Ok(written)
Note over GUI,State: On next render
GUI->>State: ensure_profiles_confirm()
State->>IPC: ReadOnboardProfiles(route)
IPC->>HW: read_onboard_profiles(route)
HW->>HID: get_onboard_profiles(route)
HID->>Dev: get_description() + get_onboard_mode() + get_current_profile() + read_profile_directory(total)
Dev-->>HID: OnboardProfilesInfo
HID-->>State: store_profiles_info(key, route, Ok(info))
State->>GUI: notify (render with real state)
Reviews (10): Last reviewed commit: "feat(hid,hidpp): recognize Lightspeed re..." | Re-trigger Greptile
|
@Stanley5249 Looks as you have issues, with your pipeline running for the following change, can you fix issues and re-push? |
bd4daa2 to
b57c81b
Compare
b57c81b to
be81dd5
Compare
be81dd5 to
10f99f6
Compare
|
@davidbudnick CI is green now, and I've updated the PR body. A few things could use a closer look: the protocol version bump, the translations, and the temporary global lock. Details on each are at the top. Thanks. |
Perfect thanks for updating it, looks as you have a few merge conflicts which need to be resolved before a merge can take place. |
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.
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.
Public `set_dpi_on` linked pub(crate) `exchange`; the onboard-profiles docs gave explicit link targets their labels already resolve to.
The firmware rejects setCurrentProfile on a ROM sector, so a ROM pill was an
action that could not succeed. Filter ROM entries out of the pill list and
never let keep_profile_for pick one; the "ROM profile %{n}" string goes with
them. `diag profiles` still prints ROM entries — showing raw device state is
its job.
The module doc described a host-mode device as having "a dark LED", which conflates a DPI indicator with RGB the reference device does not have; state only the DPI stage list. ProfileSource::Host called itself "the default", which contradicts the agent leaving unconfigured devices alone.
It is config-file only, so the TOML reference was the only place a user could learn it exists.
Protocol facts belong in the onboard_profiles rustdoc, the config key in docs/CONFIGURATION.md; the rule keeps only what an agent could otherwise violate. Drops the device-behaviour diagrams and the bench narrative, and gives the AGENTS.md row path globs like every other row.
6817f7b to
0b6da4b
Compare
Lightspeed receivers route as DeviceRoute::Unifying because they speak the same HID++ 1.0 register protocol, so route_label named them Unifying. Carry the receiver product ID on DeviceRecord and pick the label from it.
0b6da4b to
e42a2c1
Compare
|
Want your agent to iterate on Greptile's feedback? Start a greploop in Codex and it will work through the open comments and keep going until this PR reviews clean. |
The confirm set was only drained for the current device, so a rerouted or removed one left a stale key behind. Mirrors the smartshift cleanup.
|
@davidbudnick Done. Maybe close #388 as well, its commit is carried here with kiwimaker credited. |
Co-authored-by: Abnersouza7 <65561500+Abnersouza7@users.noreply.github.com>
Summary
Adds support for HID++ 2.0 Onboard Profiles (feature
0x8100), the flash profile memory G-series gaming mice use to decide whether they run host software settings or their own stored profiles. Until now0x8100was 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 and mode-control slice: read the memory description, mode, active profile and profile directory; switch between host and onboard mode; and select the active onboard profile. It deliberately does not write flash, so profile editing (functions 6 to 8) is out of scope.
The mode lives in device RAM, so a mouse left in host mode is back in onboard mode after a power cycle. The agent therefore re-applies a configured mode on every reconnect. A device with no configured mode is left in whatever mode it powered on in, and OpenLogi never switches it on the user's behalf.
Scope, and what still needs work
This PR is the wiring: the protocol wrapper, the I/O verbs, the config key, a panel to toggle the mode and pick a profile, and enough receiver support to reach these mice at all. It is not a finished onboard-profiles feature:
Worth reviewing as the foundation, not as the finished surface.
Changes
feature/onboard_profileswithgetDescription, get/set onboard mode, get/set current profile,memoryRead, and directory parsing over sector 0. Offsets are reverse-engineered against Solaar and libratbag and marked as such; unknown mode andenabledbytes surface asUnsupportedResponserather than silent fallbacks. The directory read is bounded byprofile_count + profile_count_oob, so a device that lists its read-only profiles is not truncated. No flash-write session.ProfilesMode/ProfileEntry/OnboardProfilesInfoand the read/apply verbs;apply_profiles_configskips writes the device already matches. Recognises Lightspeed receivers (0xc53f,0xc547) so a G502 X LIGHTSPEED is reachable at all. Adds theexchange()lock described below.[devices."…".onboard_profiles](mode,profile), config-file only and now documented indocs/CONFIGURATION.md;Capabilities::onboard_profilesfrom a0x8100feature probe.set_onboard_profiles/read_onboard_profilesagent methods and the reconnect re-apply.PROTOCOL_VERSION10 → 11, wire-format goldens regenerated.Capabilities::onboard_profiles, with a settings source (OpenLogi settings / onboard memory) and the active-profile selector. The selector offers user slots only: a device's read-only profiles are factory templates a slot is reset from, and the firmware rejectssetCurrentProfileon one, so offering them would be an action that cannot succeed.diag profilesstill prints them, since showing raw device state is its job.openlogi diag profilesprints the state and runs a mode/profile round-trip (--read-only,--leave-onboard).Things worth a careful look
PROTOCOL_VERSION10 → 11.ProfilesModeandProfileEntrycross the agent↔GUI IPC, so their variant and field order are wire format.The global lock is a stopgap, not the fix.
send_v20matches a reply to its request by comparing HID++ headers, and the channel is built withrotate_software_id: false, so two concurrent requests to one device carry byte-identical headers and can take each other's replies. Bench-observed on a G502 X: a DPI write racing a mode write failsInvalidArgument3/3 with the DPI payload landing on0x8100's feature index, andget_dpiracing a mode write returnsOk(0), silently wrong with no error.write::exchange()serializes verbs to stop it, but the root fix isHidppChannel::set_rotating_sw_id(true). The lock is global rather than per-device and is held acrossopen_route_channel(), so it serializes enumeration for unrelated devices too, which is heavier than the problem warrants. Rotating the software id is left out of this PR deliberately: it changes the vendored channel for every device and every feature, so it wants the bench cases re-run on hardware rather than riding along here. Note also thatgesture.rs::run_capture_sessionopens a channel outside the lock.Lightspeed receivers route as
DeviceRoute::Unifying. They speak the same HID++ 1.0 register protocol, so they are enumerated, routed and paired through the Unifying path, and onlyreceiver_display_nametells them apart. A dedicated route variant would fork every match arm for what is currently a cosmetic difference, so it seems worth revisiting only if their pairing register semantics turn out to diverge.Three new UI strings are English placeholders in all 19 non-English locales, for Crowdin to fill.
The device renders as a silhouette. The pinned asset catalog has no G502 X or X-generation entry (#461), so the panel shows generic art and hotspots.
Testing
Hardware: G502 X LIGHTSPEED over a Lightspeed receiver, on Windows.
Verified on hardware:
diag profilesreads the description (5 user + 2 read-only profiles, 11 buttons, 16 × 255 B sectors), the mode, the active profile and the directory. The strict 0/1enabledparse holds on real flash, and the unit fixture carries this captured payload.mode = onboard, profile = 2.setCurrentProfileis onboard-mode only. Host mode answersInvalidArgumentand reports the active profile as0x0000, since it parks the flash profile.0x0002active, after a power cycle.1600 → 1650 → 1600round-trips while onboard, while in host mode, and immediately after a mode write.Not verified:
setCurrentProfilerejects0x0101to0x0103, so nothing here exercises them. A device that does list them would be the first real test.Notes
Depends on #388 (Lightspeed receiver recognition). Its commit is carried here so the branch builds and the hardware test runs; the patch-ids match, so it drops out on rebase once #388 merges.
Screenshots