From 9cb0dd146e7ab4b9ec621dcb89d9c73f9f7c4b78 Mon Sep 17 00:00:00 2001 From: mofeng Date: Thu, 29 Jan 2026 20:00:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=85=A8=E5=BF=97=E5=B9=B3=E5=8F=B0=20OTG=20=E4=BD=8E=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/schema.rs | 15 + src/main.rs | 15 +- src/otg/configfs.rs | 17 + src/otg/manager.rs | 56 ++- src/web/handlers/config/apply.rs | 18 +- src/web/handlers/mod.rs | 78 +++++ test/bench_kvm.py | 566 +++++++++++++++++++++++++++++++ web/src/api/index.ts | 1 + web/src/i18n/en-US.ts | 15 +- web/src/i18n/zh-CN.ts | 15 +- web/src/stores/auth.ts | 1 + web/src/types/generated.ts | 4 + web/src/views/SettingsView.vue | 52 +++ web/src/views/SetupView.vue | 76 +++++ 14 files changed, 916 insertions(+), 13 deletions(-) create mode 100644 test/bench_kvm.py diff --git a/src/config/schema.rs b/src/config/schema.rs index ac681396..11a86126 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -168,6 +168,10 @@ pub enum OtgHidProfile { Full, /// Full HID device set without MSD FullNoMsd, + /// Full HID device set without consumer control + FullNoConsumer, + /// Full HID device set without consumer control and MSD + FullNoConsumerNoMsd, /// Legacy profile: only keyboard LegacyKeyboard, /// Legacy profile: only relative mouse @@ -203,6 +207,15 @@ impl OtgHidFunctions { } } + pub fn full_no_consumer() -> Self { + Self { + keyboard: true, + mouse_relative: true, + mouse_absolute: true, + consumer: false, + } + } + pub fn legacy_keyboard() -> Self { Self { keyboard: true, @@ -237,6 +250,8 @@ impl OtgHidProfile { match self { Self::Full => OtgHidFunctions::full(), Self::FullNoMsd => OtgHidFunctions::full(), + Self::FullNoConsumer => OtgHidFunctions::full_no_consumer(), + Self::FullNoConsumerNoMsd => OtgHidFunctions::full_no_consumer(), Self::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(), Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(), Self::Custom => custom.clone(), diff --git a/src/main.rs b/src/main.rs index e23863f6..3d8d7cfa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,7 @@ use one_kvm::events::EventBus; use one_kvm::extensions::ExtensionManager; use one_kvm::hid::{HidBackendType, HidController}; use one_kvm::msd::MsdController; -use one_kvm::otg::OtgService; +use one_kvm::otg::{configfs, OtgService}; use one_kvm::rustdesk::RustDeskService; use one_kvm::state::AppState; use one_kvm::video::format::{PixelFormat, Resolution}; @@ -312,6 +312,19 @@ async fn main() -> anyhow::Result<()> { let will_use_msd = config.msd.enabled; if will_use_otg_hid { + let mut hid_functions = config.hid.effective_otg_functions(); + if let Some(udc) = configfs::resolve_udc_name(config.hid.otg_udc.as_deref()) { + if configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer { + tracing::warn!( + "UDC {} has low endpoint resources, disabling consumer control", + udc + ); + hid_functions.consumer = false; + } + } + if let Err(e) = otg_service.update_hid_functions(hid_functions).await { + tracing::warn!("Failed to apply HID functions: {}", e); + } if let Err(e) = otg_service.enable_hid().await { tracing::warn!("Failed to pre-enable HID: {}", e); } diff --git a/src/otg/configfs.rs b/src/otg/configfs.rs index 5e7fd493..bacdd50b 100644 --- a/src/otg/configfs.rs +++ b/src/otg/configfs.rs @@ -43,6 +43,23 @@ pub fn find_udc() -> Option { .next() } +/// Check if UDC is known to have low endpoint resources +pub fn is_low_endpoint_udc(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + name.contains("musb") || name.contains("musb-hdrc") +} + +/// Resolve preferred UDC name if available, otherwise auto-detect +pub fn resolve_udc_name(preferred: Option<&str>) -> Option { + if let Some(name) = preferred { + let path = Path::new("/sys/class/udc").join(name); + if path.exists() { + return Some(name.to_string()); + } + } + find_udc() +} + /// Write string content to a file /// /// For sysfs files, this function appends a newline and flushes diff --git a/src/otg/manager.rs b/src/otg/manager.rs index 7e50949d..64773d2b 100644 --- a/src/otg/manager.rs +++ b/src/otg/manager.rs @@ -6,9 +6,9 @@ use std::path::PathBuf; use tracing::{debug, error, info, warn}; use super::configfs::{ - create_dir, find_udc, is_configfs_available, remove_dir, write_file, CONFIGFS_PATH, - DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID, DEFAULT_USB_VENDOR_ID, - USB_BCD_USB, + create_dir, create_symlink, find_udc, is_configfs_available, remove_dir, remove_file, + write_file, CONFIGFS_PATH, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID, + DEFAULT_USB_VENDOR_ID, USB_BCD_USB, }; use super::endpoint::{EndpointAllocator, DEFAULT_MAX_ENDPOINTS}; use super::function::{FunctionMeta, GadgetFunction}; @@ -16,6 +16,8 @@ use super::hid::HidFunction; use super::msd::MsdFunction; use crate::error::{AppError, Result}; +const REBIND_DELAY_MS: u64 = 300; + /// USB Gadget device descriptor configuration #[derive(Debug, Clone)] pub struct GadgetDescriptor { @@ -249,9 +251,15 @@ impl OtgGadgetManager { AppError::Internal("No USB Device Controller (UDC) found".to_string()) })?; + // Recreate config symlinks before binding to avoid kernel gadget issues after rebind + if let Err(e) = self.recreate_config_links() { + warn!("Failed to recreate gadget config links before bind: {}", e); + } + info!("Binding gadget to UDC: {}", udc); write_file(&self.gadget_path.join("UDC"), &udc)?; self.bound_udc = Some(udc); + std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS)); Ok(()) } @@ -262,6 +270,7 @@ impl OtgGadgetManager { write_file(&self.gadget_path.join("UDC"), "")?; self.bound_udc = None; info!("Unbound gadget from UDC"); + std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS)); } Ok(()) } @@ -382,6 +391,47 @@ impl OtgGadgetManager { pub fn gadget_path(&self) -> &PathBuf { &self.gadget_path } + + /// Recreate config symlinks from functions directory + fn recreate_config_links(&self) -> Result<()> { + let functions_path = self.gadget_path.join("functions"); + if !functions_path.exists() || !self.config_path.exists() { + return Ok(()); + } + + let entries = std::fs::read_dir(&functions_path).map_err(|e| { + AppError::Internal(format!( + "Failed to read functions directory {}: {}", + functions_path.display(), + e + )) + })?; + + for entry in entries.flatten() { + let name = entry.file_name(); + let name = match name.to_str() { + Some(n) => n, + None => continue, + }; + if !name.contains(".usb") { + continue; + } + + let src = functions_path.join(name); + let dest = self.config_path.join(name); + + if dest.exists() { + if let Err(e) = remove_file(&dest) { + warn!("Failed to remove existing config link {}: {}", dest.display(), e); + continue; + } + } + + create_symlink(&src, &dest)?; + } + + Ok(()) + } } impl Default for OtgGadgetManager { diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index f8977d11..d7dd88e0 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -187,7 +187,23 @@ pub async fn apply_hid_config( // 检查 OTG 描述符是否变更 let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor; let old_hid_functions = old_config.effective_otg_functions(); - let new_hid_functions = new_config.effective_otg_functions(); + let mut new_hid_functions = new_config.effective_otg_functions(); + + // Low-endpoint UDCs (e.g., musb) cannot handle consumer control endpoints reliably + if new_config.backend == HidBackend::Otg { + if let Some(udc) = + crate::otg::configfs::resolve_udc_name(new_config.otg_udc.as_deref()) + { + if crate::otg::configfs::is_low_endpoint_udc(&udc) && new_hid_functions.consumer { + tracing::warn!( + "UDC {} has low endpoint resources, disabling consumer control", + udc + ); + new_hid_functions.consumer = false; + } + } + } + let hid_functions_changed = old_hid_functions != new_hid_functions; if new_config.backend == HidBackend::Otg && new_hid_functions.is_empty() { diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index ff8a53ca..5e8eb036 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -521,11 +521,39 @@ pub struct SetupRequest { pub hid_ch9329_port: Option, pub hid_ch9329_baudrate: Option, pub hid_otg_udc: Option, + pub hid_otg_profile: Option, // Extension settings pub ttyd_enabled: Option, pub rustdesk_enabled: Option, } +fn normalize_otg_profile_for_low_endpoint(config: &mut AppConfig) { + if !matches!(config.hid.backend, crate::config::HidBackend::Otg) { + return; + } + let udc = crate::otg::configfs::resolve_udc_name(config.hid.otg_udc.as_deref()); + let Some(udc) = udc else { + return; + }; + if !crate::otg::configfs::is_low_endpoint_udc(&udc) { + return; + } + match config.hid.otg_profile { + crate::config::OtgHidProfile::Full => { + config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumer; + } + crate::config::OtgHidProfile::FullNoMsd => { + config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumerNoMsd; + } + crate::config::OtgHidProfile::Custom => { + if config.hid.otg_functions.consumer { + config.hid.otg_functions.consumer = false; + } + } + _ => {} + } +} + pub async fn setup_init( State(state): State>, Json(req): Json, @@ -601,6 +629,33 @@ pub async fn setup_init( if let Some(udc) = req.hid_otg_udc.clone() { config.hid.otg_udc = Some(udc); } + if let Some(profile) = req.hid_otg_profile.clone() { + config.hid.otg_profile = match profile.as_str() { + "full" => crate::config::OtgHidProfile::Full, + "full_no_msd" => crate::config::OtgHidProfile::FullNoMsd, + "full_no_consumer" => crate::config::OtgHidProfile::FullNoConsumer, + "full_no_consumer_no_msd" => crate::config::OtgHidProfile::FullNoConsumerNoMsd, + "legacy_keyboard" => crate::config::OtgHidProfile::LegacyKeyboard, + "legacy_mouse_relative" => crate::config::OtgHidProfile::LegacyMouseRelative, + "custom" => crate::config::OtgHidProfile::Custom, + _ => config.hid.otg_profile.clone(), + }; + if matches!(config.hid.backend, crate::config::HidBackend::Otg) { + match config.hid.otg_profile { + crate::config::OtgHidProfile::Full + | crate::config::OtgHidProfile::FullNoConsumer => { + config.msd.enabled = true; + } + crate::config::OtgHidProfile::FullNoMsd + | crate::config::OtgHidProfile::FullNoConsumerNoMsd + | crate::config::OtgHidProfile::LegacyKeyboard + | crate::config::OtgHidProfile::LegacyMouseRelative => { + config.msd.enabled = false; + } + crate::config::OtgHidProfile::Custom => {} + } + } + } // Extension settings if let Some(enabled) = req.ttyd_enabled { @@ -609,12 +664,32 @@ pub async fn setup_init( if let Some(enabled) = req.rustdesk_enabled { config.rustdesk.enabled = enabled; } + + normalize_otg_profile_for_low_endpoint(config); }) .await?; // Get updated config for HID reload let new_config = state.config.get(); + if matches!(new_config.hid.backend, crate::config::HidBackend::Otg) { + let mut hid_functions = new_config.hid.effective_otg_functions(); + if let Some(udc) = + crate::otg::configfs::resolve_udc_name(new_config.hid.otg_udc.as_deref()) + { + if crate::otg::configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer { + tracing::warn!( + "UDC {} has low endpoint resources, disabling consumer control", + udc + ); + hid_functions.consumer = false; + } + } + if let Err(e) = state.otg_service.update_hid_functions(hid_functions).await { + tracing::warn!("Failed to apply HID functions during setup: {}", e); + } + } + tracing::info!( "Extension config after save: ttyd.enabled={}, rustdesk.enabled={}", new_config.extensions.ttyd.enabled, @@ -727,6 +802,9 @@ pub async fn update_config( let new_config: AppConfig = serde_json::from_value(merged) .map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?; + let mut new_config = new_config; + normalize_otg_profile_for_low_endpoint(&mut new_config); + // Apply the validated config state.config.set(new_config.clone()).await?; diff --git a/test/bench_kvm.py b/test/bench_kvm.py new file mode 100644 index 00000000..f425052b --- /dev/null +++ b/test/bench_kvm.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python3 +""" +One-KVM benchmark script (Windows-friendly). + +Measures FPS + CPU usage across: +- input pixel formats (capture card formats) +- output codecs (mjpeg/h264/h265/vp8/vp9) +- resolution/FPS matrix +- encoder backends (software/hardware) + +Requirements: + pip install requests websockets playwright + playwright install +""" + +from __future__ import annotations + +import argparse +import asyncio +import csv +import json +import sys +import threading +import time +from dataclasses import dataclass +from typing import Dict, Iterable, List, Optional, Tuple + +import requests +import websockets +from playwright.async_api import async_playwright + + +SESSION_COOKIE = "one_kvm_session" +DEFAULT_MATRIX = [ + (1920, 1080, 30), + (1920, 1080, 60), + (1280, 720, 30), + (1280, 720, 60), +] + + +@dataclass +class Case: + input_format: str + output_codec: str + encoder: Optional[str] + width: int + height: int + fps: int + + +@dataclass +class Result: + input_format: str + output_codec: str + encoder: str + width: int + height: int + fps: int + avg_fps: float + avg_cpu: float + note: str = "" + + +class KvmClient: + def __init__(self, base_url: str, username: str, password: str) -> None: + self.base = base_url.rstrip("/") + self.s = requests.Session() + self.login(username, password) + + def login(self, username: str, password: str) -> None: + r = self.s.post(f"{self.base}/api/auth/login", json={"username": username, "password": password}) + r.raise_for_status() + + def get_cookie(self) -> str: + return self.s.cookies.get(SESSION_COOKIE, "") + + def get_video_config(self) -> Dict: + r = self.s.get(f"{self.base}/api/config/video") + r.raise_for_status() + return r.json() + + def get_stream_config(self) -> Dict: + r = self.s.get(f"{self.base}/api/config/stream") + r.raise_for_status() + return r.json() + + def get_devices(self) -> Dict: + r = self.s.get(f"{self.base}/api/devices") + r.raise_for_status() + return r.json() + + def get_codecs(self) -> Dict: + r = self.s.get(f"{self.base}/api/stream/codecs") + r.raise_for_status() + return r.json() + + def patch_video(self, device: Optional[str], fmt: str, w: int, h: int, fps: int) -> None: + payload: Dict[str, object] = {"format": fmt, "width": w, "height": h, "fps": fps} + if device: + payload["device"] = device + r = self.s.patch(f"{self.base}/api/config/video", json=payload) + r.raise_for_status() + + def patch_stream(self, encoder: Optional[str]) -> None: + if encoder is None: + return + r = self.s.patch(f"{self.base}/api/config/stream", json={"encoder": encoder}) + r.raise_for_status() + + def set_mode(self, mode: str) -> None: + r = self.s.post(f"{self.base}/api/stream/mode", json={"mode": mode}) + r.raise_for_status() + + def get_mode(self) -> Dict: + r = self.s.get(f"{self.base}/api/stream/mode") + r.raise_for_status() + return r.json() + + def wait_mode_ready(self, mode: str, timeout_sec: int = 20) -> None: + deadline = time.time() + timeout_sec + while time.time() < deadline: + data = self.get_mode() + if not data.get("switching") and data.get("mode") == mode: + return + time.sleep(0.5) + raise RuntimeError(f"mode switch timeout: {mode}") + + def start_stream(self) -> None: + r = self.s.post(f"{self.base}/api/stream/start") + r.raise_for_status() + + def stop_stream(self) -> None: + r = self.s.post(f"{self.base}/api/stream/stop") + r.raise_for_status() + + def cpu_sample(self) -> float: + r = self.s.get(f"{self.base}/api/info") + r.raise_for_status() + return float(r.json()["device_info"]["cpu_usage"]) + + def close_webrtc_session(self, session_id: str) -> None: + if not session_id: + return + self.s.post(f"{self.base}/api/webrtc/close", json={"session_id": session_id}) + + +class MjpegStream: + def __init__(self, url: str, cookie: str) -> None: + self._stop = threading.Event() + self._resp = requests.get(url, stream=True, headers={"Cookie": f"{SESSION_COOKIE}={cookie}"}) + self._thread = threading.Thread(target=self._reader, daemon=True) + self._thread.start() + + def _reader(self) -> None: + try: + for chunk in self._resp.iter_content(chunk_size=4096): + if self._stop.is_set(): + break + if not chunk: + time.sleep(0.01) + except Exception: + pass + + def close(self) -> None: + self._stop.set() + try: + self._resp.close() + except Exception: + pass + + +def parse_matrix(values: Optional[List[str]]) -> List[Tuple[int, int, int]]: + if not values: + return DEFAULT_MATRIX + result: List[Tuple[int, int, int]] = [] + for item in values: + # WIDTHxHEIGHT@FPS + part = item.strip().lower() + if "@" not in part or "x" not in part: + raise ValueError(f"invalid matrix item: {item}") + res_part, fps_part = part.split("@", 1) + w_str, h_str = res_part.split("x", 1) + result.append((int(w_str), int(h_str), int(fps_part))) + return result + + +def avg(values: Iterable[float]) -> float: + vals = list(values) + return sum(vals) / len(vals) if vals else 0.0 + + +def normalize_format(fmt: str) -> str: + return fmt.strip().upper() + + +def select_device(devices: Dict, preferred: Optional[str]) -> Optional[Dict]: + video_devices = devices.get("video", []) + if preferred: + for d in video_devices: + if d.get("path") == preferred: + return d + return video_devices[0] if video_devices else None + + +def build_supported_map(device: Dict) -> Dict[str, Dict[Tuple[int, int], List[int]]]: + supported: Dict[str, Dict[Tuple[int, int], List[int]]] = {} + for fmt in device.get("formats", []): + fmt_name = normalize_format(fmt.get("format", "")) + res_map: Dict[Tuple[int, int], List[int]] = {} + for res in fmt.get("resolutions", []): + key = (int(res.get("width", 0)), int(res.get("height", 0))) + fps_list = [int(f) for f in res.get("fps", [])] + res_map[key] = fps_list + supported[fmt_name] = res_map + return supported + + +def is_combo_supported( + supported: Dict[str, Dict[Tuple[int, int], List[int]]], + fmt: str, + width: int, + height: int, + fps: int, +) -> bool: + res_map = supported.get(fmt) + if not res_map: + return False + fps_list = res_map.get((width, height), []) + return fps in fps_list + + +async def mjpeg_sample( + base_url: str, + cookie: str, + client_id: str, + duration_sec: float, + cpu_sample_fn, +) -> Tuple[float, float]: + mjpeg_url = f"{base_url}/api/stream/mjpeg?client_id={client_id}" + stream = MjpegStream(mjpeg_url, cookie) + ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/ws" + + fps_samples: List[float] = [] + cpu_samples: List[float] = [] + + # discard first cpu sample (needs delta) + cpu_sample_fn() + + try: + async with websockets.connect(ws_url, extra_headers={"Cookie": f"{SESSION_COOKIE}={cookie}"}) as ws: + start = time.time() + while time.time() - start < duration_sec: + try: + msg = await asyncio.wait_for(ws.recv(), timeout=1.0) + except asyncio.TimeoutError: + msg = None + + if msg: + data = json.loads(msg) + if data.get("type") == "stream.stats_update": + clients = data.get("clients_stat", {}) + if client_id in clients: + fps = float(clients[client_id].get("fps", 0)) + fps_samples.append(fps) + + cpu_samples.append(float(cpu_sample_fn())) + finally: + stream.close() + + return avg(fps_samples), avg(cpu_samples) + + +async def webrtc_sample( + base_url: str, + cookie: str, + duration_sec: float, + cpu_sample_fn, + headless: bool, +) -> Tuple[float, float, str]: + fps_samples: List[float] = [] + cpu_samples: List[float] = [] + session_id = "" + + # discard first cpu sample (needs delta) + cpu_sample_fn() + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=headless) + context = await browser.new_context() + await context.add_cookies([{ + "name": SESSION_COOKIE, + "value": cookie, + "url": base_url, + "path": "/", + }]) + page = await context.new_page() + await page.goto(base_url + "/", wait_until="domcontentloaded") + + await page.evaluate( + """ + async (base) => { + const pc = new RTCPeerConnection(); + pc.addTransceiver('video', { direction: 'recvonly' }); + pc.addTransceiver('audio', { direction: 'recvonly' }); + pc.onicecandidate = async (e) => { + if (e.candidate && window.__sid) { + await fetch(base + "/api/webrtc/ice", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: window.__sid, candidate: e.candidate }) + }); + } + }; + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const resp = await fetch(base + "/api/webrtc/offer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sdp: offer.sdp }) + }); + const ans = await resp.json(); + window.__sid = ans.session_id; + await pc.setRemoteDescription({ type: "answer", sdp: ans.sdp }); + (ans.ice_candidates || []).forEach(c => pc.addIceCandidate(c)); + window.__kvmStats = { pc, lastTs: 0, lastFrames: 0 }; + } + """, + base_url, + ) + + try: + await page.wait_for_function( + "window.__kvmStats && window.__kvmStats.pc && window.__kvmStats.pc.connectionState === 'connected'", + timeout=15000, + ) + except Exception: + pass + + start = time.time() + while time.time() - start < duration_sec: + fps = await page.evaluate( + """ + async () => { + const s = window.__kvmStats; + const report = await s.pc.getStats(); + let fps = 0; + for (const r of report.values()) { + if (r.type === "inbound-rtp" && r.kind === "video") { + if (r.framesPerSecond) { + fps = r.framesPerSecond; + } else if (r.framesDecoded && s.lastTs) { + const dt = (r.timestamp - s.lastTs) / 1000.0; + const df = r.framesDecoded - s.lastFrames; + fps = dt > 0 ? df / dt : 0; + } + s.lastTs = r.timestamp; + s.lastFrames = r.framesDecoded || s.lastFrames; + break; + } + } + return fps; + } + """ + ) + fps_samples.append(float(fps)) + cpu_samples.append(float(cpu_sample_fn())) + await asyncio.sleep(1) + + session_id = await page.evaluate("window.__sid || ''") + await browser.close() + + return avg(fps_samples), avg(cpu_samples), session_id + + +async def run_case( + client: KvmClient, + device: Optional[str], + case: Case, + duration_sec: float, + warmup_sec: float, + headless: bool, +) -> Result: + client.patch_video(device, case.input_format, case.width, case.height, case.fps) + + if case.output_codec != "mjpeg": + client.patch_stream(case.encoder) + + client.set_mode(case.output_codec) + client.wait_mode_ready(case.output_codec) + + client.start_stream() + time.sleep(warmup_sec) + + note = "" + if case.output_codec == "mjpeg": + avg_fps, avg_cpu = await mjpeg_sample( + client.base, + client.get_cookie(), + client_id=f"bench-{int(time.time() * 1000)}", + duration_sec=duration_sec, + cpu_sample_fn=client.cpu_sample, + ) + else: + avg_fps, avg_cpu, session_id = await webrtc_sample( + client.base, + client.get_cookie(), + duration_sec=duration_sec, + cpu_sample_fn=client.cpu_sample, + headless=headless, + ) + if session_id: + client.close_webrtc_session(session_id) + else: + note = "no-session-id" + + client.stop_stream() + + return Result( + input_format=case.input_format, + output_codec=case.output_codec, + encoder=case.encoder or "n/a", + width=case.width, + height=case.height, + fps=case.fps, + avg_fps=avg_fps, + avg_cpu=avg_cpu, + note=note, + ) + + +def write_csv(results: List[Result], path: str) -> None: + with open(path, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["input_format", "output_codec", "encoder", "width", "height", "fps", "avg_fps", "avg_cpu", "note"]) + for r in results: + w.writerow([r.input_format, r.output_codec, r.encoder, r.width, r.height, r.fps, f"{r.avg_fps:.2f}", f"{r.avg_cpu:.2f}", r.note]) + + +def write_md(results: List[Result], path: str) -> None: + lines = [ + "| input_format | output_codec | encoder | width | height | fps | avg_fps | avg_cpu | note |", + "|---|---|---|---:|---:|---:|---:|---:|---|", + ] + for r in results: + lines.append( + f"| {r.input_format} | {r.output_codec} | {r.encoder} | {r.width} | {r.height} | {r.fps} | {r.avg_fps:.2f} | {r.avg_cpu:.2f} | {r.note} |" + ) + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + +def main() -> int: + parser = argparse.ArgumentParser(description="One-KVM benchmark (FPS + CPU)") + parser.add_argument("--base-url", required=True, help="e.g. http://192.168.1.50") + parser.add_argument("--username", required=True) + parser.add_argument("--password", required=True) + parser.add_argument("--device", help="video device path, e.g. /dev/video0") + parser.add_argument("--input-formats", help="comma list, e.g. MJPEG,YUYV,NV12") + parser.add_argument("--output-codecs", help="comma list, e.g. mjpeg,h264,h265,vp8,vp9") + parser.add_argument("--encoder-backends", help="comma list, e.g. software,auto,vaapi,nvenc,qsv,amf,rkmpp,v4l2m2m") + parser.add_argument("--matrix", action="append", help="repeatable WIDTHxHEIGHT@FPS, e.g. 1920x1080@30") + parser.add_argument("--duration", type=float, default=30.0, help="sample duration seconds (default 30)") + parser.add_argument("--warmup", type=float, default=3.0, help="warmup seconds before sampling") + parser.add_argument("--csv", default="bench_results.csv") + parser.add_argument("--md", default="bench_results.md") + parser.add_argument("--headless", action="store_true", help="run browser headless (default: headful)") + + args = parser.parse_args() + + if sys.platform.startswith("win"): + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + base_url = args.base_url.strip() + if not base_url.startswith(("http://", "https://")): + base_url = "http://" + base_url + client = KvmClient(base_url, args.username, args.password) + + devices = client.get_devices() + video_cfg = client.get_video_config() + device_path = args.device or video_cfg.get("device") + device_info = select_device(devices, device_path) + if not device_info: + print("No video device found.", file=sys.stderr) + return 2 + device_path = device_info.get("path") + + supported_map = build_supported_map(device_info) + + if args.input_formats: + input_formats = [normalize_format(f) for f in args.input_formats.split(",") if f.strip()] + else: + input_formats = list(supported_map.keys()) + + matrix = parse_matrix(args.matrix) + + codecs_info = client.get_codecs() + available_codecs = {c["id"] for c in codecs_info.get("codecs", []) if c.get("available")} + available_codecs.add("mjpeg") + + if args.output_codecs: + output_codecs = [c.strip().lower() for c in args.output_codecs.split(",") if c.strip()] + else: + output_codecs = sorted(list(available_codecs)) + + if args.encoder_backends: + encoder_backends = [e.strip().lower() for e in args.encoder_backends.split(",") if e.strip()] + else: + encoder_backends = ["software", "auto"] + + cases: List[Case] = [] + for fmt in input_formats: + for (w, h, fps) in matrix: + if not is_combo_supported(supported_map, fmt, w, h, fps): + continue + for codec in output_codecs: + if codec not in available_codecs: + continue + if codec == "mjpeg": + cases.append(Case(fmt, codec, None, w, h, fps)) + else: + for enc in encoder_backends: + cases.append(Case(fmt, codec, enc, w, h, fps)) + + print(f"Total cases: {len(cases)}") + results: List[Result] = [] + + for idx, case in enumerate(cases, 1): + print(f"[{idx}/{len(cases)}] {case.input_format} {case.output_codec} {case.encoder or 'n/a'} {case.width}x{case.height}@{case.fps}") + try: + result = asyncio.run( + run_case( + client, + device=device_path, + case=case, + duration_sec=args.duration, + warmup_sec=args.warmup, + headless=args.headless, + ) + ) + results.append(result) + print(f" -> avg_fps={result.avg_fps:.2f}, avg_cpu={result.avg_cpu:.2f}") + except Exception as exc: + results.append( + Result( + input_format=case.input_format, + output_codec=case.output_codec, + encoder=case.encoder or "n/a", + width=case.width, + height=case.height, + fps=case.fps, + avg_fps=0.0, + avg_cpu=0.0, + note=f"error: {exc}", + ) + ) + print(f" -> error: {exc}") + + write_csv(results, args.csv) + write_md(results, args.md) + print(f"Saved: {args.csv}, {args.md}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 214a4b20..8ee2459e 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -84,6 +84,7 @@ export const systemApi = { hid_ch9329_port?: string hid_ch9329_baudrate?: number hid_otg_udc?: string + hid_otg_profile?: string encoder_backend?: string audio_device?: string ttyd_enabled?: boolean diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 7454257c..fa16c1fb 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -265,6 +265,10 @@ export default { // Help tooltips ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.', otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.', + otgAdvanced: 'Advanced: OTG Preset', + otgProfile: 'Initial HID Preset', + otgProfileDesc: 'Choose the initial OTG HID preset. You can change this later in Settings.', + otgLowEndpointHint: 'Detected low-endpoint UDC; multimedia keys will be disabled automatically.', videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.', videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.', // Extensions @@ -584,10 +588,12 @@ export default { otgHidProfile: 'OTG HID Profile', otgHidProfileDesc: 'Select which HID functions are exposed to the host', profile: 'Profile', - otgProfileFull: 'Full (keyboard + relative mouse + absolute mouse + consumer + MSD)', - otgProfileFullNoMsd: 'Full (keyboard + relative mouse + absolute mouse + consumer, no MSD)', - otgProfileLegacyKeyboard: 'Legacy: keyboard only', - otgProfileLegacyMouseRelative: 'Legacy: relative mouse only', + otgProfileFull: 'Keyboard + relative mouse + absolute mouse + multimedia + MSD', + otgProfileFullNoMsd: 'Keyboard + relative mouse + absolute mouse + multimedia (no MSD)', + otgProfileFullNoConsumer: 'Keyboard + relative mouse + absolute mouse + MSD (no multimedia)', + otgProfileFullNoConsumerNoMsd: 'Keyboard + relative mouse + absolute mouse (no multimedia, no MSD)', + otgProfileLegacyKeyboard: 'Keyboard only', + otgProfileLegacyMouseRelative: 'Relative mouse only', otgProfileCustom: 'Custom', otgFunctionKeyboard: 'Keyboard', otgFunctionKeyboardDesc: 'Standard HID keyboard device', @@ -600,6 +606,7 @@ export default { otgFunctionMsd: 'Mass Storage (MSD)', otgFunctionMsdDesc: 'Expose USB storage to the host', otgProfileWarning: 'Changing HID functions will reconnect the USB device', + otgLowEndpointHint: 'Low-endpoint UDC detected; multimedia keys will be disabled automatically.', otgFunctionMinWarning: 'Enable at least one HID function before saving', // OTG Descriptor otgDescriptor: 'USB Device Descriptor', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index b355355a..01e90096 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -265,6 +265,10 @@ export default { // Help tooltips ch9329Help: 'CH9329 是一款串口转 HID 芯片,通过串口连接到主机。适用于大多数硬件配置。', otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。', + otgAdvanced: '高级:OTG 预设', + otgProfile: '初始 HID 预设', + otgProfileDesc: '选择 OTG HID 的初始预设,后续可在设置中修改。', + otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键。', videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。', videoFormatHelp: 'MJPEG 格式兼容性最好,H.264/H.265 带宽占用更低但需要编码支持。', // Extensions @@ -584,10 +588,12 @@ export default { otgHidProfile: 'OTG HID 组合', otgHidProfileDesc: '选择对目标主机暴露的 HID 功能', profile: '组合', - otgProfileFull: '完整(键盘 + 相对鼠标 + 绝对鼠标 + 多媒体 + 虚拟媒体)', - otgProfileFullNoMsd: '完整(键盘 + 相对鼠标 + 绝对鼠标 + 多媒体,不含虚拟媒体)', - otgProfileLegacyKeyboard: '兼容:仅键盘', - otgProfileLegacyMouseRelative: '兼容:仅相对鼠标', + otgProfileFull: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体 + 虚拟媒体', + otgProfileFullNoMsd: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体(不含虚拟媒体)', + otgProfileFullNoConsumer: '键盘 + 相对鼠标 + 绝对鼠标 + 虚拟媒体(不含多媒体)', + otgProfileFullNoConsumerNoMsd: '键盘 + 相对鼠标 + 绝对鼠标(不含多媒体与虚拟媒体)', + otgProfileLegacyKeyboard: '仅键盘', + otgProfileLegacyMouseRelative: '仅相对鼠标', otgProfileCustom: '自定义', otgFunctionKeyboard: '键盘', otgFunctionKeyboardDesc: '标准 HID 键盘设备', @@ -600,6 +606,7 @@ export default { otgFunctionMsd: '虚拟媒体(MSD)', otgFunctionMsdDesc: '向目标主机暴露 USB 存储', otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接', + otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键。', otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存', // OTG Descriptor otgDescriptor: 'USB 设备描述符', diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 45684943..08bdd3f8 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -95,6 +95,7 @@ export const useAuthStore = defineStore('auth', () => { hid_ch9329_port?: string hid_ch9329_baudrate?: number hid_otg_udc?: string + hid_otg_profile?: string encoder_backend?: string audio_device?: string ttyd_enabled?: boolean diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 1bb12b46..653123b9 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -60,6 +60,10 @@ export enum OtgHidProfile { Full = "full", /** Full HID device set without MSD */ FullNoMsd = "full_no_msd", + /** Full HID device set without consumer control */ + FullNoConsumer = "full_no_consumer", + /** Full HID device set without consumer control and MSD */ + FullNoConsumerNoMsd = "full_no_consumer_no_msd", /** Legacy profile: only keyboard */ LegacyKeyboard = "legacy_keyboard", /** Legacy profile: only relative mouse */ diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index a5254f18..3133ad89 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -220,12 +220,14 @@ interface DeviceConfig { }> serial: Array<{ path: string; name: string }> audio: Array<{ name: string; description: string }> + udc: Array<{ name: string }> } const devices = ref({ video: [], serial: [], audio: [], + udc: [], }) const config = ref({ @@ -237,6 +239,7 @@ const config = ref({ hid_backend: 'ch9329', hid_serial_device: '', hid_serial_baudrate: 9600, + hid_otg_udc: '', hid_otg_profile: 'full' as OtgHidProfile, hid_otg_functions: { keyboard: true, @@ -257,6 +260,39 @@ const config = ref({ // 跟踪服务器是否已配置 TURN 密码 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 +) + +function alignHidProfileForLowEndpoint() { + if (hidProfileAligned.value) return + if (!configLoaded.value || !devicesLoaded.value) return + if (config.value.hid_backend !== 'otg') { + hidProfileAligned.value = true + return + } + if (!isLowEndpointUdc.value) { + hidProfileAligned.value = true + return + } + if (config.value.hid_otg_profile === 'full') { + config.value.hid_otg_profile = 'full_no_consumer' as OtgHidProfile + } else if (config.value.hid_otg_profile === 'full_no_msd') { + config.value.hid_otg_profile = 'full_no_consumer_no_msd' as OtgHidProfile + } + hidProfileAligned.value = true +} const isHidFunctionSelectionValid = computed(() => { if (config.value.hid_backend !== 'otg') return true @@ -550,6 +586,10 @@ async function saveConfig() { 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' @@ -624,6 +664,7 @@ async function loadConfig() { hid_backend: hid.backend || 'none', hid_serial_device: hid.ch9329_port || '', hid_serial_baudrate: hid.ch9329_baudrate || 9600, + hid_otg_udc: hid.otg_udc || '', hid_otg_profile: (hid.otg_profile || 'full') as OtgHidProfile, hid_otg_functions: { keyboard: hid.otg_functions?.keyboard ?? true, @@ -664,6 +705,9 @@ async function loadConfig() { } } catch (e) { console.error('Failed to load config:', e) + } finally { + configLoaded.value = true + alignHidProfileForLowEndpoint() } } @@ -672,6 +716,9 @@ async function loadDevices() { devices.value = await configApi.listDevices() } catch (e) { console.error('Failed to load devices:', e) + } finally { + devicesLoaded.value = true + alignHidProfileForLowEndpoint() } } @@ -1492,6 +1539,8 @@ onMounted(async () => { + + + + + {{ t('settings.otgProfileFull') }} + {{ t('settings.otgProfileFullNoMsd') }} + {{ t('settings.otgProfileFullNoConsumer') }} + {{ t('settings.otgProfileFullNoConsumerNoMsd') }} + {{ t('settings.otgProfileLegacyKeyboard') }} + {{ t('settings.otgProfileLegacyMouseRelative') }} + + + +

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

+ +