mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-04-29 17:36:35 +08:00
feat: 完善 USB UVC 设备异常处理,添加 USB 设备复位功能
This commit is contained in:
20
src/main.rs
20
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 {
|
||||
|
||||
@@ -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<SignalStatus> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<std::path::PathBuf>,
|
||||
bridge_kind: Option<String>,
|
||||
_v4l2_driver: Option<String>,
|
||||
) -> 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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
205
src/video/usb_reset.rs
Normal file
205
src/video/usb_reset.rs
Normal file
@@ -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<PathBuf> {
|
||||
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<String>,
|
||||
/// Manufacturer name from sysfs `manufacturer`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manufacturer: Option<String>,
|
||||
/// Speed in Mbps from sysfs `speed`, e.g. `"480"`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub speed: Option<String>,
|
||||
/// `true` if authorized=1, `false` if authorized=0, `None` if no file.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub authorized: Option<bool>,
|
||||
/// Kernel driver bound to this device (from driver symlink).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub driver: Option<String>,
|
||||
/// Associated `/dev/videoN` node, if this USB device has a V4L2 child.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub video_device: Option<String>,
|
||||
}
|
||||
|
||||
/// Read a sysfs string attribute, trimming trailing newline.
|
||||
fn read_sysfs_str(dir: &Path, attr: &str) -> Option<String> {
|
||||
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<u32> {
|
||||
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<String, String> {
|
||||
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<UsbDeviceInfo> {
|
||||
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<UsbDeviceInfo> = 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::<u8>().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"),
|
||||
))
|
||||
}
|
||||
@@ -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<AtxDevices> {
|
||||
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<Vec<usb_reset::UsbDeviceInfo>> {
|
||||
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<UsbResetRequest>) -> Result<Json<serde_json::Value>> {
|
||||
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)
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -177,6 +177,11 @@ pub fn create_router(state: Arc<AppState>) -> 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))
|
||||
|
||||
@@ -96,6 +96,8 @@ pub struct CaptureDeviceConfig {
|
||||
pub jpeg_quality: u8,
|
||||
pub subdev_path: Option<PathBuf>,
|
||||
pub bridge_kind: Option<String>,
|
||||
/// V4L2 driver name (e.g. `uvcvideo`) for UVC-specific recovery hints.
|
||||
pub v4l2_driver: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf>,
|
||||
bridge_kind: Option<String>,
|
||||
v4l2_driver: Option<String>,
|
||||
) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UsbDeviceInfo[]>('/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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 穿透',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<import('@/api').UsbDeviceInfo[]>([])
|
||||
const usbDevicesLoading = ref(false)
|
||||
const usbDevicesError = ref('')
|
||||
const usbResetTarget = ref<import('@/api').UsbDeviceInfo | null>(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<string, string> = {
|
||||
'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) => {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Network Section -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-start justify-between space-y-0">
|
||||
<div class="space-y-1.5">
|
||||
<CardTitle>{{ t('settings.usbDevices.title') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.usbDevices.desc') }}</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="usbDevicesLoading"
|
||||
@click="fetchUsbDevices()"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4 mr-2" :class="{ 'animate-spin': usbDevicesLoading }" />
|
||||
{{ t('settings.usbDevices.refresh') }}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<p v-if="usbDevicesError" class="text-xs text-red-600 dark:text-red-400">
|
||||
{{ usbDevicesError }}
|
||||
</p>
|
||||
|
||||
<template v-if="usbDevices.length > 0">
|
||||
<div class="rounded-md border overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[540px]">
|
||||
<thead>
|
||||
<tr class="border-b bg-muted/40">
|
||||
<th class="px-3 py-2 text-left font-medium">{{ t('settings.usbDevices.colDevice') }}</th>
|
||||
<th class="px-3 py-2 text-left font-medium">VID:PID</th>
|
||||
<th class="px-3 py-2 text-left font-medium">{{ t('settings.usbDevices.colSpeed') }}</th>
|
||||
<th class="px-3 py-2 text-left font-medium">{{ t('settings.usbDevices.colVideo') }}</th>
|
||||
<th class="px-3 py-2 text-right font-medium">{{ t('settings.usbDevices.colAction') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="dev in usbDevices"
|
||||
:key="`${dev.bus_num}-${dev.dev_num}`"
|
||||
class="border-b last:border-b-0 hover:bg-muted/20"
|
||||
>
|
||||
<td class="px-3 py-2">
|
||||
<div class="font-medium truncate max-w-[180px]" :title="dev.product || dev.manufacturer || `${dev.id_vendor}:${dev.id_product}`">{{ dev.product || dev.manufacturer || `${dev.id_vendor}:${dev.id_product}` }}</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-xs">{{ dev.id_vendor }}:{{ dev.id_product }}</td>
|
||||
<td class="px-3 py-2 text-xs">{{ usbSpeedLabel(dev.speed) }}</td>
|
||||
<td class="px-3 py-2 text-xs">
|
||||
<code v-if="dev.video_device">{{ dev.video_device }}</code>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<Button
|
||||
v-if="dev.authorized != null"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs bg-black text-white border-black hover:bg-black/90 hover:text-white dark:bg-white dark:text-black dark:border-white dark:hover:bg-white/90 dark:hover:text-black"
|
||||
:disabled="usbResetLoading"
|
||||
@click="usbResetTarget = dev"
|
||||
>
|
||||
{{ t('settings.usbDevices.reset') }}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else-if="usbDevicesLoading" class="text-xs text-muted-foreground">
|
||||
{{ t('common.loading') }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted-foreground">
|
||||
{{ t('settings.usbDevices.noDevices') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- USB Reset Confirmation Dialog -->
|
||||
<AlertDialog :open="usbResetTarget != null">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ t('settings.usbDevices.resetConfirmTitle') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{{ t('settings.usbDevices.resetConfirmDesc', { device: usbResetTarget?.product || `${usbResetTarget?.id_vendor}:${usbResetTarget?.id_product}` }) }}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel @click="usbResetTarget = null">{{ t('common.cancel') }}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
:disabled="usbResetLoading"
|
||||
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
@click="confirmUsbReset()"
|
||||
>
|
||||
{{ t('settings.usbDevices.resetAction') }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<div v-show="activeSection === 'network'" class="space-y-6">
|
||||
|
||||
<!-- Auto-restart: restarting progress -->
|
||||
|
||||
Reference in New Issue
Block a user