use aethermap_common::{tracing, MacroEntry, Profile};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::fs;
use tracing::{debug, info, warn};
use crate::remap_engine::RemapProfile;
use crate::analog_calibration::AnalogCalibration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemapEntry {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HotkeyBinding {
#[serde(default)]
pub modifiers: Vec<String>,
pub key: String,
pub profile_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer_id: Option<usize>,
}
impl HotkeyBinding {
pub fn new(
modifiers: Vec<String>,
key: String,
profile_name: String,
) -> Self {
Self {
modifiers,
key,
profile_name,
device_id: None,
layer_id: None,
}
}
pub fn with_device(
modifiers: Vec<String>,
key: String,
profile_name: String,
device_id: String,
) -> Self {
Self {
modifiers,
key,
profile_name,
device_id: Some(device_id),
layer_id: None,
}
}
pub fn with_layer(
modifiers: Vec<String>,
key: String,
profile_name: String,
layer_id: usize,
) -> Self {
Self {
modifiers,
key,
profile_name,
device_id: None,
layer_id: Some(layer_id),
}
}
pub fn normalize_modifiers(&self) -> Vec<String> {
self.modifiers.iter()
.map(|m| m.to_lowercase())
.collect()
}
}
pub fn default_hotkey_bindings() -> Vec<HotkeyBinding> {
let modifiers = vec!["ctrl".to_string(), "alt".to_string(), "shift".to_string()];
let profile_names = vec![
"profile1", "profile2", "profile3", "profile4", "profile5",
"profile6", "profile7", "profile8", "profile9",
];
profile_names.iter()
.enumerate()
.map(|(i, name)| {
HotkeyBinding::new(
modifiers.clone(),
(i + 1).to_string(),
name.to_string(),
)
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutoSwitchRule {
pub app_id: String,
pub profile_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer_id: Option<usize>,
}
pub const EXAMPLE_CONFIG_WITH_AUTO_SWITCH: &str = r#"
# Example Aethermap device configuration
# with auto-switch rules and global hotkeys
devices:
# Razer Tartarus v2 (1532:0045)
"1532:0045":
name: "Razer Tartarus v2"
profiles:
gaming:
remaps:
- from: "KEY_1"
to: "KEY_F1"
# Auto-profile switching rules
# Rules are evaluated in order; first match wins
auto_switch_rules:
# Terminal emulator - activate terminal profile with layer 1
- app_id: "org.alacritty"
profile_name: "terminal"
layer_id: 1
# Steam games - activate gaming profile with layer 2
- app_id: "steam"
profile_name: "gaming"
layer_id: 2
# Web browser - activate browser profile
- app_id: "firefox"
profile_name: "browser"
# Default/fallback - matches any app
- app_id: "*"
profile_name: "default"
layer_id: 0
# Global hotkey bindings for manual profile switching
# Ctrl+Alt+Shift+Number switches to profile
hotkey_bindings:
# Ctrl+Alt+Shift+1 -> Gaming profile with layer 2
- modifiers: ["ctrl", "alt", "shift"]
key: "1"
profile_name: "gaming"
layer_id: 2
# Ctrl+Alt+Shift+2 -> Terminal profile with layer 1
- modifiers: ["ctrl", "alt", "shift"]
key: "2"
profile_name: "terminal"
layer_id: 1
# Ctrl+Alt+Shift+3 -> Browser profile
- modifiers: ["ctrl", "alt", "shift"]
key: "3"
profile_name: "browser"
"#;
pub const EXAMPLE_CONFIG_WITH_ANALOG_MODES: &str = r#"
# Example Aethermap device configuration
# demonstrating per-layer analog mode configuration
devices:
# Razer Tartarus v2 (1532:0045)
"1532:0045":
name: "Razer Tartarus v2"
profiles:
gaming:
remaps:
- from: "KEY_1"
to: "KEY_F1"
# Per-layer analog stick configuration
# Each layer can have its own analog mode
layers:
# Layer 0 (Base): D-pad mode for menu navigation
- layer_id: 0
name: "Base"
mode: "Hold"
analog_mode: "Dpad" # 8-way directional keys
led_color: [255, 255, 255]
led_zone: "Logo"
# Layer 1: Gamepad mode for gaming
- layer_id: 1
name: "Gaming"
mode: "Toggle"
analog_mode: "Gamepad" # Xbox 360 compatible gamepad
led_color: [0, 0, 255]
led_zone: "Logo"
# Optional: per-layer analog calibration
analog_calibration:
deadzone: 0.15
sensitivity_multiplier: 1.5
# Layer 2: Disabled (no analog output)
- layer_id: 2
name: "Keyboard Only"
mode: "Toggle"
analog_mode: "Disabled" # No analog output
led_color: [0, 255, 0]
led_zone: "Logo"
# Supported analog_mode values:
# - "Disabled": No output from analog stick
# - "Dpad": 8-way directional keys (hatswitch emulation)
# - "Gamepad": Xbox 360 compatible gamepad axes
# - "Camera": Scroll or key repeat (Phase 15)
# - "Mouse": Velocity-based cursor (Phase 15)
# - "Wasd": Directional keys (Phase 15)
"#;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub has_analog_stick: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_hat_switch: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub joystick_button_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub led_zones: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub analog_deadzone_percentage: Option<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalogDeviceConfig {
#[serde(default = "default_deadzone")]
pub deadzone_percentage: u8,
#[serde(default = "default_deadzone")]
pub deadzone_percentage_x: u8,
#[serde(default = "default_deadzone")]
pub deadzone_percentage_y: u8,
#[serde(default = "default_outer_deadzone")]
pub outer_deadzone_percentage: u8,
#[serde(default = "default_outer_deadzone")]
pub outer_deadzone_percentage_x: u8,
#[serde(default = "default_outer_deadzone")]
pub outer_deadzone_percentage_y: u8,
#[serde(default = "default_sensitivity")]
pub sensitivity: f32,
#[serde(default = "default_response_curve")]
pub response_curve: String,
#[serde(default = "default_dpad_mode")]
pub dpad_mode: String,
}
fn default_deadzone() -> u8 {
43 }
fn default_sensitivity() -> f32 {
1.0
}
fn default_response_curve() -> String {
"linear".to_string()
}
fn default_outer_deadzone() -> u8 {
100 }
fn default_dpad_mode() -> String {
"disabled".to_string()
}
impl Default for AnalogDeviceConfig {
fn default() -> Self {
Self {
deadzone_percentage: default_deadzone(),
deadzone_percentage_x: default_deadzone(),
deadzone_percentage_y: default_deadzone(),
outer_deadzone_percentage: default_outer_deadzone(),
outer_deadzone_percentage_x: default_outer_deadzone(),
outer_deadzone_percentage_y: default_outer_deadzone(),
sensitivity: default_sensitivity(),
response_curve: default_response_curve(),
dpad_mode: default_dpad_mode(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceRemapConfig {
pub device_id: String,
pub profiles: HashMap<String, DeviceProfile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capabilities: Option<DeviceCapabilities>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceProfile {
#[serde(skip)]
pub name: String,
pub remaps: Vec<RemapEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceProfilesConfig {
pub devices: HashMap<String, DeviceRemapConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtendedDeviceRemapConfig {
#[serde(default)]
pub match_pattern: Option<String>,
#[serde(default)]
pub profiles: HashMap<String, ProfileRemaps>,
#[serde(default)]
pub capabilities: Option<DeviceCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub analog_config: Option<AnalogDeviceConfig>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
#[serde(default)]
pub analog_calibration: HashMap<usize, AnalogCalibration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub led_config: Option<LedConfig>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
pub hotkey_bindings: Vec<HotkeyBinding>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LedConfig {
#[serde(default)]
pub zone_brightness: HashMap<String, u8>,
#[serde(default = "default_global_brightness")]
pub global_brightness: u8,
#[serde(default)]
pub zone_colors: HashMap<String, (u8, u8, u8)>,
#[serde(default = "default_active_pattern")]
pub active_pattern: String,
#[serde(default = "default_pattern_speed")]
pub pattern_speed: u8,
}
fn default_global_brightness() -> u8 {
100 }
fn default_active_pattern() -> String {
"static".to_string()
}
fn default_pattern_speed() -> u8 {
5 }
impl Default for LedConfig {
fn default() -> Self {
Self {
zone_brightness: HashMap::new(),
global_brightness: 100,
zone_colors: HashMap::new(),
active_pattern: default_active_pattern(),
pattern_speed: default_pattern_speed(),
}
}
}
pub fn pattern_to_string(pattern: &crate::led_controller::LedPattern) -> &'static str {
match pattern {
crate::led_controller::LedPattern::Static => "static",
crate::led_controller::LedPattern::Breathing => "breathing",
crate::led_controller::LedPattern::Rainbow => "rainbow",
crate::led_controller::LedPattern::RainbowWave => "rainbow_wave",
}
}
pub fn string_to_pattern(s: &str) -> Option<crate::led_controller::LedPattern> {
match s {
"static" => Some(crate::led_controller::LedPattern::Static),
"breathing" => Some(crate::led_controller::LedPattern::Breathing),
"rainbow" => Some(crate::led_controller::LedPattern::Rainbow),
"rainbow_wave" => Some(crate::led_controller::LedPattern::RainbowWave),
_ => None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileRemaps {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub remaps: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemapDevicesConfig {
#[serde(default)]
pub devices: HashMap<String, ExtendedDeviceRemapConfig>,
#[serde(default)]
pub default: Option<HashMap<String, String>>,
}
#[derive(Debug)]
pub enum RemapConfigError {
ReadError {
path: PathBuf,
source: std::io::Error,
},
ParseError {
path: PathBuf,
source: serde_yaml::Error,
},
InvalidKey {
path: PathBuf,
key: String,
expected: String,
},
WriteError {
path: PathBuf,
source: std::io::Error,
},
Validation {
field: String,
message: String,
},
}
impl std::fmt::Display for RemapConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RemapConfigError::ReadError { path, source } => {
write!(f, "Failed to read remaps file {}: {}", path.display(), source)
}
RemapConfigError::ParseError { path, source } => {
write!(f, "Failed to parse remaps file {}: {}", path.display(), source)
}
RemapConfigError::InvalidKey { path, key, expected } => {
write!(
f,
"Invalid key name '{}' in {}: expected {}",
key,
path.display(),
expected
)
}
RemapConfigError::WriteError { path, source } => {
write!(f, "Failed to write remaps file {}: {}", path.display(), source)
}
RemapConfigError::Validation { field, message } => {
write!(f, "Validation error for '{}': {}", field, message)
}
}
}
}
impl std::error::Error for RemapConfigError {}
pub struct ConfigManager {
pub config_path: PathBuf,
pub macros_path: PathBuf,
pub cache_path: PathBuf,
pub profiles_dir: PathBuf,
pub remaps_path: PathBuf,
pub device_profiles_path: PathBuf,
pub layer_state_path: PathBuf,
pub config: Arc<RwLock<DaemonConfig>>,
pub macros: Arc<RwLock<HashMap<String, MacroEntry>>>,
pub profiles: Arc<RwLock<HashMap<String, Profile>>>,
pub remaps: Arc<RwLock<HashMap<String, String>>>,
pub device_profiles: Arc<RwLock<HashMap<String, HashMap<String, RemapProfile>>>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonConfig {
pub daemon: DaemonSettings,
pub device_discovery: DeviceDiscoverySettings,
pub macro_engine: MacroEngineSettings,
pub config: ConfigSettings,
pub security: SecuritySettings,
pub led_control: LedControlSettings,
pub performance: PerformanceSettings,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
pub auto_switch_rules: Vec<AutoSwitchRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonSettings {
pub socket_path: String,
pub log_level: String,
pub drop_privileges: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceDiscoverySettings {
pub input_devices_path: String,
pub use_openrazer_db: bool,
pub fallback_name_pattern: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MacroEngineSettings {
pub max_concurrent_macros: usize,
pub default_delay: u32,
pub enable_recording: bool,
#[serde(default)]
pub latency_offset_ms: u32,
#[serde(default)]
pub jitter_pct: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigSettings {
pub config_file: String,
pub macros_file: String,
pub cache_file: String,
pub auto_save: bool,
pub reload_interval: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecuritySettings {
pub socket_group: String,
pub socket_permissions: String,
pub require_auth_token: bool,
pub retain_capabilities: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LedControlSettings {
pub enabled: bool,
pub interface: String,
pub default_color: [u8; 3],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceSettings {
pub device_poll_interval: u64,
pub event_queue_size: usize,
pub thread_pool: bool,
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
daemon: DaemonSettings {
socket_path: "/run/aethermap/aethermap.sock".to_string(),
log_level: "info".to_string(),
drop_privileges: true,
},
device_discovery: DeviceDiscoverySettings {
input_devices_path: "/dev/input/by-id".to_string(),
use_openrazer_db: true,
fallback_name_pattern: "Razer".to_string(),
},
macro_engine: MacroEngineSettings {
max_concurrent_macros: 10,
default_delay: 10,
enable_recording: true,
latency_offset_ms: 0,
jitter_pct: 0.0,
},
config: ConfigSettings {
config_file: "/etc/aethermap/config.yaml".to_string(),
macros_file: "/etc/aethermap/macros.yaml".to_string(),
cache_file: "/var/cache/aethermap/macros.bin".to_string(),
auto_save: true,
reload_interval: 30,
},
security: SecuritySettings {
socket_group: "input".to_string(),
socket_permissions: "0660".to_string(),
require_auth_token: false,
retain_capabilities: vec!["CAP_SYS_RAWIO".to_string()],
},
led_control: LedControlSettings {
enabled: true,
interface: "dbus".to_string(),
default_color: [0, 255, 0],
},
performance: PerformanceSettings {
device_poll_interval: 1,
event_queue_size: 1000,
thread_pool: true,
},
auto_switch_rules: Vec::new(),
}
}
}
impl ConfigManager {
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
let config_path = PathBuf::from("/etc/aethermap/config.yaml");
let macros_path = PathBuf::from("/etc/aethermap/macros.yaml");
let cache_path = PathBuf::from("/var/cache/aethermap/macros.bin");
let profiles_dir = PathBuf::from("/etc/aethermap/profiles");
let remaps_path = PathBuf::from("/etc/aethermap/remaps.yaml");
let device_profiles_path = PathBuf::from("/etc/aethermap/device_profiles.yaml");
let layer_state_path = PathBuf::from("/etc/aethermap/layer_state.yaml");
let manager = Self {
config_path,
macros_path,
cache_path,
profiles_dir,
remaps_path,
device_profiles_path,
layer_state_path,
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
if let Some(parent) = manager.config_path.parent() {
fs::create_dir_all(parent).await?;
}
if let Some(parent) = manager.macros_path.parent() {
fs::create_dir_all(parent).await?;
}
if let Some(parent) = manager.cache_path.parent() {
fs::create_dir_all(parent).await?;
}
fs::create_dir_all(&manager.profiles_dir).await?;
if let Some(parent) = manager.remaps_path.parent() {
fs::create_dir_all(parent).await?;
}
if let Some(parent) = manager.device_profiles_path.parent() {
fs::create_dir_all(parent).await?;
}
if let Some(parent) = manager.layer_state_path.parent() {
fs::create_dir_all(parent).await?;
}
Ok(manager)
}
pub async fn load_config(&mut self) -> Result<(), Box<dyn std::error::Error>> {
info!("Loading configuration from {}", self.config_path.display());
if self.config_path.exists() {
let content = fs::read_to_string(&self.config_path).await?;
let new_config: DaemonConfig = serde_yaml::from_str(&content)?;
*self.config.write().await = new_config;
debug!("Loaded configuration from disk");
} else {
warn!("Configuration file not found, using defaults");
self.save_config().await?;
}
if self.cache_path.exists() {
match self.load_macros_from_cache().await {
Ok(()) => {
debug!("Loaded macros from cache");
return Ok(());
}
Err(e) => {
warn!("Failed to load macros from cache: {}", e);
}
}
}
if self.macros_path.exists() {
self.load_macros_from_yaml().await?;
} else {
info!("No macros file found, creating empty macros");
self.save_macros().await?;
}
Ok(())
}
pub async fn save_config(&self) -> Result<(), Box<dyn std::error::Error>> {
info!("Saving configuration to {}", self.config_path.display());
let config = self.config.read().await;
let content = serde_yaml::to_string(&*config)?;
drop(config);
fs::write(&self.config_path, content).await?;
debug!("Configuration saved");
Ok(())
}
pub async fn set_auto_switch_rules(
&self,
rules: Vec<AutoSwitchRule>,
) -> Result<(), RemapConfigError> {
{
let mut config = self.config.write().await;
config.auto_switch_rules = rules.clone();
}
let config = self.config.read().await;
let content = serde_yaml::to_string(&*config)
.map_err(|e| RemapConfigError::WriteError {
path: self.config_path.clone(),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
})?;
drop(config);
fs::write(&self.config_path, content)
.await
.map_err(|e| RemapConfigError::WriteError {
path: self.config_path.clone(),
source: e,
})?;
info!("Saved {} auto-switch rules to {}", rules.len(), self.config_path.display());
Ok(())
}
pub async fn get_auto_switch_rules(&self) -> Vec<AutoSwitchRule> {
let config = self.config.read().await;
config.auto_switch_rules.clone()
}
async fn load_macros_from_cache(&self) -> Result<(), Box<dyn std::error::Error>> {
info!("Loading macros from cache {}", self.cache_path.display());
let content = fs::read(&self.cache_path).await?;
if content.len() < 4 {
return Err("Cache file too short".into());
}
let magic = u32::from_le_bytes([content[0], content[1], content[2], content[3]]);
if magic != 0xDEADBEEF {
return Err("Invalid cache file magic number".into());
}
let macros: HashMap<String, MacroEntry> = aethermap_common::deserialize(&content[4..])?;
*self.macros.write().await = macros;
debug!("Loaded macros from cache");
Ok(())
}
async fn load_macros_from_yaml(&self) -> Result<(), Box<dyn std::error::Error>> {
info!("Loading macros from {}", self.macros_path.display());
let content = fs::read_to_string(&self.macros_path).await?;
let macros: HashMap<String, MacroEntry> = serde_yaml::from_str(&content)?;
*self.macros.write().await = macros;
debug!("Loaded macros from YAML");
Ok(())
}
pub async fn save_macros(&self) -> Result<(), Box<dyn std::error::Error>> {
let macros = self.macros.read().await;
self.save_macros_to_cache(¯os).await?;
self.save_macros_to_yaml(¯os).await?;
debug!("Saved macros to both cache and YAML");
Ok(())
}
async fn save_macros_to_cache(&self, macros: &HashMap<String, MacroEntry>) -> Result<(), Box<dyn std::error::Error>> {
let mut data = Vec::new();
data.extend_from_slice(&0xDEADBEEFu32.to_le_bytes());
let serialized = aethermap_common::serialize(macros);
data.extend_from_slice(&serialized);
fs::write(&self.cache_path, data).await?;
debug!("Saved macros to cache");
Ok(())
}
async fn save_macros_to_yaml(&self, macros: &HashMap<String, MacroEntry>) -> Result<(), Box<dyn std::error::Error>> {
let content = serde_yaml::to_string(macros)?;
fs::write(&self.macros_path, content).await?;
debug!("Saved macros to YAML");
Ok(())
}
pub fn config(&self) -> &Arc<RwLock<DaemonConfig>> {
&self.config
}
pub fn layer_state_path(&self) -> &PathBuf {
&self.layer_state_path
}
pub async fn get_analog_calibration(
&self,
device_id: &str,
layer_id: usize,
) -> Option<AnalogCalibration> {
if !self.device_profiles_path.exists() {
return None;
}
let content = fs::read_to_string(&self.device_profiles_path).await.ok()?;
let config: RemapDevicesConfig = serde_yaml::from_str(&content).ok()?;
config.devices.get(device_id)
.and_then(|device_config| device_config.analog_calibration.get(&layer_id))
.cloned()
}
pub async fn get_all_analog_calibrations(
&self,
device_id: &str,
) -> HashMap<usize, AnalogCalibration> {
let mut calibrations = HashMap::new();
if !self.device_profiles_path.exists() {
return calibrations;
}
let Ok(content) = fs::read_to_string(&self.device_profiles_path).await else {
return calibrations;
};
let Ok(config) = serde_yaml::from_str::<RemapDevicesConfig>(&content) else {
return calibrations;
};
if let Some(device_config) = config.devices.get(device_id) {
for (layer_id, calibration) in &device_config.analog_calibration {
calibrations.insert(*layer_id, calibration.clone());
}
}
calibrations
}
pub async fn save_analog_calibration(
&self,
device_id: &str,
layer_id: usize,
calibration: AnalogCalibration,
) -> Result<(), String> {
let mut config = if self.device_profiles_path.exists() {
let content = fs::read_to_string(&self.device_profiles_path)
.await
.map_err(|e| format!("Failed to read device profiles: {}", e))?;
serde_yaml::from_str(&content)
.map_err(|e| format!("Failed to parse device profiles: {}", e))?
} else {
RemapDevicesConfig {
devices: HashMap::new(),
default: None,
}
};
if !config.devices.contains_key(device_id) {
config.devices.insert(
device_id.to_string(),
ExtendedDeviceRemapConfig {
match_pattern: None,
profiles: HashMap::new(),
capabilities: None,
analog_config: None,
analog_calibration: HashMap::new(),
led_config: None,
hotkey_bindings: Vec::new(),
}
);
}
let device_config = config.devices.get_mut(device_id)
.ok_or_else(|| format!("Device not found: {}", device_id))?;
device_config.analog_calibration.insert(layer_id, calibration);
let yaml = serde_yaml::to_string(&config)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
if let Some(parent) = self.device_profiles_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create config directory: {}", e))?;
}
fs::write(&self.device_profiles_path, yaml)
.await
.map_err(|e| format!("Failed to write device profiles: {}", e))?;
info!(
"Saved analog calibration for device {} layer {}",
device_id, layer_id
);
Ok(())
}
pub async fn load_config_mut(&self) -> Result<(), Box<dyn std::error::Error>> {
info!("Loading configuration from {}", self.config_path.display());
if self.config_path.exists() {
let content = fs::read_to_string(&self.config_path).await?;
let _config: DaemonConfig = serde_yaml::from_str(&content)?;
debug!("Loaded configuration from disk");
} else {
warn!("Configuration file not found, using defaults");
self.save_config().await?;
}
if self.cache_path.exists() {
match self.load_macros_from_cache().await {
Ok(()) => {
debug!("Loaded macros from cache");
return Ok(());
}
Err(e) => {
warn!("Failed to load macros from cache: {}", e);
}
}
}
if self.macros_path.exists() {
self.load_macros_from_yaml().await?;
} else {
info!("No macros file found, creating empty macros");
self.save_macros().await?;
}
Ok(())
}
pub fn macros(&self) -> &Arc<RwLock<HashMap<String, MacroEntry>>> {
&self.macros
}
pub async fn get_profile(&self, name: &str) -> Option<Profile> {
let profiles = self.profiles.read().await;
profiles.get(name).cloned()
}
pub async fn get_profiles(&self) -> std::collections::HashMap<String, Profile> {
let profiles = self.profiles.read().await;
profiles.clone()
}
pub async fn save_profile(&self, profile: &Profile) -> Result<(), Box<dyn std::error::Error>> {
let profile_path = self.profiles_dir.join(format!("{}.yaml", profile.name));
let yaml = serde_yaml::to_string(profile)?;
fs::write(&profile_path, yaml).await?;
let mut profiles = self.profiles.write().await;
profiles.insert(profile.name.clone(), profile.clone());
info!("Profile {} saved to {}", profile.name, profile_path.display());
Ok(())
}
pub async fn load_profile(&self, name: &str) -> Result<Profile, Box<dyn std::error::Error>> {
let profile_path = self.profiles_dir.join(format!("{}.yaml", name));
if !profile_path.exists() {
return Err(format!("Profile {} not found", name).into());
}
let yaml = fs::read_to_string(&profile_path).await?;
let profile: Profile = serde_yaml::from_str(&yaml)?;
let mut profiles = self.profiles.write().await;
profiles.insert(name.to_string(), profile.clone());
let mut macros = self.macros.write().await;
for (name, macro_entry) in &profile.macros {
macros.insert(name.clone(), macro_entry.clone());
}
info!("Profile {} loaded from {}", name, profile_path.display());
Ok(profile)
}
pub async fn list_profiles(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut entries = match fs::read_dir(&self.profiles_dir).await {
Ok(entries) => entries,
Err(e) => return Err(e.into()),
};
let mut profiles = Vec::new();
while let Some(entry) = entries.next_entry().await.unwrap_or(None) {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("yaml") {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
profiles.push(name.to_string());
}
}
}
profiles.sort();
Ok(profiles)
}
pub async fn delete_profile(&self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
let profile_path = self.profiles_dir.join(format!("{}.yaml", name));
if profile_path.exists() {
fs::remove_file(&profile_path).await?;
}
let mut profiles = self.profiles.write().await;
profiles.remove(name);
info!("Profile {} deleted", name);
Ok(())
}
pub async fn save_current_macros_as_profile(&self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
let macros = self.macros.read().await;
let profile = Profile {
name: name.to_string(),
macros: macros.clone(),
};
self.save_profile(&profile).await
}
pub async fn get_device_profile(&self, device_id: &str, profile_name: &str) -> Option<RemapProfile> {
let profiles = self.device_profiles.read().await;
profiles.get(device_id)?.get(profile_name).cloned()
}
pub async fn list_device_profiles(&self, device_id: &str) -> Vec<String> {
let profiles = self.device_profiles.read().await;
match profiles.get(device_id) {
Some(device_profiles) => device_profiles.keys().cloned().collect(),
None => Vec::new(),
}
}
pub async fn list_profile_devices(&self) -> Vec<String> {
let profiles = self.device_profiles.read().await;
profiles.keys().cloned().collect()
}
pub async fn load_remaps(&self) -> Result<Vec<RemapEntry>, RemapConfigError> {
if !self.remaps_path.exists() {
warn!(
"Remaps file not found at {}, creating empty file",
self.remaps_path.display()
);
let empty = HashMap::<String, String>::new();
let yaml = serde_yaml::to_string(&empty).map_err(|e| RemapConfigError::WriteError {
path: self.remaps_path.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
})?;
fs::write(&self.remaps_path, yaml)
.await
.map_err(|e| RemapConfigError::WriteError {
path: self.remaps_path.clone(),
source: e,
})?;
return Ok(Vec::new());
}
let content = fs::read_to_string(&self.remaps_path)
.await
.map_err(|e| RemapConfigError::ReadError {
path: self.remaps_path.clone(),
source: e,
})?;
let remap_hash: HashMap<String, String> =
serde_yaml::from_str(&content).map_err(|e| RemapConfigError::ParseError {
path: self.remaps_path.clone(),
source: e,
})?;
let key_parser = crate::key_parser::KeyParser::new();
let mut entries = Vec::new();
for (from, to) in &remap_hash {
if let Err(_e) = key_parser.parse(from) {
return Err(RemapConfigError::InvalidKey {
path: self.remaps_path.clone(),
key: from.clone(),
expected: "valid evdev key name (e.g., KEY_A, a, capslock)".to_string(),
});
}
if let Err(_e) = key_parser.parse(to) {
return Err(RemapConfigError::InvalidKey {
path: self.remaps_path.clone(),
key: to.clone(),
expected: "valid evdev key name (e.g., KEY_A, a, capslock)".to_string(),
});
}
entries.push(RemapEntry {
from: from.clone(),
to: to.clone(),
});
}
*self.remaps.write().await = remap_hash;
info!(
"Loaded {} remaps from {}",
entries.len(),
self.remaps_path.display()
);
Ok(entries)
}
pub async fn reload_device_profiles(&self) -> Result<(), RemapConfigError> {
info!(
"Reloading device profiles from {}",
self.device_profiles_path.display()
);
let content = fs::read_to_string(&self.device_profiles_path)
.await
.map_err(|e| RemapConfigError::ReadError {
path: self.device_profiles_path.clone(),
source: e,
})?;
let config: DeviceProfilesConfig = serde_yaml::from_str(&content)
.map_err(|e| RemapConfigError::ParseError {
path: self.device_profiles_path.clone(),
source: e,
})?;
let key_parser = Arc::new(crate::key_parser::KeyParser::new());
let mut all_profiles = HashMap::new();
let mut total_profiles = 0usize;
for (device_id, device_config) in &config.devices {
let mut device_profiles = HashMap::new();
for (profile_name, profile) in &device_config.profiles {
let remap_config: HashMap<String, String> = profile.remaps.iter()
.map(|r| (r.from.clone(), r.to.clone()))
.collect();
match RemapProfile::with_key_parser(
profile_name.clone(),
&remap_config,
key_parser.clone(),
) {
Ok(remap_profile) => {
debug!(
"Validated profile '{}' for device {} with {} remappings",
profile_name,
device_id,
remap_profile.remap_count().await
);
device_profiles.insert(profile_name.clone(), remap_profile);
total_profiles += 1;
}
Err(e) => {
return Err(RemapConfigError::InvalidKey {
path: self.device_profiles_path.clone(),
key: profile_name.clone(),
expected: format!("valid profile configuration: {}", e),
});
}
}
}
all_profiles.insert(device_id.clone(), device_profiles);
}
let mut config_guard = self.device_profiles.write().await;
*config_guard = all_profiles;
info!(
"Reloaded {} profiles for {} devices from {}",
total_profiles,
config.devices.len(),
self.device_profiles_path.display()
);
Ok(())
}
pub async fn reload_remaps(
&self,
remap_engine: Arc<crate::remap_engine::RemapEngine>,
) -> Result<(), RemapConfigError> {
info!(
"Reloading global remappings from {}",
self.remaps_path.display()
);
let entries = self.load_remaps().await?;
let remap_hash: HashMap<String, String> = entries
.iter()
.map(|e| (e.from.clone(), e.to.clone()))
.collect();
remap_engine
.load_config(&remap_hash)
.await
.map_err(|e| RemapConfigError::InvalidKey {
path: self.remaps_path.clone(),
key: "remap_config".to_string(),
expected: format!("valid remap configuration: {}", e),
})?;
info!(
"Reloaded {} global remappings from {}",
remap_hash.len(),
self.remaps_path.display()
);
Ok(())
}
pub async fn load_device_profiles(&self) -> Result<(), RemapConfigError> {
if !self.device_profiles_path.exists() {
warn!(
"Device profiles file not found at {}, creating empty file",
self.device_profiles_path.display()
);
let empty = DeviceProfilesConfig {
devices: HashMap::new(),
};
let yaml = serde_yaml::to_string(&empty).map_err(|e| RemapConfigError::WriteError {
path: self.device_profiles_path.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
})?;
fs::write(&self.device_profiles_path, yaml)
.await
.map_err(|e| RemapConfigError::WriteError {
path: self.device_profiles_path.clone(),
source: e,
})?;
return Ok(());
}
let content = fs::read_to_string(&self.device_profiles_path)
.await
.map_err(|e| RemapConfigError::ReadError {
path: self.device_profiles_path.clone(),
source: e,
})?;
let config: DeviceProfilesConfig = serde_yaml::from_str(&content)
.map_err(|e| RemapConfigError::ParseError {
path: self.device_profiles_path.clone(),
source: e,
})?;
let key_parser = Arc::new(crate::key_parser::KeyParser::new());
let mut all_profiles = HashMap::new();
for (device_id, device_config) in &config.devices {
let mut device_profiles = HashMap::new();
for (profile_name, profile) in &device_config.profiles {
let remap_config: HashMap<String, String> = profile.remaps.iter()
.map(|r| (r.from.clone(), r.to.clone()))
.collect();
match RemapProfile::with_key_parser(
profile_name.clone(),
&remap_config,
key_parser.clone(),
) {
Ok(remap_profile) => {
info!(
"Loaded profile '{}' for device {} with {} remappings",
profile_name,
device_id,
remap_profile.remap_count().await
);
device_profiles.insert(profile_name.clone(), remap_profile);
}
Err(e) => {
return Err(RemapConfigError::InvalidKey {
path: self.device_profiles_path.clone(),
key: profile_name.clone(),
expected: format!("valid profile configuration: {}", e),
});
}
}
}
all_profiles.insert(device_id.clone(), device_profiles);
}
*self.device_profiles.write().await = all_profiles;
info!(
"Loaded profiles for {} devices from {}",
config.devices.len(),
self.device_profiles_path.display()
);
Ok(())
}
pub async fn load_device_profiles_extended(
&self,
) -> Result<HashMap<String, Vec<RemapProfile>>, RemapConfigError> {
use std::sync::Arc;
if !self.device_profiles_path.exists() {
warn!(
"Device profiles file not found at {}, creating empty file",
self.device_profiles_path.display()
);
let empty = RemapDevicesConfig {
devices: HashMap::new(),
default: None,
};
let yaml = serde_yaml::to_string(&empty).map_err(|e| RemapConfigError::WriteError {
path: self.device_profiles_path.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
})?;
fs::write(&self.device_profiles_path, yaml)
.await
.map_err(|e| RemapConfigError::WriteError {
path: self.device_profiles_path.clone(),
source: e,
})?;
return Ok(HashMap::new());
}
let content = fs::read_to_string(&self.device_profiles_path)
.await
.map_err(|e| RemapConfigError::ReadError {
path: self.device_profiles_path.clone(),
source: e,
})?;
let config: RemapDevicesConfig = serde_yaml::from_str(&content)
.map_err(|e| RemapConfigError::ParseError {
path: self.device_profiles_path.clone(),
source: e,
})?;
let key_parser = Arc::new(crate::key_parser::KeyParser::new());
let mut result: HashMap<String, Vec<RemapProfile>> = HashMap::new();
for (device_id, device_config) in &config.devices {
let mut profiles = Vec::new();
for (profile_name, profile_config) in &device_config.profiles {
match RemapProfile::with_key_parser(
profile_name.clone(),
&profile_config.remaps,
key_parser.clone(),
) {
Ok(remap_profile) => {
info!(
"Loaded profile '{}' for device {} with {} remappings",
profile_name,
device_id,
remap_profile.remap_count().await
);
profiles.push(remap_profile);
}
Err(e) => {
return Err(RemapConfigError::InvalidKey {
path: self.device_profiles_path.clone(),
key: profile_name.clone(),
expected: format!("valid profile configuration: {}", e),
});
}
}
}
if !device_config.analog_calibration.is_empty() {
debug!(
"Loaded {} analog calibration entries for device {}",
device_config.analog_calibration.len(),
device_id
);
}
result.insert(device_id.clone(), profiles);
}
info!(
"Loaded {} device profiles from {}",
result.len(),
self.device_profiles_path.display()
);
Ok(result)
}
pub async fn load_analog_configs(
&self,
) -> Result<HashMap<String, AnalogDeviceConfig>, RemapConfigError> {
if !self.device_profiles_path.exists() {
return Ok(HashMap::new());
}
let content = fs::read_to_string(&self.device_profiles_path)
.await
.map_err(|e| RemapConfigError::ReadError {
path: self.device_profiles_path.clone(),
source: e,
})?;
let config: RemapDevicesConfig = serde_yaml::from_str(&content)
.map_err(|e| RemapConfigError::ParseError {
path: self.device_profiles_path.clone(),
source: e,
})?;
let mut result: HashMap<String, AnalogDeviceConfig> = HashMap::new();
for (device_id, device_config) in &config.devices {
if let Some(analog_config) = &device_config.analog_config {
info!(
"Loaded analog config for device {}: deadzone={}%, sensitivity={:.2}, curve={}",
device_id,
analog_config.deadzone_percentage,
analog_config.sensitivity,
analog_config.response_curve
);
result.insert(device_id.clone(), analog_config.clone());
}
}
info!(
"Loaded analog configs for {} devices from {}",
result.len(),
self.device_profiles_path.display()
);
Ok(result)
}
pub async fn save_analog_config(
&self,
device_id: &str,
analog_config: &AnalogDeviceConfig,
) -> Result<(), RemapConfigError> {
info!(
"Saving analog config for device {}: deadzone={}%, sensitivity={:.2}, curve={}, dpad={}",
device_id,
analog_config.deadzone_percentage,
analog_config.sensitivity,
analog_config.response_curve,
analog_config.dpad_mode
);
let mut config: RemapDevicesConfig = if self.device_profiles_path.exists() {
let content = fs::read_to_string(&self.device_profiles_path)
.await
.map_err(|e| RemapConfigError::ReadError {
path: self.device_profiles_path.clone(),
source: e,
})?;
serde_yaml::from_str(&content).map_err(|e| RemapConfigError::ParseError {
path: self.device_profiles_path.clone(),
source: e,
})?
} else {
RemapDevicesConfig {
devices: HashMap::new(),
default: None,
}
};
let device_entry = config.devices.entry(device_id.to_string()).or_insert_with(|| {
ExtendedDeviceRemapConfig {
match_pattern: Some(device_id.to_string()),
profiles: HashMap::new(),
capabilities: None,
analog_config: None,
analog_calibration: HashMap::new(),
led_config: None,
hotkey_bindings: Vec::new(),
}
});
device_entry.analog_config = Some(analog_config.clone());
let yaml = serde_yaml::to_string(&config).map_err(|e| RemapConfigError::WriteError {
path: self.device_profiles_path.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
})?;
fs::write(&self.device_profiles_path, yaml)
.await
.map_err(|e| RemapConfigError::WriteError {
path: self.device_profiles_path.clone(),
source: e,
})?;
info!(
"Analog config saved for device {} to {}",
device_id,
self.device_profiles_path.display()
);
Ok(())
}
pub async fn save_led_config(
&self,
device_id: &str,
led_config: &LedConfig,
) -> Result<(), RemapConfigError> {
info!(
"Saving LED config for device {}: global_brightness={}%, zone_brightness={:?}",
device_id,
led_config.global_brightness,
led_config.zone_brightness
);
let mut config: RemapDevicesConfig = if self.device_profiles_path.exists() {
let content = fs::read_to_string(&self.device_profiles_path)
.await
.map_err(|e| RemapConfigError::ReadError {
path: self.device_profiles_path.clone(),
source: e,
})?;
serde_yaml::from_str(&content).map_err(|e| RemapConfigError::ParseError {
path: self.device_profiles_path.clone(),
source: e,
})?
} else {
RemapDevicesConfig {
devices: HashMap::new(),
default: None,
}
};
let device_entry = config.devices.entry(device_id.to_string()).or_insert_with(|| {
ExtendedDeviceRemapConfig {
match_pattern: Some(device_id.to_string()),
profiles: HashMap::new(),
capabilities: None,
analog_config: None,
analog_calibration: HashMap::new(),
led_config: None,
hotkey_bindings: Vec::new(),
}
});
device_entry.led_config = Some(led_config.clone());
let yaml = serde_yaml::to_string(&config).map_err(|e| RemapConfigError::WriteError {
path: self.device_profiles_path.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
})?;
fs::write(&self.device_profiles_path, yaml)
.await
.map_err(|e| RemapConfigError::WriteError {
path: self.device_profiles_path.clone(),
source: e,
})?;
info!(
"LED config saved for device {} to {}",
device_id,
self.device_profiles_path.display()
);
Ok(())
}
pub async fn load_led_configs(
&self,
) -> Result<HashMap<String, LedConfig>, RemapConfigError> {
if !self.device_profiles_path.exists() {
return Ok(HashMap::new());
}
let content = fs::read_to_string(&self.device_profiles_path)
.await
.map_err(|e| RemapConfigError::ReadError {
path: self.device_profiles_path.clone(),
source: e,
})?;
let config: RemapDevicesConfig = serde_yaml::from_str(&content)
.map_err(|e| RemapConfigError::ParseError {
path: self.device_profiles_path.clone(),
source: e,
})?;
let mut result: HashMap<String, LedConfig> = HashMap::new();
for (device_id, device_config) in &config.devices {
if let Some(led_config) = &device_config.led_config {
info!(
"Loaded LED config for device {}: global_brightness={}%, zones={}",
device_id,
led_config.global_brightness,
led_config.zone_brightness.len()
);
result.insert(device_id.clone(), led_config.clone());
}
}
info!(
"Loaded LED configs for {} devices from {}",
result.len(),
self.device_profiles_path.display()
);
Ok(result)
}
pub async fn get_led_config(&self, device_id: &str) -> LedConfig {
match self.load_led_configs().await {
Ok(configs) => configs.get(device_id).cloned().unwrap_or_default(),
Err(_) => LedConfig::default(),
}
}
pub fn brightness_to_raw(brightness: u8) -> u8 {
(brightness as u16 * 255 / 100) as u8
}
pub fn raw_to_brightness(raw: u8) -> u8 {
(raw as u16 * 100 / 255) as u8
}
pub fn parse_led_zone(zone_name: &str) -> Option<aethermap_common::LedZone> {
match zone_name.to_lowercase().as_str() {
"side" => Some(aethermap_common::LedZone::Side),
"logo" => Some(aethermap_common::LedZone::Logo),
"keys" => Some(aethermap_common::LedZone::Keys),
"thumbstick" => Some(aethermap_common::LedZone::Thumbstick),
"all" => Some(aethermap_common::LedZone::All),
"global" => Some(aethermap_common::LedZone::Global),
_ => None,
}
}
pub fn led_zone_to_string(zone: aethermap_common::LedZone) -> String {
match zone {
aethermap_common::LedZone::Side => "side".to_string(),
aethermap_common::LedZone::Logo => "logo".to_string(),
aethermap_common::LedZone::Keys => "keys".to_string(),
aethermap_common::LedZone::Thumbstick => "thumbstick".to_string(),
aethermap_common::LedZone::All => "all".to_string(),
aethermap_common::LedZone::Global => "global".to_string(),
}
}
pub fn get_default_layer_color(layer_id: usize) -> (u8, u8, u8) {
match layer_id {
0 => (255, 255, 255), 1 => (0, 0, 255), 2 => (0, 255, 0), 3 => (255, 0, 0), 4 => (255, 255, 0), 5 => (255, 0, 255), 6 => (0, 255, 255), 7 => (255, 128, 0), 8 => (128, 0, 255), 9 => (0, 128, 255), _ => (128, 128, 128), }
}
pub async fn add_hotkey_binding(
&self,
device_id: &str,
binding: HotkeyBinding,
) -> Result<(), RemapConfigError> {
info!(
"Adding hotkey binding for device {}: key={:?}, modifiers={:?}, profile={}",
device_id, binding.key, binding.modifiers, binding.profile_name
);
let mut config: RemapDevicesConfig = if self.device_profiles_path.exists() {
let content = fs::read_to_string(&self.device_profiles_path)
.await
.map_err(|e| RemapConfigError::ReadError {
path: self.device_profiles_path.clone(),
source: e,
})?;
serde_yaml::from_str(&content).map_err(|e| RemapConfigError::ParseError {
path: self.device_profiles_path.clone(),
source: e,
})?
} else {
RemapDevicesConfig {
devices: HashMap::new(),
default: None,
}
};
let device_entry = config.devices.entry(device_id.to_string()).or_insert_with(|| {
ExtendedDeviceRemapConfig {
match_pattern: Some(device_id.to_string()),
profiles: HashMap::new(),
capabilities: None,
analog_config: None,
analog_calibration: HashMap::new(),
led_config: None,
hotkey_bindings: Vec::new(),
}
});
let normalized_binding_modifiers: Vec<String> = binding.modifiers.iter()
.map(|m| m.to_lowercase())
.collect();
let is_duplicate = device_entry.hotkey_bindings.iter().any(|existing| {
let normalized_existing: Vec<String> = existing.modifiers.iter()
.map(|m| m.to_lowercase())
.collect();
existing.key == binding.key && normalized_existing == normalized_binding_modifiers
});
if is_duplicate {
return Err(RemapConfigError::Validation {
field: "hotkey".to_string(),
message: "Hotkey with this key and modifiers already exists".to_string(),
});
}
device_entry.hotkey_bindings.push(binding);
let yaml = serde_yaml::to_string(&config).map_err(|e| RemapConfigError::WriteError {
path: self.device_profiles_path.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
})?;
fs::write(&self.device_profiles_path, yaml)
.await
.map_err(|e| RemapConfigError::WriteError {
path: self.device_profiles_path.clone(),
source: e,
})?;
info!(
"Added hotkey binding for device {} to {}",
device_id,
self.device_profiles_path.display()
);
Ok(())
}
pub async fn remove_hotkey_binding(
&self,
device_id: &str,
key: &str,
modifiers: &[String],
) -> Result<(), RemapConfigError> {
info!(
"Removing hotkey binding for device {}: key={:?}, modifiers={:?}",
device_id, key, modifiers
);
if !self.device_profiles_path.exists() {
return Err(RemapConfigError::Validation {
field: "hotkey".to_string(),
message: "Hotkey not found".to_string(),
});
}
let content = fs::read_to_string(&self.device_profiles_path)
.await
.map_err(|e| RemapConfigError::ReadError {
path: self.device_profiles_path.clone(),
source: e,
})?;
let mut config: RemapDevicesConfig = serde_yaml::from_str(&content)
.map_err(|e| RemapConfigError::ParseError {
path: self.device_profiles_path.clone(),
source: e,
})?;
let device_entry = config.devices.get_mut(device_id)
.ok_or_else(|| RemapConfigError::Validation {
field: "hotkey".to_string(),
message: "Hotkey not found".to_string(),
})?;
let original_len = device_entry.hotkey_bindings.len();
let normalized_modifiers: Vec<String> = modifiers.iter()
.map(|m| m.to_lowercase())
.collect();
device_entry.hotkey_bindings.retain(|binding| {
let normalized_binding: Vec<String> = binding.modifiers.iter()
.map(|m| m.to_lowercase())
.collect();
!(binding.key == key && normalized_binding == normalized_modifiers)
});
if device_entry.hotkey_bindings.len() == original_len {
return Err(RemapConfigError::Validation {
field: "hotkey".to_string(),
message: "Hotkey not found".to_string(),
});
}
let yaml = serde_yaml::to_string(&config).map_err(|e| RemapConfigError::WriteError {
path: self.device_profiles_path.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
})?;
fs::write(&self.device_profiles_path, yaml)
.await
.map_err(|e| RemapConfigError::WriteError {
path: self.device_profiles_path.clone(),
source: e,
})?;
info!(
"Removed hotkey binding for device {}",
device_id
);
Ok(())
}
pub async fn get_hotkey_bindings(
&self,
device_id: &str,
) -> Result<Vec<HotkeyBinding>, RemapConfigError> {
if !self.device_profiles_path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&self.device_profiles_path)
.await
.map_err(|e| RemapConfigError::ReadError {
path: self.device_profiles_path.clone(),
source: e,
})?;
let config: RemapDevicesConfig = serde_yaml::from_str(&content)
.map_err(|e| RemapConfigError::ParseError {
path: self.device_profiles_path.clone(),
source: e,
})?;
match config.devices.get(device_id) {
Some(device_entry) => Ok(device_entry.hotkey_bindings.clone()),
None => Ok(Vec::new()),
}
}
pub async fn get_all_hotkey_bindings(&self) -> Vec<HotkeyBinding> {
let mut bindings = Vec::new();
if !self.device_profiles_path.exists() {
return bindings;
}
let Ok(content) = fs::read_to_string(&self.device_profiles_path).await else {
return bindings;
};
let Ok(config) = serde_yaml::from_str::<RemapDevicesConfig>(&content) else {
return bindings;
};
for device_config in config.devices.values() {
bindings.extend(device_config.hotkey_bindings.clone());
}
bindings
}
}
pub fn brightness_to_raw(brightness: u8) -> u8 {
ConfigManager::brightness_to_raw(brightness)
}
pub fn raw_to_brightness(raw: u8) -> u8 {
ConfigManager::raw_to_brightness(raw)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tempfile::TempDir;
#[tokio::test]
async fn test_config_creation() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.yaml");
let macros_path = temp_dir.path().join("macros.yaml");
let cache_path = temp_dir.path().join("macros.bin");
let mut manager = ConfigManager {
config_path,
macros_path,
cache_path,
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path: temp_dir.path().join("device_profiles.yaml"),
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
manager.save_config().await.unwrap();
manager.load_config().await.unwrap();
}
#[tokio::test]
async fn test_macro_persistence() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.yaml");
let macros_path = temp_dir.path().join("macros.yaml");
let cache_path = temp_dir.path().join("macros.bin");
let manager = ConfigManager {
config_path: config_path.clone(),
macros_path: macros_path.clone(),
cache_path: cache_path.clone(),
profiles_dir: temp_dir.path().to_path_buf(),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path: temp_dir.path().join("device_profiles.yaml"),
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let test_macro = MacroEntry {
name: "Test Macro".to_string(),
trigger: aethermap_common::KeyCombo {
keys: vec![30, 40], modifiers: vec![29], },
actions: vec![
aethermap_common::Action::KeyPress(30),
aethermap_common::Action::Delay(100),
aethermap_common::Action::KeyRelease(30),
],
device_id: None,
enabled: true,
humanize: false,
capture_mouse: false,
};
manager.macros.write().await.insert("test_macro".to_string(), test_macro.clone());
manager.save_macros().await.unwrap();
let manager2 = ConfigManager {
config_path: config_path.clone(),
macros_path,
cache_path: temp_dir.path().join("macros2.bin"),
profiles_dir: temp_dir.path().to_path_buf(),
remaps_path: temp_dir.path().join("remaps2.yaml"),
device_profiles_path: temp_dir.path().join("device_profiles2.yaml"),
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
manager2.load_macros_from_yaml().await.unwrap();
let loaded_macros = manager2.macros.read().await;
let loaded_macro = loaded_macros.get("test_macro").unwrap();
assert_eq!(loaded_macro.name, test_macro.name);
assert_eq!(loaded_macro.trigger.keys, test_macro.trigger.keys);
}
#[tokio::test]
async fn test_device_profile_config_creation() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.yaml");
let macros_path = temp_dir.path().join("macros.yaml");
let cache_path = temp_dir.path().join("macros.bin");
let device_profiles_path = temp_dir.path().join("device_profiles.yaml");
let manager = ConfigManager {
config_path,
macros_path,
cache_path,
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path,
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
assert_eq!(manager.list_profile_devices().await.len(), 0);
}
#[tokio::test]
async fn test_device_profile_loading() {
use std::io::Write;
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("device_profiles.yaml");
let yaml_content = r#"
devices:
"1532:0220":
device_id: "1532:0220"
profiles:
gaming:
name: "gaming"
remaps:
- from: capslock
to: leftctrl
- from: a
to: b
work:
name: "work"
remaps:
- from: esc
to: grave
"1532:0221":
device_id: "1532:0221"
profiles:
default:
name: "default"
remaps:
- from: KEY_1
to: KEY_2
"#;
let mut file = std::fs::File::create(&device_profiles_path).unwrap();
file.write_all(yaml_content.as_bytes()).unwrap();
let manager = ConfigManager {
config_path: temp_dir.path().join("config.yaml"),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path,
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let result = manager.load_device_profiles().await;
if let Err(e) = &result {
eprintln!("Error loading device profiles: {}", e);
}
assert!(result.is_ok());
let devices = manager.list_profile_devices().await;
assert_eq!(devices.len(), 2);
assert!(devices.contains(&"1532:0220".to_string()));
assert!(devices.contains(&"1532:0221".to_string()));
let profiles = manager.list_device_profiles("1532:0220").await;
assert_eq!(profiles.len(), 2);
assert!(profiles.contains(&"gaming".to_string()));
assert!(profiles.contains(&"work".to_string()));
let gaming_profile = manager.get_device_profile("1532:0220", "gaming").await;
assert!(gaming_profile.is_some());
let profile = gaming_profile.unwrap();
assert_eq!(profile.name(), "gaming");
assert_eq!(profile.remap_count().await, 2);
}
#[tokio::test]
async fn test_device_profile_invalid_key_fails() {
use std::io::Write;
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("device_profiles.yaml");
let yaml_content = r#"
devices:
"1532:0220":
profiles:
bad_profile:
remaps:
- from: invalid_key_name
to: leftctrl
"#;
let mut file = std::fs::File::create(&device_profiles_path).unwrap();
file.write_all(yaml_content.as_bytes()).unwrap();
let manager = ConfigManager {
config_path: temp_dir.path().join("config.yaml"),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path,
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let result = manager.load_device_profiles().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_extended_device_profile_loading() {
use std::io::Write;
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("device_profiles_extended.yaml");
let yaml_content = r#"
devices:
"1532:0220":
match_pattern: "1532:0220"
profiles:
gaming:
name: "Gaming Profile"
description: "Optimized for gaming"
remaps:
capslock: leftctrl
a: b
work:
name: "Work Profile"
remaps:
esc: grave
default:
KEY_1: KEY_2
"#;
let mut file = std::fs::File::create(&device_profiles_path).unwrap();
file.write_all(yaml_content.as_bytes()).unwrap();
let manager = ConfigManager {
config_path: temp_dir.path().join("config.yaml"),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path,
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let result = manager.load_device_profiles_extended().await;
if let Err(e) = &result {
eprintln!("Error loading extended device profiles: {}", e);
}
assert!(result.is_ok());
let profiles = result.unwrap();
assert_eq!(profiles.len(), 1);
assert!(profiles.contains_key("1532:0220"));
let device_profiles = &profiles["1532:0220"];
assert_eq!(device_profiles.len(), 2);
let gaming_profile = device_profiles
.iter()
.find(|p| p.name() == "gaming")
.expect("gaming profile not found");
assert_eq!(gaming_profile.remap_count().await, 2);
let work_profile = device_profiles
.iter()
.find(|p| p.name() == "work")
.expect("work profile not found");
assert_eq!(work_profile.remap_count().await, 1);
}
#[tokio::test]
async fn test_extended_device_profile_invalid_key_fails() {
use std::io::Write;
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("device_profiles_extended.yaml");
let yaml_content = r#"
devices:
"1532:0220":
profiles:
bad_profile:
remaps:
invalid_key_name: leftctrl
"#;
let mut file = std::fs::File::create(&device_profiles_path).unwrap();
file.write_all(yaml_content.as_bytes()).unwrap();
let manager = ConfigManager {
config_path: temp_dir.path().join("config.yaml"),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path,
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let result = manager.load_device_profiles_extended().await;
assert!(result.is_err());
match result {
Err(RemapConfigError::InvalidKey { key, .. }) => {
assert_eq!(key, "bad_profile");
}
_ => panic!("Expected InvalidKey error"),
}
}
#[tokio::test]
async fn test_extended_device_profile_empty_file() {
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("device_profiles_empty.yaml");
let manager = ConfigManager {
config_path: temp_dir.path().join("config.yaml"),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path,
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let result = manager.load_device_profiles_extended().await;
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 0);
}
#[tokio::test]
async fn test_device_capabilities_serialization() {
use std::io::Write;
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("device_profiles_caps.yaml");
let yaml_content = r#"
devices:
"32b6:12f7":
profiles:
gaming:
name: "Gaming Profile"
remaps:
joy_btn_0: KEY_A
joy_btn_1: KEY_B
capabilities:
has_analog_stick: true
has_hat_switch: true
joystick_button_count: 26
led_zones:
- "thumbstick"
- "wrist_rest"
- "logo"
device_type: "Keypad"
"#;
let mut file = std::fs::File::create(&device_profiles_path).unwrap();
file.write_all(yaml_content.as_bytes()).unwrap();
let manager = ConfigManager {
config_path: temp_dir.path().join("config.yaml"),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path,
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let result = manager.load_device_profiles_extended().await;
assert!(result.is_ok());
let profiles = result.unwrap();
assert_eq!(profiles.len(), 1);
let content = std::fs::read_to_string(manager.device_profiles_path).unwrap();
let config: RemapDevicesConfig = serde_yaml::from_str(&content).unwrap();
let azeron_config = &config.devices["32b6:12f7"];
assert!(azeron_config.capabilities.is_some());
let caps = azeron_config.capabilities.as_ref().unwrap();
assert_eq!(caps.has_analog_stick, Some(true));
assert_eq!(caps.has_hat_switch, Some(true));
assert_eq!(caps.joystick_button_count, Some(26));
assert_eq!(caps.device_type, Some("Keypad".to_string()));
assert!(caps.led_zones.is_some());
let zones = caps.led_zones.as_ref().unwrap();
assert_eq!(zones.len(), 3);
assert!(zones.contains(&"thumbstick".to_string()));
}
#[tokio::test]
async fn test_device_capabilities_optional() {
use std::io::Write;
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("device_profiles_no_caps.yaml");
let yaml_content = r#"
devices:
"1532:0220":
profiles:
default:
name: "Default"
remaps:
capslock: leftctrl
"#;
let mut file = std::fs::File::create(&device_profiles_path).unwrap();
file.write_all(yaml_content.as_bytes()).unwrap();
let manager = ConfigManager {
config_path: temp_dir.path().join("config.yaml"),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path,
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let result = manager.load_device_profiles_extended().await;
assert!(result.is_ok());
let content = std::fs::read_to_string(manager.device_profiles_path).unwrap();
let config: RemapDevicesConfig = serde_yaml::from_str(&content).unwrap();
let device_config = &config.devices["1532:0220"];
assert!(device_config.capabilities.is_none());
}
#[tokio::test]
async fn test_analog_config_persistence() {
use std::io::Write;
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("device_profiles_analog.yaml");
let yaml_content = r#"
devices:
"1532:0220":
match_pattern: "1532:0220"
profiles:
gaming:
name: "Gaming Profile"
remaps:
capslock: leftctrl
analog_config:
deadzone_percentage: 50
sensitivity: 1.5
response_curve: "exponential"
"32b6:12f7":
profiles:
default:
name: "Default"
remaps: {}
analog_config:
deadzone_percentage: 43
sensitivity: 1.0
response_curve: "linear"
"#;
let mut file = std::fs::File::create(&device_profiles_path).unwrap();
file.write_all(yaml_content.as_bytes()).unwrap();
let manager = ConfigManager {
config_path: temp_dir.path().join("config.yaml"),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path,
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let result = manager.load_analog_configs().await;
assert!(result.is_ok());
let configs = result.unwrap();
assert_eq!(configs.len(), 2);
assert!(configs.contains_key("1532:0220"));
let config1 = &configs["1532:0220"];
assert_eq!(config1.deadzone_percentage, 50);
assert_eq!(config1.sensitivity, 1.5);
assert_eq!(config1.response_curve, "exponential");
assert!(configs.contains_key("32b6:12f7"));
let config2 = &configs["32b6:12f7"];
assert_eq!(config2.deadzone_percentage, 43);
assert_eq!(config2.sensitivity, 1.0);
assert_eq!(config2.response_curve, "linear");
}
#[tokio::test]
async fn test_analog_config_default_values() {
use std::io::Write;
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("device_profiles_analog_defaults.yaml");
let yaml_content = r#"
devices:
"1532:0220":
profiles:
default:
name: "Default"
remaps: {}
analog_config:
response_curve: "exponential(2.5)"
"#;
let mut file = std::fs::File::create(&device_profiles_path).unwrap();
file.write_all(yaml_content.as_bytes()).unwrap();
let manager = ConfigManager {
config_path: temp_dir.path().join("config.yaml"),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path,
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let result = manager.load_analog_configs().await;
assert!(result.is_ok());
let configs = result.unwrap();
assert_eq!(configs.len(), 1);
let config = &configs["1532:0220"];
assert_eq!(config.deadzone_percentage, 43); assert_eq!(config.sensitivity, 1.0); assert_eq!(config.response_curve, "exponential(2.5)"); }
#[tokio::test]
async fn test_analog_calibration_loading() {
use std::io::Write;
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("test_analog_calibration.yaml");
let yaml = r#"
devices:
"32b6:12f7":
analog_calibration:
0:
deadzone: 0.15
sensitivity: linear
1:
deadzone: 0.10
sensitivity: quadratic
"#;
fs::write(&device_profiles_path, yaml).await.unwrap();
let manager = ConfigManager {
config_path: temp_dir.path().join("config.yaml"),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path,
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let cal0 = manager.get_analog_calibration("32b6:12f7", 0).await;
assert!(cal0.is_some());
assert_eq!(cal0.unwrap().deadzone, 0.15);
let cal1 = manager.get_analog_calibration("32b6:12f7", 1).await;
assert!(cal1.is_some());
assert_eq!(cal1.unwrap().deadzone, 0.10);
let cal2 = manager.get_analog_calibration("32b6:12f7", 2).await;
assert!(cal2.is_none());
}
#[tokio::test]
async fn test_get_all_analog_calibrations() {
use std::io::Write;
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("test_all_analog_calibrations.yaml");
let yaml = r#"
devices:
"32b6:12f7":
analog_calibration:
0:
deadzone: 0.15
sensitivity_multiplier: 1.0
1:
deadzone: 0.10
sensitivity_multiplier: 1.5
2:
deadzone: 0.20
sensitivity_multiplier: 0.8
"#;
fs::write(&device_profiles_path, yaml).await.unwrap();
let manager = ConfigManager {
config_path: temp_dir.path().join("config.yaml"),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path,
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let calibrations = manager.get_all_analog_calibrations("32b6:12f7").await;
assert_eq!(calibrations.len(), 3);
assert_eq!(calibrations.get(&0).unwrap().deadzone, 0.15);
assert_eq!(calibrations.get(&1).unwrap().deadzone, 0.10);
assert_eq!(calibrations.get(&2).unwrap().deadzone, 0.20);
let empty = manager.get_all_analog_calibrations("nonexistent").await;
assert!(empty.is_empty());
}
#[tokio::test]
async fn test_add_hotkey_binding_persists_to_yaml() {
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("test_profiles.yaml");
let config_path = temp_dir.path().join("test_config.yaml");
let config = ConfigManager {
config_path: config_path.clone(),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path: device_profiles_path.clone(),
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let binding = HotkeyBinding {
modifiers: vec!["ctrl".to_string(), "shift".to_string()],
key: "1".to_string(),
profile_name: "gaming".to_string(),
device_id: Some("32b6:12f7".to_string()),
layer_id: Some(1),
};
config.add_hotkey_binding("32b6:12f7", binding).await.unwrap();
let content = fs::read_to_string(&device_profiles_path).await.unwrap();
assert!(content.contains("hotkey_bindings"));
assert!(content.contains("ctrl"));
assert!(content.contains("profile_name: gaming"));
let loaded = config.get_hotkey_bindings("32b6:12f7").await.unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].key, "1");
}
#[tokio::test]
async fn test_add_hotkey_binding_rejects_duplicate() {
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("test_profiles.yaml");
let config_path = temp_dir.path().join("test_config.yaml");
let config = ConfigManager {
config_path: config_path.clone(),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path: device_profiles_path.clone(),
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let binding = HotkeyBinding {
modifiers: vec!["ctrl".to_string()],
key: "1".to_string(),
profile_name: "gaming".to_string(),
device_id: Some("32b6:12f7".to_string()),
layer_id: None,
};
config.add_hotkey_binding("32b6:12f7", binding.clone()).await.unwrap();
let result = config.add_hotkey_binding("32b6:12f7", binding).await;
assert!(result.is_err());
match result.unwrap_err() {
RemapConfigError::Validation { field, message } => {
assert_eq!(field, "hotkey");
assert!(message.contains("already exists"));
}
_ => panic!("Expected Validation error"),
}
}
#[tokio::test]
async fn test_remove_hotkey_binding() {
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("test_profiles.yaml");
let config_path = temp_dir.path().join("test_config.yaml");
let config = ConfigManager {
config_path: config_path.clone(),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path: device_profiles_path.clone(),
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let binding1 = HotkeyBinding {
modifiers: vec!["ctrl".to_string()],
key: "1".to_string(),
profile_name: "gaming".to_string(),
device_id: Some("32b6:12f7".to_string()),
layer_id: None,
};
let binding2 = HotkeyBinding {
modifiers: vec!["alt".to_string()],
key: "2".to_string(),
profile_name: "work".to_string(),
device_id: Some("32b6:12f7".to_string()),
layer_id: None,
};
config.add_hotkey_binding("32b6:12f7", binding1).await.unwrap();
config.add_hotkey_binding("32b6:12f7", binding2).await.unwrap();
let bindings = config.get_hotkey_bindings("32b6:12f7").await.unwrap();
assert_eq!(bindings.len(), 2);
config.remove_hotkey_binding("32b6:12f7", "1", &["ctrl".to_string()]).await.unwrap();
let bindings = config.get_hotkey_bindings("32b6:12f7").await.unwrap();
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].key, "2");
}
#[tokio::test]
async fn test_get_hotkey_bindings_returns_empty_for_missing_device() {
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("test_profiles.yaml");
let config_path = temp_dir.path().join("test_config.yaml");
let config = ConfigManager {
config_path: config_path.clone(),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path: device_profiles_path.clone(),
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let bindings = config.get_hotkey_bindings("nonexistent").await.unwrap();
assert_eq!(bindings.len(), 0);
}
#[tokio::test]
async fn test_set_get_auto_switch_rules() {
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("test_profiles.yaml");
let config_path = temp_dir.path().join("test_config.yaml");
let config = ConfigManager {
config_path: config_path.clone(),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path: device_profiles_path.clone(),
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let rules = vec![
AutoSwitchRule {
app_id: "org.alacritty".to_string(),
profile_name: "terminal".to_string(),
device_id: Some("32b6:12f7".to_string()),
layer_id: Some(0),
},
AutoSwitchRule {
app_id: "*".to_string(),
profile_name: "default".to_string(),
device_id: None,
layer_id: None,
},
];
config.set_auto_switch_rules(rules.clone()).await.unwrap();
let loaded = config.get_auto_switch_rules().await;
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].app_id, "org.alacritty");
assert_eq!(loaded[1].app_id, "*");
let content = fs::read_to_string(&config_path).await.unwrap();
assert!(content.contains("auto_switch_rules"));
assert!(content.contains("org.alacritty"));
}
#[tokio::test]
async fn test_get_all_hotkey_bindings_aggregates_devices() {
let temp_dir = TempDir::new().unwrap();
let device_profiles_path = temp_dir.path().join("test_profiles.yaml");
let config_path = temp_dir.path().join("test_config.yaml");
let config = ConfigManager {
config_path: config_path.clone(),
macros_path: temp_dir.path().join("macros.yaml"),
cache_path: temp_dir.path().join("macros.bin"),
profiles_dir: temp_dir.path().join("profiles"),
remaps_path: temp_dir.path().join("remaps.yaml"),
device_profiles_path: device_profiles_path.clone(),
layer_state_path: temp_dir.path().join("layer_state.yaml"),
config: Arc::new(RwLock::new(DaemonConfig::default())),
macros: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())),
remaps: Arc::new(RwLock::new(HashMap::new())),
device_profiles: Arc::new(RwLock::new(HashMap::new())),
};
let binding1 = HotkeyBinding {
modifiers: vec!["ctrl".to_string()],
key: "1".to_string(),
profile_name: "gaming".to_string(),
device_id: Some("32b6:12f7".to_string()),
layer_id: None,
};
let binding2 = HotkeyBinding {
modifiers: vec!["alt".to_string()],
key: "2".to_string(),
profile_name: "work".to_string(),
device_id: Some("1532:0220".to_string()),
layer_id: None,
};
config.add_hotkey_binding("32b6:12f7", binding1).await.unwrap();
config.add_hotkey_binding("1532:0220", binding2).await.unwrap();
let all = config.get_all_hotkey_bindings().await;
assert_eq!(all.len(), 2);
}
}