refactor(otg): 简化运行时与设置逻辑

This commit is contained in:
mofeng-git
2026-03-28 21:09:10 +08:00
parent 4784cb75e4
commit f4283f45a4
27 changed files with 1427 additions and 1249 deletions

View File

@@ -148,13 +148,11 @@ impl Default for OtgDescriptorConfig {
pub enum OtgHidProfile { pub enum OtgHidProfile {
/// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) /// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control)
#[default] #[default]
#[serde(alias = "full_no_msd")]
Full, Full,
/// Full HID device set without MSD
FullNoMsd,
/// Full HID device set without consumer control /// Full HID device set without consumer control
#[serde(alias = "full_no_consumer_no_msd")]
FullNoConsumer, FullNoConsumer,
/// Full HID device set without consumer control and MSD
FullNoConsumerNoMsd,
/// Legacy profile: only keyboard /// Legacy profile: only keyboard
LegacyKeyboard, LegacyKeyboard,
/// Legacy profile: only relative mouse /// Legacy profile: only relative mouse
@@ -163,9 +161,52 @@ pub enum OtgHidProfile {
Custom, 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<u8> {
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) /// OTG HID function selection (used when profile is Custom)
#[typeshare] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)] #[serde(default)]
pub struct OtgHidFunctions { pub struct OtgHidFunctions {
pub keyboard: bool, pub keyboard: bool,
@@ -214,6 +255,26 @@ impl OtgHidFunctions {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
!self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer !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 { impl Default for OtgHidFunctions {
@@ -223,12 +284,21 @@ impl Default for OtgHidFunctions {
} }
impl OtgHidProfile { impl OtgHidProfile {
pub fn from_legacy_str(value: &str) -> Option<Self> {
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 { pub fn resolve_functions(&self, custom: &OtgHidFunctions) -> OtgHidFunctions {
match self { match self {
Self::Full => OtgHidFunctions::full(), Self::Full => OtgHidFunctions::full(),
Self::FullNoMsd => OtgHidFunctions::full(),
Self::FullNoConsumer => OtgHidFunctions::full_no_consumer(), Self::FullNoConsumer => OtgHidFunctions::full_no_consumer(),
Self::FullNoConsumerNoMsd => OtgHidFunctions::full_no_consumer(),
Self::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(), Self::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(),
Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(), Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(),
Self::Custom => custom.clone(), Self::Custom => custom.clone(),
@@ -243,10 +313,6 @@ impl OtgHidProfile {
pub struct HidConfig { pub struct HidConfig {
/// HID backend type /// HID backend type
pub backend: HidBackend, 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 /// OTG UDC (USB Device Controller) name
pub otg_udc: Option<String>, pub otg_udc: Option<String>,
/// OTG USB device descriptor configuration /// OTG USB device descriptor configuration
@@ -255,9 +321,15 @@ pub struct HidConfig {
/// OTG HID function profile /// OTG HID function profile
#[serde(default)] #[serde(default)]
pub otg_profile: OtgHidProfile, pub otg_profile: OtgHidProfile,
/// OTG endpoint budget policy
#[serde(default)]
pub otg_endpoint_budget: OtgEndpointBudget,
/// OTG HID function selection (used when profile is Custom) /// OTG HID function selection (used when profile is Custom)
#[serde(default)] #[serde(default)]
pub otg_functions: OtgHidFunctions, pub otg_functions: OtgHidFunctions,
/// Enable keyboard LED/status feedback for OTG keyboard
#[serde(default)]
pub otg_keyboard_leds: bool,
/// CH9329 serial port /// CH9329 serial port
pub ch9329_port: String, pub ch9329_port: String,
/// CH9329 baud rate /// CH9329 baud rate
@@ -270,12 +342,12 @@ impl Default for HidConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
backend: HidBackend::None, backend: HidBackend::None,
otg_keyboard: "/dev/hidg0".to_string(),
otg_mouse: "/dev/hidg1".to_string(),
otg_udc: None, otg_udc: None,
otg_descriptor: OtgDescriptorConfig::default(), otg_descriptor: OtgDescriptorConfig::default(),
otg_profile: OtgHidProfile::default(), otg_profile: OtgHidProfile::default(),
otg_endpoint_budget: OtgEndpointBudget::default(),
otg_functions: OtgHidFunctions::default(), otg_functions: OtgHidFunctions::default(),
otg_keyboard_leds: false,
ch9329_port: "/dev/ttyUSB0".to_string(), ch9329_port: "/dev/ttyUSB0".to_string(),
ch9329_baudrate: 9600, ch9329_baudrate: 9600,
mouse_absolute: true, mouse_absolute: true,
@@ -287,6 +359,62 @@ impl HidConfig {
pub fn effective_otg_functions(&self) -> OtgHidFunctions { pub fn effective_otg_functions(&self) -> OtgHidFunctions {
self.otg_profile.resolve_functions(&self.otg_functions) self.otg_profile.resolve_functions(&self.otg_functions)
} }
pub fn resolved_otg_udc(&self) -> Option<String> {
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<u8> {
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 /// MSD configuration

View File

@@ -5,6 +5,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use crate::hid::LedState;
// ============================================================================ // ============================================================================
// Device Info Structures (for system.device_info event) // Device Info Structures (for system.device_info event)
// ============================================================================ // ============================================================================
@@ -45,6 +47,10 @@ pub struct HidDeviceInfo {
pub online: bool, pub online: bool,
/// Whether absolute mouse positioning is supported /// Whether absolute mouse positioning is supported
pub supports_absolute_mouse: bool, 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) /// Device path (e.g., serial port for CH9329)
pub device: Option<String>, pub device: Option<String>,
/// Error message if any, None if OK /// Error message if any, None if OK

View File

@@ -2,7 +2,9 @@
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use super::otg::LedState;
use super::types::{ConsumerEvent, KeyboardEvent, MouseEvent}; use super::types::{ConsumerEvent, KeyboardEvent, MouseEvent};
use crate::error::Result; use crate::error::Result;
@@ -76,12 +78,22 @@ impl HidBackendType {
} }
/// Current runtime status reported by a HID backend. /// Current runtime status reported by a HID backend.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HidBackendStatus { pub struct HidBackendRuntimeSnapshot {
/// Whether the backend has been initialized and can accept requests. /// Whether the backend has been initialized and can accept requests.
pub initialized: bool, pub initialized: bool,
/// Whether the backend is currently online and communicating successfully. /// Whether the backend is currently online and communicating successfully.
pub online: bool, 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<String>,
/// Current user-facing error, if any. /// Current user-facing error, if any.
pub error: Option<String>, pub error: Option<String>,
/// Current programmatic error code, if any. /// Current programmatic error code, if any.
@@ -91,9 +103,6 @@ pub struct HidBackendStatus {
/// HID backend trait /// HID backend trait
#[async_trait] #[async_trait]
pub trait HidBackend: Send + Sync { pub trait HidBackend: Send + Sync {
/// Get backend name
fn name(&self) -> &'static str;
/// Initialize the backend /// Initialize the backend
async fn init(&self) -> Result<()>; async fn init(&self) -> Result<()>;
@@ -117,18 +126,11 @@ pub trait HidBackend: Send + Sync {
/// Shutdown the backend /// Shutdown the backend
async fn shutdown(&self) -> Result<()>; async fn shutdown(&self) -> Result<()>;
/// Get the current backend runtime status. /// Get the current backend runtime snapshot.
fn status(&self) -> HidBackendStatus; fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot;
/// Check if backend supports absolute mouse positioning /// Subscribe to backend runtime changes.
fn supports_absolute_mouse(&self) -> bool { fn subscribe_runtime(&self) -> watch::Receiver<()>;
false
}
/// Get screen resolution (for absolute mouse)
fn screen_resolution(&self) -> Option<(u32, u32)> {
None
}
/// Set screen resolution (for absolute mouse) /// Set screen resolution (for absolute mouse)
fn set_screen_resolution(&mut self, _width: u32, _height: u32) {} fn set_screen_resolution(&mut self, _width: u32, _height: u32) {}

View File

@@ -25,9 +25,11 @@ use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU8, Ordering};
use std::sync::{mpsc, Arc}; use std::sync::{mpsc, Arc};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::watch;
use tracing::{info, trace, warn}; 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 super::types::{KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
@@ -180,7 +182,7 @@ impl ChipInfo {
} }
/// Keyboard LED status /// Keyboard LED status
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LedStatus { pub struct LedStatus {
pub num_lock: bool, pub num_lock: bool,
pub caps_lock: bool, pub caps_lock: bool,
@@ -346,28 +348,73 @@ const MAX_PACKET_SIZE: usize = 70;
// CH9329 Backend Implementation // CH9329 Backend Implementation
// ============================================================================ // ============================================================================
#[derive(Default)]
struct Ch9329RuntimeState { struct Ch9329RuntimeState {
initialized: AtomicBool, initialized: AtomicBool,
online: AtomicBool, online: AtomicBool,
last_error: RwLock<Option<(String, String)>>, last_error: RwLock<Option<(String, String)>>,
last_success: Mutex<Option<Instant>>, notify_tx: watch::Sender<()>,
} }
impl Ch9329RuntimeState { 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) { 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) { fn set_online(&self) {
self.online.store(true, Ordering::Relaxed); let was_online = self.online.swap(true, Ordering::Relaxed);
*self.last_success.lock() = Some(Instant::now()); let mut error = self.last_error.write();
self.clear_error(); let cleared_error = error.take().is_some();
drop(error);
if !was_online || cleared_error {
self.notify();
}
} }
fn set_error(&self, reason: impl Into<String>, error_code: impl Into<String>) { fn set_error(&self, reason: impl Into<String>, error_code: impl Into<String>) {
self.online.store(false, Ordering::Relaxed); let reason = reason.into();
*self.last_error.write() = Some((reason.into(), error_code.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_x: AtomicU16::new(0),
last_abs_y: AtomicU16::new(0), last_abs_y: AtomicU16::new(0),
relative_mouse_active: AtomicBool::new(false), 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); 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 /// Check if the serial port device file exists
pub fn check_port_exists(&self) -> bool { pub fn check_port_exists(&self) -> bool {
std::path::Path::new(&self.port_path).exists() 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 /// Convert serialport error to HidError
fn serial_error_to_hid_error(e: serialport::Error, operation: &str) -> AppError { fn serial_error_to_hid_error(e: serialport::Error, operation: &str) -> AppError {
let error_code = match e.kind() { let error_code = match e.kind() {
@@ -675,23 +709,33 @@ impl Ch9329Backend {
chip_info: &Arc<RwLock<Option<ChipInfo>>>, chip_info: &Arc<RwLock<Option<ChipInfo>>>,
led_status: &Arc<RwLock<LedStatus>>, led_status: &Arc<RwLock<LedStatus>>,
info: ChipInfo, info: ChipInfo,
) { ) -> bool {
*chip_info.write() = Some(info.clone()); let next_led_status = LedStatus {
*led_status.write() = LedStatus {
num_lock: info.num_lock, num_lock: info.num_lock,
caps_lock: info.caps_lock, caps_lock: info.caps_lock,
scroll_lock: info.scroll_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<()> { fn enqueue_command(&self, command: WorkerCommand) -> Result<()> {
let guard = self.worker_tx.lock(); let guard = self.worker_tx.lock();
let sender = guard let Some(sender) = guard.as_ref() else {
.as_ref() self.record_error("CH9329 worker is not running", "worker_stopped");
.ok_or_else(|| Self::backend_error("CH9329 worker is not running", "worker_stopped"))?; return Err(Self::backend_error(
sender "CH9329 worker is not running",
.send(command) "worker_stopped",
.map_err(|_| Self::backend_error("CH9329 worker stopped", "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<()> { 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( fn worker_reconnect_loop(
rx: &mpsc::Receiver<WorkerCommand>, rx: &mpsc::Receiver<WorkerCommand>,
port_path: &str, port_path: &str,
@@ -745,7 +776,9 @@ impl Ch9329Backend {
"disconnected" "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(); runtime.set_online();
return Some(port); return Some(port);
} }
@@ -761,36 +794,6 @@ impl Ch9329Backend {
} }
} }
/// Get cached chip information
pub fn get_chip_info(&self) -> Option<ChipInfo> {
self.chip_info.read().clone()
}
pub fn query_chip_info(&self) -> Result<ChipInfo> {
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<()> { fn send_keyboard_report(&self, report: &KeyboardReport) -> Result<()> {
let data = report.to_bytes(); let data = report.to_bytes();
self.send_packet(cmd::SEND_KB_GENERAL_DATA, &data) self.send_packet(cmd::SEND_KB_GENERAL_DATA, &data)
@@ -805,20 +808,6 @@ impl Ch9329Backend {
self.send_packet(cmd::SEND_KB_MEDIA_DATA, data) 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<()> { pub fn release_media_keys(&self) -> Result<()> {
self.send_media_key(&[0x02, 0x00, 0x00, 0x00]) self.send_media_key(&[0x02, 0x00, 0x00, 0x00])
} }
@@ -843,13 +832,6 @@ impl Ch9329Backend {
Ok(()) 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( fn worker_loop(
port_path: String, port_path: String,
baud_rate: u32, baud_rate: u32,
@@ -860,7 +842,7 @@ impl Ch9329Backend {
runtime: Arc<Ch9329RuntimeState>, runtime: Arc<Ch9329RuntimeState>,
init_tx: mpsc::Sender<Result<ChipInfo>>, init_tx: mpsc::Sender<Result<ChipInfo>>,
) { ) {
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 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)?; let info = Self::query_chip_info_on_port(port.as_mut(), address)?;
@@ -871,7 +853,9 @@ impl Ch9329Backend {
"CH9329 serial port opened: {} @ {} baud", "CH9329 serial port opened: {} @ {} baud",
port_path, baud_rate 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(); runtime.set_online();
let _ = init_tx.send(Ok(info)); let _ = init_tx.send(Ok(info));
port port
@@ -884,7 +868,7 @@ impl Ch9329Backend {
runtime.set_error(reason.clone(), error_code.clone()); runtime.set_error(reason.clone(), error_code.clone());
} }
let _ = init_tx.send(Err(err)); let _ = init_tx.send(Err(err));
runtime.initialized.store(false, Ordering::Relaxed); runtime.set_initialized(false);
return; return;
} }
}; };
@@ -961,7 +945,9 @@ impl Ch9329Backend {
Err(mpsc::RecvTimeoutError::Timeout) => { Err(mpsc::RecvTimeoutError::Timeout) => {
match Self::query_chip_info_on_port(port.as_mut(), address) { match Self::query_chip_info_on_port(port.as_mut(), address) {
Ok(info) => { 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(); runtime.set_online();
} }
Err(err) => { Err(err) => {
@@ -993,8 +979,8 @@ impl Ch9329Backend {
} }
} }
runtime.online.store(false, Ordering::Relaxed); runtime.set_offline();
runtime.initialized.store(false, Ordering::Relaxed); runtime.set_initialized(false);
} }
} }
@@ -1004,10 +990,6 @@ impl Ch9329Backend {
#[async_trait] #[async_trait]
impl HidBackend for Ch9329Backend { impl HidBackend for Ch9329Backend {
fn name(&self) -> &'static str {
"CH9329 Serial"
}
async fn init(&self) -> Result<()> { async fn init(&self) -> Result<()> {
if self.worker_handle.lock().is_some() { if self.worker_handle.lock().is_some() {
return Ok(()); return Ok(());
@@ -1047,7 +1029,7 @@ impl HidBackend for Ch9329Backend {
); );
*self.worker_tx.lock() = Some(tx); *self.worker_tx.lock() = Some(tx);
*self.worker_handle.lock() = Some(handle); *self.worker_handle.lock() = Some(handle);
self.mark_online(); self.runtime.set_online();
Ok(()) Ok(())
} }
Ok(Err(err)) => { Ok(Err(err)) => {
@@ -1215,15 +1197,15 @@ impl HidBackend for Ch9329Backend {
if let Some(handle) = self.worker_handle.lock().take() { if let Some(handle) = self.worker_handle.lock().take() {
let _ = handle.join(); let _ = handle.join();
} }
self.runtime.initialized.store(false, Ordering::Relaxed); self.runtime.set_offline();
self.runtime.online.store(false, Ordering::Relaxed); self.runtime.set_initialized(false);
self.clear_error(); self.runtime.clear_error();
info!("CH9329 backend shutdown"); info!("CH9329 backend shutdown");
Ok(()) Ok(())
} }
fn status(&self) -> HidBackendStatus { fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
let initialized = self.runtime.initialized.load(Ordering::Relaxed); let initialized = self.runtime.initialized.load(Ordering::Relaxed);
let mut online = initialized && self.runtime.online.load(Ordering::Relaxed); let mut online = initialized && self.runtime.online.load(Ordering::Relaxed);
let mut error = self.runtime.last_error.read().clone(); let mut error = self.runtime.last_error.read().clone();
@@ -1236,25 +1218,36 @@ impl HidBackend for Ch9329Backend {
)); ));
} }
HidBackendStatus { HidBackendRuntimeSnapshot {
initialized, initialized,
online, 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: error.as_ref().map(|(reason, _)| reason.clone()),
error_code: error.as_ref().map(|(_, code)| code.clone()), error_code: error.as_ref().map(|(_, code)| code.clone()),
} }
} }
fn supports_absolute_mouse(&self) -> bool { fn subscribe_runtime(&self) -> watch::Receiver<()> {
true self.runtime.subscribe()
}
fn screen_resolution(&self) -> Option<(u32, u32)> {
Some((self.screen_width, self.screen_height))
} }
fn set_screen_resolution(&mut self, width: u32, height: u32) { fn set_screen_resolution(&mut self, width: u32, height: u32) {
self.screen_width = width; self.screen_width = width;
self.screen_height = height; self.screen_height = height;
self.runtime.notify();
} }
} }

View File

@@ -20,7 +20,7 @@ pub mod otg;
pub mod types; pub mod types;
pub mod websocket; pub mod websocket;
pub use backend::{HidBackend, HidBackendStatus, HidBackendType}; pub use backend::{HidBackend, HidBackendRuntimeSnapshot, HidBackendType};
pub use keyboard::CanonicalKey; pub use keyboard::CanonicalKey;
pub use otg::LedState; pub use otg::LedState;
pub use types::{ pub use types::{
@@ -54,6 +54,10 @@ pub struct HidRuntimeState {
pub online: bool, pub online: bool,
/// Whether absolute mouse positioning is supported. /// Whether absolute mouse positioning is supported.
pub supports_absolute_mouse: bool, 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. /// Screen resolution for absolute mouse mode.
pub screen_resolution: Option<(u32, u32)>, pub screen_resolution: Option<(u32, u32)>,
/// Device path associated with the backend, if any. /// Device path associated with the backend, if any.
@@ -72,6 +76,8 @@ impl HidRuntimeState {
initialized: false, initialized: false,
online: false, online: false,
supports_absolute_mouse: false, supports_absolute_mouse: false,
keyboard_leds_enabled: false,
led_state: LedState::default(),
screen_resolution: None, screen_resolution: None,
device: device_for_backend_type(backend_type), device: device_for_backend_type(backend_type),
error: None, error: None,
@@ -79,18 +85,21 @@ impl HidRuntimeState {
} }
} }
fn from_backend(backend_type: &HidBackendType, backend: &dyn HidBackend) -> Self { fn from_backend(backend_type: &HidBackendType, snapshot: HidBackendRuntimeSnapshot) -> Self {
let status = backend.status();
Self { Self {
available: !matches!(backend_type, HidBackendType::None), available: !matches!(backend_type, HidBackendType::None),
backend: backend_type.name_str().to_string(), backend: backend_type.name_str().to_string(),
initialized: status.initialized, initialized: snapshot.initialized,
online: status.online, online: snapshot.online,
supports_absolute_mouse: backend.supports_absolute_mouse(), supports_absolute_mouse: snapshot.supports_absolute_mouse,
screen_resolution: backend.screen_resolution(), keyboard_leds_enabled: snapshot.keyboard_leds_enabled,
device: device_for_backend_type(backend_type), led_state: snapshot.led_state,
error: status.error, screen_resolution: snapshot.screen_resolution,
error_code: status.error_code, 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.backend = backend_type.name_str().to_string();
next.initialized = false; next.initialized = false;
next.online = false; next.online = false;
next.keyboard_leds_enabled = false;
next.led_state = LedState::default();
next.device = device_for_backend_type(backend_type); next.device = device_for_backend_type(backend_type);
next.error = Some(reason.into()); next.error = Some(reason.into());
next.error_code = Some(error_code.into()); next.error_code = Some(error_code.into());
@@ -114,13 +125,13 @@ impl HidRuntimeState {
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::events::EventBus; use crate::events::EventBus;
use crate::otg::OtgService; use crate::otg::OtgService;
use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
@@ -158,6 +169,8 @@ pub struct HidController {
pending_move_flag: Arc<AtomicBool>, pending_move_flag: Arc<AtomicBool>,
/// Worker task handle /// Worker task handle
hid_worker: Mutex<Option<JoinHandle<()>>>, hid_worker: Mutex<Option<JoinHandle<()>>>,
/// Backend runtime subscription task handle
runtime_worker: Mutex<Option<JoinHandle<()>>>,
/// Backend initialization fast flag /// Backend initialization fast flag
backend_available: Arc<AtomicBool>, backend_available: Arc<AtomicBool>,
} }
@@ -181,6 +194,7 @@ impl HidController {
pending_move: Arc::new(parking_lot::Mutex::new(None)), pending_move: Arc::new(parking_lot::Mutex::new(None)),
pending_move_flag: Arc::new(AtomicBool::new(false)), pending_move_flag: Arc::new(AtomicBool::new(false)),
hid_worker: Mutex::new(None), hid_worker: Mutex::new(None),
runtime_worker: Mutex::new(None),
backend_available: Arc::new(AtomicBool::new(false)), backend_available: Arc::new(AtomicBool::new(false)),
} }
} }
@@ -195,16 +209,15 @@ impl HidController {
let backend_type = self.backend_type.read().await.clone(); let backend_type = self.backend_type.read().await.clone();
let backend: Arc<dyn HidBackend> = match backend_type { let backend: Arc<dyn HidBackend> = match backend_type {
HidBackendType::Otg => { HidBackendType::Otg => {
// Request HID functions from OtgService
let otg_service = self let otg_service = self
.otg_service .otg_service
.as_ref() .as_ref()
.ok_or_else(|| AppError::Internal("OtgService not available".into()))?; .ok_or_else(|| AppError::Internal("OtgService not available".into()))?;
info!("Requesting HID functions from OtgService"); let handles = otg_service.hid_device_paths().await.ok_or_else(|| {
let handles = otg_service.enable_hid().await?; 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"); info!("Creating OTG HID backend from device paths");
Arc::new(otg::OtgBackend::from_handles(handles)?) Arc::new(otg::OtgBackend::from_handles(handles)?)
} }
@@ -245,6 +258,7 @@ impl HidController {
// Start HID event worker (once) // Start HID event worker (once)
self.start_event_worker().await; self.start_event_worker().await;
self.restart_runtime_worker().await;
info!("HID backend initialized: {:?}", backend_type); info!("HID backend initialized: {:?}", backend_type);
Ok(()) Ok(())
@@ -253,6 +267,7 @@ impl HidController {
/// Shutdown the HID backend and release resources /// Shutdown the HID backend and release resources
pub async fn shutdown(&self) -> Result<()> { pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down HID controller"); info!("Shutting down HID controller");
self.stop_runtime_worker().await;
// Close the backend // Close the backend
if let Some(backend) = self.backend.write().await.take() { if let Some(backend) = self.backend.write().await.take() {
@@ -271,14 +286,6 @@ impl HidController {
} }
self.apply_runtime_state(shutdown_state).await; 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"); info!("HID controller shutdown complete");
Ok(()) Ok(())
} }
@@ -365,6 +372,7 @@ impl HidController {
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> { pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
info!("Reloading HID backend: {:?}", new_backend_type); info!("Reloading HID backend: {:?}", new_backend_type);
self.backend_available.store(false, Ordering::Release); self.backend_available.store(false, Ordering::Release);
self.stop_runtime_worker().await;
// Shutdown existing backend first // Shutdown existing backend first
if let Some(backend) = self.backend.write().await.take() { if let Some(backend) = self.backend.write().await.take() {
@@ -389,9 +397,8 @@ impl HidController {
} }
}; };
// Request HID functions from OtgService match otg_service.hid_device_paths().await {
match otg_service.enable_hid().await { Some(handles) => {
Ok(handles) => {
// Create OtgBackend from handles // Create OtgBackend from handles
match otg::OtgBackend::from_handles(handles) { match otg::OtgBackend::from_handles(handles) {
Ok(backend) => { Ok(backend) => {
@@ -403,29 +410,18 @@ impl HidController {
} }
Err(e) => { Err(e) => {
warn!("Failed to initialize OTG backend: {}", 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 None
} }
} }
} }
Err(e) => { Err(e) => {
warn!("Failed to create OTG backend: {}", 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 None
} }
} }
} }
Err(e) => { None => {
warn!("Failed to enable HID in OtgService: {}", e); warn!("OTG HID paths are not available");
None None
} }
} }
@@ -478,6 +474,7 @@ impl HidController {
*self.backend_type.write().await = new_backend_type.clone(); *self.backend_type.write().await = new_backend_type.clone();
self.sync_runtime_state_from_backend().await; self.sync_runtime_state_from_backend().await;
self.restart_runtime_worker().await;
Ok(()) Ok(())
} else { } else {
@@ -508,16 +505,14 @@ impl HidController {
async fn sync_runtime_state_from_backend(&self) { async fn sync_runtime_state_from_backend(&self) {
let backend_opt = self.backend.read().await.clone(); let backend_opt = self.backend.read().await.clone();
let backend_type = self.backend_type.read().await.clone(); apply_backend_runtime_state(
&self.backend_type,
let next = match backend_opt.as_ref() { &self.runtime_state,
Some(backend) => HidRuntimeState::from_backend(&backend_type, backend.as_ref()), &self.events,
None => HidRuntimeState::from_backend_type(&backend_type), self.backend_available.as_ref(),
}; backend_opt.as_deref(),
)
self.backend_available .await;
.store(next.initialized, Ordering::Release);
self.apply_runtime_state(next).await;
} }
async fn start_event_worker(&self) { async fn start_event_worker(&self) {
@@ -533,10 +528,6 @@ impl HidController {
}; };
let backend = self.backend.clone(); 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 = self.pending_move.clone();
let pending_move_flag = self.pending_move_flag.clone(); let pending_move_flag = self.pending_move_flag.clone();
@@ -548,29 +539,13 @@ impl HidController {
None => break, None => break,
}; };
process_hid_event( process_hid_event(event, &backend).await;
event,
&backend,
&backend_type,
&runtime_state,
&events,
backend_available.as_ref(),
)
.await;
// After each event, flush latest move if pending // After each event, flush latest move if pending
if pending_move_flag.swap(false, Ordering::AcqRel) { if pending_move_flag.swap(false, Ordering::AcqRel) {
let move_event = { pending_move.lock().take() }; let move_event = { pending_move.lock().take() };
if let Some(move_event) = move_event { if let Some(move_event) = move_event {
process_hid_event( process_hid_event(HidEvent::Mouse(move_event), &backend).await;
HidEvent::Mouse(move_event),
&backend,
&backend_type,
&runtime_state,
&events,
backend_available.as_ref(),
)
.await;
} }
} }
} }
@@ -579,6 +554,46 @@ impl HidController {
*worker_guard = Some(handle); *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<()> { fn enqueue_mouse_move(&self, event: MouseEvent) -> Result<()> {
match self.hid_tx.try_send(HidEvent::Mouse(event.clone())) { match self.hid_tx.try_send(HidEvent::Mouse(event.clone())) {
Ok(_) => Ok(()), Ok(_) => Ok(()),
@@ -618,14 +633,23 @@ impl HidController {
} }
} }
async fn process_hid_event( async fn apply_backend_runtime_state(
event: HidEvent,
backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
backend_type: &Arc<RwLock<HidBackendType>>, backend_type: &Arc<RwLock<HidBackendType>>,
runtime_state: &Arc<RwLock<HidRuntimeState>>, runtime_state: &Arc<RwLock<HidRuntimeState>>,
events: &Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>, events: &Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
backend_available: &AtomicBool, 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<RwLock<Option<Arc<dyn HidBackend>>>>) {
let backend_opt = backend.read().await.clone(); let backend_opt = backend.read().await.clone();
let backend = match backend_opt { let backend = match backend_opt {
Some(b) => b, Some(b) => b,
@@ -656,11 +680,6 @@ async fn process_hid_event(
warn!("HID event processing failed: {}", e); 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 { impl Default for HidController {

View File

@@ -1,10 +1,12 @@
//! OTG USB Gadget HID backend //! OTG USB Gadget HID backend
//! //!
//! This backend uses Linux USB Gadget API to emulate USB HID devices. //! This backend uses Linux USB Gadget API to emulate USB HID devices.
//! It creates and manages three HID devices: //! It opens the HID gadget device nodes created by `OtgService`.
//! - hidg0: Keyboard (8-byte reports, with LED feedback) //! Depending on the configured OTG profile, this may include:
//! - hidg1: Relative Mouse (4-byte reports) //! - hidg0: Keyboard
//! - hidg2: Absolute Mouse (6-byte reports) //! - hidg1: Relative Mouse
//! - hidg2: Absolute Mouse
//! - hidg3: Consumer Control Keyboard
//! //!
//! Requirements: //! Requirements:
//! - USB OTG/Device controller (UDC) //! - USB OTG/Device controller (UDC)
@@ -20,15 +22,20 @@
use async_trait::async_trait; use async_trait::async_trait;
use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
use parking_lot::Mutex; use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions}; use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsFd; use std::os::unix::io::AsFd;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; 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 tracing::{debug, info, trace, warn};
use super::backend::{HidBackend, HidBackendStatus}; use super::backend::{HidBackend, HidBackendRuntimeSnapshot};
use super::types::{ use super::types::{
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType, ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType,
}; };
@@ -45,7 +52,7 @@ enum DeviceType {
} }
/// Keyboard LED state /// Keyboard LED state
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct LedState { pub struct LedState {
/// Num Lock LED /// Num Lock LED
pub num_lock: bool, pub num_lock: bool,
@@ -123,12 +130,14 @@ pub struct OtgBackend {
mouse_abs_dev: Mutex<Option<File>>, mouse_abs_dev: Mutex<Option<File>>,
/// Consumer control device file /// Consumer control device file
consumer_dev: Mutex<Option<File>>, consumer_dev: Mutex<Option<File>>,
/// Whether keyboard LED/status feedback is enabled.
keyboard_leds_enabled: bool,
/// Current keyboard state /// Current keyboard state
keyboard_state: Mutex<KeyboardReport>, keyboard_state: Mutex<KeyboardReport>,
/// Current mouse button state /// Current mouse button state
mouse_buttons: AtomicU8, mouse_buttons: AtomicU8,
/// Last known LED state (using parking_lot::RwLock for sync access) /// Last known LED state (using parking_lot::RwLock for sync access)
led_state: parking_lot::RwLock<LedState>, led_state: Arc<parking_lot::RwLock<LedState>>,
/// Screen resolution for absolute mouse (using parking_lot::RwLock for sync access) /// Screen resolution for absolute mouse (using parking_lot::RwLock for sync access)
screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>, screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>,
/// UDC name for state checking (e.g., "fcc00000.usb") /// UDC name for state checking (e.g., "fcc00000.usb")
@@ -145,6 +154,12 @@ pub struct OtgBackend {
error_count: AtomicU8, error_count: AtomicU8,
/// Consecutive EAGAIN count (for offline threshold detection) /// Consecutive EAGAIN count (for offline threshold detection)
eagain_count: AtomicU8, eagain_count: AtomicU8,
/// Runtime change notifier.
runtime_notify_tx: watch::Sender<()>,
/// LED listener stop flag.
led_worker_stop: Arc<AtomicBool>,
/// Keyboard LED listener thread.
led_worker: Mutex<Option<thread::JoinHandle<()>>>,
} }
/// Write timeout in milliseconds (same as JetKVM's hidWriteTimeout) /// 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 /// 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. /// the USB gadget itself. The gadget must already be set up by OtgService.
pub fn from_handles(paths: HidDevicePaths) -> Result<Self> { pub fn from_handles(paths: HidDevicePaths) -> Result<Self> {
let (runtime_notify_tx, _runtime_notify_rx) = watch::channel(());
Ok(Self { Ok(Self {
keyboard_path: paths.keyboard, keyboard_path: paths.keyboard,
mouse_rel_path: paths.mouse_relative, mouse_rel_path: paths.mouse_relative,
@@ -165,32 +181,57 @@ impl OtgBackend {
mouse_rel_dev: Mutex::new(None), mouse_rel_dev: Mutex::new(None),
mouse_abs_dev: Mutex::new(None), mouse_abs_dev: Mutex::new(None),
consumer_dev: Mutex::new(None), consumer_dev: Mutex::new(None),
keyboard_leds_enabled: paths.keyboard_leds_enabled,
keyboard_state: Mutex::new(KeyboardReport::default()), keyboard_state: Mutex::new(KeyboardReport::default()),
mouse_buttons: AtomicU8::new(0), 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))), 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), initialized: AtomicBool::new(false),
online: AtomicBool::new(false), online: AtomicBool::new(false),
last_error: parking_lot::RwLock::new(None), last_error: parking_lot::RwLock::new(None),
last_error_log: parking_lot::Mutex::new(std::time::Instant::now()), last_error_log: parking_lot::Mutex::new(std::time::Instant::now()),
error_count: AtomicU8::new(0), error_count: AtomicU8::new(0),
eagain_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) { 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<String>, error_code: impl Into<String>) { fn record_error(&self, reason: impl Into<String>, error_code: impl Into<String>) {
self.online.store(false, Ordering::Relaxed); let reason = reason.into();
*self.last_error.write() = Some((reason.into(), error_code.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) { fn mark_online(&self) {
self.online.store(true, Ordering::Relaxed); let was_online = self.online.swap(true, Ordering::Relaxed);
self.clear_error(); 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) /// Log throttled error message (max once per second)
@@ -305,11 +346,6 @@ impl OtgBackend {
None 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 /// Ensure a device is open and ready for I/O
/// ///
/// This method is based on PiKVM's `__ensure_device()` pattern: /// This method is based on PiKVM's `__ensure_device()` pattern:
@@ -750,50 +786,181 @@ impl OtgBackend {
self.send_consumer_report(event.usage) self.send_consumer_report(event.usage)
} }
/// Read keyboard LED state (non-blocking)
pub fn read_led_state(&self) -> Result<Option<LedState>> {
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 /// Get last known LED state
pub fn led_state(&self) -> LedState { pub fn led_state(&self) -> LedState {
*self.led_state.read() *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] #[async_trait]
impl HidBackend for OtgBackend { impl HidBackend for OtgBackend {
fn name(&self) -> &'static str {
"OTG USB Gadget"
}
async fn init(&self) -> Result<()> { async fn init(&self) -> Result<()> {
info!("Initializing OTG HID backend"); info!("Initializing OTG HID backend");
// Auto-detect UDC name for state checking // 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() { if let Some(udc) = Self::find_udc() {
info!("Auto-detected UDC: {}", udc); info!("Auto-detected UDC: {}", udc);
self.set_udc_name(&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) // Wait for devices to appear (they should already exist from OtgService)
let mut device_paths = Vec::new(); let mut device_paths = Vec::new();
@@ -866,6 +1033,8 @@ impl HidBackend for OtgBackend {
// Mark as online if all devices opened successfully // Mark as online if all devices opened successfully
self.initialized.store(true, Ordering::Relaxed); self.initialized.store(true, Ordering::Relaxed);
self.notify_runtime_changed();
self.start_led_worker();
self.mark_online(); self.mark_online();
Ok(()) Ok(())
@@ -974,6 +1143,8 @@ impl HidBackend for OtgBackend {
} }
async fn shutdown(&self) -> Result<()> { async fn shutdown(&self) -> Result<()> {
self.stop_led_worker();
// Reset before closing // Reset before closing
self.reset().await?; self.reset().await?;
@@ -987,53 +1158,27 @@ impl HidBackend for OtgBackend {
self.initialized.store(false, Ordering::Relaxed); self.initialized.store(false, Ordering::Relaxed);
self.online.store(false, Ordering::Relaxed); self.online.store(false, Ordering::Relaxed);
self.clear_error(); self.clear_error();
self.notify_runtime_changed();
info!("OTG backend shutdown"); info!("OTG backend shutdown");
Ok(()) Ok(())
} }
fn status(&self) -> HidBackendStatus { fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
let initialized = self.initialized.load(Ordering::Relaxed); self.build_runtime_snapshot()
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 { fn subscribe_runtime(&self) -> watch::Receiver<()> {
initialized, self.runtime_notify_tx.subscribe()
online,
error: error.as_ref().map(|(reason, _)| reason.clone()),
error_code: error.as_ref().map(|(_, code)| code.clone()),
}
}
fn supports_absolute_mouse(&self) -> bool {
self.mouse_abs_path.as_ref().is_some_and(|p| p.exists())
} }
async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> { async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
self.send_consumer_report(event.usage) 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) { fn set_screen_resolution(&mut self, width: u32, height: u32) {
*self.screen_resolution.write() = Some((width, height)); *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 /// Implement Drop for OtgBackend to close device files
impl Drop for OtgBackend { impl Drop for OtgBackend {
fn drop(&mut self) { 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 // Close device files
// Note: Gadget cleanup is handled by OtgService, not here // Note: Gadget cleanup is handled by OtgService, not here
*self.keyboard_dev.lock() = None; *self.keyboard_dev.lock() = None;

View File

@@ -18,7 +18,7 @@ use one_kvm::events::EventBus;
use one_kvm::extensions::ExtensionManager; use one_kvm::extensions::ExtensionManager;
use one_kvm::hid::{HidBackendType, HidController}; use one_kvm::hid::{HidBackendType, HidController};
use one_kvm::msd::MsdController; use one_kvm::msd::MsdController;
use one_kvm::otg::{configfs, OtgService}; use one_kvm::otg::OtgService;
use one_kvm::rtsp::RtspService; use one_kvm::rtsp::RtspService;
use one_kvm::rustdesk::RustDeskService; use one_kvm::rustdesk::RustDeskService;
use one_kvm::state::AppState; use one_kvm::state::AppState;
@@ -319,32 +319,9 @@ async fn main() -> anyhow::Result<()> {
let otg_service = Arc::new(OtgService::new()); let otg_service = Arc::new(OtgService::new());
tracing::info!("OTG Service created"); tracing::info!("OTG Service created");
// Pre-enable OTG functions to avoid gadget recreation (prevents kernel crashes) // Reconcile OTG once from the persisted config so controllers only consume its result.
let will_use_otg_hid = matches!(config.hid.backend, config::HidBackend::Otg); if let Err(e) = otg_service.apply_config(&config.hid, &config.msd).await {
let will_use_msd = config.msd.enabled; tracing::warn!("Failed to apply OTG config: {}", e);
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);
}
} }
// Create HID controller based on config // Create HID controller based on config

View File

@@ -19,9 +19,6 @@ use super::types::{DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MsdMo
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::otg::{MsdFunction, MsdLunConfig, OtgService}; use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
/// USB Gadget path (system constant)
const GADGET_PATH: &str = "/sys/kernel/config/usb_gadget/one-kvm";
/// MSD Controller /// MSD Controller
pub struct MsdController { pub struct MsdController {
/// OTG Service reference /// OTG Service reference
@@ -83,9 +80,11 @@ impl MsdController {
warn!("Failed to create ventoy directory: {}", e); warn!("Failed to create ventoy directory: {}", e);
} }
// 2. Request MSD function from OtgService // 2. Get active MSD function from OtgService
info!("Requesting MSD function from OtgService"); info!("Fetching MSD function from OtgService");
let msd_func = self.otg_service.enable_msd().await?; 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 // 3. Store function handle
*self.msd_function.write().await = Some(msd_func); *self.msd_function.write().await = Some(msd_func);
@@ -190,7 +189,7 @@ impl MsdController {
MsdLunConfig::disk(image.path.clone(), read_only) 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 Some(ref msd) = *self.msd_function.read().await {
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await { if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
let error_msg = format!("Failed to configure LUN: {}", e); let error_msg = format!("Failed to configure LUN: {}", e);
@@ -264,7 +263,7 @@ impl MsdController {
// Configure LUN as read-write disk // Configure LUN as read-write disk
let config = MsdLunConfig::disk(self.drive_path.clone(), false); 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 Some(ref msd) = *self.msd_function.read().await {
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await { if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
let error_msg = format!("Failed to configure LUN: {}", e); let error_msg = format!("Failed to configure LUN: {}", e);
@@ -313,7 +312,7 @@ impl MsdController {
return Ok(()); 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 { if let Some(ref msd) = *self.msd_function.read().await {
msd.disconnect_lun_async(&gadget_path, 0).await?; msd.disconnect_lun_async(&gadget_path, 0).await?;
} }
@@ -512,6 +511,13 @@ impl MsdController {
downloads.keys().cloned().collect() downloads.keys().cloned().collect()
} }
async fn active_gadget_path(&self) -> Result<PathBuf> {
self.otg_service
.gadget_path()
.await
.ok_or_else(|| AppError::Internal("OTG gadget path is not available".to_string()))
}
/// Shutdown the controller /// Shutdown the controller
pub async fn shutdown(&self) -> Result<()> { pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down MSD controller"); info!("Shutting down MSD controller");
@@ -521,11 +527,7 @@ impl MsdController {
warn!("Error disconnecting during shutdown: {}", e); warn!("Error disconnecting during shutdown: {}", e);
} }
// 2. Notify OtgService to disable MSD // 2. Clear local state
info!("Disabling MSD function in OtgService");
self.otg_service.disable_msd().await?;
// 3. Clear local state
*self.msd_function.write().await = None; *self.msd_function.write().await = None;
let mut state = self.state.write().await; let mut state = self.state.write().await;

View File

@@ -7,14 +7,15 @@ use super::configfs::{
create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file, create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file,
}; };
use super::function::{FunctionMeta, GadgetFunction}; 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; use crate::error::Result;
/// HID function type /// HID function type
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum HidFunctionType { pub enum HidFunctionType {
/// Keyboard (no LED feedback) /// Keyboard
/// Uses 1 endpoint: IN
Keyboard, Keyboard,
/// Relative mouse (traditional mouse movement) /// Relative mouse (traditional mouse movement)
/// Uses 1 endpoint: IN /// Uses 1 endpoint: IN
@@ -28,7 +29,7 @@ pub enum HidFunctionType {
} }
impl HidFunctionType { impl HidFunctionType {
/// Get endpoints required for this function type /// Get the base endpoint cost for this function type.
pub fn endpoints(&self) -> u8 { pub fn endpoints(&self) -> u8 {
match self { match self {
HidFunctionType::Keyboard => 1, HidFunctionType::Keyboard => 1,
@@ -59,7 +60,7 @@ impl HidFunctionType {
} }
/// Get report length in bytes /// Get report length in bytes
pub fn report_length(&self) -> u8 { pub fn report_length(&self, _keyboard_leds: bool) -> u8 {
match self { match self {
HidFunctionType::Keyboard => 8, HidFunctionType::Keyboard => 8,
HidFunctionType::MouseRelative => 4, HidFunctionType::MouseRelative => 4,
@@ -69,9 +70,15 @@ impl HidFunctionType {
} }
/// Get report descriptor /// Get report descriptor
pub fn report_desc(&self) -> &'static [u8] { pub fn report_desc(&self, keyboard_leds: bool) -> &'static [u8] {
match self { match self {
HidFunctionType::Keyboard => KEYBOARD, HidFunctionType::Keyboard => {
if keyboard_leds {
KEYBOARD_WITH_LED
} else {
KEYBOARD
}
}
HidFunctionType::MouseRelative => MOUSE_RELATIVE, HidFunctionType::MouseRelative => MOUSE_RELATIVE,
HidFunctionType::MouseAbsolute => MOUSE_ABSOLUTE, HidFunctionType::MouseAbsolute => MOUSE_ABSOLUTE,
HidFunctionType::ConsumerControl => CONSUMER_CONTROL, HidFunctionType::ConsumerControl => CONSUMER_CONTROL,
@@ -98,15 +105,18 @@ pub struct HidFunction {
func_type: HidFunctionType, func_type: HidFunctionType,
/// Cached function name (avoids repeated allocation) /// Cached function name (avoids repeated allocation)
name: String, name: String,
/// Whether keyboard LED/status feedback is enabled.
keyboard_leds: bool,
} }
impl HidFunction { impl HidFunction {
/// Create a keyboard function /// Create a keyboard function
pub fn keyboard(instance: u8) -> Self { pub fn keyboard(instance: u8, keyboard_leds: bool) -> Self {
Self { Self {
instance, instance,
func_type: HidFunctionType::Keyboard, func_type: HidFunctionType::Keyboard,
name: format!("hid.usb{}", instance), name: format!("hid.usb{}", instance),
keyboard_leds,
} }
} }
@@ -116,6 +126,7 @@ impl HidFunction {
instance, instance,
func_type: HidFunctionType::MouseRelative, func_type: HidFunctionType::MouseRelative,
name: format!("hid.usb{}", instance), name: format!("hid.usb{}", instance),
keyboard_leds: false,
} }
} }
@@ -125,6 +136,7 @@ impl HidFunction {
instance, instance,
func_type: HidFunctionType::MouseAbsolute, func_type: HidFunctionType::MouseAbsolute,
name: format!("hid.usb{}", instance), name: format!("hid.usb{}", instance),
keyboard_leds: false,
} }
} }
@@ -134,6 +146,7 @@ impl HidFunction {
instance, instance,
func_type: HidFunctionType::ConsumerControl, func_type: HidFunctionType::ConsumerControl,
name: format!("hid.usb{}", instance), name: format!("hid.usb{}", instance),
keyboard_leds: false,
} }
} }
@@ -181,11 +194,14 @@ impl GadgetFunction for HidFunction {
)?; )?;
write_file( write_file(
&func_path.join("report_length"), &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 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!( debug!(
"Created HID function: {} at {}", "Created HID function: {} at {}",
@@ -232,14 +248,15 @@ mod tests {
assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1); assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1);
assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1); assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1);
assert_eq!(HidFunctionType::Keyboard.report_length(), 8); assert_eq!(HidFunctionType::Keyboard.report_length(false), 8);
assert_eq!(HidFunctionType::MouseRelative.report_length(), 4); assert_eq!(HidFunctionType::Keyboard.report_length(true), 8);
assert_eq!(HidFunctionType::MouseAbsolute.report_length(), 6); assert_eq!(HidFunctionType::MouseRelative.report_length(false), 4);
assert_eq!(HidFunctionType::MouseAbsolute.report_length(false), 6);
} }
#[test] #[test]
fn test_hid_function_names() { 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.name(), "hid.usb0");
assert_eq!(kb.device_path(), PathBuf::from("/dev/hidg0")); assert_eq!(kb.device_path(), PathBuf::from("/dev/hidg0"));

View File

@@ -19,7 +19,7 @@ use crate::error::{AppError, Result};
const REBIND_DELAY_MS: u64 = 300; const REBIND_DELAY_MS: u64 = 300;
/// USB Gadget device descriptor configuration /// USB Gadget device descriptor configuration
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct GadgetDescriptor { pub struct GadgetDescriptor {
pub vendor_id: u16, pub vendor_id: u16,
pub product_id: u16, pub product_id: u16,
@@ -131,8 +131,8 @@ impl OtgGadgetManager {
/// Add keyboard function /// Add keyboard function
/// Returns the expected device path (e.g., /dev/hidg0) /// Returns the expected device path (e.g., /dev/hidg0)
pub fn add_keyboard(&mut self) -> Result<PathBuf> { pub fn add_keyboard(&mut self, keyboard_leds: bool) -> Result<PathBuf> {
let func = HidFunction::keyboard(self.hid_instance); let func = HidFunction::keyboard(self.hid_instance, keyboard_leds);
let device_path = func.device_path(); let device_path = func.device_path();
self.add_function(Box::new(func))?; self.add_function(Box::new(func))?;
self.hid_instance += 1; self.hid_instance += 1;
@@ -245,12 +245,8 @@ impl OtgGadgetManager {
Ok(()) Ok(())
} }
/// Bind gadget to UDC /// Bind gadget to a specific UDC
pub fn bind(&mut self) -> Result<()> { pub fn bind(&mut self, udc: &str) -> Result<()> {
let udc = Self::find_udc().ok_or_else(|| {
AppError::Internal("No USB Device Controller (UDC) found".to_string())
})?;
// Recreate config symlinks before binding to avoid kernel gadget issues after rebind // Recreate config symlinks before binding to avoid kernel gadget issues after rebind
if let Err(e) = self.recreate_config_links() { if let Err(e) = self.recreate_config_links() {
warn!("Failed to recreate gadget config links before bind: {}", e); warn!("Failed to recreate gadget config links before bind: {}", e);
@@ -258,7 +254,7 @@ impl OtgGadgetManager {
info!("Binding gadget to UDC: {}", udc); info!("Binding gadget to UDC: {}", udc);
write_file(&self.gadget_path.join("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)); std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS));
Ok(()) Ok(())
@@ -504,7 +500,7 @@ mod tests {
let mut manager = OtgGadgetManager::with_config("test", 8); let mut manager = OtgGadgetManager::with_config("test", 8);
// Keyboard uses 1 endpoint // Keyboard uses 1 endpoint
let _ = manager.add_keyboard(); let _ = manager.add_keyboard(false);
assert_eq!(manager.endpoint_allocator.used(), 1); assert_eq!(manager.endpoint_allocator.used(), 1);
// Mouse uses 1 endpoint each // Mouse uses 1 endpoint each

View File

@@ -32,4 +32,4 @@ pub use hid::{HidFunction, HidFunctionType};
pub use manager::{wait_for_hid_devices, OtgGadgetManager}; pub use manager::{wait_for_hid_devices, OtgGadgetManager};
pub use msd::{MsdFunction, MsdLunConfig}; pub use msd::{MsdFunction, MsdLunConfig};
pub use report_desc::{KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE}; pub use report_desc::{KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
pub use service::{HidDevicePaths, OtgService, OtgServiceState}; pub use service::{HidDevicePaths, OtgDesiredState, OtgService, OtgServiceState};

View File

@@ -1,6 +1,6 @@
//! HID Report Descriptors //! 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): /// Report format (8 bytes input):
/// [0] Modifier keys (8 bits) /// [0] Modifier keys (8 bits)
/// [1] Reserved /// [1] Reserved
@@ -34,6 +34,53 @@ pub const KEYBOARD: &[u8] = &[
0xC0, // End Collection 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) /// Relative Mouse HID Report Descriptor (4 bytes report)
/// Report format: /// Report format:
/// [0] Buttons (5 bits) + padding (3 bits) /// [0] Buttons (5 bits) + padding (3 bits)
@@ -155,6 +202,7 @@ mod tests {
#[test] #[test]
fn test_report_descriptor_sizes() { fn test_report_descriptor_sizes() {
assert!(!KEYBOARD.is_empty()); assert!(!KEYBOARD.is_empty());
assert!(!KEYBOARD_WITH_LED.is_empty());
assert!(!MOUSE_RELATIVE.is_empty()); assert!(!MOUSE_RELATIVE.is_empty());
assert!(!MOUSE_ABSOLUTE.is_empty()); assert!(!MOUSE_ABSOLUTE.is_empty());
assert!(!CONSUMER_CONTROL.is_empty()); assert!(!CONSUMER_CONTROL.is_empty());

View File

@@ -1,39 +1,18 @@
//! OTG Service - unified gadget lifecycle management //! OTG Service - unified gadget lifecycle management
//! //!
//! This module provides centralized management for USB OTG gadget functions. //! This module provides centralized management for USB OTG gadget functions.
//! It solves the ownership problem where both HID and MSD need access to the //! It is the single owner of the USB gadget desired state and reconciles
//! same USB gadget but should be independently configurable. //! ConfigFS to match that state.
//!
//! Architecture:
//! ```text
//! ┌─────────────────────────┐
//! │ OtgService │
//! │ ┌───────────────────┐ │
//! │ │ OtgGadgetManager │ │
//! │ └───────────────────┘ │
//! │ ↓ ↓ │
//! │ ┌─────┐ ┌─────┐ │
//! │ │ HID │ │ MSD │ │
//! │ └─────┘ └─────┘ │
//! └─────────────────────────┘
//! ↑ ↑
//! HidController MsdController
//! ```
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU8, Ordering};
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager}; use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
use super::msd::MsdFunction; use super::msd::MsdFunction;
use crate::config::{OtgDescriptorConfig, OtgHidFunctions}; use crate::config::{HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
/// Bitflags for requested functions (lock-free)
const FLAG_HID: u8 = 0b01;
const FLAG_MSD: u8 = 0b10;
/// HID device paths /// HID device paths
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct HidDevicePaths { pub struct HidDevicePaths {
@@ -41,6 +20,8 @@ pub struct HidDevicePaths {
pub mouse_relative: Option<PathBuf>, pub mouse_relative: Option<PathBuf>,
pub mouse_absolute: Option<PathBuf>, pub mouse_absolute: Option<PathBuf>,
pub consumer: Option<PathBuf>, pub consumer: Option<PathBuf>,
pub udc: Option<String>,
pub keyboard_leds_enabled: bool,
} }
impl HidDevicePaths { 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<String>,
pub descriptor: GadgetDescriptor,
pub hid_functions: Option<OtgHidFunctions>,
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<Self> {
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 /// OTG Service state
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct OtgServiceState { pub struct OtgServiceState {
@@ -71,19 +105,23 @@ pub struct OtgServiceState {
pub hid_enabled: bool, pub hid_enabled: bool,
/// Whether MSD function is enabled /// Whether MSD function is enabled
pub msd_enabled: bool, pub msd_enabled: bool,
/// Bound UDC name
pub configured_udc: Option<String>,
/// HID device paths (set after gadget setup) /// HID device paths (set after gadget setup)
pub hid_paths: Option<HidDevicePaths>, pub hid_paths: Option<HidDevicePaths>,
/// HID function selection (set after gadget setup) /// HID function selection (set after gadget setup)
pub hid_functions: Option<OtgHidFunctions>, pub hid_functions: Option<OtgHidFunctions>,
/// 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<GadgetDescriptor>,
/// Error message if setup failed /// Error message if setup failed
pub error: Option<String>, pub error: Option<String>,
} }
/// OTG Service - unified gadget lifecycle management /// 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 { pub struct OtgService {
/// The underlying gadget manager /// The underlying gadget manager
manager: Mutex<Option<OtgGadgetManager>>, manager: Mutex<Option<OtgGadgetManager>>,
@@ -91,12 +129,8 @@ pub struct OtgService {
state: RwLock<OtgServiceState>, state: RwLock<OtgServiceState>,
/// MSD function handle (for runtime LUN configuration) /// MSD function handle (for runtime LUN configuration)
msd_function: RwLock<Option<MsdFunction>>, msd_function: RwLock<Option<MsdFunction>>,
/// Requested functions flags (atomic, lock-free read/write) /// Desired OTG state
requested_flags: AtomicU8, desired: RwLock<OtgDesiredState>,
/// Requested HID function set
hid_functions: RwLock<OtgHidFunctions>,
/// Current descriptor configuration
current_descriptor: RwLock<GadgetDescriptor>,
} }
impl OtgService { impl OtgService {
@@ -106,41 +140,7 @@ impl OtgService {
manager: Mutex::new(None), manager: Mutex::new(None),
state: RwLock::new(OtgServiceState::default()), state: RwLock::new(OtgServiceState::default()),
msd_function: RwLock::new(None), msd_function: RwLock::new(None),
requested_flags: AtomicU8::new(0), desired: RwLock::new(OtgDesiredState::default()),
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);
} }
} }
@@ -180,220 +180,81 @@ impl OtgService {
self.state.read().await.hid_paths.clone() 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) /// Get MSD function handle (for LUN configuration)
pub async fn msd_function(&self) -> Option<MsdFunction> { pub async fn msd_function(&self) -> Option<MsdFunction> {
self.msd_function.read().await.clone() self.msd_function.read().await.clone()
} }
/// Enable HID functions /// Apply desired OTG state derived from the current application config.
/// pub async fn apply_config(&self, hid: &HidConfig, msd: &MsdConfig) -> Result<()> {
/// This will create the gadget if not already created, add HID functions, let desired = OtgDesiredState::from_config(hid, msd)?;
/// and bind the gadget to UDC. self.apply_desired_state(desired).await
pub async fn enable_hid(&self) -> Result<HidDevicePaths> { }
info!("Enabling HID functions via OtgService");
// Mark HID as requested (lock-free) /// Apply a fully materialized desired OTG state.
self.set_hid_requested(true); pub async fn apply_desired_state(&self, desired: OtgDesiredState) -> Result<()> {
// Check if already enabled and function set unchanged
let requested_functions = self.hid_functions.read().await.clone();
{ {
let state = self.state.read().await; let mut current = self.desired.write().await;
if state.hid_enabled && state.hid_functions.as_ref() == Some(&requested_functions) { *current = desired;
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.reconcile_gadget().await
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()))
} }
/// Disable HID functions async fn reconcile_gadget(&self) -> Result<()> {
/// let desired = self.desired.read().await.clone();
/// 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
{
let state = self.state.read().await;
if !state.hid_enabled {
info!("HID already disabled");
return Ok(());
}
}
// Recreate gadget without HID (or destroy if MSD also disabled)
self.recreate_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<MsdFunction> {
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()
};
info!( info!(
"Recreating gadget with: HID={}, MSD={}", "Reconciling OTG gadget: HID={}, MSD={}, UDC={:?}",
hid_requested, msd_requested desired.hid_enabled(),
desired.msd_enabled,
desired.udc
); );
// Check if gadget already matches requested state
{ {
let state = self.state.read().await; 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 if state.gadget_active
&& state.hid_enabled == hid_requested && state.hid_enabled == desired.hid_enabled()
&& state.msd_enabled == msd_requested && state.msd_enabled == desired.msd_enabled
&& functions_match && 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(()); return Ok(());
} }
} }
// Cleanup existing gadget
{ {
let mut manager = self.manager.lock().await; let mut manager = self.manager.lock().await;
if let Some(mut m) = manager.take() { 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() { if let Err(e) = m.cleanup() {
warn!("Error cleaning up existing gadget: {}", e); warn!("Error cleaning up existing gadget: {}", e);
} }
} }
} }
// Clear MSD function
*self.msd_function.write().await = None; *self.msd_function.write().await = None;
// Update state to inactive
{ {
let mut state = self.state.write().await; let mut state = self.state.write().await;
state.gadget_active = false; state.gadget_active = false;
state.hid_enabled = false; state.hid_enabled = false;
state.msd_enabled = false; state.msd_enabled = false;
state.configured_udc = None;
state.hid_paths = None; state.hid_paths = None;
state.hid_functions = None; state.hid_functions = None;
state.keyboard_leds_enabled = false;
state.max_endpoints = super::endpoint::DEFAULT_MAX_ENDPOINTS;
state.descriptor = None;
state.error = None; state.error = None;
} }
// If nothing requested, we're done if !desired.hid_enabled() && !desired.msd_enabled {
if !hid_requested && !msd_requested { info!("OTG desired state is empty, gadget removed");
info!("No functions requested, gadget destroyed");
return Ok(()); return Ok(());
} }
@@ -401,41 +262,37 @@ impl OtgService {
warn!("Failed to ensure libcomposite is available: {}", e); warn!("Failed to ensure libcomposite is available: {}", e);
} }
// Check if OTG is available if !OtgGadgetManager::is_available() {
if !Self::is_available() { let error = "OTG not available: ConfigFS not mounted".to_string();
let error = "OTG not available: ConfigFS not mounted or no UDC found".to_string(); self.state.write().await.error = Some(error.clone());
let mut state = self.state.write().await;
state.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
// Create new gadget manager with current descriptor let udc = desired.udc.clone().ok_or_else(|| {
let descriptor = self.current_descriptor.read().await.clone(); let error = "OTG not available: no UDC found".to_string();
AppError::Internal(error)
})?;
let mut manager = OtgGadgetManager::with_descriptor( let mut manager = OtgGadgetManager::with_descriptor(
super::configfs::DEFAULT_GADGET_NAME, super::configfs::DEFAULT_GADGET_NAME,
super::endpoint::DEFAULT_MAX_ENDPOINTS, desired.max_endpoints,
descriptor, desired.descriptor.clone(),
); );
let mut hid_paths = None; let mut hid_paths = None;
if let Some(hid_functions) = desired.hid_functions.clone() {
// Add HID functions if requested let mut paths = HidDevicePaths {
if hid_requested { udc: Some(udc.clone()),
if hid_functions.is_empty() { keyboard_leds_enabled: desired.keyboard_leds,
let error = "HID functions set is empty".to_string(); ..Default::default()
let mut state = self.state.write().await; };
state.error = Some(error.clone());
return Err(AppError::BadRequest(error));
}
let mut paths = HidDevicePaths::default();
if hid_functions.keyboard { if hid_functions.keyboard {
match manager.add_keyboard() { match manager.add_keyboard(desired.keyboard_leds) {
Ok(kb) => paths.keyboard = Some(kb), Ok(kb) => paths.keyboard = Some(kb),
Err(e) => { Err(e) => {
let error = format!("Failed to add keyboard HID function: {}", e); let error = format!("Failed to add keyboard HID function: {}", e);
let mut state = self.state.write().await; self.state.write().await.error = Some(error.clone());
state.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
} }
@@ -446,8 +303,7 @@ impl OtgService {
Ok(rel) => paths.mouse_relative = Some(rel), Ok(rel) => paths.mouse_relative = Some(rel),
Err(e) => { Err(e) => {
let error = format!("Failed to add relative mouse HID function: {}", e); let error = format!("Failed to add relative mouse HID function: {}", e);
let mut state = self.state.write().await; self.state.write().await.error = Some(error.clone());
state.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
} }
@@ -458,8 +314,7 @@ impl OtgService {
Ok(abs) => paths.mouse_absolute = Some(abs), Ok(abs) => paths.mouse_absolute = Some(abs),
Err(e) => { Err(e) => {
let error = format!("Failed to add absolute mouse HID function: {}", e); let error = format!("Failed to add absolute mouse HID function: {}", e);
let mut state = self.state.write().await; self.state.write().await.error = Some(error.clone());
state.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
} }
@@ -470,8 +325,7 @@ impl OtgService {
Ok(consumer) => paths.consumer = Some(consumer), Ok(consumer) => paths.consumer = Some(consumer),
Err(e) => { Err(e) => {
let error = format!("Failed to add consumer HID function: {}", e); let error = format!("Failed to add consumer HID function: {}", e);
let mut state = self.state.write().await; self.state.write().await.error = Some(error.clone());
state.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
} }
@@ -481,8 +335,7 @@ impl OtgService {
debug!("HID functions added to gadget"); debug!("HID functions added to gadget");
} }
// Add MSD function if requested let msd_func = if desired.msd_enabled {
let msd_func = if msd_requested {
match manager.add_msd() { match manager.add_msd() {
Ok(func) => { Ok(func) => {
debug!("MSD function added to gadget"); debug!("MSD function added to gadget");
@@ -490,8 +343,7 @@ impl OtgService {
} }
Err(e) => { Err(e) => {
let error = format!("Failed to add MSD function: {}", e); let error = format!("Failed to add MSD function: {}", e);
let mut state = self.state.write().await; self.state.write().await.error = Some(error.clone());
state.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
} }
@@ -499,25 +351,19 @@ impl OtgService {
None None
}; };
// Setup gadget
if let Err(e) = manager.setup() { if let Err(e) = manager.setup() {
let error = format!("Failed to setup gadget: {}", e); let error = format!("Failed to setup gadget: {}", e);
let mut state = self.state.write().await; self.state.write().await.error = Some(error.clone());
state.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
// Bind to UDC if let Err(e) = manager.bind(&udc) {
if let Err(e) = manager.bind() { let error = format!("Failed to bind gadget to UDC {}: {}", udc, e);
let error = format!("Failed to bind gadget to UDC: {}", e); self.state.write().await.error = Some(error.clone());
let mut state = self.state.write().await;
state.error = Some(error.clone());
// Cleanup on failure
let _ = manager.cleanup(); let _ = manager.cleanup();
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
// Wait for HID devices to appear
if let Some(ref paths) = hid_paths { if let Some(ref paths) = hid_paths {
let device_paths = paths.existing_paths(); let device_paths = paths.existing_paths();
if !device_paths.is_empty() && !wait_for_hid_devices(&device_paths, 2000).await { 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.manager.lock().await = Some(manager);
}
{
*self.msd_function.write().await = msd_func; *self.msd_function.write().await = msd_func;
}
{ {
let mut state = self.state.write().await; let mut state = self.state.write().await;
state.gadget_active = true; state.gadget_active = true;
state.hid_enabled = hid_requested; state.hid_enabled = desired.hid_enabled();
state.msd_enabled = msd_requested; state.msd_enabled = desired.msd_enabled;
state.configured_udc = Some(udc);
state.hid_paths = hid_paths; state.hid_paths = hid_paths;
state.hid_functions = if hid_requested { state.hid_functions = desired.hid_functions;
Some(hid_functions) state.keyboard_leds_enabled = desired.keyboard_leds;
} else { state.max_endpoints = desired.max_endpoints;
None state.descriptor = Some(desired.descriptor);
};
state.error = None; state.error = None;
} }
info!("Gadget created successfully"); info!("OTG gadget reconciled successfully");
Ok(()) 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 /// Shutdown the OTG service and cleanup all resources
pub async fn shutdown(&self) -> Result<()> { pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down OTG service"); 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; let mut manager = self.manager.lock().await;
if let Some(mut m) = manager.take() { if let Some(mut m) = manager.take() {
if let Err(e) = m.cleanup() { if let Err(e) = m.cleanup() {
@@ -629,7 +408,6 @@ impl OtgService {
} }
} }
// Clear state
*self.msd_function.write().await = None; *self.msd_function.write().await = None;
{ {
let mut state = self.state.write().await; let mut state = self.state.write().await;
@@ -649,11 +427,26 @@ impl Default for OtgService {
impl Drop for OtgService { impl Drop for OtgService {
fn drop(&mut self) { fn drop(&mut self) {
// Gadget cleanup is handled by OtgGadgetManager's Drop
debug!("OtgService dropping"); 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -661,8 +454,7 @@ mod tests {
#[test] #[test]
fn test_service_creation() { fn test_service_creation() {
let _service = OtgService::new(); let _service = OtgService::new();
// Just test that creation doesn't panic let _ = OtgService::is_available();
let _ = OtgService::is_available(); // Depends on environment
} }
#[tokio::test] #[tokio::test]

View File

@@ -198,6 +198,8 @@ impl AppState {
initialized: state.initialized, initialized: state.initialized,
online: state.online, online: state.online,
supports_absolute_mouse: state.supports_absolute_mouse, supports_absolute_mouse: state.supports_absolute_mouse,
keyboard_leds_enabled: state.keyboard_leds_enabled,
led_state: state.led_state,
device: state.device, device: state.device,
error: state.error, error: state.error,
error_code: state.error_code, error_code: state.error_code,

View File

@@ -12,6 +12,26 @@ use crate::video::codec_constraints::{
enforce_constraints_with_stream_manager, StreamCodecConstraints, 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<AppState>) -> 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 配置变更 /// 应用 Video 配置变更
pub async fn apply_video_config( pub async fn apply_video_config(
state: &Arc<AppState>, state: &Arc<AppState>,
@@ -125,56 +145,26 @@ pub async fn apply_hid_config(
old_config: &HidConfig, old_config: &HidConfig,
new_config: &HidConfig, new_config: &HidConfig,
) -> Result<()> { ) -> 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 descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
let old_hid_functions = old_config.effective_otg_functions(); let old_hid_functions = old_config.constrained_otg_functions();
let mut new_hid_functions = new_config.effective_otg_functions(); let new_hid_functions = new_config.constrained_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 hid_functions_changed = old_hid_functions != new_hid_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 if old_config.backend == new_config.backend
&& old_config.ch9329_port == new_config.ch9329_port && old_config.ch9329_port == new_config.ch9329_port
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate && old_config.ch9329_baudrate == new_config.ch9329_baudrate
&& old_config.otg_udc == new_config.otg_udc && old_config.otg_udc == new_config.otg_udc
&& !descriptor_changed && !descriptor_changed
&& !hid_functions_changed && !hid_functions_changed
&& !keyboard_leds_changed
&& !endpoint_budget_changed
{ {
tracing::info!("HID config unchanged, skipping reload"); tracing::info!("HID config unchanged, skipping reload");
return Ok(()); return Ok(());
@@ -182,30 +172,27 @@ pub async fn apply_hid_config(
tracing::info!("Applying HID config changes..."); tracing::info!("Applying HID config changes...");
if new_config.backend == HidBackend::Otg let new_hid_backend = hid_backend_type(new_config);
&& (hid_functions_changed || old_config.backend != HidBackend::Otg) let transitioning_away_from_otg =
{ old_config.backend == HidBackend::Otg && new_config.backend != HidBackend::Otg;
if transitioning_away_from_otg {
state state
.otg_service .hid
.update_hid_functions(new_hid_functions.clone()) .reload(new_hid_backend.clone())
.await .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 { reconcile_otg_from_store(state).await?;
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,
};
if !transitioning_away_from_otg {
state state
.hid .hid
.reload(new_hid_backend) .reload(new_hid_backend)
.await .await
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?; .map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
}
tracing::info!( tracing::info!(
"HID backend reloaded successfully: {:?}", "HID backend reloaded successfully: {:?}",
@@ -221,6 +208,12 @@ pub async fn apply_msd_config(
old_config: &MsdConfig, old_config: &MsdConfig,
new_config: &MsdConfig, new_config: &MsdConfig,
) -> Result<()> { ) -> Result<()> {
state
.config
.get()
.hid
.validate_otg_endpoint_budget(new_config.enabled)?;
tracing::info!("MSD config sent, checking if reload needed..."); tracing::info!("MSD config sent, checking if reload needed...");
tracing::debug!("Old MSD config: {:?}", old_config); tracing::debug!("Old MSD config: {:?}", old_config);
tracing::debug!("New MSD config: {:?}", new_config); tracing::debug!("New MSD config: {:?}", new_config);
@@ -260,6 +253,8 @@ pub async fn apply_msd_config(
if new_msd_enabled { if new_msd_enabled {
tracing::info!("(Re)initializing MSD..."); tracing::info!("(Re)initializing MSD...");
reconcile_otg_from_store(state).await?;
// Shutdown existing controller if present // Shutdown existing controller if present
let mut msd_guard = state.msd.write().await; let mut msd_guard = state.msd.write().await;
if let Some(msd) = msd_guard.as_mut() { if let Some(msd) = msd_guard.as_mut() {
@@ -295,6 +290,17 @@ pub async fn apply_msd_config(
} }
*msd_guard = None; *msd_guard = None;
tracing::info!("MSD shutdown complete"); 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(()) Ok(())

View File

@@ -307,7 +307,9 @@ pub struct HidConfigUpdate {
pub otg_udc: Option<String>, pub otg_udc: Option<String>,
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>, pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
pub otg_profile: Option<OtgHidProfile>, pub otg_profile: Option<OtgHidProfile>,
pub otg_endpoint_budget: Option<OtgEndpointBudget>,
pub otg_functions: Option<OtgHidFunctionsUpdate>, pub otg_functions: Option<OtgHidFunctionsUpdate>,
pub otg_keyboard_leds: Option<bool>,
pub mouse_absolute: Option<bool>, pub mouse_absolute: Option<bool>,
} }
@@ -346,9 +348,15 @@ impl HidConfigUpdate {
if let Some(profile) = self.otg_profile.clone() { if let Some(profile) = self.otg_profile.clone() {
config.otg_profile = profile; 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 { if let Some(ref functions) = self.otg_functions {
functions.apply_to(&mut config.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 { if let Some(absolute) = self.mouse_absolute {
config.mouse_absolute = absolute; config.mouse_absolute = absolute;
} }

View File

@@ -598,38 +598,14 @@ pub struct SetupRequest {
pub hid_ch9329_baudrate: Option<u32>, pub hid_ch9329_baudrate: Option<u32>,
pub hid_otg_udc: Option<String>, pub hid_otg_udc: Option<String>,
pub hid_otg_profile: Option<String>, pub hid_otg_profile: Option<String>,
pub hid_otg_endpoint_budget: Option<crate::config::OtgEndpointBudget>,
pub hid_otg_keyboard_leds: Option<bool>,
pub msd_enabled: Option<bool>,
// Extension settings // Extension settings
pub ttyd_enabled: Option<bool>, pub ttyd_enabled: Option<bool>,
pub rustdesk_enabled: Option<bool>, pub rustdesk_enabled: Option<bool>,
} }
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( pub async fn setup_init(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<SetupRequest>, Json(req): Json<SetupRequest>,
@@ -703,31 +679,18 @@ pub async fn setup_init(
config.hid.otg_udc = Some(udc); config.hid.otg_udc = Some(udc);
} }
if let Some(profile) = req.hid_otg_profile.clone() { if let Some(profile) = req.hid_otg_profile.clone() {
config.hid.otg_profile = match profile.as_str() { if let Some(parsed) = crate::config::OtgHidProfile::from_legacy_str(&profile) {
"full" => crate::config::OtgHidProfile::Full, config.hid.otg_profile = parsed;
"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(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 // Extension settings
@@ -737,29 +700,18 @@ pub async fn setup_init(
if let Some(enabled) = req.rustdesk_enabled { if let Some(enabled) = req.rustdesk_enabled {
config.rustdesk.enabled = enabled; config.rustdesk.enabled = enabled;
} }
normalize_otg_profile_for_low_endpoint(config);
}) })
.await?; .await?;
// Get updated config for HID reload // Get updated config for HID reload
let new_config = state.config.get(); let new_config = state.config.get();
if matches!(new_config.hid.backend, crate::config::HidBackend::Otg) { if let Err(e) = state
let mut hid_functions = new_config.hid.effective_otg_functions(); .otg_service
if let Some(udc) = crate::otg::configfs::resolve_udc_name(new_config.hid.otg_udc.as_deref()) .apply_config(&new_config.hid, &new_config.msd)
.await
{ {
if crate::otg::configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer { tracing::warn!("Failed to apply OTG config during setup: {}", e);
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);
}
} }
tracing::info!( tracing::info!(
@@ -881,8 +833,10 @@ pub async fn update_config(
let new_config: AppConfig = serde_json::from_value(merged) let new_config: AppConfig = serde_json::from_value(merged)
.map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?; .map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?;
let mut new_config = new_config; let new_config = new_config;
normalize_otg_profile_for_low_endpoint(&mut new_config); new_config
.hid
.validate_otg_endpoint_budget(new_config.msd.enabled)?;
// Apply the validated config // Apply the validated config
state.config.set(new_config.clone()).await?; state.config.set(new_config.clone()).await?;
@@ -910,232 +864,76 @@ pub async fn update_config(
// Get new config for device reloading // Get new config for device reloading
let new_config = state.config.get(); let new_config = state.config.get();
// Video config processing - always reload if section was sent
if has_video { if has_video {
tracing::info!("Video config sent, applying settings..."); if let Err(e) =
config::apply::apply_video_config(&state, &old_config.video, &new_config.video).await
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::<crate::video::format::PixelFormat>(
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
{ {
tracing::error!("Failed to apply video config: {}", e); tracing::error!("Failed to apply video config: {}", e);
// Rollback config on failure
state.config.set((*old_config).clone()).await?; state.config.set((*old_config).clone()).await?;
return Ok(Json(LoginResponse { return Ok(Json(LoginResponse {
success: false, success: false,
message: Some(format!("Video configuration invalid: {}", e)), message: Some(format!("Video configuration invalid: {}", e)),
})); }));
} }
tracing::info!("Video config applied successfully");
} }
// Stream config processing (encoder backend, bitrate, etc.)
if has_stream { if has_stream {
tracing::info!("Stream config sent, applying encoder settings..."); if let Err(e) =
config::apply::apply_stream_config(&state, &old_config.stream, &new_config.stream).await
// Update WebRTC streamer encoder backend {
let encoder_backend = new_config.stream.encoder.to_backend(); tracing::error!("Failed to apply stream config: {}", e);
tracing::info!( state.config.set((*old_config).clone()).await?;
"Updating encoder backend to: {:?} (from config: {:?})", return Ok(Json(LoginResponse {
encoder_backend, success: false,
new_config.stream.encoder message: Some(format!("Stream configuration invalid: {}", e)),
); }));
}
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
);
} }
// HID config processing - always reload if section was sent
if has_hid { if has_hid {
tracing::info!("HID config sent, reloading HID backend..."); if let Err(e) =
config::apply::apply_hid_config(&state, &old_config.hid, &new_config.hid).await
// 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 {
tracing::error!("HID reload failed: {}", e); tracing::error!("HID reload failed: {}", e);
// Rollback config on failure
state.config.set((*old_config).clone()).await?; state.config.set((*old_config).clone()).await?;
return Ok(Json(LoginResponse { return Ok(Json(LoginResponse {
success: false, success: false,
message: Some(format!("HID configuration invalid: {}", e)), 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 { if has_audio {
tracing::info!("Audio config sent, applying settings..."); if let Err(e) =
config::apply::apply_audio_config(&state, &old_config.audio, &new_config.audio).await
// 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
{ {
tracing::warn!("Failed to update WebRTC audio state: {}", e); tracing::warn!("Audio config update failed: {}", 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;
} }
} }
// MSD config processing - reload if enabled state or directory changed
if has_msd { if has_msd {
tracing::info!("MSD config sent, checking if reload needed..."); if let Err(e) =
tracing::debug!("Old MSD config: {:?}", old_config.msd); config::apply::apply_msd_config(&state, &old_config.msd, &new_config.msd).await
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);
}
// 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); tracing::error!("MSD initialization failed: {}", e);
// Rollback config on failure
state.config.set((*old_config).clone()).await?; state.config.set((*old_config).clone()).await?;
return Ok(Json(LoginResponse { return Ok(Json(LoginResponse {
success: false, success: false,
message: Some(format!("MSD initialization failed: {}", e)), 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; if has_atx {
tracing::info!("MSD shutdown complete"); 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 initialized: bool,
pub online: bool, pub online: bool,
pub supports_absolute_mouse: bool, pub supports_absolute_mouse: bool,
pub keyboard_leds_enabled: bool,
pub led_state: crate::hid::LedState,
pub screen_resolution: Option<(u32, u32)>, pub screen_resolution: Option<(u32, u32)>,
pub device: Option<String>, pub device: Option<String>,
pub error: Option<String>, pub error: Option<String>,
@@ -3018,6 +2818,8 @@ pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> {
initialized: hid.initialized, initialized: hid.initialized,
online: hid.online, online: hid.online,
supports_absolute_mouse: hid.supports_absolute_mouse, supports_absolute_mouse: hid.supports_absolute_mouse,
keyboard_leds_enabled: hid.keyboard_leds_enabled,
led_state: hid.led_state,
screen_resolution: hid.screen_resolution, screen_resolution: hid.screen_resolution,
device: hid.device, device: hid.device,
error: hid.error, error: hid.error,

View File

@@ -86,6 +86,9 @@ export const systemApi = {
hid_ch9329_baudrate?: number hid_ch9329_baudrate?: number
hid_otg_udc?: string hid_otg_udc?: string
hid_otg_profile?: string hid_otg_profile?: string
hid_otg_endpoint_budget?: string
hid_otg_keyboard_leds?: boolean
msd_enabled?: boolean
encoder_backend?: string encoder_backend?: string
audio_device?: string audio_device?: string
ttyd_enabled?: boolean ttyd_enabled?: boolean
@@ -330,6 +333,14 @@ export const hidApi = {
initialized: boolean initialized: boolean
online: boolean online: boolean
supports_absolute_mouse: 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 screen_resolution: [number, number] | null
device: string | null device: string | null
error: string | null error: string | null

View File

@@ -7,6 +7,9 @@ import { cn } from '@/lib/utils'
const props = defineProps<{ const props = defineProps<{
pressedKeys?: CanonicalKey[] pressedKeys?: CanonicalKey[]
capsLock?: boolean capsLock?: boolean
numLock?: boolean
scrollLock?: boolean
keyboardLedEnabled?: boolean
mousePosition?: { x: number; y: number } mousePosition?: { x: number; y: number }
debugMode?: boolean debugMode?: boolean
compact?: boolean compact?: boolean
@@ -42,12 +45,21 @@ const keysDisplay = computed(() => {
<!-- Compact mode for small screens --> <!-- Compact mode for small screens -->
<div v-if="compact" class="flex items-center justify-between text-xs px-2 py-0.5"> <div v-if="compact" class="flex items-center justify-between text-xs px-2 py-0.5">
<!-- LED indicator only in compact mode --> <!-- LED indicator only in compact mode -->
<div class="flex items-center gap-1"> <div v-if="keyboardLedEnabled" class="flex items-center gap-1">
<span <span
v-if="capsLock" v-if="capsLock"
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium" class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
>C</span> >C</span>
<span v-else class="text-muted-foreground/40 text-[10px]">-</span> <span v-else class="text-muted-foreground/40 text-[10px]">C</span>
<span
:class="numLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>N</span>
<span
:class="scrollLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>S</span>
</div>
<div v-else class="text-[10px] text-muted-foreground/60">
{{ t('infobar.keyboardLedUnavailable') }}
</div> </div>
<!-- Keys in compact mode --> <!-- Keys in compact mode -->
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[150px]"> <div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[150px]">
@@ -72,8 +84,9 @@ const keysDisplay = computed(() => {
</div> </div>
</div> </div>
<!-- Right side: Caps Lock LED state --> <!-- Right side: Keyboard LED states -->
<div class="flex items-center shrink-0"> <div class="flex items-center shrink-0">
<template v-if="keyboardLedEnabled">
<div <div
:class="cn( :class="cn(
'px-2 py-1 select-none transition-colors', 'px-2 py-1 select-none transition-colors',
@@ -83,6 +96,28 @@ const keysDisplay = computed(() => {
<span class="hidden sm:inline">{{ t('infobar.caps') }}</span> <span class="hidden sm:inline">{{ t('infobar.caps') }}</span>
<span class="sm:hidden">C</span> <span class="sm:hidden">C</span>
</div> </div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>
<span class="hidden sm:inline">{{ t('infobar.num') }}</span>
<span class="sm:hidden">N</span>
</div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>
<span class="hidden sm:inline">{{ t('infobar.scroll') }}</span>
<span class="sm:hidden">S</span>
</div>
</template>
<div v-else class="px-3 py-1 text-muted-foreground/60">
{{ t('infobar.keyboardLedUnavailable') }}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -61,7 +61,6 @@ export default {
password: 'Password', password: 'Password',
enterUsername: 'Enter username', enterUsername: 'Enter username',
enterPassword: 'Enter password', enterPassword: 'Enter password',
loginPrompt: 'Enter your credentials to login',
loginFailed: 'Login failed', loginFailed: 'Login failed',
invalidPassword: 'Invalid username or password', invalidPassword: 'Invalid username or password',
changePassword: 'Change Password', changePassword: 'Change Password',
@@ -169,6 +168,7 @@ export default {
caps: 'Caps', caps: 'Caps',
num: 'Num', num: 'Num',
scroll: 'Scroll', scroll: 'Scroll',
keyboardLedUnavailable: 'Keyboard LED status is disabled or unsupported',
}, },
paste: { paste: {
title: 'Paste Text', title: 'Paste Text',
@@ -270,7 +270,7 @@ export default {
otgAdvanced: 'Advanced: OTG Preset', otgAdvanced: 'Advanced: OTG Preset',
otgProfile: 'Initial HID Preset', otgProfile: 'Initial HID Preset',
otgProfileDesc: 'Choose the initial OTG HID preset. You can change this later in Settings.', 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.', 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.', videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
// Extensions // Extensions
@@ -651,28 +651,28 @@ export default {
hidBackend: 'HID Backend', hidBackend: 'HID Backend',
serialDevice: 'Serial Device', serialDevice: 'Serial Device',
baudRate: 'Baud Rate', baudRate: 'Baud Rate',
otgHidProfile: 'OTG HID Profile', otgHidProfile: 'OTG HID Functions',
otgHidProfileDesc: 'Select which HID functions are exposed to the host', otgHidProfileDesc: 'Select which HID functions are exposed to the host',
profile: 'Profile', otgEndpointBudget: 'Max Endpoints',
otgProfileFull: 'Keyboard + relative mouse + absolute mouse + multimedia + MSD', otgEndpointBudgetUnlimited: 'Unlimited',
otgProfileFullNoMsd: 'Keyboard + relative mouse + absolute mouse + multimedia (no MSD)', otgEndpointBudgetHint: 'This is a hardware limit. If the OTG selection exceeds the real hardware endpoint count, OTG will fail.',
otgProfileFullNoConsumer: 'Keyboard + relative mouse + absolute mouse + MSD (no multimedia)', otgEndpointUsage: 'Endpoint usage: {used} / {limit}',
otgProfileFullNoConsumerNoMsd: 'Keyboard + relative mouse + absolute mouse (no multimedia, no MSD)', otgEndpointUsageUnlimited: 'Endpoint usage: {used} / unlimited',
otgProfileLegacyKeyboard: 'Keyboard only', otgEndpointExceeded: 'The current OTG selection needs {used} endpoints, exceeding the limit {limit}.',
otgProfileLegacyMouseRelative: 'Relative mouse only',
otgProfileCustom: 'Custom',
otgFunctionKeyboard: 'Keyboard', otgFunctionKeyboard: 'Keyboard',
otgFunctionKeyboardDesc: 'Standard HID keyboard device', otgFunctionKeyboardDesc: 'Standard HID keyboard device',
otgKeyboardLeds: 'Keyboard LED Status',
otgKeyboardLedsDesc: 'Enable Caps/Num/Scroll LED feedback from the host',
otgFunctionMouseRelative: 'Relative Mouse', otgFunctionMouseRelative: 'Relative Mouse',
otgFunctionMouseRelativeDesc: 'Traditional mouse movement (HID boot mouse)', otgFunctionMouseRelativeDesc: 'Traditional mouse movement (HID boot mouse)',
otgFunctionMouseAbsolute: 'Absolute Mouse', otgFunctionMouseAbsolute: 'Absolute Mouse',
otgFunctionMouseAbsoluteDesc: 'Absolute positioning (touchscreen-like)', otgFunctionMouseAbsoluteDesc: 'Absolute positioning (touchscreen-like)',
otgFunctionConsumer: 'Consumer Control', otgFunctionConsumer: 'Consumer Control Keyboard',
otgFunctionConsumerDesc: 'Media keys like volume/play/pause', otgFunctionConsumerDesc: 'Consumer Control keys such as volume/play/pause',
otgFunctionMsd: 'Mass Storage (MSD)', otgFunctionMsd: 'Mass Storage (MSD)',
otgFunctionMsdDesc: 'Expose USB storage to the host', otgFunctionMsdDesc: 'Expose USB storage to the host',
otgProfileWarning: 'Changing HID functions will reconnect the USB device', 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', otgFunctionMinWarning: 'Enable at least one HID function before saving',
// OTG Descriptor // OTG Descriptor
otgDescriptor: 'USB Device Descriptor', otgDescriptor: 'USB Device Descriptor',
@@ -799,7 +799,7 @@ export default {
osWindows: 'Windows', osWindows: 'Windows',
osMac: 'Mac', osMac: 'Mac',
osAndroid: 'Android', osAndroid: 'Android',
mediaKeys: 'Media Keys', mediaKeys: 'Consumer Control Keyboard',
}, },
config: { config: {
applied: 'Configuration applied', applied: 'Configuration applied',

View File

@@ -61,7 +61,6 @@ export default {
password: '密码', password: '密码',
enterUsername: '请输入用户名', enterUsername: '请输入用户名',
enterPassword: '请输入密码', enterPassword: '请输入密码',
loginPrompt: '请输入您的账号和密码',
loginFailed: '登录失败', loginFailed: '登录失败',
invalidPassword: '用户名或密码错误', invalidPassword: '用户名或密码错误',
changePassword: '修改密码', changePassword: '修改密码',
@@ -169,6 +168,7 @@ export default {
caps: 'Caps', caps: 'Caps',
num: 'Num', num: 'Num',
scroll: 'Scroll', scroll: 'Scroll',
keyboardLedUnavailable: '键盘状态灯功能未开启或不支持',
}, },
paste: { paste: {
title: '粘贴文本', title: '粘贴文本',
@@ -270,7 +270,7 @@ export default {
otgAdvanced: '高级OTG 预设', otgAdvanced: '高级OTG 预设',
otgProfile: '初始 HID 预设', otgProfile: '初始 HID 预设',
otgProfileDesc: '选择 OTG HID 的初始预设,后续可在设置中修改。', otgProfileDesc: '选择 OTG HID 的初始预设,后续可在设置中修改。',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键。', otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键。',
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。', videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
videoFormatHelp: 'MJPEG 格式兼容性最好H.264/H.265 带宽占用更低但需要编码支持。', videoFormatHelp: 'MJPEG 格式兼容性最好H.264/H.265 带宽占用更低但需要编码支持。',
// Extensions // Extensions
@@ -651,28 +651,28 @@ export default {
hidBackend: 'HID 后端', hidBackend: 'HID 后端',
serialDevice: '串口设备', serialDevice: '串口设备',
baudRate: '波特率', baudRate: '波特率',
otgHidProfile: 'OTG HID 组合', otgHidProfile: 'OTG HID 功能',
otgHidProfileDesc: '选择对目标主机暴露的 HID 功能', otgHidProfileDesc: '选择对目标主机暴露的 HID 功能',
profile: '组合', otgEndpointBudget: '最大端点数量',
otgProfileFull: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体 + 虚拟媒体', otgEndpointBudgetUnlimited: '无限制',
otgProfileFullNoMsd: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体(不含虚拟媒体)', otgEndpointBudgetHint: '此为硬件限制。若超出硬件端点数量OTG 功能将无法使用。',
otgProfileFullNoConsumer: '键盘 + 相对鼠标 + 绝对鼠标 + 虚拟媒体(不含多媒体)', otgEndpointUsage: '当前端点占用:{used} / {limit}',
otgProfileFullNoConsumerNoMsd: '键盘 + 相对鼠标 + 绝对鼠标(不含多媒体与虚拟媒体)', otgEndpointUsageUnlimited: '当前端点占用:{used} / 不限',
otgProfileLegacyKeyboard: '仅键盘', otgEndpointExceeded: '当前 OTG 组合需要 {used} 个端点,已超出上限 {limit}。',
otgProfileLegacyMouseRelative: '仅相对鼠标',
otgProfileCustom: '自定义',
otgFunctionKeyboard: '键盘', otgFunctionKeyboard: '键盘',
otgFunctionKeyboardDesc: '标准 HID 键盘设备', otgFunctionKeyboardDesc: '标准 HID 键盘设备',
otgKeyboardLeds: '键盘状态灯',
otgKeyboardLedsDesc: '启用 Caps/Num/Scroll 状态灯回读',
otgFunctionMouseRelative: '相对鼠标', otgFunctionMouseRelative: '相对鼠标',
otgFunctionMouseRelativeDesc: '传统鼠标移动HID 启动鼠标)', otgFunctionMouseRelativeDesc: '传统鼠标移动HID 启动鼠标)',
otgFunctionMouseAbsolute: '绝对鼠标', otgFunctionMouseAbsolute: '绝对鼠标',
otgFunctionMouseAbsoluteDesc: '绝对定位(类似触控)', otgFunctionMouseAbsoluteDesc: '绝对定位(类似触控)',
otgFunctionConsumer: '多媒体控制', otgFunctionConsumer: '多媒体键盘',
otgFunctionConsumerDesc: '音量/播放/暂停等按键', otgFunctionConsumerDesc: '音量/播放/暂停等多媒体按键',
otgFunctionMsd: '虚拟媒体MSD', otgFunctionMsd: '虚拟媒体MSD',
otgFunctionMsdDesc: '向目标主机暴露 USB 存储', otgFunctionMsdDesc: '向目标主机暴露 USB 存储',
otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接', otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键。', otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键。',
otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存', otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存',
// OTG Descriptor // OTG Descriptor
otgDescriptor: 'USB 设备描述符', otgDescriptor: 'USB 设备描述符',
@@ -799,7 +799,7 @@ export default {
osWindows: 'Windows', osWindows: 'Windows',
osMac: 'Mac', osMac: 'Mac',
osAndroid: 'Android', osAndroid: 'Android',
mediaKeys: '多媒体键', mediaKeys: '多媒体键',
}, },
config: { config: {
applied: '配置已应用', applied: '配置已应用',

View File

@@ -85,6 +85,9 @@ export const useAuthStore = defineStore('auth', () => {
hid_ch9329_baudrate?: number hid_ch9329_baudrate?: number
hid_otg_udc?: string hid_otg_udc?: string
hid_otg_profile?: string hid_otg_profile?: string
hid_otg_endpoint_budget?: string
hid_otg_keyboard_leds?: boolean
msd_enabled?: boolean
encoder_backend?: string encoder_backend?: string
audio_device?: string audio_device?: string
ttyd_enabled?: boolean ttyd_enabled?: boolean

View File

@@ -34,6 +34,12 @@ interface HidState {
initialized: boolean initialized: boolean
online: boolean online: boolean
supportsAbsoluteMouse: boolean supportsAbsoluteMouse: boolean
keyboardLedsEnabled: boolean
ledState: {
numLock: boolean
capsLock: boolean
scrollLock: boolean
}
device: string | null device: string | null
error: string | null error: string | null
errorCode: string | null errorCode: string | null
@@ -89,6 +95,14 @@ export interface HidDeviceInfo {
initialized: boolean initialized: boolean
online: boolean online: boolean
supports_absolute_mouse: 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 device: string | null
error: string | null error: string | null
error_code?: string | null error_code?: string | null
@@ -194,6 +208,12 @@ export const useSystemStore = defineStore('system', () => {
initialized: state.initialized, initialized: state.initialized,
online: state.online, online: state.online,
supportsAbsoluteMouse: state.supports_absolute_mouse, 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, device: state.device ?? null,
error: state.error ?? null, error: state.error ?? null,
errorCode: state.error_code ?? null, errorCode: state.error_code ?? null,
@@ -298,6 +318,12 @@ export const useSystemStore = defineStore('system', () => {
initialized: data.hid.initialized, initialized: data.hid.initialized,
online: data.hid.online, online: data.hid.online,
supportsAbsoluteMouse: data.hid.supports_absolute_mouse, 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, device: data.hid.device,
error: data.hid.error, error: data.hid.error,
errorCode: data.hid.error_code ?? null, errorCode: data.hid.error_code ?? null,

View File

@@ -58,12 +58,8 @@ export interface OtgDescriptorConfig {
export enum OtgHidProfile { export enum OtgHidProfile {
/** Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) */ /** Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) */
Full = "full", Full = "full",
/** Full HID device set without MSD */
FullNoMsd = "full_no_msd",
/** Full HID device set without consumer control */ /** Full HID device set without consumer control */
FullNoConsumer = "full_no_consumer", FullNoConsumer = "full_no_consumer",
/** Full HID device set without consumer control and MSD */
FullNoConsumerNoMsd = "full_no_consumer_no_msd",
/** Legacy profile: only keyboard */ /** Legacy profile: only keyboard */
LegacyKeyboard = "legacy_keyboard", LegacyKeyboard = "legacy_keyboard",
/** Legacy profile: only relative mouse */ /** Legacy profile: only relative mouse */
@@ -72,6 +68,18 @@ export enum OtgHidProfile {
Custom = "custom", 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) */ /** OTG HID function selection (used when profile is Custom) */
export interface OtgHidFunctions { export interface OtgHidFunctions {
keyboard: boolean; keyboard: boolean;
@@ -84,18 +92,18 @@ export interface OtgHidFunctions {
export interface HidConfig { export interface HidConfig {
/** HID backend type */ /** HID backend type */
backend: HidBackend; backend: HidBackend;
/** OTG keyboard device path */
otg_keyboard: string;
/** OTG mouse device path */
otg_mouse: string;
/** OTG UDC (USB Device Controller) name */ /** OTG UDC (USB Device Controller) name */
otg_udc?: string; otg_udc?: string;
/** OTG USB device descriptor configuration */ /** OTG USB device descriptor configuration */
otg_descriptor?: OtgDescriptorConfig; otg_descriptor?: OtgDescriptorConfig;
/** OTG HID function profile */ /** OTG HID function profile */
otg_profile?: OtgHidProfile; otg_profile?: OtgHidProfile;
/** OTG endpoint budget policy */
otg_endpoint_budget?: OtgEndpointBudget;
/** OTG HID function selection (used when profile is Custom) */ /** OTG HID function selection (used when profile is Custom) */
otg_functions?: OtgHidFunctions; otg_functions?: OtgHidFunctions;
/** Enable keyboard LED/status feedback for OTG keyboard */
otg_keyboard_leds?: boolean;
/** CH9329 serial port */ /** CH9329 serial port */
ch9329_port: string; ch9329_port: string;
/** CH9329 baud rate */ /** CH9329 baud rate */
@@ -580,7 +588,9 @@ export interface HidConfigUpdate {
otg_udc?: string; otg_udc?: string;
otg_descriptor?: OtgDescriptorConfigUpdate; otg_descriptor?: OtgDescriptorConfigUpdate;
otg_profile?: OtgHidProfile; otg_profile?: OtgHidProfile;
otg_endpoint_budget?: OtgEndpointBudget;
otg_functions?: OtgHidFunctionsUpdate; otg_functions?: OtgHidFunctionsUpdate;
otg_keyboard_leds?: boolean;
mouse_absolute?: boolean; mouse_absolute?: boolean;
} }

View File

@@ -119,9 +119,12 @@ const myClientId = generateUUID()
// HID state // HID state
const mouseMode = ref<'absolute' | 'relative'>('absolute') const mouseMode = ref<'absolute' | 'relative'>('absolute')
const pressedKeys = ref<CanonicalKey[]>([]) const pressedKeys = ref<CanonicalKey[]>([])
const keyboardLed = ref({ const keyboardLed = computed(() => ({
capsLock: false, 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 activeModifierMask = ref(0)
const mousePosition = ref({ x: 0, y: 0 }) const mousePosition = ref({ x: 0, y: 0 })
const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relative mode const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relative mode
@@ -346,6 +349,13 @@ const hidDetails = computed<StatusDetail[]>(() => {
{ label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.error ? 'error' : hid.initialized ? 'ok' : 'warning' }, { 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.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('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) { if (hid.errorCode) {
@@ -1618,8 +1628,6 @@ function handleKeyDown(e: KeyboardEvent) {
}) })
} }
keyboardLed.value.capsLock = e.getModifierState('CapsLock')
const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key) const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
if (canonicalKey === undefined) { if (canonicalKey === undefined) {
console.warn(`[HID] Unmapped key down: code=${e.code}, key=${e.key}`) 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)) { if (!pressedKeys.value.includes(key)) {
pressedKeys.value = [...pressedKeys.value, 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) { function handleVirtualKeyUp(key: CanonicalKey) {
@@ -2536,6 +2540,9 @@ onUnmounted(() => {
<InfoBar <InfoBar
:pressed-keys="pressedKeys" :pressed-keys="pressedKeys"
:caps-lock="keyboardLed.capsLock" :caps-lock="keyboardLed.capsLock"
:num-lock="keyboardLed.numLock"
:scroll-lock="keyboardLed.scrollLock"
:keyboard-led-enabled="keyboardLedEnabled"
:mouse-position="mousePosition" :mouse-position="mousePosition"
:debug-mode="false" :debug-mode="false"
/> />

View File

@@ -33,6 +33,7 @@ import type {
AtxDriverType, AtxDriverType,
ActiveLevel, ActiveLevel,
AtxDevices, AtxDevices,
OtgEndpointBudget,
OtgHidProfile, OtgHidProfile,
OtgHidFunctions, OtgHidFunctions,
} from '@/types/generated' } from '@/types/generated'
@@ -326,13 +327,15 @@ const config = ref({
hid_serial_device: '', hid_serial_device: '',
hid_serial_baudrate: 9600, hid_serial_baudrate: 9600,
hid_otg_udc: '', 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: { hid_otg_functions: {
keyboard: true, keyboard: true,
mouse_relative: true, mouse_relative: true,
mouse_absolute: true, mouse_absolute: true,
consumer: true, consumer: true,
} as OtgHidFunctions, } as OtgHidFunctions,
hid_otg_keyboard_leds: false,
msd_enabled: false, msd_enabled: false,
msd_dir: '', msd_dir: '',
encoder_backend: 'auto', encoder_backend: 'auto',
@@ -345,20 +348,6 @@ const config = ref({
// Tracks whether TURN password is configured on the server // Tracks whether TURN password is configured on the server
const hasTurnPassword = ref(false) 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 OtgSelfCheckLevel = 'info' | 'warn' | 'error'
type OtgCheckGroupStatus = 'ok' | 'warn' | 'error' | 'skipped' type OtgCheckGroupStatus = 'ok' | 'warn' | 'error' | 'skipped'
@@ -619,28 +608,80 @@ async function onRunVideoEncoderSelfCheckClick() {
await runVideoEncoderSelfCheck() await runVideoEncoderSelfCheck()
} }
function alignHidProfileForLowEndpoint() { function defaultOtgEndpointBudgetForUdc(udc?: string): OtgEndpointBudget {
if (hidProfileAligned.value) return return /musb/i.test(udc || '') ? 'five' as OtgEndpointBudget : 'six' as OtgEndpointBudget
if (!configLoaded.value || !devicesLoaded.value) return
if (config.value.hid_backend !== 'otg') {
hidProfileAligned.value = true
return
} }
if (!isLowEndpointUdc.value) {
hidProfileAligned.value = true function normalizeOtgEndpointBudget(budget: OtgEndpointBudget | undefined, udc?: string): OtgEndpointBudget {
return if (!budget || budget === 'auto') {
return defaultOtgEndpointBudgetForUdc(udc)
} }
if (config.value.hid_otg_profile === 'full') { return budget
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 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 (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(() => { const isHidFunctionSelectionValid = computed(() => {
if (config.value.hid_backend !== 'otg') return true if (config.value.hid_backend !== 'otg') return true
if (config.value.hid_otg_profile !== 'custom') return true
const f = config.value.hid_otg_functions const f = config.value.hid_otg_functions
return !!(f.keyboard || f.mouse_relative || f.mouse_absolute || f.consumer) return !!(f.keyboard || f.mouse_relative || f.mouse_absolute || f.consumer)
}) })
@@ -946,26 +987,9 @@ async function saveConfig() {
// HID config // HID config
if (activeSection.value === 'hid') { if (activeSection.value === 'hid') {
if (!isHidFunctionSelectionValid.value) { if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) {
return 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 = { const hidUpdate: any = {
backend: config.value.hid_backend as any, backend: config.value.hid_backend as any,
ch9329_port: config.value.hid_serial_device || undefined, ch9329_port: config.value.hid_serial_device || undefined,
@@ -980,16 +1004,15 @@ async function saveConfig() {
product: otgProduct.value || 'One-KVM USB Device', product: otgProduct.value || 'One-KVM USB Device',
serial_number: otgSerialNumber.value || undefined, 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_functions = { ...config.value.hid_otg_functions }
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
} }
savePromises.push(configStore.updateHid(hidUpdate)) savePromises.push(configStore.updateHid(hidUpdate))
if (config.value.msd_enabled !== desiredMsdEnabled) {
config.value.msd_enabled = desiredMsdEnabled
}
savePromises.push( savePromises.push(
configStore.updateMsd({ configStore.updateMsd({
enabled: desiredMsdEnabled, enabled: config.value.msd_enabled,
}) })
) )
} }
@@ -1034,13 +1057,15 @@ async function loadConfig() {
hid_serial_device: hid.ch9329_port || '', hid_serial_device: hid.ch9329_port || '',
hid_serial_baudrate: hid.ch9329_baudrate || 9600, hid_serial_baudrate: hid.ch9329_baudrate || 9600,
hid_otg_udc: hid.otg_udc || '', 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: { hid_otg_functions: {
keyboard: hid.otg_functions?.keyboard ?? true, keyboard: hid.otg_functions?.keyboard ?? true,
mouse_relative: hid.otg_functions?.mouse_relative ?? true, mouse_relative: hid.otg_functions?.mouse_relative ?? true,
mouse_absolute: hid.otg_functions?.mouse_absolute ?? true, mouse_absolute: hid.otg_functions?.mouse_absolute ?? true,
consumer: hid.otg_functions?.consumer ?? true, consumer: hid.otg_functions?.consumer ?? true,
} as OtgHidFunctions, } as OtgHidFunctions,
hid_otg_keyboard_leds: hid.otg_keyboard_leds ?? false,
msd_enabled: msd.enabled || false, msd_enabled: msd.enabled || false,
msd_dir: msd.msd_dir || '', msd_dir: msd.msd_dir || '',
encoder_backend: stream.encoder || 'auto', encoder_backend: stream.encoder || 'auto',
@@ -1065,9 +1090,6 @@ async function loadConfig() {
} catch (e) { } catch (e) {
console.error('Failed to load config:', 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() devices.value = await configApi.listDevices()
} catch (e) { } catch (e) {
console.error('Failed to load devices:', e) console.error('Failed to load devices:', e)
} finally {
devicesLoaded.value = true
alignHidProfileForLowEndpoint()
} }
} }
@@ -2230,26 +2249,16 @@ watch(() => route.query.tab, (tab) => {
<p class="text-sm text-muted-foreground">{{ t('settings.otgHidProfileDesc') }}</p> <p class="text-sm text-muted-foreground">{{ t('settings.otgHidProfileDesc') }}</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="otg-profile">{{ t('settings.profile') }}</Label> <Label for="otg-endpoint-budget">{{ t('settings.otgEndpointBudget') }}</Label>
<select id="otg-profile" v-model="config.hid_otg_profile" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"> <select id="otg-endpoint-budget" v-model="config.hid_otg_endpoint_budget" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="full">{{ t('settings.otgProfileFull') }}</option> <option value="five">5</option>
<option value="full_no_msd">{{ t('settings.otgProfileFullNoMsd') }}</option> <option value="six">6</option>
<option value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</option> <option value="unlimited">{{ t('settings.otgEndpointBudgetUnlimited') }}</option>
<option value="full_no_consumer_no_msd">{{ t('settings.otgProfileFullNoConsumerNoMsd') }}</option>
<option value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</option>
<option value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</option>
<option value="custom">{{ t('settings.otgProfileCustom') }}</option>
</select> </select>
<p class="text-xs text-muted-foreground">{{ otgEndpointUsageText }}</p>
</div> </div>
<div v-if="config.hid_otg_profile === 'custom'" class="space-y-3 rounded-md border border-border/60 p-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="space-y-3 rounded-md border border-border/60 p-3">
<div>
<Label>{{ t('settings.otgFunctionKeyboard') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionKeyboardDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.keyboard" />
</div>
<Separator />
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Label>{{ t('settings.otgFunctionMouseRelative') }}</Label> <Label>{{ t('settings.otgFunctionMouseRelative') }}</Label>
@@ -2265,6 +2274,15 @@ watch(() => route.query.tab, (tab) => {
</div> </div>
<Switch v-model="config.hid_otg_functions.mouse_absolute" /> <Switch v-model="config.hid_otg_functions.mouse_absolute" />
</div> </div>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionKeyboard') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionKeyboardDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.keyboard" />
</div>
<Separator /> <Separator />
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -2274,6 +2292,15 @@ watch(() => route.query.tab, (tab) => {
<Switch v-model="config.hid_otg_functions.consumer" /> <Switch v-model="config.hid_otg_functions.consumer" />
</div> </div>
<Separator /> <Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgKeyboardLeds') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgKeyboardLedsDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_keyboard_leds" :disabled="isKeyboardLedToggleDisabled" />
</div>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Label>{{ t('settings.otgFunctionMsd') }}</Label> <Label>{{ t('settings.otgFunctionMsd') }}</Label>
@@ -2282,11 +2309,15 @@ watch(() => route.query.tab, (tab) => {
<Switch v-model="config.msd_enabled" /> <Switch v-model="config.msd_enabled" />
</div> </div>
</div> </div>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400"> <p class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgProfileWarning') }} {{ t('settings.otgProfileWarning') }}
</p> </p>
<p v-if="showLowEndpointHint" class="text-xs text-amber-600 dark:text-amber-400"> <p v-if="showOtgEndpointBudgetHint" class="text-xs text-muted-foreground">
{{ t('settings.otgLowEndpointHint') }} {{ t('settings.otgEndpointBudgetHint') }}
</p>
<p v-if="!isOtgEndpointBudgetValid" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgEndpointExceeded', { used: otgRequiredEndpoints, limit: describeEndpointBudget(config.hid_otg_endpoint_budget) }) }}
</p> </p>
</div> </div>
<Separator class="my-4" /> <Separator class="my-4" />

View File

@@ -97,7 +97,12 @@ const ch9329Port = ref('')
const ch9329Baudrate = ref(9600) const ch9329Baudrate = ref(9600)
const otgUdc = ref('') const otgUdc = ref('')
const hidOtgProfile = ref('full') const hidOtgProfile = ref('full')
const otgMsdEnabled = ref(true)
const otgEndpointBudget = ref<'five' | 'six' | 'unlimited'>('six')
const otgKeyboardLeds = ref(true)
const otgProfileTouched = ref(false) const otgProfileTouched = ref(false)
const otgEndpointBudgetTouched = ref(false)
const otgKeyboardLedsTouched = ref(false)
const showAdvancedOtg = ref(false) const showAdvancedOtg = ref(false)
// Extension settings // Extension settings
@@ -203,19 +208,67 @@ const availableFps = computed(() => {
return resolution?.fps || [] return resolution?.fps || []
}) })
const isLowEndpointUdc = computed(() => { function defaultOtgEndpointBudgetForUdc(udc?: string): 'five' | 'six' {
if (otgUdc.value) { return /musb/i.test(udc || '') ? 'five' : 'six'
return /musb/i.test(otgUdc.value)
} }
return devices.value.udc.some((udc) => /musb/i.test(udc.name))
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',
}
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() { const otgProfileHasKeyboard = computed(() =>
if (otgProfileTouched.value) return 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 if (hidBackend.value !== 'otg') return
const preferred = isLowEndpointUdc.value ? 'full_no_consumer' : 'full'
if (hidOtgProfile.value === preferred) return const recommendedBudget = defaultOtgEndpointBudgetForUdc(otgUdc.value)
hidOtgProfile.value = preferred 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) { function onOtgProfileChange(value: unknown) {
@@ -223,6 +276,20 @@ function onOtgProfileChange(value: unknown) {
otgProfileTouched.value = true 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 // Common baud rates for CH9329
const baudRates = [9600, 19200, 38400, 57600, 115200] const baudRates = [9600, 19200, 38400, 57600, 115200]
@@ -338,16 +405,16 @@ watch(hidBackend, (newBackend) => {
if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) { if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) {
otgUdc.value = devices.value.udc[0]?.name || '' otgUdc.value = devices.value.udc[0]?.name || ''
} }
applyOtgProfileDefault() applyOtgDefaults()
}) })
watch(otgUdc, () => { watch(otgUdc, () => {
applyOtgProfileDefault() applyOtgDefaults()
}) })
watch(showAdvancedOtg, (open) => { watch(showAdvancedOtg, (open) => {
if (open) { if (open) {
applyOtgProfileDefault() applyOtgDefaults()
} }
}) })
@@ -370,7 +437,7 @@ onMounted(async () => {
if (result.udc.length > 0 && result.udc[0]) { if (result.udc.length > 0 && result.udc[0]) {
otgUdc.value = result.udc[0].name otgUdc.value = result.udc[0].name
} }
applyOtgProfileDefault() applyOtgDefaults()
// Auto-select audio device if available (and no video device to trigger watch) // Auto-select audio device if available (and no video device to trigger watch)
if (result.audio.length > 0 && !audioDevice.value) { if (result.audio.length > 0 && !audioDevice.value) {
@@ -461,6 +528,13 @@ function validateStep3(): boolean {
error.value = t('setup.selectUdc') error.value = t('setup.selectUdc')
return false 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 return true
} }
@@ -523,6 +597,9 @@ async function handleSetup() {
if (hidBackend.value === 'otg' && otgUdc.value) { if (hidBackend.value === 'otg' && otgUdc.value) {
setupData.hid_otg_udc = otgUdc.value setupData.hid_otg_udc = otgUdc.value
setupData.hid_otg_profile = hidOtgProfile.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 // Encoder backend setting
@@ -990,16 +1067,47 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="full">{{ t('settings.otgProfileFull') }}</SelectItem> <SelectItem value="full">{{ t('settings.otgProfileFull') }}</SelectItem>
<SelectItem value="full_no_msd">{{ t('settings.otgProfileFullNoMsd') }}</SelectItem>
<SelectItem value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</SelectItem> <SelectItem value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</SelectItem>
<SelectItem value="full_no_consumer_no_msd">{{ t('settings.otgProfileFullNoConsumerNoMsd') }}</SelectItem>
<SelectItem value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</SelectItem> <SelectItem value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</SelectItem>
<SelectItem value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</SelectItem> <SelectItem value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<p v-if="isLowEndpointUdc" class="text-xs text-amber-600 dark:text-amber-400"> <div class="space-y-2">
{{ t('setup.otgLowEndpointHint') }} <Label for="otgEndpointBudget">{{ t('settings.otgEndpointBudget') }}</Label>
<Select :model-value="otgEndpointBudget" @update:modelValue="onOtgEndpointBudgetChange">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="five">5</SelectItem>
<SelectItem value="six">6</SelectItem>
<SelectItem value="unlimited">{{ t('settings.otgEndpointBudgetUnlimited') }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{{ otgEndpointUsageText }}
</p>
</div>
<div class="flex items-center justify-between rounded-md border border-border/60 p-3">
<div>
<Label>{{ t('settings.otgKeyboardLeds') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgKeyboardLedsDesc') }}</p>
</div>
<Switch :model-value="otgKeyboardLeds" :disabled="!otgProfileHasKeyboard" @update:model-value="onOtgKeyboardLedsChange" />
</div>
<div class="flex items-center justify-between rounded-md border border-border/60 p-3">
<div>
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
</div>
<Switch v-model="otgMsdEnabled" />
</div>
<p class="text-xs text-muted-foreground">
{{ t('settings.otgEndpointBudgetHint') }}
</p>
<p v-if="!isOtgEndpointBudgetValid" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgEndpointExceeded', { used: otgRequiredEndpoints, limit: otgEndpointBudget === 'unlimited' ? t('settings.otgEndpointBudgetUnlimited') : otgEndpointBudget === 'five' ? '5' : '6' }) }}
</p> </p>
</div> </div>
</div> </div>