mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-15 07:26:44 +08:00
feat(hid): 增加 HID 后端健康检查与错误码上报,完善前端掉线恢复状态同步及错误提示展示
This commit is contained in:
@@ -295,7 +295,10 @@ impl AtxKeyExecutor {
|
|||||||
|
|
||||||
/// Pulse Serial relay
|
/// Pulse Serial relay
|
||||||
async fn pulse_serial(&self, duration: Duration) -> Result<()> {
|
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
|
// Turn relay on
|
||||||
self.send_serial_relay_command(true)?;
|
self.send_serial_relay_command(true)?;
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ pub trait HidBackend: Send + Sync {
|
|||||||
/// Shutdown the backend
|
/// Shutdown the backend
|
||||||
async fn shutdown(&self) -> Result<()>;
|
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
|
/// Check if backend supports absolute mouse positioning
|
||||||
fn supports_absolute_mouse(&self) -> bool {
|
fn supports_absolute_mouse(&self) -> bool {
|
||||||
false
|
false
|
||||||
|
|||||||
@@ -741,6 +741,20 @@ impl Ch9329Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_chip_info_cache(&self, response: &Response) -> Result<ChipInfo> {
|
||||||
|
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
|
// Public API
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -759,18 +773,15 @@ impl Ch9329Backend {
|
|||||||
response.cmd, response.data, response.is_error
|
response.cmd, response.data, response.is_error
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(info) = ChipInfo::from_response(&response.data) {
|
if response.is_error {
|
||||||
// Update cache
|
let reason = response
|
||||||
*self.chip_info.write() = Some(info.clone());
|
.error_code
|
||||||
*self.led_status.write() = LedStatus {
|
.map(|e| format!("CH9329 error response: {}", e))
|
||||||
num_lock: info.num_lock,
|
.unwrap_or_else(|| "CH9329 returned error response".to_string());
|
||||||
caps_lock: info.caps_lock,
|
return Err(AppError::Internal(reason));
|
||||||
scroll_lock: info.scroll_lock,
|
|
||||||
};
|
|
||||||
Ok(info)
|
|
||||||
} else {
|
|
||||||
Err(AppError::Internal("Failed to parse chip info".to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.update_chip_info_cache(&response)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get cached LED status
|
/// Get cached LED status
|
||||||
@@ -1103,6 +1114,56 @@ impl HidBackend for Ch9329Backend {
|
|||||||
Ok(())
|
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 {
|
fn supports_absolute_mouse(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ use tokio::task::JoinHandle;
|
|||||||
|
|
||||||
const HID_EVENT_QUEUE_CAPACITY: usize = 64;
|
const HID_EVENT_QUEUE_CAPACITY: usize = 64;
|
||||||
const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30;
|
const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30;
|
||||||
|
const HID_HEALTH_CHECK_INTERVAL_MS: u64 = 1000;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum HidEvent {
|
enum HidEvent {
|
||||||
@@ -87,6 +88,8 @@ pub struct HidController {
|
|||||||
pending_move_flag: Arc<AtomicBool>,
|
pending_move_flag: Arc<AtomicBool>,
|
||||||
/// Worker task handle
|
/// Worker task handle
|
||||||
hid_worker: Mutex<Option<JoinHandle<()>>>,
|
hid_worker: Mutex<Option<JoinHandle<()>>>,
|
||||||
|
/// Health check task handle
|
||||||
|
hid_health_checker: Mutex<Option<JoinHandle<()>>>,
|
||||||
/// Backend availability fast flag
|
/// Backend availability fast flag
|
||||||
backend_available: AtomicBool,
|
backend_available: AtomicBool,
|
||||||
}
|
}
|
||||||
@@ -108,6 +111,7 @@ impl HidController {
|
|||||||
pending_move: Arc::new(parking_lot::Mutex::new(None)),
|
pending_move: Arc::new(parking_lot::Mutex::new(None)),
|
||||||
pending_move_flag: Arc::new(AtomicBool::new(false)),
|
pending_move_flag: Arc::new(AtomicBool::new(false)),
|
||||||
hid_worker: Mutex::new(None),
|
hid_worker: Mutex::new(None),
|
||||||
|
hid_health_checker: Mutex::new(None),
|
||||||
backend_available: AtomicBool::new(false),
|
backend_available: AtomicBool::new(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,6 +163,7 @@ impl HidController {
|
|||||||
|
|
||||||
// Start HID event worker (once)
|
// Start HID event worker (once)
|
||||||
self.start_event_worker().await;
|
self.start_event_worker().await;
|
||||||
|
self.start_health_checker().await;
|
||||||
|
|
||||||
info!("HID backend initialized: {:?}", backend_type);
|
info!("HID backend initialized: {:?}", backend_type);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -167,6 +172,7 @@ impl HidController {
|
|||||||
/// Shutdown the HID backend and release resources
|
/// Shutdown the HID backend and release resources
|
||||||
pub async fn shutdown(&self) -> Result<()> {
|
pub async fn shutdown(&self) -> Result<()> {
|
||||||
info!("Shutting down HID controller");
|
info!("Shutting down HID controller");
|
||||||
|
self.stop_health_checker().await;
|
||||||
|
|
||||||
// Close the backend
|
// Close the backend
|
||||||
*self.backend.write().await = None;
|
*self.backend.write().await = None;
|
||||||
@@ -298,6 +304,7 @@ impl HidController {
|
|||||||
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
|
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
|
||||||
info!("Reloading HID backend: {:?}", new_backend_type);
|
info!("Reloading HID backend: {:?}", new_backend_type);
|
||||||
self.backend_available.store(false, Ordering::Release);
|
self.backend_available.store(false, Ordering::Release);
|
||||||
|
self.stop_health_checker().await;
|
||||||
|
|
||||||
// Shutdown existing backend first
|
// Shutdown existing backend first
|
||||||
if let Some(backend) = self.backend.write().await.take() {
|
if let Some(backend) = self.backend.write().await.take() {
|
||||||
@@ -400,6 +407,7 @@ impl HidController {
|
|||||||
info!("HID backend reloaded successfully: {:?}", new_backend_type);
|
info!("HID backend reloaded successfully: {:?}", new_backend_type);
|
||||||
self.backend_available.store(true, Ordering::Release);
|
self.backend_available.store(true, Ordering::Release);
|
||||||
self.start_event_worker().await;
|
self.start_event_worker().await;
|
||||||
|
self.start_health_checker().await;
|
||||||
|
|
||||||
// Update backend_type on success
|
// Update backend_type on success
|
||||||
*self.backend_type.write().await = new_backend_type.clone();
|
*self.backend_type.write().await = new_backend_type.clone();
|
||||||
@@ -494,6 +502,87 @@ impl HidController {
|
|||||||
*worker_guard = Some(handle);
|
*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<()> {
|
fn enqueue_mouse_move(&self, event: MouseEvent) -> Result<()> {
|
||||||
match self.hid_tx.try_send(HidEvent::Mouse(event.clone())) {
|
match self.hid_tx.try_send(HidEvent::Mouse(event.clone())) {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
|
|||||||
@@ -940,6 +940,30 @@ impl HidBackend for OtgBackend {
|
|||||||
Ok(())
|
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 {
|
fn supports_absolute_mouse(&self) -> bool {
|
||||||
self.mouse_abs_path.as_ref().is_some_and(|p| p.exists())
|
self.mouse_abs_path.as_ref().is_some_and(|p| p.exists())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,21 +29,84 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
|||||||
const { on, off, connect } = useWebSocket()
|
const { on, off, connect } = useWebSocket()
|
||||||
const unifiedAudio = getUnifiedAudio()
|
const unifiedAudio = getUnifiedAudio()
|
||||||
const noop = () => {}
|
const noop = () => {}
|
||||||
|
const HID_TOAST_DEDUPE_MS = 30_000
|
||||||
|
const hidLastToastAt = new Map<string, number>()
|
||||||
|
|
||||||
|
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
|
// HID event handlers
|
||||||
function handleHidStateChanged(_data: unknown) {
|
function handleHidStateChanged(data: {
|
||||||
// Empty handler to prevent warning - HID state handled via device_info
|
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 }) {
|
function handleHidDeviceLost(data: { backend: string; device?: string; reason: string; error_code: string }) {
|
||||||
const temporaryErrors = ['eagain', 'eagain_retry']
|
const temporaryErrors = ['eagain', 'eagain_retry']
|
||||||
if (temporaryErrors.includes(data.error_code)) return
|
if (temporaryErrors.includes(data.error_code)) return
|
||||||
|
|
||||||
if (systemStore.hid) {
|
systemStore.updateHidStateFromEvent({
|
||||||
systemStore.hid.initialized = false
|
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'), {
|
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,
|
duration: 5000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -58,9 +121,12 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleHidRecovered(data: { backend: string }) {
|
function handleHidRecovered(data: { backend: string }) {
|
||||||
if (systemStore.hid) {
|
systemStore.updateHidStateFromEvent({
|
||||||
systemStore.hid.initialized = true
|
backend: data.backend,
|
||||||
}
|
initialized: true,
|
||||||
|
error: null,
|
||||||
|
error_code: null,
|
||||||
|
})
|
||||||
toast.success(t('hid.recovered'), {
|
toast.success(t('hid.recovered'), {
|
||||||
description: t('hid.recoveredDesc', { backend: data.backend }),
|
description: t('hid.recoveredDesc', { backend: data.backend }),
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
|
|||||||
@@ -361,6 +361,17 @@ export default {
|
|||||||
reconnectingDesc: 'Attempting to reconnect (attempt {attempt})',
|
reconnectingDesc: 'Attempting to reconnect (attempt {attempt})',
|
||||||
recovered: 'HID Recovered',
|
recovered: 'HID Recovered',
|
||||||
recoveredDesc: '{backend} HID device reconnected successfully',
|
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: {
|
audio: {
|
||||||
// Device monitoring
|
// Device monitoring
|
||||||
@@ -812,6 +823,7 @@ export default {
|
|||||||
networkError: 'Network Error',
|
networkError: 'Network Error',
|
||||||
disconnected: 'Disconnected',
|
disconnected: 'Disconnected',
|
||||||
availability: 'Availability',
|
availability: 'Availability',
|
||||||
|
errorCode: 'Error Code',
|
||||||
hidUnavailable: 'HID Unavailable',
|
hidUnavailable: 'HID Unavailable',
|
||||||
sampleRate: 'Sample Rate',
|
sampleRate: 'Sample Rate',
|
||||||
channels: 'Channels',
|
channels: 'Channels',
|
||||||
|
|||||||
@@ -361,6 +361,17 @@ export default {
|
|||||||
reconnectingDesc: '正在尝试重连(第 {attempt} 次)',
|
reconnectingDesc: '正在尝试重连(第 {attempt} 次)',
|
||||||
recovered: 'HID 已恢复',
|
recovered: 'HID 已恢复',
|
||||||
recoveredDesc: '{backend} 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: {
|
audio: {
|
||||||
// 设备监控
|
// 设备监控
|
||||||
@@ -812,6 +823,7 @@ export default {
|
|||||||
networkError: '网络错误',
|
networkError: '网络错误',
|
||||||
disconnected: '已断开',
|
disconnected: '已断开',
|
||||||
availability: '可用性',
|
availability: '可用性',
|
||||||
|
errorCode: '错误码',
|
||||||
hidUnavailable: 'HID不可用',
|
hidUnavailable: 'HID不可用',
|
||||||
sampleRate: '采样率',
|
sampleRate: '采样率',
|
||||||
channels: '声道',
|
channels: '声道',
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface HidState {
|
|||||||
supportsAbsoluteMouse: boolean
|
supportsAbsoluteMouse: boolean
|
||||||
device: string | null
|
device: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
|
errorCode: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AtxState {
|
interface AtxState {
|
||||||
@@ -185,6 +186,7 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
supportsAbsoluteMouse: state.supports_absolute_mouse,
|
supportsAbsoluteMouse: state.supports_absolute_mouse,
|
||||||
device: null,
|
device: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
errorCode: null,
|
||||||
}
|
}
|
||||||
return state
|
return state
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -287,6 +289,8 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
supportsAbsoluteMouse: data.hid.supports_absolute_mouse,
|
supportsAbsoluteMouse: data.hid.supports_absolute_mouse,
|
||||||
device: data.hid.device,
|
device: data.hid.device,
|
||||||
error: data.hid.error,
|
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)
|
// 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 {
|
return {
|
||||||
version,
|
version,
|
||||||
buildDate,
|
buildDate,
|
||||||
@@ -380,6 +406,7 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
updateWsConnection,
|
updateWsConnection,
|
||||||
updateHidWsConnection,
|
updateHidWsConnection,
|
||||||
updateFromDeviceInfo,
|
updateFromDeviceInfo,
|
||||||
|
updateHidStateFromEvent,
|
||||||
updateStreamClients,
|
updateStreamClients,
|
||||||
setStreamOnline,
|
setStreamOnline,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,9 @@ const videoDetails = computed<StatusDetail[]>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||||
|
const hid = systemStore.hid
|
||||||
|
if (hid?.error) return 'error'
|
||||||
|
|
||||||
// In WebRTC mode, check DataChannel status first
|
// In WebRTC mode, check DataChannel status first
|
||||||
if (videoMode.value !== 'mjpeg') {
|
if (videoMode.value !== 'mjpeg') {
|
||||||
// DataChannel is ready - HID is connected via WebRTC
|
// 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'
|
if (hidWs.hidUnavailable.value) return 'disconnected'
|
||||||
|
|
||||||
// Normal status based on system state
|
// Normal status based on system state
|
||||||
if (systemStore.hid?.available && systemStore.hid?.initialized) return 'connected'
|
if (hid?.available && hid.initialized) return 'connected'
|
||||||
if (systemStore.hid?.available && !systemStore.hid?.initialized) return 'connecting'
|
if (hid?.available && !hid.initialized) return 'connecting'
|
||||||
return 'disconnected'
|
return 'disconnected'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -261,17 +264,66 @@ const hidQuickInfo = computed(() => {
|
|||||||
return mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative')
|
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<StatusDetail[]>(() => {
|
const hidDetails = computed<StatusDetail[]>(() => {
|
||||||
const hid = systemStore.hid
|
const hid = systemStore.hid
|
||||||
if (!hid) return []
|
if (!hid) return []
|
||||||
|
const errorMessage = buildHidErrorMessage(hid.error, hid.errorCode, hid.backend)
|
||||||
|
|
||||||
const details: StatusDetail[] = [
|
const details: StatusDetail[] = [
|
||||||
{ label: t('statusCard.device'), value: hid.device || '-' },
|
{ label: t('statusCard.device'), value: hid.device || '-' },
|
||||||
{ label: t('statusCard.backend'), value: hid.backend || t('common.unknown') },
|
{ 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' },
|
{ 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
|
// Add HID channel info based on video mode
|
||||||
if (videoMode.value !== 'mjpeg') {
|
if (videoMode.value !== 'mjpeg') {
|
||||||
// WebRTC mode - show DataChannel status
|
// WebRTC mode - show DataChannel status
|
||||||
@@ -2058,6 +2110,7 @@ onUnmounted(() => {
|
|||||||
type="hid"
|
type="hid"
|
||||||
:status="hidStatus"
|
:status="hidStatus"
|
||||||
:quick-info="hidQuickInfo"
|
:quick-info="hidQuickInfo"
|
||||||
|
:error-message="hidErrorMessage"
|
||||||
:details="hidDetails"
|
:details="hidDetails"
|
||||||
:hover-align="hidHoverAlign"
|
:hover-align="hidHoverAlign"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user