From 486db7b4aa381629cf5a07354dc8448f21775802 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Fri, 20 Feb 2026 20:30:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(hid):=20=E5=A2=9E=E5=8A=A0=20HID=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E4=B8=8E?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=A0=81=E4=B8=8A=E6=8A=A5=EF=BC=8C=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=89=8D=E7=AB=AF=E6=8E=89=E7=BA=BF=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=90=8C=E6=AD=A5=E5=8F=8A=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/atx/executor.rs | 7 +- src/hid/backend.rs | 7 ++ src/hid/ch9329.rs | 83 ++++++++++++++++++++--- src/hid/mod.rs | 89 +++++++++++++++++++++++++ src/hid/otg.rs | 24 +++++++ web/src/composables/useConsoleEvents.ts | 82 ++++++++++++++++++++--- web/src/i18n/en-US.ts | 12 ++++ web/src/i18n/zh-CN.ts | 12 ++++ web/src/stores/system.ts | 27 ++++++++ web/src/views/ConsoleView.vue | 59 +++++++++++++++- 10 files changed, 378 insertions(+), 24 deletions(-) diff --git a/src/atx/executor.rs b/src/atx/executor.rs index 14f98aa6..430c015d 100644 --- a/src/atx/executor.rs +++ b/src/atx/executor.rs @@ -295,7 +295,10 @@ impl AtxKeyExecutor { /// Pulse Serial relay async fn pulse_serial(&self, duration: Duration) -> Result<()> { - info!("Pulse serial relay on {} pin {}", self.config.device, self.config.pin); + info!( + "Pulse serial relay on {} pin {}", + self.config.device, self.config.pin + ); // Turn relay on self.send_serial_relay_command(true)?; @@ -328,7 +331,7 @@ impl AtxKeyExecutor { // Checksum = A0 + channel + state let state = if on { 1 } else { 0 }; let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state); - + // Example for Channel 1: // ON: A0 01 01 A2 // OFF: A0 01 00 A1 diff --git a/src/hid/backend.rs b/src/hid/backend.rs index 62fd1bf5..ed9c5cd7 100644 --- a/src/hid/backend.rs +++ b/src/hid/backend.rs @@ -104,6 +104,13 @@ pub trait HidBackend: Send + Sync { /// Shutdown the backend async fn shutdown(&self) -> Result<()>; + /// Perform backend health check. + /// + /// Default implementation assumes backend is healthy. + fn health_check(&self) -> Result<()> { + Ok(()) + } + /// Check if backend supports absolute mouse positioning fn supports_absolute_mouse(&self) -> bool { false diff --git a/src/hid/ch9329.rs b/src/hid/ch9329.rs index 13abf25e..8f35d1f4 100644 --- a/src/hid/ch9329.rs +++ b/src/hid/ch9329.rs @@ -741,6 +741,20 @@ impl Ch9329Backend { } } + fn update_chip_info_cache(&self, response: &Response) -> Result { + if let Some(info) = ChipInfo::from_response(&response.data) { + *self.chip_info.write() = Some(info.clone()); + *self.led_status.write() = LedStatus { + num_lock: info.num_lock, + caps_lock: info.caps_lock, + scroll_lock: info.scroll_lock, + }; + Ok(info) + } else { + Err(AppError::Internal("Failed to parse chip info".to_string())) + } + } + // ======================================================================== // Public API // ======================================================================== @@ -759,18 +773,15 @@ impl Ch9329Backend { response.cmd, response.data, response.is_error ); - if let Some(info) = ChipInfo::from_response(&response.data) { - // Update cache - *self.chip_info.write() = Some(info.clone()); - *self.led_status.write() = LedStatus { - num_lock: info.num_lock, - caps_lock: info.caps_lock, - scroll_lock: info.scroll_lock, - }; - Ok(info) - } else { - Err(AppError::Internal("Failed to parse chip info".to_string())) + if response.is_error { + let reason = response + .error_code + .map(|e| format!("CH9329 error response: {}", e)) + .unwrap_or_else(|| "CH9329 returned error response".to_string()); + return Err(AppError::Internal(reason)); } + + self.update_chip_info_cache(&response) } /// Get cached LED status @@ -1103,6 +1114,56 @@ impl HidBackend for Ch9329Backend { Ok(()) } + fn health_check(&self) -> Result<()> { + if !self.check_port_exists() { + return Err(AppError::HidError { + backend: "ch9329".to_string(), + reason: format!("Serial port {} not found", self.port_path), + error_code: "port_not_found".to_string(), + }); + } + + if !self.is_port_open() { + return Err(AppError::HidError { + backend: "ch9329".to_string(), + reason: "CH9329 serial port is not open".to_string(), + error_code: "port_not_opened".to_string(), + }); + } + + let response = + self.send_and_receive(cmd::GET_INFO, &[]) + .map_err(|e| AppError::HidError { + backend: "ch9329".to_string(), + reason: format!("CH9329 health check failed: {}", e), + error_code: "no_response".to_string(), + })?; + + if response.is_error { + let reason = response + .error_code + .map(|e| format!("CH9329 error response: {}", e)) + .unwrap_or_else(|| "CH9329 returned error response".to_string()); + return Err(AppError::HidError { + backend: "ch9329".to_string(), + reason, + error_code: "protocol_error".to_string(), + }); + } + + self.update_chip_info_cache(&response) + .map_err(|e| AppError::HidError { + backend: "ch9329".to_string(), + reason: format!("CH9329 invalid response: {}", e), + error_code: "invalid_response".to_string(), + })?; + + self.error_count.store(0, Ordering::Relaxed); + *self.last_success.lock() = Some(Instant::now()); + + Ok(()) + } + fn supports_absolute_mouse(&self) -> bool { true } diff --git a/src/hid/mod.rs b/src/hid/mod.rs index b73dc916..3a0f2fda 100644 --- a/src/hid/mod.rs +++ b/src/hid/mod.rs @@ -56,6 +56,7 @@ use tokio::task::JoinHandle; const HID_EVENT_QUEUE_CAPACITY: usize = 64; const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30; +const HID_HEALTH_CHECK_INTERVAL_MS: u64 = 1000; #[derive(Debug)] enum HidEvent { @@ -87,6 +88,8 @@ pub struct HidController { pending_move_flag: Arc, /// Worker task handle hid_worker: Mutex>>, + /// Health check task handle + hid_health_checker: Mutex>>, /// Backend availability fast flag backend_available: AtomicBool, } @@ -108,6 +111,7 @@ impl HidController { pending_move: Arc::new(parking_lot::Mutex::new(None)), pending_move_flag: Arc::new(AtomicBool::new(false)), hid_worker: Mutex::new(None), + hid_health_checker: Mutex::new(None), backend_available: AtomicBool::new(false), } } @@ -159,6 +163,7 @@ impl HidController { // Start HID event worker (once) self.start_event_worker().await; + self.start_health_checker().await; info!("HID backend initialized: {:?}", backend_type); Ok(()) @@ -167,6 +172,7 @@ impl HidController { /// Shutdown the HID backend and release resources pub async fn shutdown(&self) -> Result<()> { info!("Shutting down HID controller"); + self.stop_health_checker().await; // Close the backend *self.backend.write().await = None; @@ -298,6 +304,7 @@ impl HidController { pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> { info!("Reloading HID backend: {:?}", new_backend_type); self.backend_available.store(false, Ordering::Release); + self.stop_health_checker().await; // Shutdown existing backend first if let Some(backend) = self.backend.write().await.take() { @@ -400,6 +407,7 @@ impl HidController { info!("HID backend reloaded successfully: {:?}", new_backend_type); self.backend_available.store(true, Ordering::Release); self.start_event_worker().await; + self.start_health_checker().await; // Update backend_type on success *self.backend_type.write().await = new_backend_type.clone(); @@ -494,6 +502,87 @@ impl HidController { *worker_guard = Some(handle); } + async fn start_health_checker(&self) { + let mut checker_guard = self.hid_health_checker.lock().await; + if checker_guard.is_some() { + return; + } + + let backend = self.backend.clone(); + let backend_type = self.backend_type.clone(); + let monitor = self.monitor.clone(); + + let handle = tokio::spawn(async move { + let mut ticker = + tokio::time::interval(Duration::from_millis(HID_HEALTH_CHECK_INTERVAL_MS)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + ticker.tick().await; + + let backend_opt = backend.read().await.clone(); + let Some(active_backend) = backend_opt else { + continue; + }; + + let backend_name = backend_type.read().await.name_str().to_string(); + let result = + tokio::task::spawn_blocking(move || active_backend.health_check()).await; + + match result { + Ok(Ok(())) => { + if monitor.is_error().await { + monitor.report_recovered(&backend_name).await; + } + } + Ok(Err(AppError::HidError { + backend, + reason, + error_code, + })) => { + monitor + .report_error(&backend, None, &reason, &error_code) + .await; + } + Ok(Err(e)) => { + monitor + .report_error( + &backend_name, + None, + &format!("HID health check failed: {}", e), + "health_check_failed", + ) + .await; + } + Err(e) => { + monitor + .report_error( + &backend_name, + None, + &format!("HID health check task failed: {}", e), + "health_check_join_failed", + ) + .await; + } + } + } + }); + + *checker_guard = Some(handle); + } + + async fn stop_health_checker(&self) { + let handle_opt = { + let mut checker_guard = self.hid_health_checker.lock().await; + checker_guard.take() + }; + + if let Some(handle) = handle_opt { + handle.abort(); + let _ = handle.await; + } + } + fn enqueue_mouse_move(&self, event: MouseEvent) -> Result<()> { match self.hid_tx.try_send(HidEvent::Mouse(event.clone())) { Ok(_) => Ok(()), diff --git a/src/hid/otg.rs b/src/hid/otg.rs index 3c04bea0..ca26f3d5 100644 --- a/src/hid/otg.rs +++ b/src/hid/otg.rs @@ -940,6 +940,30 @@ impl HidBackend for OtgBackend { Ok(()) } + fn health_check(&self) -> Result<()> { + if !self.check_devices_exist() { + let missing = self.get_missing_devices(); + self.online.store(false, Ordering::Relaxed); + return Err(AppError::HidError { + backend: "otg".to_string(), + reason: format!("HID device node missing: {}", missing.join(", ")), + error_code: "enoent".to_string(), + }); + } + + if !self.is_udc_configured() { + self.online.store(false, Ordering::Relaxed); + return Err(AppError::HidError { + backend: "otg".to_string(), + reason: "UDC is not in configured state".to_string(), + error_code: "udc_not_configured".to_string(), + }); + } + + self.online.store(true, Ordering::Relaxed); + Ok(()) + } + fn supports_absolute_mouse(&self) -> bool { self.mouse_abs_path.as_ref().is_some_and(|p| p.exists()) } diff --git a/web/src/composables/useConsoleEvents.ts b/web/src/composables/useConsoleEvents.ts index 7c2971cc..192952f8 100644 --- a/web/src/composables/useConsoleEvents.ts +++ b/web/src/composables/useConsoleEvents.ts @@ -29,21 +29,84 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) { const { on, off, connect } = useWebSocket() const unifiedAudio = getUnifiedAudio() const noop = () => {} + const HID_TOAST_DEDUPE_MS = 30_000 + const hidLastToastAt = new Map() + + function hidErrorHint(errorCode?: string, backend?: string): string { + switch (errorCode) { + case 'udc_not_configured': + return t('hid.errorHints.udcNotConfigured') + case 'enoent': + return t('hid.errorHints.hidDeviceMissing') + case 'port_not_found': + case 'port_not_opened': + return t('hid.errorHints.portNotFound') + case 'no_response': + return t('hid.errorHints.noResponse') + case 'protocol_error': + case 'invalid_response': + return t('hid.errorHints.protocolError') + case 'health_check_failed': + case 'health_check_join_failed': + return t('hid.errorHints.healthCheckFailed') + case 'eio': + case 'epipe': + case 'eshutdown': + if (backend === 'otg') { + return t('hid.errorHints.otgIoError') + } + if (backend === 'ch9329') { + return t('hid.errorHints.ch9329IoError') + } + return t('hid.errorHints.ioError') + default: + return '' + } + } + + function formatHidReason(reason: string, errorCode?: string, backend?: string): string { + const hint = hidErrorHint(errorCode, backend) + if (!hint) return reason + return `${reason} (${hint})` + } // HID event handlers - function handleHidStateChanged(_data: unknown) { - // Empty handler to prevent warning - HID state handled via device_info + function handleHidStateChanged(data: { + backend: string + initialized: boolean + error?: string | null + error_code?: string | null + }) { + systemStore.updateHidStateFromEvent({ + backend: data.backend, + initialized: data.initialized, + error: data.error ?? null, + error_code: data.error_code ?? null, + }) } function handleHidDeviceLost(data: { backend: string; device?: string; reason: string; error_code: string }) { const temporaryErrors = ['eagain', 'eagain_retry'] if (temporaryErrors.includes(data.error_code)) return - if (systemStore.hid) { - systemStore.hid.initialized = false + systemStore.updateHidStateFromEvent({ + backend: data.backend, + initialized: false, + error: data.reason, + error_code: data.error_code, + }) + + const dedupeKey = `${data.backend}:${data.error_code}` + const now = Date.now() + const last = hidLastToastAt.get(dedupeKey) ?? 0 + if (now - last < HID_TOAST_DEDUPE_MS) { + return } + hidLastToastAt.set(dedupeKey, now) + + const reason = formatHidReason(data.reason, data.error_code, data.backend) toast.error(t('hid.deviceLost'), { - description: t('hid.deviceLostDesc', { backend: data.backend, reason: data.reason }), + description: t('hid.deviceLostDesc', { backend: data.backend, reason }), duration: 5000, }) } @@ -58,9 +121,12 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) { } function handleHidRecovered(data: { backend: string }) { - if (systemStore.hid) { - systemStore.hid.initialized = true - } + systemStore.updateHidStateFromEvent({ + backend: data.backend, + initialized: true, + error: null, + error_code: null, + }) toast.success(t('hid.recovered'), { description: t('hid.recoveredDesc', { backend: data.backend }), duration: 3000, diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 40d7b092..ead9995e 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -361,6 +361,17 @@ export default { reconnectingDesc: 'Attempting to reconnect (attempt {attempt})', recovered: 'HID Recovered', recoveredDesc: '{backend} HID device reconnected successfully', + errorHints: { + udcNotConfigured: 'Target host has not finished USB enumeration yet', + hidDeviceMissing: 'HID gadget device node is missing, try restarting HID service', + portNotFound: 'Serial port not found, check CH9329 wiring and device path', + noResponse: 'No response from CH9329, check baud rate and power', + protocolError: 'CH9329 replied with invalid protocol data', + healthCheckFailed: 'Background health check failed', + ioError: 'I/O communication error detected', + otgIoError: 'OTG link is unstable, check USB cable and host port', + ch9329IoError: 'CH9329 serial link is unstable, check wiring and power', + }, }, audio: { // Device monitoring @@ -812,6 +823,7 @@ export default { networkError: 'Network Error', disconnected: 'Disconnected', availability: 'Availability', + errorCode: 'Error Code', hidUnavailable: 'HID Unavailable', sampleRate: 'Sample Rate', channels: 'Channels', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 90188b08..b0108aba 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -361,6 +361,17 @@ export default { reconnectingDesc: '正在尝试重连(第 {attempt} 次)', recovered: 'HID 已恢复', recoveredDesc: '{backend} HID 设备已成功重连', + errorHints: { + udcNotConfigured: '被控机尚未完成 USB 枚举', + hidDeviceMissing: '未找到 HID 设备节点,可尝试重启 HID 服务', + portNotFound: '找不到串口设备,请检查 CH9329 接线与设备路径', + noResponse: 'CH9329 无响应,请检查波特率与供电', + protocolError: 'CH9329 返回了无效协议数据', + healthCheckFailed: '后台健康检查失败', + ioError: '检测到 I/O 通信异常', + otgIoError: 'OTG 链路不稳定,请检查 USB 线和被控机接口', + ch9329IoError: 'CH9329 串口链路不稳定,请检查接线与供电', + }, }, audio: { // 设备监控 @@ -812,6 +823,7 @@ export default { networkError: '网络错误', disconnected: '已断开', availability: '可用性', + errorCode: '错误码', hidUnavailable: 'HID不可用', sampleRate: '采样率', channels: '声道', diff --git a/web/src/stores/system.ts b/web/src/stores/system.ts index 1efa2cbc..f4a6ce39 100644 --- a/web/src/stores/system.ts +++ b/web/src/stores/system.ts @@ -35,6 +35,7 @@ interface HidState { supportsAbsoluteMouse: boolean device: string | null error: string | null + errorCode: string | null } interface AtxState { @@ -185,6 +186,7 @@ export const useSystemStore = defineStore('system', () => { supportsAbsoluteMouse: state.supports_absolute_mouse, device: null, error: null, + errorCode: null, } return state } catch (e) { @@ -287,6 +289,8 @@ export const useSystemStore = defineStore('system', () => { supportsAbsoluteMouse: data.hid.supports_absolute_mouse, device: data.hid.device, error: data.hid.error, + // system.device_info does not include HID error_code, keep latest one when error still exists. + errorCode: data.hid.error ? (hid.value?.errorCode ?? null) : null, } // Update MSD state (optional) @@ -356,6 +360,28 @@ export const useSystemStore = defineStore('system', () => { } } + /** + * Update HID state from hid.state_changed / hid.device_lost events. + */ + function updateHidStateFromEvent(data: { + backend: string + initialized: boolean + error?: string | null + error_code?: string | null + }) { + const current = hid.value + const nextBackend = data.backend || current?.backend || 'unknown' + hid.value = { + available: nextBackend !== 'none', + backend: nextBackend, + initialized: data.initialized, + supportsAbsoluteMouse: current?.supportsAbsoluteMouse ?? false, + device: current?.device ?? null, + error: data.error ?? null, + errorCode: data.error_code ?? null, + } + } + return { version, buildDate, @@ -380,6 +406,7 @@ export const useSystemStore = defineStore('system', () => { updateWsConnection, updateHidWsConnection, updateFromDeviceInfo, + updateHidStateFromEvent, updateStreamClients, setStreamOnline, } diff --git a/web/src/views/ConsoleView.vue b/web/src/views/ConsoleView.vue index fa055053..fd32ecf1 100644 --- a/web/src/views/ConsoleView.vue +++ b/web/src/views/ConsoleView.vue @@ -226,6 +226,9 @@ const videoDetails = computed(() => { }) const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => { + const hid = systemStore.hid + if (hid?.error) return 'error' + // In WebRTC mode, check DataChannel status first if (videoMode.value !== 'mjpeg') { // DataChannel is ready - HID is connected via WebRTC @@ -248,8 +251,8 @@ const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error' if (hidWs.hidUnavailable.value) return 'disconnected' // Normal status based on system state - if (systemStore.hid?.available && systemStore.hid?.initialized) return 'connected' - if (systemStore.hid?.available && !systemStore.hid?.initialized) return 'connecting' + if (hid?.available && hid.initialized) return 'connected' + if (hid?.available && !hid.initialized) return 'connecting' return 'disconnected' }) @@ -261,17 +264,66 @@ const hidQuickInfo = computed(() => { return mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative') }) +function hidErrorHint(errorCode?: string | null, backend?: string | null): string { + switch (errorCode) { + case 'udc_not_configured': + return t('hid.errorHints.udcNotConfigured') + case 'enoent': + return t('hid.errorHints.hidDeviceMissing') + case 'port_not_found': + case 'port_not_opened': + return t('hid.errorHints.portNotFound') + case 'no_response': + return t('hid.errorHints.noResponse') + case 'protocol_error': + case 'invalid_response': + return t('hid.errorHints.protocolError') + case 'health_check_failed': + case 'health_check_join_failed': + return t('hid.errorHints.healthCheckFailed') + case 'eio': + case 'epipe': + case 'eshutdown': + if (backend === 'otg') return t('hid.errorHints.otgIoError') + if (backend === 'ch9329') return t('hid.errorHints.ch9329IoError') + return t('hid.errorHints.ioError') + default: + return '' + } +} + +function buildHidErrorMessage(reason?: string | null, errorCode?: string | null, backend?: string | null): string { + if (!reason && !errorCode) return '' + const hint = hidErrorHint(errorCode, backend) + if (reason && hint) return `${reason} (${hint})` + if (reason) return reason + return hint || t('common.error') +} + +const hidErrorMessage = computed(() => { + const hid = systemStore.hid + return buildHidErrorMessage(hid?.error, hid?.errorCode, hid?.backend) +}) + const hidDetails = computed(() => { const hid = systemStore.hid if (!hid) return [] + const errorMessage = buildHidErrorMessage(hid.error, hid.errorCode, hid.backend) const details: StatusDetail[] = [ { label: t('statusCard.device'), value: hid.device || '-' }, { label: t('statusCard.backend'), value: hid.backend || t('common.unknown') }, - { label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.initialized ? 'ok' : 'warning' }, + { label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.error ? 'error' : hid.initialized ? 'ok' : 'warning' }, { label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' }, ] + if (hid.errorCode) { + details.push({ label: t('statusCard.errorCode'), value: hid.errorCode, status: 'error' }) + } + if (errorMessage) { + details.push({ label: t('common.error'), value: errorMessage, status: 'error' }) + } + // Add HID channel info based on video mode if (videoMode.value !== 'mjpeg') { // WebRTC mode - show DataChannel status @@ -2058,6 +2110,7 @@ onUnmounted(() => { type="hid" :status="hidStatus" :quick-info="hidQuickInfo" + :error-message="hidErrorMessage" :details="hidDetails" :hover-align="hidHoverAlign" />