mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-29 22:56:45 +08:00
refactor(otg): 简化运行时与设置逻辑
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
177
src/hid/mod.rs
177
src/hid/mod.rs
@@ -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 {
|
||||||
|
|||||||
315
src/hid/otg.rs
315
src/hid/otg.rs
@@ -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,49 +786,180 @@ 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 let Some(udc) = Self::find_udc() {
|
if self.udc_name.read().is_none() {
|
||||||
info!("Auto-detected UDC: {}", udc);
|
if let Some(udc) = Self::find_udc() {
|
||||||
self.set_udc_name(&udc);
|
info!("Auto-detected UDC: {}", udc);
|
||||||
|
self.set_udc_name(&udc);
|
||||||
|
}
|
||||||
|
} else if let Some(udc) = self.udc_name.read().clone() {
|
||||||
|
info!("Using configured UDC: {}", udc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for devices to appear (they should already exist from OtgService)
|
// Wait for devices to appear (they should already exist from OtgService)
|
||||||
@@ -866,6 +1033,8 @@ impl HidBackend for OtgBackend {
|
|||||||
|
|
||||||
// Mark as online if all devices opened successfully
|
// 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 {
|
|
||||||
initialized,
|
|
||||||
online,
|
|
||||||
error: error.as_ref().map(|(reason, _)| reason.clone()),
|
|
||||||
error_code: error.as_ref().map(|(_, code)| code.clone()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn supports_absolute_mouse(&self) -> bool {
|
fn subscribe_runtime(&self) -> watch::Receiver<()> {
|
||||||
self.mouse_abs_path.as_ref().is_some_and(|p| p.exists())
|
self.runtime_notify_tx.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
31
src/main.rs
31
src/main.rs
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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)
|
|
||||||
self.set_hid_requested(true);
|
|
||||||
|
|
||||||
// Check if already enabled and function set unchanged
|
|
||||||
let requested_functions = self.hid_functions.read().await.clone();
|
|
||||||
{
|
|
||||||
let state = self.state.read().await;
|
|
||||||
if state.hid_enabled && state.hid_functions.as_ref() == Some(&requested_functions) {
|
|
||||||
if let Some(ref paths) = state.hid_paths {
|
|
||||||
info!("HID already enabled, returning existing paths");
|
|
||||||
return Ok(paths.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recreate gadget with both HID and MSD if needed
|
|
||||||
self.recreate_gadget().await?;
|
|
||||||
|
|
||||||
// Get HID paths from state
|
|
||||||
let state = self.state.read().await;
|
|
||||||
state
|
|
||||||
.hid_paths
|
|
||||||
.clone()
|
|
||||||
.ok_or_else(|| AppError::Internal("HID paths not set after gadget setup".to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disable HID functions
|
/// Apply a fully materialized desired OTG state.
|
||||||
///
|
pub async fn apply_desired_state(&self, desired: OtgDesiredState) -> Result<()> {
|
||||||
/// 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;
|
let mut current = self.desired.write().await;
|
||||||
if !state.hid_enabled {
|
*current = desired;
|
||||||
info!("HID already disabled");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recreate gadget without HID (or destroy if MSD also disabled)
|
self.reconcile_gadget().await
|
||||||
self.recreate_gadget().await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable MSD function
|
async fn reconcile_gadget(&self) -> Result<()> {
|
||||||
///
|
let desired = self.desired.read().await.clone();
|
||||||
/// 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.msd_function.write().await = msd_func;
|
||||||
*self.manager.lock().await = Some(manager);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
*self.msd_function.write().await = msd_func;
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut state = self.state.write().await;
|
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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
|
|
||||||
state
|
if !transitioning_away_from_otg {
|
||||||
.hid
|
state
|
||||||
.reload(new_hid_backend)
|
.hid
|
||||||
.await
|
.reload(new_hid_backend)
|
||||||
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
|
.await
|
||||||
|
.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(())
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,32 +679,19 @@ 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
|
||||||
if let Some(enabled) = req.ttyd_enabled {
|
if let Some(enabled) = req.ttyd_enabled {
|
||||||
@@ -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!(
|
tracing::warn!("Failed to apply OTG config during setup: {}", e);
|
||||||
"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);
|
{
|
||||||
|
tracing::error!("MSD initialization failed: {}", e);
|
||||||
let old_msd_enabled = old_config.msd.enabled;
|
state.config.set((*old_config).clone()).await?;
|
||||||
let new_msd_enabled = new_config.msd.enabled;
|
return Ok(Json(LoginResponse {
|
||||||
let msd_dir_changed = old_config.msd.msd_dir != new_config.msd.msd_dir;
|
success: false,
|
||||||
|
message: Some(format!("MSD initialization failed: {}", e)),
|
||||||
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)
|
if has_atx {
|
||||||
let msd_dir = new_config.msd.msd_dir_path();
|
if let Err(e) =
|
||||||
if let Err(e) = std::fs::create_dir_all(msd_dir.join("images")) {
|
config::apply::apply_atx_config(&state, &old_config.atx, &new_config.atx).await
|
||||||
tracing::warn!("Failed to create MSD images directory: {}", e);
|
{
|
||||||
}
|
tracing::error!("ATX configuration invalid: {}", e);
|
||||||
if let Err(e) = std::fs::create_dir_all(msd_dir.join("ventoy")) {
|
state.config.set((*old_config).clone()).await?;
|
||||||
tracing::warn!("Failed to create MSD ventoy directory: {}", e);
|
return Ok(Json(LoginResponse {
|
||||||
}
|
success: false,
|
||||||
|
message: Some(format!("ATX configuration invalid: {}", e)),
|
||||||
let needs_reload = old_msd_enabled != new_msd_enabled || msd_dir_changed;
|
}));
|
||||||
if !needs_reload {
|
|
||||||
tracing::info!(
|
|
||||||
"MSD enabled state unchanged ({}) and directory unchanged, no reload needed",
|
|
||||||
new_msd_enabled
|
|
||||||
);
|
|
||||||
} else if new_msd_enabled {
|
|
||||||
tracing::info!("(Re)initializing MSD...");
|
|
||||||
|
|
||||||
// Shutdown existing controller if present
|
|
||||||
let mut msd_guard = state.msd.write().await;
|
|
||||||
if let Some(msd) = msd_guard.as_mut() {
|
|
||||||
if let Err(e) = msd.shutdown().await {
|
|
||||||
tracing::warn!("MSD shutdown failed: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*msd_guard = None;
|
|
||||||
drop(msd_guard);
|
|
||||||
|
|
||||||
let msd = crate::msd::MsdController::new(
|
|
||||||
state.otg_service.clone(),
|
|
||||||
new_config.msd.msd_dir_path(),
|
|
||||||
);
|
|
||||||
if let Err(e) = msd.init().await {
|
|
||||||
tracing::error!("MSD initialization failed: {}", e);
|
|
||||||
// Rollback config on failure
|
|
||||||
state.config.set((*old_config).clone()).await?;
|
|
||||||
return Ok(Json(LoginResponse {
|
|
||||||
success: false,
|
|
||||||
message: Some(format!("MSD initialization failed: {}", e)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set event bus
|
|
||||||
let events = state.events.clone();
|
|
||||||
msd.set_event_bus(events).await;
|
|
||||||
|
|
||||||
// Store the initialized controller
|
|
||||||
*state.msd.write().await = Some(msd);
|
|
||||||
tracing::info!("MSD initialized successfully");
|
|
||||||
} else {
|
|
||||||
tracing::info!("MSD disabled in config, shutting down...");
|
|
||||||
|
|
||||||
let mut msd_guard = state.msd.write().await;
|
|
||||||
if let Some(msd) = msd_guard.as_mut() {
|
|
||||||
if let Err(e) = msd.shutdown().await {
|
|
||||||
tracing::warn!("MSD shutdown failed: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*msd_guard = None;
|
|
||||||
tracing::info!("MSD shutdown complete");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,16 +84,39 @@ 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">
|
||||||
<div
|
<template v-if="keyboardLedEnabled">
|
||||||
:class="cn(
|
<div
|
||||||
'px-2 py-1 select-none transition-colors',
|
:class="cn(
|
||||||
capsLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
'px-2 py-1 select-none transition-colors',
|
||||||
)"
|
capsLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||||
>
|
)"
|
||||||
<span class="hidden sm:inline">{{ t('infobar.caps') }}</span>
|
>
|
||||||
<span class="sm:hidden">C</span>
|
<span class="hidden sm:inline">{{ t('infobar.caps') }}</span>
|
||||||
|
<span class="sm:hidden">C</span>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '配置已应用',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
function normalizeOtgEndpointBudget(budget: OtgEndpointBudget | undefined, udc?: string): OtgEndpointBudget {
|
||||||
return
|
if (!budget || budget === 'auto') {
|
||||||
|
return defaultOtgEndpointBudgetForUdc(udc)
|
||||||
}
|
}
|
||||||
if (!isLowEndpointUdc.value) {
|
return budget
|
||||||
hidProfileAligned.value = true
|
}
|
||||||
return
|
|
||||||
|
function endpointLimitForBudget(budget: OtgEndpointBudget): number | null {
|
||||||
|
if (budget === 'unlimited') return null
|
||||||
|
return budget === 'five' ? 5 : 6
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveOtgFunctions = computed(() => ({ ...config.value.hid_otg_functions }))
|
||||||
|
|
||||||
|
const otgEndpointLimit = computed(() =>
|
||||||
|
endpointLimitForBudget(config.value.hid_otg_endpoint_budget)
|
||||||
|
)
|
||||||
|
|
||||||
|
const otgRequiredEndpoints = computed(() => {
|
||||||
|
if (config.value.hid_backend !== 'otg') return 0
|
||||||
|
const functions = effectiveOtgFunctions.value
|
||||||
|
let endpoints = 0
|
||||||
|
if (functions.keyboard) {
|
||||||
|
endpoints += 1
|
||||||
|
if (config.value.hid_otg_keyboard_leds) endpoints += 1
|
||||||
}
|
}
|
||||||
if (config.value.hid_otg_profile === 'full') {
|
if (functions.mouse_relative) endpoints += 1
|
||||||
config.value.hid_otg_profile = 'full_no_consumer' as OtgHidProfile
|
if (functions.mouse_absolute) endpoints += 1
|
||||||
} else if (config.value.hid_otg_profile === 'full_no_msd') {
|
if (functions.consumer) endpoints += 1
|
||||||
config.value.hid_otg_profile = 'full_no_consumer_no_msd' as OtgHidProfile
|
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,63 +2249,75 @@ 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>
|
<div class="flex items-center justify-between">
|
||||||
<Label>{{ t('settings.otgFunctionKeyboard') }}</Label>
|
<div>
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionKeyboardDesc') }}</p>
|
<Label>{{ t('settings.otgFunctionMouseRelative') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseRelativeDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="config.hid_otg_functions.mouse_relative" />
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>{{ t('settings.otgFunctionMouseAbsolute') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseAbsoluteDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
|
||||||
</div>
|
</div>
|
||||||
<Switch v-model="config.hid_otg_functions.keyboard" />
|
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<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.otgFunctionMouseRelative') }}</Label>
|
<Label>{{ t('settings.otgFunctionKeyboard') }}</Label>
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseRelativeDesc') }}</p>
|
<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>
|
||||||
|
<Label>{{ t('settings.otgFunctionConsumer') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionConsumerDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="config.hid_otg_functions.consumer" />
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
<Switch v-model="config.hid_otg_functions.mouse_relative" />
|
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<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.otgFunctionMouseAbsolute') }}</Label>
|
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseAbsoluteDesc') }}</p>
|
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="config.msd_enabled" />
|
||||||
</div>
|
</div>
|
||||||
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label>{{ t('settings.otgFunctionConsumer') }}</Label>
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionConsumerDesc') }}</p>
|
|
||||||
</div>
|
|
||||||
<Switch v-model="config.hid_otg_functions.consumer" />
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
|
|
||||||
</div>
|
|
||||||
<Switch v-model="config.msd_enabled" />
|
|
||||||
</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" />
|
||||||
|
|||||||
@@ -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)
|
}
|
||||||
|
|
||||||
|
function endpointLimitForBudget(budget: 'five' | 'six' | 'unlimited'): number | null {
|
||||||
|
if (budget === 'unlimited') return null
|
||||||
|
return budget === 'five' ? 5 : 6
|
||||||
|
}
|
||||||
|
|
||||||
|
const otgRequiredEndpoints = computed(() => {
|
||||||
|
if (hidBackend.value !== 'otg') return 0
|
||||||
|
const functions = {
|
||||||
|
keyboard: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer' || hidOtgProfile.value === 'legacy_keyboard',
|
||||||
|
mouseRelative: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer' || hidOtgProfile.value === 'legacy_mouse_relative',
|
||||||
|
mouseAbsolute: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer',
|
||||||
|
consumer: hidOtgProfile.value === 'full',
|
||||||
}
|
}
|
||||||
return devices.value.udc.some((udc) => /musb/i.test(udc.name))
|
let endpoints = 0
|
||||||
|
if (functions.keyboard) {
|
||||||
|
endpoints += 1
|
||||||
|
if (otgKeyboardLeds.value) endpoints += 1
|
||||||
|
}
|
||||||
|
if (functions.mouseRelative) endpoints += 1
|
||||||
|
if (functions.mouseAbsolute) endpoints += 1
|
||||||
|
if (functions.consumer) endpoints += 1
|
||||||
|
if (otgMsdEnabled.value) endpoints += 2
|
||||||
|
return endpoints
|
||||||
})
|
})
|
||||||
|
|
||||||
function applyOtgProfileDefault() {
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user