From 07b982d1d2c3002fb8e98500839aacfa80bf482c Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Mon, 27 Apr 2026 16:37:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20USB=20UVC=20?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E5=BC=82=E5=B8=B8=E5=A4=84=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20USB=20=E8=AE=BE=E5=A4=87=E5=A4=8D=E4=BD=8D?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.rs | 20 ++- src/video/mod.rs | 11 ++ src/video/shared_video_pipeline.rs | 52 ++++++-- src/video/stream_manager.rs | 40 ++++-- src/video/streamer.rs | 41 +++++- src/video/usb_reset.rs | 205 +++++++++++++++++++++++++++++ src/web/handlers/devices.rs | 35 +++++ src/web/routes.rs | 5 + src/webrtc/webrtc_streamer.rs | 9 +- web/src/api/index.ts | 24 ++++ web/src/i18n/en-US.ts | 26 ++++ web/src/i18n/zh-CN.ts | 26 ++++ web/src/views/ConsoleView.vue | 14 ++ web/src/views/SettingsView.vue | 156 +++++++++++++++++++++- 14 files changed, 631 insertions(+), 33 deletions(-) create mode 100644 src/video/usb_reset.rs diff --git a/src/main.rs b/src/main.rs index c31951fa..fd667d3b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -470,13 +470,25 @@ async fn main() -> anyhow::Result<()> { .update_video_config(actual_resolution, actual_format, actual_fps) .await; if let Some(device_path) = device_path { - let (subdev_path, bridge_kind) = streamer + let (subdev_path, bridge_kind, v4l2_driver) = streamer .current_device() .await - .map(|d| (d.subdev_path.clone(), d.bridge_kind.clone())) - .unwrap_or((None, None)); + .map(|d| { + ( + d.subdev_path.clone(), + d.bridge_kind.clone(), + Some(d.driver.clone()), + ) + }) + .unwrap_or((None, None, None)); webrtc_streamer - .set_capture_device(device_path, jpeg_quality, subdev_path, bridge_kind) + .set_capture_device( + device_path, + jpeg_quality, + subdev_path, + bridge_kind, + v4l2_driver, + ) .await; tracing::info!("WebRTC streamer configured for direct capture"); } else { diff --git a/src/video/mod.rs b/src/video/mod.rs index 5d2db7ad..55c5b846 100644 --- a/src/video/mod.rs +++ b/src/video/mod.rs @@ -13,6 +13,7 @@ pub mod frame; pub mod shared_video_pipeline; pub mod stream_manager; pub mod streamer; +pub mod usb_reset; pub mod v4l2r_capture; pub use convert::{PixelConverter, Yuv420pBuffer}; @@ -41,6 +42,10 @@ pub enum SignalStatus { OutOfRange, /// Generic "no usable source" (fallback for EINVAL / EIO / unknown errnos). NoSignal, + /// UVC/USB isochronous protocol error (common kernel: status -71 / userspace EPROTO). + UvcUsbError, + /// UVC capture stalled (repeated DQBUF timeouts; often cable, hub, or controller load). + UvcCaptureStall, } impl SignalStatus { @@ -50,6 +55,8 @@ impl SignalStatus { SignalStatus::NoSync => "no_sync", SignalStatus::OutOfRange => "out_of_range", SignalStatus::NoSignal => "no_signal", + SignalStatus::UvcUsbError => "uvc_usb_error", + SignalStatus::UvcCaptureStall => "uvc_capture_stall", } } @@ -59,6 +66,8 @@ impl SignalStatus { "no_sync" => SignalStatus::NoSync, "out_of_range" => SignalStatus::OutOfRange, "no_signal" => SignalStatus::NoSignal, + "uvc_usb_error" => SignalStatus::UvcUsbError, + "uvc_capture_stall" => SignalStatus::UvcCaptureStall, _ => return None, }) } @@ -71,6 +80,8 @@ impl From for streamer::StreamerState { SignalStatus::NoSync => streamer::StreamerState::NoSync, SignalStatus::OutOfRange => streamer::StreamerState::OutOfRange, SignalStatus::NoSignal => streamer::StreamerState::NoSignal, + SignalStatus::UvcUsbError => streamer::StreamerState::UvcUsbError, + SignalStatus::UvcCaptureStall => streamer::StreamerState::UvcCaptureStall, } } } diff --git a/src/video/shared_video_pipeline.rs b/src/video/shared_video_pipeline.rs index c2e61803..267b961c 100644 --- a/src/video/shared_video_pipeline.rs +++ b/src/video/shared_video_pipeline.rs @@ -415,6 +415,11 @@ impl SharedVideoPipeline { if !should_emit { return; } + tracing::debug!( + "Pipeline state notification: state={}, reason={:?}", + notification.state, + notification.reason + ); if let Some(notifier) = self.state_notifier.read().clone() { notifier(notification); } @@ -542,6 +547,7 @@ impl SharedVideoPipeline { _jpeg_quality: u8, subdev_path: Option, bridge_kind: Option, + _v4l2_driver: Option, ) -> Result<()> { if *self.running_rx.borrow() { warn!("Pipeline already running"); @@ -982,7 +988,6 @@ impl SharedVideoPipeline { let meta = match next_result { Ok(meta) => { consecutive_timeouts = 0; - pipeline.notify_state(PipelineStateNotification::streaming()); meta } Err(e) => { @@ -1100,6 +1105,12 @@ impl SharedVideoPipeline { closing stream for soft-restart", consecutive_timeouts ); + pipeline.notify_state( + PipelineStateNotification::no_signal( + SignalStatus::UvcCaptureStall, + Some(Duration::from_secs(2).as_millis() as u64), + ), + ); stream = None; continue; } @@ -1123,17 +1134,29 @@ impl SharedVideoPipeline { } } else { consecutive_timeouts = 0; - // EIO (5) / EPIPE (32) in next_into generally - // mean the source glitched mid-stream. - // Tear down the stream and let the open loop - // re-probe via DV_TIMINGS — same logic as - // timeouts, just triggered earlier. - if matches!(e.raw_os_error(), Some(5) | Some(32)) { - warn!( - "Capture transient error ({}), closing stream for \ - soft-restart", - e - ); + // EIO (5) / EPIPE (32) / EPROTO (71) in next_into generally + // mean the source or UVC USB transport glitched mid-stream. + // Tear down the stream and let the open loop re-probe. + let os = e.raw_os_error(); + if matches!(os, Some(5) | Some(32) | Some(71)) { + if os == Some(71) { + warn!( + "Capture transient error (EPROTO/-71, often UVC USB): {} — soft-restart", + e + ); + pipeline.notify_state( + PipelineStateNotification::no_signal( + SignalStatus::UvcUsbError, + Some(Duration::from_secs(2).as_millis() as u64), + ), + ); + } else { + warn!( + "Capture transient error ({}), closing stream for \ + soft-restart", + e + ); + } stream = None; continue; } @@ -1172,6 +1195,11 @@ impl SharedVideoPipeline { } owned.truncate(frame_size); + // Notify streaming only after frame validation passes — + // stale/warm-up frames from V4L2 kernel queues can cause + // DQBUF Ok with invalid data, which would prematurely + // clear the frontend error overlay. + pipeline.notify_state(PipelineStateNotification::streaming()); let frame = Arc::new(VideoFrame::from_pooled( Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))), resolution, diff --git a/src/video/stream_manager.rs b/src/video/stream_manager.rs index 4bfc8bdd..ff0f5ed5 100644 --- a/src/video/stream_manager.rs +++ b/src/video/stream_manager.rs @@ -356,14 +356,26 @@ impl VideoStreamManager { // Resolve the paired subdev so the WebRTC pipeline can run the // RK628 STREAMON gate + SOURCE_CHANGE polling identically to the // MJPEG path. See `csi_bridge::discover_subdev_for_video`. - let (subdev_path, bridge_kind) = self + let (subdev_path, bridge_kind, v4l2_driver) = self .streamer .current_device() .await - .map(|d| (d.subdev_path.clone(), d.bridge_kind.clone())) - .unwrap_or((None, None)); + .map(|d| { + ( + d.subdev_path.clone(), + d.bridge_kind.clone(), + Some(d.driver.clone()), + ) + }) + .unwrap_or((None, None, None)); self.webrtc_streamer - .set_capture_device(device_path, jpeg_quality, subdev_path, bridge_kind) + .set_capture_device( + device_path, + jpeg_quality, + subdev_path, + bridge_kind, + v4l2_driver, + ) .await; } else { warn!("No capture device configured while syncing WebRTC capture source"); @@ -559,14 +571,26 @@ impl VideoStreamManager { } if let Some(device_path) = device_path { info!("Configuring direct capture for WebRTC after config change"); - let (subdev_path, bridge_kind) = self + let (subdev_path, bridge_kind, v4l2_driver) = self .streamer .current_device() .await - .map(|d| (d.subdev_path.clone(), d.bridge_kind.clone())) - .unwrap_or((None, None)); + .map(|d| { + ( + d.subdev_path.clone(), + d.bridge_kind.clone(), + Some(d.driver.clone()), + ) + }) + .unwrap_or((None, None, None)); self.webrtc_streamer - .set_capture_device(device_path, jpeg_quality, subdev_path, bridge_kind) + .set_capture_device( + device_path, + jpeg_quality, + subdev_path, + bridge_kind, + v4l2_driver, + ) .await; } else { warn!("No capture device configured for WebRTC after config change"); diff --git a/src/video/streamer.rs b/src/video/streamer.rs index 182a4a77..8c78767d 100644 --- a/src/video/streamer.rs +++ b/src/video/streamer.rs @@ -71,6 +71,10 @@ pub enum StreamerState { NoSync, /// Source timings are outside of what the capture hardware supports (ERANGE) OutOfRange, + /// UVC/USB isochronous protocol error (kernel EPROTO/-71) + UvcUsbError, + /// UVC capture stalled (repeated DQBUF timeouts) + UvcCaptureStall, /// Error occurred Error, /// Device was lost (unplugged) @@ -90,6 +94,8 @@ impl StreamerState { StreamerState::NoCable => "no_cable", StreamerState::NoSync => "no_sync", StreamerState::OutOfRange => "out_of_range", + StreamerState::UvcUsbError => "uvc_usb_error", + StreamerState::UvcCaptureStall => "uvc_capture_stall", StreamerState::Error => "error", StreamerState::DeviceLost => "device_lost", StreamerState::Recovering => "recovering", @@ -107,6 +113,8 @@ impl StreamerState { "no_cable" => StreamerState::NoCable, "no_sync" => StreamerState::NoSync, "out_of_range" => StreamerState::OutOfRange, + "uvc_usb_error" => StreamerState::UvcUsbError, + "uvc_capture_stall" => StreamerState::UvcCaptureStall, "error" => StreamerState::Error, "device_lost" => StreamerState::DeviceLost, "recovering" => StreamerState::Recovering, @@ -122,6 +130,8 @@ impl StreamerState { | StreamerState::NoCable | StreamerState::NoSync | StreamerState::OutOfRange + | StreamerState::UvcUsbError + | StreamerState::UvcCaptureStall ) } @@ -135,6 +145,8 @@ impl StreamerState { StreamerState::NoCable => ("no_signal", Some("no_cable")), StreamerState::NoSync => ("no_signal", Some("no_sync")), StreamerState::OutOfRange => ("no_signal", Some("out_of_range")), + StreamerState::UvcUsbError => ("no_signal", Some("uvc_usb_error")), + StreamerState::UvcCaptureStall => ("no_signal", Some("uvc_capture_stall")), StreamerState::DeviceLost => ("device_lost", Some("device_lost")), StreamerState::Recovering => ("device_lost", Some("recovering")), StreamerState::Busy => ("device_busy", None), @@ -1076,7 +1088,8 @@ impl Streamer { // in ~1 s instead of the 1 s recovery-poll loop. let os_err = e.raw_os_error(); let is_device_lost = matches!(os_err, Some(6) | Some(19) | Some(108)); - let is_transient_signal_error = matches!(os_err, Some(5) | Some(32)); + let is_transient_signal_error = + matches!(os_err, Some(5) | Some(32) | Some(71)); if is_device_lost { error!("Video device lost: {} - {}", device_path.display(), e); @@ -1098,10 +1111,28 @@ impl Streamer { } if is_transient_signal_error { - warn!( - "Capture transient error ({}): treating as NoSignal + soft-restart", - e - ); + if os_err == Some(71) { + warn!( + "Capture transient error (EPROTO/-71, often UVC USB): {}", + e + ); + let is_uvc = handle.block_on(async { + self.current_device.read().await.as_ref().is_some_and( + |d| d.driver.eq_ignore_ascii_case("uvcvideo"), + ) + }); + if is_uvc { + go_offline(); + set_state(StreamerState::UvcUsbError); + need_soft_restart = true; + break 'capture; + } + } else { + warn!( + "Capture transient error ({}): treating as NoSignal + soft-restart", + e + ); + } set_retry( backoff_secs(no_signal_restart_count).saturating_mul(1000), ); diff --git a/src/video/usb_reset.rs b/src/video/usb_reset.rs new file mode 100644 index 00000000..d2dc9443 --- /dev/null +++ b/src/video/usb_reset.rs @@ -0,0 +1,205 @@ +//! USB device enumeration and reset via sysfs `authorized`. +//! +//! Provides APIs for the settings page to list and reset USB devices. +//! Requires write access to `/sys/bus/usb/devices/.../authorized` (typically root). + +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +/// Walk up from a V4L sysfs `device` link until we find a USB device node +/// (`busnum` + `devnum` present). +fn usb_device_dir_for_v4l_sysfs(device_link: &Path) -> io::Result { + let mut p = device_link.canonicalize()?; + loop { + if p.join("busnum").is_file() && p.join("devnum").is_file() { + return Ok(p); + } + p = p + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no USB parent in sysfs"))? + .to_path_buf(); + if p.as_os_str().is_empty() || p == Path::new("/") { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "reached sysfs root without USB device", + )); + } + } +} + +// --------------------------------------------------------------------------- +// USB device enumeration & reset-by-bus/dev (for the settings API) +// --------------------------------------------------------------------------- + +use serde::Serialize; + +/// Information about a single USB device, read from `/sys/bus/usb/devices/`. +#[derive(Debug, Serialize)] +pub struct UsbDeviceInfo { + /// USB bus number (`busnum` sysfs attribute). + pub bus_num: u32, + /// USB device number on the bus (`devnum` sysfs attribute). + pub dev_num: u32, + /// Vendor ID hex string, e.g. `"1d6b"`. + pub id_vendor: String, + /// Product ID hex string, e.g. `"0002"`. + pub id_product: String, + /// Product name from sysfs `product`. + #[serde(skip_serializing_if = "Option::is_none")] + pub product: Option, + /// Manufacturer name from sysfs `manufacturer`. + #[serde(skip_serializing_if = "Option::is_none")] + pub manufacturer: Option, + /// Speed in Mbps from sysfs `speed`, e.g. `"480"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub speed: Option, + /// `true` if authorized=1, `false` if authorized=0, `None` if no file. + #[serde(skip_serializing_if = "Option::is_none")] + pub authorized: Option, + /// Kernel driver bound to this device (from driver symlink). + #[serde(skip_serializing_if = "Option::is_none")] + pub driver: Option, + /// Associated `/dev/videoN` node, if this USB device has a V4L2 child. + #[serde(skip_serializing_if = "Option::is_none")] + pub video_device: Option, +} + +/// Read a sysfs string attribute, trimming trailing newline. +fn read_sysfs_str(dir: &Path, attr: &str) -> Option { + std::fs::read_to_string(dir.join(attr)) + .ok() + .map(|s| s.trim_end().to_string()) +} + +/// Read a sysfs u32 attribute. +fn read_sysfs_u32(dir: &Path, attr: &str) -> Option { + read_sysfs_str(dir, attr).and_then(|s| s.parse().ok()) +} + +/// Build a map from USB sysfs dir → video device name by scanning +/// `/sys/class/video4linux/`. +fn build_usb_to_video_map() -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + let v4l_class = Path::new("/sys/class/video4linux"); + let entries = match std::fs::read_dir(v4l_class) { + Ok(e) => e, + Err(_) => return map, + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = match name.to_str() { + Some(s) if s.starts_with("video") => s, + _ => continue, + }; + // Resolve the device symlink and walk up to find the USB parent + let device_link = v4l_class.join(name_str).join("device"); + if let Ok(usb_dir) = usb_device_dir_for_v4l_sysfs(&device_link) { + if let Some(key) = usb_dir.file_name().and_then(|k| k.to_str()) { + map.insert(key.to_string(), format!("/dev/{}", name_str)); + } + } + } + map +} + +/// List all USB devices visible in `/sys/bus/usb/devices/`. +pub fn list_usb_devices() -> Vec { + let usb_bus = Path::new("/sys/bus/usb/devices"); + let entries = match std::fs::read_dir(usb_bus) { + Ok(e) => e, + Err(_) => return vec![], + }; + + let video_map = build_usb_to_video_map(); + + let mut devices: Vec = entries + .flatten() + .filter_map(|entry| { + let dir = entry.path(); + // Only consider entries that have busnum + devnum (actual devices, not interfaces) + let bus_num = read_sysfs_u32(&dir, "busnum")?; + let dev_num = read_sysfs_u32(&dir, "devnum")?; + + let id_vendor = read_sysfs_str(&dir, "idVendor").unwrap_or_default(); + let id_product = read_sysfs_str(&dir, "idProduct").unwrap_or_default(); + + let product = read_sysfs_str(&dir, "product"); + let manufacturer = read_sysfs_str(&dir, "manufacturer"); + let speed = read_sysfs_str(&dir, "speed"); + + let authorized = if dir.join("authorized").exists() { + read_sysfs_str(&dir, "authorized") + .and_then(|s| s.trim().parse::().ok()) + .map(|v| v != 0) + } else { + None + }; + + let driver = std::fs::read_link(dir.join("driver")) + .ok() + .and_then(|p| p.file_name().map(|f| f.to_string_lossy().to_string())); + + let dir_name = dir.file_name()?.to_str()?.to_string(); + let video_device = video_map.get(&dir_name).cloned(); + + Some(UsbDeviceInfo { + bus_num, + dev_num, + id_vendor, + id_product, + product, + manufacturer, + speed, + authorized, + driver, + video_device, + }) + }) + .collect(); + + // Sort by bus, then device number for stable ordering. + devices.sort_by(|a, b| (a.bus_num, a.dev_num).cmp(&(b.bus_num, b.dev_num))); + devices +} + +/// Reset a USB device identified by bus/dev numbers via the `authorized` sysfs +/// attribute. After re-authorizing, waits for the device to reappear. +pub fn reset_usb_device(bus_num: u32, dev_num: u32) -> io::Result<()> { + let usb_bus = Path::new("/sys/bus/usb/devices"); + let entries = std::fs::read_dir(usb_bus)?; + + for entry in entries.flatten() { + let dir = entry.path(); + if read_sysfs_u32(&dir, "busnum") != Some(bus_num) + || read_sysfs_u32(&dir, "devnum") != Some(dev_num) + { + continue; + } + let authorized = dir.join("authorized"); + if !authorized.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("device {bus_num}-{dev_num} has no authorized attribute"), + )); + } + std::fs::write(&authorized, b"0")?; + std::thread::sleep(Duration::from_millis(300)); + std::fs::write(&authorized, b"1")?; + + // Wait for device to reappear + let wait_until = Instant::now() + Duration::from_secs(2); + while !dir.join("busnum").exists() { + if Instant::now() >= wait_until { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + return Ok(()); + } + + Err(io::Error::new( + io::ErrorKind::NotFound, + format!("USB device {bus_num}-{dev_num} not found in sysfs"), + )) +} diff --git a/src/web/handlers/devices.rs b/src/web/handlers/devices.rs index ba91f6ad..af5bdaab 100644 --- a/src/web/handlers/devices.rs +++ b/src/web/handlers/devices.rs @@ -3,8 +3,11 @@ //! Provides API endpoints for discovering available hardware devices. use axum::Json; +use serde::Deserialize; use crate::atx::{discover_devices, AtxDevices}; +use crate::error::{AppError, Result}; +use crate::video::usb_reset; /// GET /api/devices/atx - List available ATX devices /// @@ -12,3 +15,35 @@ use crate::atx::{discover_devices, AtxDevices}; pub async fn list_atx_devices() -> Json { Json(discover_devices()) } + +/// GET /api/devices/usb - List all USB devices +/// +/// Enumerates USB devices from `/sys/bus/usb/devices/` with associated +/// video device mappings. +pub async fn list_usb_devices() -> Json> { + Json(usb_reset::list_usb_devices()) +} + +#[derive(Deserialize)] +pub struct UsbResetRequest { + pub bus_num: u32, + pub dev_num: u32, +} + +/// POST /api/devices/usb/reset - Reset a USB device via authorized cycle +/// +/// Writes `0` then `1` to the device's `authorized` sysfs attribute, +/// causing the kernel to deauthorize and re-authorize the device. +/// Requires root or write access to sysfs. +pub async fn reset_usb_device(Json(req): Json) -> Result> { + usb_reset::reset_usb_device(req.bus_num, req.dev_num).map_err(|e| { + AppError::VideoError(format!( + "USB reset failed for device {}-{}: {}", + req.bus_num, req.dev_num, e + )) + })?; + Ok(Json(serde_json::json!({ + "success": true, + "message": format!("USB device {}-{} reset successfully", req.bus_num, req.dev_num) + }))) +} diff --git a/src/web/routes.rs b/src/web/routes.rs index 8308d787..ec4ffae7 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -177,6 +177,11 @@ pub fn create_router(state: Arc) -> Router { .route("/atx/wol/history", get(handlers::atx_wol_history)) // Device discovery endpoints .route("/devices/atx", get(handlers::devices::list_atx_devices)) + .route("/devices/usb", get(handlers::devices::list_usb_devices)) + .route( + "/devices/usb/reset", + post(handlers::devices::reset_usb_device), + ) // Extension management endpoints .route("/extensions", get(handlers::extensions::list_extensions)) .route("/extensions/{id}", get(handlers::extensions::get_extension)) diff --git a/src/webrtc/webrtc_streamer.rs b/src/webrtc/webrtc_streamer.rs index e4a34958..0f0fedc2 100644 --- a/src/webrtc/webrtc_streamer.rs +++ b/src/webrtc/webrtc_streamer.rs @@ -96,6 +96,8 @@ pub struct CaptureDeviceConfig { pub jpeg_quality: u8, pub subdev_path: Option, pub bridge_kind: Option, + /// V4L2 driver name (e.g. `uvcvideo`) for UVC-specific recovery hints. + pub v4l2_driver: Option, } /// WebRTC streamer statistics @@ -382,6 +384,7 @@ impl WebRtcStreamer { device.jpeg_quality, device.subdev_path, device.bridge_kind, + device.v4l2_driver, ) .await?; } else { @@ -575,10 +578,11 @@ impl WebRtcStreamer { jpeg_quality: u8, subdev_path: Option, bridge_kind: Option, + v4l2_driver: Option, ) { info!( - "Setting direct capture device for WebRTC: {:?} (subdev={:?}, kind={:?})", - device_path, subdev_path, bridge_kind + "Setting direct capture device for WebRTC: {:?} (subdev={:?}, kind={:?}, driver={:?})", + device_path, subdev_path, bridge_kind, v4l2_driver ); *self.capture_device.write().await = Some(CaptureDeviceConfig { device_path, @@ -586,6 +590,7 @@ impl WebRtcStreamer { jpeg_quality, subdev_path, bridge_kind, + v4l2_driver, }); } diff --git a/web/src/api/index.ts b/web/src/api/index.ts index bd8a01a9..776876d0 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -765,4 +765,28 @@ export const audioApi = { }), } +// USB API +export interface UsbDeviceInfo { + bus_num: number + dev_num: number + id_vendor: string + id_product: string + product?: string + manufacturer?: string + speed?: string + authorized?: boolean + video_device?: string +} + +export const usbApi = { + listDevices: () => + request('/devices/usb'), + + resetDevice: (busNum: number, devNum: number) => + request<{ success: boolean; message: string }>('/devices/usb/reset', { + method: 'POST', + body: JSON.stringify({ bus_num: busNum, dev_num: devNum }), + }), +} + export { ApiError } diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index b8b659ed..142ddf4e 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -328,6 +328,14 @@ export default { title: 'Video channel busy', detail: 'Applying a new configuration or another component is using the device, please wait…', }, + uvc_usb_error: { + title: 'USB capture transport error', + detail: 'The USB capture device encountered a protocol error (EPROTO). You can try resetting the device from Settings → Environment → USB Devices.', + }, + uvc_capture_stall: { + title: 'UVC capture stalled', + detail: 'Check the device connection. If already connected, try changing the capture format and resetting the device.', + }, reason: { no_cable: 'HDMI cable not detected — check the cable and that the target is powered on', no_sync: 'Unstable signal: timings could not be locked — try a lower resolution or refresh rate', @@ -337,6 +345,9 @@ export default { device_lost: 'Video node disappeared, waiting for the driver to recover', config_changing: 'Applying new configuration', mode_switching: 'Switching video mode', + uvc_usb_error: + 'Try another USB port or cable, avoid hubs, or reconnect the device. You can also reset the device from Settings → Environment → USB Devices.', + uvc_capture_stall: '', }, }, // WebRTC @@ -836,6 +847,21 @@ export default { currentHardwareEncoder: 'Current Hardware Encoder', none: 'None', }, + usbDevices: { + title: 'USB Devices', + desc: 'View connected USB devices and reset them to recover from errors', + refresh: 'Refresh', + loadFailed: 'Failed to load USB device list', + noDevices: 'No USB devices found', + colDevice: 'Device', + colSpeed: 'Speed', + colVideo: 'Video', + colAction: 'Action', + reset: 'Reset', + resetConfirmTitle: 'Confirm USB Device Reset', + resetConfirmDesc: 'This will reset USB device "{device}" by cycling its authorized attribute. All connections to this device will be temporarily interrupted. Continue?', + resetAction: 'Reset Device', + }, // WebRTC / ICE webrtcSettings: 'WebRTC Settings', webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 1f15baf5..5a3cf120 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -327,6 +327,14 @@ export default { title: '视频通道忙', detail: '正在切换配置或被其他组件占用,请稍候…', }, + uvc_usb_error: { + title: 'USB 采集传输异常', + detail: 'USB 采集卡遇到协议错误(EPROTO),可在 设置 → 环境 → USB 设备 中尝试复位。', + }, + uvc_capture_stall: { + title: 'UVC 采集超时', + detail: '检查设备连接,若设备已连接,可尝试修改采集格式并复位设备。', + }, reason: { no_cable: '未检测到 HDMI 线缆,请检查连接或被控机是否已开机', no_sync: '信号不稳定,无法锁定时序,可尝试降低被控机分辨率/刷新率', @@ -336,6 +344,9 @@ export default { device_lost: '视频节点丢失,等待驱动恢复', config_changing: '正在应用新配置', mode_switching: '正在切换视频模式', + uvc_usb_error: + '可尝试更换 USB 口或线、避免 HUB、或重新插拔设备;也可在 设置 → 环境 → USB 设备 中复位。', + uvc_capture_stall: '', }, }, // WebRTC @@ -835,6 +846,21 @@ export default { currentHardwareEncoder: '当前硬件编码器', none: '无', }, + usbDevices: { + title: 'USB 设备', + desc: '查看已连接的 USB 设备,可通过复位恢复异常设备', + refresh: '刷新', + loadFailed: '加载 USB 设备列表失败', + noDevices: '未发现 USB 设备', + colDevice: '设备', + colSpeed: '速度', + colVideo: '视频', + colAction: '操作', + reset: '复位', + resetConfirmTitle: '确认复位 USB 设备', + resetConfirmDesc: '将通过 authorized 属性复位 USB 设备「{device}」,该设备上的所有连接将短暂中断。确定继续?', + resetAction: '确认复位', + }, // WebRTC / ICE webrtcSettings: 'WebRTC 设置', webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透', diff --git a/web/src/views/ConsoleView.vue b/web/src/views/ConsoleView.vue index 1ddb2f0a..ac2f2583 100644 --- a/web/src/views/ConsoleView.vue +++ b/web/src/views/ConsoleView.vue @@ -1102,6 +1102,20 @@ const signalOverlayInfo = computed(() => { const reasonHintKey = reason ? `console.signal.reason.${reason}` : '' const hint = reasonHintKey && te(reasonHintKey) ? t(reasonHintKey) : '' + // UVC-specific overlay when we have the detailed reason + if (streamSignalState.value === 'no_signal' && reason) { + const titleKey = `console.signal.${reason}.title` + const detailKey = `console.signal.${reason}.detail` + if (te(titleKey) && te(detailKey)) { + return { + title: t(titleKey), + detail: t(detailKey), + hint, + tone: 'info' as const, + } + } + } + switch (streamSignalState.value) { case 'no_signal': return { diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 2e8f5bff..3a399a52 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -14,6 +14,7 @@ import { extensionsApi, systemApi, updateApi, + usbApi, type EncoderBackendInfo, type AuthConfig, type RustDeskConfigResponse, @@ -59,6 +60,16 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { Monitor, Keyboard, @@ -647,6 +658,52 @@ async function onRunVideoEncoderSelfCheckClick() { await runVideoEncoderSelfCheck() } +// USB devices state +const usbDevices = ref([]) +const usbDevicesLoading = ref(false) +const usbDevicesError = ref('') +const usbResetTarget = ref(null) +const usbResetLoading = ref(false) + +async function fetchUsbDevices() { + usbDevicesLoading.value = true + usbDevicesError.value = '' + try { + usbDevices.value = await usbApi.listDevices() + } catch { + usbDevicesError.value = t('settings.usbDevices.loadFailed') + } finally { + usbDevicesLoading.value = false + } +} + +async function confirmUsbReset() { + if (!usbResetTarget.value) return + usbResetLoading.value = true + try { + await usbApi.resetDevice(usbResetTarget.value.bus_num, usbResetTarget.value.dev_num) + } catch { + // Error already shown by request helper toast + } finally { + usbResetLoading.value = false + usbResetTarget.value = null + // Refresh the list after a short delay for USB re-enumeration + setTimeout(() => fetchUsbDevices(), 1500) + } +} + +function usbSpeedLabel(speed?: string): string { + if (!speed) return '-' + const map: Record = { + '1.5': '1.5 Mbps', + '12': '12 Mbps', + '480': '480 Mbps', + '5000': '5 Gbps', + '10000': '10 Gbps', + } + return map[speed] || `${speed} Mbps` +} + function defaultOtgEndpointBudgetForUdc(udc?: string): OtgEndpointBudget { return /musb/i.test(udc || '') ? 'five' as OtgEndpointBudget : 'six' as OtgEndpointBudget } @@ -2024,6 +2081,7 @@ onMounted(async () => { loadWebServerConfig(), loadUpdateOverview(), refreshUpdateStatus(), + fetchUsbDevices(), ]) usernameInput.value = authStore.user || '' @@ -2742,9 +2800,103 @@ watch(() => route.query.tab, (tab) => {

- - + + +
+ {{ t('settings.usbDevices.title') }} + {{ t('settings.usbDevices.desc') }} +
+ +
+ +

+ {{ usbDevicesError }} +

+ + +

+ {{ t('common.loading') }} +

+

+ {{ t('settings.usbDevices.noDevices') }} +

+
+
+ + + + + + {{ t('settings.usbDevices.resetConfirmTitle') }} + + {{ t('settings.usbDevices.resetConfirmDesc', { device: usbResetTarget?.product || `${usbResetTarget?.id_vendor}:${usbResetTarget?.id_product}` }) }} + + + + {{ t('common.cancel') }} + + {{ t('settings.usbDevices.resetAction') }} + + + + +