diff --git a/src/config/schema.rs b/src/config/schema.rs index ff5fb131..094a479b 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -148,13 +148,11 @@ impl Default for OtgDescriptorConfig { pub enum OtgHidProfile { /// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) #[default] + #[serde(alias = "full_no_msd")] Full, - /// Full HID device set without MSD - FullNoMsd, /// Full HID device set without consumer control + #[serde(alias = "full_no_consumer_no_msd")] FullNoConsumer, - /// Full HID device set without consumer control and MSD - FullNoConsumerNoMsd, /// Legacy profile: only keyboard LegacyKeyboard, /// Legacy profile: only relative mouse @@ -163,9 +161,52 @@ pub enum OtgHidProfile { Custom, } +/// OTG endpoint budget policy. +#[typeshare] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum OtgEndpointBudget { + /// Derive a safe default from the selected UDC. + #[default] + Auto, + /// Limit OTG gadget functions to 5 endpoints. + Five, + /// Limit OTG gadget functions to 6 endpoints. + Six, + /// Do not impose a software endpoint budget. + Unlimited, +} + +impl OtgEndpointBudget { + pub fn default_for_udc_name(udc: Option<&str>) -> Self { + if udc.is_some_and(crate::otg::configfs::is_low_endpoint_udc) { + Self::Five + } else { + Self::Six + } + } + + pub fn resolved(self, udc: Option<&str>) -> Self { + match self { + Self::Auto => Self::default_for_udc_name(udc), + other => other, + } + } + + pub fn endpoint_limit(self, udc: Option<&str>) -> Option { + match self.resolved(udc) { + Self::Five => Some(5), + Self::Six => Some(6), + Self::Unlimited => None, + Self::Auto => unreachable!("auto budget must be resolved before use"), + } + } +} + /// OTG HID function selection (used when profile is Custom) #[typeshare] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct OtgHidFunctions { pub keyboard: bool, @@ -214,6 +255,26 @@ impl OtgHidFunctions { pub fn is_empty(&self) -> bool { !self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer } + + pub fn endpoint_cost(&self, keyboard_leds: bool) -> u8 { + let mut endpoints = 0; + if self.keyboard { + endpoints += 1; + if keyboard_leds { + endpoints += 1; + } + } + if self.mouse_relative { + endpoints += 1; + } + if self.mouse_absolute { + endpoints += 1; + } + if self.consumer { + endpoints += 1; + } + endpoints + } } impl Default for OtgHidFunctions { @@ -223,12 +284,21 @@ impl Default for OtgHidFunctions { } impl OtgHidProfile { + pub fn from_legacy_str(value: &str) -> Option { + match value { + "full" | "full_no_msd" => Some(Self::Full), + "full_no_consumer" | "full_no_consumer_no_msd" => Some(Self::FullNoConsumer), + "legacy_keyboard" => Some(Self::LegacyKeyboard), + "legacy_mouse_relative" => Some(Self::LegacyMouseRelative), + "custom" => Some(Self::Custom), + _ => None, + } + } + pub fn resolve_functions(&self, custom: &OtgHidFunctions) -> OtgHidFunctions { match self { Self::Full => OtgHidFunctions::full(), - Self::FullNoMsd => OtgHidFunctions::full(), Self::FullNoConsumer => OtgHidFunctions::full_no_consumer(), - Self::FullNoConsumerNoMsd => OtgHidFunctions::full_no_consumer(), Self::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(), Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(), Self::Custom => custom.clone(), @@ -243,10 +313,6 @@ impl OtgHidProfile { pub struct HidConfig { /// HID backend type pub backend: HidBackend, - /// OTG keyboard device path - pub otg_keyboard: String, - /// OTG mouse device path - pub otg_mouse: String, /// OTG UDC (USB Device Controller) name pub otg_udc: Option, /// OTG USB device descriptor configuration @@ -255,9 +321,15 @@ pub struct HidConfig { /// OTG HID function profile #[serde(default)] pub otg_profile: OtgHidProfile, + /// OTG endpoint budget policy + #[serde(default)] + pub otg_endpoint_budget: OtgEndpointBudget, /// OTG HID function selection (used when profile is Custom) #[serde(default)] pub otg_functions: OtgHidFunctions, + /// Enable keyboard LED/status feedback for OTG keyboard + #[serde(default)] + pub otg_keyboard_leds: bool, /// CH9329 serial port pub ch9329_port: String, /// CH9329 baud rate @@ -270,12 +342,12 @@ impl Default for HidConfig { fn default() -> Self { Self { backend: HidBackend::None, - otg_keyboard: "/dev/hidg0".to_string(), - otg_mouse: "/dev/hidg1".to_string(), otg_udc: None, otg_descriptor: OtgDescriptorConfig::default(), otg_profile: OtgHidProfile::default(), + otg_endpoint_budget: OtgEndpointBudget::default(), otg_functions: OtgHidFunctions::default(), + otg_keyboard_leds: false, ch9329_port: "/dev/ttyUSB0".to_string(), ch9329_baudrate: 9600, mouse_absolute: true, @@ -287,6 +359,62 @@ impl HidConfig { pub fn effective_otg_functions(&self) -> OtgHidFunctions { self.otg_profile.resolve_functions(&self.otg_functions) } + + pub fn resolved_otg_udc(&self) -> Option { + crate::otg::configfs::resolve_udc_name(self.otg_udc.as_deref()) + } + + pub fn resolved_otg_endpoint_budget(&self) -> OtgEndpointBudget { + self.otg_endpoint_budget + .resolved(self.resolved_otg_udc().as_deref()) + } + + pub fn resolved_otg_endpoint_limit(&self) -> Option { + self.otg_endpoint_budget + .endpoint_limit(self.resolved_otg_udc().as_deref()) + } + + pub fn effective_otg_keyboard_leds(&self) -> bool { + self.otg_keyboard_leds && self.effective_otg_functions().keyboard + } + + pub fn constrained_otg_functions(&self) -> OtgHidFunctions { + self.effective_otg_functions() + } + + pub fn effective_otg_required_endpoints(&self, msd_enabled: bool) -> u8 { + let functions = self.effective_otg_functions(); + let mut endpoints = functions.endpoint_cost(self.effective_otg_keyboard_leds()); + if msd_enabled { + endpoints += 2; + } + endpoints + } + + pub fn validate_otg_endpoint_budget(&self, msd_enabled: bool) -> crate::error::Result<()> { + if self.backend != HidBackend::Otg { + return Ok(()); + } + + let functions = self.effective_otg_functions(); + if functions.is_empty() { + return Err(crate::error::AppError::BadRequest( + "OTG HID functions cannot be empty".to_string(), + )); + } + + let required = self.effective_otg_required_endpoints(msd_enabled); + if let Some(limit) = self.resolved_otg_endpoint_limit() { + if required > limit { + return Err(crate::error::AppError::BadRequest(format!( + "OTG selection requires {} endpoints, but the configured limit is {}", + required, limit + ))); + } + } + + Ok(()) + } } /// MSD configuration diff --git a/src/events/types.rs b/src/events/types.rs index df72c3dd..148021e1 100644 --- a/src/events/types.rs +++ b/src/events/types.rs @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use crate::hid::LedState; + // ============================================================================ // Device Info Structures (for system.device_info event) // ============================================================================ @@ -45,6 +47,10 @@ pub struct HidDeviceInfo { pub online: bool, /// Whether absolute mouse positioning is supported pub supports_absolute_mouse: bool, + /// Whether keyboard LED/status feedback is enabled. + pub keyboard_leds_enabled: bool, + /// Last known keyboard LED state. + pub led_state: LedState, /// Device path (e.g., serial port for CH9329) pub device: Option, /// Error message if any, None if OK diff --git a/src/hid/backend.rs b/src/hid/backend.rs index dc519160..fe8d6fdf 100644 --- a/src/hid/backend.rs +++ b/src/hid/backend.rs @@ -2,7 +2,9 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; +use tokio::sync::watch; +use super::otg::LedState; use super::types::{ConsumerEvent, KeyboardEvent, MouseEvent}; use crate::error::Result; @@ -76,12 +78,22 @@ impl HidBackendType { } /// Current runtime status reported by a HID backend. -#[derive(Debug, Clone, Default)] -pub struct HidBackendStatus { +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HidBackendRuntimeSnapshot { /// Whether the backend has been initialized and can accept requests. pub initialized: bool, /// Whether the backend is currently online and communicating successfully. pub online: bool, + /// Whether absolute mouse positioning is supported. + pub supports_absolute_mouse: bool, + /// Whether keyboard LED/status feedback is currently enabled. + pub keyboard_leds_enabled: bool, + /// Last known keyboard LED state. + pub led_state: LedState, + /// Screen resolution for absolute mouse mode. + pub screen_resolution: Option<(u32, u32)>, + /// Device identifier associated with the backend, if any. + pub device: Option, /// Current user-facing error, if any. pub error: Option, /// Current programmatic error code, if any. @@ -91,9 +103,6 @@ pub struct HidBackendStatus { /// HID backend trait #[async_trait] pub trait HidBackend: Send + Sync { - /// Get backend name - fn name(&self) -> &'static str; - /// Initialize the backend async fn init(&self) -> Result<()>; @@ -117,18 +126,11 @@ pub trait HidBackend: Send + Sync { /// Shutdown the backend async fn shutdown(&self) -> Result<()>; - /// Get the current backend runtime status. - fn status(&self) -> HidBackendStatus; + /// Get the current backend runtime snapshot. + fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot; - /// Check if backend supports absolute mouse positioning - fn supports_absolute_mouse(&self) -> bool { - false - } - - /// Get screen resolution (for absolute mouse) - fn screen_resolution(&self) -> Option<(u32, u32)> { - None - } + /// Subscribe to backend runtime changes. + fn subscribe_runtime(&self) -> watch::Receiver<()>; /// Set screen resolution (for absolute mouse) fn set_screen_resolution(&mut self, _width: u32, _height: u32) {} diff --git a/src/hid/ch9329.rs b/src/hid/ch9329.rs index b689dad1..dc835d8d 100644 --- a/src/hid/ch9329.rs +++ b/src/hid/ch9329.rs @@ -25,9 +25,11 @@ use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU8, Ordering}; use std::sync::{mpsc, Arc}; use std::thread; use std::time::{Duration, Instant}; +use tokio::sync::watch; use tracing::{info, trace, warn}; -use super::backend::{HidBackend, HidBackendStatus}; +use super::backend::{HidBackend, HidBackendRuntimeSnapshot}; +use super::otg::LedState; use super::types::{KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType}; use crate::error::{AppError, Result}; @@ -180,7 +182,7 @@ impl ChipInfo { } /// Keyboard LED status -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct LedStatus { pub num_lock: bool, pub caps_lock: bool, @@ -346,28 +348,73 @@ const MAX_PACKET_SIZE: usize = 70; // CH9329 Backend Implementation // ============================================================================ -#[derive(Default)] struct Ch9329RuntimeState { initialized: AtomicBool, online: AtomicBool, last_error: RwLock>, - last_success: Mutex>, + notify_tx: watch::Sender<()>, } impl Ch9329RuntimeState { + fn new() -> Self { + let (notify_tx, _notify_rx) = watch::channel(()); + Self { + initialized: AtomicBool::new(false), + online: AtomicBool::new(false), + last_error: RwLock::new(None), + notify_tx, + } + } + + fn subscribe(&self) -> watch::Receiver<()> { + self.notify_tx.subscribe() + } + + fn notify(&self) { + let _ = self.notify_tx.send(()); + } + fn clear_error(&self) { - *self.last_error.write() = None; + let mut guard = self.last_error.write(); + if guard.is_some() { + *guard = None; + self.notify(); + } } fn set_online(&self) { - self.online.store(true, Ordering::Relaxed); - *self.last_success.lock() = Some(Instant::now()); - self.clear_error(); + let was_online = self.online.swap(true, Ordering::Relaxed); + let mut error = self.last_error.write(); + let cleared_error = error.take().is_some(); + drop(error); + if !was_online || cleared_error { + self.notify(); + } } fn set_error(&self, reason: impl Into, error_code: impl Into) { - self.online.store(false, Ordering::Relaxed); - *self.last_error.write() = Some((reason.into(), error_code.into())); + let reason = reason.into(); + let error_code = error_code.into(); + let was_online = self.online.swap(false, Ordering::Relaxed); + let mut error = self.last_error.write(); + let changed = error.as_ref() != Some(&(reason.clone(), error_code.clone())); + *error = Some((reason, error_code)); + drop(error); + if was_online || changed { + self.notify(); + } + } + + fn set_initialized(&self, initialized: bool) { + if self.initialized.swap(initialized, Ordering::Relaxed) != initialized { + self.notify(); + } + } + + fn set_offline(&self) { + if self.online.swap(false, Ordering::Relaxed) { + self.notify(); + } } } @@ -434,7 +481,7 @@ impl Ch9329Backend { last_abs_x: AtomicU16::new(0), last_abs_y: AtomicU16::new(0), relative_mouse_active: AtomicBool::new(false), - runtime: Arc::new(Ch9329RuntimeState::default()), + runtime: Arc::new(Ch9329RuntimeState::new()), }) } @@ -442,24 +489,11 @@ impl Ch9329Backend { self.runtime.set_error(reason, error_code); } - fn mark_online(&self) { - self.runtime.set_online(); - } - - fn clear_error(&self) { - self.runtime.clear_error(); - } - /// Check if the serial port device file exists pub fn check_port_exists(&self) -> bool { std::path::Path::new(&self.port_path).exists() } - /// Get the serial port path - pub fn port_path(&self) -> &str { - &self.port_path - } - /// Convert serialport error to HidError fn serial_error_to_hid_error(e: serialport::Error, operation: &str) -> AppError { let error_code = match e.kind() { @@ -675,23 +709,33 @@ impl Ch9329Backend { chip_info: &Arc>>, led_status: &Arc>, info: ChipInfo, - ) { - *chip_info.write() = Some(info.clone()); - *led_status.write() = LedStatus { + ) -> bool { + let next_led_status = LedStatus { num_lock: info.num_lock, caps_lock: info.caps_lock, scroll_lock: info.scroll_lock, }; + *chip_info.write() = Some(info); + let mut led_guard = led_status.write(); + let changed = *led_guard != next_led_status; + *led_guard = next_led_status; + changed } fn enqueue_command(&self, command: WorkerCommand) -> Result<()> { let guard = self.worker_tx.lock(); - let sender = guard - .as_ref() - .ok_or_else(|| Self::backend_error("CH9329 worker is not running", "worker_stopped"))?; - sender - .send(command) - .map_err(|_| Self::backend_error("CH9329 worker stopped", "worker_stopped")) + let Some(sender) = guard.as_ref() else { + self.record_error("CH9329 worker is not running", "worker_stopped"); + return Err(Self::backend_error( + "CH9329 worker is not running", + "worker_stopped", + )); + }; + + sender.send(command).map_err(|_| { + self.record_error("CH9329 worker stopped", "worker_stopped"); + Self::backend_error("CH9329 worker stopped", "worker_stopped") + }) } fn send_packet(&self, cmd: u8, data: &[u8]) -> Result<()> { @@ -701,19 +745,6 @@ impl Ch9329Backend { }) } - pub fn error_count(&self) -> u32 { - 0 - } - - /// Check if device communication is healthy (recent successful operation) - pub fn is_healthy(&self) -> bool { - if let Some(last) = *self.runtime.last_success.lock() { - last.elapsed() < Duration::from_secs(30) - } else { - false - } - } - fn worker_reconnect_loop( rx: &mpsc::Receiver, port_path: &str, @@ -745,7 +776,9 @@ impl Ch9329Backend { "disconnected" } ); - Self::update_chip_info_cache(chip_info, led_status, info); + if Self::update_chip_info_cache(chip_info, led_status, info) { + runtime.notify(); + } runtime.set_online(); return Some(port); } @@ -761,36 +794,6 @@ impl Ch9329Backend { } } - /// Get cached chip information - pub fn get_chip_info(&self) -> Option { - self.chip_info.read().clone() - } - - pub fn query_chip_info(&self) -> Result { - if let Some(info) = self.get_chip_info() { - return Ok(info); - } - - let error = self.runtime.last_error.read().clone(); - Err(match error { - Some((reason, error_code)) => Self::backend_error(reason, error_code), - None => Self::backend_error("CH9329 info unavailable", "not_ready"), - }) - } - - /// Get cached LED status - pub fn get_led_status(&self) -> LedStatus { - *self.led_status.read() - } - - pub fn software_reset(&self) -> Result<()> { - self.send_packet(cmd::RESET, &[]) - } - - pub fn restore_factory_defaults(&self) -> Result<()> { - self.send_packet(cmd::SET_DEFAULT_CFG, &[]) - } - fn send_keyboard_report(&self, report: &KeyboardReport) -> Result<()> { let data = report.to_bytes(); self.send_packet(cmd::SEND_KB_GENERAL_DATA, &data) @@ -805,20 +808,6 @@ impl Ch9329Backend { self.send_packet(cmd::SEND_KB_MEDIA_DATA, data) } - pub fn send_acpi_key(&self, power: bool, sleep: bool, wake: bool) -> Result<()> { - let mut byte = 0u8; - if power { - byte |= 0x01; - } - if sleep { - byte |= 0x02; - } - if wake { - byte |= 0x04; - } - self.send_media_key(&[0x01, byte]) - } - pub fn release_media_keys(&self) -> Result<()> { self.send_media_key(&[0x02, 0x00, 0x00, 0x00]) } @@ -843,13 +832,6 @@ impl Ch9329Backend { Ok(()) } - pub fn send_custom_hid(&self, data: &[u8]) -> Result<()> { - if data.len() > MAX_DATA_LEN { - return Err(AppError::Internal("Custom HID data too long".to_string())); - } - self.send_packet(cmd::SEND_MY_HID_DATA, data) - } - fn worker_loop( port_path: String, baud_rate: u32, @@ -860,7 +842,7 @@ impl Ch9329Backend { runtime: Arc, init_tx: mpsc::Sender>, ) { - runtime.initialized.store(true, Ordering::Relaxed); + runtime.set_initialized(true); let mut port = match Self::open_port(&port_path, baud_rate).and_then(|mut port| { let info = Self::query_chip_info_on_port(port.as_mut(), address)?; @@ -871,7 +853,9 @@ impl Ch9329Backend { "CH9329 serial port opened: {} @ {} baud", port_path, baud_rate ); - Self::update_chip_info_cache(&chip_info, &led_status, info.clone()); + if Self::update_chip_info_cache(&chip_info, &led_status, info.clone()) { + runtime.notify(); + } runtime.set_online(); let _ = init_tx.send(Ok(info)); port @@ -884,7 +868,7 @@ impl Ch9329Backend { runtime.set_error(reason.clone(), error_code.clone()); } let _ = init_tx.send(Err(err)); - runtime.initialized.store(false, Ordering::Relaxed); + runtime.set_initialized(false); return; } }; @@ -961,7 +945,9 @@ impl Ch9329Backend { Err(mpsc::RecvTimeoutError::Timeout) => { match Self::query_chip_info_on_port(port.as_mut(), address) { Ok(info) => { - Self::update_chip_info_cache(&chip_info, &led_status, info); + if Self::update_chip_info_cache(&chip_info, &led_status, info) { + runtime.notify(); + } runtime.set_online(); } Err(err) => { @@ -993,8 +979,8 @@ impl Ch9329Backend { } } - runtime.online.store(false, Ordering::Relaxed); - runtime.initialized.store(false, Ordering::Relaxed); + runtime.set_offline(); + runtime.set_initialized(false); } } @@ -1004,10 +990,6 @@ impl Ch9329Backend { #[async_trait] impl HidBackend for Ch9329Backend { - fn name(&self) -> &'static str { - "CH9329 Serial" - } - async fn init(&self) -> Result<()> { if self.worker_handle.lock().is_some() { return Ok(()); @@ -1047,7 +1029,7 @@ impl HidBackend for Ch9329Backend { ); *self.worker_tx.lock() = Some(tx); *self.worker_handle.lock() = Some(handle); - self.mark_online(); + self.runtime.set_online(); Ok(()) } Ok(Err(err)) => { @@ -1215,15 +1197,15 @@ impl HidBackend for Ch9329Backend { if let Some(handle) = self.worker_handle.lock().take() { let _ = handle.join(); } - self.runtime.initialized.store(false, Ordering::Relaxed); - self.runtime.online.store(false, Ordering::Relaxed); - self.clear_error(); + self.runtime.set_offline(); + self.runtime.set_initialized(false); + self.runtime.clear_error(); info!("CH9329 backend shutdown"); Ok(()) } - fn status(&self) -> HidBackendStatus { + fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot { let initialized = self.runtime.initialized.load(Ordering::Relaxed); let mut online = initialized && self.runtime.online.load(Ordering::Relaxed); let mut error = self.runtime.last_error.read().clone(); @@ -1236,25 +1218,36 @@ impl HidBackend for Ch9329Backend { )); } - HidBackendStatus { + HidBackendRuntimeSnapshot { initialized, online, + supports_absolute_mouse: true, + keyboard_leds_enabled: true, + led_state: { + let led = *self.led_status.read(); + LedState { + num_lock: led.num_lock, + caps_lock: led.caps_lock, + scroll_lock: led.scroll_lock, + compose: false, + kana: false, + } + }, + screen_resolution: Some((self.screen_width, self.screen_height)), + device: Some(self.port_path.clone()), error: error.as_ref().map(|(reason, _)| reason.clone()), error_code: error.as_ref().map(|(_, code)| code.clone()), } } - fn supports_absolute_mouse(&self) -> bool { - true - } - - fn screen_resolution(&self) -> Option<(u32, u32)> { - Some((self.screen_width, self.screen_height)) + fn subscribe_runtime(&self) -> watch::Receiver<()> { + self.runtime.subscribe() } fn set_screen_resolution(&mut self, width: u32, height: u32) { self.screen_width = width; self.screen_height = height; + self.runtime.notify(); } } diff --git a/src/hid/mod.rs b/src/hid/mod.rs index 5ebdd0a1..cddfdf6f 100644 --- a/src/hid/mod.rs +++ b/src/hid/mod.rs @@ -20,7 +20,7 @@ pub mod otg; pub mod types; pub mod websocket; -pub use backend::{HidBackend, HidBackendStatus, HidBackendType}; +pub use backend::{HidBackend, HidBackendRuntimeSnapshot, HidBackendType}; pub use keyboard::CanonicalKey; pub use otg::LedState; pub use types::{ @@ -54,6 +54,10 @@ pub struct HidRuntimeState { pub online: bool, /// Whether absolute mouse positioning is supported. pub supports_absolute_mouse: bool, + /// Whether keyboard LED/status feedback is enabled. + pub keyboard_leds_enabled: bool, + /// Last known keyboard LED state. + pub led_state: LedState, /// Screen resolution for absolute mouse mode. pub screen_resolution: Option<(u32, u32)>, /// Device path associated with the backend, if any. @@ -72,6 +76,8 @@ impl HidRuntimeState { initialized: false, online: false, supports_absolute_mouse: false, + keyboard_leds_enabled: false, + led_state: LedState::default(), screen_resolution: None, device: device_for_backend_type(backend_type), error: None, @@ -79,18 +85,21 @@ impl HidRuntimeState { } } - fn from_backend(backend_type: &HidBackendType, backend: &dyn HidBackend) -> Self { - let status = backend.status(); + fn from_backend(backend_type: &HidBackendType, snapshot: HidBackendRuntimeSnapshot) -> Self { Self { available: !matches!(backend_type, HidBackendType::None), backend: backend_type.name_str().to_string(), - initialized: status.initialized, - online: status.online, - supports_absolute_mouse: backend.supports_absolute_mouse(), - screen_resolution: backend.screen_resolution(), - device: device_for_backend_type(backend_type), - error: status.error, - error_code: status.error_code, + initialized: snapshot.initialized, + online: snapshot.online, + supports_absolute_mouse: snapshot.supports_absolute_mouse, + keyboard_leds_enabled: snapshot.keyboard_leds_enabled, + led_state: snapshot.led_state, + screen_resolution: snapshot.screen_resolution, + device: snapshot + .device + .or_else(|| device_for_backend_type(backend_type)), + error: snapshot.error, + error_code: snapshot.error_code, } } @@ -105,6 +114,8 @@ impl HidRuntimeState { next.backend = backend_type.name_str().to_string(); next.initialized = false; next.online = false; + next.keyboard_leds_enabled = false; + next.led_state = LedState::default(); next.device = device_for_backend_type(backend_type); next.error = Some(reason.into()); next.error_code = Some(error_code.into()); @@ -114,13 +125,13 @@ impl HidRuntimeState { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::time::Duration; use tokio::sync::RwLock; use tracing::{info, warn}; use crate::error::{AppError, Result}; use crate::events::EventBus; use crate::otg::OtgService; -use std::time::Duration; use tokio::sync::mpsc; use tokio::sync::Mutex; use tokio::task::JoinHandle; @@ -158,6 +169,8 @@ pub struct HidController { pending_move_flag: Arc, /// Worker task handle hid_worker: Mutex>>, + /// Backend runtime subscription task handle + runtime_worker: Mutex>>, /// Backend initialization fast flag backend_available: Arc, } @@ -181,6 +194,7 @@ impl HidController { pending_move: Arc::new(parking_lot::Mutex::new(None)), pending_move_flag: Arc::new(AtomicBool::new(false)), hid_worker: Mutex::new(None), + runtime_worker: Mutex::new(None), backend_available: Arc::new(AtomicBool::new(false)), } } @@ -195,16 +209,15 @@ impl HidController { let backend_type = self.backend_type.read().await.clone(); let backend: Arc = match backend_type { HidBackendType::Otg => { - // Request HID functions from OtgService let otg_service = self .otg_service .as_ref() .ok_or_else(|| AppError::Internal("OtgService not available".into()))?; - info!("Requesting HID functions from OtgService"); - let handles = otg_service.enable_hid().await?; + let handles = otg_service.hid_device_paths().await.ok_or_else(|| { + AppError::Config("OTG HID paths are not available".to_string()) + })?; - // Create OtgBackend from handles (no longer manages gadget itself) info!("Creating OTG HID backend from device paths"); Arc::new(otg::OtgBackend::from_handles(handles)?) } @@ -245,6 +258,7 @@ impl HidController { // Start HID event worker (once) self.start_event_worker().await; + self.restart_runtime_worker().await; info!("HID backend initialized: {:?}", backend_type); Ok(()) @@ -253,6 +267,7 @@ impl HidController { /// Shutdown the HID backend and release resources pub async fn shutdown(&self) -> Result<()> { info!("Shutting down HID controller"); + self.stop_runtime_worker().await; // Close the backend if let Some(backend) = self.backend.write().await.take() { @@ -271,14 +286,6 @@ impl HidController { } self.apply_runtime_state(shutdown_state).await; - // If OTG backend, notify OtgService to disable HID - if matches!(backend_type, HidBackendType::Otg) { - if let Some(ref otg_service) = self.otg_service { - info!("Disabling HID functions in OtgService"); - otg_service.disable_hid().await?; - } - } - info!("HID controller shutdown complete"); Ok(()) } @@ -365,6 +372,7 @@ impl HidController { pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> { info!("Reloading HID backend: {:?}", new_backend_type); self.backend_available.store(false, Ordering::Release); + self.stop_runtime_worker().await; // Shutdown existing backend first if let Some(backend) = self.backend.write().await.take() { @@ -389,9 +397,8 @@ impl HidController { } }; - // Request HID functions from OtgService - match otg_service.enable_hid().await { - Ok(handles) => { + match otg_service.hid_device_paths().await { + Some(handles) => { // Create OtgBackend from handles match otg::OtgBackend::from_handles(handles) { Ok(backend) => { @@ -403,29 +410,18 @@ impl HidController { } Err(e) => { warn!("Failed to initialize OTG backend: {}", e); - // Cleanup: disable HID in OtgService - if let Err(e2) = otg_service.disable_hid().await { - warn!( - "Failed to cleanup HID after init failure: {}", - e2 - ); - } None } } } Err(e) => { warn!("Failed to create OTG backend: {}", e); - // Cleanup: disable HID in OtgService - if let Err(e2) = otg_service.disable_hid().await { - warn!("Failed to cleanup HID after creation failure: {}", e2); - } None } } } - Err(e) => { - warn!("Failed to enable HID in OtgService: {}", e); + None => { + warn!("OTG HID paths are not available"); None } } @@ -478,6 +474,7 @@ impl HidController { *self.backend_type.write().await = new_backend_type.clone(); self.sync_runtime_state_from_backend().await; + self.restart_runtime_worker().await; Ok(()) } else { @@ -508,16 +505,14 @@ impl HidController { async fn sync_runtime_state_from_backend(&self) { let backend_opt = self.backend.read().await.clone(); - let backend_type = self.backend_type.read().await.clone(); - - let next = match backend_opt.as_ref() { - Some(backend) => HidRuntimeState::from_backend(&backend_type, backend.as_ref()), - None => HidRuntimeState::from_backend_type(&backend_type), - }; - - self.backend_available - .store(next.initialized, Ordering::Release); - self.apply_runtime_state(next).await; + apply_backend_runtime_state( + &self.backend_type, + &self.runtime_state, + &self.events, + self.backend_available.as_ref(), + backend_opt.as_deref(), + ) + .await; } async fn start_event_worker(&self) { @@ -533,10 +528,6 @@ impl HidController { }; let backend = self.backend.clone(); - let backend_type = self.backend_type.clone(); - let runtime_state = self.runtime_state.clone(); - let events = self.events.clone(); - let backend_available = self.backend_available.clone(); let pending_move = self.pending_move.clone(); let pending_move_flag = self.pending_move_flag.clone(); @@ -548,29 +539,13 @@ impl HidController { None => break, }; - process_hid_event( - event, - &backend, - &backend_type, - &runtime_state, - &events, - backend_available.as_ref(), - ) - .await; + process_hid_event(event, &backend).await; // After each event, flush latest move if pending if pending_move_flag.swap(false, Ordering::AcqRel) { let move_event = { pending_move.lock().take() }; if let Some(move_event) = move_event { - process_hid_event( - HidEvent::Mouse(move_event), - &backend, - &backend_type, - &runtime_state, - &events, - backend_available.as_ref(), - ) - .await; + process_hid_event(HidEvent::Mouse(move_event), &backend).await; } } } @@ -579,6 +554,46 @@ impl HidController { *worker_guard = Some(handle); } + async fn restart_runtime_worker(&self) { + self.stop_runtime_worker().await; + + let backend_opt = self.backend.read().await.clone(); + let Some(backend) = backend_opt else { + return; + }; + + let mut runtime_rx = backend.subscribe_runtime(); + let runtime_state = self.runtime_state.clone(); + let events = self.events.clone(); + let backend_available = self.backend_available.clone(); + let backend_type = self.backend_type.clone(); + + let handle = tokio::spawn(async move { + loop { + if runtime_rx.changed().await.is_err() { + break; + } + + apply_backend_runtime_state( + &backend_type, + &runtime_state, + &events, + backend_available.as_ref(), + Some(backend.as_ref()), + ) + .await; + } + }); + + *self.runtime_worker.lock().await = Some(handle); + } + + async fn stop_runtime_worker(&self) { + if let Some(handle) = self.runtime_worker.lock().await.take() { + handle.abort(); + } + } + fn enqueue_mouse_move(&self, event: MouseEvent) -> Result<()> { match self.hid_tx.try_send(HidEvent::Mouse(event.clone())) { Ok(_) => Ok(()), @@ -618,14 +633,23 @@ impl HidController { } } -async fn process_hid_event( - event: HidEvent, - backend: &Arc>>>, +async fn apply_backend_runtime_state( backend_type: &Arc>, runtime_state: &Arc>, events: &Arc>>>, backend_available: &AtomicBool, + backend: Option<&dyn HidBackend>, ) { + let backend_kind = backend_type.read().await.clone(); + let next = match backend { + Some(backend) => HidRuntimeState::from_backend(&backend_kind, backend.runtime_snapshot()), + None => HidRuntimeState::from_backend_type(&backend_kind), + }; + backend_available.store(next.initialized, Ordering::Release); + apply_runtime_state(runtime_state, events, next).await; +} + +async fn process_hid_event(event: HidEvent, backend: &Arc>>>) { let backend_opt = backend.read().await.clone(); let backend = match backend_opt { Some(b) => b, @@ -656,11 +680,6 @@ async fn process_hid_event( warn!("HID event processing failed: {}", e); } } - - let backend_kind = backend_type.read().await.clone(); - let next = HidRuntimeState::from_backend(&backend_kind, backend.as_ref()); - backend_available.store(next.initialized, Ordering::Release); - apply_runtime_state(runtime_state, events, next).await; } impl Default for HidController { diff --git a/src/hid/otg.rs b/src/hid/otg.rs index 696e4dba..fb0d52c9 100644 --- a/src/hid/otg.rs +++ b/src/hid/otg.rs @@ -1,10 +1,12 @@ //! OTG USB Gadget HID backend //! //! This backend uses Linux USB Gadget API to emulate USB HID devices. -//! It creates and manages three HID devices: -//! - hidg0: Keyboard (8-byte reports, with LED feedback) -//! - hidg1: Relative Mouse (4-byte reports) -//! - hidg2: Absolute Mouse (6-byte reports) +//! It opens the HID gadget device nodes created by `OtgService`. +//! Depending on the configured OTG profile, this may include: +//! - hidg0: Keyboard +//! - hidg1: Relative Mouse +//! - hidg2: Absolute Mouse +//! - hidg3: Consumer Control Keyboard //! //! Requirements: //! - USB OTG/Device controller (UDC) @@ -20,15 +22,20 @@ use async_trait::async_trait; use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::os::unix::fs::OpenOptionsExt; use std::os::unix::io::AsFd; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; +use tokio::sync::watch; use tracing::{debug, info, trace, warn}; -use super::backend::{HidBackend, HidBackendStatus}; +use super::backend::{HidBackend, HidBackendRuntimeSnapshot}; use super::types::{ ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType, }; @@ -45,7 +52,7 @@ enum DeviceType { } /// Keyboard LED state -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct LedState { /// Num Lock LED pub num_lock: bool, @@ -123,12 +130,14 @@ pub struct OtgBackend { mouse_abs_dev: Mutex>, /// Consumer control device file consumer_dev: Mutex>, + /// Whether keyboard LED/status feedback is enabled. + keyboard_leds_enabled: bool, /// Current keyboard state keyboard_state: Mutex, /// Current mouse button state mouse_buttons: AtomicU8, /// Last known LED state (using parking_lot::RwLock for sync access) - led_state: parking_lot::RwLock, + led_state: Arc>, /// Screen resolution for absolute mouse (using parking_lot::RwLock for sync access) screen_resolution: parking_lot::RwLock>, /// UDC name for state checking (e.g., "fcc00000.usb") @@ -145,6 +154,12 @@ pub struct OtgBackend { error_count: AtomicU8, /// Consecutive EAGAIN count (for offline threshold detection) eagain_count: AtomicU8, + /// Runtime change notifier. + runtime_notify_tx: watch::Sender<()>, + /// LED listener stop flag. + led_worker_stop: Arc, + /// Keyboard LED listener thread. + led_worker: Mutex>>, } /// Write timeout in milliseconds (same as JetKVM's hidWriteTimeout) @@ -156,6 +171,7 @@ impl OtgBackend { /// This is the ONLY way to create an OtgBackend - it no longer manages /// the USB gadget itself. The gadget must already be set up by OtgService. pub fn from_handles(paths: HidDevicePaths) -> Result { + let (runtime_notify_tx, _runtime_notify_rx) = watch::channel(()); Ok(Self { keyboard_path: paths.keyboard, mouse_rel_path: paths.mouse_relative, @@ -165,32 +181,57 @@ impl OtgBackend { mouse_rel_dev: Mutex::new(None), mouse_abs_dev: Mutex::new(None), consumer_dev: Mutex::new(None), + keyboard_leds_enabled: paths.keyboard_leds_enabled, keyboard_state: Mutex::new(KeyboardReport::default()), mouse_buttons: AtomicU8::new(0), - led_state: parking_lot::RwLock::new(LedState::default()), + led_state: Arc::new(parking_lot::RwLock::new(LedState::default())), screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))), - udc_name: parking_lot::RwLock::new(None), + udc_name: parking_lot::RwLock::new(paths.udc), initialized: AtomicBool::new(false), online: AtomicBool::new(false), last_error: parking_lot::RwLock::new(None), last_error_log: parking_lot::Mutex::new(std::time::Instant::now()), error_count: AtomicU8::new(0), eagain_count: AtomicU8::new(0), + runtime_notify_tx, + led_worker_stop: Arc::new(AtomicBool::new(false)), + led_worker: Mutex::new(None), }) } + fn notify_runtime_changed(&self) { + let _ = self.runtime_notify_tx.send(()); + } + fn clear_error(&self) { - *self.last_error.write() = None; + let mut error = self.last_error.write(); + if error.is_some() { + *error = None; + self.notify_runtime_changed(); + } } fn record_error(&self, reason: impl Into, error_code: impl Into) { - self.online.store(false, Ordering::Relaxed); - *self.last_error.write() = Some((reason.into(), error_code.into())); + let reason = reason.into(); + let error_code = error_code.into(); + let was_online = self.online.swap(false, Ordering::Relaxed); + let mut error = self.last_error.write(); + let changed = error.as_ref() != Some(&(reason.clone(), error_code.clone())); + *error = Some((reason, error_code)); + drop(error); + if was_online || changed { + self.notify_runtime_changed(); + } } fn mark_online(&self) { - self.online.store(true, Ordering::Relaxed); - self.clear_error(); + let was_online = self.online.swap(true, Ordering::Relaxed); + let mut error = self.last_error.write(); + let cleared_error = error.take().is_some(); + drop(error); + if !was_online || cleared_error { + self.notify_runtime_changed(); + } } /// Log throttled error message (max once per second) @@ -305,11 +346,6 @@ impl OtgBackend { None } - /// Check if device is online - pub fn is_online(&self) -> bool { - self.online.load(Ordering::Relaxed) - } - /// Ensure a device is open and ready for I/O /// /// This method is based on PiKVM's `__ensure_device()` pattern: @@ -750,49 +786,180 @@ impl OtgBackend { self.send_consumer_report(event.usage) } - /// Read keyboard LED state (non-blocking) - pub fn read_led_state(&self) -> Result> { - let mut dev = self.keyboard_dev.lock(); - if let Some(ref mut file) = *dev { - let mut buf = [0u8; 1]; - match file.read(&mut buf) { - Ok(1) => { - let state = LedState::from_byte(buf[0]); - // Update LED state (using parking_lot RwLock) - *self.led_state.write() = state; - Ok(Some(state)) - } - Ok(_) => Ok(None), // No data available - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None), - Err(e) => Err(AppError::Internal(format!( - "Failed to read LED state: {}", - e - ))), - } - } else { - Ok(None) - } - } - /// Get last known LED state pub fn led_state(&self) -> LedState { *self.led_state.read() } + + fn build_runtime_snapshot(&self) -> HidBackendRuntimeSnapshot { + let initialized = self.initialized.load(Ordering::Relaxed); + let mut online = initialized && self.online.load(Ordering::Relaxed); + let mut error = self.last_error.read().clone(); + + if initialized && !self.check_devices_exist() { + online = false; + let missing = self.get_missing_devices(); + error = Some(( + format!("HID device node missing: {}", missing.join(", ")), + "enoent".to_string(), + )); + } else if initialized && !self.is_udc_configured() { + online = false; + error = Some(( + "UDC is not in configured state".to_string(), + "udc_not_configured".to_string(), + )); + } + + HidBackendRuntimeSnapshot { + initialized, + online, + supports_absolute_mouse: self.mouse_abs_path.as_ref().is_some_and(|p| p.exists()), + keyboard_leds_enabled: self.keyboard_leds_enabled, + led_state: self.led_state(), + screen_resolution: *self.screen_resolution.read(), + device: self.udc_name.read().clone(), + error: error.as_ref().map(|(reason, _)| reason.clone()), + error_code: error.as_ref().map(|(_, code)| code.clone()), + } + } + + fn start_led_worker(&self) { + if !self.keyboard_leds_enabled { + return; + } + + let Some(path) = self.keyboard_path.clone() else { + return; + }; + + let mut worker = self.led_worker.lock(); + if worker.is_some() { + return; + } + + self.led_worker_stop.store(false, Ordering::Relaxed); + let stop = self.led_worker_stop.clone(); + let led_state = self.led_state.clone(); + let runtime_notify_tx = self.runtime_notify_tx.clone(); + + let handle = thread::Builder::new() + .name("otg-led-listener".to_string()) + .spawn(move || { + while !stop.load(Ordering::Relaxed) { + let mut file = match OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(&path) + { + Ok(file) => file, + Err(err) => { + warn!( + "Failed to open OTG keyboard LED listener {}: {}", + path.display(), + err + ); + let _ = runtime_notify_tx.send(()); + thread::sleep(Duration::from_millis(500)); + continue; + } + }; + + while !stop.load(Ordering::Relaxed) { + let mut pollfd = [PollFd::new( + file.as_fd(), + PollFlags::POLLIN | PollFlags::POLLERR | PollFlags::POLLHUP, + )]; + + match poll(&mut pollfd, PollTimeout::from(500u16)) { + Ok(0) => continue, + Ok(_) => { + let Some(revents) = pollfd[0].revents() else { + continue; + }; + + if revents.contains(PollFlags::POLLERR) + || revents.contains(PollFlags::POLLHUP) + { + let _ = runtime_notify_tx.send(()); + break; + } + + if !revents.contains(PollFlags::POLLIN) { + continue; + } + + let mut buf = [0u8; 1]; + match file.read(&mut buf) { + Ok(1) => { + let next = LedState::from_byte(buf[0]); + let changed = { + let mut guard = led_state.write(); + if *guard == next { + false + } else { + *guard = next; + true + } + }; + if changed { + let _ = runtime_notify_tx.send(()); + } + } + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {} + Err(err) => { + warn!("OTG keyboard LED listener read failed: {}", err); + let _ = runtime_notify_tx.send(()); + break; + } + } + } + Err(err) => { + warn!("OTG keyboard LED listener poll failed: {}", err); + let _ = runtime_notify_tx.send(()); + break; + } + } + } + + if !stop.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(100)); + } + } + }); + + match handle { + Ok(handle) => { + *worker = Some(handle); + } + Err(err) => { + warn!("Failed to spawn OTG keyboard LED listener: {}", err); + } + } + } + + fn stop_led_worker(&self) { + self.led_worker_stop.store(true, Ordering::Relaxed); + if let Some(handle) = self.led_worker.lock().take() { + let _ = handle.join(); + } + } } #[async_trait] impl HidBackend for OtgBackend { - fn name(&self) -> &'static str { - "OTG USB Gadget" - } - async fn init(&self) -> Result<()> { info!("Initializing OTG HID backend"); - // Auto-detect UDC name for state checking - if let Some(udc) = Self::find_udc() { - info!("Auto-detected UDC: {}", udc); - self.set_udc_name(&udc); + // Auto-detect UDC name for state checking only if OtgService did not provide one + if self.udc_name.read().is_none() { + if let Some(udc) = Self::find_udc() { + info!("Auto-detected UDC: {}", udc); + self.set_udc_name(&udc); + } + } else if let Some(udc) = self.udc_name.read().clone() { + info!("Using configured UDC: {}", udc); } // Wait for devices to appear (they should already exist from OtgService) @@ -866,6 +1033,8 @@ impl HidBackend for OtgBackend { // Mark as online if all devices opened successfully self.initialized.store(true, Ordering::Relaxed); + self.notify_runtime_changed(); + self.start_led_worker(); self.mark_online(); Ok(()) @@ -974,6 +1143,8 @@ impl HidBackend for OtgBackend { } async fn shutdown(&self) -> Result<()> { + self.stop_led_worker(); + // Reset before closing self.reset().await?; @@ -987,53 +1158,27 @@ impl HidBackend for OtgBackend { self.initialized.store(false, Ordering::Relaxed); self.online.store(false, Ordering::Relaxed); self.clear_error(); + self.notify_runtime_changed(); info!("OTG backend shutdown"); Ok(()) } - fn status(&self) -> HidBackendStatus { - let initialized = self.initialized.load(Ordering::Relaxed); - let mut online = initialized && self.online.load(Ordering::Relaxed); - let mut error = self.last_error.read().clone(); - - if initialized && !self.check_devices_exist() { - online = false; - let missing = self.get_missing_devices(); - error = Some(( - format!("HID device node missing: {}", missing.join(", ")), - "enoent".to_string(), - )); - } else if initialized && !self.is_udc_configured() { - online = false; - error = Some(( - "UDC is not in configured state".to_string(), - "udc_not_configured".to_string(), - )); - } - - HidBackendStatus { - initialized, - online, - error: error.as_ref().map(|(reason, _)| reason.clone()), - error_code: error.as_ref().map(|(_, code)| code.clone()), - } + fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot { + self.build_runtime_snapshot() } - fn supports_absolute_mouse(&self) -> bool { - self.mouse_abs_path.as_ref().is_some_and(|p| p.exists()) + fn subscribe_runtime(&self) -> watch::Receiver<()> { + self.runtime_notify_tx.subscribe() } async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> { self.send_consumer_report(event.usage) } - fn screen_resolution(&self) -> Option<(u32, u32)> { - *self.screen_resolution.read() - } - fn set_screen_resolution(&mut self, width: u32, height: u32) { *self.screen_resolution.write() = Some((width, height)); + self.notify_runtime_changed(); } } @@ -1050,6 +1195,10 @@ pub fn is_otg_available() -> bool { /// Implement Drop for OtgBackend to close device files impl Drop for OtgBackend { fn drop(&mut self) { + self.led_worker_stop.store(true, Ordering::Relaxed); + if let Some(handle) = self.led_worker.get_mut().take() { + let _ = handle.join(); + } // Close device files // Note: Gadget cleanup is handled by OtgService, not here *self.keyboard_dev.lock() = None; diff --git a/src/main.rs b/src/main.rs index ca82ad36..7f518ec9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ use one_kvm::events::EventBus; use one_kvm::extensions::ExtensionManager; use one_kvm::hid::{HidBackendType, HidController}; use one_kvm::msd::MsdController; -use one_kvm::otg::{configfs, OtgService}; +use one_kvm::otg::OtgService; use one_kvm::rtsp::RtspService; use one_kvm::rustdesk::RustDeskService; use one_kvm::state::AppState; @@ -319,32 +319,9 @@ async fn main() -> anyhow::Result<()> { let otg_service = Arc::new(OtgService::new()); tracing::info!("OTG Service created"); - // Pre-enable OTG functions to avoid gadget recreation (prevents kernel crashes) - let will_use_otg_hid = matches!(config.hid.backend, config::HidBackend::Otg); - let will_use_msd = config.msd.enabled; - - if will_use_otg_hid { - let mut hid_functions = config.hid.effective_otg_functions(); - if let Some(udc) = configfs::resolve_udc_name(config.hid.otg_udc.as_deref()) { - if configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer { - tracing::warn!( - "UDC {} has low endpoint resources, disabling consumer control", - udc - ); - hid_functions.consumer = false; - } - } - if let Err(e) = otg_service.update_hid_functions(hid_functions).await { - tracing::warn!("Failed to apply HID functions: {}", e); - } - if let Err(e) = otg_service.enable_hid().await { - tracing::warn!("Failed to pre-enable HID: {}", e); - } - } - if will_use_msd { - if let Err(e) = otg_service.enable_msd().await { - tracing::warn!("Failed to pre-enable MSD: {}", e); - } + // Reconcile OTG once from the persisted config so controllers only consume its result. + if let Err(e) = otg_service.apply_config(&config.hid, &config.msd).await { + tracing::warn!("Failed to apply OTG config: {}", e); } // Create HID controller based on config diff --git a/src/msd/controller.rs b/src/msd/controller.rs index d2e039bd..0be5428d 100644 --- a/src/msd/controller.rs +++ b/src/msd/controller.rs @@ -19,9 +19,6 @@ use super::types::{DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MsdMo use crate::error::{AppError, Result}; use crate::otg::{MsdFunction, MsdLunConfig, OtgService}; -/// USB Gadget path (system constant) -const GADGET_PATH: &str = "/sys/kernel/config/usb_gadget/one-kvm"; - /// MSD Controller pub struct MsdController { /// OTG Service reference @@ -83,9 +80,11 @@ impl MsdController { warn!("Failed to create ventoy directory: {}", e); } - // 2. Request MSD function from OtgService - info!("Requesting MSD function from OtgService"); - let msd_func = self.otg_service.enable_msd().await?; + // 2. Get active MSD function from OtgService + info!("Fetching MSD function from OtgService"); + let msd_func = self.otg_service.msd_function().await.ok_or_else(|| { + AppError::Internal("MSD function is not active in OtgService".to_string()) + })?; // 3. Store function handle *self.msd_function.write().await = Some(msd_func); @@ -190,7 +189,7 @@ impl MsdController { MsdLunConfig::disk(image.path.clone(), read_only) }; - let gadget_path = PathBuf::from(GADGET_PATH); + let gadget_path = self.active_gadget_path().await?; if let Some(ref msd) = *self.msd_function.read().await { if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await { let error_msg = format!("Failed to configure LUN: {}", e); @@ -264,7 +263,7 @@ impl MsdController { // Configure LUN as read-write disk let config = MsdLunConfig::disk(self.drive_path.clone(), false); - let gadget_path = PathBuf::from(GADGET_PATH); + let gadget_path = self.active_gadget_path().await?; if let Some(ref msd) = *self.msd_function.read().await { if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await { let error_msg = format!("Failed to configure LUN: {}", e); @@ -313,7 +312,7 @@ impl MsdController { return Ok(()); } - let gadget_path = PathBuf::from(GADGET_PATH); + let gadget_path = self.active_gadget_path().await?; if let Some(ref msd) = *self.msd_function.read().await { msd.disconnect_lun_async(&gadget_path, 0).await?; } @@ -512,6 +511,13 @@ impl MsdController { downloads.keys().cloned().collect() } + async fn active_gadget_path(&self) -> Result { + self.otg_service + .gadget_path() + .await + .ok_or_else(|| AppError::Internal("OTG gadget path is not available".to_string())) + } + /// Shutdown the controller pub async fn shutdown(&self) -> Result<()> { info!("Shutting down MSD controller"); @@ -521,11 +527,7 @@ impl MsdController { warn!("Error disconnecting during shutdown: {}", e); } - // 2. Notify OtgService to disable MSD - info!("Disabling MSD function in OtgService"); - self.otg_service.disable_msd().await?; - - // 3. Clear local state + // 2. Clear local state *self.msd_function.write().await = None; let mut state = self.state.write().await; diff --git a/src/otg/hid.rs b/src/otg/hid.rs index 38283b79..1c6c236a 100644 --- a/src/otg/hid.rs +++ b/src/otg/hid.rs @@ -7,14 +7,15 @@ use super::configfs::{ create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file, }; use super::function::{FunctionMeta, GadgetFunction}; -use super::report_desc::{CONSUMER_CONTROL, KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE}; +use super::report_desc::{ + CONSUMER_CONTROL, KEYBOARD, KEYBOARD_WITH_LED, MOUSE_ABSOLUTE, MOUSE_RELATIVE, +}; use crate::error::Result; /// HID function type #[derive(Debug, Clone)] pub enum HidFunctionType { - /// Keyboard (no LED feedback) - /// Uses 1 endpoint: IN + /// Keyboard Keyboard, /// Relative mouse (traditional mouse movement) /// Uses 1 endpoint: IN @@ -28,7 +29,7 @@ pub enum HidFunctionType { } impl HidFunctionType { - /// Get endpoints required for this function type + /// Get the base endpoint cost for this function type. pub fn endpoints(&self) -> u8 { match self { HidFunctionType::Keyboard => 1, @@ -59,7 +60,7 @@ impl HidFunctionType { } /// Get report length in bytes - pub fn report_length(&self) -> u8 { + pub fn report_length(&self, _keyboard_leds: bool) -> u8 { match self { HidFunctionType::Keyboard => 8, HidFunctionType::MouseRelative => 4, @@ -69,9 +70,15 @@ impl HidFunctionType { } /// Get report descriptor - pub fn report_desc(&self) -> &'static [u8] { + pub fn report_desc(&self, keyboard_leds: bool) -> &'static [u8] { match self { - HidFunctionType::Keyboard => KEYBOARD, + HidFunctionType::Keyboard => { + if keyboard_leds { + KEYBOARD_WITH_LED + } else { + KEYBOARD + } + } HidFunctionType::MouseRelative => MOUSE_RELATIVE, HidFunctionType::MouseAbsolute => MOUSE_ABSOLUTE, HidFunctionType::ConsumerControl => CONSUMER_CONTROL, @@ -98,15 +105,18 @@ pub struct HidFunction { func_type: HidFunctionType, /// Cached function name (avoids repeated allocation) name: String, + /// Whether keyboard LED/status feedback is enabled. + keyboard_leds: bool, } impl HidFunction { /// Create a keyboard function - pub fn keyboard(instance: u8) -> Self { + pub fn keyboard(instance: u8, keyboard_leds: bool) -> Self { Self { instance, func_type: HidFunctionType::Keyboard, name: format!("hid.usb{}", instance), + keyboard_leds, } } @@ -116,6 +126,7 @@ impl HidFunction { instance, func_type: HidFunctionType::MouseRelative, name: format!("hid.usb{}", instance), + keyboard_leds: false, } } @@ -125,6 +136,7 @@ impl HidFunction { instance, func_type: HidFunctionType::MouseAbsolute, name: format!("hid.usb{}", instance), + keyboard_leds: false, } } @@ -134,6 +146,7 @@ impl HidFunction { instance, func_type: HidFunctionType::ConsumerControl, name: format!("hid.usb{}", instance), + keyboard_leds: false, } } @@ -181,11 +194,14 @@ impl GadgetFunction for HidFunction { )?; write_file( &func_path.join("report_length"), - &self.func_type.report_length().to_string(), + &self.func_type.report_length(self.keyboard_leds).to_string(), )?; // Write report descriptor - write_bytes(&func_path.join("report_desc"), self.func_type.report_desc())?; + write_bytes( + &func_path.join("report_desc"), + self.func_type.report_desc(self.keyboard_leds), + )?; debug!( "Created HID function: {} at {}", @@ -232,14 +248,15 @@ mod tests { assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1); assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1); - assert_eq!(HidFunctionType::Keyboard.report_length(), 8); - assert_eq!(HidFunctionType::MouseRelative.report_length(), 4); - assert_eq!(HidFunctionType::MouseAbsolute.report_length(), 6); + assert_eq!(HidFunctionType::Keyboard.report_length(false), 8); + assert_eq!(HidFunctionType::Keyboard.report_length(true), 8); + assert_eq!(HidFunctionType::MouseRelative.report_length(false), 4); + assert_eq!(HidFunctionType::MouseAbsolute.report_length(false), 6); } #[test] fn test_hid_function_names() { - let kb = HidFunction::keyboard(0); + let kb = HidFunction::keyboard(0, false); assert_eq!(kb.name(), "hid.usb0"); assert_eq!(kb.device_path(), PathBuf::from("/dev/hidg0")); diff --git a/src/otg/manager.rs b/src/otg/manager.rs index 9996238a..b120f37f 100644 --- a/src/otg/manager.rs +++ b/src/otg/manager.rs @@ -19,7 +19,7 @@ use crate::error::{AppError, Result}; const REBIND_DELAY_MS: u64 = 300; /// USB Gadget device descriptor configuration -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct GadgetDescriptor { pub vendor_id: u16, pub product_id: u16, @@ -131,8 +131,8 @@ impl OtgGadgetManager { /// Add keyboard function /// Returns the expected device path (e.g., /dev/hidg0) - pub fn add_keyboard(&mut self) -> Result { - let func = HidFunction::keyboard(self.hid_instance); + pub fn add_keyboard(&mut self, keyboard_leds: bool) -> Result { + let func = HidFunction::keyboard(self.hid_instance, keyboard_leds); let device_path = func.device_path(); self.add_function(Box::new(func))?; self.hid_instance += 1; @@ -245,12 +245,8 @@ impl OtgGadgetManager { Ok(()) } - /// Bind gadget to UDC - pub fn bind(&mut self) -> Result<()> { - let udc = Self::find_udc().ok_or_else(|| { - AppError::Internal("No USB Device Controller (UDC) found".to_string()) - })?; - + /// Bind gadget to a specific UDC + pub fn bind(&mut self, udc: &str) -> Result<()> { // Recreate config symlinks before binding to avoid kernel gadget issues after rebind if let Err(e) = self.recreate_config_links() { warn!("Failed to recreate gadget config links before bind: {}", e); @@ -258,7 +254,7 @@ impl OtgGadgetManager { info!("Binding gadget to UDC: {}", udc); write_file(&self.gadget_path.join("UDC"), &udc)?; - self.bound_udc = Some(udc); + self.bound_udc = Some(udc.to_string()); std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS)); Ok(()) @@ -504,7 +500,7 @@ mod tests { let mut manager = OtgGadgetManager::with_config("test", 8); // Keyboard uses 1 endpoint - let _ = manager.add_keyboard(); + let _ = manager.add_keyboard(false); assert_eq!(manager.endpoint_allocator.used(), 1); // Mouse uses 1 endpoint each diff --git a/src/otg/mod.rs b/src/otg/mod.rs index 91d7b0eb..f016bc89 100644 --- a/src/otg/mod.rs +++ b/src/otg/mod.rs @@ -32,4 +32,4 @@ pub use hid::{HidFunction, HidFunctionType}; pub use manager::{wait_for_hid_devices, OtgGadgetManager}; pub use msd::{MsdFunction, MsdLunConfig}; pub use report_desc::{KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE}; -pub use service::{HidDevicePaths, OtgService, OtgServiceState}; +pub use service::{HidDevicePaths, OtgDesiredState, OtgService, OtgServiceState}; diff --git a/src/otg/report_desc.rs b/src/otg/report_desc.rs index 45871e2b..62fa0dc6 100644 --- a/src/otg/report_desc.rs +++ b/src/otg/report_desc.rs @@ -1,6 +1,6 @@ //! HID Report Descriptors -/// Keyboard HID Report Descriptor (no LED output - saves 1 endpoint) +/// Keyboard HID Report Descriptor (no LED output) /// Report format (8 bytes input): /// [0] Modifier keys (8 bits) /// [1] Reserved @@ -34,6 +34,53 @@ pub const KEYBOARD: &[u8] = &[ 0xC0, // End Collection ]; +/// Keyboard HID Report Descriptor with LED output support. +/// Input report format (8 bytes): +/// [0] Modifier keys (8 bits) +/// [1] Reserved +/// [2-7] Key codes (6 keys) +/// Output report format (1 byte): +/// [0] Num Lock / Caps Lock / Scroll Lock / Compose / Kana +pub const KEYBOARD_WITH_LED: &[u8] = &[ + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x06, // Usage (Keyboard) + 0xA1, 0x01, // Collection (Application) + // Modifier keys input (8 bits) + 0x05, 0x07, // Usage Page (Key Codes) + 0x19, 0xE0, // Usage Minimum (224) - Left Control + 0x29, 0xE7, // Usage Maximum (231) - Right GUI + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x75, 0x01, // Report Size (1) + 0x95, 0x08, // Report Count (8) + 0x81, 0x02, // Input (Data, Variable, Absolute) - Modifier byte + // Reserved byte + 0x95, 0x01, // Report Count (1) + 0x75, 0x08, // Report Size (8) + 0x81, 0x01, // Input (Constant) - Reserved byte + // LED output bits + 0x95, 0x05, // Report Count (5) + 0x75, 0x01, // Report Size (1) + 0x05, 0x08, // Usage Page (LEDs) + 0x19, 0x01, // Usage Minimum (1) + 0x29, 0x05, // Usage Maximum (5) + 0x91, 0x02, // Output (Data, Variable, Absolute) + // LED padding + 0x95, 0x01, // Report Count (1) + 0x75, 0x03, // Report Size (3) + 0x91, 0x01, // Output (Constant) + // Key array (6 bytes) + 0x95, 0x06, // Report Count (6) + 0x75, 0x08, // Report Size (8) + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x00, // Logical Maximum (255) + 0x05, 0x07, // Usage Page (Key Codes) + 0x19, 0x00, // Usage Minimum (0) + 0x2A, 0xFF, 0x00, // Usage Maximum (255) + 0x81, 0x00, // Input (Data, Array) - Key array (6 keys) + 0xC0, // End Collection +]; + /// Relative Mouse HID Report Descriptor (4 bytes report) /// Report format: /// [0] Buttons (5 bits) + padding (3 bits) @@ -155,6 +202,7 @@ mod tests { #[test] fn test_report_descriptor_sizes() { assert!(!KEYBOARD.is_empty()); + assert!(!KEYBOARD_WITH_LED.is_empty()); assert!(!MOUSE_RELATIVE.is_empty()); assert!(!MOUSE_ABSOLUTE.is_empty()); assert!(!CONSUMER_CONTROL.is_empty()); diff --git a/src/otg/service.rs b/src/otg/service.rs index 7885ae0f..1166f48d 100644 --- a/src/otg/service.rs +++ b/src/otg/service.rs @@ -1,39 +1,18 @@ //! OTG Service - unified gadget lifecycle management //! //! This module provides centralized management for USB OTG gadget functions. -//! It solves the ownership problem where both HID and MSD need access to the -//! same USB gadget but should be independently configurable. -//! -//! Architecture: -//! ```text -//! ┌─────────────────────────┐ -//! │ OtgService │ -//! │ ┌───────────────────┐ │ -//! │ │ OtgGadgetManager │ │ -//! │ └───────────────────┘ │ -//! │ ↓ ↓ │ -//! │ ┌─────┐ ┌─────┐ │ -//! │ │ HID │ │ MSD │ │ -//! │ └─────┘ └─────┘ │ -//! └─────────────────────────┘ -//! ↑ ↑ -//! HidController MsdController -//! ``` +//! It is the single owner of the USB gadget desired state and reconciles +//! ConfigFS to match that state. use std::path::PathBuf; -use std::sync::atomic::{AtomicU8, Ordering}; use tokio::sync::{Mutex, RwLock}; use tracing::{debug, info, warn}; use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager}; use super::msd::MsdFunction; -use crate::config::{OtgDescriptorConfig, OtgHidFunctions}; +use crate::config::{HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions}; use crate::error::{AppError, Result}; -/// Bitflags for requested functions (lock-free) -const FLAG_HID: u8 = 0b01; -const FLAG_MSD: u8 = 0b10; - /// HID device paths #[derive(Debug, Clone, Default)] pub struct HidDevicePaths { @@ -41,6 +20,8 @@ pub struct HidDevicePaths { pub mouse_relative: Option, pub mouse_absolute: Option, pub consumer: Option, + pub udc: Option, + pub keyboard_leds_enabled: bool, } impl HidDevicePaths { @@ -62,6 +43,59 @@ impl HidDevicePaths { } } +/// Desired OTG gadget state derived from configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OtgDesiredState { + pub udc: Option, + pub descriptor: GadgetDescriptor, + pub hid_functions: Option, + pub keyboard_leds: bool, + pub msd_enabled: bool, + pub max_endpoints: u8, +} + +impl Default for OtgDesiredState { + fn default() -> Self { + Self { + udc: None, + descriptor: GadgetDescriptor::default(), + hid_functions: None, + keyboard_leds: false, + msd_enabled: false, + max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS, + } + } +} + +impl OtgDesiredState { + pub fn from_config(hid: &HidConfig, msd: &MsdConfig) -> Result { + let hid_functions = if hid.backend == HidBackend::Otg { + let functions = hid.constrained_otg_functions(); + Some(functions) + } else { + None + }; + + hid.validate_otg_endpoint_budget(msd.enabled)?; + + Ok(Self { + udc: hid.resolved_otg_udc(), + descriptor: GadgetDescriptor::from(&hid.otg_descriptor), + hid_functions, + keyboard_leds: hid.effective_otg_keyboard_leds(), + msd_enabled: msd.enabled, + max_endpoints: hid + .resolved_otg_endpoint_limit() + .unwrap_or(super::endpoint::DEFAULT_MAX_ENDPOINTS), + }) + } + + #[inline] + pub fn hid_enabled(&self) -> bool { + self.hid_functions.is_some() + } +} + /// OTG Service state #[derive(Debug, Clone, Default)] pub struct OtgServiceState { @@ -71,19 +105,23 @@ pub struct OtgServiceState { pub hid_enabled: bool, /// Whether MSD function is enabled pub msd_enabled: bool, + /// Bound UDC name + pub configured_udc: Option, /// HID device paths (set after gadget setup) pub hid_paths: Option, /// HID function selection (set after gadget setup) pub hid_functions: Option, + /// Whether keyboard LED/status feedback is enabled. + pub keyboard_leds_enabled: bool, + /// Applied endpoint budget. + pub max_endpoints: u8, + /// Applied descriptor configuration + pub descriptor: Option, /// Error message if setup failed pub error: Option, } /// OTG Service - unified gadget lifecycle management -/// -/// This service owns the OtgGadgetManager and provides a high-level interface -/// for enabling/disabling HID and MSD functions. It ensures proper coordination -/// between the two subsystems and handles gadget lifecycle management. pub struct OtgService { /// The underlying gadget manager manager: Mutex>, @@ -91,12 +129,8 @@ pub struct OtgService { state: RwLock, /// MSD function handle (for runtime LUN configuration) msd_function: RwLock>, - /// Requested functions flags (atomic, lock-free read/write) - requested_flags: AtomicU8, - /// Requested HID function set - hid_functions: RwLock, - /// Current descriptor configuration - current_descriptor: RwLock, + /// Desired OTG state + desired: RwLock, } impl OtgService { @@ -106,41 +140,7 @@ impl OtgService { manager: Mutex::new(None), state: RwLock::new(OtgServiceState::default()), msd_function: RwLock::new(None), - requested_flags: AtomicU8::new(0), - hid_functions: RwLock::new(OtgHidFunctions::default()), - current_descriptor: RwLock::new(GadgetDescriptor::default()), - } - } - - /// Check if HID is requested (lock-free) - #[inline] - fn is_hid_requested(&self) -> bool { - self.requested_flags.load(Ordering::Acquire) & FLAG_HID != 0 - } - - /// Check if MSD is requested (lock-free) - #[inline] - fn is_msd_requested(&self) -> bool { - self.requested_flags.load(Ordering::Acquire) & FLAG_MSD != 0 - } - - /// Set HID requested flag (lock-free) - #[inline] - fn set_hid_requested(&self, requested: bool) { - if requested { - self.requested_flags.fetch_or(FLAG_HID, Ordering::Release); - } else { - self.requested_flags.fetch_and(!FLAG_HID, Ordering::Release); - } - } - - /// Set MSD requested flag (lock-free) - #[inline] - fn set_msd_requested(&self, requested: bool) { - if requested { - self.requested_flags.fetch_or(FLAG_MSD, Ordering::Release); - } else { - self.requested_flags.fetch_and(!FLAG_MSD, Ordering::Release); + desired: RwLock::new(OtgDesiredState::default()), } } @@ -180,220 +180,81 @@ impl OtgService { self.state.read().await.hid_paths.clone() } - /// Get current HID function selection - pub async fn hid_functions(&self) -> OtgHidFunctions { - self.hid_functions.read().await.clone() - } - - /// Update HID function selection - pub async fn update_hid_functions(&self, functions: OtgHidFunctions) -> Result<()> { - if functions.is_empty() { - return Err(AppError::BadRequest( - "OTG HID functions cannot be empty".to_string(), - )); - } - - { - let mut current = self.hid_functions.write().await; - if *current == functions { - return Ok(()); - } - *current = functions; - } - - // If HID is active, recreate gadget with new function set - if self.is_hid_requested() { - self.recreate_gadget().await?; - } - - Ok(()) - } - /// Get MSD function handle (for LUN configuration) pub async fn msd_function(&self) -> Option { self.msd_function.read().await.clone() } - /// Enable HID functions - /// - /// This will create the gadget if not already created, add HID functions, - /// and bind the gadget to UDC. - pub async fn enable_hid(&self) -> Result { - info!("Enabling HID functions via OtgService"); - - // Mark HID as requested (lock-free) - self.set_hid_requested(true); - - // Check if already enabled and function set unchanged - let requested_functions = self.hid_functions.read().await.clone(); - { - let state = self.state.read().await; - if state.hid_enabled && state.hid_functions.as_ref() == Some(&requested_functions) { - if let Some(ref paths) = state.hid_paths { - info!("HID already enabled, returning existing paths"); - return Ok(paths.clone()); - } - } - } - - // Recreate gadget with both HID and MSD if needed - self.recreate_gadget().await?; - - // Get HID paths from state - let state = self.state.read().await; - state - .hid_paths - .clone() - .ok_or_else(|| AppError::Internal("HID paths not set after gadget setup".to_string())) + /// Apply desired OTG state derived from the current application config. + pub async fn apply_config(&self, hid: &HidConfig, msd: &MsdConfig) -> Result<()> { + let desired = OtgDesiredState::from_config(hid, msd)?; + self.apply_desired_state(desired).await } - /// Disable HID functions - /// - /// This will unbind the gadget, remove HID functions, and optionally - /// recreate the gadget with only MSD if MSD is still enabled. - pub async fn disable_hid(&self) -> Result<()> { - info!("Disabling HID functions via OtgService"); - - // Mark HID as not requested (lock-free) - self.set_hid_requested(false); - - // Check if HID is enabled + /// Apply a fully materialized desired OTG state. + pub async fn apply_desired_state(&self, desired: OtgDesiredState) -> Result<()> { { - let state = self.state.read().await; - if !state.hid_enabled { - info!("HID already disabled"); - return Ok(()); - } + let mut current = self.desired.write().await; + *current = desired; } - // Recreate gadget without HID (or destroy if MSD also disabled) - self.recreate_gadget().await + self.reconcile_gadget().await } - /// Enable MSD function - /// - /// This will create the gadget if not already created, add MSD function, - /// and bind the gadget to UDC. - pub async fn enable_msd(&self) -> Result { - info!("Enabling MSD function via OtgService"); - - // Mark MSD as requested (lock-free) - self.set_msd_requested(true); - - // Check if already enabled - { - let state = self.state.read().await; - if state.msd_enabled { - let msd = self.msd_function.read().await; - if let Some(ref func) = *msd { - info!("MSD already enabled, returning existing function"); - return Ok(func.clone()); - } - } - } - - // Recreate gadget with both HID and MSD if needed - self.recreate_gadget().await?; - - // Get MSD function - let msd = self.msd_function.read().await; - msd.clone().ok_or_else(|| { - AppError::Internal("MSD function not set after gadget setup".to_string()) - }) - } - - /// Disable MSD function - /// - /// This will unbind the gadget, remove MSD function, and optionally - /// recreate the gadget with only HID if HID is still enabled. - pub async fn disable_msd(&self) -> Result<()> { - info!("Disabling MSD function via OtgService"); - - // Mark MSD as not requested (lock-free) - self.set_msd_requested(false); - - // Check if MSD is enabled - { - let state = self.state.read().await; - if !state.msd_enabled { - info!("MSD already disabled"); - return Ok(()); - } - } - - // Recreate gadget without MSD (or destroy if HID also disabled) - self.recreate_gadget().await - } - - /// Recreate the gadget with currently requested functions - /// - /// This is called whenever the set of enabled functions changes. - /// It will: - /// 1. Check if recreation is needed (function set changed) - /// 2. If needed: cleanup existing gadget - /// 3. Create new gadget with requested functions - /// 4. Setup and bind - async fn recreate_gadget(&self) -> Result<()> { - // Read requested flags atomically (lock-free) - let hid_requested = self.is_hid_requested(); - let msd_requested = self.is_msd_requested(); - let hid_functions = if hid_requested { - self.hid_functions.read().await.clone() - } else { - OtgHidFunctions::default() - }; + async fn reconcile_gadget(&self) -> Result<()> { + let desired = self.desired.read().await.clone(); info!( - "Recreating gadget with: HID={}, MSD={}", - hid_requested, msd_requested + "Reconciling OTG gadget: HID={}, MSD={}, UDC={:?}", + desired.hid_enabled(), + desired.msd_enabled, + desired.udc ); - // Check if gadget already matches requested state { let state = self.state.read().await; - let functions_match = if hid_requested { - state.hid_functions.as_ref() == Some(&hid_functions) - } else { - state.hid_functions.is_none() - }; if state.gadget_active - && state.hid_enabled == hid_requested - && state.msd_enabled == msd_requested - && functions_match + && state.hid_enabled == desired.hid_enabled() + && state.msd_enabled == desired.msd_enabled + && state.configured_udc == desired.udc + && state.hid_functions == desired.hid_functions + && state.keyboard_leds_enabled == desired.keyboard_leds + && state.max_endpoints == desired.max_endpoints + && state.descriptor.as_ref() == Some(&desired.descriptor) { - info!("Gadget already has requested functions, skipping recreate"); + info!("OTG gadget already matches desired state"); return Ok(()); } } - // Cleanup existing gadget { let mut manager = self.manager.lock().await; if let Some(mut m) = manager.take() { - info!("Cleaning up existing gadget before recreate"); + info!("Cleaning up existing gadget before OTG reconcile"); if let Err(e) = m.cleanup() { warn!("Error cleaning up existing gadget: {}", e); } } } - // Clear MSD function *self.msd_function.write().await = None; - // Update state to inactive { let mut state = self.state.write().await; state.gadget_active = false; state.hid_enabled = false; state.msd_enabled = false; + state.configured_udc = None; state.hid_paths = None; state.hid_functions = None; + state.keyboard_leds_enabled = false; + state.max_endpoints = super::endpoint::DEFAULT_MAX_ENDPOINTS; + state.descriptor = None; state.error = None; } - // If nothing requested, we're done - if !hid_requested && !msd_requested { - info!("No functions requested, gadget destroyed"); + if !desired.hid_enabled() && !desired.msd_enabled { + info!("OTG desired state is empty, gadget removed"); return Ok(()); } @@ -401,41 +262,37 @@ impl OtgService { warn!("Failed to ensure libcomposite is available: {}", e); } - // Check if OTG is available - if !Self::is_available() { - let error = "OTG not available: ConfigFS not mounted or no UDC found".to_string(); - let mut state = self.state.write().await; - state.error = Some(error.clone()); + if !OtgGadgetManager::is_available() { + let error = "OTG not available: ConfigFS not mounted".to_string(); + self.state.write().await.error = Some(error.clone()); return Err(AppError::Internal(error)); } - // Create new gadget manager with current descriptor - let descriptor = self.current_descriptor.read().await.clone(); + let udc = desired.udc.clone().ok_or_else(|| { + let error = "OTG not available: no UDC found".to_string(); + AppError::Internal(error) + })?; + let mut manager = OtgGadgetManager::with_descriptor( super::configfs::DEFAULT_GADGET_NAME, - super::endpoint::DEFAULT_MAX_ENDPOINTS, - descriptor, + desired.max_endpoints, + desired.descriptor.clone(), ); + let mut hid_paths = None; - - // Add HID functions if requested - if hid_requested { - if hid_functions.is_empty() { - let error = "HID functions set is empty".to_string(); - let mut state = self.state.write().await; - state.error = Some(error.clone()); - return Err(AppError::BadRequest(error)); - } - - let mut paths = HidDevicePaths::default(); + if let Some(hid_functions) = desired.hid_functions.clone() { + let mut paths = HidDevicePaths { + udc: Some(udc.clone()), + keyboard_leds_enabled: desired.keyboard_leds, + ..Default::default() + }; if hid_functions.keyboard { - match manager.add_keyboard() { + match manager.add_keyboard(desired.keyboard_leds) { Ok(kb) => paths.keyboard = Some(kb), Err(e) => { let error = format!("Failed to add keyboard HID function: {}", e); - let mut state = self.state.write().await; - state.error = Some(error.clone()); + self.state.write().await.error = Some(error.clone()); return Err(AppError::Internal(error)); } } @@ -446,8 +303,7 @@ impl OtgService { Ok(rel) => paths.mouse_relative = Some(rel), Err(e) => { let error = format!("Failed to add relative mouse HID function: {}", e); - let mut state = self.state.write().await; - state.error = Some(error.clone()); + self.state.write().await.error = Some(error.clone()); return Err(AppError::Internal(error)); } } @@ -458,8 +314,7 @@ impl OtgService { Ok(abs) => paths.mouse_absolute = Some(abs), Err(e) => { let error = format!("Failed to add absolute mouse HID function: {}", e); - let mut state = self.state.write().await; - state.error = Some(error.clone()); + self.state.write().await.error = Some(error.clone()); return Err(AppError::Internal(error)); } } @@ -470,8 +325,7 @@ impl OtgService { Ok(consumer) => paths.consumer = Some(consumer), Err(e) => { let error = format!("Failed to add consumer HID function: {}", e); - let mut state = self.state.write().await; - state.error = Some(error.clone()); + self.state.write().await.error = Some(error.clone()); return Err(AppError::Internal(error)); } } @@ -481,8 +335,7 @@ impl OtgService { debug!("HID functions added to gadget"); } - // Add MSD function if requested - let msd_func = if msd_requested { + let msd_func = if desired.msd_enabled { match manager.add_msd() { Ok(func) => { debug!("MSD function added to gadget"); @@ -490,8 +343,7 @@ impl OtgService { } Err(e) => { let error = format!("Failed to add MSD function: {}", e); - let mut state = self.state.write().await; - state.error = Some(error.clone()); + self.state.write().await.error = Some(error.clone()); return Err(AppError::Internal(error)); } } @@ -499,25 +351,19 @@ impl OtgService { None }; - // Setup gadget if let Err(e) = manager.setup() { let error = format!("Failed to setup gadget: {}", e); - let mut state = self.state.write().await; - state.error = Some(error.clone()); + self.state.write().await.error = Some(error.clone()); return Err(AppError::Internal(error)); } - // Bind to UDC - if let Err(e) = manager.bind() { - let error = format!("Failed to bind gadget to UDC: {}", e); - let mut state = self.state.write().await; - state.error = Some(error.clone()); - // Cleanup on failure + if let Err(e) = manager.bind(&udc) { + let error = format!("Failed to bind gadget to UDC {}: {}", udc, e); + self.state.write().await.error = Some(error.clone()); let _ = manager.cleanup(); return Err(AppError::Internal(error)); } - // Wait for HID devices to appear if let Some(ref paths) = hid_paths { let device_paths = paths.existing_paths(); if !device_paths.is_empty() && !wait_for_hid_devices(&device_paths, 2000).await { @@ -525,103 +371,36 @@ impl OtgService { } } - // Store manager and update state - { - *self.manager.lock().await = Some(manager); - } - - { - *self.msd_function.write().await = msd_func; - } + *self.manager.lock().await = Some(manager); + *self.msd_function.write().await = msd_func; { let mut state = self.state.write().await; state.gadget_active = true; - state.hid_enabled = hid_requested; - state.msd_enabled = msd_requested; + state.hid_enabled = desired.hid_enabled(); + state.msd_enabled = desired.msd_enabled; + state.configured_udc = Some(udc); state.hid_paths = hid_paths; - state.hid_functions = if hid_requested { - Some(hid_functions) - } else { - None - }; + state.hid_functions = desired.hid_functions; + state.keyboard_leds_enabled = desired.keyboard_leds; + state.max_endpoints = desired.max_endpoints; + state.descriptor = Some(desired.descriptor); state.error = None; } - info!("Gadget created successfully"); + info!("OTG gadget reconciled successfully"); Ok(()) } - /// Update the descriptor configuration - /// - /// This updates the stored descriptor and triggers a gadget recreation - /// if the gadget is currently active. - pub async fn update_descriptor(&self, config: &OtgDescriptorConfig) -> Result<()> { - let new_descriptor = GadgetDescriptor { - vendor_id: config.vendor_id, - product_id: config.product_id, - device_version: super::configfs::DEFAULT_USB_BCD_DEVICE, - manufacturer: config.manufacturer.clone(), - product: config.product.clone(), - serial_number: config - .serial_number - .clone() - .unwrap_or_else(|| "0123456789".to_string()), - }; - - // Update stored descriptor - *self.current_descriptor.write().await = new_descriptor; - - // If gadget is active, recreate it with new descriptor - let state = self.state.read().await; - if state.gadget_active { - drop(state); // Release read lock before calling recreate - info!("Descriptor changed, recreating gadget"); - self.force_recreate_gadget().await?; - } - - Ok(()) - } - - /// Force recreate the gadget (used when descriptor changes) - async fn force_recreate_gadget(&self) -> Result<()> { - // Cleanup existing gadget - { - let mut manager = self.manager.lock().await; - if let Some(mut m) = manager.take() { - info!("Cleaning up existing gadget for descriptor change"); - if let Err(e) = m.cleanup() { - warn!("Error cleaning up existing gadget: {}", e); - } - } - } - - // Clear MSD function - *self.msd_function.write().await = None; - - // Update state to inactive - { - let mut state = self.state.write().await; - state.gadget_active = false; - state.hid_enabled = false; - state.msd_enabled = false; - state.hid_paths = None; - state.hid_functions = None; - state.error = None; - } - - // Recreate with current requested functions - self.recreate_gadget().await - } - /// Shutdown the OTG service and cleanup all resources pub async fn shutdown(&self) -> Result<()> { info!("Shutting down OTG service"); - // Mark nothing as requested (lock-free) - self.requested_flags.store(0, Ordering::Release); + { + let mut desired = self.desired.write().await; + *desired = OtgDesiredState::default(); + } - // Cleanup gadget let mut manager = self.manager.lock().await; if let Some(mut m) = manager.take() { if let Err(e) = m.cleanup() { @@ -629,7 +408,6 @@ impl OtgService { } } - // Clear state *self.msd_function.write().await = None; { let mut state = self.state.write().await; @@ -649,11 +427,26 @@ impl Default for OtgService { impl Drop for OtgService { fn drop(&mut self) { - // Gadget cleanup is handled by OtgGadgetManager's Drop debug!("OtgService dropping"); } } +impl From<&OtgDescriptorConfig> for GadgetDescriptor { + fn from(config: &OtgDescriptorConfig) -> Self { + Self { + vendor_id: config.vendor_id, + product_id: config.product_id, + device_version: super::configfs::DEFAULT_USB_BCD_DEVICE, + manufacturer: config.manufacturer.clone(), + product: config.product.clone(), + serial_number: config + .serial_number + .clone() + .unwrap_or_else(|| "0123456789".to_string()), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -661,8 +454,7 @@ mod tests { #[test] fn test_service_creation() { let _service = OtgService::new(); - // Just test that creation doesn't panic - let _ = OtgService::is_available(); // Depends on environment + let _ = OtgService::is_available(); } #[tokio::test] diff --git a/src/state.rs b/src/state.rs index 58ad3c30..d1443f0d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -198,6 +198,8 @@ impl AppState { initialized: state.initialized, online: state.online, supports_absolute_mouse: state.supports_absolute_mouse, + keyboard_leds_enabled: state.keyboard_leds_enabled, + led_state: state.led_state, device: state.device, error: state.error, error_code: state.error_code, diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index c2282b9a..8a8b53eb 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -12,6 +12,26 @@ use crate::video::codec_constraints::{ enforce_constraints_with_stream_manager, StreamCodecConstraints, }; +fn hid_backend_type(config: &HidConfig) -> crate::hid::HidBackendType { + match config.backend { + HidBackend::Otg => crate::hid::HidBackendType::Otg, + HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 { + port: config.ch9329_port.clone(), + baud_rate: config.ch9329_baudrate, + }, + HidBackend::None => crate::hid::HidBackendType::None, + } +} + +async fn reconcile_otg_from_store(state: &Arc) -> Result<()> { + let config = state.config.get(); + state + .otg_service + .apply_config(&config.hid, &config.msd) + .await + .map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e))) +} + /// 应用 Video 配置变更 pub async fn apply_video_config( state: &Arc, @@ -125,56 +145,26 @@ pub async fn apply_hid_config( old_config: &HidConfig, new_config: &HidConfig, ) -> Result<()> { - // 检查 OTG 描述符是否变更 + let current_msd_enabled = state.config.get().msd.enabled; + new_config.validate_otg_endpoint_budget(current_msd_enabled)?; + let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor; - let old_hid_functions = old_config.effective_otg_functions(); - let mut new_hid_functions = new_config.effective_otg_functions(); - - // Low-endpoint UDCs (e.g., musb) cannot handle consumer control endpoints reliably - if new_config.backend == HidBackend::Otg { - if let Some(udc) = crate::otg::configfs::resolve_udc_name(new_config.otg_udc.as_deref()) { - if crate::otg::configfs::is_low_endpoint_udc(&udc) && new_hid_functions.consumer { - tracing::warn!( - "UDC {} has low endpoint resources, disabling consumer control", - udc - ); - new_hid_functions.consumer = false; - } - } - } - + let old_hid_functions = old_config.constrained_otg_functions(); + let new_hid_functions = new_config.constrained_otg_functions(); let hid_functions_changed = old_hid_functions != new_hid_functions; + let keyboard_leds_changed = + old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds(); + let endpoint_budget_changed = + old_config.resolved_otg_endpoint_limit() != new_config.resolved_otg_endpoint_limit(); - if new_config.backend == HidBackend::Otg && new_hid_functions.is_empty() { - return Err(AppError::BadRequest( - "OTG HID functions cannot be empty".to_string(), - )); - } - - // 如果描述符变更且当前使用 OTG 后端,需要重建 Gadget - if descriptor_changed && new_config.backend == HidBackend::Otg { - tracing::info!("OTG descriptor changed, updating gadget..."); - if let Err(e) = state - .otg_service - .update_descriptor(&new_config.otg_descriptor) - .await - { - tracing::error!("Failed to update OTG descriptor: {}", e); - return Err(AppError::Config(format!( - "OTG descriptor update failed: {}", - e - ))); - } - tracing::info!("OTG descriptor updated successfully"); - } - - // 检查是否需要重载 HID 后端 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.otg_udc == new_config.otg_udc && !descriptor_changed && !hid_functions_changed + && !keyboard_leds_changed + && !endpoint_budget_changed { tracing::info!("HID config unchanged, skipping reload"); return Ok(()); @@ -182,30 +172,27 @@ pub async fn apply_hid_config( tracing::info!("Applying HID config changes..."); - if new_config.backend == HidBackend::Otg - && (hid_functions_changed || old_config.backend != HidBackend::Otg) - { + let new_hid_backend = hid_backend_type(new_config); + let transitioning_away_from_otg = + old_config.backend == HidBackend::Otg && new_config.backend != HidBackend::Otg; + + if transitioning_away_from_otg { state - .otg_service - .update_hid_functions(new_hid_functions.clone()) + .hid + .reload(new_hid_backend.clone()) .await - .map_err(|e| AppError::Config(format!("OTG HID function update failed: {}", e)))?; + .map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?; } - let new_hid_backend = match new_config.backend { - HidBackend::Otg => crate::hid::HidBackendType::Otg, - HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 { - port: new_config.ch9329_port.clone(), - baud_rate: new_config.ch9329_baudrate, - }, - HidBackend::None => crate::hid::HidBackendType::None, - }; + reconcile_otg_from_store(state).await?; - state - .hid - .reload(new_hid_backend) - .await - .map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?; + if !transitioning_away_from_otg { + state + .hid + .reload(new_hid_backend) + .await + .map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?; + } tracing::info!( "HID backend reloaded successfully: {:?}", @@ -221,6 +208,12 @@ pub async fn apply_msd_config( old_config: &MsdConfig, new_config: &MsdConfig, ) -> Result<()> { + state + .config + .get() + .hid + .validate_otg_endpoint_budget(new_config.enabled)?; + tracing::info!("MSD config sent, checking if reload needed..."); tracing::debug!("Old MSD config: {:?}", old_config); tracing::debug!("New MSD config: {:?}", new_config); @@ -260,6 +253,8 @@ pub async fn apply_msd_config( if new_msd_enabled { tracing::info!("(Re)initializing MSD..."); + reconcile_otg_from_store(state).await?; + // Shutdown existing controller if present let mut msd_guard = state.msd.write().await; if let Some(msd) = msd_guard.as_mut() { @@ -295,6 +290,17 @@ pub async fn apply_msd_config( } *msd_guard = None; tracing::info!("MSD shutdown complete"); + + reconcile_otg_from_store(state).await?; + } + + let current_config = state.config.get(); + if current_config.hid.backend == HidBackend::Otg && old_msd_enabled != new_msd_enabled { + state + .hid + .reload(crate::hid::HidBackendType::Otg) + .await + .map_err(|e| AppError::Config(format!("OTG HID reload failed: {}", e)))?; } Ok(()) diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index d94cecc7..d26131a1 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -307,7 +307,9 @@ pub struct HidConfigUpdate { pub otg_udc: Option, pub otg_descriptor: Option, pub otg_profile: Option, + pub otg_endpoint_budget: Option, pub otg_functions: Option, + pub otg_keyboard_leds: Option, pub mouse_absolute: Option, } @@ -346,9 +348,15 @@ impl HidConfigUpdate { if let Some(profile) = self.otg_profile.clone() { config.otg_profile = profile; } + if let Some(budget) = self.otg_endpoint_budget { + config.otg_endpoint_budget = budget; + } if let Some(ref functions) = self.otg_functions { functions.apply_to(&mut config.otg_functions); } + if let Some(enabled) = self.otg_keyboard_leds { + config.otg_keyboard_leds = enabled; + } if let Some(absolute) = self.mouse_absolute { config.mouse_absolute = absolute; } diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index 91448ab9..a5a79b00 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -598,38 +598,14 @@ pub struct SetupRequest { pub hid_ch9329_baudrate: Option, pub hid_otg_udc: Option, pub hid_otg_profile: Option, + pub hid_otg_endpoint_budget: Option, + pub hid_otg_keyboard_leds: Option, + pub msd_enabled: Option, // Extension settings pub ttyd_enabled: Option, pub rustdesk_enabled: Option, } -fn normalize_otg_profile_for_low_endpoint(config: &mut AppConfig) { - if !matches!(config.hid.backend, crate::config::HidBackend::Otg) { - return; - } - let udc = crate::otg::configfs::resolve_udc_name(config.hid.otg_udc.as_deref()); - let Some(udc) = udc else { - return; - }; - if !crate::otg::configfs::is_low_endpoint_udc(&udc) { - return; - } - match config.hid.otg_profile { - crate::config::OtgHidProfile::Full => { - config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumer; - } - crate::config::OtgHidProfile::FullNoMsd => { - config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumerNoMsd; - } - crate::config::OtgHidProfile::Custom => { - if config.hid.otg_functions.consumer { - config.hid.otg_functions.consumer = false; - } - } - _ => {} - } -} - pub async fn setup_init( State(state): State>, Json(req): Json, @@ -703,32 +679,19 @@ pub async fn setup_init( config.hid.otg_udc = Some(udc); } if let Some(profile) = req.hid_otg_profile.clone() { - config.hid.otg_profile = match profile.as_str() { - "full" => crate::config::OtgHidProfile::Full, - "full_no_msd" => crate::config::OtgHidProfile::FullNoMsd, - "full_no_consumer" => crate::config::OtgHidProfile::FullNoConsumer, - "full_no_consumer_no_msd" => crate::config::OtgHidProfile::FullNoConsumerNoMsd, - "legacy_keyboard" => crate::config::OtgHidProfile::LegacyKeyboard, - "legacy_mouse_relative" => crate::config::OtgHidProfile::LegacyMouseRelative, - "custom" => crate::config::OtgHidProfile::Custom, - _ => config.hid.otg_profile.clone(), - }; - if matches!(config.hid.backend, crate::config::HidBackend::Otg) { - match config.hid.otg_profile { - crate::config::OtgHidProfile::Full - | crate::config::OtgHidProfile::FullNoConsumer => { - config.msd.enabled = true; - } - crate::config::OtgHidProfile::FullNoMsd - | crate::config::OtgHidProfile::FullNoConsumerNoMsd - | crate::config::OtgHidProfile::LegacyKeyboard - | crate::config::OtgHidProfile::LegacyMouseRelative => { - config.msd.enabled = false; - } - crate::config::OtgHidProfile::Custom => {} - } + if let Some(parsed) = crate::config::OtgHidProfile::from_legacy_str(&profile) { + config.hid.otg_profile = parsed; } } + if let Some(budget) = req.hid_otg_endpoint_budget { + config.hid.otg_endpoint_budget = budget; + } + if let Some(enabled) = req.hid_otg_keyboard_leds { + config.hid.otg_keyboard_leds = enabled; + } + if let Some(enabled) = req.msd_enabled { + config.msd.enabled = enabled; + } // Extension settings if let Some(enabled) = req.ttyd_enabled { @@ -737,29 +700,18 @@ pub async fn setup_init( if let Some(enabled) = req.rustdesk_enabled { config.rustdesk.enabled = enabled; } - - normalize_otg_profile_for_low_endpoint(config); }) .await?; // Get updated config for HID reload let new_config = state.config.get(); - if matches!(new_config.hid.backend, crate::config::HidBackend::Otg) { - let mut hid_functions = new_config.hid.effective_otg_functions(); - if let Some(udc) = crate::otg::configfs::resolve_udc_name(new_config.hid.otg_udc.as_deref()) - { - if crate::otg::configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer { - tracing::warn!( - "UDC {} has low endpoint resources, disabling consumer control", - udc - ); - hid_functions.consumer = false; - } - } - if let Err(e) = state.otg_service.update_hid_functions(hid_functions).await { - tracing::warn!("Failed to apply HID functions during setup: {}", e); - } + if let Err(e) = state + .otg_service + .apply_config(&new_config.hid, &new_config.msd) + .await + { + tracing::warn!("Failed to apply OTG config during setup: {}", e); } tracing::info!( @@ -881,8 +833,10 @@ pub async fn update_config( let new_config: AppConfig = serde_json::from_value(merged) .map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?; - let mut new_config = new_config; - normalize_otg_profile_for_low_endpoint(&mut new_config); + let new_config = new_config; + new_config + .hid + .validate_otg_endpoint_budget(new_config.msd.enabled)?; // Apply the validated config state.config.set(new_config.clone()).await?; @@ -910,232 +864,76 @@ pub async fn update_config( // Get new config for device reloading let new_config = state.config.get(); - // Video config processing - always reload if section was sent if has_video { - tracing::info!("Video config sent, applying settings..."); - - let device = new_config - .video - .device - .clone() - .ok_or_else(|| AppError::BadRequest("video_device is required".to_string()))?; - - // Map to PixelFormat/Resolution - let format = new_config - .video - .format - .as_ref() - .and_then(|f| { - serde_json::from_value::( - serde_json::Value::String(f.clone()), - ) - .ok() - }) - .unwrap_or(crate::video::format::PixelFormat::Mjpeg); - let resolution = - crate::video::format::Resolution::new(new_config.video.width, new_config.video.height); - - if let Err(e) = state - .stream_manager - .apply_video_config(&device, format, resolution, new_config.video.fps) - .await + if let Err(e) = + config::apply::apply_video_config(&state, &old_config.video, &new_config.video).await { tracing::error!("Failed to apply video config: {}", e); - // Rollback config on failure state.config.set((*old_config).clone()).await?; return Ok(Json(LoginResponse { success: false, message: Some(format!("Video configuration invalid: {}", e)), })); } - tracing::info!("Video config applied successfully"); } - // Stream config processing (encoder backend, bitrate, etc.) if has_stream { - tracing::info!("Stream config sent, applying encoder settings..."); - - // Update WebRTC streamer encoder backend - let encoder_backend = new_config.stream.encoder.to_backend(); - tracing::info!( - "Updating encoder backend to: {:?} (from config: {:?})", - encoder_backend, - new_config.stream.encoder - ); - - state - .stream_manager - .webrtc_streamer() - .update_encoder_backend(encoder_backend) - .await; - - // Update bitrate if changed - state - .stream_manager - .webrtc_streamer() - .set_bitrate_preset(new_config.stream.bitrate_preset) - .await - .ok(); // Ignore error if no active stream - - tracing::info!( - "Stream config applied: encoder={:?}, bitrate={}", - new_config.stream.encoder, - new_config.stream.bitrate_preset - ); + if let Err(e) = + config::apply::apply_stream_config(&state, &old_config.stream, &new_config.stream).await + { + tracing::error!("Failed to apply stream config: {}", e); + state.config.set((*old_config).clone()).await?; + return Ok(Json(LoginResponse { + success: false, + message: Some(format!("Stream configuration invalid: {}", e)), + })); + } } - // HID config processing - always reload if section was sent if has_hid { - tracing::info!("HID config sent, reloading HID backend..."); - - // Determine new backend type - let new_hid_backend = match new_config.hid.backend { - crate::config::HidBackend::Otg => crate::hid::HidBackendType::Otg, - crate::config::HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 { - port: new_config.hid.ch9329_port.clone(), - baud_rate: new_config.hid.ch9329_baudrate, - }, - crate::config::HidBackend::None => crate::hid::HidBackendType::None, - }; - - // Reload HID backend - return success=false on error - if let Err(e) = state.hid.reload(new_hid_backend).await { + if let Err(e) = + config::apply::apply_hid_config(&state, &old_config.hid, &new_config.hid).await + { tracing::error!("HID reload failed: {}", e); - // Rollback config on failure state.config.set((*old_config).clone()).await?; return Ok(Json(LoginResponse { success: false, message: Some(format!("HID configuration invalid: {}", e)), })); } - - tracing::info!( - "HID backend reloaded successfully: {:?}", - new_config.hid.backend - ); } - // Audio config processing - always reload if section was sent if has_audio { - tracing::info!("Audio config sent, applying settings..."); - - // Create audio controller config from new config - let audio_config = crate::audio::AudioControllerConfig { - enabled: new_config.audio.enabled, - device: new_config.audio.device.clone(), - quality: crate::audio::AudioQuality::from_str(&new_config.audio.quality), - }; - - // Update audio controller - if let Err(e) = state.audio.update_config(audio_config).await { - tracing::error!("Audio config update failed: {}", e); - // Don't rollback config for audio errors - it's not critical - // Just log the error - } else { - tracing::info!( - "Audio config applied: enabled={}, device={}", - new_config.audio.enabled, - new_config.audio.device - ); - } - - // Also update WebRTC audio enabled state - if let Err(e) = state - .stream_manager - .set_webrtc_audio_enabled(new_config.audio.enabled) - .await + if let Err(e) = + config::apply::apply_audio_config(&state, &old_config.audio, &new_config.audio).await { - tracing::warn!("Failed to update WebRTC audio state: {}", e); - } else { - tracing::info!("WebRTC audio enabled: {}", new_config.audio.enabled); - } - - // Reconnect audio sources for existing WebRTC sessions - // This is needed because the audio controller was restarted with new config - if new_config.audio.enabled { - state.stream_manager.reconnect_webrtc_audio_sources().await; + tracing::warn!("Audio config update failed: {}", e); } } - // MSD config processing - reload if enabled state or directory changed if has_msd { - tracing::info!("MSD config sent, checking if reload needed..."); - tracing::debug!("Old MSD config: {:?}", old_config.msd); - tracing::debug!("New MSD config: {:?}", new_config.msd); - - let old_msd_enabled = old_config.msd.enabled; - let new_msd_enabled = new_config.msd.enabled; - let msd_dir_changed = old_config.msd.msd_dir != new_config.msd.msd_dir; - - tracing::info!( - "MSD enabled: old={}, new={}", - old_msd_enabled, - new_msd_enabled - ); - if msd_dir_changed { - tracing::info!("MSD directory changed: {}", new_config.msd.msd_dir); + if let Err(e) = + config::apply::apply_msd_config(&state, &old_config.msd, &new_config.msd).await + { + tracing::error!("MSD initialization failed: {}", e); + state.config.set((*old_config).clone()).await?; + return Ok(Json(LoginResponse { + success: false, + message: Some(format!("MSD initialization failed: {}", e)), + })); } + } - // Ensure MSD directories exist (msd/images, msd/ventoy) - let msd_dir = new_config.msd.msd_dir_path(); - if let Err(e) = std::fs::create_dir_all(msd_dir.join("images")) { - tracing::warn!("Failed to create MSD images directory: {}", e); - } - if let Err(e) = std::fs::create_dir_all(msd_dir.join("ventoy")) { - tracing::warn!("Failed to create MSD ventoy directory: {}", e); - } - - let needs_reload = old_msd_enabled != new_msd_enabled || msd_dir_changed; - if !needs_reload { - tracing::info!( - "MSD enabled state unchanged ({}) and directory unchanged, no reload needed", - new_msd_enabled - ); - } else if new_msd_enabled { - tracing::info!("(Re)initializing MSD..."); - - // Shutdown existing controller if present - let mut msd_guard = state.msd.write().await; - if let Some(msd) = msd_guard.as_mut() { - if let Err(e) = msd.shutdown().await { - tracing::warn!("MSD shutdown failed: {}", e); - } - } - *msd_guard = None; - drop(msd_guard); - - let msd = crate::msd::MsdController::new( - state.otg_service.clone(), - new_config.msd.msd_dir_path(), - ); - if let Err(e) = msd.init().await { - tracing::error!("MSD initialization failed: {}", e); - // Rollback config on failure - state.config.set((*old_config).clone()).await?; - return Ok(Json(LoginResponse { - success: false, - message: Some(format!("MSD initialization failed: {}", e)), - })); - } - - // Set event bus - let events = state.events.clone(); - msd.set_event_bus(events).await; - - // Store the initialized controller - *state.msd.write().await = Some(msd); - tracing::info!("MSD initialized successfully"); - } else { - tracing::info!("MSD disabled in config, shutting down..."); - - let mut msd_guard = state.msd.write().await; - if let Some(msd) = msd_guard.as_mut() { - if let Err(e) = msd.shutdown().await { - tracing::warn!("MSD shutdown failed: {}", e); - } - } - *msd_guard = None; - tracing::info!("MSD shutdown complete"); + if has_atx { + if let Err(e) = + config::apply::apply_atx_config(&state, &old_config.atx, &new_config.atx).await + { + tracing::error!("ATX configuration invalid: {}", e); + state.config.set((*old_config).clone()).await?; + return Ok(Json(LoginResponse { + success: false, + message: Some(format!("ATX configuration invalid: {}", e)), + })); } } @@ -2247,6 +2045,8 @@ pub struct HidStatus { pub initialized: bool, pub online: bool, pub supports_absolute_mouse: bool, + pub keyboard_leds_enabled: bool, + pub led_state: crate::hid::LedState, pub screen_resolution: Option<(u32, u32)>, pub device: Option, pub error: Option, @@ -3018,6 +2818,8 @@ pub async fn hid_status(State(state): State>) -> Json { initialized: hid.initialized, online: hid.online, supports_absolute_mouse: hid.supports_absolute_mouse, + keyboard_leds_enabled: hid.keyboard_leds_enabled, + led_state: hid.led_state, screen_resolution: hid.screen_resolution, device: hid.device, error: hid.error, diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 9f2a7f7c..4a27a420 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -86,6 +86,9 @@ export const systemApi = { hid_ch9329_baudrate?: number hid_otg_udc?: string hid_otg_profile?: string + hid_otg_endpoint_budget?: string + hid_otg_keyboard_leds?: boolean + msd_enabled?: boolean encoder_backend?: string audio_device?: string ttyd_enabled?: boolean @@ -330,6 +333,14 @@ export const hidApi = { initialized: boolean online: boolean supports_absolute_mouse: boolean + keyboard_leds_enabled: boolean + led_state: { + num_lock: boolean + caps_lock: boolean + scroll_lock: boolean + compose: boolean + kana: boolean + } screen_resolution: [number, number] | null device: string | null error: string | null diff --git a/web/src/components/InfoBar.vue b/web/src/components/InfoBar.vue index 6a051a6a..9320ab7d 100644 --- a/web/src/components/InfoBar.vue +++ b/web/src/components/InfoBar.vue @@ -7,6 +7,9 @@ import { cn } from '@/lib/utils' const props = defineProps<{ pressedKeys?: CanonicalKey[] capsLock?: boolean + numLock?: boolean + scrollLock?: boolean + keyboardLedEnabled?: boolean mousePosition?: { x: number; y: number } debugMode?: boolean compact?: boolean @@ -42,12 +45,21 @@ const keysDisplay = computed(() => {
-
+
C - - + C + N + S +
+
+ {{ t('infobar.keyboardLedUnavailable') }}
@@ -72,16 +84,39 @@ const keysDisplay = computed(() => {
- +
-
- - C + +
+ {{ t('infobar.keyboardLedUnavailable') }}
diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 6e03a24b..c3ad3995 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -61,7 +61,6 @@ export default { password: 'Password', enterUsername: 'Enter username', enterPassword: 'Enter password', - loginPrompt: 'Enter your credentials to login', loginFailed: 'Login failed', invalidPassword: 'Invalid username or password', changePassword: 'Change Password', @@ -169,6 +168,7 @@ export default { caps: 'Caps', num: 'Num', scroll: 'Scroll', + keyboardLedUnavailable: 'Keyboard LED status is disabled or unsupported', }, paste: { title: 'Paste Text', @@ -270,7 +270,7 @@ export default { otgAdvanced: 'Advanced: OTG Preset', otgProfile: 'Initial HID Preset', otgProfileDesc: 'Choose the initial OTG HID preset. You can change this later in Settings.', - otgLowEndpointHint: 'Detected low-endpoint UDC; multimedia keys will be disabled automatically.', + otgLowEndpointHint: 'Detected low-endpoint UDC; Consumer Control Keyboard will be disabled automatically.', videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.', videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.', // Extensions @@ -651,28 +651,28 @@ export default { hidBackend: 'HID Backend', serialDevice: 'Serial Device', baudRate: 'Baud Rate', - otgHidProfile: 'OTG HID Profile', + otgHidProfile: 'OTG HID Functions', otgHidProfileDesc: 'Select which HID functions are exposed to the host', - profile: 'Profile', - otgProfileFull: 'Keyboard + relative mouse + absolute mouse + multimedia + MSD', - otgProfileFullNoMsd: 'Keyboard + relative mouse + absolute mouse + multimedia (no MSD)', - otgProfileFullNoConsumer: 'Keyboard + relative mouse + absolute mouse + MSD (no multimedia)', - otgProfileFullNoConsumerNoMsd: 'Keyboard + relative mouse + absolute mouse (no multimedia, no MSD)', - otgProfileLegacyKeyboard: 'Keyboard only', - otgProfileLegacyMouseRelative: 'Relative mouse only', - otgProfileCustom: 'Custom', + otgEndpointBudget: 'Max Endpoints', + otgEndpointBudgetUnlimited: 'Unlimited', + otgEndpointBudgetHint: 'This is a hardware limit. If the OTG selection exceeds the real hardware endpoint count, OTG will fail.', + otgEndpointUsage: 'Endpoint usage: {used} / {limit}', + otgEndpointUsageUnlimited: 'Endpoint usage: {used} / unlimited', + otgEndpointExceeded: 'The current OTG selection needs {used} endpoints, exceeding the limit {limit}.', otgFunctionKeyboard: 'Keyboard', otgFunctionKeyboardDesc: 'Standard HID keyboard device', + otgKeyboardLeds: 'Keyboard LED Status', + otgKeyboardLedsDesc: 'Enable Caps/Num/Scroll LED feedback from the host', otgFunctionMouseRelative: 'Relative Mouse', otgFunctionMouseRelativeDesc: 'Traditional mouse movement (HID boot mouse)', otgFunctionMouseAbsolute: 'Absolute Mouse', otgFunctionMouseAbsoluteDesc: 'Absolute positioning (touchscreen-like)', - otgFunctionConsumer: 'Consumer Control', - otgFunctionConsumerDesc: 'Media keys like volume/play/pause', + otgFunctionConsumer: 'Consumer Control Keyboard', + otgFunctionConsumerDesc: 'Consumer Control keys such as volume/play/pause', otgFunctionMsd: 'Mass Storage (MSD)', otgFunctionMsdDesc: 'Expose USB storage to the host', otgProfileWarning: 'Changing HID functions will reconnect the USB device', - otgLowEndpointHint: 'Low-endpoint UDC detected; multimedia keys will be disabled automatically.', + otgLowEndpointHint: 'Low-endpoint UDC detected; Consumer Control Keyboard will be disabled automatically.', otgFunctionMinWarning: 'Enable at least one HID function before saving', // OTG Descriptor otgDescriptor: 'USB Device Descriptor', @@ -799,7 +799,7 @@ export default { osWindows: 'Windows', osMac: 'Mac', osAndroid: 'Android', - mediaKeys: 'Media Keys', + mediaKeys: 'Consumer Control Keyboard', }, config: { applied: 'Configuration applied', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 64e9ec0c..3f463d93 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -61,7 +61,6 @@ export default { password: '密码', enterUsername: '请输入用户名', enterPassword: '请输入密码', - loginPrompt: '请输入您的账号和密码', loginFailed: '登录失败', invalidPassword: '用户名或密码错误', changePassword: '修改密码', @@ -169,6 +168,7 @@ export default { caps: 'Caps', num: 'Num', scroll: 'Scroll', + keyboardLedUnavailable: '键盘状态灯功能未开启或不支持', }, paste: { title: '粘贴文本', @@ -270,7 +270,7 @@ export default { otgAdvanced: '高级:OTG 预设', otgProfile: '初始 HID 预设', otgProfileDesc: '选择 OTG HID 的初始预设,后续可在设置中修改。', - otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键。', + otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键盘。', videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。', videoFormatHelp: 'MJPEG 格式兼容性最好,H.264/H.265 带宽占用更低但需要编码支持。', // Extensions @@ -651,28 +651,28 @@ export default { hidBackend: 'HID 后端', serialDevice: '串口设备', baudRate: '波特率', - otgHidProfile: 'OTG HID 组合', + otgHidProfile: 'OTG HID 功能', otgHidProfileDesc: '选择对目标主机暴露的 HID 功能', - profile: '组合', - otgProfileFull: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体 + 虚拟媒体', - otgProfileFullNoMsd: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体(不含虚拟媒体)', - otgProfileFullNoConsumer: '键盘 + 相对鼠标 + 绝对鼠标 + 虚拟媒体(不含多媒体)', - otgProfileFullNoConsumerNoMsd: '键盘 + 相对鼠标 + 绝对鼠标(不含多媒体与虚拟媒体)', - otgProfileLegacyKeyboard: '仅键盘', - otgProfileLegacyMouseRelative: '仅相对鼠标', - otgProfileCustom: '自定义', + otgEndpointBudget: '最大端点数量', + otgEndpointBudgetUnlimited: '无限制', + otgEndpointBudgetHint: '此为硬件限制。若超出硬件端点数量,OTG 功能将无法使用。', + otgEndpointUsage: '当前端点占用:{used} / {limit}', + otgEndpointUsageUnlimited: '当前端点占用:{used} / 不限', + otgEndpointExceeded: '当前 OTG 组合需要 {used} 个端点,已超出上限 {limit}。', otgFunctionKeyboard: '键盘', otgFunctionKeyboardDesc: '标准 HID 键盘设备', + otgKeyboardLeds: '键盘状态灯', + otgKeyboardLedsDesc: '启用 Caps/Num/Scroll 状态灯回读', otgFunctionMouseRelative: '相对鼠标', otgFunctionMouseRelativeDesc: '传统鼠标移动(HID 启动鼠标)', otgFunctionMouseAbsolute: '绝对鼠标', otgFunctionMouseAbsoluteDesc: '绝对定位(类似触控)', - otgFunctionConsumer: '多媒体控制', - otgFunctionConsumerDesc: '音量/播放/暂停等按键', + otgFunctionConsumer: '多媒体键盘', + otgFunctionConsumerDesc: '音量/播放/暂停等多媒体按键', otgFunctionMsd: '虚拟媒体(MSD)', otgFunctionMsdDesc: '向目标主机暴露 USB 存储', otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接', - otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键。', + otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键盘。', otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存', // OTG Descriptor otgDescriptor: 'USB 设备描述符', @@ -799,7 +799,7 @@ export default { osWindows: 'Windows', osMac: 'Mac', osAndroid: 'Android', - mediaKeys: '多媒体键', + mediaKeys: '多媒体键盘', }, config: { applied: '配置已应用', diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 5c5817f8..c66e8931 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -85,6 +85,9 @@ export const useAuthStore = defineStore('auth', () => { hid_ch9329_baudrate?: number hid_otg_udc?: string hid_otg_profile?: string + hid_otg_endpoint_budget?: string + hid_otg_keyboard_leds?: boolean + msd_enabled?: boolean encoder_backend?: string audio_device?: string ttyd_enabled?: boolean diff --git a/web/src/stores/system.ts b/web/src/stores/system.ts index 1511f725..4864db0f 100644 --- a/web/src/stores/system.ts +++ b/web/src/stores/system.ts @@ -34,6 +34,12 @@ interface HidState { initialized: boolean online: boolean supportsAbsoluteMouse: boolean + keyboardLedsEnabled: boolean + ledState: { + numLock: boolean + capsLock: boolean + scrollLock: boolean + } device: string | null error: string | null errorCode: string | null @@ -89,6 +95,14 @@ export interface HidDeviceInfo { initialized: boolean online: boolean supports_absolute_mouse: boolean + keyboard_leds_enabled: boolean + led_state: { + num_lock: boolean + caps_lock: boolean + scroll_lock: boolean + compose: boolean + kana: boolean + } device: string | null error: string | null error_code?: string | null @@ -194,6 +208,12 @@ export const useSystemStore = defineStore('system', () => { initialized: state.initialized, online: state.online, supportsAbsoluteMouse: state.supports_absolute_mouse, + keyboardLedsEnabled: state.keyboard_leds_enabled, + ledState: { + numLock: state.led_state.num_lock, + capsLock: state.led_state.caps_lock, + scrollLock: state.led_state.scroll_lock, + }, device: state.device ?? null, error: state.error ?? null, errorCode: state.error_code ?? null, @@ -298,6 +318,12 @@ export const useSystemStore = defineStore('system', () => { initialized: data.hid.initialized, online: data.hid.online, supportsAbsoluteMouse: data.hid.supports_absolute_mouse, + keyboardLedsEnabled: data.hid.keyboard_leds_enabled, + ledState: { + numLock: data.hid.led_state.num_lock, + capsLock: data.hid.led_state.caps_lock, + scrollLock: data.hid.led_state.scroll_lock, + }, device: data.hid.device, error: data.hid.error, errorCode: data.hid.error_code ?? null, diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index ed4c5aa3..0412cad7 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -58,12 +58,8 @@ export interface OtgDescriptorConfig { export enum OtgHidProfile { /** Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) */ Full = "full", - /** Full HID device set without MSD */ - FullNoMsd = "full_no_msd", /** Full HID device set without consumer control */ FullNoConsumer = "full_no_consumer", - /** Full HID device set without consumer control and MSD */ - FullNoConsumerNoMsd = "full_no_consumer_no_msd", /** Legacy profile: only keyboard */ LegacyKeyboard = "legacy_keyboard", /** Legacy profile: only relative mouse */ @@ -72,6 +68,18 @@ export enum OtgHidProfile { Custom = "custom", } +/** OTG endpoint budget policy. */ +export enum OtgEndpointBudget { + /** Derive a safe default from the selected UDC. */ + Auto = "auto", + /** Limit OTG gadget functions to 5 endpoints. */ + Five = "five", + /** Limit OTG gadget functions to 6 endpoints. */ + Six = "six", + /** Do not impose a software endpoint budget. */ + Unlimited = "unlimited", +} + /** OTG HID function selection (used when profile is Custom) */ export interface OtgHidFunctions { keyboard: boolean; @@ -84,18 +92,18 @@ export interface OtgHidFunctions { export interface HidConfig { /** HID backend type */ backend: HidBackend; - /** OTG keyboard device path */ - otg_keyboard: string; - /** OTG mouse device path */ - otg_mouse: string; /** OTG UDC (USB Device Controller) name */ otg_udc?: string; /** OTG USB device descriptor configuration */ otg_descriptor?: OtgDescriptorConfig; /** OTG HID function profile */ otg_profile?: OtgHidProfile; + /** OTG endpoint budget policy */ + otg_endpoint_budget?: OtgEndpointBudget; /** OTG HID function selection (used when profile is Custom) */ otg_functions?: OtgHidFunctions; + /** Enable keyboard LED/status feedback for OTG keyboard */ + otg_keyboard_leds?: boolean; /** CH9329 serial port */ ch9329_port: string; /** CH9329 baud rate */ @@ -580,7 +588,9 @@ export interface HidConfigUpdate { otg_udc?: string; otg_descriptor?: OtgDescriptorConfigUpdate; otg_profile?: OtgHidProfile; + otg_endpoint_budget?: OtgEndpointBudget; otg_functions?: OtgHidFunctionsUpdate; + otg_keyboard_leds?: boolean; mouse_absolute?: boolean; } diff --git a/web/src/views/ConsoleView.vue b/web/src/views/ConsoleView.vue index dfb6409f..69f55d7b 100644 --- a/web/src/views/ConsoleView.vue +++ b/web/src/views/ConsoleView.vue @@ -119,9 +119,12 @@ const myClientId = generateUUID() // HID state const mouseMode = ref<'absolute' | 'relative'>('absolute') const pressedKeys = ref([]) -const keyboardLed = ref({ - capsLock: false, -}) +const keyboardLed = computed(() => ({ + capsLock: systemStore.hid?.ledState.capsLock ?? false, + numLock: systemStore.hid?.ledState.numLock ?? false, + scrollLock: systemStore.hid?.ledState.scrollLock ?? false, +})) +const keyboardLedEnabled = computed(() => systemStore.hid?.keyboardLedsEnabled ?? false) const activeModifierMask = ref(0) const mousePosition = ref({ x: 0, y: 0 }) const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relative mode @@ -346,6 +349,13 @@ const hidDetails = computed(() => { { label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.error ? 'error' : hid.initialized ? 'ok' : 'warning' }, { label: t('statusCard.online'), value: hid.online ? t('statusCard.yes') : t('statusCard.no'), status: hid.online ? 'ok' : hid.initialized ? 'warning' : 'error' }, { label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' }, + { + label: t('settings.otgKeyboardLeds'), + value: hid.keyboardLedsEnabled + ? `Caps:${hid.ledState.capsLock ? t('common.on') : t('common.off')} Num:${hid.ledState.numLock ? t('common.on') : t('common.off')} Scroll:${hid.ledState.scrollLock ? t('common.on') : t('common.off')}` + : t('infobar.keyboardLedUnavailable'), + status: hid.keyboardLedsEnabled ? 'ok' : undefined, + }, ] if (hid.errorCode) { @@ -1618,8 +1628,6 @@ function handleKeyDown(e: KeyboardEvent) { }) } - keyboardLed.value.capsLock = e.getModifierState('CapsLock') - const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key) if (canonicalKey === undefined) { console.warn(`[HID] Unmapped key down: code=${e.code}, key=${e.key}`) @@ -2088,10 +2096,6 @@ function handleVirtualKeyDown(key: CanonicalKey) { if (!pressedKeys.value.includes(key)) { pressedKeys.value = [...pressedKeys.value, key] } - // Toggle CapsLock state when virtual keyboard presses CapsLock - if (key === CanonicalKey.CapsLock) { - keyboardLed.value.capsLock = !keyboardLed.value.capsLock - } } function handleVirtualKeyUp(key: CanonicalKey) { @@ -2536,6 +2540,9 @@ onUnmounted(() => { diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 440c0575..e3b859ea 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -33,6 +33,7 @@ import type { AtxDriverType, ActiveLevel, AtxDevices, + OtgEndpointBudget, OtgHidProfile, OtgHidFunctions, } from '@/types/generated' @@ -326,13 +327,15 @@ const config = ref({ hid_serial_device: '', hid_serial_baudrate: 9600, hid_otg_udc: '', - hid_otg_profile: 'full' as OtgHidProfile, + hid_otg_profile: 'custom' as OtgHidProfile, + hid_otg_endpoint_budget: 'six' as OtgEndpointBudget, hid_otg_functions: { keyboard: true, mouse_relative: true, mouse_absolute: true, consumer: true, } as OtgHidFunctions, + hid_otg_keyboard_leds: false, msd_enabled: false, msd_dir: '', encoder_backend: 'auto', @@ -345,20 +348,6 @@ const config = ref({ // Tracks whether TURN password is configured on the server const hasTurnPassword = ref(false) -const configLoaded = ref(false) -const devicesLoaded = ref(false) -const hidProfileAligned = ref(false) - -const isLowEndpointUdc = computed(() => { - if (config.value.hid_otg_udc) { - return /musb/i.test(config.value.hid_otg_udc) - } - return devices.value.udc.some((udc) => /musb/i.test(udc.name)) -}) - -const showLowEndpointHint = computed(() => - config.value.hid_backend === 'otg' && isLowEndpointUdc.value -) type OtgSelfCheckLevel = 'info' | 'warn' | 'error' type OtgCheckGroupStatus = 'ok' | 'warn' | 'error' | 'skipped' @@ -619,28 +608,80 @@ async function onRunVideoEncoderSelfCheckClick() { await runVideoEncoderSelfCheck() } -function alignHidProfileForLowEndpoint() { - if (hidProfileAligned.value) return - if (!configLoaded.value || !devicesLoaded.value) return - if (config.value.hid_backend !== 'otg') { - hidProfileAligned.value = true - return +function defaultOtgEndpointBudgetForUdc(udc?: string): OtgEndpointBudget { + return /musb/i.test(udc || '') ? 'five' as OtgEndpointBudget : 'six' as OtgEndpointBudget +} + +function normalizeOtgEndpointBudget(budget: OtgEndpointBudget | undefined, udc?: string): OtgEndpointBudget { + if (!budget || budget === 'auto') { + return defaultOtgEndpointBudgetForUdc(udc) } - if (!isLowEndpointUdc.value) { - hidProfileAligned.value = true - return + return budget +} + +function endpointLimitForBudget(budget: OtgEndpointBudget): number | null { + if (budget === 'unlimited') return null + return budget === 'five' ? 5 : 6 +} + +const effectiveOtgFunctions = computed(() => ({ ...config.value.hid_otg_functions })) + +const otgEndpointLimit = computed(() => + endpointLimitForBudget(config.value.hid_otg_endpoint_budget) +) + +const otgRequiredEndpoints = computed(() => { + if (config.value.hid_backend !== 'otg') return 0 + const functions = effectiveOtgFunctions.value + let endpoints = 0 + if (functions.keyboard) { + endpoints += 1 + if (config.value.hid_otg_keyboard_leds) endpoints += 1 } - if (config.value.hid_otg_profile === 'full') { - config.value.hid_otg_profile = 'full_no_consumer' as OtgHidProfile - } else if (config.value.hid_otg_profile === 'full_no_msd') { - config.value.hid_otg_profile = 'full_no_consumer_no_msd' as OtgHidProfile + if (functions.mouse_relative) endpoints += 1 + if (functions.mouse_absolute) endpoints += 1 + if (functions.consumer) endpoints += 1 + if (config.value.msd_enabled) endpoints += 2 + return endpoints +}) + +const isOtgEndpointBudgetValid = computed(() => { + if (config.value.hid_backend !== 'otg') return true + const limit = otgEndpointLimit.value + return limit === null || otgRequiredEndpoints.value <= limit +}) + +const otgEndpointUsageText = computed(() => { + const limit = otgEndpointLimit.value + if (limit === null) { + return t('settings.otgEndpointUsageUnlimited', { used: otgRequiredEndpoints.value }) + } + return t('settings.otgEndpointUsage', { used: otgRequiredEndpoints.value, limit }) +}) + +const showOtgEndpointBudgetHint = computed(() => + config.value.hid_backend === 'otg' +) + +const isKeyboardLedToggleDisabled = computed(() => + config.value.hid_backend !== 'otg' || !effectiveOtgFunctions.value.keyboard +) + +function describeEndpointBudget(budget: OtgEndpointBudget): string { + switch (budget) { + case 'five': + return '5' + case 'six': + return '6' + case 'unlimited': + return t('settings.otgEndpointBudgetUnlimited') + default: + return '6' } - hidProfileAligned.value = true } const isHidFunctionSelectionValid = computed(() => { if (config.value.hid_backend !== 'otg') return true - if (config.value.hid_otg_profile !== 'custom') return true const f = config.value.hid_otg_functions return !!(f.keyboard || f.mouse_relative || f.mouse_absolute || f.consumer) }) @@ -946,26 +987,9 @@ async function saveConfig() { // HID config if (activeSection.value === 'hid') { - if (!isHidFunctionSelectionValid.value) { + if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) { return } - let desiredMsdEnabled = config.value.msd_enabled - if (config.value.hid_backend === 'otg') { - if (config.value.hid_otg_profile === 'full') { - desiredMsdEnabled = true - } else if (config.value.hid_otg_profile === 'full_no_msd') { - desiredMsdEnabled = false - } else if (config.value.hid_otg_profile === 'full_no_consumer') { - desiredMsdEnabled = true - } else if (config.value.hid_otg_profile === 'full_no_consumer_no_msd') { - desiredMsdEnabled = false - } else if ( - config.value.hid_otg_profile === 'legacy_keyboard' - || config.value.hid_otg_profile === 'legacy_mouse_relative' - ) { - desiredMsdEnabled = false - } - } const hidUpdate: any = { backend: config.value.hid_backend as any, ch9329_port: config.value.hid_serial_device || undefined, @@ -980,16 +1004,15 @@ async function saveConfig() { product: otgProduct.value || 'One-KVM USB Device', serial_number: otgSerialNumber.value || undefined, } - hidUpdate.otg_profile = config.value.hid_otg_profile + hidUpdate.otg_profile = 'custom' + hidUpdate.otg_endpoint_budget = config.value.hid_otg_endpoint_budget hidUpdate.otg_functions = { ...config.value.hid_otg_functions } + hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds } savePromises.push(configStore.updateHid(hidUpdate)) - if (config.value.msd_enabled !== desiredMsdEnabled) { - config.value.msd_enabled = desiredMsdEnabled - } savePromises.push( configStore.updateMsd({ - enabled: desiredMsdEnabled, + enabled: config.value.msd_enabled, }) ) } @@ -1034,13 +1057,15 @@ async function loadConfig() { hid_serial_device: hid.ch9329_port || '', hid_serial_baudrate: hid.ch9329_baudrate || 9600, hid_otg_udc: hid.otg_udc || '', - hid_otg_profile: (hid.otg_profile || 'full') as OtgHidProfile, + hid_otg_profile: 'custom' as OtgHidProfile, + hid_otg_endpoint_budget: normalizeOtgEndpointBudget(hid.otg_endpoint_budget, hid.otg_udc || ''), hid_otg_functions: { keyboard: hid.otg_functions?.keyboard ?? true, mouse_relative: hid.otg_functions?.mouse_relative ?? true, mouse_absolute: hid.otg_functions?.mouse_absolute ?? true, consumer: hid.otg_functions?.consumer ?? true, } as OtgHidFunctions, + hid_otg_keyboard_leds: hid.otg_keyboard_leds ?? false, msd_enabled: msd.enabled || false, msd_dir: msd.msd_dir || '', encoder_backend: stream.encoder || 'auto', @@ -1065,9 +1090,6 @@ async function loadConfig() { } catch (e) { console.error('Failed to load config:', e) - } finally { - configLoaded.value = true - alignHidProfileForLowEndpoint() } } @@ -1076,9 +1098,6 @@ async function loadDevices() { devices.value = await configApi.listDevices() } catch (e) { console.error('Failed to load devices:', e) - } finally { - devicesLoaded.value = true - alignHidProfileForLowEndpoint() } } @@ -2230,63 +2249,75 @@ watch(() => route.query.tab, (tab) => {

{{ t('settings.otgHidProfileDesc') }}

- - + + + +

{{ otgEndpointUsageText }}

-
-
-
- -

{{ t('settings.otgFunctionKeyboardDesc') }}

+
+
+
+
+ +

{{ t('settings.otgFunctionMouseRelativeDesc') }}

+
+ +
+ +
+
+ +

{{ t('settings.otgFunctionMouseAbsoluteDesc') }}

+
+
-
- -
-
- -

{{ t('settings.otgFunctionMouseRelativeDesc') }}

+
+
+
+ +

{{ t('settings.otgFunctionKeyboardDesc') }}

+
+ +
+ +
+
+ +

{{ t('settings.otgFunctionConsumerDesc') }}

+
+ +
+ +
+
+ +

{{ t('settings.otgKeyboardLedsDesc') }}

+
+
-
- -
-
- -

{{ t('settings.otgFunctionMouseAbsoluteDesc') }}

+
+
+
+ +

{{ t('settings.otgFunctionMsdDesc') }}

+
+
- -
- -
-
- -

{{ t('settings.otgFunctionConsumerDesc') }}

-
- -
- -
-
- -

{{ t('settings.otgFunctionMsdDesc') }}

-
-

{{ t('settings.otgProfileWarning') }}

-

- {{ t('settings.otgLowEndpointHint') }} +

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

+

+ {{ t('settings.otgEndpointExceeded', { used: otgRequiredEndpoints, limit: describeEndpointBudget(config.hid_otg_endpoint_budget) }) }}

diff --git a/web/src/views/SetupView.vue b/web/src/views/SetupView.vue index d7625a8d..83299dbc 100644 --- a/web/src/views/SetupView.vue +++ b/web/src/views/SetupView.vue @@ -97,7 +97,12 @@ const ch9329Port = ref('') const ch9329Baudrate = ref(9600) const otgUdc = ref('') const hidOtgProfile = ref('full') +const otgMsdEnabled = ref(true) +const otgEndpointBudget = ref<'five' | 'six' | 'unlimited'>('six') +const otgKeyboardLeds = ref(true) const otgProfileTouched = ref(false) +const otgEndpointBudgetTouched = ref(false) +const otgKeyboardLedsTouched = ref(false) const showAdvancedOtg = ref(false) // Extension settings @@ -203,19 +208,67 @@ const availableFps = computed(() => { return resolution?.fps || [] }) -const isLowEndpointUdc = computed(() => { - if (otgUdc.value) { - return /musb/i.test(otgUdc.value) +function defaultOtgEndpointBudgetForUdc(udc?: string): 'five' | 'six' { + return /musb/i.test(udc || '') ? 'five' : 'six' +} + +function endpointLimitForBudget(budget: 'five' | 'six' | 'unlimited'): number | null { + if (budget === 'unlimited') return null + return budget === 'five' ? 5 : 6 +} + +const otgRequiredEndpoints = computed(() => { + if (hidBackend.value !== 'otg') return 0 + const functions = { + keyboard: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer' || hidOtgProfile.value === 'legacy_keyboard', + mouseRelative: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer' || hidOtgProfile.value === 'legacy_mouse_relative', + mouseAbsolute: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer', + consumer: hidOtgProfile.value === 'full', } - return devices.value.udc.some((udc) => /musb/i.test(udc.name)) + let endpoints = 0 + if (functions.keyboard) { + endpoints += 1 + if (otgKeyboardLeds.value) endpoints += 1 + } + if (functions.mouseRelative) endpoints += 1 + if (functions.mouseAbsolute) endpoints += 1 + if (functions.consumer) endpoints += 1 + if (otgMsdEnabled.value) endpoints += 2 + return endpoints }) -function applyOtgProfileDefault() { - if (otgProfileTouched.value) return +const otgProfileHasKeyboard = computed(() => + hidOtgProfile.value === 'full' + || hidOtgProfile.value === 'full_no_consumer' + || hidOtgProfile.value === 'legacy_keyboard' +) + +const isOtgEndpointBudgetValid = computed(() => { + const limit = endpointLimitForBudget(otgEndpointBudget.value) + return limit === null || otgRequiredEndpoints.value <= limit +}) + +const otgEndpointUsageText = computed(() => { + const limit = endpointLimitForBudget(otgEndpointBudget.value) + if (limit === null) { + return t('settings.otgEndpointUsageUnlimited', { used: otgRequiredEndpoints.value }) + } + return t('settings.otgEndpointUsage', { used: otgRequiredEndpoints.value, limit }) +}) + +function applyOtgDefaults() { if (hidBackend.value !== 'otg') return - const preferred = isLowEndpointUdc.value ? 'full_no_consumer' : 'full' - if (hidOtgProfile.value === preferred) return - hidOtgProfile.value = preferred + + const recommendedBudget = defaultOtgEndpointBudgetForUdc(otgUdc.value) + if (!otgEndpointBudgetTouched.value) { + otgEndpointBudget.value = recommendedBudget + } + if (!otgProfileTouched.value) { + hidOtgProfile.value = 'full_no_consumer' + } + if (!otgKeyboardLedsTouched.value) { + otgKeyboardLeds.value = otgEndpointBudget.value !== 'five' + } } function onOtgProfileChange(value: unknown) { @@ -223,6 +276,20 @@ function onOtgProfileChange(value: unknown) { otgProfileTouched.value = true } +function onOtgEndpointBudgetChange(value: unknown) { + otgEndpointBudget.value = + value === 'five' || value === 'six' || value === 'unlimited' ? value : 'six' + otgEndpointBudgetTouched.value = true + if (!otgKeyboardLedsTouched.value) { + otgKeyboardLeds.value = otgEndpointBudget.value !== 'five' + } +} + +function onOtgKeyboardLedsChange(value: boolean) { + otgKeyboardLeds.value = value + otgKeyboardLedsTouched.value = true +} + // Common baud rates for CH9329 const baudRates = [9600, 19200, 38400, 57600, 115200] @@ -338,16 +405,16 @@ watch(hidBackend, (newBackend) => { if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) { otgUdc.value = devices.value.udc[0]?.name || '' } - applyOtgProfileDefault() + applyOtgDefaults() }) watch(otgUdc, () => { - applyOtgProfileDefault() + applyOtgDefaults() }) watch(showAdvancedOtg, (open) => { if (open) { - applyOtgProfileDefault() + applyOtgDefaults() } }) @@ -370,7 +437,7 @@ onMounted(async () => { if (result.udc.length > 0 && result.udc[0]) { otgUdc.value = result.udc[0].name } - applyOtgProfileDefault() + applyOtgDefaults() // Auto-select audio device if available (and no video device to trigger watch) if (result.audio.length > 0 && !audioDevice.value) { @@ -461,6 +528,13 @@ function validateStep3(): boolean { error.value = t('setup.selectUdc') return false } + if (hidBackend.value === 'otg' && !isOtgEndpointBudgetValid.value) { + error.value = t('settings.otgEndpointExceeded', { + used: otgRequiredEndpoints.value, + limit: otgEndpointBudget.value === 'unlimited' ? t('settings.otgEndpointBudgetUnlimited') : otgEndpointBudget.value === 'five' ? '5' : '6', + }) + return false + } return true } @@ -523,6 +597,9 @@ async function handleSetup() { if (hidBackend.value === 'otg' && otgUdc.value) { setupData.hid_otg_udc = otgUdc.value setupData.hid_otg_profile = hidOtgProfile.value + setupData.hid_otg_endpoint_budget = otgEndpointBudget.value + setupData.hid_otg_keyboard_leds = otgKeyboardLeds.value + setupData.msd_enabled = otgMsdEnabled.value } // Encoder backend setting @@ -990,16 +1067,47 @@ const stepIcons = [User, Video, Keyboard, Puzzle] {{ t('settings.otgProfileFull') }} - {{ t('settings.otgProfileFullNoMsd') }} {{ t('settings.otgProfileFullNoConsumer') }} - {{ t('settings.otgProfileFullNoConsumerNoMsd') }} {{ t('settings.otgProfileLegacyKeyboard') }} {{ t('settings.otgProfileLegacyMouseRelative') }}
-

- {{ t('setup.otgLowEndpointHint') }} +

+ + +

+ {{ otgEndpointUsageText }} +

+
+
+
+ +

{{ t('settings.otgKeyboardLedsDesc') }}

+
+ +
+
+
+ +

{{ t('settings.otgFunctionMsdDesc') }}

+
+ +
+

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

+

+ {{ t('settings.otgEndpointExceeded', { used: otgRequiredEndpoints, limit: otgEndpointBudget === 'unlimited' ? t('settings.otgEndpointBudgetUnlimited') : otgEndpointBudget === 'five' ? '5' : '6' }) }}