fix: 修复适配全志平台 OTG 低端点情况

This commit is contained in:
mofeng
2026-01-29 20:00:40 +08:00
parent 2938af32a9
commit 9cb0dd146e
14 changed files with 916 additions and 13 deletions

View File

@@ -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(),

View File

@@ -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);
}

View File

@@ -43,6 +43,23 @@ pub fn find_udc() -> Option<String> {
.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<String> {
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

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -521,11 +521,39 @@ pub struct SetupRequest {
pub hid_ch9329_port: Option<String>,
pub hid_ch9329_baudrate: Option<u32>,
pub hid_otg_udc: Option<String>,
pub hid_otg_profile: Option<String>,
// Extension settings
pub ttyd_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(
State(state): State<Arc<AppState>>,
Json(req): Json<SetupRequest>,
@@ -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?;

566
test/bench_kvm.py Normal file
View File

@@ -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())

View File

@@ -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

View File

@@ -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',

View File

@@ -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 设备描述符',

View File

@@ -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

View File

@@ -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 */

View File

@@ -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<DeviceConfig>({
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 () => {
<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">
<option value="full">{{ t('settings.otgProfileFull') }}</option>
<option value="full_no_msd">{{ t('settings.otgProfileFullNoMsd') }}</option>
<option value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</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>
@@ -1541,6 +1590,9 @@ onMounted(async () => {
<p class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgProfileWarning') }}
</p>
<p v-if="showLowEndpointHint" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgLowEndpointHint') }}
</p>
</div>
<Separator class="my-4" />
<div class="space-y-4">

View File

@@ -96,6 +96,9 @@ const hidBackend = ref('ch9329')
const ch9329Port = ref('')
const ch9329Baudrate = ref(9600)
const otgUdc = ref('')
const hidOtgProfile = ref('full')
const otgProfileTouched = ref(false)
const showAdvancedOtg = ref(false)
// Extension settings
const ttydEnabled = ref(false)
@@ -200,6 +203,26 @@ const availableFps = computed(() => {
return resolution?.fps || []
})
const isLowEndpointUdc = computed(() => {
if (otgUdc.value) {
return /musb/i.test(otgUdc.value)
}
return devices.value.udc.some((udc) => /musb/i.test(udc.name))
})
function applyOtgProfileDefault() {
if (otgProfileTouched.value) return
if (hidBackend.value !== 'otg') return
const preferred = isLowEndpointUdc.value ? 'full_no_consumer' : 'full'
if (hidOtgProfile.value === preferred) return
hidOtgProfile.value = preferred
}
function onOtgProfileChange(value: unknown) {
hidOtgProfile.value = typeof value === 'string' ? value : 'full'
otgProfileTouched.value = true
}
// Common baud rates for CH9329
const baudRates = [9600, 19200, 38400, 57600, 115200]
@@ -315,6 +338,17 @@ watch(hidBackend, (newBackend) => {
if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) {
otgUdc.value = devices.value.udc[0]?.name || ''
}
applyOtgProfileDefault()
})
watch(otgUdc, () => {
applyOtgProfileDefault()
})
watch(showAdvancedOtg, (open) => {
if (open) {
applyOtgProfileDefault()
}
})
onMounted(async () => {
@@ -336,6 +370,7 @@ onMounted(async () => {
if (result.udc.length > 0 && result.udc[0]) {
otgUdc.value = result.udc[0].name
}
applyOtgProfileDefault()
// Auto-select audio device if available (and no video device to trigger watch)
if (result.audio.length > 0 && !audioDevice.value) {
@@ -487,6 +522,7 @@ async function handleSetup() {
}
if (hidBackend.value === 'otg' && otgUdc.value) {
setupData.hid_otg_udc = otgUdc.value
setupData.hid_otg_profile = hidOtgProfile.value
}
// Encoder backend setting
@@ -924,6 +960,46 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
{{ t('setup.noUdcDevices') }}
</p>
</div>
<div class="mt-2 border rounded-lg">
<button
type="button"
class="w-full flex items-center justify-between p-3 text-left hover:bg-muted/50 rounded-lg transition-colors"
@click="showAdvancedOtg = !showAdvancedOtg"
>
<span class="text-sm font-medium">
{{ t('setup.otgAdvanced') }} ({{ t('common.optional') }})
</span>
<ChevronRight
class="h-4 w-4 transition-transform duration-200"
:class="{ 'rotate-90': showAdvancedOtg }"
/>
</button>
<div v-if="showAdvancedOtg" class="px-3 pb-3 space-y-3">
<p class="text-xs text-muted-foreground">
{{ t('setup.otgProfileDesc') }}
</p>
<div class="space-y-2">
<Label for="otgProfile">{{ t('setup.otgProfile') }}</Label>
<Select :model-value="hidOtgProfile" @update:modelValue="onOtgProfileChange">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<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_no_msd">{{ t('settings.otgProfileFullNoConsumerNoMsd') }}</SelectItem>
<SelectItem value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</SelectItem>
<SelectItem value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</SelectItem>
</SelectContent>
</Select>
</div>
<p v-if="isLowEndpointUdc" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('setup.otgLowEndpointHint') }}
</p>
</div>
</div>
</div>
</div>