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('settings.usbDevices.colDevice') }} |
+ VID:PID |
+ {{ t('settings.usbDevices.colSpeed') }} |
+ {{ t('settings.usbDevices.colVideo') }} |
+ {{ t('settings.usbDevices.colAction') }} |
+
+
+
+
+ |
+ {{ dev.product || dev.manufacturer || `${dev.id_vendor}:${dev.id_product}` }}
+ |
+ {{ dev.id_vendor }}:{{ dev.id_product }} |
+ {{ usbSpeedLabel(dev.speed) }} |
+
+ {{ dev.video_device }}
+ -
+ |
+
+
+ |
+
+
+
+
+
+
+ {{ 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') }}
+
+
+
+
+