mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-30 17:41:54 +08:00
fix: 修复适配全志平台 OTG 低端点情况
This commit is contained in:
@@ -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(),
|
||||
|
||||
15
src/main.rs
15
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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
566
test/bench_kvm.py
Normal 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())
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 设备描述符',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user