From 2068fbfd25304f95ed85e16a8016b37eb0bdb8df Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sat, 25 Jul 2026 01:30:24 +0800 Subject: [PATCH 01/21] feat(hidpp): add OnboardProfiles (0x8100) feature Memory description, onboard/host mode, active profile, flash sector reads and profile-directory parsing. Protocol cross-checked against libratbag hidpp20.c and Solaar hidpp20.py (official x8100 spec is not public). The flash write session (functions 6-8) is deliberately not implemented. --- crates/openlogi-hidpp/src/feature/mod.rs | 1 + .../src/feature/onboard_profiles/mod.rs | 139 ++++++++++++++++ .../src/feature/onboard_profiles/tests.rs | 129 +++++++++++++++ .../src/feature/onboard_profiles/types.rs | 149 ++++++++++++++++++ crates/openlogi-hidpp/src/feature/registry.rs | 3 +- 5 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/onboard_profiles/tests.rs create mode 100644 crates/openlogi-hidpp/src/feature/onboard_profiles/types.rs 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..e81632e8 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs @@ -0,0 +1,139 @@ +//! Implements the `OnboardProfiles` feature (ID `0x8100`) that controls a +//! gaming device's onboard profile memory. +//! +//! In onboard mode the device applies a profile stored in its own flash and +//! ignores most host software settings; in host mode the host drives the +//! device. 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). + 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..a26e21f9 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/onboard_profiles/tests.rs @@ -0,0 +1,129 @@ +use super::types::{OnboardMode, ProfileDirectoryEntry, ProfilesDescription, parse_directory}; +use crate::protocol::v20::Hidpp20Error; + +#[test] +fn parses_full_description_payload() { + // G502-style description: 5 user profiles, 3 OOB, 11 buttons, 16 sectors + // of 256 bytes (0x0100 big-endian at offset 7). + let payload = [1, 2, 3, 5, 3, 11, 16, 0x01, 0x00, 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, 2); + assert_eq!(descr.macro_format_id, 3); + assert_eq!(descr.profile_count, 5); + assert_eq!(descr.profile_count_oob, 3); + assert_eq!(descr.button_count, 11); + assert_eq!(descr.sector_count, 16); + assert_eq!(descr.sector_size, 256); + 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", From 90f07802a44a77e13055c4ac9673f14d83a70a65 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sat, 25 Jul 2026 00:24:18 +0800 Subject: [PATCH 02/21] fix(hid): declare Win32_System_IO feature for windows-sys WriteFile --- crates/openlogi-hid/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) 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] From 698fef5b5c4e116c12fb2d8421d1f5524a40daed Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sat, 25 Jul 2026 01:42:06 +0800 Subject: [PATCH 03/21] feat(hid): onboard-profiles read/apply verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfilesMode / ProfileEntry / OnboardProfilesInfo boundary types (serde, IPC wire format from day one), get_onboard_profiles, set_profiles_mode and set_active_profile with read-back, and apply_profiles_config — the agent-facing verb that skips writes the device already matches. Appends ReadOnboardProfiles / WriteOnboardProfiles to HidppOperation (wire-safe append; goldens updated with the PROTOCOL_VERSION bump). --- crates/openlogi-hid/src/lib.rs | 11 +- crates/openlogi-hid/src/onboard_profiles.rs | 101 +++++++ crates/openlogi-hid/src/write.rs | 8 +- crates/openlogi-hid/src/write/error.rs | 5 + .../src/write/onboard_profiles.rs | 259 ++++++++++++++++++ crates/openlogi-hid/src/write/shared.rs | 13 + crates/openlogi-hid/src/write/tests.rs | 28 +- 7 files changed, 419 insertions(+), 6 deletions(-) create mode 100644 crates/openlogi-hid/src/onboard_profiles.rs create mode 100644 crates/openlogi-hid/src/write/onboard_profiles.rs diff --git a/crates/openlogi-hid/src/lib.rs b/crates/openlogi-hid/src/lib.rs index b6cf1547..a1af4203 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,6 +38,7 @@ pub use hires_wheel::{ }; pub use hotplug::{HotplugEvent, watch_hotplug}; pub use inventory::{Enumerator, InventoryError, enumerate}; +pub use onboard_profiles::{OnboardProfilesInfo, ProfileEntry, ProfilesMode}; pub use pairing::{ Click, DiscoveredDevice, PairingCommand, PairingError, PairingEvent, PairingReceiver, PasskeyMethod, ReceiverFamily, ReceiverSelector, list_pairing_receivers, run_pairing, unpair, @@ -45,8 +47,9 @@ pub use route::{BOLT_PIDS, DIRECT_DEVICE_INDEX, DeviceRoute, UNIFYING_PIDS}; 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..8f34e681 --- /dev/null +++ b/crates/openlogi-hid/src/onboard_profiles.rs @@ -0,0 +1,101 @@ +//! 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 applies a profile from its own flash and ignores most host software +//! settings; OpenLogi therefore defaults such devices to host mode so the +//! configured DPI / buttons / report rate actually apply. + +use serde::{Deserialize, Serialize}; + +/// 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 { + self.sector & 0x0100 != 0 + } +} + +/// 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; `0x0000` when none has been activated. + 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/write.rs b/crates/openlogi-hid/src/write.rs index e01c79e2..85ee9159 100644 --- a/crates/openlogi-hid/src/write.rs +++ b/crates/openlogi-hid/src/write.rs @@ -17,6 +17,7 @@ mod diagnostics; mod dpi; mod error; mod lighting; +mod onboard_profiles; mod shared; mod smartshift; @@ -24,7 +25,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, }; 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..c4199b35 --- /dev/null +++ b/crates/openlogi-hid/src/write/onboard_profiles.rs @@ -0,0 +1,259 @@ +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)?; + let directory = feature + .read_profile_directory(descr.profile_count) + .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 is logged, not fatal, because the request + // reached the device. + let actual = read_mode(&feature).await?; + if actual != mode { + tracing::warn!( + index, + requested = ?mode, + ?actual, + "onboard mode write accepted but device reports a different mode" + ); + } + 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, + ) + })?; + let actual = feature.get_current_profile().await.map_err(read)?; + if actual != sector { + tracing::warn!( + index, + requested = sector, + actual, + "active-profile write accepted but device reports a different sector" + ); + } + 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..70fa4f65 100644 --- a/crates/openlogi-hid/src/write/shared.rs +++ b/crates/openlogi-hid/src/write/shared.rs @@ -7,6 +7,7 @@ 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}; /// An open HID++ channel to a device, shared so DPI / SmartShift writes can @@ -56,6 +57,18 @@ pub async fn toggle_smartshift_on(shared: &SharedChannel) -> Result, +) -> Result { + 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( 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 { From 316a08e3badb266567c691fdc333706873ef0f35 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sat, 25 Jul 2026 01:48:51 +0800 Subject: [PATCH 04/21] feat(core): per-device onboard-profiles config OnboardProfiles { mode: host|onboard, profile } persisted per device so the agent can re-apply it on reconnect (the mode reverts to onboard on a power cycle). Config-file only; the default policy for an unconfigured device is host mode so OpenLogi's settings apply. --- crates/openlogi-core/src/config.rs | 42 +++++++++++++++-- crates/openlogi-core/src/config/device.rs | 11 ++++- crates/openlogi-core/src/config/settings.rs | 51 +++++++++++++++++++++ 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index dead22c3..07abfe39 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"); 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` From d1115b61655a00bf4442121dcc5f7eddbcf8e9c4 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sat, 25 Jul 2026 01:59:33 +0800 Subject: [PATCH 05/21] feat(ipc): onboard-profiles agent methods and reconnect apply (v11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appends set_onboard_profiles / read_onboard_profiles to the Agent trait and Capabilities::onboard_profiles (0x8100) — PROTOCOL_VERSION 10 -> 11, wire goldens regenerated. The orchestrator applies the configured mode (default: host, so software settings work on gaming mice) first in the reconnect reapply path; the mode is RAM-volatile and reverts to onboard on power cycle. Devices without 0x8100 skip at debug level. --- crates/openlogi-agent-core/src/hardware.rs | 99 ++++++++++++++++++- crates/openlogi-agent-core/src/ipc.rs | 24 ++++- .../openlogi-agent-core/src/orchestrator.rs | 51 ++++++++-- .../openlogi-agent-core/tests/wire_format.rs | 72 +++++++++++++- crates/openlogi-agent/src/server.rs | 21 +++- crates/openlogi-core/src/config.rs | 1 + crates/openlogi-core/src/device.rs | 11 +++ crates/openlogi-core/src/diagnostics.rs | 1 + crates/openlogi-gui/src/app.rs | 2 + crates/openlogi-gui/src/state/devices.rs | 1 + 10 files changed, 266 insertions(+), 17 deletions(-) diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs index 758c0f10..95be6837 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,72 @@ 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`. +/// +/// 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, +) { + let Some(target) = target else { + debug!(?mode, "no target device — onboard-profiles apply skipped"); + return; + }; + let shared = reusable_channel(capture, &target); + let reused = shared.is_some(); + std::thread::spawn(move || { + 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)" + ), + } + }); +} + /// Apply `lighting` to the keyboard at `target` on a background thread. /// /// Resolves the configured colour (scaled by brightness, or black when the @@ -501,6 +568,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..5f9c44a4 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); @@ -247,6 +247,21 @@ impl Orchestrator { return; }; let key = &dev.config_key; + // First, before any DPI/SmartShift write: a gaming mouse boots in + // onboard mode, where the profile in its flash shadows every software + // setting below. The configured mode (default: host, so OpenLogi's + // settings apply) is written to RAM and reverts on power cycle. Each + // helper spawns its own thread, so this races the writes below; the + // next reapply trigger self-heals if the mode switch lands after a + // DPI write. + if let Some((mode, profile)) = configured_onboard_profiles(&self.config, dev) { + crate::hardware::apply_onboard_profiles_in_background( + Some(&self.shared.capture_channel), + Some(route.clone()), + mode, + profile, + ); + } let (resolution, inverted) = configured_wheel_mode(&self.config, dev); crate::hardware::write_scroll_wheel_mode_in_background( Some(&self.shared.capture_channel), @@ -366,6 +381,30 @@ impl Orchestrator { /// Resolve the two independently-gated HiResWheel settings for one device. /// `None` means preserve the device's current value. +/// The onboard-profiles mode + profile to apply to `dev`, or `None` for a +/// device whose measured feature table has no `0x8100` (or was never probed). +/// An unconfigured device gets the default policy — host mode — so OpenLogi's +/// DPI/button/report-rate settings apply instead of the onboard profile the +/// device booted into. +fn configured_onboard_profiles( + config: &Config, + dev: &AgentDevice, +) -> Option<(ProfilesMode, Option)> { + if !dev.capabilities?.onboard_profiles { + return None; + } + Some(match config.onboard_profiles(&dev.config_key) { + None => (ProfilesMode::Host, None), + Some(cfg) => ( + match cfg.mode { + ProfileSource::Host => ProfilesMode::Host, + ProfileSource::Onboard => ProfilesMode::Onboard, + }, + cfg.profile, + ), + }) +} + fn configured_wheel_mode( config: &Config, dev: &AgentDevice, 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-core/src/config.rs b/crates/openlogi-core/src/config.rs index 07abfe39..b3b2d6b4 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -931,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/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/src/app.rs b/crates/openlogi-gui/src/app.rs index bd6d2e2a..c7268ee4 100644 --- a/crates/openlogi-gui/src/app.rs +++ b/crates/openlogi-gui/src/app.rs @@ -581,6 +581,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 +601,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/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, From ca5ccccd4c0bed77e9f7831edc4d643956e92928 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sat, 25 Jul 2026 00:34:59 +0800 Subject: [PATCH 06/21] feat(hid): recognise Lightspeed receivers (c53f, c547) via the Unifying path Mirrors upstream PR #388 (kiwimaker) rebased onto current master, with the G502 X LIGHTSPEED receiver (046d:c547) added and hardware-verified on Windows 11: enumeration, feature dump, and a diag dpi round-trip. Also chains LIGHTSPEED_PIDS into the Linux receiver-child filter and refreshes the stale Bolt-only note in the CLI list help. Co-authored-by: kiwimaker <49567144+kiwimaker@users.noreply.github.com> --- crates/openlogi-cli/src/cmd/list.rs | 4 +- crates/openlogi-hid/src/inventory/probe.rs | 2 +- crates/openlogi-hid/src/lib.rs | 5 +- crates/openlogi-hid/src/pairing.rs | 3 +- crates/openlogi-hid/src/route.rs | 84 +++++++++++++++---- crates/openlogi-hid/src/transport.rs | 1 + .../openlogi-hidpp/src/receiver/unifying.rs | 16 +++- 7 files changed, 95 insertions(+), 20 deletions(-) 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-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 a1af4203..663e6aa2 100644 --- a/crates/openlogi-hid/src/lib.rs +++ b/crates/openlogi-hid/src/lib.rs @@ -43,7 +43,10 @@ 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, 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-hidpp/src/receiver/unifying.rs b/crates/openlogi-hidpp/src/receiver/unifying.rs index 6a7eca7d..fa22790d 100755 --- a/crates/openlogi-hidpp/src/receiver/unifying.rs +++ b/crates/openlogi-hidpp/src/receiver/unifying.rs @@ -21,7 +21,21 @@ 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`). Verified against a G305 (paired device wpid `0x4074`) +/// and a G502 X LS (paired device wpid `0x409f`). +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)] From e12631a7a1866776052e191a512d1769807ddcd8 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sat, 25 Jul 2026 02:39:03 +0800 Subject: [PATCH 07/21] feat(gui): onboard-profiles panel for gaming mice Profiles tab gated on Capabilities::onboard_profiles: a settings-source control (OpenLogi settings vs onboard memory) and an active-profile selector for the enabled directory entries. Commits persist to config.toml and apply over IPC with an optimistic cache + confirming re-read, on the SmartShift panel's lazy-read pattern. New locale keys seeded with English values across all locales (Crowdin translates). --- crates/openlogi-gui/locales/da.yml | 14 + crates/openlogi-gui/locales/de.yml | 14 + crates/openlogi-gui/locales/el.yml | 14 + crates/openlogi-gui/locales/en.yml | 14 + crates/openlogi-gui/locales/es.yml | 14 + crates/openlogi-gui/locales/fi.yml | 14 + crates/openlogi-gui/locales/fr.yml | 14 + crates/openlogi-gui/locales/it.yml | 14 + crates/openlogi-gui/locales/ja.yml | 14 + crates/openlogi-gui/locales/ko.yml | 14 + crates/openlogi-gui/locales/nb.yml | 14 + crates/openlogi-gui/locales/nl.yml | 14 + crates/openlogi-gui/locales/pl.yml | 14 + crates/openlogi-gui/locales/pt-BR.yml | 14 + crates/openlogi-gui/locales/pt-PT.yml | 14 + crates/openlogi-gui/locales/ru.yml | 14 + crates/openlogi-gui/locales/sv.yml | 14 + crates/openlogi-gui/locales/zh-CN.yml | 14 + crates/openlogi-gui/locales/zh-HK.yml | 14 + crates/openlogi-gui/locales/zh-TW.yml | 14 + crates/openlogi-gui/src/app.rs | 11 + crates/openlogi-gui/src/app/detail.rs | 26 ++ crates/openlogi-gui/src/components/mod.rs | 1 + .../src/components/profiles_panel.rs | 269 ++++++++++++++++++ crates/openlogi-gui/src/ipc_client.rs | 17 +- crates/openlogi-gui/src/state.rs | 140 ++++++++- crates/openlogi-gui/src/state/load.rs | 5 +- 27 files changed, 745 insertions(+), 4 deletions(-) create mode 100644 crates/openlogi-gui/src/components/profiles_panel.rs 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 c7268ee4..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, 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..745cd621 --- /dev/null +++ b/crates/openlogi-gui/src/components/profiles_panel.rs @@ -0,0 +1,269 @@ +//! 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 { + let enabled: Vec<&ProfileEntry> = info.directory.iter().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().enumerate().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/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 From 2943e673dc31ee9d851682d6660a60e107083cf7 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sat, 25 Jul 2026 02:39:03 +0800 Subject: [PATCH 08/21] feat(cli): add openlogi diag profiles round-trip Reads the 0x8100 description, mode, active profile, and profile directory, then round-trips the mode and the active profile with read-back verification and restore. --read-only skips the writes; --leave-onboard keeps the device in onboard mode for verifying the agent's reapply. The profile write runs inside the onboard-mode window because host mode rejects setCurrentProfile (verified on a G502 X). --- crates/openlogi-cli/src/cmd/diag.rs | 4 + crates/openlogi-cli/src/cmd/diag/profiles.rs | 167 +++++++++++++++++++ crates/openlogi-cli/src/lib.rs | 21 +++ 3 files changed, 192 insertions(+) create mode 100644 crates/openlogi-cli/src/cmd/diag/profiles.rs 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..4ac87273 --- /dev/null +++ b/crates/openlogi-cli/src/cmd/diag/profiles.rs @@ -0,0 +1,167 @@ +//! `openlogi diag profiles` — onboard-profiles (HID++ `0x8100`) round-trip. + +use anyhow::{Context, Result}; +use clap::Args; +use openlogi_hid::{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:?}" + ); + } + } + + // Active-profile round-trip against an enabled entry. Prefer one that is + // not already active; a single-profile device re-writes the same sector, + // which still exercises the write path. + 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 the device had never activated a profile + // (0x0000 is not a writable target). + 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"); + } + } + + if args.leave_onboard { + 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). + if info.mode != ProfilesMode::Onboard { + println!(" restoring mode: {:?}", info.mode); + let restored = openlogi_hid::set_profiles_mode(&route, info.mode) + .await + .context("restore onboard mode")?; + if restored != info.mode { + anyhow::bail!( + "onboard mode restore not applied: requested {:?}, device reports {restored:?}", + info.mode + ); + } + println!(" ✓ mode round-trip OK"); + } + + println!("✓ onboard-profiles diag 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 sector & 0x0100 != 0 => { + 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/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([ From 3c28fedbba80179808cabdaa586841da4db1d480 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sat, 25 Jul 2026 02:39:03 +0800 Subject: [PATCH 09/21] fix(agent): sequence the volatile reapply after the onboard-mode switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A G502 X still in onboard mode answered the parallel DPI reapply with InvalidArgument — the mode switch and the other volatile writes raced in separate threads. apply_onboard_profiles_in_background now takes the rest of the reapply as a continuation and runs it after the mode apply settles; devices without 0x8100 run it immediately as before. Also tolerate read-back failures after mode/profile writes (the read races the device's mode transition) like the DPI path, and document that setCurrentProfile is onboard-mode-only. --- crates/openlogi-agent-core/src/hardware.rs | 13 ++- .../openlogi-agent-core/src/orchestrator.rs | 88 ++++++++++--------- .../src/write/onboard_profiles.rs | 25 +++--- .../src/feature/onboard_profiles/mod.rs | 4 + 4 files changed, 78 insertions(+), 52 deletions(-) diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs index 95be6837..5e1982bf 100644 --- a/crates/openlogi-agent-core/src/hardware.rs +++ b/crates/openlogi-agent-core/src/hardware.rs @@ -384,7 +384,14 @@ 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`. +/// 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. It exists because a gaming +/// mouse still in onboard mode rejects the other volatile writes (a G502 X +/// answered a DPI reapply with `InvalidArgument` while onboard), so the +/// reconnect reapply passes the rest of its writes as this continuation +/// instead of racing 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 @@ -396,9 +403,11 @@ pub fn apply_onboard_profiles_in_background( 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); @@ -411,6 +420,7 @@ pub fn apply_onboard_profiles_in_background( Ok(rt) => rt, Err(e) => { warn!(error = %e, "tokio runtime init failed; onboard-profiles apply skipped"); + after(); return; } }; @@ -446,6 +456,7 @@ pub fn apply_onboard_profiles_in_background( "onboard-profiles apply timed out (device asleep/unresponsive)" ), } + after(); }); } diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 5f9c44a4..872529d9 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -238,55 +238,61 @@ 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; - // First, before any DPI/SmartShift write: a gaming mouse boots in - // onboard mode, where the profile in its flash shadows every software - // setting below. The configured mode (default: host, so OpenLogi's - // settings apply) is written to RAM and reverts on power cycle. Each - // helper spawns its own thread, so this races the writes below; the - // next reapply trigger self-heals if the mode switch lands after a - // DPI write. - if let Some((mode, profile)) = configured_onboard_profiles(&self.config, dev) { - crate::hardware::apply_onboard_profiles_in_background( - Some(&self.shared.capture_channel), - Some(route.clone()), - mode, - profile, - ); - } + + // Resolve every configured value up front, then bundle the writes so + // they can run either immediately (no 0x8100) or strictly *after* the + // onboard-profiles mode switch: a gaming mouse boots in onboard mode, + // where the profile in its flash shadows the settings below and the + // firmware rejects writes like DPI with InvalidArgument (observed on + // a G502 X) until host mode is active. 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(), } } diff --git a/crates/openlogi-hid/src/write/onboard_profiles.rs b/crates/openlogi-hid/src/write/onboard_profiles.rs index c4199b35..6e52c110 100644 --- a/crates/openlogi-hid/src/write/onboard_profiles.rs +++ b/crates/openlogi-hid/src/write/onboard_profiles.rs @@ -174,16 +174,18 @@ pub(super) async fn apply_profiles_config_on_channel( 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 is logged, not fatal, because the request - // reached the device. - let actual = read_mode(&feature).await?; - if actual != mode { - tracing::warn!( + // 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; } @@ -207,14 +209,17 @@ pub(super) async fn apply_profiles_config_on_channel( OnboardProfilesFeature::ID, ) })?; - let actual = feature.get_current_profile().await.map_err(read)?; - if actual != sector { - tracing::warn!( + 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; } diff --git a/crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs b/crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs index e81632e8..dc07a667 100644 --- a/crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs +++ b/crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs @@ -78,6 +78,10 @@ impl OnboardProfilesFeature { /// /// 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?; From 2af8ba5f873904de3e9b8315b7bcb10e6a53a2fd Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sun, 26 Jul 2026 03:38:54 +0800 Subject: [PATCH 10/21] refactor(hid): share one ROM-sector predicate The 0x0100 ROM flag was open-coded in ProfileEntry::is_rom and again in the CLI's info printer, while hidpp already exports ROM_SECTOR_FLAG. Route both through openlogi_hid::is_rom_sector, which takes a bare sector so callers holding only active_profile can ask too. --- crates/openlogi-hid/src/lib.rs | 2 +- crates/openlogi-hid/src/onboard_profiles.rs | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/openlogi-hid/src/lib.rs b/crates/openlogi-hid/src/lib.rs index 663e6aa2..71c99e19 100644 --- a/crates/openlogi-hid/src/lib.rs +++ b/crates/openlogi-hid/src/lib.rs @@ -38,7 +38,7 @@ pub use hires_wheel::{ }; pub use hotplug::{HotplugEvent, watch_hotplug}; pub use inventory::{Enumerator, InventoryError, enumerate}; -pub use onboard_profiles::{OnboardProfilesInfo, ProfileEntry, ProfilesMode}; +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, diff --git a/crates/openlogi-hid/src/onboard_profiles.rs b/crates/openlogi-hid/src/onboard_profiles.rs index 8f34e681..d7970977 100644 --- a/crates/openlogi-hid/src/onboard_profiles.rs +++ b/crates/openlogi-hid/src/onboard_profiles.rs @@ -6,8 +6,17 @@ //! settings; OpenLogi therefore defaults such devices to host mode so the //! configured DPI / buttons / report rate actually apply. +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. /// @@ -42,7 +51,7 @@ impl ProfileEntry { /// profile. #[must_use] pub fn is_rom(&self) -> bool { - self.sector & 0x0100 != 0 + is_rom_sector(self.sector) } } From 02f13974c30e6eb00a9db92ba53141f8f6abd014 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sun, 26 Jul 2026 03:39:10 +0800 Subject: [PATCH 11/21] fix(cli): restore the onboard mode when the profile round-trip fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diag profiles forces the device into onboard mode before the active-profile round-trip and restores the original mode at the end. Every `?` and `bail!` in between returned past that restore, leaving the device in onboard mode — which is exactly when it matters, since a failed run is when the user is least likely to notice the mouse stopped honouring host settings. Split the round-trip and the restore into their own functions so the restore always runs, and report the round-trip error first when both fail. --- crates/openlogi-cli/src/cmd/diag/profiles.rs | 68 ++++++++++++-------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/crates/openlogi-cli/src/cmd/diag/profiles.rs b/crates/openlogi-cli/src/cmd/diag/profiles.rs index 4ac87273..663a5f6d 100644 --- a/crates/openlogi-cli/src/cmd/diag/profiles.rs +++ b/crates/openlogi-cli/src/cmd/diag/profiles.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use clap::Args; -use openlogi_hid::{OnboardProfilesInfo, ProfilesMode}; +use openlogi_hid::{DeviceRoute, OnboardProfilesInfo, ProfilesMode}; use crate::cmd::diag::select_device; @@ -55,9 +55,30 @@ pub async fn run(args: ProfilesArgs) -> Result<()> { } } - // Active-profile round-trip against an enabled entry. Prefer one that is - // not already active; a single-profile device re-writes the same sector, - // which still exercises the write path. + 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() @@ -76,7 +97,7 @@ pub async fn run(args: ProfilesArgs) -> Result<()> { ); } println!(" activating profile sector {target:#06x}"); - let read_back = openlogi_hid::set_active_profile(&route, target) + let read_back = openlogi_hid::set_active_profile(route, target) .await .context("write active profile")?; if read_back != target { @@ -90,7 +111,7 @@ pub async fn run(args: ProfilesArgs) -> Result<()> { 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) + let restored = openlogi_hid::set_active_profile(route, info.active_profile) .await .context("restore active profile")?; if restored != info.active_profile { @@ -103,29 +124,24 @@ pub async fn run(args: ProfilesArgs) -> Result<()> { println!(" ✓ profile round-trip OK"); } } + Ok(()) +} - if args.leave_onboard { - println!("✓ onboard-profiles diag OK (device left in Onboard mode)"); +/// 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(()); } - - // Restore the original mode last, so the device leaves the diag exactly - // as it entered (host-mode users get host mode back). - if info.mode != ProfilesMode::Onboard { - println!(" restoring mode: {:?}", info.mode); - let restored = openlogi_hid::set_profiles_mode(&route, info.mode) - .await - .context("restore onboard mode")?; - if restored != info.mode { - anyhow::bail!( - "onboard mode restore not applied: requested {:?}, device reports {restored:?}", - info.mode - ); - } - println!(" ✓ mode round-trip 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!("✓ onboard-profiles diag OK"); + println!(" ✓ mode round-trip OK"); Ok(()) } @@ -146,7 +162,7 @@ fn print_info(info: &OnboardProfilesInfo) { println!(" mode: {:?}", info.mode); match info.active_profile { 0 => println!(" active profile: none reported (0x0000)"), - sector if sector & 0x0100 != 0 => { + sector if openlogi_hid::is_rom_sector(sector) => { println!(" active profile: {sector:#06x} (ROM)"); } sector => println!(" active profile: {sector:#06x}"), From a7f1e81121e2fd2c7484a897a4cb49fb9dea244f Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sun, 26 Jul 2026 03:39:16 +0800 Subject: [PATCH 12/21] fix(agent): keep the reconnect reapply alive if the onboard apply panics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_onboard_profiles_in_background carries the rest of the reconnect volatile reapply as its `after` continuation, and called it as the last statement of the spawned thread. A panic on the way there — runtime build, block_on, logging — dropped every remaining write with nothing to show for it. Run the continuation from a drop guard instead, which also covers the runtime-init early return. --- crates/openlogi-agent-core/src/hardware.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs index 5e1982bf..84e55a22 100644 --- a/crates/openlogi-agent-core/src/hardware.rs +++ b/crates/openlogi-agent-core/src/hardware.rs @@ -387,7 +387,8 @@ pub fn write_scroll_wheel_mode_in_background( /// 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. It exists because a gaming +/// `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 a gaming /// mouse still in onboard mode rejects the other volatile writes (a G502 X /// answered a DPI reapply with `InvalidArgument` while onboard), so the /// reconnect reapply passes the rest of its writes as this continuation @@ -413,6 +414,7 @@ pub fn apply_onboard_profiles_in_background( 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() @@ -420,7 +422,6 @@ pub fn apply_onboard_profiles_in_background( Ok(rt) => rt, Err(e) => { warn!(error = %e, "tokio runtime init failed; onboard-profiles apply skipped"); - after(); return; } }; @@ -456,10 +457,21 @@ pub fn apply_onboard_profiles_in_background( "onboard-profiles apply timed out (device asleep/unresponsive)" ), } - after(); }); } +/// Runs the wrapped closure when dropped, so a continuation survives an early +/// return or a panic on the way to 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 From 43bbba7d136a4efdce4e273097cb74ca5085dad8 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sun, 26 Jul 2026 03:39:23 +0800 Subject: [PATCH 13/21] fix(gui): number profile pills by directory slot The pill labels enumerated the enabled-filtered list, so a disabled slot renumbered every profile after it and the labels stopped lining up with the slot numbers G HUB shows. Enumerate the directory, then filter. --- .../openlogi-gui/src/components/profiles_panel.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/openlogi-gui/src/components/profiles_panel.rs b/crates/openlogi-gui/src/components/profiles_panel.rs index 745cd621..377972af 100644 --- a/crates/openlogi-gui/src/components/profiles_panel.rs +++ b/crates/openlogi-gui/src/components/profiles_panel.rs @@ -108,7 +108,14 @@ impl ProfilesPanel { let mut body = v_flex().gap_4().w_full().child(source_row); if onboard { - let enabled: Vec<&ProfileEntry> = info.directory.iter().filter(|e| e.enabled).collect(); + // Number by directory position, not by position among the enabled + // entries: a disabled slot must not renumber the ones after it. + 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)) @@ -118,8 +125,8 @@ impl ProfilesPanel { h_flex() .gap_2() .flex_wrap() - .children(enabled.iter().enumerate().map(|(i, entry)| { - profile_pill(i, **entry, entry.sector == info.active_profile, pal) + .children(enabled.iter().map(|&(i, entry)| { + profile_pill(i, *entry, entry.sector == info.active_profile, pal) })) .into_any_element() }); From c391ef0e59bcfe2dd32450092495b8d52a7409a8 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sun, 26 Jul 2026 03:39:32 +0800 Subject: [PATCH 14/21] docs: reattach the wheel-mode rustdoc and scope the Lightspeed claim configured_onboard_profiles was inserted between configured_wheel_mode's rustdoc and its signature, so the HiResWheel doc described the wrong function and the wheel helper was left undocumented. VPID_PAIRS claimed both new Lightspeed receivers were verified against hardware, but only the G502 X LIGHTSPEED (0xc547) was on the bench; 0xc53f comes from upstream hidpp PR #388. --- crates/openlogi-agent-core/src/orchestrator.rs | 4 ++-- crates/openlogi-hidpp/src/receiver/unifying.rs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 872529d9..7ae8ae37 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -385,8 +385,6 @@ impl Orchestrator { } } -/// Resolve the two independently-gated HiResWheel settings for one device. -/// `None` means preserve the device's current value. /// The onboard-profiles mode + profile to apply to `dev`, or `None` for a /// device whose measured feature table has no `0x8100` (or was never probed). /// An unconfigured device gets the default policy — host mode — so OpenLogi's @@ -411,6 +409,8 @@ fn configured_onboard_profiles( }) } +/// Resolve the two independently-gated HiResWheel settings for one device. +/// `None` means preserve the device's current value. fn configured_wheel_mode( config: &Config, dev: &AgentDevice, diff --git a/crates/openlogi-hidpp/src/receiver/unifying.rs b/crates/openlogi-hidpp/src/receiver/unifying.rs index fa22790d..2bb74806 100755 --- a/crates/openlogi-hidpp/src/receiver/unifying.rs +++ b/crates/openlogi-hidpp/src/receiver/unifying.rs @@ -28,8 +28,9 @@ use crate::{ /// 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`). Verified against a G305 (paired device wpid `0x4074`) -/// and a G502 X LS (paired device wpid `0x409f`). +/// (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), From b46274fc2ed5a085ada1196d53c53854972c673d Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sun, 26 Jul 2026 04:17:20 +0800 Subject: [PATCH 15/21] fix(agent): only write the onboard mode when the user configured one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configured_onboard_profiles fell back to host mode for any 0x8100 device with no onboard_profiles config, so the agent switched a gaming mouse out of onboard mode on every reconnect without being asked. The mode is user-visible state the mouse changes on its own — a G502 X boots into onboard mode and reports its active profile only by blinking an LED — so taking it over uninvited silently overrode the profile the user picked on the device itself. Return None when nothing is configured and leave the device in whatever mode it powered on in. A configured mode is still re-asserted on every reconnect, since the device does not remember host mode. --- .../openlogi-agent-core/src/orchestrator.rs | 33 ++++++++++--------- crates/openlogi-hid/src/onboard_profiles.rs | 5 +-- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 7ae8ae37..4cf01162 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -385,11 +385,16 @@ impl Orchestrator { } } -/// The onboard-profiles mode + profile to apply to `dev`, or `None` for a -/// device whose measured feature table has no `0x8100` (or was never probed). -/// An unconfigured device gets the default policy — host mode — so OpenLogi's -/// DPI/button/report-rate settings apply instead of the onboard profile the -/// device booted into. +/// 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. +/// +/// A device nobody configured is left in whatever mode it powered on in. The +/// mode is user-visible state the mouse changes on its own (a G502 X boots into +/// onboard mode and signals its profile only by blinking an LED), so taking it +/// over uninvited would silently override the profile the user chose on the +/// device itself. The flip side: a configured mode has to be re-asserted on +/// every reconnect, since the device does not remember host mode. fn configured_onboard_profiles( config: &Config, dev: &AgentDevice, @@ -397,16 +402,14 @@ fn configured_onboard_profiles( if !dev.capabilities?.onboard_profiles { return None; } - Some(match config.onboard_profiles(&dev.config_key) { - None => (ProfilesMode::Host, None), - Some(cfg) => ( - match cfg.mode { - ProfileSource::Host => ProfilesMode::Host, - ProfileSource::Onboard => ProfilesMode::Onboard, - }, - cfg.profile, - ), - }) + 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. diff --git a/crates/openlogi-hid/src/onboard_profiles.rs b/crates/openlogi-hid/src/onboard_profiles.rs index d7970977..4579e47d 100644 --- a/crates/openlogi-hid/src/onboard_profiles.rs +++ b/crates/openlogi-hid/src/onboard_profiles.rs @@ -3,8 +3,9 @@ //! 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 applies a profile from its own flash and ignores most host software -//! settings; OpenLogi therefore defaults such devices to host mode so the -//! configured DPI / buttons / report rate actually apply. +//! settings, so the configured DPI / buttons / report rate only take effect in +//! host mode. OpenLogi never switches a device over 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}; From dfef215c6541a0edac0ccce9f0a859f804585f7b Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sun, 26 Jul 2026 04:17:30 +0800 Subject: [PATCH 16/21] docs(agent): add an onboard-profiles guide for agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0x8100 feature spans hidpp, hid, agent-core, gui and cli, and its shape is dictated by firmware behaviour that is invisible in the code: the device boots into onboard mode and forgets host mode, onboard mode rejects DPI writes while host mode rejects setCurrentProfile, and the mouse reports its active profile only by blinking an LED. Map the feature across the five crates, then record each constraint together with the code decision it forces — why the volatile reapply is a continuation rather than parallel writes, why the continuation runs from a drop guard, and why a device nobody configured is left alone. Call out the known gaps as current state rather than bugs, and keep the hardware questions listed as unverified so they are answered on a device instead of reasoned about. Sources the Logitech setup guide for the device facts and marks bench observations as observed, since the 0x8100 spec is not public. --- .claude/rules/onboard-profiles.md | 178 ++++++++++++++++++++++++++++++ AGENTS.md | 1 + 2 files changed, 179 insertions(+) create mode 100644 .claude/rules/onboard-profiles.md diff --git a/.claude/rules/onboard-profiles.md b/.claude/rules/onboard-profiles.md new file mode 100644 index 00000000..2090e02f --- /dev/null +++ b/.claude/rules/onboard-profiles.md @@ -0,0 +1,178 @@ +--- +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 + +## The firmware rejects half of what you'd expect, which fixes the ordering + +**Onboard mode rejects host writes.** DPI comes back `InvalidArgument` while the +device is onboard (observed). This is why `reapply_volatile_settings` does not +fan its writes out into parallel threads: it bundles wheel mode, lighting, DPI +and SmartShift into the `after` continuation of +`apply_onboard_profiles_in_background`, so the mode switch is guaranteed to land +first. If you flatten that back into parallel spawns, DPI silently stops +applying on any gaming mouse — and only on a gaming mouse, so it survives every +test on other hardware. + +**Host mode rejects `set_current_profile`.** The active-profile write is legal +only inside an onboard-mode window. `openlogi diag profiles` enters onboard mode +for exactly that reason, not as a side effect. + +**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.** The mouse powers on in onboard mode every time, 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. + +Smaller ones, same category: `memory_read` rejects offsets past +`sector_size - 16`, so a full-sector read fetches its last partial chunk from +`sector_size - 16`; erased flash reads back as `0xFF`, which parses as the +directory terminator, so an empty directory is a legal state and not a failure; +ROM profiles carry `ROM_SECTOR_FLAG` (`0x0100`) and the bit is never re-derived +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. The tempting fix — "default to host mode so our DPI actually +applies" — is precisely the bug this policy exists to prevent. If you are +writing that fallback, stop and read this section again. + +The live consequence: DPI configured in OpenLogi will not apply to a mouse +sitting in onboard mode, and the write fails with `InvalidArgument`. Surfacing +that to the user is unsolved. Do not solve it by taking the mode. + +```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 — factory default + + 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 dormant · host drives DPI / buttons / report rate + } + + 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 is not symmetric work: `0x2202` already decodes `ParametersChanged` +("e.g. via a DPI button") in the vendored fork and the decoder is simply unused, +whereas `0x8100` has no event support in the fork at all, so reflecting a +device-side profile change means adding the event there first. + +## 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. + +## Unverified — resolve on hardware, do not reason about + +- Whether G7/G8 still change DPI, and G9 still cycles profiles, in **host** mode, + or whether they become plain reprogrammable buttons. +- Whether the DPI stage list lives in the device (`0x2202 getSensorDpiList`) or + in host-side state while in host mode. +- Whether the LED still tracks DPI and profile in host mode. +- 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` | From 194d5097299467ee3aa595d6c0ec6cb64b11f5be Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sun, 26 Jul 2026 04:40:38 +0800 Subject: [PATCH 17/21] docs: cut rationale duplicated between rustdoc and the rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configured_onboard_profiles is private, so its rustdoc and .claude/rules/onboard-profiles.md address the same readers — the consent rationale was written out twice. Keep the contract in the rustdoc, the argument in the rule. Same for the firmware facts already documented at memory_read, DIRECTORY_END and is_rom_sector, and for RunOnDrop restating its own function doc. --- .claude/rules/onboard-profiles.md | 8 ++------ crates/openlogi-agent-core/src/hardware.rs | 3 +-- crates/openlogi-agent-core/src/orchestrator.rs | 8 ++------ crates/openlogi-gui/src/components/profiles_panel.rs | 3 +-- 4 files changed, 6 insertions(+), 16 deletions(-) diff --git a/.claude/rules/onboard-profiles.md b/.claude/rules/onboard-profiles.md index 2090e02f..2ae25dc2 100644 --- a/.claude/rules/onboard-profiles.md +++ b/.claude/rules/onboard-profiles.md @@ -63,12 +63,8 @@ of the spawned thread. Do not "simplify" it back to a trailing call. 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. -Smaller ones, same category: `memory_read` rejects offsets past -`sector_size - 16`, so a full-sector read fetches its last partial chunk from -`sector_size - 16`; erased flash reads back as `0xFF`, which parses as the -directory terminator, so an empty directory is a legal state and not a failure; -ROM profiles carry `ROM_SECTOR_FLAG` (`0x0100`) and the bit is never re-derived -at a call site — go through `openlogi_hid::is_rom_sector`. +Never re-derive `ROM_SECTOR_FLAG` at a call site — go through +`openlogi_hid::is_rom_sector`. ## Never switch a device's mode uninvited diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs index 84e55a22..df3f24d1 100644 --- a/crates/openlogi-agent-core/src/hardware.rs +++ b/crates/openlogi-agent-core/src/hardware.rs @@ -460,8 +460,7 @@ pub fn apply_onboard_profiles_in_background( }); } -/// Runs the wrapped closure when dropped, so a continuation survives an early -/// return or a panic on the way to it. +/// Runs the closure on drop, so an early return or panic can't swallow it. struct RunOnDrop(Option); impl Drop for RunOnDrop { diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 4cf01162..67fa2297 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -389,12 +389,8 @@ impl Orchestrator { /// 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. /// -/// A device nobody configured is left in whatever mode it powered on in. The -/// mode is user-visible state the mouse changes on its own (a G502 X boots into -/// onboard mode and signals its profile only by blinking an LED), so taking it -/// over uninvited would silently override the profile the user chose on the -/// device itself. The flip side: a configured mode has to be re-asserted on -/// every reconnect, since the device does not remember host mode. +/// 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, diff --git a/crates/openlogi-gui/src/components/profiles_panel.rs b/crates/openlogi-gui/src/components/profiles_panel.rs index 377972af..54ec1acc 100644 --- a/crates/openlogi-gui/src/components/profiles_panel.rs +++ b/crates/openlogi-gui/src/components/profiles_panel.rs @@ -108,8 +108,7 @@ impl ProfilesPanel { let mut body = v_flex().gap_4().w_full().child(source_row); if onboard { - // Number by directory position, not by position among the enabled - // entries: a disabled slot must not renumber the ones after it. + // Number by directory position: a disabled slot must not renumber later ones. let enabled: Vec<(usize, &ProfileEntry)> = info .directory .iter() From 350731145e9ac8f04b777240241b8c0ceccc8b4d Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sun, 26 Jul 2026 05:51:27 +0800 Subject: [PATCH 18/21] fix(hid): serialize HID++ verbs so concurrent requests stop cross-matching HID++ correlates a reply to its request only by (device index, feature index, function, software id), and every verb opens by resolving its feature with the same 0x0000 getFeature header at the channel's fixed software id. Two verbs in flight on one device therefore match each other's replies. Bench-observed on a G502 X LIGHTSPEED: set_profiles_mode || set_dpi failed InvalidArgument 3/3 (the DPI payload reaching 0x8100's index), set_dpi || set_scroll_wheel_mode reported 0x2121 unsupported though the feature table lists it, and get_dpi || set_profiles_mode returned Ok(0) -- silently wrong, no error. Sequentially all of them succeed. An exchange() lock now spans each verb, taken in with_route and in every *_on shared-channel fast path. All four cases pass after the change. --- crates/openlogi-hid/src/hires_wheel.rs | 7 ++++++- crates/openlogi-hid/src/write.rs | 25 +++++++++++++++++++++++++ crates/openlogi-hid/src/write/shared.rs | 10 +++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) 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/write.rs b/crates/openlogi-hid/src/write.rs index 85ee9159..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}; @@ -57,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/shared.rs b/crates/openlogi-hid/src/write/shared.rs index 70fa4f65..b7f29803 100644 --- a/crates/openlogi-hid/src/write/shared.rs +++ b/crates/openlogi-hid/src/write/shared.rs @@ -5,10 +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 @@ -48,12 +48,18 @@ 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 } @@ -65,6 +71,7 @@ pub async fn apply_profiles_config_on( 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 } @@ -77,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(), From 8fdfc641d35eb3a525ff54d05770e0d984f84f9a Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sun, 26 Jul 2026 05:54:11 +0800 Subject: [PATCH 19/21] test(hidpp): use the captured G502 X description payload The description fixture was hand-invented and disagreed with the real device on two fields: 3 ROM profiles instead of 2, and 256-byte sectors instead of 255. The sector size is the one that matters -- it feeds the `sector_size - 16` read bound, so a rounded-up 256 would hide an off-by-one there. --- .../src/feature/onboard_profiles/tests.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/openlogi-hidpp/src/feature/onboard_profiles/tests.rs b/crates/openlogi-hidpp/src/feature/onboard_profiles/tests.rs index a26e21f9..a94dac56 100644 --- a/crates/openlogi-hidpp/src/feature/onboard_profiles/tests.rs +++ b/crates/openlogi-hidpp/src/feature/onboard_profiles/tests.rs @@ -3,20 +3,22 @@ use crate::protocol::v20::Hidpp20Error; #[test] fn parses_full_description_payload() { - // G502-style description: 5 user profiles, 3 OOB, 11 buttons, 16 sectors - // of 256 bytes (0x0100 big-endian at offset 7). - let payload = [1, 2, 3, 5, 3, 11, 16, 0x01, 0x00, 0x04, 0x07, 0, 0, 0, 0, 0]; + // 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, 2); - assert_eq!(descr.macro_format_id, 3); + 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, 3); + assert_eq!(descr.profile_count_oob, 2); assert_eq!(descr.button_count, 11); assert_eq!(descr.sector_count, 16); - assert_eq!(descr.sector_size, 256); + assert_eq!(descr.sector_size, 255); assert_eq!(descr.mechanical_layout, 0x04); assert_eq!(descr.various_info, 0x07); } From 834bde86466c9850f9fd004886beb1d316f75b7a Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sun, 26 Jul 2026 05:55:07 +0800 Subject: [PATCH 20/21] docs: replace the onboard-mode premise with what the hardware does "Onboard mode rejects host writes with InvalidArgument" was wrong. On a G502 X LIGHTSPEED, DPI round-trips 1600->1650->1600 in onboard mode, in host mode, and immediately after a mode write; the rejection that started this belonged to the concurrent-request cross-talk fixed one commit back. The reapply still has to run after the onboard-profiles apply, for the reason that actually holds: activating a profile reloads that profile's DPI out of flash, so a write that raced ahead of it would be discarded. Bench facts added: host mode rejects setCurrentProfile and reports active profile 0x0000 (the flash profile is parked); the mode is volatile, confirmed by a host-mode device returning onboard after a power cycle; host mode leaves G7/G8 dead and the LED dark, because OpenLogi writes the mode byte and none of what the parked profile supplied. The device exposes no 0x1b04 and no 0x807x lighting, which bounds how far that can be closed -- and it is a fresh argument for keeping host mode opt-in, replacing the "configured DPI silently no-ops" cost that does not exist. --- .claude/rules/onboard-profiles.md | 112 ++++++++++++------ crates/openlogi-agent-core/src/hardware.rs | 10 +- .../openlogi-agent-core/src/orchestrator.rs | 7 +- crates/openlogi-cli/src/cmd/diag/profiles.rs | 5 +- crates/openlogi-hid/src/onboard_profiles.rs | 12 +- .../src/feature/onboard_profiles/mod.rs | 8 +- 6 files changed, 101 insertions(+), 53 deletions(-) diff --git a/.claude/rules/onboard-profiles.md b/.claude/rules/onboard-profiles.md index 2ae25dc2..01a28f84 100644 --- a/.claude/rules/onboard-profiles.md +++ b/.claude/rules/onboard-profiles.md @@ -40,28 +40,49 @@ 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 -## The firmware rejects half of what you'd expect, which fixes the ordering - -**Onboard mode rejects host writes.** DPI comes back `InvalidArgument` while the -device is onboard (observed). This is why `reapply_volatile_settings` does not -fan its writes out into parallel threads: it bundles wheel mode, lighting, DPI -and SmartShift into the `after` continuation of -`apply_onboard_profiles_in_background`, so the mode switch is guaranteed to land -first. If you flatten that back into parallel spawns, DPI silently stops -applying on any gaming mouse — and only on a gaming mouse, so it survives every -test on other hardware. - -**Host mode rejects `set_current_profile`.** The active-profile write is legal -only inside an onboard-mode window. `openlogi diag profiles` enters onboard mode -for exactly that reason, not as a side effect. +## 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.** The mouse powers on in onboard mode every time, 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. +**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. Never re-derive `ROM_SECTOR_FLAG` at a call site — go through `openlogi_hid::is_rom_sector`. @@ -74,13 +95,16 @@ 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. The tempting fix — "default to host mode so our DPI actually -applies" — is precisely the bug this policy exists to prevent. If you are -writing that fallback, stop and read this section again. +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 live consequence: DPI configured in OpenLogi will not apply to a mouse -sitting in onboard mode, and the write fails with `InvalidArgument`. Surfacing -that to the user is unsolved. Do not solve it by taking 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 @@ -97,7 +121,7 @@ flowchart TD ```mermaid stateDiagram-v2 - [*] --> Onboard : power on — factory default + [*] --> Onboard : power on — always, whatever mode it was left in state Onboard { direction LR @@ -112,7 +136,8 @@ stateDiagram-v2 state Host { direction LR - HostState : flash profile dormant · host drives DPI / buttons / report rate + HostState : flash profile parked · getCurrentProfile reports 0x0000 + Degraded : G7/G8 dead · LED dark · until a host writes stages + lighting } Onboard --> Host : 0x8100 setOnboardMode(Host) @@ -137,10 +162,9 @@ The GUI reads onboard-profiles state **once** per device 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 is not symmetric work: `0x2202` already decodes `ParametersChanged` -("e.g. via a DPI button") in the vendored fork and the decoder is simply unused, -whereas `0x8100` has no event support in the fork at all, so reflecting a -device-side profile change means adding the event there first. +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 @@ -162,13 +186,33 @@ 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 G7/G8 still change DPI, and G9 still cycles profiles, in **host** mode, - or whether they become plain reprogrammable buttons. -- Whether the DPI stage list lives in the device (`0x2202 getSensorDpiList`) or - in host-side state while in host mode. -- Whether the LED still tracks DPI and profile in host mode. +- 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/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs index df3f24d1..31b9edcc 100644 --- a/crates/openlogi-agent-core/src/hardware.rs +++ b/crates/openlogi-agent-core/src/hardware.rs @@ -388,11 +388,11 @@ pub fn write_scroll_wheel_mode_in_background( /// 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 a gaming -/// mouse still in onboard mode rejects the other volatile writes (a G502 X -/// answered a DPI reapply with `InvalidArgument` while onboard), so the -/// reconnect reapply passes the rest of its writes as this continuation -/// instead of racing them in parallel threads. +/// 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 diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 67fa2297..0878de1f 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -250,10 +250,9 @@ impl Orchestrator { // Resolve every configured value up front, then bundle the writes so // they can run either immediately (no 0x8100) or strictly *after* the - // onboard-profiles mode switch: a gaming mouse boots in onboard mode, - // where the profile in its flash shadows the settings below and the - // firmware rejects writes like DPI with InvalidArgument (observed on - // a G502 X) until host mode is active. + // 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); let lighting = self.config.lighting(key).filter(|l| l.enabled); let dpi = self.config.dpi(key); diff --git a/crates/openlogi-cli/src/cmd/diag/profiles.rs b/crates/openlogi-cli/src/cmd/diag/profiles.rs index 663a5f6d..a1105b9c 100644 --- a/crates/openlogi-cli/src/cmd/diag/profiles.rs +++ b/crates/openlogi-cli/src/cmd/diag/profiles.rs @@ -105,8 +105,9 @@ async fn profile_round_trip(route: &DeviceRoute, info: &OnboardProfilesInfo) -> "active-profile write not applied: requested {target:#06x}, device reports {read_back:#06x}" ); } - // Restore, unless the device had never activated a profile - // (0x0000 is not a writable target). + // 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 { diff --git a/crates/openlogi-hid/src/onboard_profiles.rs b/crates/openlogi-hid/src/onboard_profiles.rs index 4579e47d..39499adc 100644 --- a/crates/openlogi-hid/src/onboard_profiles.rs +++ b/crates/openlogi-hid/src/onboard_profiles.rs @@ -2,10 +2,10 @@ //! //! 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 applies a profile from its own flash and ignores most host software -//! settings, so the configured DPI / buttons / report rate only take effect in -//! host mode. OpenLogi never switches a device over on its own — the mode is -//! written only after the user picks one. +//! 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}; @@ -81,7 +81,9 @@ pub struct OnboardProfilesInfo { pub macro_format_id: u8, /// Whether the device is in host or onboard mode. pub mode: ProfilesMode, - /// Sector of the active profile; `0x0000` when none has been activated. + /// 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, diff --git a/crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs b/crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs index dc07a667..ce80c485 100644 --- a/crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs +++ b/crates/openlogi-hidpp/src/feature/onboard_profiles/mod.rs @@ -1,9 +1,11 @@ //! Implements the `OnboardProfiles` feature (ID `0x8100`) that controls a //! gaming device's onboard profile memory. //! -//! In onboard mode the device applies a profile stored in its own flash and -//! ignores most host software settings; in host mode the host drives the -//! device. This implementation covers reading the memory description, getting +//! 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 From bd4daa28f474982ede9313ee686f0a220aafecfd Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Sun, 26 Jul 2026 06:12:11 +0800 Subject: [PATCH 21/21] fix(hid): bound the profile directory read by user + ROM profiles parse_directory stops at max_entries even when the terminator has not been reached, so passing profile_count alone truncates the directory on any device that lists its ROM profiles after the user ones. Bounding by profile_count + profile_count_oob costs nothing -- the read loop already stops early at the terminator -- and the terminator stays the real end of the directory. Reported by Greptile on #459. Its worked example does not reproduce: a G502 X terminates the directory right after the 5 user entries, and rejects set_current_profile for 0x0101/0x0102/0x0103 with InvalidArgument, so ROM profiles there are counted in the description but neither listed nor selectable. The bound is still wrong in principle, and a device that does list them would lose entries. --- .claude/rules/onboard-profiles.md | 9 +++++++++ crates/openlogi-hid/src/write/onboard_profiles.rs | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.claude/rules/onboard-profiles.md b/.claude/rules/onboard-profiles.md index 01a28f84..3934bb52 100644 --- a/.claude/rules/onboard-profiles.md +++ b/.claude/rules/onboard-profiles.md @@ -84,6 +84,15 @@ 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`. diff --git a/crates/openlogi-hid/src/write/onboard_profiles.rs b/crates/openlogi-hid/src/write/onboard_profiles.rs index 6e52c110..dbfd7c65 100644 --- a/crates/openlogi-hid/src/write/onboard_profiles.rs +++ b/crates/openlogi-hid/src/write/onboard_profiles.rs @@ -69,8 +69,12 @@ pub async fn get_onboard_profiles(route: &DeviceRoute) -> Result