diff --git a/.claude/rules/onboard-profiles.md b/.claude/rules/onboard-profiles.md
new file mode 100644
index 00000000..3934bb52
--- /dev/null
+++ b/.claude/rules/onboard-profiles.md
@@ -0,0 +1,227 @@
+---
+paths:
+ - "crates/openlogi-hidpp/src/feature/onboard_profiles/**"
+ - "crates/openlogi-hid/src/onboard_profiles.rs"
+ - "crates/openlogi-hid/src/write/onboard_profiles.rs"
+ - "crates/openlogi-agent-core/src/orchestrator.rs"
+ - "crates/openlogi-agent-core/src/hardware.rs"
+ - "crates/openlogi-gui/src/components/profiles_panel.rs"
+ - "crates/openlogi-cli/src/cmd/diag/profiles.rs"
+---
+
+# Onboard profiles — HID++ `0x8100`
+
+Gaming mice keep profiles in their own flash and switch between them from
+buttons on the device, with no host involved. That is the whole reason this
+feature is awkward: OpenLogi is not the only writer of the state it displays,
+and the device's own UI for that state is a coloured LED.
+
+The surface spans five crates — read the one you are changing, but the
+sequencing rules below cut across all of them:
+
+- `openlogi-hidpp/src/feature/onboard_profiles/` — the protocol wrapper.
+ `mod.rs` holds the eight endpoint calls, `types.rs` the parsed description,
+ directory entries, `ROM_SECTOR_FLAG` and `DIRECTORY_SECTOR`.
+- `openlogi-hid/src/onboard_profiles.rs` — the IPC-facing types
+ (`ProfilesMode`, `ProfileEntry`, `OnboardProfilesInfo`) and `is_rom_sector`.
+- `openlogi-hid/src/write/onboard_profiles.rs` — the I/O verbs
+ (`get_onboard_profiles`, `set_profiles_mode`, `set_active_profile`,
+ `apply_profiles_config`) and the firmware byte mapping.
+- `openlogi-agent-core/src/orchestrator.rs` — `configured_onboard_profiles`
+ (the consent policy) and `reapply_volatile_settings` (the sequencing).
+- `openlogi-agent-core/src/hardware.rs` —
+ `apply_onboard_profiles_in_background`, the thread that owns the write.
+- `openlogi-gui/src/components/profiles_panel.rs` — the panel.
+- `openlogi-cli/src/cmd/diag/profiles.rs` — the round-trip diagnostic.
+
+Reference device is a G502 X LIGHTSPEED ([setup guide][qsg], pages 8–9); it is
+the only one anyone has run this against. The official `0x8100` spec is not
+public, so several facts below are bench observations, marked as such.
+
+[qsg]: https://www.logitech.com/assets/66193/3/g502-x-artanis-web-qsg.pdf
+
+## What the firmware actually rejects
+
+**Onboard mode does not reject host writes.** Bench-checked on a G502 X LS
+(2026-07-26): DPI round-trips 1600→1650→1600 in onboard mode, in host mode, and
+immediately after a mode write. An earlier note here claimed onboard mode
+answered DPI with `InvalidArgument` — that was a misread of the cross-talk bug
+below. Do not reintroduce it.
+
+**Host mode rejects `set_current_profile`.** `InvalidArgument`, confirmed on the
+same bench run. The active-profile write is legal only inside an onboard-mode
+window, which is why `openlogi diag profiles` enters onboard mode — not as a
+side effect.
+
+**Concurrent verbs on one device get each other's replies.** HID++ correlates a
+reply to its request only by `(device index, feature index, function, software
+id)`, and every verb opens with the same `0x0000 getFeature` header at the
+channel's fixed software id. Two in flight at once therefore cross-match.
+Observed: `set_profiles_mode || set_dpi` → DPI `InvalidArgument` 3/3 (the DPI
+payload lands on `0x8100`'s index); `set_dpi || set_scroll_wheel_mode` → `0x2121`
+reported unsupported though the feature table lists it; `get_dpi ||
+set_profiles_mode` → `Ok(0)`, silently wrong with no error. `openlogi-hid`'s
+`write::exchange()` lock now serializes every verb; anything that opens a channel
+outside `with_route` or the `*_on` fast paths has to take it too.
+
+**Order still matters inside the reapply.** Not because onboard mode blocks
+writes, but because activating a profile reloads that profile's DPI and the rest
+of its stored settings out of flash — a DPI write that landed first would be
+overwritten. That is what the `after` continuation of
+`apply_onboard_profiles_in_background` is for.
+
+**The continuation must outlive a panic.** It carries the entire rest of the
+volatile reapply, so it runs from a drop guard rather than as the last statement
+of the spawned thread. Do not "simplify" it back to a trailing call.
+
+**The mode is volatile — confirmed 2026-07-26.** A G502 X LS left in host mode
+came back in onboard mode after a power cycle, with sector `0x0002` active again.
+So a configured mode has to be re-asserted on every reconnect; never skip the
+reapply because the device reported the right mode in an earlier session.
+
+**Host mode reports active profile `0x0000`.** Not "never activated" — host mode
+parks the flash profile, so `get_current_profile` has nothing to name. `0x0000`
+is not a writable target, which is why the `diag profiles` round-trip skips its
+restore step when it found the device in host mode.
+
+**ROM profiles are counted but not reachable — on this device.** The G502 X
+description reports 2 ROM profiles, yet sector 0's directory terminates right
+after the 5 user entries, and `set_current_profile` rejects `0x0101`, `0x0102`
+and `0x0103` with `InvalidArgument`. So `is_rom_sector` and the GUI's
+`entry.is_rom()` branch are dead paths here — kept because the directory bound
+(`profile_count + profile_count_oob`) is what the format allows, not what this
+one device happens to fill in. Do not "simplify" them away on the strength of a
+single device.
+
+Never re-derive `ROM_SECTOR_FLAG` at a call site — go through
+`openlogi_hid::is_rom_sector`.
+
+## Never switch a device's mode uninvited
+
+`configured_onboard_profiles` returns `None` when the user has not chosen a
+mode, and the device keeps whatever mode it powered on in.
+
+This is not a preference, it is a consequence of the LED being the only feedback
+channel. A host-mode switch the user did not ask for discards the profile they
+selected on the mouse itself, with nothing on screen and nothing on the device
+to explain it. Worse on a G502 X: host mode leaves the DPI stages and the LED
+unset, so G7/G8 stop stepping DPI and the light goes dark until a host writes
+them — G HUB only looks lossless there because it pushes a full configuration
+the moment it takes the mode.
+
+The policy is also cheaper than it used to look. "DPI cannot apply while the
+mouse is onboard" was the reason to want the fallback, and it is false (see
+above) — DPI writes land in either mode. They only survive until the next
+profile switch reloads flash, which is a persistence question, not a blocked
+write, and one the user causes with a button they can see.
+
+```mermaid
+flowchart TD
+ A[device connects / agent reconnects] --> B{feature table has 0x8100?}
+ B -- no --> R[apply whatever IS configured:
DPI · wheel · SmartShift · lighting]
+ B -- yes --> C{config.toml has onboard_profiles
for this device?}
+ C -- yes --> D[re-assert configured mode + profile]
+ C -- no --> N[leave the device alone]
+ D --> R
+ N --> R
+```
+
+## What the device does while you are not looking
+
+```mermaid
+stateDiagram-v2
+ [*] --> Onboard : power on — always, whatever mode it was left in
+
+ state Onboard {
+ direction LR
+ Profile : Active profile · 1 of up to 5 in flash
+ DPI : Active DPI stage · 800 / 1200 / 1600 / 2400 / 3200 · default 1600
+ GShift : G-Shift layer · momentary
+
+ Profile --> Profile : G9 · cycle profile · blinking colour
+ DPI --> DPI : G7 / G8 · DPI down / up · steady colour
+ GShift --> GShift : G6 held · DPI Shift to 800
+ }
+
+ state Host {
+ direction LR
+ HostState : flash profile parked · getCurrentProfile reports 0x0000
+ Degraded : G7/G8 dead · LED dark · until a host writes stages + lighting
+ }
+
+ Onboard --> Host : 0x8100 setOnboardMode(Host)
+ Host --> Onboard : 0x8100 setOnboardMode(Onboard)
+```
+
+G502 X controls: G1/G2/G3 clicks, G4/G5 side, G6 DPI Shift, G7/G8 DPI down/up,
+G9 profile cycle, wheel tilt L/R — 13 programmable; the wheel-mode toggle and
+the power switch are not. Factory profiles are MAIN "GAMING" (1 ms report rate,
+G6 = DPI Shift to 800) and SECONDARY "PRODUCTIVITY" (2 ms, G6 = G-Shift layer),
+both on DPI steps 800/1200/1600/2400/3200 at 1600 default, coloured 1 White,
+2 Orange, 3 Teal, 4 Yellow, 5 Magenta. Up to 5 profiles unlock in G HUB.
+
+Per the guide: _"DPI change is expressed by different steady colors, while
+profile change is displayed by different blinking colors."_
+
+## Reading state back — currently one-shot, by omission
+
+The GUI reads onboard-profiles state **once** per device
+(`profiles_panel.rs`, `ensure_profiles_load`), and nothing anywhere in
+`openlogi-hid` or the agent subscribes to HID++ feature events. So a profile
+cycled with G9 is invisible until the panel is retried. This is current state,
+not a bug to re-report — fix it or leave it.
+
+Closing it means adding event support to the fork: `0x8100` has none, and the
+`ParametersChanged` decoder that does exist is on `0x2202`, which the reference
+device does not expose (it reports `0x2201 v2`).
+
+## Wire format
+
+`ProfilesMode` and `ProfileEntry` cross the agent↔GUI IPC, so variant order and
+field order are wire format: changes need a `PROTOCOL_VERSION` bump and
+regenerated goldens (`.claude/rules/ipc-protocol.md`). The GUI panel gates on
+`Capabilities::onboard_profiles`, never on device `kind`.
+
+## Build & verify
+
+`openlogi diag profiles` is the round-trip and the fastest proof a change works:
+it reads the description and directory, enters onboard mode, sets and restores
+the active profile, then restores the original mode — on the error path too.
+`--read-only` skips every write; `--leave-onboard` skips the final mode restore,
+which is how you set the device up to test the agent's reapply on reconnect.
+
+Unit tests cover the parsers (`onboard_profiles/tests.rs`) and the ROM flag, and
+they run everywhere. Nothing else here is testable without hardware: the whole
+feature is firmware behaviour. Real-device verification is the maintainer's job
+— state plainly what you did not test rather than implying coverage.
+
+## Host mode is not free — the host inherits the profile's job
+
+Maintainer observation on a G502 X LS, 2026-07-26: in the host mode OpenLogi
+writes, **G7/G8 no longer step DPI and the LED is dark**. In G HUB's host mode
+both still work. The difference is not the mode, it is that OpenLogi writes the
+mode and nothing else — the DPI stage list, the lighting, and the button
+assignments all came from the flash profile that host mode just parked.
+
+So host mode is only usable once OpenLogi can supply those itself, and the
+feature table says how far that can go. Present and unwrapped: `0x8060` (report
+rate), `0x8110` (button spy — the likely way a host watches G7/G8 without
+`0x1b04`, unverified). Absent entirely: **`0x1b04`**, so HID++ button remapping
+is impossible here and the OS hook is the only path — and **any `0x807x`/`0x8081`
+lighting feature**, so nothing OpenLogi can send will light the LED while the
+profile that owns it is parked. How G HUB lights it in host mode is unexplained;
+do not assume a feature that is not in the table.
+
+Until that is closed, host mode is a downgrade on this device. Keep it opt-in and
+say so in the UI rather than making it the default.
+
+## Unverified — resolve on hardware, do not reason about
+
+- Whether G9 still cycles profiles in host mode (G7/G8 and the LED are answered
+ above; G9 was not separately checked).
+- Where the DPI stage list lives in host mode. Note the reference device exposes
+ `0x2201 v2`, **not** `0x2202` — any plan that leans on `0x2202 getSensorDpiList`
+ or its `ParametersChanged` event does not apply to a G502 X.
+- Every gaming mouse that is not a G502 X. The behaviour above is generalised
+ from a single device; a second device's quirks are new facts to record here,
+ not bugs to fix.
diff --git a/AGENTS.md b/AGENTS.md
index 9ac57ecb..f013fec5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -159,6 +159,7 @@ before editing that area.
| `crates/openlogi-gui/locales/**`, `src/i18n.rs` | `.claude/rules/i18n.md` |
| `crates/openlogi-agent-core/**`, `crates/openlogi-agent/**` (IPC wire) | `.claude/rules/ipc-protocol.md` |
| `crates/openlogi-hidpp/**`, `crates/openlogi-hid/**` | `.claude/rules/hidpp.md` |
+| Onboard profiles (`0x8100`) across hidpp/hid/agent/gui/cli | `.claude/rules/onboard-profiles.md` |
| `crates/openlogi-hook/**` (event taps) | `.claude/rules/hook.md` |
| `xtask/**`, `packaging/**`, `scripts/**` | `.claude/rules/xtask.md` (+ `xtask/README.md`) |
| `crates/openlogi-gui/src/platform/**` (ObjC FFI) | `crates/openlogi-gui/src/platform/AGENTS.md` |
diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs
index 758c0f10..31b9edcc 100644
--- a/crates/openlogi-agent-core/src/hardware.rs
+++ b/crates/openlogi-agent-core/src/hardware.rs
@@ -17,8 +17,9 @@ use std::time::Duration;
use openlogi_core::config::Lighting;
use openlogi_hid::{
- CaptureChannel, DeviceRoute, DpiInfo, HidppFeatureErrorKind, HidppOperation, ScrollResolution,
- SharedChannel, SmartShiftMode, SmartShiftStatus, WriteError,
+ CaptureChannel, DeviceRoute, DpiInfo, HidppFeatureErrorKind, HidppOperation,
+ OnboardProfilesInfo, ProfilesMode, ScrollResolution, SharedChannel, SmartShiftMode,
+ SmartShiftStatus, WriteError,
};
use tracing::{debug, warn};
@@ -382,6 +383,94 @@ pub fn write_scroll_wheel_mode_in_background(
});
}
+/// Spawn an OS thread that applies the configured onboard-profiles mode (and,
+/// in onboard mode, the active profile) to the gaming device at `target`,
+/// then runs `after` on the same thread.
+///
+/// `after` runs regardless of the apply's outcome — including a panic, since
+/// the whole rest of the reconnect reapply rides on it. It exists because
+/// activating an onboard profile reloads that profile's DPI (and the rest of
+/// its stored settings) out of flash, which would overwrite a configured DPI
+/// that had already landed — so the reapply passes its remaining writes as this
+/// continuation rather than starting them in parallel threads.
+///
+/// Devices without HID++ `0x8100` are expected and only logged at debug level
+/// — this fires for every reconnecting device, most of which have no onboard
+/// profile memory. The write path short-circuits when the device already
+/// matches, so a reapply on an already-configured device costs one or two
+/// reads.
+pub fn apply_onboard_profiles_in_background(
+ capture: Option<&CaptureChannel>,
+ target: Option,
+ mode: ProfilesMode,
+ profile: Option,
+ after: impl FnOnce() + Send + 'static,
+) {
+ let Some(target) = target else {
+ debug!(?mode, "no target device — onboard-profiles apply skipped");
+ after();
+ return;
+ };
+ let shared = reusable_channel(capture, &target);
+ let reused = shared.is_some();
+ std::thread::spawn(move || {
+ let _continuation = RunOnDrop(Some(after));
+ let rt = match tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ {
+ Ok(rt) => rt,
+ Err(e) => {
+ warn!(error = %e, "tokio runtime init failed; onboard-profiles apply skipped");
+ return;
+ }
+ };
+ let result = rt.block_on(async {
+ tokio::time::timeout(WRITE_BUDGET, async {
+ match &shared {
+ Some(shared) => {
+ openlogi_hid::apply_profiles_config_on(shared, mode, profile).await
+ }
+ None => openlogi_hid::apply_profiles_config(&target, mode, profile).await,
+ }
+ })
+ .await
+ });
+ let index = target.device_index();
+ match result {
+ Ok(Ok(written)) => debug!(
+ index,
+ ?mode,
+ ?profile,
+ written,
+ reused,
+ "onboard-profiles config applied"
+ ),
+ Ok(Err(WriteError::FeatureUnsupported { feature_hex })) => debug!(
+ index,
+ feature = format_args!("{feature_hex:#06x}"),
+ "no onboard profile memory — profiles apply not applicable"
+ ),
+ Ok(Err(e)) => warn!(error = ?e, "onboard-profiles apply failed"),
+ Err(_) => warn!(
+ index,
+ "onboard-profiles apply timed out (device asleep/unresponsive)"
+ ),
+ }
+ });
+}
+
+/// Runs the closure on drop, so an early return or panic can't swallow it.
+struct RunOnDrop(Option);
+
+impl Drop for RunOnDrop {
+ fn drop(&mut self) {
+ if let Some(f) = self.0.take() {
+ f();
+ }
+ }
+}
+
/// Apply `lighting` to the keyboard at `target` on a background thread.
///
/// Resolves the configured colour (scaled by brightness, or black when the
@@ -501,6 +590,34 @@ pub async fn read_smartshift(route: &DeviceRoute) -> Result,
+) -> Result<(), WriteError> {
+ let shared = reusable_channel(Some(capture), route);
+ timed(HidppOperation::WriteOnboardProfiles, async {
+ match &shared {
+ Some(shared) => openlogi_hid::apply_profiles_config_on(shared, mode, profile).await,
+ None => openlogi_hid::apply_profiles_config(route, mode, profile).await,
+ }
+ })
+ .await
+ .map(|_written| ())
+}
+
+/// Read the onboard-profiles state (description, mode, active profile,
+/// directory) from `route`.
+pub async fn read_onboard_profiles(route: &DeviceRoute) -> Result {
+ timed(
+ HidppOperation::ReadOnboardProfiles,
+ openlogi_hid::get_onboard_profiles(route),
+ )
+ .await
+}
+
/// Bound any single HID++ call by [`WRITE_BUDGET`] so an asleep / unresponsive
/// device can't hang the awaiting IPC handler indefinitely.
async fn timed(
diff --git a/crates/openlogi-agent-core/src/ipc.rs b/crates/openlogi-agent-core/src/ipc.rs
index 9acdc281..7caf1f9d 100644
--- a/crates/openlogi-agent-core/src/ipc.rs
+++ b/crates/openlogi-agent-core/src/ipc.rs
@@ -9,8 +9,8 @@
use openlogi_core::config::Lighting;
use openlogi_core::device::DeviceInventory;
use openlogi_hid::{
- DeviceRoute, DpiInfo, PairingError, PasskeyMethod, ReceiverSelector, SmartShiftMode,
- SmartShiftStatus, WriteError,
+ DeviceRoute, DpiInfo, OnboardProfilesInfo, PairingError, PasskeyMethod, ProfilesMode,
+ ReceiverSelector, SmartShiftMode, SmartShiftStatus, WriteError,
};
use serde::{Deserialize, Serialize};
@@ -28,7 +28,10 @@ use serde::{Deserialize, Serialize};
/// v8: [`WriteError`] carries typed HID++ operation failures.
/// v9: `poll_event_monitor` appended + [`MonitorEvent`] (live event monitor).
/// v10: `Capabilities::hires_wheel` appended.
-pub const PROTOCOL_VERSION: u32 = 10;
+/// v11: `set_onboard_profiles` / `read_onboard_profiles` appended,
+/// `Capabilities::onboard_profiles` appended, `HidppOperation` gains
+/// `ReadOnboardProfiles` / `WriteOnboardProfiles`.
+pub const PROTOCOL_VERSION: u32 = 11;
/// Where the agent's device enumeration stands. The distinction matters
/// because an empty inventory list is ambiguous on its own: the GUI must keep
@@ -272,7 +275,18 @@ pub trait Agent {
/// Drain the events the hook has observed since the last poll, for the GUI's
/// live event monitor. The first poll enables monitoring; the agent
/// auto-disables it once polls stop (the GUI closed the panel or died), so
- /// there is no explicit stop. Appended last — see the method-order note on
- /// [`Agent::protocol_version`].
+ /// there is no explicit stop.
async fn poll_event_monitor() -> Vec;
+ /// Apply an onboard-profiles config to `route` now: host mode (OpenLogi
+ /// drives the device) or onboard mode with an optional profile sector to
+ /// activate. Appended for protocol v11.
+ async fn set_onboard_profiles(
+ route: DeviceRoute,
+ mode: ProfilesMode,
+ profile: Option,
+ ) -> Result<(), WriteError>;
+ /// Read the onboard-profiles state (description, mode, active profile,
+ /// directory) from `route`. Appended last — see the method-order note on
+ /// [`Agent::protocol_version`].
+ async fn read_onboard_profiles(route: DeviceRoute) -> Result;
}
diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs
index a3c26fdd..0878de1f 100644
--- a/crates/openlogi-agent-core/src/orchestrator.rs
+++ b/crates/openlogi-agent-core/src/orchestrator.rs
@@ -14,9 +14,9 @@ use std::collections::{BTreeMap, HashSet};
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::{Arc, RwLock};
-use openlogi_core::config::{Config, ScrollResolution};
+use openlogi_core::config::{Config, ProfileSource, ScrollResolution};
use openlogi_core::device::{Capabilities, DeviceInventory};
-use openlogi_hid::{CaptureChannel, DeviceRoute};
+use openlogi_hid::{CaptureChannel, DeviceRoute, ProfilesMode};
use tracing::warn;
use crate::DpiCycleState;
@@ -199,10 +199,10 @@ impl Orchestrator {
self.inventory = InventoryState::Ready(inventories.to_vec());
let devices = build_devices(inventories);
// Volatile settings (lighting colour, sensor DPI, SmartShift, native
- // wheel mode) live in device RAM and reset on a power cycle. Every
- // reconnect shape re-applies the persisted values (#189): a first
- // sighting, a replug (new route), a wake from device sleep
- // (offline→online), or — via the
+ // wheel mode, onboard-profiles host mode) live in device RAM and reset
+ // on a power cycle. Every reconnect shape re-applies the persisted
+ // values (#189): a first sighting, a replug (new route), a wake from
+ // device sleep (offline→online), or — via the
// flag — a system wake where none of those are observable.
let reapply_all = std::mem::take(&mut self.reapply_all_next_refresh);
let followup = std::mem::take(&mut self.reapply_followup);
@@ -238,40 +238,60 @@ impl Orchestrator {
self.reapply_all_next_refresh = true;
}
- /// Push the persisted volatile settings (lighting, sensor DPI, SmartShift,
- /// native wheel mode) to one device, fire-and-forget on background threads.
- /// Reuses the capture session's channel when it already points at the
- /// device, like every other hardware write.
+ /// Push the persisted volatile settings (onboard-profiles mode, lighting,
+ /// sensor DPI, SmartShift, native wheel mode) to one device, fire-and-forget
+ /// on background threads. Reuses the capture session's channel when it
+ /// already points at the device, like every other hardware write.
fn reapply_volatile_settings(&self, dev: &AgentDevice) {
let Some(route) = dev.route.clone() else {
return;
};
let key = &dev.config_key;
+
+ // Resolve every configured value up front, then bundle the writes so
+ // they can run either immediately (no 0x8100) or strictly *after* the
+ // onboard-profiles apply: activating a profile reloads that profile's
+ // stored DPI from flash, so a DPI write that raced ahead of it would be
+ // silently discarded.
let (resolution, inverted) = configured_wheel_mode(&self.config, dev);
- crate::hardware::write_scroll_wheel_mode_in_background(
- Some(&self.shared.capture_channel),
- (resolution.is_some() || inverted.is_some()).then_some(route.clone()),
- resolution,
- inverted,
- );
- if let Some(lighting) = self.config.lighting(key).filter(|l| l.enabled) {
- crate::hardware::set_lighting_in_background(Some(route.clone()), &lighting);
- }
- if let Some(dpi) = self.config.dpi(key) {
- crate::hardware::write_dpi_in_background(
- Some(&self.shared.capture_channel),
- Some(route.clone()),
- dpi,
+ let lighting = self.config.lighting(key).filter(|l| l.enabled);
+ let dpi = self.config.dpi(key);
+ let smartshift = self.config.smartshift(key);
+ let capture = Arc::clone(&self.shared.capture_channel);
+ let profiles_route = route.clone();
+ let rest = move || {
+ crate::hardware::write_scroll_wheel_mode_in_background(
+ Some(&capture),
+ (resolution.is_some() || inverted.is_some()).then_some(route.clone()),
+ resolution,
+ inverted,
);
- }
- if let Some(smartshift) = self.config.smartshift(key) {
- crate::hardware::write_smartshift_in_background(
+ if let Some(lighting) = lighting {
+ crate::hardware::set_lighting_in_background(Some(route.clone()), &lighting);
+ }
+ if let Some(dpi) = dpi {
+ crate::hardware::write_dpi_in_background(Some(&capture), Some(route.clone()), dpi);
+ }
+ if let Some(smartshift) = smartshift {
+ crate::hardware::write_smartshift_in_background(
+ Some(&capture),
+ Some(route),
+ smartshift.mode.into(),
+ smartshift.auto_disengage,
+ smartshift.tunable_torque,
+ );
+ }
+ };
+
+ match configured_onboard_profiles(&self.config, dev) {
+ Some((mode, profile)) => crate::hardware::apply_onboard_profiles_in_background(
Some(&self.shared.capture_channel),
- Some(route),
- smartshift.mode.into(),
- smartshift.auto_disengage,
- smartshift.tunable_torque,
- );
+ Some(profiles_route),
+ mode,
+ profile,
+ rest,
+ ),
+ None => rest(),
}
}
@@ -364,6 +384,29 @@ impl Orchestrator {
}
}
+/// The onboard-profiles mode + profile to apply to `dev`, or `None` when there
+/// is nothing to assert — no `0x8100` in the measured feature table (or it was
+/// never probed), or the user has never chosen a mode for this device.
+///
+/// The mode is device state the user changes from the mouse itself, so OpenLogi
+/// never takes it uninvited. See `.claude/rules/onboard-profiles.md`.
+fn configured_onboard_profiles(
+ config: &Config,
+ dev: &AgentDevice,
+) -> Option<(ProfilesMode, Option)> {
+ if !dev.capabilities?.onboard_profiles {
+ return None;
+ }
+ let cfg = config.onboard_profiles(&dev.config_key)?;
+ Some((
+ match cfg.mode {
+ ProfileSource::Host => ProfilesMode::Host,
+ ProfileSource::Onboard => ProfilesMode::Onboard,
+ },
+ cfg.profile,
+ ))
+}
+
/// Resolve the two independently-gated HiResWheel settings for one device.
/// `None` means preserve the device's current value.
fn configured_wheel_mode(
diff --git a/crates/openlogi-agent-core/tests/wire_format.rs b/crates/openlogi-agent-core/tests/wire_format.rs
index c5b14e4c..582324e6 100644
--- a/crates/openlogi-agent-core/tests/wire_format.rs
+++ b/crates/openlogi-agent-core/tests/wire_format.rs
@@ -33,7 +33,8 @@ use openlogi_core::device::{
};
use openlogi_hid::{
Click, DeviceRoute, DpiCapabilities, DpiInfo, HidppFeatureErrorKind, HidppOperation,
- PasskeyMethod, ReceiverSelector, SmartShiftMode, SmartShiftStatus, WriteError,
+ OnboardProfilesInfo, PasskeyMethod, ProfileEntry, ProfilesMode, ReceiverSelector,
+ SmartShiftMode, SmartShiftStatus, WriteError,
};
/// Serialize exactly as the transport does (`tokio_serde::formats::Bincode`
@@ -61,7 +62,7 @@ fn assert_wire(value: &T, golden: &str) {
/// that makes that visible in the same diff.
#[test]
fn protocol_version_is_pinned() {
- assert_eq!(PROTOCOL_VERSION, 10);
+ assert_eq!(PROTOCOL_VERSION, 11);
}
/// tarpc encodes the request enum's variant index, so trait *method order* is
@@ -83,6 +84,26 @@ fn request_variant_order() {
assert_wire(&AgentRequest::NextPairing {}, "0d");
assert_wire(&AgentRequest::Snapshot {}, "0e");
assert_wire(&AgentRequest::PollEventMonitor {}, "0f");
+ assert_wire(
+ &AgentRequest::SetOnboardProfiles {
+ route: DeviceRoute::Bolt {
+ receiver_uid: "F00DCAFE".into(),
+ slot: 1,
+ },
+ mode: ProfilesMode::Host,
+ profile: Some(2),
+ },
+ "100008463030444341464501000102",
+ );
+ assert_wire(
+ &AgentRequest::ReadOnboardProfiles {
+ route: DeviceRoute::Bolt {
+ receiver_uid: "F00DCAFE".into(),
+ slot: 1,
+ },
+ },
+ "110008463030444341464501",
+ );
}
#[test]
@@ -178,12 +199,13 @@ fn device_inventory() {
lighting: false,
scroll_inversion: false,
hires_wheel: true,
+ onboard_profiles: false,
}),
}],
}];
assert_wire(
&inventory,
- "010d426f6c74205265636569766572fb6d04fb48c501084630304443414645010101094d58204d535452335301fb34b000010150020001030106323134304c5a0102030400010100fb34b0fb8240000b010101000001",
+ "010d426f6c74205265636569766572fb6d04fb48c501084630304443414645010101094d58204d535452335301fb34b000010150020001030106323134304c5a0102030400010100fb34b0fb8240000b01010100000100",
);
}
@@ -291,4 +313,48 @@ fn device_settings_payloads() {
&ReceiverSelector::BoltUid("F00DCAFE".into()),
"01084630304443414645",
);
+
+ // HidppOperation variants appended for v11 — their indices are pinned so a
+ // later insertion above them fails loudly.
+ assert_wire(
+ &WriteError::RequestTimedOut {
+ operation: HidppOperation::ReadOnboardProfiles,
+ },
+ "080a",
+ );
+ assert_wire(
+ &WriteError::RequestTimedOut {
+ operation: HidppOperation::WriteOnboardProfiles,
+ },
+ "080b",
+ );
+
+ // serde encodes ProfilesMode's variant *index* (Host=0, Onboard=1); there
+ // is no firmware discriminant on this type by design.
+ assert_wire(&ProfilesMode::Host, "00");
+ assert_wire(&ProfilesMode::Onboard, "01");
+
+ let profiles: Result = Ok(OnboardProfilesInfo {
+ profile_count: 5,
+ profile_count_oob: 3,
+ button_count: 11,
+ sector_count: 16,
+ sector_size: 256,
+ memory_model_id: 1,
+ profile_format_id: 2,
+ macro_format_id: 3,
+ mode: ProfilesMode::Onboard,
+ active_profile: 2,
+ directory: vec![
+ ProfileEntry {
+ sector: 1,
+ enabled: true,
+ },
+ ProfileEntry {
+ sector: 2,
+ enabled: false,
+ },
+ ],
+ });
+ assert_wire(&profiles, "0005030b10fb000101020301020201010200");
}
diff --git a/crates/openlogi-agent/src/server.rs b/crates/openlogi-agent/src/server.rs
index 4e69d75b..27fa194b 100644
--- a/crates/openlogi-agent/src/server.rs
+++ b/crates/openlogi-agent/src/server.rs
@@ -19,7 +19,8 @@ use openlogi_agent_core::{hardware, transport};
use openlogi_core::config::{Config, Lighting};
use openlogi_core::device::DeviceInventory;
use openlogi_hid::{
- DeviceRoute, DpiInfo, ReceiverSelector, SmartShiftMode, SmartShiftStatus, WriteError,
+ DeviceRoute, DpiInfo, OnboardProfilesInfo, ProfilesMode, ReceiverSelector, SmartShiftMode,
+ SmartShiftStatus, WriteError,
};
use crate::pairing::PairingManager;
@@ -171,6 +172,24 @@ impl Agent for AgentServer {
async fn poll_event_monitor(self, _: Context) -> Vec {
self.event_monitor.poll()
}
+
+ async fn set_onboard_profiles(
+ self,
+ _: Context,
+ route: DeviceRoute,
+ mode: ProfilesMode,
+ profile: Option,
+ ) -> Result<(), WriteError> {
+ hardware::apply_onboard_profiles(&self.shared.capture_channel, &route, mode, profile).await
+ }
+
+ async fn read_onboard_profiles(
+ self,
+ _: Context,
+ route: DeviceRoute,
+ ) -> Result {
+ hardware::read_onboard_profiles(&route).await
+ }
}
/// Bind the agent's IPC socket and serve [`Agent`] requests until the process
diff --git a/crates/openlogi-cli/src/cmd/diag.rs b/crates/openlogi-cli/src/cmd/diag.rs
index 3a9e0a74..d68d794b 100644
--- a/crates/openlogi-cli/src/cmd/diag.rs
+++ b/crates/openlogi-cli/src/cmd/diag.rs
@@ -14,6 +14,7 @@ pub mod controls;
pub mod dpi;
pub mod features;
pub mod lighting;
+pub mod profiles;
pub mod smartshift;
pub mod wheel;
@@ -31,6 +32,8 @@ pub enum DiagCmd {
Lighting(lighting::LightingArgs),
/// Read or set the HID++ 0x2121 wheel reporting resolution.
Wheel(wheel::WheelArgs),
+ /// Read onboard-profiles state; round-trip the mode and active profile.
+ Profiles(profiles::ProfilesArgs),
}
impl DiagCmd {
@@ -42,6 +45,7 @@ impl DiagCmd {
Self::Smartshift(args) => smartshift::run(args).await,
Self::Lighting(args) => lighting::run(args).await,
Self::Wheel(args) => wheel::run(args).await,
+ Self::Profiles(args) => profiles::run(args).await,
}
}
}
diff --git a/crates/openlogi-cli/src/cmd/diag/profiles.rs b/crates/openlogi-cli/src/cmd/diag/profiles.rs
new file mode 100644
index 00000000..a1105b9c
--- /dev/null
+++ b/crates/openlogi-cli/src/cmd/diag/profiles.rs
@@ -0,0 +1,184 @@
+//! `openlogi diag profiles` — onboard-profiles (HID++ `0x8100`) round-trip.
+
+use anyhow::{Context, Result};
+use clap::Args;
+use openlogi_hid::{DeviceRoute, OnboardProfilesInfo, ProfilesMode};
+
+use crate::cmd::diag::select_device;
+
+#[derive(Debug, Args)]
+pub struct ProfilesArgs {
+ /// Only read and print the onboard-profiles state; skip the mode and
+ /// active-profile write round-trips.
+ #[arg(long, conflicts_with = "leave_onboard")]
+ pub read_only: bool,
+
+ /// Leave the device in onboard mode (skip the final restore to the
+ /// original mode). Useful for verifying the agent's host-mode reapply
+ /// on reconnect, or the onboard behaviour visually.
+ #[arg(long)]
+ pub leave_onboard: bool,
+
+ /// Run against the device whose name contains this string
+ /// (case-insensitive) instead of auto-selecting.
+ #[arg(long, value_name = "NAME")]
+ pub device: Option,
+}
+
+pub async fn run(args: ProfilesArgs) -> Result<()> {
+ // 0x8100 = OnboardProfiles — auto-skip devices without profile memory.
+ let (route, name) = select_device(args.device.as_deref(), &[0x8100]).await?;
+ println!("device: {name} ({route})");
+
+ let info = openlogi_hid::get_onboard_profiles(&route)
+ .await
+ .context("read onboard-profiles state")?;
+ print_info(&info);
+
+ if args.read_only {
+ return Ok(());
+ }
+
+ // Enter onboard mode first: the firmware rejects setCurrentProfile with
+ // InvalidArgument while in host mode (verified on a G502 X LIGHTSPEED),
+ // so the profile round-trip must run inside the onboard-mode window. This
+ // also exercises the mode write path in both directions.
+ if info.mode != ProfilesMode::Onboard {
+ println!(" entering mode: {:?} -> Onboard", info.mode);
+ let read_back = openlogi_hid::set_profiles_mode(&route, ProfilesMode::Onboard)
+ .await
+ .context("write onboard mode")?;
+ if read_back != ProfilesMode::Onboard {
+ anyhow::bail!(
+ "onboard mode write not applied: requested Onboard, device reports {read_back:?}"
+ );
+ }
+ }
+
+ let round_trip = profile_round_trip(&route, &info).await;
+
+ if args.leave_onboard {
+ round_trip?;
+ println!("✓ onboard-profiles diag OK (device left in Onboard mode)");
+ return Ok(());
+ }
+
+ // Restore the original mode last, so the device leaves the diag exactly as
+ // it entered (host-mode users get host mode back) — including when the
+ // round-trip failed, which is precisely when the device is left changed.
+ let restore = restore_mode(&route, info.mode).await;
+ round_trip?;
+ restore?;
+
+ println!("✓ onboard-profiles diag OK");
+ Ok(())
+}
+
+/// Activate an enabled profile and put the original one back.
+///
+/// Prefers a sector that is not already active; a single-profile device
+/// re-writes the same sector, which still exercises the write path.
+async fn profile_round_trip(route: &DeviceRoute, info: &OnboardProfilesInfo) -> Result<()> {
+ let target = info
+ .directory
+ .iter()
+ .filter(|e| e.enabled)
+ .map(|e| e.sector)
+ .find(|&s| s != info.active_profile)
+ .or_else(|| info.directory.iter().find(|e| e.enabled).map(|e| e.sector));
+ match target {
+ None => {
+ println!(" no enabled profiles in the directory — profile round-trip skipped");
+ }
+ Some(target) => {
+ if target == info.active_profile {
+ println!(
+ " only one enabled profile — set/restore exercised against sector {target:#06x}"
+ );
+ }
+ println!(" activating profile sector {target:#06x}");
+ let read_back = openlogi_hid::set_active_profile(route, target)
+ .await
+ .context("write active profile")?;
+ if read_back != target {
+ anyhow::bail!(
+ "active-profile write not applied: requested {target:#06x}, device reports {read_back:#06x}"
+ );
+ }
+ // Restore, unless nothing was active to begin with — which is what
+ // a device found in host mode reports (0x0000, not a writable
+ // target), since host mode parks the flash profile.
+ if info.active_profile == 0 {
+ println!(" original active profile was 0x0000 (none) — restore skipped");
+ } else if info.active_profile != target {
+ println!(" restoring profile sector {:#06x}", info.active_profile);
+ let restored = openlogi_hid::set_active_profile(route, info.active_profile)
+ .await
+ .context("restore active profile")?;
+ if restored != info.active_profile {
+ anyhow::bail!(
+ "active-profile restore not applied: requested {:#06x}, device reports {restored:#06x}",
+ info.active_profile
+ );
+ }
+ }
+ println!(" ✓ profile round-trip OK");
+ }
+ }
+ Ok(())
+}
+
+/// Put the device back into `mode`. A no-op when the diag never left it.
+async fn restore_mode(route: &DeviceRoute, mode: ProfilesMode) -> Result<()> {
+ if mode == ProfilesMode::Onboard {
+ return Ok(());
+ }
+ println!(" restoring mode: {mode:?}");
+ let restored = openlogi_hid::set_profiles_mode(route, mode)
+ .await
+ .context("restore onboard mode")?;
+ if restored != mode {
+ anyhow::bail!(
+ "onboard mode restore not applied: requested {mode:?}, device reports {restored:?}"
+ );
+ }
+ println!(" ✓ mode round-trip OK");
+ Ok(())
+}
+
+/// Print the description, mode, active profile, and directory table.
+fn print_info(info: &OnboardProfilesInfo) {
+ println!(
+ " memory: {} user + {} ROM profiles, {} buttons, {} sectors x {} bytes",
+ info.profile_count,
+ info.profile_count_oob,
+ info.button_count,
+ info.sector_count,
+ info.sector_size
+ );
+ println!(
+ " formats: memory_model={} profile={} macro={}",
+ info.memory_model_id, info.profile_format_id, info.macro_format_id
+ );
+ println!(" mode: {:?}", info.mode);
+ match info.active_profile {
+ 0 => println!(" active profile: none reported (0x0000)"),
+ sector if openlogi_hid::is_rom_sector(sector) => {
+ println!(" active profile: {sector:#06x} (ROM)");
+ }
+ sector => println!(" active profile: {sector:#06x}"),
+ }
+ if info.directory.is_empty() {
+ println!(" directory: empty (erased flash or no profiles written)");
+ } else {
+ println!(" directory:");
+ for entry in &info.directory {
+ println!(
+ " sector {:#06x} {}{}",
+ entry.sector,
+ if entry.enabled { "enabled" } else { "disabled" },
+ if entry.is_rom() { " (ROM)" } else { "" }
+ );
+ }
+ }
+}
diff --git a/crates/openlogi-cli/src/cmd/list.rs b/crates/openlogi-cli/src/cmd/list.rs
index 963d7f29..d5e7f913 100644
--- a/crates/openlogi-cli/src/cmd/list.rs
+++ b/crates/openlogi-cli/src/cmd/list.rs
@@ -20,8 +20,8 @@ pub async fn run(_args: ListArgs) -> Result<()> {
permission: System Settings → Privacy & Security → Input Monitoring."
);
println!(
- " - hidpp 0.2 only recognises Logi Bolt receivers (PID 0xC548); other \
- receivers (Unifying) aren't surfaced yet."
+ " - Supported receivers: Logi Bolt (0xC548), Unifying (0xC52B/0xC532), \
+ and Lightspeed (0xC53F/0xC547); other receiver PIDs aren't surfaced yet."
);
std::process::exit(2);
}
diff --git a/crates/openlogi-cli/src/lib.rs b/crates/openlogi-cli/src/lib.rs
index 97af9981..b3e4605a 100644
--- a/crates/openlogi-cli/src/lib.rs
+++ b/crates/openlogi-cli/src/lib.rs
@@ -130,6 +130,27 @@ mod tests {
assert!(result.is_err());
}
+ #[test]
+ fn profiles_read_only_and_device_flags_are_mapped() {
+ let cli = Cli::try_parse_from([
+ "openlogi",
+ "diag",
+ "profiles",
+ "--read-only",
+ "--device",
+ "G502",
+ ])
+ .expect("valid profiles invocation parses");
+
+ match cli.cmd.expect("subcommand present") {
+ Command::Diag(DiagCmd::Profiles(args)) => {
+ assert!(args.read_only);
+ assert_eq!(args.device.as_deref(), Some("G502"));
+ }
+ other => panic!("expected Diag(Profiles), got {other:?}"),
+ }
+ }
+
#[test]
fn wheel_resolution_and_device_flags_are_mapped() {
let cli = Cli::try_parse_from([
diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs
index dead22c3..b3b2d6b4 100644
--- a/crates/openlogi-core/src/config.rs
+++ b/crates/openlogi-core/src/config.rs
@@ -22,9 +22,9 @@ mod settings;
pub use device::{DeviceConfig, DeviceIdentity};
pub use settings::{
AppSettings, Appearance, AssetSourcePreference, DEFAULT_THUMBWHEEL_SENSITIVITY, GestureOwner,
- Lighting, MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY,
- SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE, ScrollResolution, SmartShift,
- WheelMode,
+ Lighting, MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY, OnboardProfiles,
+ ProfileSource, SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE,
+ ScrollResolution, SmartShift, WheelMode,
};
use crate::binding::{Action, Binding, ButtonId, GestureDirection, default_binding_for};
@@ -563,6 +563,25 @@ impl Config {
.or_default()
.scroll_resolution = resolution;
}
+
+ /// The onboard-profiles config for `device_key`, or `None` if never set —
+ /// which means the default policy (host mode) applies.
+ #[must_use]
+ pub fn onboard_profiles(&self, device_key: &str) -> Option {
+ self.devices
+ .get(device_key)
+ .and_then(|d| d.onboard_profiles)
+ }
+
+ /// Record the onboard-profiles config for `device_key`, so the agent can
+ /// re-apply it when the device reconnects (the mode reverts to onboard on
+ /// a power cycle).
+ pub fn set_onboard_profiles(&mut self, device_key: &str, profiles: OnboardProfiles) {
+ self.devices
+ .entry(device_key.to_string())
+ .or_default()
+ .onboard_profiles = Some(profiles);
+ }
}
/// Write `bytes` to `path` atomically via a randomized temp file + rename,
@@ -600,6 +619,23 @@ mod tests {
Config::load_from_path(&path).expect("load")
}
+ #[test]
+ fn onboard_profiles_round_trip_through_save_and_load() {
+ let mut cfg = Config::default();
+ assert_eq!(cfg.onboard_profiles("2b042"), None);
+
+ let profiles = OnboardProfiles {
+ mode: ProfileSource::Onboard,
+ profile: Some(2),
+ };
+ cfg.set_onboard_profiles("2b042", profiles);
+
+ let reloaded = write_and_read(&cfg);
+ assert_eq!(reloaded.onboard_profiles("2b042"), Some(profiles));
+ // An unconfigured device stays on the default policy (None → host).
+ assert_eq!(reloaded.onboard_profiles("other"), None);
+ }
+
#[test]
fn missing_file_yields_default() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -895,6 +931,7 @@ mod tests {
lighting: false,
scroll_inversion: false,
hires_wheel: true,
+ onboard_profiles: false,
},
};
cfg.set_device_identity("2b034", mouse.clone());
diff --git a/crates/openlogi-core/src/config/device.rs b/crates/openlogi-core/src/config/device.rs
index 9d73ae37..73b2f638 100644
--- a/crates/openlogi-core/src/config/device.rs
+++ b/crates/openlogi-core/src/config/device.rs
@@ -7,7 +7,8 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::settings::{
- GestureOwner, Lighting, ScrollResolution, SmartShift, deserialize_gesture_owner,
+ GestureOwner, Lighting, OnboardProfiles, ScrollResolution, SmartShift,
+ deserialize_gesture_owner,
};
use crate::binding::{Action, Binding, ButtonId, GestureDirection};
use crate::device::{Capabilities, DeviceKind, DeviceModelInfo};
@@ -113,6 +114,11 @@ pub struct DeviceConfig {
/// current resolution unmanaged and omits the field from `config.toml`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scroll_resolution: Option,
+ /// Onboard-profiles mode for gaming mice with HID++ `0x8100`, re-applied
+ /// on reconnect for the same reason as [`Self::dpi`]. `None` means the
+ /// default policy: host mode, so OpenLogi's settings apply.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub onboard_profiles: Option,
}
/// `skip_serializing_if` helper for plain `bool` fields whose default is
@@ -163,6 +169,8 @@ struct RawDeviceConfig {
invert_scroll: bool,
#[serde(default)]
scroll_resolution: Option,
+ #[serde(default)]
+ onboard_profiles: Option,
}
impl From for DeviceConfig {
@@ -207,6 +215,7 @@ impl From for DeviceConfig {
smartshift: raw.smartshift,
invert_scroll: raw.invert_scroll,
scroll_resolution: raw.scroll_resolution,
+ onboard_profiles: raw.onboard_profiles,
}
}
}
diff --git a/crates/openlogi-core/src/config/settings.rs b/crates/openlogi-core/src/config/settings.rs
index 1192c1d0..d715e15a 100644
--- a/crates/openlogi-core/src/config/settings.rs
+++ b/crates/openlogi-core/src/config/settings.rs
@@ -339,6 +339,37 @@ pub struct SmartShift {
pub tunable_torque: u8,
}
+/// Where a gaming device with HID++ `0x8100` onboard profile memory takes its
+/// settings from.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ProfileSource {
+ /// Host mode: OpenLogi drives the device; onboard profiles are dormant.
+ /// The default — onboard mode would shadow every software setting.
+ #[default]
+ Host,
+ /// Onboard mode: the device applies a profile from its own flash.
+ Onboard,
+}
+
+/// Per-device onboard-profiles configuration for gaming mice, persisted so the
+/// agent can re-apply it when the device reconnects: the mode is written to
+/// device RAM and reverts to onboard on a power cycle.
+///
+/// Config-file only — never crosses the IPC (the agent reads it from
+/// `config.toml` on reload), so it is free to evolve without a
+/// `PROTOCOL_VERSION` bump.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct OnboardProfiles {
+ /// Whether the device runs from host software or its onboard memory.
+ pub mode: ProfileSource,
+ /// Flash sector of the onboard profile to activate in
+ /// [`ProfileSource::Onboard`] mode. `None` keeps whatever profile the
+ /// device already has active. Ignored in host mode.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub profile: Option,
+}
+
/// Which control owns a device's single gesture role.
///
/// Stored explicitly — rather than inferred from which button happens to carry a
@@ -397,6 +428,26 @@ where
mod tests {
use super::*;
+ #[test]
+ fn onboard_profiles_parses_both_toml_shapes() {
+ // Onboard with an explicit profile sector.
+ let onboard: OnboardProfiles =
+ toml::from_str("mode = \"onboard\"\nprofile = 2\n").expect("parse onboard");
+ assert_eq!(onboard.mode, ProfileSource::Onboard);
+ assert_eq!(onboard.profile, Some(2));
+
+ // Host mode omits the profile field entirely.
+ let host: OnboardProfiles = toml::from_str("mode = \"host\"\n").expect("parse host");
+ assert_eq!(host.mode, ProfileSource::Host);
+ assert_eq!(host.profile, None);
+ assert!(
+ !toml::to_string(&host)
+ .expect("serialize")
+ .contains("profile"),
+ "unset profile must stay out of config.toml"
+ );
+ }
+
#[test]
fn low_auto_disengage_heals_to_default_on_load() {
// A pre-#317 config could persist a runaway-low threshold (or the `0`
diff --git a/crates/openlogi-core/src/device.rs b/crates/openlogi-core/src/device.rs
index 2c4e9795..8b85c6ec 100644
--- a/crates/openlogi-core/src/device.rs
+++ b/crates/openlogi-core/src/device.rs
@@ -102,6 +102,9 @@ pub struct Capabilities {
/// can be read and changed independently of inversion support.
#[serde(default)]
pub hires_wheel: bool,
+ /// Onboard profile memory — HID++ `0x8100 OnboardProfiles` (gaming mice).
+ #[serde(default)]
+ pub onboard_profiles: bool,
}
impl Capabilities {
@@ -123,6 +126,7 @@ impl Capabilities {
lighting: has(&LIGHTING),
scroll_inversion: false,
hires_wheel: ids.contains(&0x2121),
+ onboard_profiles: ids.contains(&0x8100),
}
}
@@ -140,6 +144,7 @@ impl Capabilities {
lighting: false,
scroll_inversion: false,
hires_wheel: false,
+ onboard_profiles: false,
},
DeviceKind::Keyboard => Self {
lighting: true,
@@ -371,6 +376,7 @@ mod tests {
lighting: false,
scroll_inversion: false,
hires_wheel: false,
+ onboard_profiles: false,
}),
}],
}
@@ -434,6 +440,7 @@ mod tests {
lighting: false,
scroll_inversion: false,
hires_wheel: true,
+ onboard_profiles: false,
}
);
// A wired G-series keyboard: PerKeyLighting (0x8080), no DPI/buttons.
@@ -446,8 +453,12 @@ mod tests {
lighting: true,
scroll_inversion: false,
hires_wheel: false,
+ onboard_profiles: false,
}
);
+ // A G-series gaming mouse: OnboardProfiles (0x8100) flips the flag.
+ let gaming = Capabilities::from_feature_ids(&[0x1b04, 0x2201, 0x8100]);
+ assert!(gaming.onboard_profiles);
// No driving features → nothing offered.
assert_eq!(
Capabilities::from_feature_ids(&[0x0000, 0x0003]),
diff --git a/crates/openlogi-core/src/diagnostics.rs b/crates/openlogi-core/src/diagnostics.rs
index 9fac9d79..cd900b85 100644
--- a/crates/openlogi-core/src/diagnostics.rs
+++ b/crates/openlogi-core/src/diagnostics.rs
@@ -594,6 +594,7 @@ mod tests {
lighting: false,
scroll_inversion: false,
hires_wheel: true,
+ onboard_profiles: false,
}),
dpi: Some("1600 dpi (range 200–8000, 5 steps)".to_string()),
config_key: "4082d".to_string(),
diff --git a/crates/openlogi-gui/locales/da.yml b/crates/openlogi-gui/locales/da.yml
index cf5983fb..3463799e 100644
--- a/crates/openlogi-gui/locales/da.yml
+++ b/crates/openlogi-gui/locales/da.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Registrerer andre apps, der opfanger musens hændelsesstrøm – en almindelig årsag til markørforsinkelse."
"No other app is intercepting mouse input.": "Ingen anden app opfanger museinput."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "En anden app opfanger museinput, hvilket kan forårsage markørforsinkelse eller dublerede knaphandlinger: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/de.yml b/crates/openlogi-gui/locales/de.yml
index 203108dd..fa847fd3 100644
--- a/crates/openlogi-gui/locales/de.yml
+++ b/crates/openlogi-gui/locales/de.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Erkennt andere Apps, die den Maus-Ereignisstrom abgreifen – eine häufige Ursache für Zeigerverzögerung."
"No other app is intercepting mouse input.": "Keine andere App fängt Mauseingaben ab."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Eine andere App fängt Mauseingaben ab, was zu Zeigerverzögerung oder doppelten Tastenaktionen führen kann: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/el.yml b/crates/openlogi-gui/locales/el.yml
index 80d7156d..dfefae2b 100644
--- a/crates/openlogi-gui/locales/el.yml
+++ b/crates/openlogi-gui/locales/el.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Εντοπίζει άλλες εφαρμογές που υποκλέπτουν τη ροή συμβάντων του ποντικιού — μια συνηθισμένη αιτία καθυστέρησης του δείκτη."
"No other app is intercepting mouse input.": "Καμία άλλη εφαρμογή δεν υποκλέπτει την είσοδο του ποντικιού."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Μια άλλη εφαρμογή υποκλέπτει την είσοδο του ποντικιού, κάτι που μπορεί να προκαλέσει καθυστέρηση του δείκτη ή διπλές ενέργειες κουμπιών: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/en.yml b/crates/openlogi-gui/locales/en.yml
index e574c8c5..4391afbe 100644
--- a/crates/openlogi-gui/locales/en.yml
+++ b/crates/openlogi-gui/locales/en.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Detects other apps tapping the mouse event stream — a common cause of pointer lag."
"No other app is intercepting mouse input.": "No other app is intercepting mouse input."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/es.yml b/crates/openlogi-gui/locales/es.yml
index 37f0c3a2..257384d0 100644
--- a/crates/openlogi-gui/locales/es.yml
+++ b/crates/openlogi-gui/locales/es.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Detecta otras apps que interceptan el flujo de eventos del ratón, una causa habitual de retraso del puntero."
"No other app is intercepting mouse input.": "Ninguna otra app está interceptando la entrada del ratón."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Otra app está interceptando la entrada del ratón, lo que puede causar retraso del puntero o acciones de botón duplicadas: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/fi.yml b/crates/openlogi-gui/locales/fi.yml
index b14640ce..a799f9cb 100644
--- a/crates/openlogi-gui/locales/fi.yml
+++ b/crates/openlogi-gui/locales/fi.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Tunnistaa muut sovellukset, jotka sieppaavat hiiren tapahtumavirtaa – yleinen osoittimen viiveen syy."
"No other app is intercepting mouse input.": "Mikään muu sovellus ei sieppaa hiiren syötettä."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Toinen sovellus sieppaa hiiren syötettä, mikä voi aiheuttaa osoittimen viivettä tai painikkeiden kaksoistoimintoja: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/fr.yml b/crates/openlogi-gui/locales/fr.yml
index cdb201d9..abe21c78 100644
--- a/crates/openlogi-gui/locales/fr.yml
+++ b/crates/openlogi-gui/locales/fr.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Détecte les autres applications qui interceptent le flux d'événements de la souris — une cause fréquente de latence du pointeur."
"No other app is intercepting mouse input.": "Aucune autre application n'intercepte les entrées de la souris."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Une autre application intercepte les entrées de la souris, ce qui peut provoquer une latence du pointeur ou des actions de bouton en double : %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/it.yml b/crates/openlogi-gui/locales/it.yml
index f35d58a8..56699303 100644
--- a/crates/openlogi-gui/locales/it.yml
+++ b/crates/openlogi-gui/locales/it.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Rileva altre app che intercettano il flusso di eventi del mouse, una causa comune di lentezza del puntatore."
"No other app is intercepting mouse input.": "Nessun'altra app sta intercettando l'input del mouse."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Un'altra app sta intercettando l'input del mouse, il che può causare lentezza del puntatore o azioni dei pulsanti duplicate: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/ja.yml b/crates/openlogi-gui/locales/ja.yml
index ba765827..db4ed7bc 100644
--- a/crates/openlogi-gui/locales/ja.yml
+++ b/crates/openlogi-gui/locales/ja.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "マウスイベントストリームを監視している他のアプリを検出します。ポインタ遅延のよくある原因です。"
"No other app is intercepting mouse input.": "マウス入力を横取りしている他のアプリはありません。"
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "別のアプリがマウス入力を横取りしています。ポインタの遅延やボタンの二重動作の原因になることがあります:%{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/ko.yml b/crates/openlogi-gui/locales/ko.yml
index 1f6cfa17..8dfe4953 100644
--- a/crates/openlogi-gui/locales/ko.yml
+++ b/crates/openlogi-gui/locales/ko.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "마우스 이벤트 스트림을 가로채는 다른 앱을 감지합니다. 포인터 지연의 흔한 원인입니다."
"No other app is intercepting mouse input.": "마우스 입력을 가로채는 다른 앱이 없습니다."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "다른 앱이 마우스 입력을 가로채고 있어 포인터 지연이나 버튼 중복 동작이 발생할 수 있습니다: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/nb.yml b/crates/openlogi-gui/locales/nb.yml
index 3c74b79e..d0c8abdf 100644
--- a/crates/openlogi-gui/locales/nb.yml
+++ b/crates/openlogi-gui/locales/nb.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Oppdager andre apper som fanger opp musens hendelsesstrøm – en vanlig årsak til pekerforsinkelse."
"No other app is intercepting mouse input.": "Ingen annen app fanger opp museinndata."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "En annen app fanger opp museinndata, noe som kan føre til pekerforsinkelse eller dupliserte knappehandlinger: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/nl.yml b/crates/openlogi-gui/locales/nl.yml
index f9e485dc..bcb000b7 100644
--- a/crates/openlogi-gui/locales/nl.yml
+++ b/crates/openlogi-gui/locales/nl.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Detecteert andere apps die de muisgebeurtenissenstroom onderscheppen — een veelvoorkomende oorzaak van vertraging van de aanwijzer."
"No other app is intercepting mouse input.": "Geen andere app onderschept muisinvoer."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Een andere app onderschept muisinvoer, wat kan leiden tot vertraging van de aanwijzer of dubbele knopacties: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/pl.yml b/crates/openlogi-gui/locales/pl.yml
index e72d22b1..4220c714 100644
--- a/crates/openlogi-gui/locales/pl.yml
+++ b/crates/openlogi-gui/locales/pl.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Wykrywa inne aplikacje przechwytujące strumień zdarzeń myszy — częstą przyczynę opóźnień wskaźnika."
"No other app is intercepting mouse input.": "Żadna inna aplikacja nie przechwytuje danych wejściowych myszy."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Inna aplikacja przechwytuje dane wejściowe myszy, co może powodować opóźnienia wskaźnika lub zdublowane działania przycisków: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/pt-BR.yml b/crates/openlogi-gui/locales/pt-BR.yml
index a298fe26..3bd2b059 100644
--- a/crates/openlogi-gui/locales/pt-BR.yml
+++ b/crates/openlogi-gui/locales/pt-BR.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Detecta outros apps que interceptam o fluxo de eventos do mouse — uma causa comum de lentidão do ponteiro."
"No other app is intercepting mouse input.": "Nenhum outro app está interceptando a entrada do mouse."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Outro app está interceptando a entrada do mouse, o que pode causar lentidão do ponteiro ou ações de botão duplicadas: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/pt-PT.yml b/crates/openlogi-gui/locales/pt-PT.yml
index dfe9346b..969da937 100644
--- a/crates/openlogi-gui/locales/pt-PT.yml
+++ b/crates/openlogi-gui/locales/pt-PT.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Deteta outras apps que intercetam o fluxo de eventos do rato — uma causa comum de lentidão do ponteiro."
"No other app is intercepting mouse input.": "Nenhuma outra app está a intercetar a entrada do rato."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Outra app está a intercetar a entrada do rato, o que pode causar lentidão do ponteiro ou ações de botão duplicadas: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/ru.yml b/crates/openlogi-gui/locales/ru.yml
index c50a9281..79df7f22 100644
--- a/crates/openlogi-gui/locales/ru.yml
+++ b/crates/openlogi-gui/locales/ru.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Обнаруживает другие приложения, перехватывающие поток событий мыши, — частая причина задержки указателя."
"No other app is intercepting mouse input.": "Никакое другое приложение не перехватывает ввод мыши."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Другое приложение перехватывает ввод мыши, что может вызывать задержку указателя или дублирование действий кнопок: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/sv.yml b/crates/openlogi-gui/locales/sv.yml
index df82147f..fb65e2fd 100644
--- a/crates/openlogi-gui/locales/sv.yml
+++ b/crates/openlogi-gui/locales/sv.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Upptäcker andra appar som fångar upp musens händelseström – en vanlig orsak till pekarfördröjning."
"No other app is intercepting mouse input.": "Ingen annan app fångar upp musinmatning."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "En annan app fångar upp musinmatning, vilket kan orsaka pekarfördröjning eller dubblerade knappåtgärder: %{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/zh-CN.yml b/crates/openlogi-gui/locales/zh-CN.yml
index 318d29eb..b0d1ca0a 100644
--- a/crates/openlogi-gui/locales/zh-CN.yml
+++ b/crates/openlogi-gui/locales/zh-CN.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "检测其它正在监听鼠标事件流的应用——这是指针卡顿的常见原因。"
"No other app is intercepting mouse input.": "没有其它应用在拦截鼠标输入。"
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "另一个应用正在拦截鼠标输入,可能导致指针卡顿或按键重复触发:%{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/zh-HK.yml b/crates/openlogi-gui/locales/zh-HK.yml
index 65a363c5..228b3034 100644
--- a/crates/openlogi-gui/locales/zh-HK.yml
+++ b/crates/openlogi-gui/locales/zh-HK.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "偵測其他正在監聽滑鼠事件流的應用程式——這是指標延遲的常見原因。"
"No other app is intercepting mouse input.": "沒有其他應用程式正在攔截滑鼠輸入。"
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "另一個應用程式正在攔截滑鼠輸入,可能導致指標延遲或按鍵重複觸發:%{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/locales/zh-TW.yml b/crates/openlogi-gui/locales/zh-TW.yml
index 7505b25a..efd147a8 100644
--- a/crates/openlogi-gui/locales/zh-TW.yml
+++ b/crates/openlogi-gui/locales/zh-TW.yml
@@ -305,3 +305,17 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "偵測其他正在監聽滑鼠事件流的應用程式——這是指標延遲的常見原因。"
"No other app is intercepting mouse input.": "沒有其他應用程式在攔截滑鼠輸入。"
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "另一個應用程式正在攔截滑鼠輸入,可能導致指標延遲或按鍵重複觸發:%{apps}"
+"Profiles": "Profiles"
+"Settings source": "Settings source"
+"OpenLogi settings": "OpenLogi settings"
+"Onboard memory": "Onboard memory"
+"OpenLogi drives this mouse; the onboard profile is dormant.": "OpenLogi drives this mouse; the onboard profile is dormant."
+"The mouse runs the profile stored in its memory; OpenLogi settings do not apply.": "The mouse runs the profile stored in its memory; OpenLogi settings do not apply."
+"Active onboard profile": "Active onboard profile"
+"Profile %{n}": "Profile %{n}"
+"ROM profile %{n}": "ROM profile %{n}"
+"No enabled profiles in the device's memory.": "No enabled profiles in the device's memory."
+"Reading onboard profiles…": "Reading onboard profiles…"
+"Device offline — onboard profiles unavailable.": "Device offline — onboard profiles unavailable."
+"Couldn't read onboard profiles — click to retry.": "Couldn't read onboard profiles — click to retry."
+"This device has no onboard profile memory.": "This device has no onboard profile memory."
diff --git a/crates/openlogi-gui/src/app.rs b/crates/openlogi-gui/src/app.rs
index bd6d2e2a..8e6029b1 100644
--- a/crates/openlogi-gui/src/app.rs
+++ b/crates/openlogi-gui/src/app.rs
@@ -17,6 +17,7 @@ use crate::app_menu::{CloseWindow, Minimize, Zoom};
use crate::asset::AssetResolver;
use crate::components::dpi_panel::DpiPanel;
use crate::components::lighting_panel::LightingPanel;
+use crate::components::profiles_panel::ProfilesPanel;
use crate::components::smartshift_panel::SmartShiftPanel;
use crate::mouse_model::view::MouseModelView;
use crate::state::{AgentLink, AppState, DeviceRecord};
@@ -67,6 +68,8 @@ enum DetailTab {
Pointer,
/// RGB lighting — color, brightness, on/off.
Lighting,
+ /// Onboard profile memory (gaming mice) — host vs. onboard mode.
+ Profiles,
/// Device info and configuration.
Device,
}
@@ -104,6 +107,9 @@ impl DetailTab {
if caps.lighting {
tabs.push(Self::Lighting);
}
+ if caps.onboard_profiles {
+ tabs.push(Self::Profiles);
+ }
tabs.push(Self::Device);
tabs
}
@@ -121,6 +127,7 @@ impl DetailTab {
Self::Buttons => tr!("Buttons"),
Self::Pointer => tr!("Pointer"),
Self::Lighting => tr!("Lighting"),
+ Self::Profiles => tr!("Profiles"),
Self::Device => tr!("Device"),
}
}
@@ -134,6 +141,7 @@ pub struct AppView {
dpi_panel: Entity,
smartshift_panel: Entity,
lighting_panel: Entity,
+ profiles_panel: Entity,
#[allow(dead_code, reason = "held to keep the appearance observer alive")]
appearance_obs: Option,
/// Re-renders the root when the device list changes so the empty state
@@ -178,6 +186,7 @@ impl AppView {
let dpi_panel = cx.new(DpiPanel::new);
let smartshift_panel = cx.new(SmartShiftPanel::new);
let lighting_panel = cx.new(LightingPanel::new);
+ let profiles_panel = cx.new(ProfilesPanel::new);
let state_obs = cx.observe_global::(|_, cx| cx.notify());
Self {
focus_handle,
@@ -186,6 +195,7 @@ impl AppView {
dpi_panel,
smartshift_panel,
lighting_panel,
+ profiles_panel,
appearance_obs: None,
state_obs,
accessibility_dismissed: false,
@@ -438,6 +448,7 @@ impl Render for AppView {
&self.dpi_panel,
&self.smartshift_panel,
&self.lighting_panel,
+ &self.profiles_panel,
active,
pal,
cx,
@@ -581,6 +592,7 @@ mod tests {
lighting: false,
scroll_inversion: false,
hires_wheel: false,
+ onboard_profiles: false,
});
// After 0x0005 kind-correction the record has kind=Mouse, not Keyboard.
let tabs = DetailTab::tabs_for(&record(DeviceKind::Mouse, caps));
@@ -600,6 +612,7 @@ mod tests {
lighting: true,
scroll_inversion: false,
hires_wheel: false,
+ onboard_profiles: false,
});
let tabs = DetailTab::tabs_for(&record(DeviceKind::Keyboard, caps));
assert!(
diff --git a/crates/openlogi-gui/src/app/detail.rs b/crates/openlogi-gui/src/app/detail.rs
index 0d9280e1..0d0a92fa 100644
--- a/crates/openlogi-gui/src/app/detail.rs
+++ b/crates/openlogi-gui/src/app/detail.rs
@@ -24,6 +24,7 @@ use super::{AppView, DetailTab};
use crate::app_menu::file_url;
use crate::components::dpi_panel::DpiPanel;
use crate::components::lighting_panel::LightingPanel;
+use crate::components::profiles_panel::ProfilesPanel;
use crate::components::smartshift_panel::SmartShiftPanel;
use crate::mouse_model::view::MouseModelView;
use crate::state::{AppState, DeviceRecord};
@@ -83,11 +84,16 @@ pub(super) fn detail_header(
/// switches them — is the header's job (see [`detail_header`] and
/// [`DetailTab::tabs_for`]); `active` arrives pre-resolved against this device's
/// tab set, so this only has to render the chosen section.
+#[allow(
+ clippy::too_many_arguments,
+ reason = "one entity per section panel; bundling would just hide the dependency"
+)]
pub(super) fn detail_content(
mouse_model: &gpui::Entity,
dpi_panel: &gpui::Entity,
smartshift_panel: &gpui::Entity,
lighting_panel: &gpui::Entity,
+ profiles_panel: &gpui::Entity,
active: DetailTab,
pal: Palette,
cx: &mut Context,
@@ -96,6 +102,7 @@ pub(super) fn detail_content(
DetailTab::Buttons => buttons_tab(mouse_model).into_any_element(),
DetailTab::Pointer => pointer_tab(dpi_panel, smartshift_panel, pal, cx).into_any_element(),
DetailTab::Lighting => lighting_tab(lighting_panel, pal).into_any_element(),
+ DetailTab::Profiles => profiles_tab(profiles_panel, pal).into_any_element(),
DetailTab::Device => device_tab(pal, cx).into_any_element(),
}
}
@@ -409,6 +416,25 @@ fn lighting_tab(lighting_panel: &gpui::Entity, pal: Palette) -> i
)))
}
+/// Profiles tab: the onboard-profiles controls (settings source + active
+/// profile) in a titled card. Shown when the device reports HID++ `0x8100` —
+/// see [`DetailTab::tabs_for`].
+fn profiles_tab(profiles_panel: &gpui::Entity, pal: Palette) -> impl IntoElement {
+ v_flex()
+ .flex_1()
+ .w_full()
+ .min_h_0()
+ .items_center()
+ .overflow_y_scrollbar()
+ .p(px(SCREEN_PAD))
+ .child(div().w_full().max_w(px(560.)).child(panel_card(
+ tr!("Profiles"),
+ IconName::Settings,
+ pal,
+ profiles_panel.clone().into_any_element(),
+ )))
+}
+
/// Device tab: device details and configuration cards stacked.
fn device_tab(pal: Palette, cx: &mut Context) -> impl IntoElement {
v_flex()
diff --git a/crates/openlogi-gui/src/components/mod.rs b/crates/openlogi-gui/src/components/mod.rs
index 51b5587d..9e3f33fc 100644
--- a/crates/openlogi-gui/src/components/mod.rs
+++ b/crates/openlogi-gui/src/components/mod.rs
@@ -8,5 +8,6 @@ pub mod carousel;
pub mod device_read;
pub mod dpi_panel;
pub mod lighting_panel;
+pub mod profiles_panel;
pub mod smartshift_panel;
pub mod status;
diff --git a/crates/openlogi-gui/src/components/profiles_panel.rs b/crates/openlogi-gui/src/components/profiles_panel.rs
new file mode 100644
index 00000000..54ec1acc
--- /dev/null
+++ b/crates/openlogi-gui/src/components/profiles_panel.rs
@@ -0,0 +1,275 @@
+//! Onboard-profiles controls for gaming mice (HID++ `0x8100`).
+//!
+//! Two source pills — OpenLogi settings (host mode) vs. onboard memory — and,
+//! under onboard memory, a selector for which stored profile is active. Each
+//! change is written to the device *and* persisted to `config.toml` (via
+//! [`AppState::commit_onboard_profiles`]): the mode lives in device RAM and
+//! reverts to onboard on a power cycle, so the agent re-applies the saved
+//! config when the device reconnects. State is read lazily on the same
+//! background pattern as [`crate::components::smartshift_panel`].
+
+use gpui::{
+ AnyElement, BorrowAppContext as _, Context, InteractiveElement, IntoElement, ParentElement,
+ Render, SharedString, StatefulInteractiveElement as _, Styled, Subscription, Window, div,
+};
+use gpui_component::{h_flex, v_flex};
+use openlogi_hid::{DeviceRoute, OnboardProfilesInfo, ProfileEntry, ProfilesMode};
+
+use crate::components::device_read::issue_device_read;
+use crate::components::status::{retry_line, status_line};
+use crate::state::{AppState, ProfilesLoad};
+use crate::theme::{self, Palette, SelectableStyle, Typography as _};
+
+pub struct ProfilesPanel {
+ _state_obs: Subscription,
+}
+
+impl ProfilesPanel {
+ pub fn new(cx: &mut Context) -> Self {
+ let state_obs = cx.observe_global::(|_, cx| cx.notify());
+ Self {
+ _state_obs: state_obs,
+ }
+ }
+
+ /// Kick off a one-shot onboard-profiles read for the active device when it
+ /// hasn't been queried yet.
+ fn ensure_profiles_load(cx: &mut Context) {
+ let Some((key, route)) = profiles_load_target(cx) else {
+ return;
+ };
+ cx.update_global::(|state, _| state.mark_profiles_loading(&key));
+ Self::issue_profiles_read(key, route, cx);
+ }
+
+ /// Re-read once after an optimistic write to confirm the device actually
+ /// took it. No Loading marker, so the optimistic value stays on screen
+ /// until the real state replaces it.
+ fn ensure_profiles_confirm(cx: &mut Context) {
+ let Some((key, route)) =
+ cx.update_global::(|state, _| state.take_active_profiles_confirm())
+ else {
+ return;
+ };
+ Self::issue_profiles_read(key, route, cx);
+ }
+
+ fn issue_profiles_read(key: String, route: DeviceRoute, cx: &mut Context) {
+ issue_device_read(
+ cx,
+ key,
+ route,
+ crate::ipc_client::Command::ReadOnboardProfiles,
+ AppState::store_profiles_info,
+ AppState::clear_profiles_loading,
+ );
+ }
+
+ /// The interactive body shown once the device's profiles state resolves.
+ fn ready_body(info: &OnboardProfilesInfo, pal: Palette) -> AnyElement {
+ let onboard = info.mode == ProfilesMode::Onboard;
+ // In host mode there is nothing to select; keep whatever profile the
+ // device has active so switching back to onboard restores it.
+ let keep_profile = keep_profile_for(info);
+
+ let source_row = v_flex()
+ .gap_2()
+ .child(section_label(tr!("Settings source"), pal))
+ .child(
+ h_flex()
+ .gap_2()
+ .child(source_pill(
+ "profiles-source-host",
+ tr!("OpenLogi settings"),
+ !onboard,
+ ProfilesMode::Host,
+ keep_profile,
+ pal,
+ ))
+ .child(source_pill(
+ "profiles-source-onboard",
+ tr!("Onboard memory"),
+ onboard,
+ ProfilesMode::Onboard,
+ keep_profile,
+ pal,
+ )),
+ )
+ .child(
+ div()
+ .text_caption()
+ .text_color(pal.text_muted)
+ .child(if onboard {
+ tr!("The mouse runs the profile stored in its memory; OpenLogi settings do not apply.")
+ } else {
+ tr!("OpenLogi drives this mouse; the onboard profile is dormant.")
+ }),
+ );
+
+ let mut body = v_flex().gap_4().w_full().child(source_row);
+ if onboard {
+ // Number by directory position: a disabled slot must not renumber later ones.
+ let enabled: Vec<(usize, &ProfileEntry)> = info
+ .directory
+ .iter()
+ .enumerate()
+ .filter(|(_, e)| e.enabled)
+ .collect();
+ let profile_row = v_flex()
+ .gap_2()
+ .child(section_label(tr!("Active onboard profile"), pal))
+ .child(if enabled.is_empty() {
+ status_line(tr!("No enabled profiles in the device's memory."), pal)
+ } else {
+ h_flex()
+ .gap_2()
+ .flex_wrap()
+ .children(enabled.iter().map(|&(i, entry)| {
+ profile_pill(i, *entry, entry.sector == info.active_profile, pal)
+ }))
+ .into_any_element()
+ });
+ body = body.child(profile_row);
+ }
+ body.into_any_element()
+ }
+}
+
+impl Render for ProfilesPanel {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ Self::ensure_profiles_load(cx);
+ Self::ensure_profiles_confirm(cx);
+ let pal = theme::palette(cx);
+
+ let status = cx
+ .try_global::()
+ .map_or(ProfilesLoad::Unknown, AppState::current_profiles_status);
+ let reachable = cx
+ .try_global::()
+ .and_then(AppState::current_record)
+ .is_some_and(|r| r.route.is_some());
+
+ let content: AnyElement = match status {
+ ProfilesLoad::Ready(info) => Self::ready_body(&info, pal),
+ ProfilesLoad::Loading | ProfilesLoad::Unknown if !reachable => {
+ status_line(tr!("Device offline — onboard profiles unavailable."), pal)
+ }
+ ProfilesLoad::Loading | ProfilesLoad::Unknown => {
+ status_line(tr!("Reading onboard profiles…"), pal)
+ }
+ ProfilesLoad::Failed(_) => retry_line(
+ "profiles-retry",
+ tr!("Couldn't read onboard profiles — click to retry."),
+ pal,
+ |cx| {
+ cx.update_global::(|state, _| state.retry_active_profiles());
+ cx.refresh_windows();
+ },
+ ),
+ ProfilesLoad::Unsupported(_) => {
+ status_line(tr!("This device has no onboard profile memory."), pal)
+ }
+ };
+
+ v_flex().gap_3().w_full().child(content)
+ }
+}
+
+fn profiles_load_target(cx: &mut Context) -> Option<(String, DeviceRoute)> {
+ cx.try_global::().and_then(|state| {
+ if !state.current_profiles_unqueried() {
+ return None;
+ }
+ let record = state.current_record()?;
+ Some((record.config_key.clone(), record.route.clone()?))
+ })
+}
+
+/// The profile sector a mode switch should carry: the currently active one
+/// when it is a real, enabled entry, otherwise the first enabled entry (a
+/// device that has never run onboard reports `0x0000`).
+fn keep_profile_for(info: &OnboardProfilesInfo) -> Option {
+ let enabled = |sector: u16| {
+ info.directory
+ .iter()
+ .any(|e| e.enabled && e.sector == sector)
+ };
+ if info.active_profile != 0 && enabled(info.active_profile) {
+ Some(info.active_profile)
+ } else {
+ info.directory.iter().find(|e| e.enabled).map(|e| e.sector)
+ }
+}
+
+/// A small muted section heading.
+fn section_label(text: SharedString, pal: Palette) -> AnyElement {
+ div()
+ .text_body()
+ .text_color(pal.text_muted)
+ .child(text)
+ .into_any_element()
+}
+
+/// One settings-source pill. Clicking it commits `target`, carrying the
+/// profile selection along so an onboard switch activates a real profile.
+fn source_pill(
+ id: &'static str,
+ label: SharedString,
+ selected: bool,
+ target: ProfilesMode,
+ profile: Option,
+ pal: Palette,
+) -> AnyElement {
+ div()
+ .id(id)
+ .px_3()
+ .py_1()
+ .rounded(pal.control_radius)
+ .selected_border(selected, pal)
+ .bg(pal.surface)
+ .selected_fill(selected)
+ .text_body()
+ .text_color(pal.text_primary)
+ .cursor_pointer()
+ .hover(|s| s.bg(pal.surface_hover))
+ .child(label)
+ .on_click(move |_event, _window, cx| {
+ cx.update_global::(|state, _| {
+ state.commit_onboard_profiles(target, profile);
+ });
+ cx.refresh_windows();
+ })
+ .into_any_element()
+}
+
+/// One enabled directory entry as a selectable pill, labeled by its position
+/// (ROM profiles are labeled as such).
+fn profile_pill(index: usize, entry: ProfileEntry, selected: bool, pal: Palette) -> AnyElement {
+ let n = (index + 1).to_string();
+ let label = if entry.is_rom() {
+ tr!("ROM profile %{n}", n => n)
+ } else {
+ tr!("Profile %{n}", n => n)
+ };
+ let sector = entry.sector;
+ div()
+ .id(SharedString::from(format!("profile-pill-{sector}")))
+ .px_3()
+ .py_1()
+ .rounded(pal.control_radius)
+ .selected_border(selected, pal)
+ .bg(pal.surface)
+ .selected_fill(selected)
+ .text_body()
+ .text_color(pal.text_primary)
+ .cursor_pointer()
+ .hover(|s| s.bg(pal.surface_hover))
+ .child(label)
+ .on_click(move |_event, _window, cx| {
+ cx.update_global::(|state, _| {
+ state.commit_onboard_profiles(ProfilesMode::Onboard, Some(sector));
+ });
+ cx.refresh_windows();
+ })
+ .into_any_element()
+}
diff --git a/crates/openlogi-gui/src/ipc_client.rs b/crates/openlogi-gui/src/ipc_client.rs
index 73040715..4931d78e 100644
--- a/crates/openlogi-gui/src/ipc_client.rs
+++ b/crates/openlogi-gui/src/ipc_client.rs
@@ -26,7 +26,8 @@ use openlogi_agent_core::ipc::{
use openlogi_core::config::Lighting;
use openlogi_core::device::DeviceInventory;
use openlogi_hid::{
- DeviceRoute, DpiInfo, ReceiverSelector, SmartShiftMode, SmartShiftStatus, WriteError,
+ DeviceRoute, DpiInfo, OnboardProfilesInfo, ProfilesMode, ReceiverSelector, SmartShiftMode,
+ SmartShiftStatus, WriteError,
};
use tarpc::client;
use tarpc::context;
@@ -85,6 +86,11 @@ pub enum Command {
DeviceRoute,
oneshot::Sender>,
),
+ SetOnboardProfiles(DeviceRoute, ProfilesMode, Option),
+ ReadOnboardProfiles(
+ DeviceRoute,
+ oneshot::Sender>,
+ ),
ReloadConfig,
/// Ask the agent to fire the macOS Accessibility prompt. The agent owns the
/// CGEventTap, so the system dialog must name (and authorize) the *agent*
@@ -559,6 +565,12 @@ async fn handle(
Command::ReadSmartShift(route, reply) => {
let _ = reply.send(rpc_result(client.read_smartshift(ctx, route).await)?);
}
+ Command::SetOnboardProfiles(route, mode, profile) => {
+ log_apply(client.set_onboard_profiles(ctx, route, mode, profile).await)?;
+ }
+ Command::ReadOnboardProfiles(route, reply) => {
+ let _ = reply.send(rpc_result(client.read_onboard_profiles(ctx, route).await)?);
+ }
Command::ReloadConfig => client.reload_config(ctx).await.map_err(|_| ())?,
Command::RequestAccessibilityPrompt => client
.request_accessibility_prompt(ctx)
@@ -629,6 +641,9 @@ fn reply_disconnected(pairing_tx: &mpsc::UnboundedSender, cmd: Co
Command::ReadSmartShift(_, reply) => {
let _ = reply.send(Err(WriteError::AgentUnavailable));
}
+ Command::ReadOnboardProfiles(_, reply) => {
+ let _ = reply.send(Err(WriteError::AgentUnavailable));
+ }
Command::StartPairing(_) | Command::PairDevice(_) => {
let _ = pairing_tx.send(PairingUpdate::Failed(PairingFailure::AgentRestarted));
}
diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs
index c6a74711..23984b61 100644
--- a/crates/openlogi-gui/src/state.rs
+++ b/crates/openlogi-gui/src/state.rs
@@ -17,7 +17,8 @@ use openlogi_core::config::{
};
use openlogi_core::device::{DeviceInventory, DeviceModelInfo};
use openlogi_hid::{
- DeviceRoute, DpiCapabilities, DpiInfo, SmartShiftMode, SmartShiftStatus, WriteError,
+ DeviceRoute, DpiCapabilities, DpiInfo, OnboardProfilesInfo, ProfilesMode, SmartShiftMode,
+ SmartShiftStatus, WriteError,
};
use tokio::sync::mpsc;
use tracing::{debug, warn};
@@ -26,7 +27,7 @@ mod devices;
mod load;
pub use devices::DeviceRecord;
-pub use load::{DpiStatus, Load, SmartShiftLoad};
+pub use load::{DpiStatus, Load, ProfilesLoad, SmartShiftLoad};
use load::LazyDeviceData;
@@ -116,6 +117,14 @@ pub struct AppState {
/// re-reads (without a Loading flicker) to replace the optimistic value with
/// the device's actual state. See [`Self::commit_smartshift`].
smartshift_pending_confirm: std::collections::BTreeSet,
+ /// Onboard-profiles (`0x8100`) state keyed by
+ /// [`DeviceRecord::config_key`], loaded lazily on the same pattern as
+ /// [`Self::smartshift_data`].
+ profiles_data: LazyDeviceData,
+ /// Devices whose onboard-profiles config was just written optimistically
+ /// and still need a confirming re-read — same policy as
+ /// [`Self::smartshift_pending_confirm`].
+ profiles_pending_confirm: std::collections::BTreeSet,
/// All paired devices, in carousel order. Each entry caches the per-
/// device data the views need so a switch is a pure index update.
pub device_list: Vec,
@@ -180,6 +189,8 @@ impl AppState {
inventory_misses: BTreeMap::new(),
smartshift_data: LazyDeviceData::default(),
smartshift_pending_confirm: std::collections::BTreeSet::new(),
+ profiles_data: LazyDeviceData::default(),
+ profiles_pending_confirm: std::collections::BTreeSet::new(),
device_list,
config,
ipc_commands,
@@ -418,6 +429,7 @@ impl AppState {
for key in &rerouted {
self.dpi_data.remove(key);
self.smartshift_data.remove(key);
+ self.profiles_data.remove(key);
}
let present = |key: &str| {
self.device_list
@@ -426,6 +438,7 @@ impl AppState {
};
self.dpi_data.retain_present(present);
self.smartshift_data.retain_present(present);
+ self.profiles_data.retain_present(present);
self.current_device = new_index;
// The active device may have changed (selection fell back to index 0
// when the previous one vanished); re-seed the displayed DPI so it
@@ -499,6 +512,9 @@ impl AppState {
if matches!(self.smartshift_data.get(&key), Some(Load::Failed(_))) {
self.smartshift_data.retry(&key);
}
+ if matches!(self.profiles_data.get(&key), Some(Load::Failed(_))) {
+ self.profiles_data.retry(&key);
+ }
}
// `self.dpi` is the active device's value; adopt the newly-selected
// device's known DPI so the panel doesn't keep showing the previous
@@ -861,6 +877,120 @@ impl AppState {
.then_some((key, route))
}
+ /// Onboard-profiles status for the active device.
+ #[must_use]
+ pub fn current_profiles_status(&self) -> ProfilesLoad {
+ self.current_record()
+ .map_or(ProfilesLoad::Unknown, |record| {
+ self.profiles_data.status(&record.config_key)
+ })
+ }
+
+ /// Whether the active device still needs an onboard-profiles read.
+ #[must_use]
+ pub fn current_profiles_unqueried(&self) -> bool {
+ self.current_record()
+ .is_some_and(|record| self.profiles_data.unqueried(&record.config_key))
+ }
+
+ /// Mark onboard-profiles discovery as in flight for `key`.
+ pub fn mark_profiles_loading(&mut self, key: &str) {
+ self.profiles_data.mark_loading(key);
+ }
+
+ /// Reset a stuck `Loading` for `key` back to `Unknown` — called when the
+ /// read worker vanished without delivering a result.
+ pub fn clear_profiles_loading(&mut self, key: &str) {
+ self.profiles_data.clear_loading(key);
+ }
+
+ /// Drop the active device's recorded onboard-profiles state so the next
+ /// render re-runs discovery — the "click to retry" affordance.
+ pub fn retry_active_profiles(&mut self) {
+ if let Some(key) = self.current_record().map(|r| r.config_key.clone()) {
+ self.profiles_data.retry(&key);
+ }
+ }
+
+ /// Store an onboard-profiles read result, with the same stale-route guard
+ /// and retry policy as [`Self::store_smartshift_status`].
+ pub fn store_profiles_info(
+ &mut self,
+ key: String,
+ route: &DeviceRoute,
+ result: Result,
+ ) {
+ let matches_route = self
+ .device_list
+ .iter()
+ .any(|record| record.config_key == key && record.route.as_ref() == Some(route));
+ let still_present = self
+ .device_list
+ .iter()
+ .any(|record| record.config_key == key);
+ self.profiles_data.store(
+ key,
+ result,
+ profiles_error_is_permanent,
+ matches_route,
+ still_present,
+ "onboard profiles",
+ );
+ }
+
+ /// Write an onboard-profiles config to the active device (best-effort over
+ /// IPC), persist it to `config.toml`, and optimistically update the cached
+ /// state — the mode lives in device RAM and reverts to onboard on a power
+ /// cycle, so the agent re-applies the persisted config on reconnect.
+ /// No-op when no device is selected.
+ pub fn commit_onboard_profiles(&mut self, mode: ProfilesMode, profile: Option) {
+ let Some(record) = self.current_record() else {
+ debug!("no active device — onboard-profiles change ignored");
+ return;
+ };
+ let key = record.config_key.clone();
+ let route = record.route.clone();
+ if let Some(route) = route {
+ self.send_ipc(crate::ipc_client::Command::SetOnboardProfiles(
+ route, mode, profile,
+ ));
+ }
+ self.config.set_onboard_profiles(
+ &key,
+ openlogi_core::config::OnboardProfiles {
+ mode: match mode {
+ ProfilesMode::Host => openlogi_core::config::ProfileSource::Host,
+ ProfilesMode::Onboard => openlogi_core::config::ProfileSource::Onboard,
+ },
+ profile,
+ },
+ );
+ self.persist_and_reload("onboard profiles");
+ // Reflect the write immediately (when a read already resolved) so the
+ // panel doesn't flicker back, and queue a confirming re-read to replace
+ // the optimistic value with the device's actual state.
+ if let Some(ProfilesLoad::Ready(info)) = self.profiles_data.get(&key) {
+ let mut info = info.clone();
+ info.mode = mode;
+ if let Some(sector) = profile {
+ info.active_profile = sector;
+ }
+ self.profiles_data.set_ready(key.clone(), info);
+ }
+ self.profiles_pending_confirm.insert(key);
+ }
+
+ /// Take the active device's pending onboard-profiles confirm, if any — the
+ /// one-shot re-read companion to [`Self::commit_onboard_profiles`].
+ pub fn take_active_profiles_confirm(&mut self) -> Option<(String, DeviceRoute)> {
+ let record = self.current_record()?;
+ let key = record.config_key.clone();
+ let route = record.route.clone()?;
+ self.profiles_pending_confirm
+ .remove(&key)
+ .then_some((key, route))
+ }
+
/// The lighting config for the active device, or the default when none is
/// stored / no device is selected.
#[must_use]
@@ -1264,6 +1394,12 @@ fn smartshift_error_is_permanent(error: &WriteError) -> bool {
matches!(error, WriteError::FeatureUnsupported { .. })
}
+/// Whether an onboard-profiles read error is permanent: the device genuinely
+/// lacks `0x8100`. Everything else (timeouts, busy device) is transient.
+fn profiles_error_is_permanent(error: &WriteError) -> bool {
+ matches!(error, WriteError::FeatureUnsupported { .. })
+}
+
fn set_scroll_resolution_if_supported(
config: &mut Config,
key: &str,
diff --git a/crates/openlogi-gui/src/state/devices.rs b/crates/openlogi-gui/src/state/devices.rs
index 09fd68dc..86791d4e 100644
--- a/crates/openlogi-gui/src/state/devices.rs
+++ b/crates/openlogi-gui/src/state/devices.rs
@@ -452,6 +452,7 @@ mod tests {
lighting: false,
scroll_inversion: false,
hires_wheel: false,
+ onboard_profiles: false,
},
model_info: None,
codename: None,
diff --git a/crates/openlogi-gui/src/state/load.rs b/crates/openlogi-gui/src/state/load.rs
index d1e72eb4..d7814b03 100644
--- a/crates/openlogi-gui/src/state/load.rs
+++ b/crates/openlogi-gui/src/state/load.rs
@@ -3,7 +3,7 @@
use std::collections::BTreeMap;
-use openlogi_hid::{DpiInfo, SmartShiftStatus, WriteError};
+use openlogi_hid::{DpiInfo, OnboardProfilesInfo, SmartShiftStatus, WriteError};
use tracing::debug;
/// How many times to retry a device read (DPI capability discovery or a
@@ -44,6 +44,9 @@ pub type DpiStatus = Load;
/// GUI only ever reads and writes the device.
pub type SmartShiftLoad = Load;
+/// Per-device onboard-profiles (`0x8100`) state load. See [`Load`].
+pub type ProfilesLoad = Load;
+
/// Per-device lazy-load cache for a background HID++ read, keyed by
/// [`DeviceRecord::config_key`](super::DeviceRecord::config_key). Holds each
/// device's [`Load`] state plus its transient-retry counter, and carries the
diff --git a/crates/openlogi-hid/Cargo.toml b/crates/openlogi-hid/Cargo.toml
index 59b2b70b..8c38646b 100644
--- a/crates/openlogi-hid/Cargo.toml
+++ b/crates/openlogi-hid/Cargo.toml
@@ -34,6 +34,9 @@ windows-sys = { workspace = true, features = [
# exposed when Win32_Security is also enabled.
"Win32_Security",
"Win32_Storage_FileSystem",
+ # WriteFile's signature references OVERLAPPED, so windows-sys 0.61.2 gates
+ # it behind Win32_System_IO as well.
+ "Win32_System_IO",
] }
[lints]
diff --git a/crates/openlogi-hid/src/hires_wheel.rs b/crates/openlogi-hid/src/hires_wheel.rs
index 82ebabf9..a430aac3 100644
--- a/crates/openlogi-hid/src/hires_wheel.rs
+++ b/crates/openlogi-hid/src/hires_wheel.rs
@@ -16,7 +16,8 @@ use tracing::debug;
use crate::route::DeviceRoute;
use crate::write::{
- HidppOperation, SharedChannel, WriteError, classify_hidpp_error, open_feature, with_route,
+ HidppOperation, SharedChannel, WriteError, classify_hidpp_error, exchange, open_feature,
+ with_route,
};
/// Destination for vertical wheel movement reports.
@@ -62,6 +63,7 @@ pub async fn get_scroll_wheel_mode(route: &DeviceRoute) -> Result Result {
+ let _guard = exchange().await;
get_scroll_wheel_mode_on_channel(shared.channel(), shared.device_index()).await
}
@@ -92,6 +94,7 @@ pub async fn set_scroll_resolution_on(
shared: &SharedChannel,
resolution: ScrollResolution,
) -> Result {
+ let _guard = exchange().await;
change_wheel_mode_on_channel(
shared.channel(),
shared.device_index(),
@@ -124,6 +127,7 @@ pub async fn set_scroll_wheel_mode_on(
resolution: ScrollResolution,
inverted: bool,
) -> Result {
+ let _guard = exchange().await;
change_wheel_mode_on_channel(
shared.channel(),
shared.device_index(),
@@ -154,6 +158,7 @@ pub async fn set_scroll_inversion_on(
shared: &SharedChannel,
inverted: bool,
) -> Result<(), WriteError> {
+ let _guard = exchange().await;
change_wheel_mode_on_channel(
shared.channel(),
shared.device_index(),
diff --git a/crates/openlogi-hid/src/inventory/probe.rs b/crates/openlogi-hid/src/inventory/probe.rs
index 8e2b717d..79c85dda 100644
--- a/crates/openlogi-hid/src/inventory/probe.rs
+++ b/crates/openlogi-hid/src/inventory/probe.rs
@@ -253,7 +253,7 @@ async fn probe_unifying_receiver(
NodeProbe {
inventory: Some(DeviceInventory {
receiver: ReceiverInfo {
- name: "Unifying Receiver".to_string(),
+ name: crate::route::receiver_display_name(info.product_id).to_string(),
vendor_id: info.vendor_id,
product_id: info.product_id,
unique_id,
diff --git a/crates/openlogi-hid/src/lib.rs b/crates/openlogi-hid/src/lib.rs
index b6cf1547..71c99e19 100644
--- a/crates/openlogi-hid/src/lib.rs
+++ b/crates/openlogi-hid/src/lib.rs
@@ -23,6 +23,7 @@ pub mod gesture;
mod hires_wheel;
pub mod hotplug;
pub mod inventory;
+pub mod onboard_profiles;
pub mod pairing;
pub mod reprog_controls;
pub mod smartshift;
@@ -37,16 +38,21 @@ pub use hires_wheel::{
};
pub use hotplug::{HotplugEvent, watch_hotplug};
pub use inventory::{Enumerator, InventoryError, enumerate};
+pub use onboard_profiles::{OnboardProfilesInfo, ProfileEntry, ProfilesMode, is_rom_sector};
pub use pairing::{
Click, DiscoveredDevice, PairingCommand, PairingError, PairingEvent, PairingReceiver,
PasskeyMethod, ReceiverFamily, ReceiverSelector, list_pairing_receivers, run_pairing, unpair,
};
-pub use route::{BOLT_PIDS, DIRECT_DEVICE_INDEX, DeviceRoute, UNIFYING_PIDS};
+pub use route::{
+ BOLT_PIDS, DIRECT_DEVICE_INDEX, DeviceRoute, LIGHTSPEED_PIDS, UNIFYING_PIDS,
+ receiver_display_name, speaks_unifying_protocol,
+};
pub use smartshift::{AUTO_DISENGAGE_PERMANENT, SmartShiftMode, SmartShiftStatus};
pub use write::{
DpiCapabilities, DpiInfo, FeatureEntry, HidppFeatureErrorKind, HidppOperation, LightingMethod,
- ReprogControlEntry, SharedChannel, WriteError, dump_features, dump_reprog_controls, get_dpi,
- get_dpi_info, get_smartshift_status, set_dpi, set_dpi_on, set_keyboard_color,
- set_keyboard_color_with, set_smartshift, set_smartshift_on, set_smartshift_sensitivity,
- toggle_smartshift, toggle_smartshift_on,
+ ReprogControlEntry, SharedChannel, WriteError, apply_profiles_config, apply_profiles_config_on,
+ dump_features, dump_reprog_controls, get_dpi, get_dpi_info, get_onboard_profiles,
+ get_smartshift_status, set_active_profile, set_dpi, set_dpi_on, set_keyboard_color,
+ set_keyboard_color_with, set_profiles_mode, set_smartshift, set_smartshift_on,
+ set_smartshift_sensitivity, toggle_smartshift, toggle_smartshift_on,
};
diff --git a/crates/openlogi-hid/src/onboard_profiles.rs b/crates/openlogi-hid/src/onboard_profiles.rs
new file mode 100644
index 00000000..39499adc
--- /dev/null
+++ b/crates/openlogi-hid/src/onboard_profiles.rs
@@ -0,0 +1,113 @@
+//! HID++ `OnboardProfiles` (feature `0x8100`) — gaming-device profile memory.
+//!
+//! The protocol-level `0x8100` wrapper lives in `openlogi-hidpp`; this module
+//! keeps OpenLogi's IPC-facing mode and snapshot types. In onboard mode the
+//! device runs a profile out of its own flash, and activating one reloads that
+//! profile's stored settings — so a host DPI write is accepted either way, but
+//! only survives until the next profile switch. OpenLogi never moves a device
+//! between modes on its own — the mode is written only after the user picks one.
+
+use hidpp::feature::onboard_profiles::ROM_SECTOR_FLAG;
+use serde::{Deserialize, Serialize};
+
+/// Whether `sector` names a ROM (factory) profile rather than a writable user
+/// profile. Takes a bare sector so callers holding only
+/// [`OnboardProfilesInfo::active_profile`] can ask too.
+#[must_use]
+pub fn is_rom_sector(sector: u16) -> bool {
+ sector & ROM_SECTOR_FLAG != 0
+}
+
+/// Whether a gaming device applies its onboard flash profile or host software
+/// settings.
+///
+/// Crosses the agent↔GUI IPC — serde encodes the variant *index* (Host=0,
+/// Onboard=1), not a firmware byte — so variant order is wire format and
+/// changes require a `PROTOCOL_VERSION` bump (guarded by
+/// `openlogi-agent-core/tests/wire_format.rs`). The firmware byte mapping
+/// lives in `write::onboard_profiles`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ProfilesMode {
+ /// The host drives the device; onboard profiles are dormant.
+ Host,
+ /// The device applies the profile stored in its onboard memory.
+ Onboard,
+}
+
+/// One entry of the device's onboard profile directory.
+///
+/// Crosses the agent↔GUI IPC (`read_onboard_profiles`), so field order is wire
+/// format — changes require a `PROTOCOL_VERSION` bump.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ProfileEntry {
+ /// Flash sector holding the profile. User profiles live in sectors
+ /// `0x0001..`; ROM (factory) profiles carry the `0x0100` flag.
+ pub sector: u16,
+ /// Whether the profile is enabled on the device.
+ pub enabled: bool,
+}
+
+impl ProfileEntry {
+ /// Whether this is a ROM (factory) profile rather than a writable user
+ /// profile.
+ #[must_use]
+ pub fn is_rom(&self) -> bool {
+ is_rom_sector(self.sector)
+ }
+}
+
+/// Snapshot of a device's onboard-profiles state.
+///
+/// Crosses the agent↔GUI IPC (`read_onboard_profiles`), so field order is wire
+/// format — changes require a `PROTOCOL_VERSION` bump (guarded by
+/// `openlogi-agent-core/tests/wire_format.rs`).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct OnboardProfilesInfo {
+ /// Number of writable user profiles.
+ pub profile_count: u8,
+ /// Number of out-of-box (ROM) profiles.
+ pub profile_count_oob: u8,
+ /// Number of physical buttons covered by a profile.
+ pub button_count: u8,
+ /// Number of writable flash sectors.
+ pub sector_count: u8,
+ /// Size of one flash sector in bytes.
+ pub sector_size: u16,
+ /// Memory model identifier (raw, informational).
+ pub memory_model_id: u8,
+ /// Profile format identifier (raw, informational).
+ pub profile_format_id: u8,
+ /// Macro format identifier (raw, informational).
+ pub macro_format_id: u8,
+ /// Whether the device is in host or onboard mode.
+ pub mode: ProfilesMode,
+ /// Sector of the active profile, or `0x0000` for "none active" — which is
+ /// what a device in [`ProfilesMode::Host`] reports, since host mode parks
+ /// the flash profile. Not a writable target.
+ pub active_profile: u16,
+ /// The profile directory (enabled and disabled entries).
+ pub directory: Vec,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn rom_flag_is_detected() {
+ assert!(
+ ProfileEntry {
+ sector: 0x0101,
+ enabled: true
+ }
+ .is_rom()
+ );
+ assert!(
+ !ProfileEntry {
+ sector: 0x0002,
+ enabled: true
+ }
+ .is_rom()
+ );
+ }
+}
diff --git a/crates/openlogi-hid/src/pairing.rs b/crates/openlogi-hid/src/pairing.rs
index 2f8acf67..88f46391 100644
--- a/crates/openlogi-hid/src/pairing.rs
+++ b/crates/openlogi-hid/src/pairing.rs
@@ -78,7 +78,8 @@ impl From for PairingPhase {
fn family_for(product_id: u16) -> Option {
if crate::BOLT_PIDS.contains(&product_id) {
Some(ReceiverFamily::Bolt)
- } else if crate::UNIFYING_PIDS.contains(&product_id) {
+ } else if crate::speaks_unifying_protocol(product_id) {
+ // Unifying proper plus protocol-compatible Lightspeed receivers.
Some(ReceiverFamily::Unifying)
} else {
None
diff --git a/crates/openlogi-hid/src/route.rs b/crates/openlogi-hid/src/route.rs
index c9ecfba5..a19acc9f 100644
--- a/crates/openlogi-hid/src/route.rs
+++ b/crates/openlogi-hid/src/route.rs
@@ -72,6 +72,35 @@ pub const BOLT_PIDS: &[u16] = &[0xc548];
/// need to construct the correct [`DeviceRoute`] variant from a raw inventory.
pub const UNIFYING_PIDS: &[u16] = &[0xc52b, 0xc532];
+/// USB product IDs that identify Logitech Lightspeed nano receivers — the
+/// receivers bundled with G-series wireless mice such as the G305 (`0xc53f`)
+/// and the G502 X LIGHTSPEED (`0xc547`). They speak the same HID++ 1.0
+/// receiver register protocol as Unifying, so they are enumerated, routed, and
+/// paired through the Unifying code path; only the user-facing receiver name
+/// (see [`receiver_display_name`]) differs.
+pub const LIGHTSPEED_PIDS: &[u16] = &[0xc53f, 0xc547];
+
+/// Whether `product_id` is a receiver that speaks the Unifying HID++ 1.0
+/// register protocol — a Unifying receiver proper, or a protocol-compatible
+/// Lightspeed receiver. Such receivers are addressed with
+/// [`DeviceRoute::Unifying`].
+#[must_use]
+pub fn speaks_unifying_protocol(product_id: u16) -> bool {
+ UNIFYING_PIDS.contains(&product_id) || LIGHTSPEED_PIDS.contains(&product_id)
+}
+
+/// Human-readable name for a receiver identified by `product_id`, used to label
+/// it in the inventory. Lightspeed receivers share the Unifying protocol path
+/// but are surfaced under their own name.
+#[must_use]
+pub fn receiver_display_name(product_id: u16) -> &'static str {
+ if LIGHTSPEED_PIDS.contains(&product_id) {
+ "Lightspeed Receiver"
+ } else {
+ "Unifying Receiver"
+ }
+}
+
impl DeviceRoute {
/// The HID++ device index features are addressed at for this route: the
/// pairing slot for a Bolt device, the self-index for a direct one.
@@ -86,25 +115,29 @@ impl DeviceRoute {
/// Build the route that reaches a paired device from a receiver inventory.
///
/// Picks [`DeviceRoute::Unifying`] or [`DeviceRoute::Bolt`] based on the
- /// receiver's product ID using the canonical `UNIFYING_PIDS` / `BOLT_PIDS`
- /// lists. Any receiver PID not in `UNIFYING_PIDS` — including future Bolt
- /// variants whose PID isn't yet in `BOLT_PIDS` — defaults to
- /// [`DeviceRoute::Bolt`] so writes keep working rather than silently
- /// dropping. [`DeviceRoute::Direct`] is used for directly-attached devices
+ /// receiver's product ID via [`speaks_unifying_protocol`] (Unifying proper
+ /// plus protocol-compatible Lightspeed receivers). Any receiver that does
+ /// not speak the Unifying protocol — including future Bolt variants whose
+ /// PID isn't yet in `BOLT_PIDS` — defaults to [`DeviceRoute::Bolt`] so
+ /// writes keep working rather than silently dropping.
+ /// [`DeviceRoute::Direct`] is used for directly-attached devices
/// (slot == [`DIRECT_DEVICE_INDEX`] with no receiver UID). Returns `None`
/// when the receiver UID is unknown (writes are skipped, not mis-routed).
#[must_use]
pub fn device_route_for(inv: &DeviceInventory, slot: u8) -> Option {
match &inv.receiver.unique_id {
- Some(uid) if UNIFYING_PIDS.contains(&inv.receiver.product_id) => Some(Self::Unifying {
- receiver_uid: uid.clone(),
- slot,
- }),
+ Some(uid) if speaks_unifying_protocol(inv.receiver.product_id) => {
+ Some(Self::Unifying {
+ receiver_uid: uid.clone(),
+ slot,
+ })
+ }
Some(uid) => {
- // Default to Bolt for any receiver whose PID is not in
- // UNIFYING_PIDS. This covers both known Bolt PIDs (BOLT_PIDS)
- // and any future Bolt-compatible receiver with a new PID —
- // returning None would silently drop writes for such receivers.
+ // Default to Bolt for any receiver that does not speak the
+ // Unifying protocol. This covers both known Bolt PIDs
+ // (BOLT_PIDS) and any future Bolt-compatible receiver with a new
+ // PID — returning None would silently drop writes for such
+ // receivers.
if !BOLT_PIDS.contains(&inv.receiver.product_id) {
tracing::debug!(
pid = format_args!("{:04x}", inv.receiver.product_id),
@@ -200,7 +233,9 @@ mod tests {
use openlogi_core::device::{DeviceInventory, ReceiverInfo};
- use super::{DIRECT_DEVICE_INDEX, DeviceRoute, UNIFYING_PIDS};
+ use super::{
+ DIRECT_DEVICE_INDEX, DeviceRoute, LIGHTSPEED_PIDS, UNIFYING_PIDS, receiver_display_name,
+ };
fn inv(product_id: u16, unique_id: Option<&str>) -> DeviceInventory {
DeviceInventory {
@@ -225,6 +260,27 @@ mod tests {
}
}
+ #[test]
+ fn device_route_for_lightspeed_pids_create_unifying_route() {
+ // Lightspeed nano receivers (e.g. the G305's) speak the Unifying
+ // protocol, so writes must be routed through DeviceRoute::Unifying —
+ // not defaulted to Bolt, which would address the pairing slot wrong.
+ for &pid in LIGHTSPEED_PIDS {
+ let route = DeviceRoute::device_route_for(&inv(pid, Some("A1B2")), 2);
+ assert!(
+ matches!(route, Some(DeviceRoute::Unifying { ref receiver_uid, slot: 2 }) if receiver_uid == "A1B2"),
+ "lightspeed pid {pid:#06x} should produce a Unifying route"
+ );
+ }
+ }
+
+ #[test]
+ fn lightspeed_receiver_has_its_own_display_name() {
+ assert_eq!(receiver_display_name(0xc53f), "Lightspeed Receiver");
+ assert_eq!(receiver_display_name(0xc547), "Lightspeed Receiver");
+ assert_eq!(receiver_display_name(0xc52b), "Unifying Receiver");
+ }
+
#[test]
fn device_route_for_bolt_pid_creates_bolt_route() {
// 0xC548 is Bolt; anything not in UNIFYING_PIDS defaults to Bolt so
diff --git a/crates/openlogi-hid/src/transport.rs b/crates/openlogi-hid/src/transport.rs
index 76e4a65d..9bfbb9de 100644
--- a/crates/openlogi-hid/src/transport.rs
+++ b/crates/openlogi-hid/src/transport.rs
@@ -178,6 +178,7 @@ fn is_receiver_child_sysfs_path(path: &str) -> bool {
crate::BOLT_PIDS
.iter()
.chain(crate::UNIFYING_PIDS.iter())
+ .chain(crate::LIGHTSPEED_PIDS.iter())
.any(|&pid| {
let marker = format!(":{LOGITECH_VID:04X}:{pid:04X}.");
// A parent component contains the marker followed by at least one
diff --git a/crates/openlogi-hid/src/write.rs b/crates/openlogi-hid/src/write.rs
index e01c79e2..f1ff64ac 100644
--- a/crates/openlogi-hid/src/write.rs
+++ b/crates/openlogi-hid/src/write.rs
@@ -10,6 +10,7 @@
use std::sync::Arc;
use hidpp::{channel::HidppChannel, device::Device, feature::CreatableFeature};
+use tokio::sync::{Mutex, MutexGuard};
use crate::route::{DeviceRoute, open_route_channel};
@@ -17,6 +18,7 @@ mod diagnostics;
mod dpi;
mod error;
mod lighting;
+mod onboard_profiles;
mod shared;
mod smartshift;
@@ -24,7 +26,12 @@ pub use diagnostics::{FeatureEntry, ReprogControlEntry, dump_features, dump_repr
pub use dpi::{DpiCapabilities, DpiInfo, get_dpi, get_dpi_info, set_dpi};
pub use error::{HidppFeatureErrorKind, HidppOperation, WriteError};
pub use lighting::{LightingMethod, set_keyboard_color, set_keyboard_color_with};
-pub use shared::{SharedChannel, set_dpi_on, set_smartshift_on, toggle_smartshift_on};
+pub use onboard_profiles::{
+ apply_profiles_config, get_onboard_profiles, set_active_profile, set_profiles_mode,
+};
+pub use shared::{
+ SharedChannel, apply_profiles_config_on, set_dpi_on, set_smartshift_on, toggle_smartshift_on,
+};
pub use smartshift::{
get_smartshift_status, set_smartshift, set_smartshift_sensitivity, toggle_smartshift,
};
@@ -51,13 +58,37 @@ pub(crate) async fn open_feature(
Ok(device.add_feature::(info.index))
}
+/// Serializes device verbs so two never have requests in flight at once.
+///
+/// HID++ correlates a reply to its request only by
+/// `(device index, feature index, function, software id)`, and every verb here
+/// starts by resolving its feature with the *same* `0x0000 getFeature` header at
+/// the channel's fixed software id — so concurrent verbs on one device match
+/// each other's replies. Bench-observed on a G502 X LIGHTSPEED: a DPI write
+/// racing an onboard-mode write fails `InvalidArgument` 3/3 (the DPI payload
+/// reaches `0x8100`'s index), a wheel write racing a DPI write reports `0x2121`
+/// unsupported though the feature table lists it, and a `get_dpi` racing a mode
+/// write returns **0 with no error**. Sequentially, all of them succeed.
+///
+/// ponytail: one global lock, not per-device — device I/O is a handful of calls
+/// per user action. Key it by route if two devices' writes ever need to overlap.
+static EXCHANGE: Mutex<()> = Mutex::const_new(());
+
+/// Hold the device-exchange lock for the duration of one verb. Never taken
+/// twice on one path: the `*_on_channel` internals assume the caller holds it.
+pub(crate) async fn exchange() -> MutexGuard<'static, ()> {
+ EXCHANGE.lock().await
+}
+
/// Boilerplate-eater: open the channel that reaches `route`, then run `f` once
/// with it. The caller addresses features at [`DeviceRoute::device_index`].
+/// Holds [`exchange`] across both, so the enumerate-and-open is serialized too.
pub(crate) async fn with_route(route: &DeviceRoute, f: F) -> Result
where
F: FnOnce(Arc) -> Fut,
Fut: std::future::Future