feat: 完善 USB UVC 设备异常处理,添加 USB 设备复位功能

This commit is contained in:
mofeng-git
2026-04-27 16:37:04 +08:00
parent 9065e01225
commit 07b982d1d2
14 changed files with 631 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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