Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions crates/openlogi-agent-core/src/hardware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<u16>,
}
/// Re-apply every volatile mouse setting for `op`'s device on a **single**
/// background thread, sequentially, on the current inventory-owned channel.
///
Expand All @@ -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<OnboardProfilesApply>,
resolution: Option<ScrollResolution>,
inverted: Option<bool>,
dpi: Option<Dpi>,
Expand All @@ -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
Expand Down
41 changes: 34 additions & 7 deletions crates/openlogi-agent-core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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<u16>)> {
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(
Expand Down
35 changes: 31 additions & 4 deletions crates/openlogi-agent-core/src/orchestrator/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
89 changes: 85 additions & 4 deletions crates/openlogi-agent/src/bin/mock_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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::{
Expand Down Expand Up @@ -221,6 +222,7 @@ struct DpiState {
struct DeviceSettings {
dpi: Option<DpiState>,
smartshift: Option<SmartShiftStatus>,
onboard_profiles: Option<OnboardProfilesInfo>,
lighting: bool,
}

Expand All @@ -229,6 +231,7 @@ impl DeviceSettings {
Self {
dpi: None,
smartshift: None,
onboard_profiles: None,
lighting: false,
}
}
Expand Down Expand Up @@ -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,
},
);
Expand All @@ -294,6 +323,7 @@ impl State {
DeviceSettings {
dpi: None,
smartshift: None,
onboard_profiles: None,
lighting: true,
},
);
Expand All @@ -305,6 +335,7 @@ impl State {
capabilities: DpiCapabilities::new((400u16..=4000).step_by(100).collect())?,
}),
smartshift: None,
onboard_profiles: None,
lighting: false,
},
);
Expand Down Expand Up @@ -513,6 +544,7 @@ fn bolt_inventory(mouse_battery: BatteryInfo) -> DeviceInventory {
thumbwheel: true,
haptic_feedback: true,
haptic_panel: true,
onboard_profiles: true,
}),
},
PairedDevice {
Expand Down Expand Up @@ -560,6 +592,7 @@ fn bolt_inventory(mouse_battery: BatteryInfo) -> DeviceInventory {
thumbwheel: false,
haptic_feedback: false,
haptic_panel: false,
onboard_profiles: false,
}),
},
],
Expand Down Expand Up @@ -609,6 +642,7 @@ fn direct_inventory() -> DeviceInventory {
thumbwheel: false,
haptic_feedback: false,
haptic_panel: false,
onboard_profiles: false,
}),
}],
}
Expand Down Expand Up @@ -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<u16>,
) -> 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<OnboardProfilesInfo, WriteError> {
self.state
.lock()
.await
.settings_for(&route)?
.onboard_profiles
.clone()
.ok_or(WriteError::FeatureUnsupported {
feature_hex: 0x8100,
})
}
}
Loading