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>, { + let _guard = exchange().await; match open_route_channel(route).await? { Some(channel) => f(channel).await, None => Err(WriteError::DeviceNotFound), diff --git a/crates/openlogi-hid/src/write/error.rs b/crates/openlogi-hid/src/write/error.rs index 5d4fee7b..dc9ddf19 100644 --- a/crates/openlogi-hid/src/write/error.rs +++ b/crates/openlogi-hid/src/write/error.rs @@ -100,6 +100,11 @@ pub enum HidppOperation { ReadWheelMode, /// Write and verify the native HiResWheel mode. WriteWheelMode, + /// Read onboard-profiles state (description, mode, active profile, + /// directory). + ReadOnboardProfiles, + /// Write the onboard mode or the active onboard profile. + WriteOnboardProfiles, } /// HID++ feature error kind in a serializable wire-safe form. diff --git a/crates/openlogi-hid/src/write/onboard_profiles.rs b/crates/openlogi-hid/src/write/onboard_profiles.rs new file mode 100644 index 00000000..dbfd7c65 --- /dev/null +++ b/crates/openlogi-hid/src/write/onboard_profiles.rs @@ -0,0 +1,268 @@ +use std::sync::Arc; + +use hidpp::{ + channel::HidppChannel, + device::Device, + feature::{ + CreatableFeature, + onboard_profiles::{OnboardMode, OnboardProfilesFeature}, + }, +}; +use tracing::debug; + +use crate::onboard_profiles::{OnboardProfilesInfo, ProfileEntry, ProfilesMode}; +use crate::route::DeviceRoute; + +use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route}; + +/// Map the fork's `0x8100` [`OnboardMode`] onto OpenLogi's [`ProfilesMode`]. +/// A future `#[non_exhaustive]` variant maps to [`ProfilesMode::Onboard`] — +/// the conservative reading that keeps the agent treating the device as +/// self-driven until told otherwise. (Reserved wire bytes never reach here — +/// the fork's `get_onboard_mode` rejects them.) +pub(super) fn onboard_mode_to_profiles(mode: OnboardMode) -> ProfilesMode { + if matches!(mode, OnboardMode::Host) { + ProfilesMode::Host + } else { + ProfilesMode::Onboard + } +} + +/// Map OpenLogi's [`ProfilesMode`] onto the fork's `0x8100` [`OnboardMode`] — +/// the inverse of [`onboard_mode_to_profiles`], used when writing the mode. +pub(super) fn profiles_to_onboard_mode(mode: ProfilesMode) -> OnboardMode { + match mode { + ProfilesMode::Host => OnboardMode::Host, + ProfilesMode::Onboard => OnboardMode::Onboard, + } +} + +/// Open the `0x8100` feature on an already-open channel at HID++ `index`. +async fn open_profiles( + channel: &Arc, + index: u8, +) -> Result<(Device, Arc), WriteError> { + let mut device = Device::new(Arc::clone(channel), index) + .await + .map_err(|_| WriteError::DeviceUnreachable { index })?; + let feature = open_feature::(&mut device).await?; + Ok((device, feature)) +} + +/// Read the full onboard-profiles state of the device addressed by `route`: +/// memory description, mode, active profile, and the profile directory. +/// +/// `FeatureUnsupported` when the device has no HID++ `0x8100` — i.e. it is not +/// a gaming device with onboard profile memory. +pub async fn get_onboard_profiles(route: &DeviceRoute) -> Result { + let index = route.device_index(); + with_route(route, move |channel| async move { + let (_device, feature) = open_profiles(&channel, index).await?; + + let read = |e| { + classify_hidpp_error( + e, + HidppOperation::ReadOnboardProfiles, + OnboardProfilesFeature::ID, + ) + }; + let descr = feature.get_description().await.map_err(read)?; + let mode = feature.get_onboard_mode().await.map_err(read)?; + let active_profile = feature.get_current_profile().await.map_err(read)?; + // User *and* ROM profiles share sector 0's directory, and the ROM + // entries sit after the user ones — bounding the read by + // `profile_count` alone drops them before the terminator is reached. + let total_profiles = descr.profile_count.saturating_add(descr.profile_count_oob); + let directory = feature + .read_profile_directory(total_profiles) + .await + .map_err(read)? + .into_iter() + .map(|entry| ProfileEntry { + sector: entry.sector, + enabled: entry.enabled, + }) + .collect(); + + Ok(OnboardProfilesInfo { + profile_count: descr.profile_count, + profile_count_oob: descr.profile_count_oob, + button_count: descr.button_count, + sector_count: descr.sector_count, + sector_size: descr.sector_size, + memory_model_id: descr.memory_model_id, + profile_format_id: descr.profile_format_id, + macro_format_id: descr.macro_format_id, + mode: onboard_mode_to_profiles(mode), + active_profile, + directory, + }) + }) + .await +} + +/// Write the onboard/host mode on `route` and return the read-back mode so the +/// caller can verify the firmware accepted it. +pub async fn set_profiles_mode( + route: &DeviceRoute, + mode: ProfilesMode, +) -> Result { + let index = route.device_index(); + with_route(route, move |channel| async move { + let (_device, feature) = open_profiles(&channel, index).await?; + write_mode(&feature, index, mode).await?; + read_mode(&feature).await + }) + .await +} + +/// Write the active profile `sector` on `route` and return the read-back +/// sector so the caller can verify the firmware accepted it. +pub async fn set_active_profile(route: &DeviceRoute, sector: u16) -> Result { + let index = route.device_index(); + with_route(route, move |channel| async move { + let (_device, feature) = open_profiles(&channel, index).await?; + feature.set_current_profile(sector).await.map_err(|e| { + classify_hidpp_error( + e, + HidppOperation::WriteOnboardProfiles, + OnboardProfilesFeature::ID, + ) + })?; + debug!(index, sector, "wrote active onboard profile"); + feature.get_current_profile().await.map_err(|e| { + classify_hidpp_error( + e, + HidppOperation::ReadOnboardProfiles, + OnboardProfilesFeature::ID, + ) + }) + }) + .await +} + +/// Apply the persisted onboard-profiles configuration to the device addressed +/// by `route`: put it in `mode`, and in onboard mode also activate `profile` +/// when given. Skips writes the device already matches, so re-applying on +/// every reconnect costs one or two reads on an already-configured device. +/// Returns whether anything was written. +/// +/// The mode is volatile — devices revert to onboard mode on power cycle — so +/// the agent re-applies this whenever a device (re)appears. +pub async fn apply_profiles_config( + route: &DeviceRoute, + mode: ProfilesMode, + profile: Option, +) -> Result { + let index = route.device_index(); + with_route(route, move |channel| async move { + apply_profiles_config_on_channel(&channel, index, mode, profile).await + }) + .await +} + +/// The config apply itself, on an already-open channel at HID++ `index`. +/// Shared by [`apply_profiles_config`] and +/// [`apply_profiles_config_on`](super::apply_profiles_config_on). +pub(super) async fn apply_profiles_config_on_channel( + channel: &Arc, + index: u8, + mode: ProfilesMode, + profile: Option, +) -> Result { + let (_device, feature) = open_profiles(channel, index).await?; + + let mut written = false; + + let current = read_mode(&feature).await?; + if current != mode { + write_mode(&feature, index, mode).await?; + // Read back to confirm the firmware accepted the mode. Mirrors the DPI + // write path: a mismatch — or a failed read-back, which happens when + // the read races the device's mode transition (observed on a G502 X) + // — is logged, not fatal, because the request reached the device. + match read_mode(&feature).await { + Ok(actual) if actual != mode => tracing::warn!( + index, + requested = ?mode, + ?actual, + "onboard mode write accepted but device reports a different mode" + ), + Ok(_) => {} + Err(error) => debug!(index, %error, "onboard mode read-back skipped"), + } + written = true; + } + + if mode == ProfilesMode::Onboard + && let Some(sector) = profile + { + let read = |e| { + classify_hidpp_error( + e, + HidppOperation::ReadOnboardProfiles, + OnboardProfilesFeature::ID, + ) + }; + let active = feature.get_current_profile().await.map_err(read)?; + if active != sector { + feature.set_current_profile(sector).await.map_err(|e| { + classify_hidpp_error( + e, + HidppOperation::WriteOnboardProfiles, + OnboardProfilesFeature::ID, + ) + })?; + match feature.get_current_profile().await { + Ok(actual) if actual != sector => tracing::warn!( + index, + requested = sector, + actual, + "active-profile write accepted but device reports a different sector" + ), + Ok(_) => {} + Err(error) => { + debug!(index, %error, "active-profile read-back skipped"); + } + } + written = true; + } + } + + if written { + debug!(index, ?mode, ?profile, "applied onboard-profiles config"); + } + Ok(written) +} + +/// Read the current mode through the OpenLogi error mapping. +async fn read_mode(feature: &Arc) -> Result { + let mode = feature.get_onboard_mode().await.map_err(|e| { + classify_hidpp_error( + e, + HidppOperation::ReadOnboardProfiles, + OnboardProfilesFeature::ID, + ) + })?; + Ok(onboard_mode_to_profiles(mode)) +} + +/// Write `mode` through the OpenLogi error mapping. +async fn write_mode( + feature: &Arc, + index: u8, + mode: ProfilesMode, +) -> Result<(), WriteError> { + feature + .set_onboard_mode(profiles_to_onboard_mode(mode)) + .await + .map_err(|e| { + classify_hidpp_error( + e, + HidppOperation::WriteOnboardProfiles, + OnboardProfilesFeature::ID, + ) + })?; + debug!(index, ?mode, "wrote onboard mode"); + Ok(()) +} diff --git a/crates/openlogi-hid/src/write/shared.rs b/crates/openlogi-hid/src/write/shared.rs index aa7a6110..b7f29803 100644 --- a/crates/openlogi-hid/src/write/shared.rs +++ b/crates/openlogi-hid/src/write/shared.rs @@ -5,9 +5,10 @@ use hidpp::channel::HidppChannel; use crate::route::DeviceRoute; use crate::smartshift::SmartShiftMode; -use super::WriteError; use super::dpi::set_dpi_on_channel; +use super::onboard_profiles::apply_profiles_config_on_channel; use super::smartshift::{set_smartshift_on_channel, toggle_smartshift_on_channel}; +use super::{WriteError, exchange}; /// An open HID++ channel to a device, shared so DPI / SmartShift writes can /// reuse the capture session's connection instead of re-enumerating and @@ -47,15 +48,34 @@ impl SharedChannel { /// Write DPI on an already-open [`SharedChannel`] — the fast path that skips /// enumeration and channel setup. +/// +/// Skipping the open does not skip [`exchange`]: sharing one channel makes the +/// reply cross-talk it guards against more likely, not less, because both verbs +/// then wait on the same pending queue. pub async fn set_dpi_on(shared: &SharedChannel, dpi: u16) -> Result<(), WriteError> { + let _guard = exchange().await; set_dpi_on_channel(&shared.channel, shared.route.device_index(), dpi).await } /// Toggle SmartShift on an already-open [`SharedChannel`]. pub async fn toggle_smartshift_on(shared: &SharedChannel) -> Result { + let _guard = exchange().await; toggle_smartshift_on_channel(&shared.channel, shared.route.device_index()).await } +/// Apply the persisted onboard-profiles configuration on an already-open +/// [`SharedChannel`] — the fast path that skips enumeration and channel setup. +/// Returns whether anything was written. +pub async fn apply_profiles_config_on( + shared: &SharedChannel, + mode: crate::onboard_profiles::ProfilesMode, + profile: Option, +) -> Result { + let _guard = exchange().await; + apply_profiles_config_on_channel(&shared.channel, shared.route.device_index(), mode, profile) + .await +} + /// Write a full SmartShift configuration on an already-open [`SharedChannel`] /// — the fast path that skips enumeration and channel setup. pub async fn set_smartshift_on( @@ -64,6 +84,7 @@ pub async fn set_smartshift_on( auto_disengage: u8, tunable_torque: u8, ) -> Result<(), WriteError> { + let _guard = exchange().await; set_smartshift_on_channel( &shared.channel, shared.route.device_index(), diff --git a/crates/openlogi-hid/src/write/tests.rs b/crates/openlogi-hid/src/write/tests.rs index 4fc1896f..35e352e1 100644 --- a/crates/openlogi-hid/src/write/tests.rs +++ b/crates/openlogi-hid/src/write/tests.rs @@ -3,10 +3,13 @@ use std::assert_matches; use super::*; use hidpp::feature::smartshift::WheelMode; -use crate::SmartShiftMode; +use hidpp::feature::onboard_profiles::OnboardMode; + +use crate::write::onboard_profiles::{onboard_mode_to_profiles, profiles_to_onboard_mode}; use crate::write::smartshift::{ is_missing_enhanced, smartshift_to_wheel, wheel_mode_to_smartshift, }; +use crate::{ProfilesMode, SmartShiftMode}; #[test] fn capabilities_sort_and_deduplicate_values() -> Result<(), WriteError> { @@ -98,6 +101,29 @@ fn smartshift_to_wheel_round_trips() { } } +#[test] +fn onboard_mode_maps_to_profiles_mode() { + assert_eq!( + onboard_mode_to_profiles(OnboardMode::Host), + ProfilesMode::Host + ); + assert_eq!( + onboard_mode_to_profiles(OnboardMode::Onboard), + ProfilesMode::Onboard + ); +} + +#[test] +fn profiles_to_onboard_mode_round_trips() { + // profiles_to_onboard_mode is the inverse of onboard_mode_to_profiles. + for mode in [ProfilesMode::Host, ProfilesMode::Onboard] { + assert_eq!( + onboard_mode_to_profiles(profiles_to_onboard_mode(mode)), + mode + ); + } +} + #[test] fn missing_enhanced_triggers_fallback() { assert!(is_missing_enhanced(&WriteError::FeatureUnsupported { diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs index ec39e990..8266cda6 100755 --- a/crates/openlogi-hidpp/src/feature/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mod.rs @@ -31,6 +31,7 @@ pub mod illumination; pub mod mode_status; pub mod mouse_pointer; pub mod multi_platform; +pub mod onboard_profiles; pub mod per_key_lighting; pub mod persistent_remappable_action; pub mod registry; diff --git a/crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs b/crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs new file mode 100644 index 00000000..ce80c485 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs @@ -0,0 +1,145 @@ +//! Implements the `OnboardProfiles` feature (ID `0x8100`) that controls a +//! gaming device's onboard profile memory. +//! +//! In onboard mode the device runs a profile stored in its own flash; in host +//! mode that profile lies dormant and the host drives the device — but the host +//! must then supply what the profile used to (a G502 X LIGHTSPEED in host mode +//! has no DPI stages and a dark LED until something writes them). +//! This implementation covers reading the memory description, getting +//! and setting the mode and the active profile, and reading flash sectors — +//! enough to parse the profile directory. The flash *write* session +//! (`memoryAddrWrite` / `memoryWrite` / `memoryWriteEnd`, functions 6–8) is +//! deliberately not implemented: OpenLogi does not edit onboard profiles. +//! +//! The official `x8100` specification is not public; the protocol facts here +//! are reverse-engineered, cross-checked against libratbag (`hidpp20.c`) and +//! Solaar (`hidpp20.py`). All multi-byte fields are big-endian. + +pub mod types; + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +pub use types::{ + DIRECTORY_ENTRY_LEN, DIRECTORY_SECTOR, OnboardMode, ProfileDirectoryEntry, ProfilesDescription, + ROM_SECTOR_FLAG, +}; + +use self::types::{DIRECTORY_END, be16, parse_directory}; +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +/// Implements the `OnboardProfiles` / `0x8100` feature. +pub struct OnboardProfilesFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for OnboardProfilesFeature { + const ID: u16 = 0x8100; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for OnboardProfilesFeature {} + +impl OnboardProfilesFeature { + /// Retrieves the description of the device's profile memory. + pub async fn get_description(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + + Ok(ProfilesDescription::from_payload(&payload)) + } + + /// Sets whether the device applies its onboard profile or host settings. + pub async fn set_onboard_mode(&self, mode: OnboardMode) -> Result<(), Hidpp20Error> { + self.endpoint.call(1, [mode.into(), 0, 0]).await?; + + Ok(()) + } + + /// Retrieves whether the device applies its onboard profile or host + /// settings. + pub async fn get_onboard_mode(&self) -> Result { + let payload = self.endpoint.call(2, [0; 3]).await?.extend_payload(); + + OnboardMode::try_from(payload[0]).map_err(|_| Hidpp20Error::UnsupportedResponse) + } + + /// Sets the active profile by its flash sector. + /// + /// User profiles live in sectors `0x0001..`; ROM profiles carry + /// [`ROM_SECTOR_FLAG`](types::ROM_SECTOR_FLAG). + /// + /// Only legal in [`OnboardMode::Onboard`]: in host mode the firmware + /// rejects this with an invalid-argument error (observed on a G502 X + /// LIGHTSPEED; the official spec is not public). + pub async fn set_current_profile(&self, sector: u16) -> Result<(), Hidpp20Error> { + let [hi, lo] = sector.to_be_bytes(); + self.endpoint.call(3, [hi, lo, 0]).await?; + + Ok(()) + } + + /// Retrieves the sector of the active profile. + /// + /// Devices report `0x0000` when no profile has been activated yet. + pub async fn get_current_profile(&self) -> Result { + let payload = self.endpoint.call(4, [0; 3]).await?.extend_payload(); + + Ok(be16(&payload, 0)) + } + + /// Reads 16 bytes of flash at `offset` of `sector`. + /// + /// The firmware rejects reads past `sector_size - 16` with an + /// invalid-argument error, so a full-sector read must fetch the final + /// partial chunk from `sector_size - 16`. + pub async fn memory_read(&self, sector: u16, offset: u16) -> Result<[u8; 16], Hidpp20Error> { + let mut args = [0; 16]; + args[..2].copy_from_slice(§or.to_be_bytes()); + args[2..4].copy_from_slice(&offset.to_be_bytes()); + + Ok(self.endpoint.call_long(5, args).await?.extend_payload()) + } + + /// Reads and parses the profile directory from sector + /// [`DIRECTORY_SECTOR`](types::DIRECTORY_SECTOR). + /// + /// `profile_count` (from [`Self::get_description`]) bounds the number of + /// entries; reading stops early at the directory terminator. + pub async fn read_profile_directory( + &self, + profile_count: u8, + ) -> Result, Hidpp20Error> { + let max_entries = usize::from(profile_count); + // Room for every entry plus the terminator entry. + let needed = (max_entries + 1) * DIRECTORY_ENTRY_LEN; + + let mut bytes: Vec = Vec::with_capacity(needed.next_multiple_of(16)); + while bytes.len() < needed && !contains_terminator(&bytes) { + let offset = + u16::try_from(bytes.len()).map_err(|_| Hidpp20Error::UnsupportedResponse)?; + bytes.extend_from_slice(&self.memory_read(DIRECTORY_SECTOR, offset).await?); + } + + parse_directory(&bytes, max_entries) + } +} + +/// Whether any complete directory entry in `bytes` is the terminator. +fn contains_terminator(bytes: &[u8]) -> bool { + bytes + .chunks_exact(DIRECTORY_ENTRY_LEN) + .any(|entry| be16(entry, 0) == DIRECTORY_END) +} diff --git a/crates/openlogi-hidpp/src/feature/onboard_profiles/tests.rs b/crates/openlogi-hidpp/src/feature/onboard_profiles/tests.rs new file mode 100644 index 00000000..a94dac56 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/onboard_profiles/tests.rs @@ -0,0 +1,131 @@ +use super::types::{OnboardMode, ProfileDirectoryEntry, ProfilesDescription, parse_directory}; +use crate::protocol::v20::Hidpp20Error; + +#[test] +fn parses_full_description_payload() { + // As read from a G502 X LIGHTSPEED: 5 user profiles, 2 ROM, 11 buttons, + // 16 sectors of 255 bytes (0x00ff big-endian at offset 7). The odd sector + // size is the point — it feeds the `sector_size - 16` read bound, so a + // rounded-up 256 here would hide an off-by-one. + let payload = [1, 3, 1, 5, 2, 11, 16, 0x00, 0xff, 0x04, 0x07, 0, 0, 0, 0, 0]; + + let descr = ProfilesDescription::from_payload(&payload); + + assert_eq!(descr.memory_model_id, 1); + assert_eq!(descr.profile_format_id, 3); + assert_eq!(descr.macro_format_id, 1); + assert_eq!(descr.profile_count, 5); + assert_eq!(descr.profile_count_oob, 2); + assert_eq!(descr.button_count, 11); + assert_eq!(descr.sector_count, 16); + assert_eq!(descr.sector_size, 255); + assert_eq!(descr.mechanical_layout, 0x04); + assert_eq!(descr.various_info, 0x07); +} + +#[test] +fn onboard_mode_roundtrips_known_values() { + assert_eq!(OnboardMode::try_from(1), Ok(OnboardMode::Onboard)); + assert_eq!(OnboardMode::try_from(2), Ok(OnboardMode::Host)); + assert_eq!(u8::from(OnboardMode::Onboard), 1); + assert_eq!(u8::from(OnboardMode::Host), 2); +} + +#[test] +fn rejects_unknown_mode_discriminants() { + // 0 is "no change" in set requests and never a valid reported mode. + assert!(OnboardMode::try_from(0).is_err()); + assert!(OnboardMode::try_from(3).is_err()); +} + +#[test] +fn parse_directory_stops_at_terminator() { + let bytes = [ + 0x00, 0x01, 0x01, 0x00, // sector 1, enabled + 0x00, 0x02, 0x00, 0x00, // sector 2, disabled + 0xff, 0xff, 0xff, 0xff, // terminator + 0x00, 0x03, 0x01, 0x00, // past the terminator, must be ignored + ]; + + let entries = parse_directory(&bytes, 5).expect("directory should parse"); + + assert_eq!( + entries, + vec![ + ProfileDirectoryEntry { + sector: 1, + enabled: true + }, + ProfileDirectoryEntry { + sector: 2, + enabled: false + }, + ] + ); +} + +#[test] +fn parse_directory_handles_erased_flash() { + // A never-written directory reads back as erased flash. + let entries = parse_directory(&[0xff; 16], 5).expect("erased flash should parse"); + + assert!(entries.is_empty()); +} + +#[test] +fn parse_directory_respects_max_entries() { + // No terminator within the bound: a full directory simply fills up. + let bytes = [ + 0x00, 0x01, 0x01, 0x00, // + 0x00, 0x02, 0x01, 0x00, // + 0x00, 0x03, 0x01, 0x00, // + ]; + + let entries = parse_directory(&bytes, 2).expect("bounded parse should succeed"); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[1].sector, 2); +} + +#[test] +fn parse_directory_rejects_unknown_enabled_byte() { + let bytes = [0x00, 0x01, 0x02, 0x00]; + + assert!(matches!( + parse_directory(&bytes, 5), + Err(Hidpp20Error::UnsupportedResponse) + )); +} + +#[test] +fn parse_directory_rejects_truncated_entry() { + // Two full entries, then a 2-byte tail with neither terminator nor bound. + let bytes = [ + 0x00, 0x01, 0x01, 0x00, // + 0x00, 0x02, 0x01, 0x00, // + 0x00, 0x03, + ]; + + assert!(matches!( + parse_directory(&bytes, 5), + Err(Hidpp20Error::UnsupportedResponse) + )); +} + +#[test] +fn parse_directory_accepts_rom_sectors() { + let bytes = [ + 0x01, 0x01, 0x01, 0x00, // ROM profile 1 + 0xff, 0xff, 0xff, 0xff, + ]; + + let entries = parse_directory(&bytes, 5).expect("ROM entry should parse"); + + assert_eq!( + entries, + vec![ProfileDirectoryEntry { + sector: 0x0101, + enabled: true + }] + ); +} diff --git a/crates/openlogi-hidpp/src/feature/onboard_profiles/types.rs b/crates/openlogi-hidpp/src/feature/onboard_profiles/types.rs new file mode 100644 index 00000000..7f9706a9 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/onboard_profiles/types.rs @@ -0,0 +1,149 @@ +//! Domain types for the `OnboardProfiles` feature (`0x8100`). + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +use crate::protocol::v20::Hidpp20Error; + +/// Sector holding the profile directory. +pub const DIRECTORY_SECTOR: u16 = 0x0000; + +/// Bit set in sector numbers referring to ROM (factory) profiles rather than +/// writable user profiles. +pub const ROM_SECTOR_FLAG: u16 = 0x0100; + +/// Sector value terminating the profile directory. Also what erased flash +/// (`0xFF` bytes) reads back as, so an empty directory parses as no entries. +pub const DIRECTORY_END: u16 = 0xffff; + +/// Size of one profile-directory entry in bytes. +pub const DIRECTORY_ENTRY_LEN: usize = 4; + +/// Reads a big-endian `u16` at `offset` of a payload. +pub(super) fn be16(payload: &[u8], offset: usize) -> u16 { + u16::from_be_bytes([payload[offset], payload[offset + 1]]) +} + +/// Whether profile settings come from onboard flash or host software. +/// +/// The wire encoding also defines `0x00` as "no change" for set requests; it is +/// never a valid mode report, so it is deliberately not representable here. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum OnboardMode { + /// The device applies the profile stored in its onboard memory. + Onboard = 1, + /// The device takes its settings from host software. + Host = 2, +} + +/// The `getProfilesDescription` response describing the device's profile +/// memory. +/// +/// Field order matches the wire layout as implemented by libratbag +/// (`hidpp20_onboard_profiles_info`); the official `x8100` specification is not +/// public. The format ids are kept raw — they are informational and never +/// branched on. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct ProfilesDescription { + /// Memory model identifier. + pub memory_model_id: u8, + + /// Profile format identifier. + pub profile_format_id: u8, + + /// Macro format identifier. + pub macro_format_id: u8, + + /// 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, + + /// Mechanical layout descriptor (raw). + pub mechanical_layout: u8, + + /// Additional device info (raw). + pub various_info: u8, +} + +impl ProfilesDescription { + /// Parses a description from a `getProfilesDescription` response payload. + pub(super) fn from_payload(payload: &[u8; 16]) -> Self { + Self { + memory_model_id: payload[0], + profile_format_id: payload[1], + macro_format_id: payload[2], + profile_count: payload[3], + profile_count_oob: payload[4], + button_count: payload[5], + sector_count: payload[6], + sector_size: be16(payload, 7), + mechanical_layout: payload[9], + various_info: payload[10], + } + } +} + +/// One entry of the profile directory in sector +/// [`DIRECTORY_SECTOR`](self::DIRECTORY_SECTOR). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct ProfileDirectoryEntry { + /// The flash sector holding the profile. + pub sector: u16, + + /// Whether the profile is enabled. + pub enabled: bool, +} + +/// Parses profile-directory entries out of accumulated sector bytes. +/// +/// Entries are 4 bytes each — `sector` (big-endian `u16`), an enabled byte and +/// a reserved byte — and the directory ends at a [`DIRECTORY_END`] sector or +/// after `max_entries` entries, whichever comes first. Running out of bytes +/// before either bound, or an enabled byte other than `0`/`1`, is an +/// [`UnsupportedResponse`](Hidpp20Error::UnsupportedResponse). +pub(super) fn parse_directory( + bytes: &[u8], + max_entries: usize, +) -> Result, Hidpp20Error> { + let mut entries = Vec::new(); + let mut offset = 0; + + while entries.len() < max_entries { + let Some(entry) = bytes.get(offset..offset + DIRECTORY_ENTRY_LEN) else { + return Err(Hidpp20Error::UnsupportedResponse); + }; + + let sector = be16(entry, 0); + if sector == DIRECTORY_END { + break; + } + + let enabled = match entry[2] { + 0 => false, + 1 => true, + _ => return Err(Hidpp20Error::UnsupportedResponse), + }; + + entries.push(ProfileDirectoryEntry { sector, enabled }); + offset += DIRECTORY_ENTRY_LEN; + } + + Ok(entries) +} diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs index 83897373..affc8c88 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -35,6 +35,7 @@ use crate::{ mode_status::ModeStatusFeature, mouse_pointer::MousePointerFeature, multi_platform::MultiPlatformFeature, + onboard_profiles::OnboardProfilesFeature, per_key_lighting::PerKeyLightingFeature, persistent_remappable_action::PersistentRemappableActionFeature, report_rate::ReportRateFeature, @@ -238,7 +239,7 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x8080 "PerKeyLighting", 0x8081 "PerKeyLighting2" => PerKeyLightingFeature, 0x8090 "ModeStatus" => ModeStatusFeature, - 0x8100 "OnboardProfiles", + 0x8100 "OnboardProfiles" => OnboardProfilesFeature, 0x8110 "MouseButtonFilter", 0x8111 "LatencyMonitoring", 0x8120 "GamingAttachments", diff --git a/crates/openlogi-hidpp/src/receiver/unifying.rs b/crates/openlogi-hidpp/src/receiver/unifying.rs index 6a7eca7d..2bb74806 100755 --- a/crates/openlogi-hidpp/src/receiver/unifying.rs +++ b/crates/openlogi-hidpp/src/receiver/unifying.rs @@ -21,7 +21,22 @@ use crate::{ /// All USB vendor & product ID pairs that are known to identify Unifying /// receivers. -pub const VPID_PAIRS: &[(u16, u16)] = &[(0x046d, 0xc52b), (0x046d, 0xc532)]; +/// +/// `0xc53f` and `0xc547` are Lightspeed receivers (bundled with G-series +/// wireless mice — `0xc53f` with the G305, `0xc547` with the G502 X +/// LIGHTSPEED). They are not Unifying receivers, but they expose the same +/// HID++ 1.0 receiver registers (`0x02` connections, `0xB5/0x5N` pairing +/// info), so device enumeration goes through this implementation unchanged. +/// Callers that surface a user-facing receiver name label them separately +/// (see `openlogi-hid`). `0xc547` is verified on hardware (a G502 X LIGHTSPEED, +/// paired device wpid `0x409f`); `0xc53f` follows upstream `hidpp` PR #388 and +/// is not bench-verified here. +pub const VPID_PAIRS: &[(u16, u16)] = &[ + (0x046d, 0xc52b), + (0x046d, 0xc532), + (0x046d, 0xc53f), + (0x046d, 0xc547), +]; /// All known registers of the Unifying receiver. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)]