From 016c0d5dbbef39a0128a8015b5e50e75b2474c8f Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Fri, 20 Feb 2026 15:36:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(atx):=20=E5=AE=8C=E5=96=84=E4=B8=B2?= =?UTF-8?q?=E5=8F=A3=E7=BB=A7=E7=94=B5=E5=99=A8=E9=85=8D=E7=BD=AE=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E=E5=89=8D=E7=AB=AF=E9=98=B2=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/atx/executor.rs | 106 ++++++++++++++-- src/web/handlers/config/atx.rs | 83 +++++++++++-- src/web/handlers/config/types.rs | 205 ++++++++++++++++++++++++++++++- web/src/i18n/en-US.ts | 4 +- web/src/i18n/zh-CN.ts | 4 +- web/src/views/SettingsView.vue | 148 ++++++++++++++++++---- 6 files changed, 501 insertions(+), 49 deletions(-) diff --git a/src/atx/executor.rs b/src/atx/executor.rs index d45af70d..14f98aa6 100644 --- a/src/atx/executor.rs +++ b/src/atx/executor.rs @@ -73,6 +73,8 @@ impl AtxKeyExecutor { return Ok(()); } + self.validate_runtime_config()?; + match self.config.driver { AtxDriverType::Gpio => self.init_gpio().await?, AtxDriverType::UsbRelay => self.init_usb_relay().await?, @@ -84,6 +86,39 @@ impl AtxKeyExecutor { Ok(()) } + fn validate_runtime_config(&self) -> Result<()> { + match self.config.driver { + AtxDriverType::Serial => { + if self.config.pin == 0 { + return Err(AppError::Config( + "Serial ATX channel must be 1-based (>= 1)".to_string(), + )); + } + if self.config.pin > u8::MAX as u32 { + return Err(AppError::Config(format!( + "Serial ATX channel must be <= {}", + u8::MAX + ))); + } + if self.config.baud_rate == 0 { + return Err(AppError::Config( + "Serial ATX baud_rate must be greater than 0".to_string(), + )); + } + } + AtxDriverType::UsbRelay => { + if self.config.pin > u8::MAX as u32 { + return Err(AppError::Config(format!( + "USB relay channel must be <= {}", + u8::MAX + ))); + } + } + AtxDriverType::Gpio | AtxDriverType::None => {} + } + Ok(()) + } + /// Initialize GPIO backend async fn init_gpio(&mut self) -> Result<()> { info!( @@ -146,11 +181,7 @@ impl AtxKeyExecutor { self.config.device, self.config.pin ); - let baud_rate = if self.config.baud_rate > 0 { - self.config.baud_rate - } else { - 9600 - }; + let baud_rate = self.config.baud_rate; let port = serialport::new(&self.config.device, baud_rate) .timeout(Duration::from_millis(100)) @@ -235,7 +266,13 @@ impl AtxKeyExecutor { /// Send USB relay command using cached handle fn send_usb_relay_command(&self, on: bool) -> Result<()> { - let channel = self.config.pin as u8; + let channel = u8::try_from(self.config.pin).map_err(|_| { + AppError::Config(format!( + "USB relay channel {} exceeds max {}", + self.config.pin, + u8::MAX + )) + })?; // Standard HID relay command format let cmd = if on { @@ -273,9 +310,18 @@ impl AtxKeyExecutor { /// Send Serial relay command using cached handle fn send_serial_relay_command(&self, on: bool) -> Result<()> { - // user config pin should be 1 for most LCUS modules (A0 01 01 A2) - // if user set 0, it will send A0 00 01 A1 which might not work - let channel = self.config.pin as u8; + let channel = u8::try_from(self.config.pin).map_err(|_| { + AppError::Config(format!( + "Serial relay channel {} exceeds max {}", + self.config.pin, + u8::MAX + )) + })?; + if channel == 0 { + return Err(AppError::Config( + "Serial relay channel must be 1-based (>= 1)".to_string(), + )); + } // LCUS-Type Protocol // Frame: [StopByte(A0), Channel, State, Checksum] @@ -408,4 +454,46 @@ mod tests { assert_eq!(timing::LONG_PRESS.as_millis(), 5000); assert_eq!(timing::RESET_PRESS.as_millis(), 500); } + + #[tokio::test] + async fn test_executor_init_rejects_serial_channel_zero() { + let config = AtxKeyConfig { + driver: AtxDriverType::Serial, + device: "/dev/ttyUSB0".to_string(), + pin: 0, + active_level: ActiveLevel::High, + baud_rate: 9600, + }; + let mut executor = AtxKeyExecutor::new(config); + let err = executor.init().await.unwrap_err(); + assert!(matches!(err, AppError::Config(_))); + } + + #[tokio::test] + async fn test_executor_init_rejects_serial_channel_overflow() { + let config = AtxKeyConfig { + driver: AtxDriverType::Serial, + device: "/dev/ttyUSB0".to_string(), + pin: 256, + active_level: ActiveLevel::High, + baud_rate: 9600, + }; + let mut executor = AtxKeyExecutor::new(config); + let err = executor.init().await.unwrap_err(); + assert!(matches!(err, AppError::Config(_))); + } + + #[tokio::test] + async fn test_executor_init_rejects_zero_serial_baud_rate() { + let config = AtxKeyConfig { + driver: AtxDriverType::Serial, + device: "/dev/ttyUSB0".to_string(), + pin: 1, + active_level: ActiveLevel::High, + baud_rate: 0, + }; + let mut executor = AtxKeyExecutor::new(config); + let err = executor.init().await.unwrap_err(); + assert!(matches!(err, AppError::Config(_))); + } } diff --git a/src/web/handlers/config/atx.rs b/src/web/handlers/config/atx.rs index d0c5f2bf..f1bc7c55 100644 --- a/src/web/handlers/config/atx.rs +++ b/src/web/handlers/config/atx.rs @@ -1,32 +1,39 @@ -//! ATX 配置 Handler +//! ATX configuration handlers use axum::{extract::State, Json}; use std::sync::Arc; -use crate::config::AtxConfig; -use crate::error::Result; +use crate::atx::AtxDriverType; +use crate::config::{AtxConfig, HidBackend, HidConfig}; +use crate::error::{AppError, Result}; use crate::state::AppState; use super::apply::apply_atx_config; use super::types::AtxConfigUpdate; -/// 获取 ATX 配置 +/// Get ATX configuration pub async fn get_atx_config(State(state): State>) -> Json { Json(state.config.get().atx.clone()) } -/// 更新 ATX 配置 +/// Update ATX configuration pub async fn update_atx_config( State(state): State>, Json(req): Json, ) -> Result> { - // 1. 验证请求 - req.validate()?; + // 1. Read current configuration snapshot + let current_config = state.config.get(); + let old_atx_config = current_config.atx.clone(); - // 2. 获取旧配置 - let old_atx_config = state.config.get().atx.clone(); + // 2. Validate request, including merged effective serial parameter checks + req.validate_with_current(&old_atx_config)?; - // 3. 应用更新到配置存储 + // 3. Ensure ATX serial devices do not conflict with HID CH9329 serial device + let mut merged_atx_config = old_atx_config.clone(); + req.apply_to(&mut merged_atx_config); + validate_serial_device_conflict(&merged_atx_config, ¤t_config.hid)?; + + // 4. Persist update into config store state .config .update(|config| { @@ -34,13 +41,65 @@ pub async fn update_atx_config( }) .await?; - // 4. 获取新配置 + // 5. Load new config let new_atx_config = state.config.get().atx.clone(); - // 5. 应用到子系统(热重载) + // 6. Apply to subsystem (hot reload) if let Err(e) = apply_atx_config(&state, &old_atx_config, &new_atx_config).await { tracing::error!("Failed to apply ATX config: {}", e); } Ok(Json(new_atx_config)) } + +fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<()> { + if hid.backend != HidBackend::Ch9329 { + return Ok(()); + } + let reserved = hid.ch9329_port.trim(); + if reserved.is_empty() { + return Ok(()); + } + + for (name, key) in [("power", &atx.power), ("reset", &atx.reset)] { + if key.driver == AtxDriverType::Serial && key.device.trim() == reserved { + return Err(AppError::BadRequest(format!( + "ATX {} serial device '{}' conflicts with HID CH9329 serial device", + name, reserved + ))); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_serial_device_conflict_rejects_ch9329_overlap() { + let mut atx = AtxConfig::default(); + atx.power.driver = AtxDriverType::Serial; + atx.power.device = "/dev/ttyUSB0".to_string(); + + let mut hid = HidConfig::default(); + hid.backend = HidBackend::Ch9329; + hid.ch9329_port = "/dev/ttyUSB0".to_string(); + + assert!(validate_serial_device_conflict(&atx, &hid).is_err()); + } + + #[test] + fn test_validate_serial_device_conflict_allows_non_ch9329_backend() { + let mut atx = AtxConfig::default(); + atx.power.driver = AtxDriverType::Serial; + atx.power.device = "/dev/ttyUSB0".to_string(); + + let mut hid = HidConfig::default(); + hid.backend = HidBackend::None; + hid.ch9329_port = "/dev/ttyUSB0".to_string(); + + assert!(validate_serial_device_conflict(&atx, &hid).is_ok()); + } +} diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index cffef3a1..d94cecc7 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -93,21 +93,21 @@ impl VideoConfigUpdate { // ===== Stream Config ===== -/// Stream 配置响应(包含 has_turn_password 字段) +/// Stream configuration response (includes has_turn_password) #[typeshare] #[derive(Debug, serde::Serialize)] pub struct StreamConfigResponse { pub mode: StreamMode, pub encoder: EncoderType, pub bitrate_preset: BitratePreset, - /// 是否有公共 ICE 服务器可用(编译时确定) + /// Whether public ICE servers are available (compile-time decision) pub has_public_ice_servers: bool, - /// 当前是否正在使用公共 ICE 服务器(STUN/TURN 都为空时) + /// Whether public ICE servers are currently in use (when STUN/TURN are unset) pub using_public_ice_servers: bool, pub stun_server: Option, pub turn_server: Option, pub turn_username: Option, - /// 指示是否已设置 TURN 密码(实际密码不返回) + /// Indicates whether TURN password has been configured (password is not returned) pub has_turn_password: bool, } @@ -397,6 +397,7 @@ impl MsdConfigUpdate { pub struct AtxKeyConfigUpdate { pub driver: Option, pub device: Option, + pub baud_rate: Option, pub pin: Option, pub active_level: Option, } @@ -439,6 +440,37 @@ impl AtxConfigUpdate { Ok(()) } + pub fn validate_with_current(&self, current: &AtxConfig) -> crate::error::Result<()> { + self.validate()?; + + // Validate with full context after applying the patch payload. + let mut merged = current.clone(); + self.apply_to(&mut merged); + + Self::validate_effective_key_config(&merged.power, "power")?; + Self::validate_effective_key_config(&merged.reset, "reset")?; + Self::validate_shared_serial_baud_rate(&merged)?; + Ok(()) + } + + fn validate_shared_serial_baud_rate(config: &AtxConfig) -> crate::error::Result<()> { + let power = &config.power; + let reset = &config.reset; + let same_serial_device = power.driver == crate::atx::AtxDriverType::Serial + && reset.driver == crate::atx::AtxDriverType::Serial + && !power.device.trim().is_empty() + && power.device.trim() == reset.device.trim(); + + if same_serial_device && power.baud_rate != reset.baud_rate { + return Err(AppError::BadRequest( + "ATX power/reset sharing the same serial relay device must use one baud_rate" + .to_string(), + )); + } + + Ok(()) + } + fn validate_key_config(key: &AtxKeyConfigUpdate, name: &str) -> crate::error::Result<()> { if let Some(ref device) = key.device { if !device.is_empty() && !std::path::Path::new(device).exists() { @@ -448,6 +480,88 @@ impl AtxConfigUpdate { ))); } } + if let Some(baud_rate) = key.baud_rate { + if baud_rate == 0 { + return Err(AppError::BadRequest(format!( + "{} baud_rate must be greater than 0", + name + ))); + } + } + + if let Some(driver) = key.driver { + match driver { + crate::atx::AtxDriverType::Serial => { + if let Some(pin) = key.pin { + if pin == 0 { + return Err(AppError::BadRequest(format!( + "{} serial channel must be 1-based (>= 1)", + name + ))); + } + if pin > u8::MAX as u32 { + return Err(AppError::BadRequest(format!( + "{} serial channel must be <= {}", + name, + u8::MAX + ))); + } + } + } + crate::atx::AtxDriverType::UsbRelay => { + if let Some(pin) = key.pin { + if pin > u8::MAX as u32 { + return Err(AppError::BadRequest(format!( + "{} USB relay channel must be <= {}", + name, + u8::MAX + ))); + } + } + } + crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {} + } + } + Ok(()) + } + + fn validate_effective_key_config( + key: &crate::atx::AtxKeyConfig, + name: &str, + ) -> crate::error::Result<()> { + match key.driver { + crate::atx::AtxDriverType::Serial => { + if key.pin == 0 { + return Err(AppError::BadRequest(format!( + "{} serial channel must be 1-based (>= 1)", + name + ))); + } + if key.pin > u8::MAX as u32 { + return Err(AppError::BadRequest(format!( + "{} serial channel must be <= {}", + name, + u8::MAX + ))); + } + if key.baud_rate == 0 { + return Err(AppError::BadRequest(format!( + "{} baud_rate must be greater than 0", + name + ))); + } + } + crate::atx::AtxDriverType::UsbRelay => { + if key.pin > u8::MAX as u32 { + return Err(AppError::BadRequest(format!( + "{} USB relay channel must be <= {}", + name, + u8::MAX + ))); + } + } + crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {} + } Ok(()) } @@ -476,6 +590,9 @@ impl AtxConfigUpdate { if let Some(ref device) = update.device { config.device = device.clone(); } + if let Some(baud_rate) = update.baud_rate { + config.baud_rate = baud_rate; + } if let Some(pin) = update.pin { config.pin = pin; } @@ -784,3 +901,83 @@ impl WebConfigUpdate { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_atx_apply_key_update_applies_baud_rate() { + let mut config = AtxConfig::default(); + let update = AtxConfigUpdate { + enabled: None, + power: Some(AtxKeyConfigUpdate { + driver: Some(crate::atx::AtxDriverType::Serial), + device: Some("/dev/null".to_string()), + baud_rate: Some(19200), + pin: Some(1), + active_level: None, + }), + reset: None, + led: None, + wol_interface: None, + }; + + update.apply_to(&mut config); + assert_eq!(config.power.baud_rate, 19200); + } + + #[test] + fn test_atx_validate_with_current_rejects_serial_pin_zero() { + let mut current = AtxConfig::default(); + current.power.driver = crate::atx::AtxDriverType::Serial; + current.power.device = "/dev/null".to_string(); + current.power.pin = 1; + current.power.baud_rate = 9600; + + let update = AtxConfigUpdate { + enabled: None, + power: Some(AtxKeyConfigUpdate { + driver: None, + device: None, + baud_rate: None, + pin: Some(0), + active_level: None, + }), + reset: None, + led: None, + wol_interface: None, + }; + + assert!(update.validate_with_current(¤t).is_err()); + } + + #[test] + fn test_atx_validate_with_current_rejects_shared_serial_baud_mismatch() { + let mut current = AtxConfig::default(); + current.power.driver = crate::atx::AtxDriverType::Serial; + current.power.device = "/dev/ttyUSB0".to_string(); + current.power.pin = 1; + current.power.baud_rate = 9600; + current.reset.driver = crate::atx::AtxDriverType::Serial; + current.reset.device = "/dev/ttyUSB0".to_string(); + current.reset.pin = 2; + current.reset.baud_rate = 9600; + + let update = AtxConfigUpdate { + enabled: None, + power: None, + reset: Some(AtxKeyConfigUpdate { + driver: None, + device: None, + baud_rate: Some(115200), + pin: None, + active_level: None, + }), + led: None, + wol_interface: None, + }; + + assert!(update.validate_with_current(¤t).is_err()); + } +} diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 35469587..40d7b092 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -584,10 +584,12 @@ export default { atxDriver: 'Driver Type', atxDriverNone: 'Disabled', atxDriverGpio: 'GPIO', - atxDriverUsbRelay: 'USB Relay', + atxDriverUsbRelay: 'USB LCUS HID Relay', + atxDriverSerial: 'USB LCUS Serial Relay', atxDevice: 'Device', atxPin: 'GPIO Pin', atxChannel: 'Relay Channel', + atxSharedSerialBaudHint: 'When Power and Reset share one serial relay device, baud rate is controlled by the first config', atxActiveLevel: 'Active Level', atxLevelHigh: 'Active High', atxLevelLow: 'Active Low', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 8b4e3c78..90188b08 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -584,10 +584,12 @@ export default { atxDriver: '驱动类型', atxDriverNone: '禁用', atxDriverGpio: 'GPIO', - atxDriverUsbRelay: 'USB 继电器', + atxDriverUsbRelay: 'USB LCUS HID继电器', + atxDriverSerial: 'USB LCUS 串口继电器', atxDevice: '设备', atxPin: 'GPIO 引脚', atxChannel: '继电器通道', + atxSharedSerialBaudHint: 'Power 与 Reset 使用同一串口继电器时,波特率由第一个配置统一控制', atxActiveLevel: '有效电平', atxLevelHigh: '高电平有效', atxLevelLow: '低电平有效', diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index b59a13fe..34f663c2 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -321,7 +321,7 @@ const config = ref({ turn_password: '', }) -// 跟踪服务器是否已配置 TURN 密码 +// Tracks whether TURN password is configured on the server const hasTurnPassword = ref(false) const configLoaded = ref(false) const devicesLoaded = ref(false) @@ -623,6 +623,22 @@ const atxDevices = ref({ serial_ports: [], }) +const ch9329ReservedSerialDevice = computed(() => { + if (config.value.hid_backend !== 'ch9329') return '' + return config.value.hid_serial_device.trim() +}) + +const isSharedAtxSerialRelay = computed(() => { + const power = atxConfig.value.power + const reset = atxConfig.value.reset + return ( + power.driver === 'serial' + && reset.driver === 'serial' + && !!power.device.trim() + && power.device === reset.device + ) +}) + // Encoder backend const availableBackends = ref([]) @@ -816,16 +832,16 @@ async function changePassword() { } } -// Save config - 使用域分离 API +// Save config using domain-separated APIs async function saveConfig() { loading.value = true saved.value = false try { - // 根据当前激活的 section 只保存相关配置 + // Save only config related to the active section const savePromises: Promise[] = [] - // Video 配置(包括编码器和 WebRTC/STUN/TURN 设置) + // Video config (including encoder and WebRTC/STUN/TURN settings) if (activeSection.value === 'video') { savePromises.push( configStore.updateVideo({ @@ -836,7 +852,7 @@ async function saveConfig() { fps: config.value.video_fps, }) ) - // 同时保存 Stream/Encoder 和 STUN/TURN 配置 + // Save Stream/Encoder and STUN/TURN config together savePromises.push( configStore.updateStream({ encoder: config.value.encoder_backend as any, @@ -848,7 +864,7 @@ async function saveConfig() { ) } - // HID 配置 + // HID config if (activeSection.value === 'hid') { if (!isHidFunctionSelectionValid.value) { return @@ -875,7 +891,7 @@ async function saveConfig() { ch9329_port: config.value.hid_serial_device || undefined, ch9329_baudrate: config.value.hid_serial_baudrate, } - // 如果是 OTG 后端,添加描述符配置 + // Add descriptor config for OTG backend if (config.value.hid_backend === 'otg') { hidUpdate.otg_descriptor = { vendor_id: parseInt(otgVendorIdHex.value, 16) || 0x1d6b, @@ -898,7 +914,7 @@ async function saveConfig() { ) } - // MSD 配置 + // MSD config if (activeSection.value === 'msd') { savePromises.push( configStore.updateMsd({ @@ -917,10 +933,10 @@ async function saveConfig() { } } -// Load config - 使用域分离 API +// Load config using domain-separated APIs async function loadConfig() { try { - // 并行加载所有域配置 + // Load all domain configs in parallel const [video, stream, hid, msd] = await Promise.all([ configStore.refreshVideo(), configStore.refreshStream(), @@ -952,13 +968,13 @@ async function loadConfig() { stun_server: stream.stun_server || '', turn_server: stream.turn_server || '', turn_username: stream.turn_username || '', - turn_password: '', // 密码不从服务器返回,仅用于设置 + turn_password: '', // Password is never returned from server; set-only field } - // 设置是否已配置 TURN 密码 + // Track whether TURN password is configured hasTurnPassword.value = stream.has_turn_password || false - // 加载 OTG 描述符配置 + // Load OTG descriptor config if (hid.otg_descriptor) { otgVendorIdHex.value = hid.otg_descriptor.vendor_id?.toString(16).padStart(4, '0') || '1d6b' otgProductIdHex.value = hid.otg_descriptor.product_id?.toString(16).padStart(4, '0') || '0104' @@ -1154,6 +1170,8 @@ async function loadAtxConfig() { led: { ...config.led }, wol_interface: config.wol_interface || '', } + clearAtxSerialDeviceConflicts() + syncSharedAtxSerialBaudRate() } catch (e) { console.error('Failed to load ATX config:', e) } @@ -1171,6 +1189,7 @@ async function saveAtxConfig() { loading.value = true saved.value = false try { + syncSharedAtxSerialBaudRate() await configStore.updateAtx({ enabled: atxConfig.value.enabled, power: { @@ -1185,7 +1204,9 @@ async function saveAtxConfig() { device: atxConfig.value.reset.device || undefined, pin: atxConfig.value.reset.pin, active_level: atxConfig.value.reset.active_level, - baud_rate: atxConfig.value.reset.baud_rate, + baud_rate: isSharedAtxSerialRelay.value + ? atxConfig.value.power.baud_rate + : atxConfig.value.reset.baud_rate, }, led: { enabled: atxConfig.value.led.enabled, @@ -1215,6 +1236,55 @@ function getAtxDevicesForDriver(driver: string): string[] { return [] } +function isAtxSerialDeviceReserved(device: string): boolean { + const reserved = ch9329ReservedSerialDevice.value + return !!reserved && device === reserved +} + +function formatAtxDeviceLabel(driver: string, device: string): string { + if (driver === 'serial' && isAtxSerialDeviceReserved(device)) { + return `${device} (CH9329 in use)` + } + return device +} + +function clearAtxSerialDeviceConflicts() { + const reserved = ch9329ReservedSerialDevice.value + if (!reserved) return + + if (atxConfig.value.power.driver === 'serial' && atxConfig.value.power.device === reserved) { + atxConfig.value.power.device = '' + } + if (atxConfig.value.reset.driver === 'serial' && atxConfig.value.reset.device === reserved) { + atxConfig.value.reset.device = '' + } +} + +function syncSharedAtxSerialBaudRate() { + if (!isSharedAtxSerialRelay.value) return + atxConfig.value.reset.baud_rate = atxConfig.value.power.baud_rate +} + +watch( + () => [config.value.hid_backend, config.value.hid_serial_device], + () => { + clearAtxSerialDeviceConflicts() + }, +) + +watch( + () => [ + atxConfig.value.power.driver, + atxConfig.value.power.device, + atxConfig.value.power.baud_rate, + atxConfig.value.reset.driver, + atxConfig.value.reset.device, + ], + () => { + syncSharedAtxSerialBaudRate() + }, +) + // RustDesk management functions async function loadRustdeskConfig() { rustdeskLoading.value = true @@ -2482,21 +2552,34 @@ watch(() => config.value.hid_backend, async () => { - +
- +
@@ -2533,21 +2616,34 @@ watch(() => config.value.hid_backend, async () => { - +
- +
@@ -2558,13 +2654,21 @@ watch(() => config.value.hid_backend, async () => {
- +

+ {{ t('settings.atxSharedSerialBaudHint') }} +

@@ -3174,7 +3278,7 @@ watch(() => config.value.hid_backend, async () => { - +