diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs index c9c2fdfa..355fbd76 100644 --- a/crates/openlogi-agent-core/src/hardware.rs +++ b/crates/openlogi-agent-core/src/hardware.rs @@ -22,8 +22,8 @@ use std::time::Duration; use openlogi_core::config::Lighting; use openlogi_hid::{ - CaptureChannel, ChannelRegistry, DeviceRoute, Dpi, HidppOperation, ScrollResolution, - SharedChannel, SmartShiftStatus, WriteError, + CaptureChannel, ChannelRegistry, DeviceRoute, Dpi, HidppOperation, ProfilesMode, + ScrollResolution, SharedChannel, SmartShiftStatus, WriteError, }; use tokio::time::error::Elapsed; use tracing::{debug, warn}; @@ -264,6 +264,14 @@ pub fn write_fn_lock_in_background(op: DeviceOp<'_>, on: bool) { ); } +/// Desired onboard-profile state for a reconnect re-apply. +#[derive(Debug, Clone, Copy)] +pub struct OnboardProfilesApply { + /// Whether host software or onboard flash drives the device. + pub mode: ProfilesMode, + /// User-profile sector to activate in onboard mode, when configured. + pub profile: Option, +} /// Re-apply every volatile mouse setting for `op`'s device on a **single** /// background thread, sequentially, on the current inventory-owned channel. /// @@ -280,6 +288,7 @@ pub fn write_fn_lock_in_background(op: DeviceOp<'_>, on: bool) { /// hands the operation itself to [`DeviceOp::run`] or [`DeviceOp::detach`]. pub fn reapply_mouse_volatile_in_background( op: &DeviceOp<'_>, + profiles: Option, resolution: Option, inverted: Option, dpi: Option, @@ -297,6 +306,32 @@ pub fn reapply_mouse_volatile_in_background( }; rt.block_on(async { let _lease = receiver_access.acquire_for_io().await; + if let Some(profiles) = profiles { + let result = tokio::time::timeout(WRITE_BUDGET, async { + openlogi_hid::apply_profiles_config_on(&shared, profiles.mode, profiles.profile) + .await + }) + .await; + match result { + Ok(Ok(written)) => debug!( + index, + mode = ?profiles.mode, + profile = ?profiles.profile, + written, + "onboard-profiles config applied" + ), + Ok(Err(WriteError::FeatureUnsupported { feature_hex })) => debug!( + index, + feature = format_args!("{feature_hex:#06x}"), + "onboard-profile memory unsupported" + ), + Ok(Err(e)) => warn!(error = ?e, "onboard-profiles apply failed"), + Err(_) => warn!( + index, + "onboard-profiles apply timed out (device asleep/unresponsive)" + ), + } + } if resolution.is_some() || inverted.is_some() { let result = tokio::time::timeout(WRITE_BUDGET, async { apply_wheel_mode(&shared, resolution, inverted).await diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index e15541b2..3dd7ded9 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -16,14 +16,14 @@ use std::sync::{Arc, RwLock}; use openlogi_core::binding::Action; use openlogi_core::bindings::{bindings_for, oshook_gestures_for}; -use openlogi_core::config::{Config, LightSettings, ScrollResolution}; +use openlogi_core::config::{Config, LightSettings, OnboardProfiles, ScrollResolution}; use openlogi_core::device::{ Capabilities, DeviceInventory, DeviceKind, LightCapabilities, StandaloneDevice, }; use openlogi_core::device_order::DeviceStableId; use openlogi_hid::{ CaptureChannel, ChannelPool, ChannelRegistry, DIRECT_DEVICE_INDEX, DeviceRoute, - KEYBOARD_KEY_CIDS, + KEYBOARD_KEY_CIDS, ProfilesMode, }; use openlogi_ipc::InventoryHealth; use tracing::{debug, info, warn}; @@ -469,10 +469,12 @@ impl Orchestrator { self.reapply_all_next_refresh = true; } - /// Push the persisted volatile settings (lighting, sensor DPI, SmartShift, - /// native wheel mode) to one device. Mouse settings run on one background - /// thread and one HID++ channel so concurrent multi-open of the same - /// receiver cannot cross-talk (#485); lighting stays a separate path + /// Push the persisted volatile settings (onboard-profile mode, lighting, + /// sensor DPI, SmartShift, native wheel mode) to one device. Mouse + /// settings run sequentially on one background thread and one HID++ + /// channel so the onboard-mode switch settles before the writes it can + /// otherwise shadow or reject, and concurrent multi-open of the same + /// receiver cannot cross-talk (#485). Lighting stays a separate path /// (keyboards / different feature). fn reapply_volatile_settings(&self, dev: &AgentDevice) { // A disabled device is left fully native — no writes of any kind. @@ -483,15 +485,23 @@ impl Orchestrator { return; }; let key = &dev.config_key; + let profiles = configured_onboard_profiles(&self.config, dev) + .map(|(mode, profile)| crate::hardware::OnboardProfilesApply { mode, profile }); let (resolution, inverted) = configured_wheel_mode(&self.config, dev); let dpi = self.config.dpi(key); let smartshift = self .config .smartshift(key) .map(openlogi_hid::SmartShiftStatus::from); - if resolution.is_some() || inverted.is_some() || dpi.is_some() || smartshift.is_some() { + if profiles.is_some() + || resolution.is_some() + || inverted.is_some() + || dpi.is_some() + || smartshift.is_some() + { crate::hardware::reapply_mouse_volatile_in_background( &self.shared.device(&route), + profiles, resolution, inverted, dpi, @@ -779,6 +789,23 @@ impl Orchestrator { } } +/// Resolve the onboard-profile mode and optional user sector to re-apply to +/// `dev`. An unconfigured device stays unmanaged: OpenLogi does not silently +/// take it out of the mode its hardware selected. Once configured, the mode is +/// asserted on every reconnect because host mode is volatile. +fn configured_onboard_profiles( + config: &Config, + dev: &AgentDevice, +) -> Option<(ProfilesMode, Option)> { + if !dev.capabilities?.onboard_profiles { + return None; + } + match config.onboard_profiles(&dev.config_key)? { + OnboardProfiles::Host {} => Some((ProfilesMode::Host, None)), + OnboardProfiles::Onboard { profile } => Some((ProfilesMode::Onboard, profile)), + } +} + /// Resolve the two independently-gated HiResWheel settings for one device. /// `None` means preserve the device's current value. fn configured_wheel_mode( diff --git a/crates/openlogi-agent-core/src/orchestrator/tests.rs b/crates/openlogi-agent-core/src/orchestrator/tests.rs index 4b176844..18f4c02e 100644 --- a/crates/openlogi-agent-core/src/orchestrator/tests.rs +++ b/crates/openlogi-agent-core/src/orchestrator/tests.rs @@ -2,16 +2,16 @@ use super::{ AgentDevice, InventoryHealth, Orchestrator, VOLATILE_REAPPLY_CONFIRM_RETRIES, - any_device_needs_capture_rearm, build_devices, configured_wheel_mode, host_switch_links, - pick_current, plan_reapply, reapply_targets, + any_device_needs_capture_rearm, build_devices, configured_onboard_profiles, + configured_wheel_mode, host_switch_links, pick_current, plan_reapply, reapply_targets, }; use openlogi_core::binding::{Action, ButtonId}; -use openlogi_core::config::{Config, LightSettings, ScrollResolution}; +use openlogi_core::config::{Config, LightSettings, OnboardProfiles, ScrollResolution}; use openlogi_core::device::{ Capabilities, DeviceInventory, DeviceKind, DeviceModelInfo, DeviceTransports, LightCapabilities, PairedDevice, RawDeviceAddress, ReceiverInfo, StandaloneDevice, }; -use openlogi_hid::{DIRECT_DEVICE_INDEX, DeviceRoute}; +use openlogi_hid::{DIRECT_DEVICE_INDEX, DeviceRoute, ProfilesMode}; use std::sync::Arc; use crate::observable::ObservableState; @@ -272,6 +272,33 @@ fn configured_wheel_mode_leaves_unset_resolution_unmanaged() { assert_eq!(configured_wheel_mode(&config, &device), (None, None)); } +#[test] +fn configured_onboard_profiles_requires_capability_and_explicit_config() { + let mut config = Config::default(); + let mut device = dev("a", 1, true); + device.capabilities = Some(Capabilities { + onboard_profiles: true, + ..Capabilities::default() + }); + + assert_eq!(configured_onboard_profiles(&config, &device), None); + + config.set_onboard_profiles("a", Some(OnboardProfiles::Host {})); + assert_eq!( + configured_onboard_profiles(&config, &device), + Some((ProfilesMode::Host, None)) + ); + + config.set_onboard_profiles("a", Some(OnboardProfiles::Onboard { profile: Some(2) })); + assert_eq!( + configured_onboard_profiles(&config, &device), + Some((ProfilesMode::Onboard, Some(2))) + ); + + device.capabilities = Some(Capabilities::default()); + assert_eq!(configured_onboard_profiles(&config, &device), None); +} + #[test] fn host_switch_links_keep_sleeping_targets_but_require_online_keyboard() { let mut config = Config::default(); diff --git a/crates/openlogi-agent/src/bin/mock_agent.rs b/crates/openlogi-agent/src/bin/mock_agent.rs index 67958909..35953e0b 100644 --- a/crates/openlogi-agent/src/bin/mock_agent.rs +++ b/crates/openlogi-agent/src/bin/mock_agent.rs @@ -25,8 +25,8 @@ //! - A standalone Litra light whose power / brightness / temperature writes //! persist, and a `camera_active` flag that flips every 30s so the //! camera-linked light rendering has something to follow. -//! - DPI / SmartShift writes persist in memory and read back, so sliders and -//! toggles behave like a live device. +//! - DPI / SmartShift / onboard-profile writes persist in memory and read +//! back, so sliders and toggles behave like a live device. //! - `start_pairing` runs a scripted Bolt flow: discovery → passkey → paired, //! and the paired keyboard joins the inventory. @@ -49,8 +49,9 @@ use openlogi_core::hid::LOGITECH_VENDOR_ID; use openlogi_core::single_instance::{self, InstanceError}; use openlogi_hid::{ DIRECT_DEVICE_INDEX, DeviceRoute, Dpi, DpiCapabilities, DpiInfo, LITRA_GLOW_PRODUCT_ID, - LightCommand, PasskeyMethod, ReceiverSelector, SmartShiftAutoDisengage, SmartShiftMode, - SmartShiftStatus, TunableTorque, WriteError, + LightCommand, OnboardProfilesInfo, PasskeyMethod, ProfileEntry, ProfilesMode, ReceiverSelector, + SmartShiftAutoDisengage, SmartShiftMode, SmartShiftStatus, TunableTorque, WriteError, + is_rom_sector, }; use openlogi_ipc::transport; use openlogi_ipc::{ @@ -221,6 +222,7 @@ struct DpiState { struct DeviceSettings { dpi: Option, smartshift: Option, + onboard_profiles: Option, lighting: bool, } @@ -229,6 +231,7 @@ impl DeviceSettings { Self { dpi: None, smartshift: None, + onboard_profiles: None, lighting: false, } } @@ -285,6 +288,32 @@ impl State { ), tunable_torque: Some(MOCK_TORQUE), }), + onboard_profiles: Some(OnboardProfilesInfo { + profile_count: 2, + profile_count_oob: 1, + button_count: 11, + sector_count: 4, + sector_size: 254, + memory_model_id: 1, + profile_format_id: 1, + macro_format_id: 1, + mode: ProfilesMode::Host, + active_profile: 0, + directory: vec![ + ProfileEntry { + sector: 1, + enabled: true, + }, + ProfileEntry { + sector: 2, + enabled: true, + }, + ProfileEntry { + sector: 0x0101, + enabled: true, + }, + ], + }), lighting: false, }, ); @@ -294,6 +323,7 @@ impl State { DeviceSettings { dpi: None, smartshift: None, + onboard_profiles: None, lighting: true, }, ); @@ -305,6 +335,7 @@ impl State { capabilities: DpiCapabilities::new((400u16..=4000).step_by(100).collect())?, }), smartshift: None, + onboard_profiles: None, lighting: false, }, ); @@ -513,6 +544,7 @@ fn bolt_inventory(mouse_battery: BatteryInfo) -> DeviceInventory { thumbwheel: true, haptic_feedback: true, haptic_panel: true, + onboard_profiles: true, }), }, PairedDevice { @@ -560,6 +592,7 @@ fn bolt_inventory(mouse_battery: BatteryInfo) -> DeviceInventory { thumbwheel: false, haptic_feedback: false, haptic_panel: false, + onboard_profiles: false, }), }, ], @@ -609,6 +642,7 @@ fn direct_inventory() -> DeviceInventory { thumbwheel: false, haptic_feedback: false, haptic_panel: false, + onboard_profiles: false, }), }], } @@ -997,4 +1031,51 @@ impl Agent for MockAgent { info!(%route, enabled, "set_light_manual_power"); Ok(()) } + + async fn set_onboard_profiles( + self, + _: Context, + route: DeviceRoute, + mode: ProfilesMode, + profile: Option, + ) -> Result<(), WriteError> { + if mode == ProfilesMode::Onboard + && let Some(sector) = profile + && is_rom_sector(sector) + { + return Err(WriteError::InvalidProfileSector { sector }); + } + let mut state = self.state.lock().await; + let profiles = state + .settings_for_mut(&route)? + .onboard_profiles + .as_mut() + .ok_or(WriteError::FeatureUnsupported { + feature_hex: 0x8100, + })?; + profiles.mode = mode; + match (mode, profile) { + (ProfilesMode::Host, _) => profiles.active_profile = 0, + (ProfilesMode::Onboard, Some(sector)) => profiles.active_profile = sector, + (ProfilesMode::Onboard, None) => {} + } + info!(%route, ?mode, ?profile, "set_onboard_profiles"); + Ok(()) + } + + async fn read_onboard_profiles( + self, + _: Context, + route: DeviceRoute, + ) -> Result { + self.state + .lock() + .await + .settings_for(&route)? + .onboard_profiles + .clone() + .ok_or(WriteError::FeatureUnsupported { + feature_hex: 0x8100, + }) + } } diff --git a/crates/openlogi-agent/src/server.rs b/crates/openlogi-agent/src/server.rs index 540ca2c1..4aec8ae5 100644 --- a/crates/openlogi-agent/src/server.rs +++ b/crates/openlogi-agent/src/server.rs @@ -19,8 +19,8 @@ use openlogi_core::binding::ActionRingSlot; use openlogi_core::config::{Config, Lighting}; use openlogi_core::device::DeviceInventory; use openlogi_hid::{ - DeviceRoute, Dpi, DpiInfo, HapticWaveform, HidppOperation, LightCommand, ReceiverSelector, - SmartShiftStatus, WriteError, + DeviceRoute, Dpi, DpiInfo, HapticWaveform, HidppOperation, LightCommand, OnboardProfilesInfo, + ProfilesMode, ReceiverSelector, SmartShiftStatus, WriteError, }; use openlogi_ipc::transport; use openlogi_ipc::{ @@ -293,6 +293,36 @@ impl Agent for AgentServer { async fn action_ring_cancel(self, _: Context, session_id: u64) { self.action_ring.cancel(session_id); } + + async fn set_onboard_profiles( + self, + _: Context, + route: DeviceRoute, + mode: ProfilesMode, + profile: Option, + ) -> Result<(), WriteError> { + self.shared + .device(&route) + .run(HidppOperation::WriteOnboardProfiles, |c| async move { + openlogi_hid::apply_profiles_config_on(&c, mode, profile) + .await + .map(|_written| ()) + }) + .await + } + + async fn read_onboard_profiles( + self, + _: Context, + route: DeviceRoute, + ) -> Result { + self.shared + .device(&route) + .run(HidppOperation::ReadOnboardProfiles, |c| async move { + openlogi_hid::get_onboard_profiles_on(&c).await + }) + .await + } } /// Coalescing Actions Ring haptic player: at most one waveform is in flight, diff --git a/crates/openlogi-cli/src/cmd/diag.rs b/crates/openlogi-cli/src/cmd/diag.rs index deecea0c..96ccf67e 100644 --- a/crates/openlogi-cli/src/cmd/diag.rs +++ b/crates/openlogi-cli/src/cmd/diag.rs @@ -15,6 +15,7 @@ pub mod controls; pub mod dpi; pub mod features; pub mod lighting; +pub mod profiles; pub mod smartshift; pub mod wheel; @@ -34,6 +35,8 @@ pub enum DiagCmd { Lighting(lighting::LightingArgs), /// Read or set the HID++ 0x2121 wheel reporting resolution. Wheel(wheel::WheelArgs), + /// Read onboard-profile state; round-trip the mode and active profile. + Profiles(profiles::ProfilesArgs), } impl DiagCmd { @@ -46,6 +49,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..e11cdd15 --- /dev/null +++ b/crates/openlogi-cli/src/cmd/diag/profiles.rs @@ -0,0 +1,277 @@ +//! `openlogi diag profiles` — onboard-profiles (HID++ `0x8100`) round-trip. + +use anyhow::{Context, Result, anyhow}; +use clap::Args; +use openlogi_hid::{DeviceRoute, OnboardProfilesInfo, ProfilesMode, is_rom_sector}; + +use crate::cmd::diag::select_device; + +#[derive(Debug, Args)] +pub struct ProfilesArgs { + /// Only read and print the onboard-profile state; skip all writes. + #[arg(long, conflicts_with = "leave_onboard")] + pub read_only: bool, + + /// Leave the device in onboard mode after the diagnostic. Useful for + /// visually checking an onboard profile or the agent's reconnect reapply. + #[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<()> { + 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-profile state")?; + print_info(&info); + if args.read_only { + return Ok(()); + } + + // Firmware rejects setCurrentProfile in host mode, so the profile test + // runs inside an onboard-mode window. Capture the whole operation as a + // result so a failure still reaches the mode-restoration path below. + let operation = match enter_onboard(&route, info.mode).await { + Ok(()) => profile_round_trip(&route, &info).await, + Err(error) => Err(error), + }; + + if args.leave_onboard { + operation?; + println!("✓ onboard-profile diagnostic OK (device left in onboard mode)"); + return Ok(()); + } + + let restore = restore_mode(&route, info.mode).await; + finish_with_restore(operation, restore)?; + println!("✓ onboard-profile diagnostic OK"); + Ok(()) +} + +async fn enter_onboard(route: &DeviceRoute, original: ProfilesMode) -> Result<()> { + if original == ProfilesMode::Onboard { + return Ok(()); + } + println!(" entering mode: {original:?} -> Onboard"); + 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:?}" + ); + } + Ok(()) +} + +/// Activate an enabled user profile and restore the original user profile. +async fn profile_round_trip(route: &DeviceRoute, info: &OnboardProfilesInfo) -> Result<()> { + let Some(target) = round_trip_target(info) else { + if info.mode == ProfilesMode::Onboard + && (info.active_profile == 0 || is_rom_sector(info.active_profile)) + { + println!( + " active onboard profile is not a restorable user sector — profile round-trip skipped" + ); + } else { + println!(" no enabled user profiles in the directory — profile round-trip skipped"); + } + return Ok(()); + }; + + if target == info.active_profile { + println!(" only one enabled user profile — exercising sector {target:#06x} in place"); + } + println!(" activating profile sector {target:#06x}"); + let write = openlogi_hid::set_active_profile(route, target) + .await + .context("write active profile") + .and_then(|read_back| { + if read_back == target { + Ok(()) + } else { + anyhow::bail!( + "active-profile write not applied: requested {target:#06x}, device reports {read_back:#06x}" + ) + } + }); + + // A failed write may still have reached the device before its confirming + // read failed. Restore whenever the original is a distinct user sector. + let restore = restore_profile(route, info.active_profile, target).await; + finish_with_restore(write, restore)?; + println!(" ✓ profile round-trip OK"); + Ok(()) +} + +fn round_trip_target(info: &OnboardProfilesInfo) -> Option { + if info.mode == ProfilesMode::Onboard + && (info.active_profile == 0 || is_rom_sector(info.active_profile)) + { + return None; + } + let mut enabled_users = info + .directory + .iter() + .filter(|entry| entry.enabled && !entry.is_rom()) + .map(|entry| entry.sector); + enabled_users + .find(|§or| sector != info.active_profile) + .or_else(|| { + info.directory + .iter() + .find(|entry| entry.enabled && !entry.is_rom()) + .map(|entry| entry.sector) + }) +} + +async fn restore_profile(route: &DeviceRoute, original: u16, target: u16) -> Result<()> { + if original == 0 || original == target || is_rom_sector(original) { + return Ok(()); + } + println!(" restoring profile sector {original:#06x}"); + let restored = openlogi_hid::set_active_profile(route, original) + .await + .context("restore active profile")?; + if restored != original { + anyhow::bail!( + "active-profile restore not applied: requested {original:#06x}, device reports {restored:#06x}" + ); + } + Ok(()) +} + +async fn restore_mode(route: &DeviceRoute, original: ProfilesMode) -> Result<()> { + if original == ProfilesMode::Onboard { + return Ok(()); + } + println!(" restoring mode: {original:?}"); + let restored = openlogi_hid::set_profiles_mode(route, original) + .await + .context("restore onboard mode")?; + if restored != original { + anyhow::bail!( + "onboard mode restore not applied: requested {original:?}, device reports {restored:?}" + ); + } + println!(" ✓ mode round-trip OK"); + Ok(()) +} + +fn finish_with_restore(operation: Result<()>, restore: Result<()>) -> Result<()> { + match (operation, restore) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), + (Err(operation), Err(restore)) => Err(anyhow!( + "{operation:#}; additionally failed to restore device state: {restore:#}" + )), + } +} + +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 is_rom_sector(sector) => println!(" active profile: {sector:#06x} (ROM)"), + sector => println!(" active profile: {sector:#06x}"), + } + if info.directory.is_empty() { + println!(" directory: empty (erased flash or no profiles written)"); + return; + } + println!(" directory:"); + for entry in &info.directory { + println!( + " sector {:#06x} {}{}", + entry.sector, + if entry.enabled { "enabled" } else { "disabled" }, + if entry.is_rom() { " (ROM)" } else { "" } + ); + } +} + +#[cfg(test)] +mod tests { + use openlogi_hid::{OnboardProfilesInfo, ProfileEntry, ProfilesMode}; + + use super::{finish_with_restore, round_trip_target}; + + fn info(mode: ProfilesMode, active_profile: u16) -> OnboardProfilesInfo { + OnboardProfilesInfo { + profile_count: 2, + profile_count_oob: 1, + button_count: 11, + sector_count: 4, + sector_size: 254, + memory_model_id: 1, + profile_format_id: 1, + macro_format_id: 1, + mode, + active_profile, + directory: vec![ + ProfileEntry { + sector: 1, + enabled: true, + }, + ProfileEntry { + sector: 2, + enabled: true, + }, + ProfileEntry { + sector: 0x0101, + enabled: true, + }, + ], + } + } + + #[test] + fn target_prefers_an_alternate_enabled_user_sector() { + assert_eq!(round_trip_target(&info(ProfilesMode::Onboard, 1)), Some(2)); + } + + #[test] + fn target_never_selects_or_replaces_an_active_rom_sector() { + assert_eq!( + round_trip_target(&info(ProfilesMode::Onboard, 0x0101)), + None + ); + } + + #[test] + fn host_mode_with_no_active_profile_can_exercise_a_user_sector() { + assert_eq!(round_trip_target(&info(ProfilesMode::Host, 0)), Some(1)); + } + + #[test] + fn operation_error_is_not_hidden_when_restoration_also_fails() { + let Err(error) = finish_with_restore( + Err(anyhow::anyhow!("write")), + Err(anyhow::anyhow!("restore")), + ) else { + panic!("both failures must be reported"); + }; + let error = error.to_string(); + assert!(error.contains("write")); + assert!(error.contains("restore")); + } +} diff --git a/crates/openlogi-cli/src/lib.rs b/crates/openlogi-cli/src/lib.rs index 7906dfa7..e5f3eaa7 100644 --- a/crates/openlogi-cli/src/lib.rs +++ b/crates/openlogi-cli/src/lib.rs @@ -146,6 +146,40 @@ mod tests { } } + #[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!(!args.leave_onboard); + assert_eq!(args.device.as_deref(), Some("G502")); + } + other => panic!("expected Diag(Profiles), got {other:?}"), + } + } + + #[test] + fn profiles_read_only_conflicts_with_leave_onboard() { + Cli::try_parse_from([ + "openlogi", + "diag", + "profiles", + "--read-only", + "--leave-onboard", + ]) + .expect_err("read-only and leave-onboard must conflict"); + } + #[test] fn lighting_color_is_positional_and_method_is_a_flag() { let cli = Cli::try_parse_from([ diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index a49b5ab9..71210b46 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -25,7 +25,7 @@ use file::{backup_existing_config, config_backup_path}; pub use key_trigger::{KeyModifiers, KeyTrigger, KeyboardConfig, ParseTriggerError}; pub use settings::LightSettings; pub use settings::{ - AppSettings, Appearance, AssetSourcePreference, CameraControls, Lighting, + AppSettings, Appearance, AssetSourcePreference, CameraControls, Lighting, OnboardProfiles, SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE, ScrollResolution, SmartShift, ThumbwheelSensitivity, WheelMode, }; @@ -742,6 +742,24 @@ impl Config { .scroll_resolution = resolution; } + /// The configured onboard-profile selection for `device_key`, or `None` + /// when OpenLogi must leave the device's current mode unchanged. + #[must_use] + pub fn onboard_profiles(&self, device_key: &str) -> Option { + self.devices + .get(device_key) + .and_then(|device| device.onboard_profiles) + } + + /// Set or clear the onboard-profile selection OpenLogi should restore for + /// `device_key`. Passing `None` returns the device to unmanaged mode. + pub fn set_onboard_profiles(&mut self, device_key: &str, profiles: Option) { + self.devices + .entry(device_key.to_string()) + .or_default() + .onboard_profiles = profiles; + } + /// Whether OpenLogi manages `device_key` at all (capture + volatile /// re-apply). Unconfigured devices are managed. #[must_use] diff --git a/crates/openlogi-core/src/config/device.rs b/crates/openlogi-core/src/config/device.rs index 52548dde..62f250d5 100644 --- a/crates/openlogi-core/src/config/device.rs +++ b/crates/openlogi-core/src/config/device.rs @@ -7,8 +7,8 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use super::settings::{ - CameraControls, GestureOwner, LightSettings, Lighting, ScrollResolution, SmartShift, - ThumbwheelSensitivity, deserialize_gesture_owner, + CameraControls, GestureOwner, LightSettings, Lighting, OnboardProfiles, ScrollResolution, + SmartShift, ThumbwheelSensitivity, deserialize_gesture_owner, }; use crate::binding::{Action, ActionRingConfig, Binding, ButtonId, GestureDirection}; use crate::device::{Capabilities, DeviceKind, DeviceModelInfo, LightCapabilities}; @@ -197,6 +197,10 @@ pub struct DeviceConfig { /// [`Self::dpi`]. `None` means "never set — leave the keyboard alone". #[serde(default, skip_serializing_if = "Option::is_none")] pub fn_lock: Option, + /// Onboard-profile selection for HID++ `0x8100`, re-applied on every + /// reconnect. `None` leaves the device's current mode unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onboard_profiles: Option, } impl Default for DeviceConfig { @@ -224,6 +228,7 @@ impl Default for DeviceConfig { scroll_resolution: None, host_switch_targets: Vec::new(), fn_lock: None, + onboard_profiles: None, } } } @@ -341,6 +346,8 @@ struct RawDeviceConfig { host_switch_targets: Vec, #[serde(default)] fn_lock: Option, + #[serde(default)] + onboard_profiles: Option, #[serde(default = "default_true")] enabled: bool, } @@ -397,6 +404,7 @@ impl From for DeviceConfig { scroll_resolution: raw.scroll_resolution, host_switch_targets: raw.host_switch_targets, fn_lock: raw.fn_lock, + onboard_profiles: raw.onboard_profiles, } } } diff --git a/crates/openlogi-core/src/config/settings.rs b/crates/openlogi-core/src/config/settings.rs index 2ca23c3a..e5a55cc5 100644 --- a/crates/openlogi-core/src/config/settings.rs +++ b/crates/openlogi-core/src/config/settings.rs @@ -447,6 +447,26 @@ pub enum ScrollResolution { High, } +/// Per-device source selection for HID++ `0x8100` onboard profile memory. +/// +/// Tagged serialization preserves the existing TOML shape +/// (`mode = "host"` or `mode = "onboard"`). Making host and onboard distinct +/// variants prevents a host-mode configuration from carrying an ignored +/// profile sector. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)] +pub enum OnboardProfiles { + /// Host mode: OpenLogi drives the device and onboard profiles are dormant. + Host {}, + /// Onboard mode: the device applies a profile from its own flash. + Onboard { + /// Flash sector to activate, or `None` to keep the device's current + /// active profile. + #[serde(default, skip_serializing_if = "Option::is_none")] + profile: Option, + }, +} + /// Scroll-wheel mode for [`SmartShift`]: free-spin or ratchet (clicky). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -560,6 +580,29 @@ where mod tests { use super::*; + #[test] + fn onboard_profiles_parse_both_toml_shapes() { + let onboard: OnboardProfiles = + toml::from_str("mode = \"onboard\"\nprofile = 2\n").expect("parse onboard"); + assert_eq!(onboard, OnboardProfiles::Onboard { profile: Some(2) }); + + let host: OnboardProfiles = toml::from_str("mode = \"host\"\n").expect("parse host"); + assert_eq!(host, OnboardProfiles::Host {}); + assert!( + !toml::to_string(&host) + .expect("serialize") + .contains("profile"), + "host mode must not serialize a profile" + ); + } + + #[test] + fn host_mode_rejects_a_profile_sector() { + let result = toml::from_str::("mode = \"host\"\nprofile = 2\n"); + + assert!(result.is_err(), "host mode must not accept a profile"); + } + #[test] fn smartshift_rejects_values_outside_the_persisted_contract() { let parse = |auto_disengage: u8, tunable_torque: u8| { diff --git a/crates/openlogi-core/src/config/tests.rs b/crates/openlogi-core/src/config/tests.rs index 8fe0cafa..34f733f8 100644 --- a/crates/openlogi-core/src/config/tests.rs +++ b/crates/openlogi-core/src/config/tests.rs @@ -13,6 +13,22 @@ fn write_and_read(config: &Config) -> Config { Config::load_from_path(&path).expect("load") } +#[test] +fn onboard_profiles_round_trip_and_can_return_to_unmanaged() { + let mut config = Config::default(); + assert_eq!(config.onboard_profiles("mouse"), None); + + let profiles = OnboardProfiles::Onboard { profile: Some(2) }; + config.set_onboard_profiles("mouse", Some(profiles)); + let restored = write_and_read(&config); + assert_eq!(restored.onboard_profiles("mouse"), Some(profiles)); + assert_eq!(restored.onboard_profiles("other"), None); + + config.set_onboard_profiles("mouse", None); + let restored = write_and_read(&config); + assert_eq!(restored.onboard_profiles("mouse"), None); +} + #[test] fn canonical_configuration_example_parses() { let body = include_str!("../../../../docs/config.example.toml"); @@ -527,6 +543,7 @@ fn device_identity_roundtrips_and_is_iterable() { thumbwheel: false, haptic_feedback: false, haptic_panel: false, + onboard_profiles: false, }, light_capabilities: None, driver_id: None, diff --git a/crates/openlogi-core/src/device.rs b/crates/openlogi-core/src/device.rs index d12f592a..5829f086 100644 --- a/crates/openlogi-core/src/device.rs +++ b/crates/openlogi-core/src/device.rs @@ -128,6 +128,9 @@ pub struct Capabilities { /// device's `0x1b04` control table. #[serde(default)] pub haptic_panel: bool, + /// HID++ `0x8100 OnboardProfiles` is present. + #[serde(default)] + pub onboard_profiles: bool, } impl Capabilities { @@ -153,6 +156,7 @@ impl Capabilities { thumbwheel: ids.contains(&0x2150), haptic_feedback: ids.contains(&0x19b0), haptic_panel: false, + onboard_profiles: ids.contains(&0x8100), } } @@ -173,6 +177,7 @@ impl Capabilities { thumbwheel: false, haptic_feedback: false, haptic_panel: false, + onboard_profiles: false, }, DeviceKind::Keyboard => Self { lighting: true, @@ -478,6 +483,7 @@ mod tests { thumbwheel: false, haptic_feedback: false, haptic_panel: false, + onboard_profiles: false, }), }], } @@ -545,9 +551,11 @@ mod tests { thumbwheel: true, haptic_feedback: false, haptic_panel: false, + onboard_profiles: false, } ); assert!(!Capabilities::from_feature_ids(&[0x0003, 0x1b04]).thumbwheel); + assert!(Capabilities::from_feature_ids(&[0x8100]).onboard_profiles); // A wired G-series keyboard: PerKeyLighting (0x8080), no DPI/buttons. let keyboard = Capabilities::from_feature_ids(&[0x0001, 0x8080]); assert_eq!( @@ -561,6 +569,7 @@ mod tests { thumbwheel: false, haptic_feedback: false, haptic_panel: false, + onboard_profiles: false, } ); // No driving features → nothing offered. @@ -587,7 +596,7 @@ mod tests { } #[test] - fn persisted_capabilities_without_appended_wheel_fields_load_as_unsupported() + fn persisted_capabilities_without_appended_fields_load_as_unsupported() -> Result<(), toml::de::Error> { use super::Capabilities; @@ -602,6 +611,7 @@ mod tests { assert!(!capabilities.hires_wheel); assert!(!capabilities.thumbwheel); + assert!(!capabilities.onboard_profiles); assert!(capabilities.scroll_inversion); Ok(()) } diff --git a/crates/openlogi-core/src/diagnostics.rs b/crates/openlogi-core/src/diagnostics.rs index d9ac43b5..0df5ad40 100644 --- a/crates/openlogi-core/src/diagnostics.rs +++ b/crates/openlogi-core/src/diagnostics.rs @@ -598,6 +598,7 @@ mod tests { thumbwheel: false, haptic_feedback: false, haptic_panel: false, + onboard_profiles: false, }), dpi: Some("1600 dpi (range 200–8000, 5 steps)".to_string()), config_key: "4082d".to_string(), diff --git a/crates/openlogi-core/src/hid.rs b/crates/openlogi-core/src/hid.rs index 7942e172..e4c15790 100644 --- a/crates/openlogi-core/src/hid.rs +++ b/crates/openlogi-core/src/hid.rs @@ -11,6 +11,7 @@ pub mod dpi; pub mod error; pub mod light; +pub mod onboard_profiles; pub mod pairing; pub mod route; pub mod smartshift; @@ -18,6 +19,7 @@ pub mod smartshift; pub use dpi::{Dpi, DpiCapabilities, DpiInfo}; pub use error::{HidppFeatureErrorKind, HidppOperation, WriteError}; pub use light::{LightCommand, commands_for_light_settings}; +pub use onboard_profiles::{OnboardProfilesInfo, ProfileEntry, ProfilesMode, is_rom_sector}; pub use pairing::{Click, PairingError, PasskeyMethod, ReceiverSelector}; pub use route::{ BOLT_PIDS, DIRECT_DEVICE_INDEX, DeviceRoute, LIGHTSPEED_PIDS, LOGITECH_VENDOR_ID, diff --git a/crates/openlogi-core/src/hid/error.rs b/crates/openlogi-core/src/hid/error.rs index da6e99f6..f9219e99 100644 --- a/crates/openlogi-core/src/hid/error.rs +++ b/crates/openlogi-core/src/hid/error.rs @@ -95,6 +95,13 @@ pub enum WriteError { /// Multiple raw HID nodes matched one physical route. #[error("multiple raw HID devices matched the route")] AmbiguousRawDevice, + /// A ROM profile sector was supplied to an operation that only accepts + /// selectable user profiles. + #[error("ROM profile sector {sector:#06x} is not selectable")] + InvalidProfileSector { + /// Rejected ROM sector. + sector: u16, + }, } /// HID++ operation being performed when a device write/read failed. @@ -132,6 +139,11 @@ pub enum HidppOperation { Light, /// Play one haptic waveform. Appended last — variant order is wire format. PlayHaptic, + /// Read onboard-profile state. Appended last—variant order is wire format. + ReadOnboardProfiles, + /// Write onboard-profile mode or active sector. Appended last—variant + /// order is wire format. + WriteOnboardProfiles, } /// HID++ feature error kind in a serializable wire-safe form. diff --git a/crates/openlogi-core/src/hid/onboard_profiles.rs b/crates/openlogi-core/src/hid/onboard_profiles.rs new file mode 100644 index 00000000..8552ff44 --- /dev/null +++ b/crates/openlogi-core/src/hid/onboard_profiles.rs @@ -0,0 +1,109 @@ +//! Pure wire types for HID++ `OnboardProfiles` (feature `0x8100`). +//! +//! The protocol wrapper and device I/O live outside `openlogi-core`. These +//! types describe the state that crosses the agent↔GUI boundary. Activating an +//! onboard profile reloads its stored settings, so the agent applies a +//! configured mode/profile before other volatile device settings. + +use serde::{Deserialize, Serialize}; + +/// Whether `sector` names a ROM (factory) profile rather than a writable user +/// profile. +#[must_use] +pub fn is_rom_sector(sector: u16) -> bool { + const ROM_SECTOR_FLAG: u16 = 0x0100; + sector & ROM_SECTOR_FLAG != 0 +} + +/// Whether a gaming device applies its onboard flash profile or host software +/// settings. +/// +/// Crosses the agent↔GUI IPC — serde encodes the variant *index* (Host=0, +/// Onboard=1), not a firmware byte — so variant order is wire format and +/// changes require a `PROTOCOL_VERSION` bump (guarded by +/// `openlogi-ipc/tests/wire_format.rs`). The firmware byte mapping lives in +/// `openlogi-hid`. +#[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, so field order is wire format—changes require a +/// `PROTOCOL_VERSION` bump. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProfileEntry { + /// Flash sector holding the profile. User profiles live in sectors + /// `0x0001..`; ROM (factory) profiles carry the `0x0100` flag. + pub sector: u16, + /// Whether the profile is enabled on the device. + pub enabled: bool, +} + +impl ProfileEntry { + /// Whether this is a ROM (factory) profile rather than a writable user + /// profile. + #[must_use] + pub fn is_rom(&self) -> bool { + is_rom_sector(self.sector) + } +} + +/// Snapshot of a device's onboard-profiles state. +/// +/// Crosses the agent↔GUI IPC, so field order is wire format—changes require a +/// `PROTOCOL_VERSION` bump (guarded by +/// `openlogi-ipc/tests/wire_format.rs`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OnboardProfilesInfo { + /// Number of writable user profiles. + pub profile_count: u8, + /// Number of out-of-box (ROM) profiles. + pub profile_count_oob: u8, + /// Number of physical buttons covered by a profile. + pub button_count: u8, + /// Number of writable flash sectors. + pub sector_count: u8, + /// Size of one flash sector in bytes. + pub sector_size: u16, + /// Memory model identifier (raw, informational). + pub memory_model_id: u8, + /// Profile format identifier (raw, informational). + pub profile_format_id: u8, + /// Macro format identifier (raw, informational). + pub macro_format_id: u8, + /// Whether the device is in host or onboard mode. + pub mode: ProfilesMode, + /// Sector of the active profile, or `0x0000` in host mode because no + /// onboard profile is active. + 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-desktop/src/app.rs b/crates/openlogi-desktop/src/app.rs index baed2cfe..a8199e39 100644 --- a/crates/openlogi-desktop/src/app.rs +++ b/crates/openlogi-desktop/src/app.rs @@ -23,6 +23,7 @@ use crate::features::lighting::standalone::LightPanel; use crate::features::mouse::view::MouseModelView; use crate::features::pointer::dpi::DpiPanel; use crate::features::pointer::smartshift::SmartShiftPanel; +use crate::features::profiles::ProfilesPanel; use crate::services::assets::AssetResolver; use crate::state::{AgentLink, AppState, DeviceRecord}; use crate::ui::theme::{self, Palette, Typography as _}; @@ -63,11 +64,8 @@ enum Route { /// The active section of the device-detail screen. Backs the detail `TabBar`; /// reset to the device's first tab whenever a device is opened. /// -/// The tab *set* depends on the device kind — see [`DetailTab::tabs_for`]. A -/// mouse gets button-mapping + pointer tuning; a wired keyboard gets RGB -/// lighting; every device gets the info tab. Tailoring the tabs is what keeps a -/// keyboard from rendering a mouse silhouette and an irrelevant DPI panel -/// (issue #19). +/// The tab set follows measured device capabilities — see +/// [`DetailTab::tabs_for`]. Every device also gets the info tab. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DetailTab { /// The mouse model with clickable button hotspots. @@ -80,6 +78,8 @@ enum DetailTab { Pointer, /// RGB lighting — color, brightness, on/off. Lighting, + /// Onboard profile source and active profile. + Profiles, /// Live webcam preview (UVC cameras only). Camera, /// Standalone light controls driven by a raw-HID device driver. @@ -131,6 +131,9 @@ impl DetailTab { if caps.lighting { tabs.push(Self::Lighting); } + if caps.onboard_profiles { + tabs.push(Self::Profiles); + } if record.light_capabilities.is_some() { tabs.push(Self::Light); } @@ -153,6 +156,7 @@ impl DetailTab { Self::Keys => tr!("Keys"), Self::Pointer => tr!("Pointer"), Self::Lighting | Self::Light => tr!("Lighting"), + Self::Profiles => tr!("Profiles"), Self::Camera => tr!("Camera"), Self::Device => tr!("Device"), } @@ -169,6 +173,7 @@ pub struct AppView { dpi_panel: Entity, smartshift_panel: Entity, lighting_panel: Entity, + profiles_panel: Entity, camera_preview: Entity, camera_controls: Entity, light_panel: Entity, @@ -217,6 +222,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 camera_preview = cx.new(CameraPreview::new); let camera_controls = cx.new(CameraControlsPanel::new); let light_panel = cx.new(LightPanel::new); @@ -230,6 +236,7 @@ impl AppView { dpi_panel, smartshift_panel, lighting_panel, + profiles_panel, camera_preview, camera_controls, light_panel, @@ -543,6 +550,7 @@ impl Render for AppView { dpi_panel: &self.dpi_panel, smartshift_panel: &self.smartshift_panel, lighting_panel: &self.lighting_panel, + profiles_panel: &self.profiles_panel, camera_preview: &self.camera_preview, camera_controls: &self.camera_controls, light_panel: &self.light_panel, @@ -728,6 +736,7 @@ mod tests { thumbwheel: false, haptic_feedback: false, haptic_panel: false, + onboard_profiles: false, }); // After 0x0005 kind-correction the record has kind=Mouse, not Keyboard. let tabs = DetailTab::tabs_for(&record(DeviceKind::Mouse, caps)); @@ -736,6 +745,16 @@ mod tests { assert!(!tabs.contains(&DetailTab::Lighting)); } + #[test] + fn profiles_tab_follows_onboard_profiles_capability() { + let caps = Some(Capabilities { + onboard_profiles: true, + ..Capabilities::default() + }); + let tabs = DetailTab::tabs_for(&record(DeviceKind::Mouse, caps)); + assert_eq!(tabs, vec![DetailTab::Profiles, DetailTab::Device]); + } + /// A keyboard that exposes ReprogControls (buttons=true) but has no resolved /// asset should not get the mouse-model Buttons panel — the generic mouse /// hotspot layout (Middle Click, DPI Toggle, …) is wrong for a keyboard. @@ -750,6 +769,7 @@ mod tests { thumbwheel: false, haptic_feedback: false, haptic_panel: false, + onboard_profiles: false, }); let tabs = DetailTab::tabs_for(&record(DeviceKind::Keyboard, caps)); assert!( @@ -770,6 +790,7 @@ mod tests { thumbwheel: false, haptic_feedback: false, haptic_panel: false, + onboard_profiles: false, }); let tabs = DetailTab::tabs_for(&record(DeviceKind::Keyboard, caps)); assert!(tabs.contains(&DetailTab::Keys)); diff --git a/crates/openlogi-desktop/src/app/detail.rs b/crates/openlogi-desktop/src/app/detail.rs index dfb2f8c6..f29fdd35 100644 --- a/crates/openlogi-desktop/src/app/detail.rs +++ b/crates/openlogi-desktop/src/app/detail.rs @@ -1,5 +1,5 @@ //! The device-detail screen: the header (back + name + section tabs), and the -//! section bodies (Buttons, Keys, Pointer, Lighting, Camera, Device). +//! section bodies (Buttons, Keys, Pointer, Lighting, Profiles, Camera, Device). use gpui::{ AnyElement, BorrowAppContext as _, Context, IntoElement, ParentElement, SharedString, Styled, @@ -34,6 +34,7 @@ use crate::features::lighting::visual as light_visual; use crate::features::mouse::view::MouseModelView; use crate::features::pointer::dpi::DpiPanel; use crate::features::pointer::smartshift::SmartShiftPanel; +use crate::features::profiles::ProfilesPanel; use crate::state::{AppState, DeviceRecord}; use crate::ui::theme::{HEADER_H, Palette, SCREEN_PAD, Typography as _}; @@ -100,6 +101,7 @@ pub(super) struct DetailPanels<'a> { pub dpi_panel: &'a gpui::Entity, pub smartshift_panel: &'a gpui::Entity, pub lighting_panel: &'a gpui::Entity, + pub profiles_panel: &'a gpui::Entity, pub camera_preview: &'a gpui::Entity, pub camera_controls: &'a gpui::Entity, pub light_panel: &'a gpui::Entity, @@ -123,6 +125,7 @@ pub(super) fn detail_content( pointer_tab(panels.dpi_panel, panels.smartshift_panel, pal, cx).into_any_element() } DetailTab::Lighting => lighting_tab(panels.lighting_panel, pal).into_any_element(), + DetailTab::Profiles => profiles_tab(panels.profiles_panel, pal).into_any_element(), DetailTab::Camera => { camera_tab(panels.camera_preview, panels.camera_controls, pal).into_any_element() } @@ -449,6 +452,23 @@ fn lighting_tab(lighting_panel: &gpui::Entity, pal: Palette) -> i ))) } +/// Onboard-profile source and active-profile controls. +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"), + Icon::empty().path("action-icons/list-checks.svg"), + pal, + profiles_panel.clone().into_any_element(), + ))) +} + /// Camera tab: the live webcam preview beside the device-level image controls, /// each in a titled card. Side by side at the default window width so every /// control is visible without scrolling; the cards wrap to a stacked column diff --git a/crates/openlogi-desktop/src/features/mod.rs b/crates/openlogi-desktop/src/features/mod.rs index 9893120f..6452b4b5 100644 --- a/crates/openlogi-desktop/src/features/mod.rs +++ b/crates/openlogi-desktop/src/features/mod.rs @@ -6,3 +6,4 @@ pub mod keyboard; pub mod lighting; pub mod mouse; pub mod pointer; +pub mod profiles; diff --git a/crates/openlogi-desktop/src/features/profiles.rs b/crates/openlogi-desktop/src/features/profiles.rs new file mode 100644 index 00000000..bda93b2f --- /dev/null +++ b/crates/openlogi-desktop/src/features/profiles.rs @@ -0,0 +1,319 @@ +//! Onboard-profile controls for devices with HID++ feature `0x8100`. +//! +//! The panel selects whether OpenLogi or onboard memory drives the device and, +//! in onboard mode, which writable profile is active. Reads are lazy and every +//! write is followed by a confirming read so rejected changes do not remain as +//! optimistic UI state. + +use gpui::{ + AnyElement, App, BorrowAppContext as _, Context, IntoElement, ParentElement, Render, + SharedString, Styled, Subscription, Window, div, +}; +use gpui_component::{Selectable as _, button::Button, h_flex, v_flex}; +use openlogi_core::hid::{DeviceRoute, OnboardProfilesInfo, ProfileEntry, ProfilesMode}; + +use crate::state::{AppState, DeviceKey, ProfilesLoad}; +use crate::ui::device_read::issue_device_read; +use crate::ui::status::{retry_line, status_line}; +use crate::ui::theme::{self, Palette, Typography as _}; + +/// Onboard-profile source and active-profile controls. +pub struct ProfilesPanel { + _state_obs: Subscription, +} + +impl ProfilesPanel { + /// Construct the panel and subscribe it to application-state changes. + pub fn new(cx: &mut Context) -> Self { + let state_obs = cx.observe_global::(|_, cx| cx.notify()); + Self { + _state_obs: state_obs, + } + } + + fn ensure_profiles_load(cx: &mut Context) { + let Some((key, route)) = profiles_load_target(cx) else { + return; + }; + cx.update_global::(|state, _| state.reads.profiles.mark_loading(&key)); + Self::issue_profiles_read( + key, + route, + |state, key| state.reads.profiles.clear_loading(key), + cx, + ); + } + + 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: DeviceKey, + route: DeviceRoute, + clear: impl Fn(&mut AppState, &DeviceKey) + 'static, + cx: &mut Context, + ) { + issue_device_read( + cx, + key, + route, + crate::services::ipc::Command::ReadOnboardProfiles, + AppState::store_profiles_info, + clear, + ); + } + + fn ready_body(info: &OnboardProfilesInfo, pal: Palette) -> AnyElement { + let onboard = info.mode == ProfilesMode::Onboard; + 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_button( + "profiles-source-host", + tr!("OpenLogi settings"), + !onboard, + ProfilesMode::Host, + None, + )) + .child(source_button( + "profiles-source-onboard", + tr!("Onboard memory"), + onboard, + ProfilesMode::Onboard, + keep_profile, + )), + ) + .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 profiles: Vec<_> = selectable_profiles(info).collect(); + let profile_row = v_flex() + .gap_2() + .child(section_label(tr!("Active onboard profile"), pal)) + .child(if profiles.is_empty() { + status_line(tr!("No enabled profiles in the device's memory."), pal) + } else { + h_flex() + .gap_2() + .flex_wrap() + .children(profiles.into_iter().map(|(index, entry)| { + profile_button(index, entry, entry.sector == info.active_profile) + })) + .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 (key, status) = cx + .try_global::() + .and_then(|state| { + let key = state.current_record()?.device_key(); + Some((Some(key), state.current_profiles_status())) + }) + .unwrap_or((None, ProfilesLoad::Unknown)); + let reachable = cx + .try_global::() + .and_then(AppState::current_record) + .is_some_and(|record| record.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, + retry_profiles_closure(key), + ), + 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<(DeviceKey, DeviceRoute)> { + cx.try_global::().and_then(|state| { + let record = state.current_record()?; + let key = record.device_key(); + if !state.current_profiles_unqueried() { + return None; + } + Some((key, record.route.clone()?)) + }) +} + +fn retry_profiles_closure(key: Option) -> impl Fn(&mut App) + 'static { + move |cx| { + if let Some(key) = &key { + cx.update_global::(|state, _| state.retry_profiles(key)); + } + cx.refresh_windows(); + } +} + +/// Keep the active writable profile, or fall back to the first enabled one. +/// ROM sectors are excluded because firmware rejects them as active profiles. +fn keep_profile_for(info: &OnboardProfilesInfo) -> Option { + let active_is_selectable = + selectable_profiles(info).any(|(_, entry)| entry.sector == info.active_profile); + active_is_selectable + .then_some(info.active_profile) + .or_else(|| { + selectable_profiles(info) + .next() + .map(|(_, entry)| entry.sector) + }) +} + +/// Selectable profiles paired with their original directory positions. +fn selectable_profiles( + info: &OnboardProfilesInfo, +) -> impl Iterator + '_ { + info.directory + .iter() + .copied() + .enumerate() + .filter(|(_, entry)| entry.enabled && !entry.is_rom()) +} + +fn section_label(text: SharedString, pal: Palette) -> AnyElement { + div() + .text_body() + .text_color(pal.text_muted) + .child(text) + .into_any_element() +} + +fn source_button( + id: &'static str, + label: SharedString, + selected: bool, + target: ProfilesMode, + profile: Option, +) -> Button { + Button::new(id) + .compact() + .label(label) + .selected(selected) + .on_click(move |_, _, cx| { + cx.update_global::(|state, _| { + state.commit_onboard_profiles(target, profile); + }); + cx.refresh_windows(); + }) +} + +fn profile_button(index: usize, entry: ProfileEntry, selected: bool) -> Button { + let sector = entry.sector; + let n = (index + 1).to_string(); + Button::new(SharedString::from(format!("profile-button-{sector}"))) + .compact() + .label(tr!("Profile %{n}", n => n)) + .selected(selected) + .on_click(move |_, _, cx| { + cx.update_global::(|state, _| { + state.commit_onboard_profiles(ProfilesMode::Onboard, Some(sector)); + }); + cx.refresh_windows(); + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn info(active_profile: u16, directory: Vec) -> OnboardProfilesInfo { + OnboardProfilesInfo { + profile_count: 3, + profile_count_oob: 1, + button_count: 5, + sector_count: 3, + sector_size: 256, + memory_model_id: 1, + profile_format_id: 1, + macro_format_id: 1, + mode: ProfilesMode::Onboard, + active_profile, + directory, + } + } + + #[test] + fn selectable_profiles_exclude_disabled_and_rom_entries_without_renumbering() { + let info = info( + 3, + vec![ + ProfileEntry { + sector: 1, + enabled: false, + }, + ProfileEntry { + sector: 0x0101, + enabled: true, + }, + ProfileEntry { + sector: 3, + enabled: true, + }, + ], + ); + let profiles: Vec<_> = selectable_profiles(&info).collect(); + assert_eq!(profiles, vec![(2, info.directory[2])]); + } + + #[test] + fn profile_fallback_never_selects_rom() { + let info = info( + 0x0101, + vec![ + ProfileEntry { + sector: 0x0101, + enabled: true, + }, + ProfileEntry { + sector: 2, + enabled: true, + }, + ], + ); + assert_eq!(keep_profile_for(&info), Some(2)); + } +} diff --git a/crates/openlogi-desktop/src/services/ipc.rs b/crates/openlogi-desktop/src/services/ipc.rs index 67a2f598..5429ecfa 100644 --- a/crates/openlogi-desktop/src/services/ipc.rs +++ b/crates/openlogi-desktop/src/services/ipc.rs @@ -27,7 +27,8 @@ use std::time::{Duration, Instant}; use openlogi_core::config::Lighting; use openlogi_core::hid::{ - DeviceRoute, Dpi, DpiInfo, LightCommand, ReceiverSelector, SmartShiftStatus, WriteError, + DeviceRoute, Dpi, DpiInfo, LightCommand, OnboardProfilesInfo, ProfilesMode, ReceiverSelector, + SmartShiftStatus, WriteError, }; use openlogi_ipc::{ AgentClient, AgentSnapshot, ConfigReloadError, Generation, OBSERVE_HOLD, Observation, @@ -103,6 +104,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* @@ -482,6 +488,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 => { // A transport failure is not the agent rejecting the config, but it // is still a reload that did not happen — and the file on disk has @@ -600,6 +612,9 @@ fn reply_disconnected(update_tx: &mpsc::UnboundedSender, cmd: Command Command::ReadSmartShift(_, reply) => { let _ = reply.send(Err(WriteError::AgentUnavailable)); } + Command::ReadOnboardProfiles(_, reply) => { + let _ = reply.send(Err(WriteError::AgentUnavailable)); + } Command::SetLight(_, command, key, request_id) => { let _ = update_tx.send(GuiUpdate::LightCommandResult { key, diff --git a/crates/openlogi-desktop/src/state.rs b/crates/openlogi-desktop/src/state.rs index 8e4b8ed6..31523981 100644 --- a/crates/openlogi-desktop/src/state.rs +++ b/crates/openlogi-desktop/src/state.rs @@ -27,7 +27,7 @@ pub use devices::DeviceRecord; pub use light::LightCommandStatus; #[cfg(test)] pub use load::Load; -pub use load::{DpiStatus, SmartShiftLoad}; +pub use load::{DpiStatus, ProfilesLoad, SmartShiftLoad}; /// Result of confirming a SmartShift write by reading the value back. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -63,6 +63,7 @@ mod inventory; mod light; mod lighting; mod load; +mod profiles; mod scroll; mod settings; mod smartshift; @@ -184,7 +185,8 @@ pub struct AppState { /// Sorted (`BTreeMap`) for stable render order in the function-row view. pub keyboard_bindings: BTreeMap, pub dpi: Dpi, - /// Lazily-loaded DPI and SmartShift read caches, keyed by [`DeviceKey`]. + /// Lazily-loaded DPI, SmartShift, and onboard-profile read caches, keyed + /// by [`DeviceKey`]. /// HID++ reads must not block device switching or rendering, so callers /// reach these directly (`state.reads.dpi.retry(&key)`, /// `state.reads.smartshift.status(&key)`, …) rather than through a diff --git a/crates/openlogi-desktop/src/state/device_ui.rs b/crates/openlogi-desktop/src/state/device_ui.rs index ee78b072..059115c6 100644 --- a/crates/openlogi-desktop/src/state/device_ui.rs +++ b/crates/openlogi-desktop/src/state/device_ui.rs @@ -6,12 +6,13 @@ use super::SmartShiftWriteStatus; use super::light::PendingLightCommand; /// Everything `AppState` tracks per device outside the persisted config and -/// the lazily-loaded DPI/SmartShift reads ([`super::load::LazyDeviceData`]). +/// the lazily-loaded device reads ([`super::load::LazyDeviceData`]). /// -/// Replaces six parallel `BTreeMap` fields that all shared the +/// Replaces the parallel `BTreeMap` fields that all shared the /// same device-key domain — manual camera-light override, volatile light /// settings, an in-flight light command, the inventory-miss counter, a -/// pending SmartShift write id, and the SmartShift write-confirmation status +/// pending SmartShift write id, SmartShift write-confirmation status, and an +/// onboard-profile confirmation marker /// — with one row per device. A device absent from the owning map is /// equivalent to every field here at its default. #[derive(Debug, Default)] @@ -30,4 +31,6 @@ pub(super) struct DeviceUiState { pub(super) smartshift_pending_confirm: Option, /// Visible outcome of the post-write SmartShift confirmation. pub(super) smartshift_write_status: Option, + /// Whether an optimistic onboard-profile write needs one confirming read. + pub(super) profiles_pending_confirm: bool, } diff --git a/crates/openlogi-desktop/src/state/devices.rs b/crates/openlogi-desktop/src/state/devices.rs index fbebce2c..d4d64087 100644 --- a/crates/openlogi-desktop/src/state/devices.rs +++ b/crates/openlogi-desktop/src/state/devices.rs @@ -756,6 +756,7 @@ mod tests { thumbwheel: false, haptic_feedback: false, haptic_panel: false, + onboard_profiles: false, }, light_capabilities: None, model_info: None, diff --git a/crates/openlogi-desktop/src/state/inventory.rs b/crates/openlogi-desktop/src/state/inventory.rs index 048f4d94..1f821eca 100644 --- a/crates/openlogi-desktop/src/state/inventory.rs +++ b/crates/openlogi-desktop/src/state/inventory.rs @@ -126,9 +126,11 @@ impl AppState { for key in &rerouted { self.reads.dpi.remove(key); self.reads.smartshift.remove(key); + self.reads.profiles.remove(key); if let Some(entry) = self.device_ui.get_mut(key) { entry.smartshift_pending_confirm = None; entry.smartshift_write_status = None; + entry.profiles_pending_confirm = false; } } let present = |key: &str| { @@ -138,6 +140,7 @@ impl AppState { }; self.reads.dpi.retain_present(present); self.reads.smartshift.retain_present(present); + self.reads.profiles.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 @@ -310,6 +313,9 @@ impl AppState { if matches!(self.reads.smartshift.get(&key), Some(Load::Failed(_))) { self.retry_smartshift(&key); } + if matches!(self.reads.profiles.get(&key), Some(Load::Failed(_))) { + self.retry_profiles(&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 diff --git a/crates/openlogi-desktop/src/state/load.rs b/crates/openlogi-desktop/src/state/load.rs index 1b723bbe..c63d769b 100644 --- a/crates/openlogi-desktop/src/state/load.rs +++ b/crates/openlogi-desktop/src/state/load.rs @@ -1,15 +1,15 @@ -//! Lazy per-device load state for background HID++ reads, shared by DPI -//! capability discovery and SmartShift reads. +//! Lazy per-device load state for background HID++ reads, shared by DPI, +//! SmartShift, and onboard-profile discovery. use std::collections::BTreeMap; -use openlogi_core::hid::{DpiInfo, SmartShiftStatus, WriteError}; +use openlogi_core::hid::{DpiInfo, OnboardProfilesInfo, SmartShiftStatus, WriteError}; use tracing::debug; use super::device_key::DeviceKey; -/// How many times to retry a device read (DPI capability discovery or a -/// SmartShift read) after a transient HID++ error (read timeout, busy device) +/// How many times to retry a device read after a transient HID++ error +/// (read timeout, busy device) /// before giving up. A genuine "feature not supported" reply is permanent and /// never retried. const LOAD_MAX_ATTEMPTS: u8 = 3; @@ -46,6 +46,9 @@ pub type DpiStatus = Load; /// GUI only ever reads and writes the device. pub type SmartShiftLoad = Load; +/// Per-device onboard-profile (`0x8100`) state load. See [`Load`]. +pub type ProfilesLoad = Load; + /// The lazily-loaded DPI and SmartShift read caches, grouped so callers reach /// them as `state.reads.dpi` / `state.reads.smartshift` and use /// [`LazyDeviceData`]'s own methods directly — instead of `AppState` growing @@ -55,6 +58,7 @@ pub type SmartShiftLoad = Load; pub(crate) struct DeviceReads { pub(crate) dpi: LazyDeviceData, pub(crate) smartshift: LazyDeviceData, + pub(crate) profiles: LazyDeviceData, } /// Per-device lazy-load cache for a background HID++ read, keyed by diff --git a/crates/openlogi-desktop/src/state/profiles.rs b/crates/openlogi-desktop/src/state/profiles.rs new file mode 100644 index 00000000..fbf894a8 --- /dev/null +++ b/crates/openlogi-desktop/src/state/profiles.rs @@ -0,0 +1,142 @@ +//! Onboard-profile lazy reads, optimistic writes, and reconnect persistence. + +use openlogi_core::config::OnboardProfiles; +use openlogi_core::hid::{DeviceRoute, OnboardProfilesInfo, ProfilesMode, WriteError}; +use tracing::debug; + +use super::AppState; +use super::device_key::DeviceKey; +use super::load::ProfilesLoad; + +impl AppState { + /// Onboard-profile status for the active device. + #[must_use] + pub fn current_profiles_status(&self) -> ProfilesLoad { + self.current_record() + .map_or(ProfilesLoad::Unknown, |record| { + self.reads.profiles.status(&record.device_key()) + }) + } + + /// Whether the active device has never had its onboard-profile state read. + #[must_use] + pub fn current_profiles_unqueried(&self) -> bool { + self.current_record() + .is_some_and(|record| self.reads.profiles.unqueried(&record.device_key())) + } + + /// Drop `key`'s failed state so the panel's next render retries the read. + pub fn retry_profiles(&mut self, key: &DeviceKey) { + self.reads.profiles.retry(key); + } + + /// Store a read only while its physical device and route still match. + pub fn store_profiles_info( + &mut self, + key: DeviceKey, + route: &DeviceRoute, + result: Result, + ) { + let matches_route = self + .device_list + .iter() + .any(|record| record.device_key() == key && record.route.as_ref() == Some(route)); + let still_present = self + .device_list + .iter() + .any(|record| record.device_key() == key); + self.reads.profiles.store( + key, + result, + profiles_error_is_permanent, + matches_route, + still_present, + "onboard profiles", + ); + } + + /// Persist and apply an onboard-profile mode for the active device, then + /// optimistically update its cached state until a confirming read lands. + pub fn commit_onboard_profiles(&mut self, mode: ProfilesMode, profile: Option) { + let Some(record) = self.current_record() else { + debug!("no active device — onboard-profile change ignored"); + return; + }; + let key = record.device_key(); + let persistent_key = record.persistent_config_key().map(str::to_string); + let route = record.route.clone(); + let profile = match mode { + ProfilesMode::Host => None, + ProfilesMode::Onboard => profile, + }; + + if let Some(persistent_key) = persistent_key { + let config = match mode { + ProfilesMode::Host => OnboardProfiles::Host {}, + ProfilesMode::Onboard => OnboardProfiles::Onboard { profile }, + }; + self.config + .set_onboard_profiles(&persistent_key, Some(config)); + if !self.persist_and_reload("onboard profiles") { + return; + } + } + if let Some(route) = route.clone() { + self.send_ipc(crate::services::ipc::Command::SetOnboardProfiles( + route, mode, profile, + )); + } + + if let Some(ProfilesLoad::Ready(info)) = self.reads.profiles.get(&key) { + let mut info = info.clone(); + info.mode = mode; + match (mode, profile) { + (ProfilesMode::Host, _) => info.active_profile = 0, + (ProfilesMode::Onboard, Some(sector)) => info.active_profile = sector, + (ProfilesMode::Onboard, None) => {} + } + self.reads.profiles.set_ready(key.clone(), info); + } + if route.is_some() { + self.device_ui + .entry(key) + .or_default() + .profiles_pending_confirm = true; + } + } + + /// Take the active device's one-shot post-write confirmation target. + pub fn take_active_profiles_confirm(&mut self) -> Option<(DeviceKey, DeviceRoute)> { + let record = self.current_record()?; + let key = record.device_key(); + let route = record.route.clone()?; + let pending = &mut self.device_ui.get_mut(&key)?.profiles_pending_confirm; + if !std::mem::take(pending) { + return None; + } + Some((key, route)) + } +} + +fn profiles_error_is_permanent(error: &WriteError) -> bool { + matches!(error, WriteError::FeatureUnsupported { .. }) +} + +#[cfg(test)] +mod tests { + use openlogi_core::hid::{HidppOperation, WriteError}; + + use super::profiles_error_is_permanent; + + #[test] + fn only_missing_feature_is_a_permanent_profiles_read_error() { + assert!(profiles_error_is_permanent( + &WriteError::FeatureUnsupported { + feature_hex: 0x8100 + } + )); + assert!(!profiles_error_is_permanent(&WriteError::RequestTimedOut { + operation: HidppOperation::ReadOnboardProfiles + })); + } +} diff --git a/crates/openlogi-desktop/src/ui/device_read.rs b/crates/openlogi-desktop/src/ui/device_read.rs index 7b2c2093..63cbeda1 100644 --- a/crates/openlogi-desktop/src/ui/device_read.rs +++ b/crates/openlogi-desktop/src/ui/device_read.rs @@ -1,6 +1,6 @@ -//! Lazy device reads shared by the DPI and SmartShift panels. +//! Lazy device reads shared by the DPI, SmartShift, and Profiles panels. //! -//! Both panels resolve their state by sending a one-shot read request to the +//! These panels resolve their state by sending a one-shot read request to the //! agent over IPC and awaiting the typed reply off the render thread, then //! storing the result — or clearing the loading marker if the reply never //! comes. Only the command, the store action, and the clear action differ; the diff --git a/crates/openlogi-hid/src/lib.rs b/crates/openlogi-hid/src/lib.rs index 910bbcb4..b2c24ae9 100644 --- a/crates/openlogi-hid/src/lib.rs +++ b/crates/openlogi-hid/src/lib.rs @@ -37,6 +37,7 @@ pub use hidpp::feature::device_information::DeviceEntityType; pub use inventory::hotplug::{HotplugEvent, watch_hotplug}; pub use inventory::standalone::enumerate_standalone; pub use inventory::{Enumerator, InventoryError, enumerate}; +pub use openlogi_core::hid::{OnboardProfilesInfo, ProfileEntry, ProfilesMode, is_rom_sector}; pub use pairing::{ Click, DiscoveredDevice, PairingCommand, PairingError, PairingEvent, PairingReceiver, PasskeyMethod, ReceiverFamily, ReceiverSelector, list_pairing_receivers, run_pairing, unpair, @@ -59,13 +60,15 @@ pub use write::{ HapticWaveform, HidppFeatureErrorKind, HidppOperation, LITRA_BEAM_PRODUCT_ID, LITRA_GLOW_PRODUCT_ID, LightCommand, LightingMethod, LitraModel, ReprogControlEntry, ScrollReportingTarget, ScrollResolution, ScrollWheelMode, WriteError, apply_litra, - clear_haptic_feature_cache, commands_for_light_settings, dump_features, dump_firmware_entities, - dump_reprog_controls, encode_litra_command, ensure_haptics_armed_on, get_backlight, get_dpi, - get_dpi_info, get_dpi_info_on, get_scroll_wheel_mode, get_scroll_wheel_mode_on, - get_smartshift_status, get_smartshift_status_on, matches_litra, play_haptic, play_haptic_on, - read_battery_raw, set_backlight_enabled, set_dpi, set_dpi_on, set_fn_lock, set_fn_lock_on, - set_keyboard_color, set_keyboard_color_on, set_keyboard_color_with, set_keyboard_color_with_on, - set_scroll_inversion, set_scroll_inversion_on, set_scroll_resolution, set_scroll_resolution_on, + apply_profiles_config, apply_profiles_config_on, clear_haptic_feature_cache, + commands_for_light_settings, dump_features, dump_firmware_entities, dump_reprog_controls, + encode_litra_command, ensure_haptics_armed_on, get_backlight, get_dpi, get_dpi_info, + get_dpi_info_on, get_onboard_profiles, get_onboard_profiles_on, get_scroll_wheel_mode, + get_scroll_wheel_mode_on, get_smartshift_status, get_smartshift_status_on, matches_litra, + play_haptic, play_haptic_on, read_battery_raw, set_active_profile, set_backlight_enabled, + set_dpi, set_dpi_on, set_fn_lock, set_fn_lock_on, set_keyboard_color, set_keyboard_color_on, + set_keyboard_color_with, set_keyboard_color_with_on, set_profiles_mode, set_scroll_inversion, + set_scroll_inversion_on, set_scroll_resolution, set_scroll_resolution_on, set_scroll_wheel_mode, set_scroll_wheel_mode_on, set_smartshift, set_smartshift_on, set_smartshift_sensitivity, toggle_smartshift, toggle_smartshift_on, }; diff --git a/crates/openlogi-hid/src/write.rs b/crates/openlogi-hid/src/write.rs index a45c660e..94f81ec2 100644 --- a/crates/openlogi-hid/src/write.rs +++ b/crates/openlogi-hid/src/write.rs @@ -23,6 +23,7 @@ mod haptic; mod hires_wheel; mod lighting; mod litra; +mod onboard_profiles; mod smartshift; pub use backlight::{get_backlight, set_backlight_enabled}; @@ -54,6 +55,10 @@ pub use litra::{ LITRA_BEAM_PRODUCT_ID, LITRA_GLOW_PRODUCT_ID, LightCommand, LitraModel, apply as apply_litra, encode_command as encode_litra_command, matches_litra, }; +pub use onboard_profiles::{ + apply_profiles_config, apply_profiles_config_on, get_onboard_profiles, get_onboard_profiles_on, + set_active_profile, set_profiles_mode, +}; pub use smartshift::{ get_smartshift_status, get_smartshift_status_on, set_smartshift, set_smartshift_on, set_smartshift_sensitivity, toggle_smartshift, toggle_smartshift_on, 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..6666bbe6 --- /dev/null +++ b/crates/openlogi-hid/src/write/onboard_profiles.rs @@ -0,0 +1,301 @@ +use std::sync::Arc; + +use hidpp::{ + channel::HidppChannel, + device::Device, + feature::{ + CreatableFeature, + onboard_profiles::{OnboardMode, OnboardProfilesFeature}, + }, +}; +use tracing::debug; + +use crate::{SharedChannel, channel::route::DeviceRoute}; + +use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route}; +use openlogi_core::hid::{OnboardProfilesInfo, ProfileEntry, ProfilesMode, is_rom_sector}; + +/// 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 { + get_onboard_profiles_on_channel(&channel, index).await + }) + .await +} + +/// Read onboard-profile state on an already-open [`SharedChannel`]. +pub async fn get_onboard_profiles_on( + shared: &SharedChannel, +) -> Result { + get_onboard_profiles_on_channel(shared.channel(), shared.device_index()).await +} + +/// Read onboard-profile state on an already-open HID++ channel. +pub(super) async fn get_onboard_profiles_on_channel( + channel: &Arc, + index: u8, +) -> Result { + 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) + .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, + }) +} + +/// 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 { + validate_user_profile(sector)?; + 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`]. +pub(super) async fn apply_profiles_config_on_channel( + channel: &Arc, + index: u8, + mode: ProfilesMode, + profile: Option, +) -> Result { + if mode == ProfilesMode::Onboard + && let Some(sector) = profile + { + validate_user_profile(sector)?; + } + + 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?; + // A mismatch—or a failed read-back while the device is transitioning + // modes—is logged rather than turning an accepted write into failure. + match read_mode(&feature).await { + Ok(actual) if actual != mode => tracing::warn!( + index, + requested = ?mode, + ?actual, + "onboard mode write accepted but device reports a different mode" + ), + Ok(_) => {} + Err(error) => debug!(index, %error, "onboard mode read-back skipped"), + } + written = true; + } + + if mode == ProfilesMode::Onboard + && let Some(sector) = profile + { + let read = |e| { + classify_hidpp_error( + e, + HidppOperation::ReadOnboardProfiles, + OnboardProfilesFeature::ID, + ) + }; + let active = feature.get_current_profile().await.map_err(read)?; + if active != sector { + feature.set_current_profile(sector).await.map_err(|e| { + classify_hidpp_error( + e, + HidppOperation::WriteOnboardProfiles, + OnboardProfilesFeature::ID, + ) + })?; + match feature.get_current_profile().await { + Ok(actual) if actual != sector => tracing::warn!( + index, + requested = sector, + actual, + "active-profile write accepted but device reports a different sector" + ), + Ok(_) => {} + Err(error) => debug!(index, %error, "active-profile read-back skipped"), + } + written = true; + } + } + + if written { + debug!(index, ?mode, ?profile, "applied onboard-profiles config"); + } + Ok(written) +} + +/// Apply persisted onboard-profile configuration on an already-open +/// [`SharedChannel`]. Returns whether a device write was needed. +pub async fn apply_profiles_config_on( + shared: &SharedChannel, + mode: ProfilesMode, + profile: Option, +) -> Result { + apply_profiles_config_on_channel(shared.channel(), shared.device_index(), mode, profile).await +} + +/// Reject ROM template sectors before asking firmware to select them. +pub(super) fn validate_user_profile(sector: u16) -> Result<(), WriteError> { + if is_rom_sector(sector) { + Err(WriteError::InvalidProfileSector { sector }) + } else { + Ok(()) + } +} + +/// 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/tests.rs b/crates/openlogi-hid/src/write/tests.rs index d73f9a58..748c5d0b 100644 --- a/crates/openlogi-hid/src/write/tests.rs +++ b/crates/openlogi-hid/src/write/tests.rs @@ -1,8 +1,9 @@ -use std::sync::Arc; +use std::{assert_matches, sync::Arc}; use super::*; use hidpp::channel::HidppChannel; use hidpp::feature::extended_dpi::{DpiRange, Lod}; +use hidpp::feature::onboard_profiles::OnboardMode; use hidpp::feature::per_key_lighting::FramePersistence; use hidpp::feature::smartshift::WheelMode; @@ -11,13 +12,17 @@ use crate::channel::scripted::{ScriptedRawHidChannel, feature_error}; use crate::write::diagnostics::dump_firmware_entities_on_channel; use crate::write::dpi::expand_dpi_ranges; use crate::write::lighting::{collect_present_zones, per_key_reports}; +use crate::write::onboard_profiles::{ + onboard_mode_to_profiles, profiles_to_onboard_mode, validate_user_profile, +}; use crate::write::smartshift::{ is_missing_enhanced, is_transient_smartshift_error, smartshift_to_wheel, status_matches_desired, wheel_mode_to_smartshift, }; use crate::write::{HidppFeatureErrorKind, HidppOperation}; use crate::{ - SmartShiftAutoDisengage, SmartShiftMode, SmartShiftStatus, SmartShiftThreshold, TunableTorque, + ProfilesMode, SmartShiftAutoDisengage, SmartShiftMode, SmartShiftStatus, SmartShiftThreshold, + TunableTorque, }; use hidpp::feature::device_information::DeviceEntityType; @@ -65,6 +70,37 @@ 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_mode_round_trips_through_firmware_mode() { + for mode in [ProfilesMode::Host, ProfilesMode::Onboard] { + assert_eq!( + onboard_mode_to_profiles(profiles_to_onboard_mode(mode)), + mode + ); + } +} + +#[test] +fn rom_profile_sectors_are_rejected_before_device_io() { + assert_matches!( + validate_user_profile(0x0101), + Err(WriteError::InvalidProfileSector { sector: 0x0101 }) + ); + assert_matches!(validate_user_profile(0x0002), Ok(())); +} + #[test] fn missing_enhanced_triggers_fallback() { assert!(is_missing_enhanced(&WriteError::FeatureUnsupported { diff --git a/crates/openlogi-hidpp/src/feature.rs b/crates/openlogi-hidpp/src/feature.rs index e3a59d4f..cbb012cc 100755 --- a/crates/openlogi-hidpp/src/feature.rs +++ b/crates/openlogi-hidpp/src/feature.rs @@ -36,6 +36,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.rs b/crates/openlogi-hidpp/src/feature/onboard_profiles.rs new file mode 100644 index 00000000..420ab7fb --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/onboard_profiles.rs @@ -0,0 +1,130 @@ +//! Implements the `OnboardProfiles` feature (ID `0x8100`) that controls a +//! gaming device's onboard profile memory. +//! +//! In onboard mode the device runs a profile stored in its own flash; in host +//! mode that profile lies dormant and the host drives the device—but the host +//! must then supply whatever the profile used to, such as the DPI stage list. +//! This implementation covers reading the memory description, getting and +//! setting the mode and 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. + +mod types; + +#[cfg(test)] +mod tests; + +use openlogi_hidpp_derive::Feature; + +pub use types::{ + DIRECTORY_ENTRY_LEN, DIRECTORY_SECTOR, OnboardMode, ProfileDirectoryEntry, ProfilesDescription, + ROM_SECTOR_FLAG, +}; + +use self::types::{DIRECTORY_END, be16, parse_directory}; +use crate::{feature::FeatureEndpoint, protocol::v20::Hidpp20Error}; + +/// Implements the `OnboardProfiles` / `0x8100` feature. +#[derive(Clone, Feature)] +#[creatable(id = 0x8100, version = 0)] +pub struct OnboardProfilesFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +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`]. + /// + /// 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 specification is not public). + pub async fn set_current_profile(&self, sector: u16) -> Result<(), Hidpp20Error> { + let [hi, lo] = sector.to_be_bytes(); + self.endpoint.call(3, [hi, lo, 0]).await?; + + Ok(()) + } + + /// Retrieves the sector of the active profile. + /// + /// Host mode reports `0x0000` because no onboard profile is active. + 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`]. + /// + /// Both user and out-of-box counts from [`Self::get_description`] bound + /// the number of entries; reading stops early at the directory terminator. + pub async fn read_profile_directory( + &self, + description: &ProfilesDescription, + ) -> Result, Hidpp20Error> { + let max_entries = description.total_profile_count(); + // Room for every entry plus the terminator entry. + let needed = (max_entries + 1) * DIRECTORY_ENTRY_LEN; + + let mut bytes = 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 + .as_chunks::() + .0 + .iter() + .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..e7c2cfa4 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/onboard_profiles/tests.rs @@ -0,0 +1,134 @@ +use std::assert_matches; + +use super::types::{OnboardMode, ProfileDirectoryEntry, ProfilesDescription, parse_directory}; +use crate::protocol::v20::Hidpp20Error; + +#[test] +fn parses_captured_g502_x_description_payload() { + // As read from a G502 X LIGHTSPEED: 5 user profiles, 2 ROM, 11 buttons, + // 16 sectors of 255 bytes (0x00ff big-endian at offset 7). The odd sector + // size is the point — it feeds the `sector_size - 16` read bound, so a + // rounded-up 256 here would hide an off-by-one. + let payload = [1, 3, 1, 5, 2, 11, 16, 0x00, 0xff, 0x04, 0x07, 0, 0, 0, 0, 0]; + + let descr = ProfilesDescription::from_payload(&payload); + + assert_eq!(descr.memory_model_id, 1); + assert_eq!(descr.profile_format_id, 3); + assert_eq!(descr.macro_format_id, 1); + assert_eq!(descr.profile_count, 5); + assert_eq!(descr.profile_count_oob, 2); + assert_eq!(descr.button_count, 11); + assert_eq!(descr.sector_count, 16); + assert_eq!(descr.sector_size, 255); + assert_eq!(descr.mechanical_layout, 0x04); + assert_eq!(descr.various_info, 0x07); + assert_eq!(descr.total_profile_count(), 7); +} + +#[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_matches!(OnboardMode::try_from(0), Err(_)); + assert_matches!(OnboardMode::try_from(3), 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..eb670087 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/onboard_profiles/types.rs @@ -0,0 +1,154 @@ +//! 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], + } + } + + /// Total number of user and out-of-box profiles that may appear in the + /// shared directory. + pub(super) fn total_profile_count(&self) -> usize { + usize::from(self.profile_count) + usize::from(self.profile_count_oob) + } +} + +/// One entry of the profile directory in sector [`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 e43c5a68..400fdfab 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -39,6 +39,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, @@ -257,7 +258,7 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x8080 "PerKeyLighting", 0x8081 "PerKeyLighting2" => PerKeyLightingFeature, 0x8090 "ModeStatus" => ModeStatusFeature, - 0x8100 "OnboardProfiles", + 0x8100 "OnboardProfiles" => OnboardProfilesFeature, 0x8110 "MouseButtonFilter", 0x8111 "LatencyMonitoring", 0x8120 "GamingAttachments", diff --git a/crates/openlogi-ipc/src/ipc.rs b/crates/openlogi-ipc/src/ipc.rs index 391daf12..4386d5cd 100644 --- a/crates/openlogi-ipc/src/ipc.rs +++ b/crates/openlogi-ipc/src/ipc.rs @@ -16,8 +16,8 @@ use openlogi_core::binding::{ActionRingIcon, ActionRingSlot}; use openlogi_core::config::Lighting; use openlogi_core::device::{DeviceInventory, StandaloneDevice}; use openlogi_core::hid::{ - DeviceRoute, Dpi, DpiInfo, LightCommand, PairingError, PasskeyMethod, ReceiverSelector, - SmartShiftStatus, WriteError, + DeviceRoute, Dpi, DpiInfo, LightCommand, OnboardProfilesInfo, PairingError, PasskeyMethod, + ProfilesMode, ReceiverSelector, SmartShiftStatus, WriteError, }; use serde::{Deserialize, Serialize}; pub use succession::Identity; @@ -52,7 +52,9 @@ pub use succession::Identity; /// [`RingObservation`]). /// v22: DPI scalar values use the validated [`Dpi`] type end to end. /// v23: SmartShift writes carry one typed [`SmartShiftStatus`] value. -pub const PROTOCOL_VERSION: u32 = 23; +/// v24: onboard-profile DTOs and capability/error/operation variants added; +/// `set_onboard_profiles` and `read_onboard_profiles` appended. +pub const PROTOCOL_VERSION: u32 = 24; /// Environment variable through which the agent hands a supervised helper the /// run token it will serve, so the helper knows which agent it belongs to @@ -480,4 +482,14 @@ pub trait Agent { /// then return it. Same contract as [`Agent::observe`] — whole state, hold /// window, `0` for "seen nothing" — over the ring's own cell. async fn observe_action_ring(since: Generation) -> RingObservation; + /// Apply an onboard-profile mode to `route` now, optionally selecting a + /// user-profile sector when entering onboard mode. + async fn set_onboard_profiles( + route: DeviceRoute, + mode: ProfilesMode, + profile: Option, + ) -> Result<(), WriteError>; + /// Read the onboard-profile memory description, current state, and + /// complete user + factory directory from `route`. + async fn read_onboard_profiles(route: DeviceRoute) -> Result; } diff --git a/crates/openlogi-ipc/tests/wire_format.rs b/crates/openlogi-ipc/tests/wire_format.rs index 5c293843..1c218c09 100644 --- a/crates/openlogi-ipc/tests/wire_format.rs +++ b/crates/openlogi-ipc/tests/wire_format.rs @@ -39,8 +39,9 @@ use openlogi_core::device::{ }; use openlogi_core::hid::{ Click, DeviceRoute, Dpi, DpiCapabilities, DpiInfo, HidppFeatureErrorKind, HidppOperation, - LightCommand, PasskeyMethod, ReceiverSelector, SmartShiftAutoDisengage, SmartShiftMode, - SmartShiftStatus, SmartShiftThreshold, TunableTorque, WriteError, + LightCommand, OnboardProfilesInfo, PasskeyMethod, ProfileEntry, ProfilesMode, ReceiverSelector, + SmartShiftAutoDisengage, SmartShiftMode, SmartShiftStatus, SmartShiftThreshold, TunableTorque, + WriteError, }; use openlogi_ipc::{ ActionRingCommandError, ActionRingInvocation, ActionRingPresentation, AgentRequest, @@ -85,7 +86,7 @@ fn representative_smartshift_status() -> SmartShiftStatus { /// that makes that visible in the same diff. #[test] fn protocol_version_is_pinned() { - assert_eq!(PROTOCOL_VERSION, 23); + assert_eq!(PROTOCOL_VERSION, 24); } #[test] @@ -172,6 +173,26 @@ fn request_variant_order() { assert_wire(&AgentRequest::Identity {}, "16"); assert_wire(&AgentRequest::Observe { since: 7 }, "1707"); assert_wire(&AgentRequest::ObserveActionRing { since: 7 }, "1807"); + assert_wire( + &AgentRequest::SetOnboardProfiles { + route: DeviceRoute::Bolt { + receiver_uid: "F00DCAFE".into(), + slot: 1, + }, + mode: ProfilesMode::Onboard, + profile: Some(2), + }, + "190008463030444341464501010102", + ); + assert_wire( + &AgentRequest::ReadOnboardProfiles { + route: DeviceRoute::Bolt { + receiver_uid: "F00DCAFE".into(), + slot: 1, + }, + }, + "1a0008463030444341464501", + ); } /// The agent identity is frozen: a helper from any build has to be able to @@ -223,6 +244,41 @@ fn action_ring_types() { assert_wire(&ActionRingCommandError::SessionNotFound, "00"); assert_wire(&ActionRingCommandError::SlotEmpty, "01"); assert_wire(&HidppOperation::PlayHaptic, "0e"); + assert_wire(&HidppOperation::ReadOnboardProfiles, "0f"); + assert_wire(&HidppOperation::WriteOnboardProfiles, "10"); +} + +#[test] +fn onboard_profile_types() { + let profiles = OnboardProfilesInfo { + profile_count: 2, + profile_count_oob: 1, + button_count: 11, + sector_count: 4, + sector_size: 254, + memory_model_id: 1, + profile_format_id: 1, + macro_format_id: 1, + mode: ProfilesMode::Onboard, + active_profile: 2, + directory: vec![ + ProfileEntry { + sector: 1, + enabled: true, + }, + ProfileEntry { + sector: 2, + enabled: false, + }, + ProfileEntry { + sector: 0x0101, + enabled: true, + }, + ], + }; + assert_wire(&ProfilesMode::Host, "00"); + assert_wire(&ProfilesMode::Onboard, "01"); + assert_wire(&profiles, "02010b04fbfe0001010101020301010200fb010101"); } #[test] @@ -353,12 +409,13 @@ fn device_inventory() { thumbwheel: true, haptic_feedback: true, haptic_panel: true, + onboard_profiles: true, }), }], }]; assert_wire( &inventory, - "010d426f6c74205265636569766572fb6d04fb48c501084630304443414645010101094d58204d535452335301fb34b000010150020001030106323134304c5a0102030400010100fb34b0fb8240000b010101000001010101", + "010d426f6c74205265636569766572fb6d04fb48c501084630304443414645010101094d58204d535452335301fb34b000010150020001030106323134304c5a0102030400010100fb34b0fb8240000b01010100000101010101", ); } @@ -446,6 +503,10 @@ fn device_settings_payloads() { "0904626f6f6d", ); assert_wire(&WriteError::AgentUnavailable, "0a"); + assert_wire( + &WriteError::InvalidProfileSector { sector: 0x0101 }, + "0efb0101", + ); // serde encodes SmartShiftMode's variant *index* (Free=0, Ratchet=1), not // the `#[repr(u8)]` firmware discriminants (1/2) — pinned here because it diff --git a/crates/openlogi-permissions/src/lib.rs b/crates/openlogi-permissions/src/lib.rs index 311fc4e2..161ee266 100644 --- a/crates/openlogi-permissions/src/lib.rs +++ b/crates/openlogi-permissions/src/lib.rs @@ -14,7 +14,7 @@ //! Two permissions matter: **Accessibility** (the hook's event tap) and **Input //! Monitoring** (opening HID devices via `IOHIDManager`). **Bluetooth** is //! surfaced for completeness — OpenLogi reaches BLE mice through `IOHIDManager`, -//! so it usually reads [`PermissionStatus::Unknown`]. +//! so it usually reads `PermissionStatus::Unknown`. //! //! Accessibility status is not read here: the agent owns the tap, so //! `openlogi_hook::has_accessibility` is the source of truth. diff --git a/crates/openlogi-ui/locales/da.yml b/crates/openlogi-ui/locales/da.yml index c5b5bcf6..ebac229a 100644 --- a/crates/openlogi-ui/locales/da.yml +++ b/crates/openlogi-ui/locales/da.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Genvej, f.eks. Cmd+Shift+P" "Application, folder path, or URL": "Program, mappesti eller URL" "Open application or folder": "Åbn program eller mappe" +"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}" +"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-ui/locales/de.yml b/crates/openlogi-ui/locales/de.yml index 45cf6478..2bae2f08 100644 --- a/crates/openlogi-ui/locales/de.yml +++ b/crates/openlogi-ui/locales/de.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Tastenkürzel, z. B. Cmd+Shift+P" "Application, folder path, or URL": "Anwendung, Ordnerpfad oder URL" "Open application or folder": "Anwendung oder Ordner öffnen" +"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}" +"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-ui/locales/el.yml b/crates/openlogi-ui/locales/el.yml index 4bfb5cfe..b70354f5 100644 --- a/crates/openlogi-ui/locales/el.yml +++ b/crates/openlogi-ui/locales/el.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Συντόμευση, π.χ. Cmd+Shift+P" "Application, folder path, or URL": "Εφαρμογή, διαδρομή φακέλου ή URL" "Open application or folder": "Άνοιγμα εφαρμογής ή φακέλου" +"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}" +"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-ui/locales/en.yml b/crates/openlogi-ui/locales/en.yml index 48783cc9..fe9e8e18 100644 --- a/crates/openlogi-ui/locales/en.yml +++ b/crates/openlogi-ui/locales/en.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Shortcut, e.g. Cmd+Shift+P" "Application, folder path, or URL": "Application, folder path, or URL" "Open application or folder": "Open application or folder" +"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}" +"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-ui/locales/es.yml b/crates/openlogi-ui/locales/es.yml index afe0ef69..a741a21f 100644 --- a/crates/openlogi-ui/locales/es.yml +++ b/crates/openlogi-ui/locales/es.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Atajo, p. ej. Cmd+Shift+P" "Application, folder path, or URL": "Aplicación, ruta de carpeta o URL" "Open application or folder": "Abrir aplicación o carpeta" +"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}" +"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-ui/locales/fi.yml b/crates/openlogi-ui/locales/fi.yml index 28e0261f..c9f008f8 100644 --- a/crates/openlogi-ui/locales/fi.yml +++ b/crates/openlogi-ui/locales/fi.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Pikanäppäin, esim. Cmd+Shift+P" "Application, folder path, or URL": "Sovellus, kansiopolku tai URL" "Open application or folder": "Avaa sovellus tai kansio" +"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}" +"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-ui/locales/fr.yml b/crates/openlogi-ui/locales/fr.yml index 011635e0..95a59ccb 100644 --- a/crates/openlogi-ui/locales/fr.yml +++ b/crates/openlogi-ui/locales/fr.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Raccourci, p. ex. Cmd+Shift+P" "Application, folder path, or URL": "Application, chemin de dossier ou URL" "Open application or folder": "Ouvrir une application ou un dossier" +"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}" +"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-ui/locales/it.yml b/crates/openlogi-ui/locales/it.yml index 445c56f8..03de8947 100644 --- a/crates/openlogi-ui/locales/it.yml +++ b/crates/openlogi-ui/locales/it.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Scorciatoia, ad es. Cmd+Shift+P" "Application, folder path, or URL": "Applicazione, percorso cartella o URL" "Open application or folder": "Apri applicazione o cartella" +"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}" +"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-ui/locales/ja.yml b/crates/openlogi-ui/locales/ja.yml index ba9d1613..cc79d00e 100644 --- a/crates/openlogi-ui/locales/ja.yml +++ b/crates/openlogi-ui/locales/ja.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "ショートカット(例: Cmd+Shift+P)" "Application, folder path, or URL": "アプリ、フォルダのパス、またはURL" "Open application or folder": "アプリまたはフォルダを開く" +"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}" +"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-ui/locales/ko.yml b/crates/openlogi-ui/locales/ko.yml index 84a51407..97e4c6e8 100644 --- a/crates/openlogi-ui/locales/ko.yml +++ b/crates/openlogi-ui/locales/ko.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "단축키(예: Cmd+Shift+P)" "Application, folder path, or URL": "앱, 폴더 경로 또는 URL" "Open application or folder": "앱 또는 폴더 열기" +"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}" +"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-ui/locales/nb.yml b/crates/openlogi-ui/locales/nb.yml index a6bbc557..9afca94b 100644 --- a/crates/openlogi-ui/locales/nb.yml +++ b/crates/openlogi-ui/locales/nb.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Snarvei, f.eks. Cmd+Shift+P" "Application, folder path, or URL": "Program, mappebane eller URL" "Open application or folder": "Åpne program eller mappe" +"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}" +"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-ui/locales/nl.yml b/crates/openlogi-ui/locales/nl.yml index 6ba1b6a9..78378019 100644 --- a/crates/openlogi-ui/locales/nl.yml +++ b/crates/openlogi-ui/locales/nl.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Sneltoets, bijv. Cmd+Shift+P" "Application, folder path, or URL": "Toepassing, mappad of URL" "Open application or folder": "Toepassing of map openen" +"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}" +"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-ui/locales/pl.yml b/crates/openlogi-ui/locales/pl.yml index 12c5d482..2d30758d 100644 --- a/crates/openlogi-ui/locales/pl.yml +++ b/crates/openlogi-ui/locales/pl.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Skrót, np. Cmd+Shift+P" "Application, folder path, or URL": "Aplikacja, ścieżka folderu lub URL" "Open application or folder": "Otwórz aplikację lub folder" +"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}" +"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-ui/locales/pt-BR.yml b/crates/openlogi-ui/locales/pt-BR.yml index 92af3c9e..118ca56c 100644 --- a/crates/openlogi-ui/locales/pt-BR.yml +++ b/crates/openlogi-ui/locales/pt-BR.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Atalho, por exemplo, Cmd+Shift+P" "Application, folder path, or URL": "Aplicativo, caminho de pasta ou URL" "Open application or folder": "Abrir aplicativo ou pasta" +"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}" +"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-ui/locales/pt-PT.yml b/crates/openlogi-ui/locales/pt-PT.yml index e9ce980e..47e9066f 100644 --- a/crates/openlogi-ui/locales/pt-PT.yml +++ b/crates/openlogi-ui/locales/pt-PT.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Atalho, por exemplo, Cmd+Shift+P" "Application, folder path, or URL": "Aplicação, caminho de pasta ou URL" "Open application or folder": "Abrir aplicação ou pasta" +"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}" +"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-ui/locales/ru.yml b/crates/openlogi-ui/locales/ru.yml index 3b38533e..c5231bcb 100644 --- a/crates/openlogi-ui/locales/ru.yml +++ b/crates/openlogi-ui/locales/ru.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Сочетание, например Cmd+Shift+P" "Application, folder path, or URL": "Приложение, путь к папке или URL" "Open application or folder": "Открыть приложение или папку" +"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}" +"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-ui/locales/sv.yml b/crates/openlogi-ui/locales/sv.yml index c55c1616..be814109 100644 --- a/crates/openlogi-ui/locales/sv.yml +++ b/crates/openlogi-ui/locales/sv.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Kortkommando, t.ex. Cmd+Shift+P" "Application, folder path, or URL": "Program, mappsökväg eller URL" "Open application or folder": "Öppna program eller mapp" +"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}" +"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-ui/locales/uk.yml b/crates/openlogi-ui/locales/uk.yml index e32c0478..175f31dd 100644 --- a/crates/openlogi-ui/locales/uk.yml +++ b/crates/openlogi-ui/locales/uk.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "Сполучення клавіш, напр. Cmd+Shift+P" "Application, folder path, or URL": "Програма, шлях до папки або URL" "Open application or folder": "Відкрити програму або папку" +"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}" +"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-ui/locales/zh-CN.yml b/crates/openlogi-ui/locales/zh-CN.yml index 61b4c145..2bd07f74 100644 --- a/crates/openlogi-ui/locales/zh-CN.yml +++ b/crates/openlogi-ui/locales/zh-CN.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "快捷键,例如 Cmd+Shift+P" "Application, folder path, or URL": "应用、文件夹路径或 URL" "Open application or folder": "打开应用或文件夹" +"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}" +"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-ui/locales/zh-HK.yml b/crates/openlogi-ui/locales/zh-HK.yml index d6594aaf..4e82a5c2 100644 --- a/crates/openlogi-ui/locales/zh-HK.yml +++ b/crates/openlogi-ui/locales/zh-HK.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "快速鍵,例如 Cmd+Shift+P" "Application, folder path, or URL": "應用程式、資料夾路徑或 URL" "Open application or folder": "開啟應用程式或資料夾" +"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}" +"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-ui/locales/zh-TW.yml b/crates/openlogi-ui/locales/zh-TW.yml index 382c3ff2..8ae48219 100644 --- a/crates/openlogi-ui/locales/zh-TW.yml +++ b/crates/openlogi-ui/locales/zh-TW.yml @@ -397,3 +397,16 @@ _version: 1 "Shortcut, e.g. Cmd+Shift+P": "快速鍵,例如 Cmd+Shift+P" "Application, folder path, or URL": "應用程式、資料夾路徑或 URL" "Open application or folder": "開啟應用程式或資料夾" +"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}" +"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/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2e4bcf42..3bbba01f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -58,9 +58,30 @@ Common device fields are: `exe:.exe` - `action_ring`: default and complete per-application eight-slot layouts - `lighting`, `smartshift`, standalone `light`, and camera controls / profiles +- `onboard_profiles` for gaming mice with HID++ onboard profile memory - `host_switch_targets` and `fn_lock` for compatible keyboards - `identity` and `disabled_gestures`, which are application-managed metadata +### Onboard profiles + +Gaming mice with HID++ `0x8100` onboard profile memory can run either from +OpenLogi's host settings or from a profile stored in the device's flash. The +setting uses the device's physical config key: + +```toml +[devices."receiver:aabbccdd:slot:1".onboard_profiles] +mode = "onboard" +profile = 2 +``` + +Set `mode = "host"` to let OpenLogi drive the mouse. In onboard mode, `profile` +is the flash sector to activate; omit it to keep the currently active profile. +The GUI only offers enabled user sectors and never selects factory ROM sectors. + +Omit the entire `onboard_profiles` table to leave the device's current mode +unmanaged. Once configured, OpenLogi re-applies the selected mode after every +reconnect or wake because host mode is volatile. + `[keyboard.bindings]` contains global key triggers such as `f1` or `shift+command+f5`. Supported trigger modifiers are `shift`, `control`, `option`, and `command`; aliases such as `ctrl`, `alt`, and `cmd` are accepted. diff --git a/docs/config.example.toml b/docs/config.example.toml index 0ba8784f..7ce58e94 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -57,6 +57,11 @@ mode = "ratchet" auto_disengage = 16 tunable_torque = 50 +# Omit this table to leave the device's current mode unmanaged. +[devices."receiver:aabbccdd:slot:1".onboard_profiles] +mode = "onboard" +profile = 2 + # Put host-switch links on the keyboard's physical entry. [devices."receiver:aabbccdd:slot:2"] host_switch_targets = ["receiver:aabbccdd:slot:1"] diff --git a/xtask/src/commands/ci/jobs/tests.rs b/xtask/src/commands/ci/jobs/tests.rs index d007f9e6..29eda48d 100644 --- a/xtask/src/commands/ci/jobs/tests.rs +++ b/xtask/src/commands/ci/jobs/tests.rs @@ -28,12 +28,21 @@ fn workflow() -> Option { /// again. fn workflow_commands(workflow: &str) -> String { workflow + .replace("\\\r\n", " ") .replace("\\\n", " ") .split_whitespace() .collect::>() .join(" ") } +#[test] +fn workflow_commands_joins_windows_continuations() { + assert_eq!( + workflow_commands("cargo doc \\\r\n --workspace"), + "cargo doc --workspace" + ); +} + /// `ci.yml` is the pipeline's source of truth and this runner is a copy of it. /// A copy nothing checks is a copy that drifts. ///