From f303a8cb2f3aabff782efee63f3b4375c78631ef Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Mon, 26 Oct 2026 14:33:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(hid):=20=E6=94=AF=E6=8C=81=20CH9329=20+=20?= =?UTF-8?q?OTG=20=E7=BB=84=E5=90=88=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CH9329 负责键鼠,OTG 提供虚拟媒体、USB 网络和音频;分别配置设备、报告运行状态,并在 OTG 配置变更和回滚时保留未变化的 CH9329 实例。 补齐双设备校验、串口冲突检查、初始化引导和功能入口,修复组合模式下鼠标兼容选项缺失。 验证:独立功能版本的 591 项 Rust 库测试、前端生产构建、3 项引导逻辑测试及 2 组浏览器回归通过。未进行目标 KVM 硬件验证。 --- src/config/schema/hid.rs | 127 ++++++++++++++++++- src/config/schema/mod.rs | 2 +- src/hid/backend.rs | 21 ++++ src/hid/mod.rs | 54 ++++++++ src/otg/self_check.rs | 40 +++--- src/otg/service.rs | 75 ++++++++++- src/platform/linux.rs | 2 +- src/runtime/builder.rs | 20 +-- src/runtime/usb.rs | 141 +++++++++++++-------- src/web/handlers/config/atx.rs | 12 +- src/web/handlers/config/otg.rs | 8 +- src/web/handlers/config/types.rs | 1 + src/web/handlers/config/usb_update.rs | 44 ++++++- src/web/handlers/hid_api.rs | 15 +++ src/web/handlers/setup.rs | 17 +++ web/src/api/index.ts | 4 +- web/src/components/ActionBar.vue | 6 +- web/src/components/HidConfigPopover.vue | 3 +- web/src/components/HidDeviceOverview.vue | 12 +- web/src/components/HidDriverDialog.vue | 6 +- web/src/components/HidDriverForm.vue | 152 ++++++++++++++--------- web/src/components/HidWiringDiagram.vue | 22 +++- web/src/composables/useHidConnection.ts | 5 +- web/src/i18n/en-US.ts | 14 ++- web/src/i18n/zh-CN.ts | 14 ++- web/src/lib/hidGuide.ts | 33 ++++- web/src/types/generated.ts | 12 ++ web/src/views/ConsoleView.vue | 2 +- web/src/views/SettingsView.vue | 74 +++++------ web/src/views/SetupView.vue | 5 +- 30 files changed, 718 insertions(+), 225 deletions(-) diff --git a/src/config/schema/hid.rs b/src/config/schema/hid.rs index 7ecd514d..69e2bc9c 100644 --- a/src/config/schema/hid.rs +++ b/src/config/schema/hid.rs @@ -56,11 +56,37 @@ impl BluetoothHidConfig { pub enum HidBackend { Otg, Ch9329, + #[serde(rename = "ch9329_otg")] + Ch9329Otg, Bluetooth, #[default] None, } +impl HidBackend { + pub fn uses_ch9329(&self) -> bool { + matches!(self, Self::Ch9329 | Self::Ch9329Otg) + } + + pub fn uses_otg_hid(&self) -> bool { + matches!(self, Self::Otg) + } + + pub fn supports_otg_functions(&self) -> bool { + matches!(self, Self::Otg | Self::Ch9329Otg) + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Otg => "otg", + Self::Ch9329 => "ch9329", + Self::Ch9329Otg => "ch9329_otg", + Self::Bluetooth => "bluetooth", + Self::None => "none", + } + } +} + #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct OtgDescriptorConfig { @@ -141,6 +167,15 @@ pub struct OtgHidFunctions { } impl OtgHidFunctions { + pub fn disabled() -> Self { + Self { + keyboard: false, + mouse_relative: false, + mouse_absolute: false, + consumer: false, + } + } + pub fn full() -> Self { Self { keyboard: true, @@ -264,15 +299,49 @@ impl HidConfig { } pub fn effective_otg_keyboard_leds(&self) -> bool { - self.otg_keyboard_leds && self.effective_otg_functions().keyboard + self.otg_keyboard_leds && self.constrained_otg_functions().keyboard } pub fn constrained_otg_functions(&self) -> OtgHidFunctions { - self.effective_otg_functions() + if self.backend.uses_otg_hid() { + self.effective_otg_functions() + } else { + OtgHidFunctions::disabled() + } + } + + pub fn validate_platform(&self) -> crate::error::Result<()> { + if self.backend == HidBackend::Ch9329Otg && !cfg!(target_os = "linux") { + return Err(crate::error::AppError::BadRequest( + "CH9329 + OTG requires Linux with a USB device controller".into(), + )); + } + Ok(()) + } + + pub fn validate_composite_selection(&self) -> crate::error::Result<()> { + if self.backend != HidBackend::Ch9329Otg { + return Ok(()); + } + if self.ch9329_port.trim().is_empty() + || ![9600, 19200, 38400, 57600, 115200].contains(&self.ch9329_baudrate) + { + return Err(crate::error::AppError::BadRequest( + "CH9329 + OTG requires a serial port and a supported baudrate".into(), + )); + } + if !self.otg_udc.as_ref().is_some_and(|udc| { + !udc.trim().is_empty() && !udc.contains('/') && udc != "." && udc != ".." + }) { + return Err(crate::error::AppError::BadRequest( + "CH9329 + OTG requires an explicit USB device controller".into(), + )); + } + Ok(()) } pub fn validate_otg_functions(&self) -> crate::error::Result<()> { - if self.backend != HidBackend::Otg { + if !self.backend.uses_otg_hid() { return Ok(()); } @@ -293,7 +362,7 @@ impl HidConfig { #[inline] pub fn resolved_otg_udc(&self) -> Option { - if self.backend != HidBackend::Otg { + if !self.backend.supports_otg_functions() { return None; } self.otg_udc @@ -301,6 +370,9 @@ impl HidConfig { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .or_else(|| { + if !self.backend.uses_otg_hid() { + return None; + } #[cfg(unix)] { crate::otg::OtgGadgetManager::find_udc() @@ -316,6 +388,53 @@ impl HidConfig { #[cfg(test)] mod bluetooth_tests { use super::*; + #[test] + fn composite_mode_preserves_usb_functions_and_saved_otg_profile() { + let mut config = crate::config::AppConfig::default(); + config.hid.backend = HidBackend::Ch9329Otg; + config.hid.otg_profile = OtgHidProfile::LegacyKeyboard; + config.hid.otg_keyboard_leds = true; + config.hid.mouse_macos_drag = true; + config.msd.enabled = true; + config.otg_network.enabled = true; + config.uac.enabled = true; + config.enforce_invariants(); + assert!(config.msd.enabled && config.otg_network.enabled && config.uac.enabled); + assert!(config.hid.constrained_otg_functions().is_empty()); + assert!(!config.hid.effective_otg_keyboard_leds()); + assert!(config.hid.validate_otg_functions().is_ok()); + let saved = serde_json::to_value(&config.hid).unwrap(); + assert_eq!(saved["backend"], "ch9329_otg"); + assert_eq!(saved["otg_profile"], "legacy_keyboard"); + config.hid = serde_json::from_value(saved).unwrap(); + config.hid.backend = HidBackend::Otg; + assert!(config.hid.constrained_otg_functions().keyboard); + assert!(config.hid.effective_otg_keyboard_leds()); + config.hid.backend = HidBackend::Ch9329; + config.enforce_invariants(); + assert!(!config.msd.enabled && !config.otg_network.enabled && !config.uac.enabled); + } + + #[test] + fn composite_selection_requires_both_devices_without_auto_udc_fallback() { + let mut config = HidConfig { + backend: HidBackend::Ch9329Otg, + ..Default::default() + }; + assert!(config.validate_composite_selection().is_err()); + assert!(config.resolved_otg_udc().is_none()); + config.otg_udc = Some("test.udc".into()); + assert!(config.validate_composite_selection().is_ok()); + assert_eq!(config.resolved_otg_udc().as_deref(), Some("test.udc")); + for udc in ["", "../other", "/sys/class/udc/test", ".."] { + config.otg_udc = Some(udc.into()); + assert!(config.validate_composite_selection().is_err()); + } + config.otg_udc = Some("test.udc".into()); + config.ch9329_port.clear(); + assert!(config.validate_composite_selection().is_err()); + } + #[test] fn mouse_compatibility_defaults_alias_and_otg_validation() { let defaults: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap(); diff --git a/src/config/schema/mod.rs b/src/config/schema/mod.rs index edaa230b..a1448e2c 100644 --- a/src/config/schema/mod.rs +++ b/src/config/schema/mod.rs @@ -51,7 +51,7 @@ pub struct AppConfig { impl AppConfig { pub fn enforce_invariants(&mut self) { - if self.hid.backend != HidBackend::Otg { + if !self.hid.backend.supports_otg_functions() { self.msd.enabled = false; self.otg_network.enabled = false; self.uac.enabled = false; diff --git a/src/hid/backend.rs b/src/hid/backend.rs index 38604bd2..c112cd65 100644 --- a/src/hid/backend.rs +++ b/src/hid/backend.rs @@ -37,6 +37,27 @@ pub enum HidBackendType { None, } +impl From<&crate::config::HidConfig> for HidBackendType { + fn from(config: &crate::config::HidConfig) -> Self { + use crate::config::HidBackend; + match config.backend { + HidBackend::Otg => Self::Otg { + macos_drag: config.mouse_macos_drag, + }, + HidBackend::Ch9329 | HidBackend::Ch9329Otg => Self::Ch9329 { + port: config.ch9329_port.clone(), + baud_rate: config.ch9329_baudrate, + hybrid_mouse: config.ch9329_hybrid_mouse, + macos_drag: config.mouse_macos_drag, + }, + HidBackend::Bluetooth => Self::Bluetooth { + config: config.bluetooth.clone(), + }, + HidBackend::None => Self::None, + } + } +} + impl HidBackendType { pub fn name_str(&self) -> &str { match self { diff --git a/src/hid/mod.rs b/src/hid/mod.rs index f815a1c9..a29da42a 100644 --- a/src/hid/mod.rs +++ b/src/hid/mod.rs @@ -767,6 +767,60 @@ fn merge_pending_move(pending: &mut Option, event: MouseEvent) { #[cfg(test)] mod queue_tests { use super::*; + #[cfg(unix)] + #[tokio::test] + async fn composite_otg_updates_and_rollback_preserve_hid_instance() { + let mut old = crate::config::AppConfig::default(); + old.hid.backend = crate::config::HidBackend::Ch9329; + old.enforce_invariants(); + let otg = Arc::new(OtgService::new()); + let controller = Arc::new(HidController::new( + HidBackendType::from(&old.hid), + Some(otg.clone()), + )); + let pressed = Arc::new(AtomicBool::new(true)); + let (runtime, _) = tokio::sync::watch::channel(()); + let backend: Arc = Arc::new(TestBackend { + pressed: pressed.clone(), + reset_done: Arc::new(tokio::sync::Notify::new()), + runtime, + }); + *controller.backend.write().await = Some(backend.clone()); + let data = tempfile::tempdir().unwrap(); + let coordinator = crate::runtime::UsbCoordinator::new( + controller.clone(), + otg, + Arc::new(RwLock::new(None)), + Arc::new(RwLock::new(None)), + Arc::new(EventBus::new()), + data.path().into(), + ); + let mut new = old.clone(); + new.hid.backend = crate::config::HidBackend::Ch9329Otg; + new.hid.otg_udc = Some("test.udc".into()); + new.hid.otg_descriptor.product = "Advanced USB".into(); + new.hid.otg_keyboard_leds = true; + coordinator.apply_config(&old, &new).await.unwrap(); + assert!(Arc::ptr_eq( + controller.backend.read().await.as_ref().unwrap(), + &backend + )); + coordinator.apply_config(&new, &old).await.unwrap(); + assert!(Arc::ptr_eq( + controller.backend.read().await.as_ref().unwrap(), + &backend + )); + assert!(pressed.load(Ordering::Acquire)); + // Invalid OTG configuration must not touch the healthy input backend. + new.uac.enabled = true; + new.uac.sample_rate = 8000; + assert!(coordinator.apply_config(&old, &new).await.is_err()); + assert!(Arc::ptr_eq( + controller.backend.read().await.as_ref().unwrap(), + &backend + )); + } + #[tokio::test] async fn screen_resolution_updates_backend_and_retains_valid_dimensions() { #[cfg(unix)] diff --git a/src/otg/self_check.rs b/src/otg/self_check.rs index 8fa10566..73a5e11c 100644 --- a/src/otg/self_check.rs +++ b/src/otg/self_check.rs @@ -149,8 +149,11 @@ fn detect_libcomposite_available(gadget_root: &std::path::Path) -> bool { /// OTG self-check status for troubleshooting USB gadget issues pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse { - let hid_backend_is_otg = matches!(config.hid.backend, crate::config::HidBackend::Otg); - let gadget_expected = hid_backend_is_otg || config.msd.enabled || config.otg_network.enabled; + let hid_backend_is_otg = config.hid.backend.uses_otg_hid(); + let gadget_expected = hid_backend_is_otg + || config.msd.enabled + || config.otg_network.enabled + || config.uac.enabled; let mut checks = Vec::new(); let build_response = |checks: Vec, @@ -173,7 +176,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse { overall_ok: error_count == 0, error_count, warning_count, - hid_backend: format!("{:?}", config.hid.backend).to_lowercase(), + hid_backend: config.hid.backend.as_str().to_string(), selected_udc, bound_udc, udc_state, @@ -415,7 +418,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse { OtgSelfCheckLevel::Warn }, "Check one-kvm gadget presence", - Some("Enable OTG HID, MSD, or USB Ethernet to create the one-kvm gadget"), + Some("Enable OTG HID, MSD, USB Ethernet, or USB audio to create the one-kvm gadget"), Some(one_kvm_path.display().to_string()), ); } @@ -459,7 +462,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse { false, OtgSelfCheckLevel::Warn, "Check one-kvm UDC binding", - Some("Ensure HID/MSD is enabled and initialized successfully"), + Some("Ensure an OTG function is enabled and initialized successfully"), Some(one_kvm_udc_path.display().to_string()), ); } else { @@ -494,18 +497,19 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse { }) .cloned() .collect::>(); - if hid_functions.is_empty() { + let hid_functions_ok = hid_backend_is_otg != hid_functions.is_empty(); + if !hid_functions_ok { push_otg_check( &mut checks, "hid_functions_present", false, - if hid_backend_is_otg { - OtgSelfCheckLevel::Error + OtgSelfCheckLevel::Error, + "Check OTG HID function selection", + Some(if hid_backend_is_otg { + "Check OTG HID config and enable at least one HID function" } else { - OtgSelfCheckLevel::Warn - }, - "Check HID function creation", - Some("Check OTG HID config and enable at least one HID function"), + "OTG HID must be disabled when input is handled by another backend" + }), Some(functions_path.display().to_string()), ); } else { @@ -514,7 +518,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse { "hid_functions_present", true, OtgSelfCheckLevel::Info, - "Check HID function creation", + "Check OTG HID function selection", None::, Some(functions_path.display().to_string()), ); @@ -569,9 +573,13 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse { .map(|name| name.to_string_lossy().to_string()) }) .is_some_and(|name| { - name == "okvm-br0" || name == config.otg_network.bridge_interface + name == "okvm-br0" + || name == config.otg_network.bridge_interface || (config.otg_network.bridge_interface.is_empty() - && std::path::Path::new("/sys/class/net").join(name).join("bridge").is_dir()) + && std::path::Path::new("/sys/class/net") + .join(name) + .join("bridge") + .is_dir()) }); push_otg_check( &mut checks, @@ -843,7 +851,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse { overall_ok: error_count == 0, error_count, warning_count, - hid_backend: format!("{:?}", config.hid.backend).to_lowercase(), + hid_backend: config.hid.backend.as_str().to_string(), selected_udc, bound_udc, udc_state, diff --git a/src/otg/service.rs b/src/otg/service.rs index 29d081c5..9d451c09 100644 --- a/src/otg/service.rs +++ b/src/otg/service.rs @@ -8,8 +8,7 @@ use super::bridge::NetworkBridgeRuntime; use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager}; use super::msd::{MsdFunction, MsdInquiryStrings, MsdLunConfig}; use crate::config::{ - HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig, - UacConfig, + HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig, UacConfig, }; use crate::error::{AppError, Result}; @@ -40,6 +39,17 @@ pub struct OtgNetworkStatus { pub error: Option, } +#[typeshare] +#[derive(Debug, Clone, serde::Serialize)] +pub struct OtgRuntimeStatus { + pub enabled: bool, + pub health: OtgRuntimeHealth, + pub gadget_active: bool, + pub udc: Option, + pub udc_state: Option, + pub error: Option, +} + impl HidDevicePaths { pub fn existing_paths(&self) -> Vec { [ @@ -93,7 +103,7 @@ impl OtgDesiredState { network.validate()?; uac.validate()?; msd.validate()?; - let hid_functions = if hid.backend == HidBackend::Otg { + let hid_functions = if hid.backend.uses_otg_hid() { let functions = hid.constrained_otg_functions(); Some(functions) } else { @@ -107,7 +117,13 @@ impl OtgDesiredState { .as_ref() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) - .or_else(OtgGadgetManager::find_udc) + .or_else(|| { + if hid.backend.uses_ch9329() { + None + } else { + OtgGadgetManager::find_udc() + } + }) } else { None }; @@ -249,6 +265,25 @@ impl OtgService { } } + pub async fn runtime_status(&self, enabled: bool) -> OtgRuntimeStatus { + let state = self.state.read().await; + let udc_state = state.configured_udc.as_ref().and_then(|udc| { + // Only UDC names, never a path supplied through a configuration file. + if udc.contains('/') || udc == "." || udc == ".." { + return None; + } + crate::utils::read_trimmed(&PathBuf::from(format!("/sys/class/udc/{udc}/state"))) + }); + OtgRuntimeStatus { + enabled, + health: state.health, + gadget_active: state.gadget_active, + udc: state.configured_udc.clone(), + udc_state, + error: state.error.clone(), + } + } + pub async fn apply_config( &self, hid: &HidConfig, @@ -687,6 +722,38 @@ impl From<&OtgDescriptorConfig> for GadgetDescriptor { #[cfg(test)] mod tests { use super::*; + use crate::config::HidBackend; + + #[test] + fn composite_gadget_supports_every_advanced_function_combination_without_hid() { + let hid = HidConfig { + backend: HidBackend::Ch9329Otg, + otg_udc: Some("test.udc".into()), + otg_keyboard_leds: true, + ..Default::default() + }; + for mask in 0..8 { + let msd = MsdConfig { + enabled: mask & 1 != 0, + ..Default::default() + }; + let network = OtgNetworkConfig { + enabled: mask & 2 != 0, + ..Default::default() + }; + let uac = UacConfig { + enabled: mask & 4 != 0, + ..Default::default() + }; + let desired = OtgDesiredState::from_config(&hid, &msd, &network, &uac).unwrap(); + assert!(!desired.hid_enabled()); + assert!(!desired.keyboard_leds); + assert_eq!(desired.msd_enabled, msd.enabled); + assert_eq!(desired.network_enabled(), network.enabled); + assert_eq!(desired.uac.enabled, uac.enabled); + assert_eq!(desired.udc.is_some(), mask != 0); + } + } #[test] fn service_new_and_availability_probe() { diff --git a/src/platform/linux.rs b/src/platform/linux.rs index b3fc00c4..4680422b 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -10,7 +10,7 @@ pub fn capabilities() -> PlatformCapabilities { encoder: FeatureCapability::available([ "software", "vaapi", "nvenc", "qsv", "amf", "rkmpp", "v4l2m2m", ]), - hid: FeatureCapability::available(["otg", "ch9329", "bluetooth", "none"]), + hid: FeatureCapability::available(["otg", "ch9329", "ch9329_otg", "bluetooth", "none"]), atx: FeatureCapability::available(["gpio", "usb_relay", "serial", "wol", "none"]), msd: FeatureCapability::available(["configfs"]), otg: FeatureCapability::available(["configfs"]), diff --git a/src/runtime/builder.rs b/src/runtime/builder.rs index e5fd2c0f..76b66923 100644 --- a/src/runtime/builder.rs +++ b/src/runtime/builder.rs @@ -6,7 +6,7 @@ use tokio::sync::broadcast; use crate::atx::AtxController; use crate::audio::{AudioController, AudioControllerConfig, AudioQuality}; use crate::auth::{SessionStore, TwoFactorService, UserStore}; -use crate::config::{self, AppConfig, ConfigStore}; +use crate::config::{AppConfig, ConfigStore}; use crate::db::{open_database_pool, DatabasePool}; use crate::events::EventBus; use crate::extensions::ExtensionManager; @@ -81,6 +81,7 @@ impl RuntimeBuilder { web_overrides, } = self; let (db, config_store, mut config) = load_runtime_config(&data_dir).await?; + config.hid.validate_platform()?; web_overrides.apply(&mut config); let sessions = SessionStore::new(config.auth.session_timeout_secs as i64); @@ -400,26 +401,13 @@ async fn build_otg(config: &AppConfig) -> Arc { .await { tracing::warn!("Failed to apply OTG config: {}", error); + service.mark_degraded(error.to_string()).await; } service } fn hid_backend_type(config: &AppConfig) -> HidBackendType { - match config.hid.backend { - config::HidBackend::Otg => HidBackendType::Otg { - macos_drag: config.hid.mouse_macos_drag, - }, - config::HidBackend::Ch9329 => HidBackendType::Ch9329 { - port: config.hid.ch9329_port.clone(), - baud_rate: config.hid.ch9329_baudrate, - hybrid_mouse: config.hid.ch9329_hybrid_mouse, - macos_drag: config.hid.mouse_macos_drag, - }, - config::HidBackend::None => HidBackendType::None, - config::HidBackend::Bluetooth => HidBackendType::Bluetooth { - config: config.hid.bluetooth.clone(), - }, - } + HidBackendType::from(&config.hid) } #[cfg(unix)] diff --git a/src/runtime/usb.rs b/src/runtime/usb.rs index 3fa7eebe..7bacb9f9 100644 --- a/src/runtime/usb.rs +++ b/src/runtime/usb.rs @@ -51,24 +51,31 @@ impl UsbCoordinator { } pub async fn apply_config(&self, old_config: &AppConfig, new_config: &AppConfig) -> Result<()> { + new_config.hid.validate_platform()?; #[cfg(unix)] { let transitioning_away_from_otg = old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg; - let hid_unchanged = old_config.hid == new_config.hid; - let gadget_rebuilt = old_config.msd != new_config.msd - || old_config.otg_network != new_config.otg_network - || old_config.uac != new_config.uac - || old_config.hid.otg_udc != new_config.hid.otg_udc - || old_config.hid.otg_descriptor != new_config.hid.otg_descriptor - || old_config.hid.backend != new_config.hid.backend - || old_config.hid.constrained_otg_functions() - != new_config.hid.constrained_otg_functions() - || old_config.hid.effective_otg_keyboard_leds() - != new_config.hid.effective_otg_keyboard_leds(); + let hid_unchanged = !hid_requires_reload(&old_config.hid, &new_config.hid); + let gadget_rebuilt = crate::otg::service::OtgDesiredState::from_config( + &old_config.hid, + &old_config.msd, + &old_config.otg_network, + &old_config.uac, + )? != crate::otg::service::OtgDesiredState::from_config( + &new_config.hid, + &new_config.msd, + &new_config.otg_network, + &new_config.uac, + )?; let restart_uac = old_config.uac != new_config.uac || (new_config.uac.enabled && gadget_rebuilt); + if gadget_rebuilt { + // This is a no-op for CH9329, including the composite mode. + self.hid.prepare_otg_rebuild().await?; + } + if restart_uac { let playback = self.uac_playback.write().await.take(); if let Some(playback) = playback { @@ -167,29 +174,11 @@ impl UsbCoordinator { uac_config: &UacConfig, options: ConfigApplyOptions, ) -> Result<()> { + new_config.validate_platform()?; new_config.validate_otg_functions()?; new_config.bluetooth.validate()?; - let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor; - let hid_functions_changed = - old_config.constrained_otg_functions() != new_config.constrained_otg_functions(); - let keyboard_leds_changed = - old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds(); - let mouse_compatibility_changed = old_config.ch9329_hybrid_mouse - != new_config.ch9329_hybrid_mouse - || old_config.mouse_macos_drag != new_config.mouse_macos_drag; - - if old_config.backend == new_config.backend - && old_config.ch9329_port == new_config.ch9329_port - && old_config.ch9329_baudrate == new_config.ch9329_baudrate - && old_config.bluetooth == new_config.bluetooth - && !mouse_compatibility_changed - && old_config.otg_udc == new_config.otg_udc - && !descriptor_changed - && !hid_functions_changed - && !keyboard_leds_changed - && !options.force - { + if !hid_requires_reload(old_config, new_config) && !options.force { tracing::info!("HID config unchanged, skipping reload"); return Ok(()); } @@ -256,7 +245,7 @@ impl UsbCoordinator { options: ConfigApplyOptions, ) -> Result<()> { let old_enabled = old_config.enabled; - let new_enabled = new_config.enabled && hid_config.backend == HidBackend::Otg; + let new_enabled = new_config.enabled && hid_config.backend.supports_otg_functions(); let directory_changed = old_config.msd_dir != new_config.msd_dir; let inquiry_changed = old_config.flash_inquiry_string != new_config.flash_inquiry_string || old_config.cdrom_inquiry_string != new_config.cdrom_inquiry_string; @@ -309,28 +298,72 @@ impl UsbCoordinator { } fn hid_backend_type(config: &HidConfig) -> HidBackendType { - match config.backend { - HidBackend::Otg => HidBackendType::Otg { - macos_drag: config.mouse_macos_drag, - }, - HidBackend::Ch9329 => HidBackendType::Ch9329 { - port: config.ch9329_port.clone(), - baud_rate: config.ch9329_baudrate, - hybrid_mouse: config.ch9329_hybrid_mouse, - macos_drag: config.mouse_macos_drag, - }, - HidBackend::None => HidBackendType::None, - HidBackend::Bluetooth => HidBackendType::Bluetooth { - config: config.bluetooth.clone(), - }, - } + HidBackendType::from(config) } -fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> bool { - old_config.backend == HidBackend::Otg - || new_config.backend == HidBackend::Otg - || old_config.otg_udc != new_config.otg_udc - || old_config.otg_descriptor != new_config.otg_descriptor - || old_config.constrained_otg_functions() != new_config.constrained_otg_functions() - || old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds() +fn hid_requires_reload(old: &HidConfig, new: &HidConfig) -> bool { + HidBackendType::from(old) != HidBackendType::from(new) + || (new.backend.uses_otg_hid() && hid_otg_config_changed(old, new)) +} + +fn hid_otg_config_changed(old: &HidConfig, new: &HidConfig) -> bool { + old.backend.uses_otg_hid() != new.backend.uses_otg_hid() + || old.otg_udc != new.otg_udc + || old.otg_descriptor != new.otg_descriptor + || old.constrained_otg_functions() != new.constrained_otg_functions() + || old.effective_otg_keyboard_leds() != new.effective_otg_keyboard_leds() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn composite_otg_preferences_do_not_reload_ch9329_but_input_changes_do() { + let old = HidConfig { + backend: HidBackend::Ch9329Otg, + otg_udc: Some("first.udc".into()), + ..Default::default() + }; + let mut new = old.clone(); + new.otg_udc = Some("second.udc".into()); + new.otg_descriptor.product = "Advanced USB".into(); + new.otg_keyboard_leds = !old.otg_keyboard_leds; + new.otg_profile = crate::config::OtgHidProfile::LegacyKeyboard; + assert!(!hid_requires_reload(&old, &new)); + assert!(!hid_requires_reload(&new, &old)); + + let input_changes: [fn(&mut HidConfig); 4] = [ + |hid: &mut HidConfig| hid.ch9329_port.push('1'), + |hid: &mut HidConfig| hid.ch9329_baudrate = 115200, + |hid: &mut HidConfig| hid.ch9329_hybrid_mouse = !hid.ch9329_hybrid_mouse, + |hid: &mut HidConfig| hid.mouse_macos_drag = !hid.mouse_macos_drag, + ]; + for change in input_changes { + let mut changed = old.clone(); + change(&mut changed); + assert!(hid_requires_reload(&old, &changed)); + assert!(hid_requires_reload(&changed, &old)); + } + } + + #[test] + fn composite_mode_switch_only_reloads_when_input_driver_changes() { + let composite = HidConfig { + backend: HidBackend::Ch9329Otg, + otg_udc: Some("test.udc".into()), + ..Default::default() + }; + for (backend, reload) in [ + (HidBackend::Ch9329, false), + (HidBackend::Otg, true), + (HidBackend::Bluetooth, true), + (HidBackend::None, true), + ] { + let mut other = composite.clone(); + other.backend = backend; + assert_eq!(hid_requires_reload(&composite, &other), reload); + assert_eq!(hid_requires_reload(&other, &composite), reload); + } + } } diff --git a/src/web/handlers/config/atx.rs b/src/web/handlers/config/atx.rs index 86144538..b3cc221a 100644 --- a/src/web/handlers/config/atx.rs +++ b/src/web/handlers/config/atx.rs @@ -2,7 +2,7 @@ use axum::{extract::State, Json}; use std::sync::Arc; use crate::atx::AtxDriverType; -use crate::config::{AtxConfig, HidBackend, HidConfig}; +use crate::config::{AtxConfig, HidConfig}; use crate::error::{AppError, Result}; use crate::state::AppState; @@ -17,12 +17,13 @@ pub async fn update_atx_config( State(state): State>, Json(req): Json, ) -> Result> { + let _usb_guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?; + let _apply_guard = try_apply_lock(&state.config_apply_locks.atx, "atx")?; let current_config = state.config.get(); let old_atx_config = current_config.atx.clone(); req.validate_with_current(&old_atx_config)?; - let _apply_guard = try_apply_lock(&state.config_apply_locks.atx, "atx")?; let mut merged_atx_config = old_atx_config.clone(); req.apply_to(&mut merged_atx_config); validate_windows_atx_backends(&merged_atx_config)?; @@ -56,8 +57,8 @@ fn validate_windows_atx_backends(atx: &AtxConfig) -> Result<()> { Ok(()) } -fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<()> { - if hid.backend != HidBackend::Ch9329 { +pub(super) fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<()> { + if !hid.backend.uses_ch9329() { return Ok(()); } let reserved = hid.ch9329_port.trim(); @@ -78,6 +79,7 @@ fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<( #[cfg(test)] mod tests { use super::*; + use crate::config::HidBackend; #[test] fn test_validate_serial_device_conflict_rejects_ch9329_overlap() { @@ -90,6 +92,8 @@ mod tests { hid.ch9329_port = "/dev/ttyUSB0".to_string(); assert!(validate_serial_device_conflict(&atx, &hid).is_err()); + hid.backend = HidBackend::Ch9329Otg; + assert!(validate_serial_device_conflict(&atx, &hid).is_err()); } #[test] diff --git a/src/web/handlers/config/otg.rs b/src/web/handlers/config/otg.rs index 4ba7004f..d2dc794f 100644 --- a/src/web/handlers/config/otg.rs +++ b/src/web/handlers/config/otg.rs @@ -2,7 +2,7 @@ use axum::{extract::State, Json}; use serde::Serialize; use typeshare::typeshare; -use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig}; +use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig, UacConfig}; use crate::error::Result; use crate::otg::OtgNetworkStatus; use crate::web::state::UsbApiState; @@ -16,6 +16,7 @@ pub struct OtgConfigResponse { pub hid: HidConfig, pub msd: MsdConfig, pub network: OtgNetworkConfig, + pub uac: UacConfig, pub status: OtgNetworkStatus, } @@ -48,6 +49,10 @@ pub(super) async fn update_otg_config_inner( if let Some(ref update) = request.network { update.apply_to(&mut staged.otg_network); } + if let Some(ref uac) = request.uac { + uac.validate()?; + staged.uac = uac.clone(); + } Ok(requested_ch9329_descriptor) }) @@ -57,6 +62,7 @@ pub(super) async fn update_otg_config_inner( hid: staged_config.hid, msd: staged_config.msd, network: staged_config.otg_network, + uac: staged_config.uac, status: state.otg.network_status().await, }) } diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index 59275182..ea151871 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -427,6 +427,7 @@ pub struct OtgConfigUpdate { pub hid: Option, pub msd: Option, pub network: Option, + pub uac: Option, } impl HidConfigUpdate { diff --git a/src/web/handlers/config/usb_update.rs b/src/web/handlers/config/usb_update.rs index 6f2d1691..c9d3a697 100644 --- a/src/web/handlers/config/usb_update.rs +++ b/src/web/handlers/config/usb_update.rs @@ -47,6 +47,21 @@ where let requested_ch9329_descriptor = stage_update(&mut staged_config)?; staged_config.enforce_invariants(); + staged_config.hid.validate_platform()?; + staged_config.hid.validate_composite_selection()?; + #[cfg(unix)] + if staged_config.hid.backend == HidBackend::Ch9329Otg + && (old_config.hid.backend != staged_config.hid.backend + || old_config.hid.otg_udc != staged_config.hid.otg_udc) + && !crate::otg::list_udc_devices() + .iter() + .any(|udc| Some(udc) == staged_config.hid.otg_udc.as_ref()) + { + return Err(AppError::BadRequest( + "Selected USB device controller is missing".into(), + )); + } + super::atx::validate_serial_device_conflict(&staged_config.atx, &staged_config.hid)?; staged_config.hid.validate_otg_functions()?; #[cfg(unix)] @@ -179,7 +194,7 @@ where } let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor { - if staged_config.hid.backend == HidBackend::Ch9329 { + if staged_config.hid.backend.uses_ch9329() { match state.hid.apply_ch9329_descriptor(descriptor).await { Ok(actual) => { staged_config.hid.ch9329_descriptor = actual.descriptor; @@ -250,7 +265,7 @@ async fn rollback_after_failure( { rollback_errors.push(format!("runtime rollback failed: {error}")); } - if restore_descriptor && old_config.hid.backend == HidBackend::Ch9329 { + if restore_descriptor && old_config.hid.backend.uses_ch9329() { if let Err(error) = state .hid .apply_ch9329_descriptor(&old_config.hid.ch9329_descriptor) @@ -276,6 +291,31 @@ mod tests { use crate::config::HidBackend; use crate::web::handlers::config::types::Ch9329DescriptorConfigUpdate; + #[cfg(unix)] + #[test] + fn composite_advanced_features_can_be_submitted_in_one_usb_transaction() { + let request: super::super::types::OtgConfigUpdate = serde_json::from_value(serde_json::json!({ + "hid": { "backend": "ch9329_otg", "ch9329_port": "/dev/ttyUSB1", "otg_udc": "test.udc" }, + "msd": { "enabled": true }, + "network": { "enabled": true }, + "uac": { "enabled": true, "sample_rate": 48000, "channels": 2 } + })).unwrap(); + let mut config = AppConfig::default(); + stage_hid_config_update(&mut config.hid, request.hid.as_ref().unwrap()).unwrap(); + request.msd.as_ref().unwrap().apply_to(&mut config.msd); + request + .network + .as_ref() + .unwrap() + .apply_to(&mut config.otg_network); + config.uac = request.uac.unwrap(); + config.enforce_invariants(); + assert!(config.msd.enabled && config.otg_network.enabled && config.uac.enabled); + assert!(config.hid.constrained_otg_functions().is_empty()); + assert_eq!(config.hid.ch9329_port, "/dev/ttyUSB1"); + assert_eq!(config.hid.otg_udc.as_deref(), Some("test.udc")); + } + fn hid_update() -> HidConfigUpdate { HidConfigUpdate { bluetooth_reset_pairing: None, diff --git a/src/web/handlers/hid_api.rs b/src/web/handlers/hid_api.rs index e1f25b01..f067b709 100644 --- a/src/web/handlers/hid_api.rs +++ b/src/web/handlers/hid_api.rs @@ -11,6 +11,9 @@ pub struct Ch9329DescriptorQuery { pub struct HidStatus { pub available: bool, pub backend: String, + pub configured_backend: String, + #[cfg(unix)] + pub otg: crate::otg::service::OtgRuntimeStatus, pub initialized: bool, pub online: bool, pub supports_absolute_mouse: bool, @@ -34,9 +37,21 @@ pub async fn hid_otg_self_check( /// Get HID status pub async fn hid_status(State(state): State>) -> Json { let hid = state.hid.snapshot().await; + let config = state.config.get(); Json(HidStatus { available: hid.available, backend: hid.backend, + configured_backend: config.hid.backend.as_str().to_string(), + #[cfg(unix)] + otg: state + .otg_service + .runtime_status( + config.hid.backend.uses_otg_hid() + || config.msd.enabled + || config.otg_network.enabled + || config.uac.enabled, + ) + .await, initialized: hid.initialized, online: hid.online, supports_absolute_mouse: hid.supports_absolute_mouse, diff --git a/src/web/handlers/setup.rs b/src/web/handlers/setup.rs index 5855ded8..43f90915 100644 --- a/src/web/handlers/setup.rs +++ b/src/web/handlers/setup.rs @@ -69,6 +69,8 @@ impl SetupRequest { if let Some(enabled) = self.hid_otg_keyboard_leds { hid.otg_keyboard_leds = enabled; } + hid.validate_platform()?; + hid.validate_composite_selection()?; hid.bluetooth.validate()?; hid.validate_otg_functions()?; Ok(hid) @@ -305,6 +307,21 @@ mod tests { assert!(hid.effective_otg_keyboard_leds()); } + #[test] + #[cfg(target_os = "linux")] + fn setup_applies_composite_selection_to_both_usb_paths() { + let req = request(json!({ + "hid_backend": "ch9329_otg", "hid_ch9329_port": "/dev/ttyUSB2", + "hid_ch9329_baudrate": 115200, "hid_otg_udc": "test.udc" + })); + let hid = req.hid_config(&HidConfig::default()).unwrap(); + assert_eq!(hid.backend, HidBackend::Ch9329Otg); + assert_eq!(hid.ch9329_port, "/dev/ttyUSB2"); + assert_eq!(hid.ch9329_baudrate, 115200); + assert_eq!(hid.otg_udc.as_deref(), Some("test.udc")); + assert!(hid.constrained_otg_functions().is_empty()); + } + #[test] fn setup_applies_selected_ch9329_port_and_baudrate() { let req = request(json!({ diff --git a/web/src/api/index.ts b/web/src/api/index.ts index dffb52f7..ed7434f9 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -164,7 +164,7 @@ export const systemApi = { video_width?: number video_height?: number video_fps?: number - hid_backend?: 'otg' | 'ch9329' | 'bluetooth' | 'none' + hid_backend?: 'otg' | 'ch9329' | 'ch9329_otg' | 'bluetooth' | 'none' hid_bluetooth?: { adapter: string; name: string } hid_ch9329_port?: string hid_ch9329_baudrate?: number @@ -411,6 +411,8 @@ export const hidApi = { request<{ available: boolean backend: string + configured_backend: string + otg?: import('@/lib/hidGuide').OtgDeviceStatus initialized: boolean online: boolean supports_absolute_mouse: boolean diff --git a/web/src/components/ActionBar.vue b/web/src/components/ActionBar.vue index b6efb880..50837dfe 100644 --- a/web/src/components/ActionBar.vue +++ b/web/src/components/ActionBar.vue @@ -58,11 +58,7 @@ const systemStore = useSystemStore() const overflowMenuOpen = ref(false) -const hidBackend = computed(() => (systemStore.hid?.backend ?? '').toLowerCase()) -const isCh9329Backend = computed(() => hidBackend.value.includes('ch9329')) -const showMsd = computed(() => { - return !!systemStore.msd?.available && !isCh9329Backend.value -}) +const showMsd = computed(() => !!systemStore.msd?.available) const props = defineProps<{ layout?: ConsoleLayout mouseMode?: 'absolute' | 'relative' diff --git a/web/src/components/HidConfigPopover.vue b/web/src/components/HidConfigPopover.vue index a15fc674..e69819c1 100644 --- a/web/src/components/HidConfigPopover.vue +++ b/web/src/components/HidConfigPopover.vue @@ -17,6 +17,7 @@ import { MousePointer, Move, Loader2 } from 'lucide-vue-next' import HelpTooltip from '@/components/HelpTooltip.vue' import { useConfigStore } from '@/stores/config' import { HidBackend, OtgHidProfile } from '@/types/generated' +import { usesCh9329 } from '@/lib/hidGuide' const props = defineProps<{ open: boolean @@ -66,7 +67,7 @@ watch(showCursor, (newValue, oldValue) => { const buttonText = computed(() => t('actionbar.hidConfig')) const hidSaving = ref(false) const hidControlsDisabled = computed(() => hidSaving.value || configStore.hidLoading || !configStore.hid) -const supportsLinuxCompatibility = computed(() => configStore.hid?.backend === HidBackend.Ch9329) +const supportsLinuxCompatibility = computed(() => usesCh9329(configStore.hid?.backend)) const supportsMacosCompatibility = computed(() => supportsLinuxCompatibility.value || configStore.hid?.backend === HidBackend.Otg ) diff --git a/web/src/components/HidDeviceOverview.vue b/web/src/components/HidDeviceOverview.vue index e3fa7700..ce83e9e9 100644 --- a/web/src/components/HidDeviceOverview.vue +++ b/web/src/components/HidDeviceOverview.vue @@ -3,7 +3,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import type { HidConfig } from '@/types/generated' import type { BluetoothStatus } from '@/types/bluetooth' -import { hidDeviceError, hidDeviceStage, type HidDeviceStatus } from '@/lib/hidGuide' +import { hidDeviceError, hidDeviceStage, otgDeviceStage, usesCh9329, supportsOtgFunctions, type HidDeviceStatus } from '@/lib/hidGuide' const props = defineProps<{ hid: HidConfig | null; status?: HidDeviceStatus | null; bluetooth?: BluetoothStatus | null; error?: string }>() const { t } = useI18n() const connectionError = computed(() => props.error || hidDeviceError(props.status) || props.bluetooth?.error) @@ -13,8 +13,8 @@ const stage = computed(() => hidDeviceStage(props.status, props.bluetooth ?? nul

{{ !hid ? t('hidGuide.unconfigured') : hid.backend === 'none' ? t('hidGuide.disabled') : t(`hidGuide.driver_${hid.backend}`) }}

-
UDC:
{{ hid.otg_udc || status?.backend === 'otg' && t('hidGuide.legacyAuto') || '—' }}
-

- {{ t(`hidGuide.${stage}`) }} + {{ t('hidGuide.keyboardChannel') }}: {{ t(`hidGuide.${stage}`) }} · {{ bluetooth.pairing_seconds }}s

+
diff --git a/web/src/components/HidDriverDialog.vue b/web/src/components/HidDriverDialog.vue index cf2eff5b..985757ae 100644 --- a/web/src/components/HidDriverDialog.vue +++ b/web/src/components/HidDriverDialog.vue @@ -8,7 +8,7 @@ import { useSystemStore } from '@/stores/system' import { request } from '@/api/request' import type { HidConfig, MsdConfig } from '@/types/generated' import { useHidConnection } from '@/composables/useHidConnection' -import { deviceRequest, matchesSelection, selectionFrom, type HidSelection } from '@/lib/hidGuide' +import { deviceRequest, matchesSelection, selectionFrom, supportsOtgFunctions, type HidSelection } from '@/lib/hidGuide' import HidDriverForm from './HidDriverForm.vue' import HidWiringDiagram from './HidWiringDiagram.vue' import HidDeviceOverview from './HidDeviceOverview.vue' @@ -127,7 +127,7 @@ async function load() { if (!systemStore.platform) await systemStore.fetchSystemInfo() await refreshHid() draft.value = selectionFrom(store.hid) - if (otgSupported.value && store.hid?.backend === 'otg') { + if (otgSupported.value && supportsOtgFunctions(store.hid?.backend)) { const [msd, network, audio] = await Promise.all([refreshMsd(), readConfig<{ enabled: boolean }>('/config/otg-network'), readConfig<{ enabled: boolean }>('/config/uac')]) disabledUsb.value = [msd.enabled ? t('hidGuide.msd') : '', network.enabled ? t('hidGuide.network') : '', audio.enabled ? t('hidGuide.audio') : ''].filter(Boolean) } @@ -162,7 +162,7 @@ onMounted(load)

{{ t('hidGuide.connectionTimeout') }}

-

{{ t('hidGuide.disableUsb', { functions: disabledUsb.join('、') }) }}

+

{{ t('hidGuide.disableUsb', { functions: disabledUsb.join('、') }) }}

{{ t('hidGuide.resetWarning') }}

diff --git a/web/src/components/HidDriverForm.vue b/web/src/components/HidDriverForm.vue index 7bebdfdd..93c91413 100644 --- a/web/src/components/HidDriverForm.vue +++ b/web/src/components/HidDriverForm.vue @@ -9,57 +9,82 @@ import { Cable, RefreshCw } from 'lucide-vue-next' import { configApi, type PlatformCapabilities } from '@/api' import { request } from '@/api/request' import type { BluetoothAdapter } from '@/types/bluetooth' -import { selectDevice, validName, type HidSelection, type Driver } from '@/lib/hidGuide' +import { selectDevice, validName, usesCh9329, supportsOtgFunctions, type HidSelection, type Driver } from '@/lib/hidGuide' import HidWiringDiagram from './HidWiringDiagram.vue' -const props = defineProps<{ modelValue: HidSelection; platform: PlatformCapabilities | null; locked?: boolean }>() +const props = withDefaults(defineProps<{ modelValue: HidSelection; platform: PlatformCapabilities | null; locked?: boolean; deviceOnly?: boolean; idPrefix?: string }>(), { idPrefix: 'hid' }) const emit = defineEmits<{ 'update:modelValue': [HidSelection]; valid: [boolean] }>() const { t } = useI18n() const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__' -const options = ref>([]) -const loading = ref(false), error = ref(''), missing = ref('') +type DeviceField = 'serial' | 'udc' | 'bluetooth' +type DeviceOption = { value: string; label: string } +const options = ref>({ serial: [], udc: [], bluetooth: [] }) +const loading = ref(false), error = ref('') let generation = 0 -const drivers = computed(() => (['otg', 'ch9329', 'bluetooth', 'none'] as Driver[]) +const drivers = computed(() => (['otg', 'ch9329', 'ch9329_otg', 'bluetooth', 'none'] as Driver[]) .filter(driver => props.platform?.hid.backends.includes(driver))) -const selected = computed(() => props.modelValue.backend === 'otg' ? props.modelValue.otg_udc : props.modelValue.backend === 'ch9329' ? props.modelValue.ch9329_port : props.modelValue.bluetooth.adapter) -function device(value: string) { +const fields = computed(() => { + if (props.modelValue.backend === 'bluetooth') return ['bluetooth'] + const result: DeviceField[] = [] + if (usesCh9329(props.modelValue.backend)) result.push('serial') + if (supportsOtgFunctions(props.modelValue.backend)) result.push('udc') + return result +}) +function selected(field: DeviceField): string { + return field === 'serial' ? props.modelValue.ch9329_port : field === 'udc' ? props.modelValue.otg_udc : props.modelValue.bluetooth.adapter +} +function deviceLabel(field: DeviceField): string { + return options.value[field].find(option => option.value === selected(field))?.label + || selected(field) || t('hidGuide.selectDevice') +} +function assign(draft: HidSelection, field: DeviceField, value: string) { + if (field === 'serial') draft.ch9329_port = value + if (field === 'udc') draft.otg_udc = value + if (field === 'bluetooth') draft.bluetooth.adapter = value +} +function device(field: DeviceField, value: string) { + if (value === selected(field)) return const draft = { ...props.modelValue, bluetooth: { ...props.modelValue.bluetooth } } - if (draft.backend === 'otg') draft.otg_udc = value - if (draft.backend === 'ch9329') draft.ch9329_port = value - if (draft.backend === 'bluetooth') draft.bluetooth.adapter = value + assign(draft, field, value) emit('update:modelValue', draft) } +function fieldLabel(field: DeviceField): string { + return field === 'udc' && props.deviceOnly ? t('hidPage.udcLabel') : t(`hidGuide.device_${field === 'serial' ? 'ch9329' : field === 'udc' ? 'otg' : 'bluetooth'}`) +} async function refresh() { const own = ++generation - options.value = []; error.value = ''; missing.value = ''; loading.value = true + options.value = { serial: [], udc: [], bluetooth: [] }; error.value = ''; loading.value = true try { if (!drivers.value.includes(props.modelValue.backend) || props.modelValue.backend === 'none') return - let next: typeof options.value + const next: Record = { serial: [], udc: [], bluetooth: [] } if (props.modelValue.backend === 'bluetooth') { - next = (await request('/hid/bluetooth/adapters')).map(a => ({ value: a.name, label: `${a.name} · ${a.address}` })) + next.bluetooth = (await request('/hid/bluetooth/adapters')).map(a => ({ value: a.name, label: `${a.name} · ${a.address}` })) } else { const devices = await configApi.listDevices() - next = props.modelValue.backend === 'otg' ? devices.udc.map(d => ({ value: d.name, label: d.name })) : devices.serial.map(d => ({ value: d.path, label: `${d.name} · ${d.path}` })) + next.udc = devices.udc.map(d => ({ value: d.name, label: d.name })) + next.serial = devices.serial.map(d => ({ value: d.path, label: `${d.name} · ${d.path}` })) } if (own !== generation) return options.value = next - if (selected.value && !next.some(d => d.value === selected.value)) missing.value = t('hidGuide.missingDevice', { device: selected.value }) - device(selectDevice(selected.value, next.map(d => d.value))) - if (!next.length) error.value = t('hidGuide.noDevices') + const draft = { ...props.modelValue, bluetooth: { ...props.modelValue.bluetooth } } + for (const field of fields.value) assign(draft, field, selectDevice(selected(field), next[field].map(d => d.value))) + if (JSON.stringify(draft) !== JSON.stringify(props.modelValue)) emit('update:modelValue', draft) } catch (e) { if (own === generation) error.value = e instanceof Error ? e.message : String(e) } finally { if (own === generation) loading.value = false } } -const valid = computed(() => drivers.value.includes(props.modelValue.backend) && (props.modelValue.backend === 'none' || (!loading.value && !error.value && options.value.some(d => d.value === selected.value) +const valid = computed(() => drivers.value.includes(props.modelValue.backend) && (props.modelValue.backend === 'none' || (!loading.value && !error.value + && fields.value.every(field => options.value[field].some(d => d.value === selected(field))) && (props.modelValue.backend !== 'bluetooth' || validName(props.modelValue.bluetooth.name)) - && (props.modelValue.backend !== 'ch9329' || [9600, 19200, 38400, 57600, 115200].includes(props.modelValue.ch9329_baudrate))))) + && (!usesCh9329(props.modelValue.backend) || [9600, 19200, 38400, 57600, 115200].includes(props.modelValue.ch9329_baudrate))))) watch(valid, value => emit('valid', value), { immediate: true }) -watch(() => [props.modelValue.backend, drivers.value.includes(props.modelValue.backend)], refresh, { immediate: true }) +watch([() => props.modelValue.backend, () => drivers.value.includes(props.modelValue.backend)], refresh, { immediate: true }) onUnmounted(() => generation++) +defineExpose({ refresh, loading }) diff --git a/web/src/components/HidWiringDiagram.vue b/web/src/components/HidWiringDiagram.vue index 07d90e5a..157002ae 100644 --- a/web/src/components/HidWiringDiagram.vue +++ b/web/src/components/HidWiringDiagram.vue @@ -8,7 +8,27 @@ const { t } = useI18n()