diff --git a/src/audio/controller.rs b/src/audio/controller.rs index 5191fd82..9da09108 100644 --- a/src/audio/controller.rs +++ b/src/audio/controller.rs @@ -141,9 +141,7 @@ impl AudioController { /// Set event bus for publishing audio events pub async fn set_event_bus(&self, event_bus: Arc) { - *self.event_bus.write().await = Some(event_bus.clone()); - // Also set event bus on the monitor for health notifications - self.monitor.set_event_bus(event_bus).await; + *self.event_bus.write().await = Some(event_bus); } /// Publish an event to the event bus @@ -207,12 +205,6 @@ impl AudioController { config.device = device.to_string(); } - // Publish event - self.publish_event(SystemEvent::AudioDeviceSelected { - device: device.to_string(), - }) - .await; - info!("Audio device selected: {}", device); // If streaming, restart with new device @@ -237,12 +229,6 @@ impl AudioController { streamer.set_bitrate(quality.bitrate()).await?; } - // Publish event - self.publish_event(SystemEvent::AudioQualityChanged { - quality: quality.to_string(), - }) - .await; - info!( "Audio quality set to: {:?} ({}bps)", quality, @@ -408,7 +394,6 @@ impl AudioController { /// Update full configuration pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> { let was_streaming = self.is_streaming().await; - let old_config = self.config.read().await.clone(); // Stop streaming if running if was_streaming { @@ -423,21 +408,6 @@ impl AudioController { self.start_streaming().await?; } - // Publish events for changes - if old_config.device != new_config.device { - self.publish_event(SystemEvent::AudioDeviceSelected { - device: new_config.device.clone(), - }) - .await; - } - - if old_config.quality != new_config.quality { - self.publish_event(SystemEvent::AudioQualityChanged { - quality: new_config.quality.to_string(), - }) - .await; - } - Ok(()) } diff --git a/src/audio/monitor.rs b/src/audio/monitor.rs index f764c775..9ee57fbe 100644 --- a/src/audio/monitor.rs +++ b/src/audio/monitor.rs @@ -3,16 +3,14 @@ //! This module provides health monitoring for audio capture devices, including: //! - Device connectivity checks //! - Automatic reconnection on failure -//! - Error tracking and notification +//! - Error tracking //! - Log throttling to prevent log flooding use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::Arc; use std::time::Duration; use tokio::sync::RwLock; -use tracing::{debug, info, warn}; +use tracing::{info, warn}; -use crate::events::{EventBus, SystemEvent}; use crate::utils::LogThrottler; /// Audio health status @@ -58,12 +56,9 @@ impl Default for AudioMonitorConfig { /// Audio health monitor /// /// Monitors audio device health and manages error recovery. -/// Publishes WebSocket events when device status changes. pub struct AudioHealthMonitor { /// Current health status status: RwLock, - /// Event bus for notifications - events: RwLock>>, /// Log throttler to prevent log flooding throttler: LogThrottler, /// Configuration @@ -83,7 +78,6 @@ impl AudioHealthMonitor { let throttle_secs = config.log_throttle_secs; Self { status: RwLock::new(AudioHealthStatus::Healthy), - events: RwLock::new(None), throttler: LogThrottler::with_secs(throttle_secs), config, running: AtomicBool::new(false), @@ -97,24 +91,19 @@ impl AudioHealthMonitor { Self::new(AudioMonitorConfig::default()) } - /// Set the event bus for broadcasting state changes - pub async fn set_event_bus(&self, events: Arc) { - *self.events.write().await = Some(events); - } - /// Report an error from audio operations /// /// This method is called when an audio operation fails. It: /// 1. Updates the health status /// 2. Logs the error (with throttling) - /// 3. Publishes a WebSocket event if the error is new or changed + /// 3. Updates in-memory error state /// /// # Arguments /// /// * `device` - The audio device name (if known) /// * `reason` - Human-readable error description /// * `error_code` - Error code for programmatic handling - pub async fn report_error(&self, device: Option<&str>, reason: &str, error_code: &str) { + pub async fn report_error(&self, _device: Option<&str>, reason: &str, error_code: &str) { let count = self.retry_count.fetch_add(1, Ordering::Relaxed) + 1; // Check if error code changed @@ -141,44 +130,17 @@ impl AudioHealthMonitor { error_code: error_code.to_string(), retry_count: count, }; - - // Publish event (only if error changed or first occurrence) - if error_changed || count == 1 { - if let Some(ref events) = *self.events.read().await { - events.publish(SystemEvent::AudioDeviceLost { - device: device.map(|s| s.to_string()), - reason: reason.to_string(), - error_code: error_code.to_string(), - }); - } - } - } - - /// Report that a reconnection attempt is starting - /// - /// Publishes a reconnecting event to notify clients. - pub async fn report_reconnecting(&self) { - let attempt = self.retry_count.load(Ordering::Relaxed); - - // Only publish every 5 attempts to avoid event spam - if attempt == 1 || attempt.is_multiple_of(5) { - debug!("Audio reconnecting, attempt {}", attempt); - - if let Some(ref events) = *self.events.read().await { - events.publish(SystemEvent::AudioReconnecting { attempt }); - } - } } /// Report that the device has recovered /// /// This method is called when the audio device successfully reconnects. - /// It resets the error state and publishes a recovery event. + /// It resets the error state. /// /// # Arguments /// /// * `device` - The audio device name - pub async fn report_recovered(&self, device: Option<&str>) { + pub async fn report_recovered(&self, _device: Option<&str>) { let prev_status = self.status.read().await.clone(); // Only report recovery if we were in an error state @@ -191,13 +153,6 @@ impl AudioHealthMonitor { self.throttler.clear("audio_"); *self.last_error_code.write().await = None; *self.status.write().await = AudioHealthStatus::Healthy; - - // Publish recovery event - if let Some(ref events) = *self.events.read().await { - events.publish(SystemEvent::AudioRecovered { - device: device.map(|s| s.to_string()), - }); - } } } diff --git a/src/events/mod.rs b/src/events/mod.rs index 466b259d..b3de4e18 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -110,17 +110,16 @@ mod tests { assert_eq!(bus.subscriber_count(), 2); - bus.publish(SystemEvent::SystemError { - module: "test".to_string(), - severity: "info".to_string(), - message: "test message".to_string(), + bus.publish(SystemEvent::StreamStateChanged { + state: "ready".to_string(), + device: Some("/dev/video0".to_string()), }); let event1 = rx1.recv().await.unwrap(); let event2 = rx2.recv().await.unwrap(); - assert!(matches!(event1, SystemEvent::SystemError { .. })); - assert!(matches!(event2, SystemEvent::SystemError { .. })); + assert!(matches!(event1, SystemEvent::StreamStateChanged { .. })); + assert!(matches!(event2, SystemEvent::StreamStateChanged { .. })); } #[test] @@ -129,10 +128,9 @@ mod tests { assert_eq!(bus.subscriber_count(), 0); // Should not panic when publishing with no subscribers - bus.publish(SystemEvent::SystemError { - module: "test".to_string(), - severity: "info".to_string(), - message: "test".to_string(), + bus.publish(SystemEvent::StreamStateChanged { + state: "ready".to_string(), + device: None, }); } } diff --git a/src/events/types.rs b/src/events/types.rs index ab3ebfd4..8f7930ef 100644 --- a/src/events/types.rs +++ b/src/events/types.rs @@ -2,7 +2,6 @@ //! //! Defines all event types that can be broadcast through the event bus. -use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -45,12 +44,16 @@ pub struct HidDeviceInfo { pub backend: String, /// Whether backend is initialized and ready pub initialized: bool, + /// Whether backend is currently online + pub online: bool, /// Whether absolute mouse positioning is supported pub supports_absolute_mouse: bool, /// Device path (e.g., serial port for CH9329) pub device: Option, /// Error message if any, None if OK pub error: Option, + /// Error code if any, None if OK + pub error_code: Option, } /// MSD device information @@ -285,50 +288,14 @@ pub enum SystemEvent { backend: String, /// Whether backend is initialized and ready initialized: bool, + /// Whether backend is currently online + online: bool, /// Error message if any, None if OK error: Option, /// Error code for programmatic handling: "epipe", "eagain", "port_not_found", etc. error_code: Option, }, - /// HID backend is being switched - #[serde(rename = "hid.backend_switching")] - HidBackendSwitching { - /// Current backend - from: String, - /// New backend - to: String, - }, - - /// HID device lost (device file missing or I/O error) - #[serde(rename = "hid.device_lost")] - HidDeviceLost { - /// Backend type: "otg", "ch9329" - backend: String, - /// Device path that was lost (e.g., /dev/hidg0 or /dev/ttyUSB0) - device: Option, - /// Human-readable reason for loss - reason: String, - /// Error code: "epipe", "eshutdown", "eagain", "enxio", "port_not_found", "io_error" - error_code: String, - }, - - /// HID device is reconnecting - #[serde(rename = "hid.reconnecting")] - HidReconnecting { - /// Backend type: "otg", "ch9329" - backend: String, - /// Current retry attempt number - attempt: u32, - }, - - /// HID device has recovered after error - #[serde(rename = "hid.recovered")] - HidRecovered { - /// Backend type: "otg", "ch9329" - backend: String, - }, - // ============================================================================ // MSD (Mass Storage Device) Events // ============================================================================ @@ -341,23 +308,6 @@ pub enum SystemEvent { connected: bool, }, - /// Image has been mounted - #[serde(rename = "msd.image_mounted")] - MsdImageMounted { - /// Image ID - image_id: String, - /// Image filename - image_name: String, - /// Image size in bytes - size: u64, - /// Mount as CD-ROM (read-only) - cdrom: bool, - }, - - /// Image has been unmounted - #[serde(rename = "msd.image_unmounted")] - MsdImageUnmounted, - /// File upload progress (for large file uploads) #[serde(rename = "msd.upload_progress")] MsdUploadProgress { @@ -392,28 +342,6 @@ pub enum SystemEvent { status: String, }, - /// USB gadget connection status changed (host connected/disconnected) - #[serde(rename = "msd.usb_status_changed")] - MsdUsbStatusChanged { - /// Whether host is connected to USB device - connected: bool, - /// USB device state from kernel (e.g., "configured", "not attached") - device_state: String, - }, - - /// MSD operation error (configfs, image mount, etc.) - #[serde(rename = "msd.error")] - MsdError { - /// Human-readable reason for error - reason: String, - /// Error code: "configfs_error", "image_not_found", "mount_failed", "io_error" - error_code: String, - }, - - /// MSD has recovered after error - #[serde(rename = "msd.recovered")] - MsdRecovered, - // ============================================================================ // ATX (Power Control) Events // ============================================================================ @@ -424,15 +352,6 @@ pub enum SystemEvent { power_status: PowerStatus, }, - /// ATX action was executed - #[serde(rename = "atx.action_executed")] - AtxActionExecuted { - /// Action: "short", "long", "reset" - action: String, - /// When the action was executed - timestamp: DateTime, - }, - // ============================================================================ // Audio Events // ============================================================================ @@ -445,79 +364,6 @@ pub enum SystemEvent { device: Option, }, - /// Audio device was selected - #[serde(rename = "audio.device_selected")] - AudioDeviceSelected { - /// Selected device name - device: String, - }, - - /// Audio quality was changed - #[serde(rename = "audio.quality_changed")] - AudioQualityChanged { - /// New quality setting: "voice", "balanced", "high" - quality: String, - }, - - /// Audio device lost (capture error or device disconnected) - #[serde(rename = "audio.device_lost")] - AudioDeviceLost { - /// Audio device name (e.g., "hw:0,0") - device: Option, - /// Human-readable reason for loss - reason: String, - /// Error code: "device_busy", "device_disconnected", "capture_error", "io_error" - error_code: String, - }, - - /// Audio device is reconnecting - #[serde(rename = "audio.reconnecting")] - AudioReconnecting { - /// Current retry attempt number - attempt: u32, - }, - - /// Audio device has recovered after error - #[serde(rename = "audio.recovered")] - AudioRecovered { - /// Audio device name - device: Option, - }, - - // ============================================================================ - // System Events - // ============================================================================ - /// A device was added (hot-plug) - #[serde(rename = "system.device_added")] - SystemDeviceAdded { - /// Device type: "video", "audio", "hid", etc. - device_type: String, - /// Device path - device_path: String, - /// Device name/description - device_name: String, - }, - - /// A device was removed (hot-unplug) - #[serde(rename = "system.device_removed")] - SystemDeviceRemoved { - /// Device type - device_type: String, - /// Device path that was removed - device_path: String, - }, - - /// System error or warning - #[serde(rename = "system.error")] - SystemError { - /// Module that generated the error: "stream", "hid", "msd", "atx" - module: String, - /// Severity: "warning", "error", "critical" - severity: String, - /// Error message - message: String, - }, - /// Complete device information (sent on WebSocket connect and state changes) #[serde(rename = "system.device_info")] DeviceInfo { @@ -559,29 +405,11 @@ impl SystemEvent { Self::WebRTCIceCandidate { .. } => "webrtc.ice_candidate", Self::WebRTCIceComplete { .. } => "webrtc.ice_complete", Self::HidStateChanged { .. } => "hid.state_changed", - Self::HidBackendSwitching { .. } => "hid.backend_switching", - Self::HidDeviceLost { .. } => "hid.device_lost", - Self::HidReconnecting { .. } => "hid.reconnecting", - Self::HidRecovered { .. } => "hid.recovered", Self::MsdStateChanged { .. } => "msd.state_changed", - Self::MsdImageMounted { .. } => "msd.image_mounted", - Self::MsdImageUnmounted => "msd.image_unmounted", Self::MsdUploadProgress { .. } => "msd.upload_progress", Self::MsdDownloadProgress { .. } => "msd.download_progress", - Self::MsdUsbStatusChanged { .. } => "msd.usb_status_changed", - Self::MsdError { .. } => "msd.error", - Self::MsdRecovered => "msd.recovered", Self::AtxStateChanged { .. } => "atx.state_changed", - Self::AtxActionExecuted { .. } => "atx.action_executed", Self::AudioStateChanged { .. } => "audio.state_changed", - Self::AudioDeviceSelected { .. } => "audio.device_selected", - Self::AudioQualityChanged { .. } => "audio.quality_changed", - Self::AudioDeviceLost { .. } => "audio.device_lost", - Self::AudioReconnecting { .. } => "audio.reconnecting", - Self::AudioRecovered { .. } => "audio.recovered", - Self::SystemDeviceAdded { .. } => "system.device_added", - Self::SystemDeviceRemoved { .. } => "system.device_removed", - Self::SystemError { .. } => "system.error", Self::DeviceInfo { .. } => "system.device_info", Self::Error { .. } => "error", } @@ -621,13 +449,11 @@ mod tests { }; assert_eq!(event.event_name(), "stream.state_changed"); - let event = SystemEvent::MsdImageMounted { - image_id: "123".to_string(), - image_name: "ubuntu.iso".to_string(), - size: 1024, - cdrom: true, + let event = SystemEvent::MsdStateChanged { + mode: MsdMode::Image, + connected: true, }; - assert_eq!(event.event_name(), "msd.image_mounted"); + assert_eq!(event.event_name(), "msd.state_changed"); } #[test] diff --git a/src/hid/backend.rs b/src/hid/backend.rs index ed9c5cd7..dc519160 100644 --- a/src/hid/backend.rs +++ b/src/hid/backend.rs @@ -75,6 +75,19 @@ impl HidBackendType { } } +/// Current runtime status reported by a HID backend. +#[derive(Debug, Clone, Default)] +pub struct HidBackendStatus { + /// Whether the backend has been initialized and can accept requests. + pub initialized: bool, + /// Whether the backend is currently online and communicating successfully. + pub online: bool, + /// Current user-facing error, if any. + pub error: Option, + /// Current programmatic error code, if any. + pub error_code: Option, +} + /// HID backend trait #[async_trait] pub trait HidBackend: Send + Sync { @@ -104,12 +117,8 @@ 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(()) - } + /// Get the current backend runtime status. + fn status(&self) -> HidBackendStatus; /// Check if backend supports absolute mouse positioning fn supports_absolute_mouse(&self) -> bool { diff --git a/src/hid/ch9329.rs b/src/hid/ch9329.rs index 8f35d1f4..e7ec3a93 100644 --- a/src/hid/ch9329.rs +++ b/src/hid/ch9329.rs @@ -21,11 +21,13 @@ use async_trait::async_trait; use parking_lot::{Mutex, RwLock}; use serde::{Deserialize, Serialize}; use std::io::{Read, Write}; -use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU8, Ordering}; +use std::sync::{mpsc, Arc}; +use std::thread; use std::time::{Duration, Instant}; -use tracing::{debug, info, trace, warn}; +use tracing::{info, trace, warn}; -use super::backend::HidBackend; +use super::backend::{HidBackend, HidBackendStatus}; use super::keymap; use super::types::{KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType}; use crate::error::{AppError, Result}; @@ -56,14 +58,14 @@ const MAX_DATA_LEN: usize = 64; /// CH9329 absolute mouse resolution const CH9329_MOUSE_RESOLUTION: u32 = 4096; -/// Default retry count for failed operations -const DEFAULT_RETRY_COUNT: u32 = 3; +/// How often the worker probes the chip when idle. +const PROBE_INTERVAL_MS: u64 = 100; -/// Reset wait time in milliseconds (after software reset) -const RESET_WAIT_MS: u64 = 2000; +/// How long the worker waits before reopening the serial port after a failure. +const RECONNECT_DELAY_MS: u64 = 2000; -/// Cooldown between retries in milliseconds -const RETRY_COOLDOWN_MS: u64 = 100; +/// Initial startup wait for the worker to confirm CH9329 is reachable. +const INIT_WAIT_MS: u64 = 3000; /// CH9329 command codes #[allow(dead_code)] @@ -361,14 +363,47 @@ const MAX_PACKET_SIZE: usize = 70; // CH9329 Backend Implementation // ============================================================================ +#[derive(Default)] +struct Ch9329RuntimeState { + initialized: AtomicBool, + online: AtomicBool, + last_error: RwLock>, + last_success: Mutex>, +} + +impl Ch9329RuntimeState { + fn clear_error(&self) { + *self.last_error.write() = None; + } + + fn set_online(&self) { + self.online.store(true, Ordering::Relaxed); + *self.last_success.lock() = Some(Instant::now()); + self.clear_error(); + } + + fn set_error(&self, reason: impl Into, error_code: impl Into) { + self.online.store(false, Ordering::Relaxed); + *self.last_error.write() = Some((reason.into(), error_code.into())); + } +} + +enum WorkerCommand { + Packet { cmd: u8, data: Vec }, + ResetState, + Shutdown, +} + /// CH9329 HID backend pub struct Ch9329Backend { /// Serial port path port_path: String, /// Baud rate baud_rate: u32, - /// Serial port handle - port: Mutex>>, + /// Worker command sender + worker_tx: Mutex>>, + /// Background worker thread + worker_handle: Mutex>>, /// Current keyboard state keyboard_state: Mutex, /// Current mouse button state @@ -378,9 +413,9 @@ pub struct Ch9329Backend { /// Screen height for absolute mouse coordinate conversion screen_height: u32, /// Cached chip information - chip_info: RwLock>, + chip_info: Arc>>, /// LED status cache - led_status: RwLock, + led_status: Arc>, /// Device address (default 0x00) address: u8, /// Last absolute mouse X position (CH9329 coordinate: 0-4095) @@ -389,14 +424,8 @@ pub struct Ch9329Backend { last_abs_y: AtomicU16, /// Whether relative mouse mode is active (set by incoming events) relative_mouse_active: AtomicBool, - /// Consecutive error count - error_count: AtomicU32, - /// Whether a reset is in progress - reset_in_progress: AtomicBool, - /// Last successful communication time - last_success: Mutex>, - /// Maximum retry count for failed operations - max_retries: u32, + /// Shared runtime status updated only by the worker. + runtime: Arc, } impl Ch9329Backend { @@ -410,24 +439,34 @@ impl Ch9329Backend { Ok(Self { port_path: port_path.to_string(), baud_rate, - port: Mutex::new(None), + worker_tx: Mutex::new(None), + worker_handle: Mutex::new(None), keyboard_state: Mutex::new(KeyboardReport::default()), mouse_buttons: AtomicU8::new(0), screen_width: 1920, screen_height: 1080, - chip_info: RwLock::new(None), - led_status: RwLock::new(LedStatus::default()), + chip_info: Arc::new(RwLock::new(None)), + led_status: Arc::new(RwLock::new(LedStatus::default())), address: DEFAULT_ADDR, last_abs_x: AtomicU16::new(0), last_abs_y: AtomicU16::new(0), relative_mouse_active: AtomicBool::new(false), - error_count: AtomicU32::new(0), - reset_in_progress: AtomicBool::new(false), - last_success: Mutex::new(None), - max_retries: DEFAULT_RETRY_COUNT, + runtime: Arc::new(Ch9329RuntimeState::default()), }) } + fn record_error(&self, reason: impl Into, error_code: impl Into) { + self.runtime.set_error(reason, error_code); + } + + fn mark_online(&self) { + self.runtime.set_online(); + } + + fn clear_error(&self) { + self.runtime.clear_error(); + } + /// Check if the serial port device file exists pub fn check_port_exists(&self) -> bool { std::path::Path::new(&self.port_path).exists() @@ -438,11 +477,6 @@ impl Ch9329Backend { &self.port_path } - /// Check if the port is currently open - pub fn is_port_open(&self) -> bool { - self.port.lock().is_some() - } - /// Convert serialport error to HidError fn serial_error_to_hid_error(e: serialport::Error, operation: &str) -> AppError { let error_code = match e.kind() { @@ -459,48 +493,12 @@ impl Ch9329Backend { } } - /// Try to reconnect to the serial port - /// - /// This method is called when the device is detected as lost. - /// It will attempt to reopen the port and verify the CH9329 is responding. - pub fn try_reconnect(&self) -> Result<()> { - // First check if device file exists - 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(), - }); + fn backend_error(reason: impl Into, error_code: impl Into) -> AppError { + AppError::HidError { + backend: "ch9329".to_string(), + reason: reason.into(), + error_code: error_code.into(), } - - // Close existing port if any - *self.port.lock() = None; - - // Try to open the port - let port = serialport::new(&self.port_path, self.baud_rate) - .timeout(Duration::from_millis(RESPONSE_TIMEOUT_MS)) - .open() - .map_err(|e| Self::serial_error_to_hid_error(e, "Failed to open serial port"))?; - - *self.port.lock() = Some(port); - info!( - "CH9329 serial port reopened: {} @ {} baud", - self.port_path, self.baud_rate - ); - - // Verify connection with GET_INFO command - self.query_chip_info().map_err(|e| { - // Close the port on failure - *self.port.lock() = None; - AppError::HidError { - backend: "ch9329".to_string(), - reason: format!("CH9329 not responding after reconnect: {}", e), - error_code: "no_response".to_string(), - } - })?; - - info!("CH9329 successfully reconnected"); - Ok(()) } /// Calculate checksum for CH9329 packet (sum of ALL bytes including header) @@ -514,7 +512,7 @@ impl Ch9329Backend { /// Packet format: `[Header 0x57 0xAB] [Address] [Command] [Length] [Data] [Checksum]` /// Returns the packet buffer and the actual length #[inline] - fn build_packet_buf(&self, cmd: u8, data: &[u8]) -> ([u8; MAX_PACKET_SIZE], usize) { + fn build_packet_buf(address: u8, cmd: u8, data: &[u8]) -> ([u8; MAX_PACKET_SIZE], usize) { debug_assert!( data.len() <= MAX_DATA_LEN, "Data too long for CH9329 packet" @@ -528,7 +526,7 @@ impl Ch9329Backend { packet[0] = PACKET_HEADER[0]; packet[1] = PACKET_HEADER[1]; // Address (1 byte) - packet[2] = self.address; + packet[2] = address; // Command (1 byte) packet[3] = cmd; // Length (1 byte) - data length only @@ -543,120 +541,217 @@ impl Ch9329Backend { } /// Build a CH9329 packet (legacy Vec version for compatibility) - fn build_packet(&self, cmd: u8, data: &[u8]) -> Vec { - let (buf, len) = self.build_packet_buf(cmd, data); + fn build_packet(address: u8, cmd: u8, data: &[u8]) -> Vec { + let (buf, len) = Self::build_packet_buf(address, cmd, data); buf[..len].to_vec() } - /// Send a packet to the CH9329 (internal, no retry) - fn send_packet_raw(&self, cmd: u8, data: &[u8]) -> Result<()> { - let (packet, packet_len) = self.build_packet_buf(cmd, data); - - let mut port_guard = self.port.lock(); - if let Some(ref mut port) = *port_guard { - port.write_all(&packet[..packet_len]) - .map_err(|e| AppError::HidError { - backend: "ch9329".to_string(), - reason: format!("Failed to write to CH9329: {}", e), - error_code: "write_failed".to_string(), - })?; - // Only log mouse button events at debug level to avoid flooding - if cmd == cmd::SEND_MS_ABS_DATA && data.len() >= 2 && data[1] != 0 { - debug!( - "CH9329 TX [cmd=0x{:02X}]: {:02X?}", - cmd, - &packet[..packet_len] - ); - } - Ok(()) - } else { - Err(AppError::HidError { - backend: "ch9329".to_string(), - reason: "CH9329 port not opened".to_string(), - error_code: "port_not_opened".to_string(), - }) + fn open_port(port_path: &str, baud_rate: u32) -> Result> { + if !std::path::Path::new(port_path).exists() { + return Err(Self::backend_error( + format!("Serial port {} not found", port_path), + "port_not_found", + )); } + + serialport::new(port_path, baud_rate) + .timeout(Duration::from_millis(RESPONSE_TIMEOUT_MS)) + .open() + .map_err(|e| Self::serial_error_to_hid_error(e, "Failed to open serial port")) } - /// Send a packet to the CH9329 with automatic retry and reset on failure - fn send_packet(&self, cmd: u8, data: &[u8]) -> Result<()> { - // Don't retry reset commands to avoid infinite loops - if cmd == cmd::RESET { - return self.send_packet_raw(cmd, data); + fn write_packet( + port: &mut dyn serialport::SerialPort, + address: u8, + cmd: u8, + data: &[u8], + ) -> Result<()> { + let packet = Self::build_packet(address, cmd, data); + port.write_all(&packet) + .map_err(|e| Self::backend_error(format!("Failed to write to CH9329: {}", e), "write_failed"))?; + trace!("CH9329 TX [cmd=0x{:02X}]: {:02X?}", cmd, packet); + Ok(()) + } + + fn try_extract_response(buffer: &[u8]) -> Option<(Response, usize)> { + let mut offset = 0; + while offset + 6 <= buffer.len() { + if buffer[offset] != PACKET_HEADER[0] || buffer[offset + 1] != PACKET_HEADER[1] { + offset += 1; + continue; + } + + let len = buffer[offset + 4] as usize; + let frame_len = 6 + len; + if offset + frame_len > buffer.len() { + return None; + } + + let frame = &buffer[offset..offset + frame_len]; + if let Some(response) = Response::parse(frame) { + return Some((response, offset + frame_len)); + } + + offset += 1; } - let mut last_error = None; + None + } - for attempt in 0..self.max_retries { - match self.send_packet_raw(cmd, data) { - Ok(()) => { - // Success - reset error count and update last success time - self.error_count.store(0, Ordering::Relaxed); - *self.last_success.lock() = Some(Instant::now()); - return Ok(()); - } - Err(e) => { - let count = self.error_count.fetch_add(1, Ordering::Relaxed) + 1; - last_error = Some(e); + fn expected_response_cmd(cmd: u8, is_error: bool) -> u8 { + cmd | if is_error { RESPONSE_ERROR_MASK } else { RESPONSE_SUCCESS_MASK } + } - if attempt + 1 < self.max_retries { - debug!( - "CH9329 send failed (attempt {}/{}), error count: {}", - attempt + 1, - self.max_retries, - count - ); + fn xfer_packet( + port: &mut dyn serialport::SerialPort, + address: u8, + cmd: u8, + data: &[u8], + ) -> Result { + Self::write_packet(port, address, cmd, data)?; - // Try reset if we have multiple consecutive errors - if count >= 2 && !self.reset_in_progress.load(Ordering::Relaxed) { - if let Err(reset_err) = self.try_reset_and_recover() { - warn!("CH9329 reset failed: {}", reset_err); - } - } else { - // Brief cooldown before retry - std::thread::sleep(Duration::from_millis(RETRY_COOLDOWN_MS)); + let mut pending = Vec::with_capacity(128); + let deadline = Instant::now() + Duration::from_millis(RESPONSE_TIMEOUT_MS); + let expected_ok = Self::expected_response_cmd(cmd, false); + let expected_err = Self::expected_response_cmd(cmd, true); + + loop { + let mut chunk = [0u8; 128]; + match port.read(&mut chunk) { + Ok(n) if n > 0 => { + pending.extend_from_slice(&chunk[..n]); + trace!("CH9329 RX pending: {:02X?}", pending); + + while let Some((response, consumed)) = Self::try_extract_response(&pending) { + pending.drain(..consumed); + if response.cmd == expected_ok || response.cmd == expected_err { + return Ok(response); } + + trace!( + "CH9329 ignored out-of-order response: expected 0x{:02X}/0x{:02X}, got 0x{:02X}", + expected_ok, + expected_err, + response.cmd + ); + } + + if pending.len() > MAX_PACKET_SIZE * 4 { + let keep = MAX_PACKET_SIZE; + pending.drain(..pending.len().saturating_sub(keep)); } } + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => { + return Err(Self::backend_error( + format!("Failed to read from CH9329: {}", e), + "read_failed", + )); + } } - } - // All retries exhausted - Err(last_error.unwrap_or_else(|| AppError::HidError { - backend: "ch9329".to_string(), - reason: "Send failed after all retries".to_string(), - error_code: "max_retries_exceeded".to_string(), - })) + if Instant::now() >= deadline { + return Err(Self::backend_error( + format!("No matching response from CH9329 for cmd 0x{:02X}", cmd), + "no_response", + )); + } + + thread::sleep(Duration::from_millis(1)); + } } - /// Try to reset the CH9329 chip and recover communication - /// - /// This method: - /// 1. Sends RESET command (0x00 0x0F 0x00) - /// 2. Waits for chip to reboot (2 seconds) - /// 3. Verifies communication with GET_INFO - fn try_reset_and_recover(&self) -> Result<()> { - // Prevent concurrent resets - if self.reset_in_progress.swap(true, Ordering::SeqCst) { - debug!("CH9329 reset already in progress, skipping"); - return Ok(()); + fn try_best_effort_reset(port: &mut dyn serialport::SerialPort, address: u8) { + if let Err(err) = Self::write_packet(port, address, cmd::RESET, &[]) { + trace!("CH9329 best-effort reset failed: {}", err); + } + } + + fn query_chip_info_on_port( + port: &mut dyn serialport::SerialPort, + address: u8, + ) -> Result { + let response = Self::xfer_packet(port, address, cmd::GET_INFO, &[])?; + 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(Self::backend_error(reason, "protocol_error")); } - info!("CH9329: Attempting automatic reset and recovery"); + ChipInfo::from_response(&response.data) + .ok_or_else(|| Self::backend_error("Failed to parse chip info", "invalid_response")) + } - let result = (|| { - // Send reset command directly (bypass retry logic) - self.send_packet_raw(cmd::RESET, &[])?; + fn update_chip_info_cache( + chip_info: &Arc>>, + led_status: &Arc>, + info: ChipInfo, + ) { + *chip_info.write() = Some(info.clone()); + *led_status.write() = LedStatus { + num_lock: info.num_lock, + caps_lock: info.caps_lock, + scroll_lock: info.scroll_lock, + }; + } - // Wait for chip to reset (2 seconds as per reference implementation) - info!("CH9329: Waiting {}ms for chip to reset...", RESET_WAIT_MS); - std::thread::sleep(Duration::from_millis(RESET_WAIT_MS)); + fn enqueue_command(&self, command: WorkerCommand) -> Result<()> { + let guard = self.worker_tx.lock(); + let sender = guard.as_ref().ok_or_else(|| { + Self::backend_error("CH9329 worker is not running", "worker_stopped") + })?; + sender + .send(command) + .map_err(|_| Self::backend_error("CH9329 worker stopped", "worker_stopped")) + } - // Verify communication - match self.query_chip_info() { - Ok(info) => { + fn send_packet(&self, cmd: u8, data: &[u8]) -> Result<()> { + self.enqueue_command(WorkerCommand::Packet { + cmd, + data: data.to_vec(), + }) + } + + pub fn error_count(&self) -> u32 { + 0 + } + + /// Check if device communication is healthy (recent successful operation) + pub fn is_healthy(&self) -> bool { + if let Some(last) = *self.runtime.last_success.lock() { + last.elapsed() < Duration::from_secs(30) + } else { + false + } + } + + fn worker_reconnect_loop( + rx: &mpsc::Receiver, + port_path: &str, + baud_rate: u32, + address: u8, + chip_info: &Arc>>, + led_status: &Arc>, + runtime: &Arc, + ) -> Option> { + loop { + match rx.recv_timeout(Duration::from_millis(RECONNECT_DELAY_MS)) { + Ok(WorkerCommand::Shutdown) => return None, + Ok(_) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => return None, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + + match Self::open_port(port_path, baud_rate).and_then(|mut port| { + let info = Self::query_chip_info_on_port(port.as_mut(), address)?; + Ok((port, info)) + }) { + Ok((port, info)) => { info!( - "CH9329: Recovery successful, chip version: {}, USB: {}", + "CH9329 reconnected: {}, USB: {}", info.version, if info.usb_connected { "connected" @@ -664,124 +759,39 @@ impl Ch9329Backend { "disconnected" } ); - // Reset error count on successful recovery - self.error_count.store(0, Ordering::Relaxed); - *self.last_success.lock() = Some(Instant::now()); - Ok(()) + Self::update_chip_info_cache(chip_info, led_status, info); + runtime.set_online(); + return Some(port); } - Err(e) => { - warn!("CH9329: Recovery verification failed: {}", e); - Err(e) - } - } - })(); - - self.reset_in_progress.store(false, Ordering::SeqCst); - result - } - - /// Get current error count - pub fn error_count(&self) -> u32 { - self.error_count.load(Ordering::Relaxed) - } - - /// Check if device communication is healthy (recent successful operation) - pub fn is_healthy(&self) -> bool { - if let Some(last) = *self.last_success.lock() { - // Consider healthy if last success was within 30 seconds - last.elapsed() < Duration::from_secs(30) - } else { - false - } - } - - /// Send a packet and read response - fn send_and_receive(&self, cmd: u8, data: &[u8]) -> Result { - let packet = self.build_packet(cmd, data); - - let mut port_guard = self.port.lock(); - if let Some(ref mut port) = *port_guard { - // Send packet - port.write_all(&packet) - .map_err(|e| AppError::Internal(format!("Failed to write to CH9329: {}", e)))?; - trace!("CH9329 TX: {:02X?}", packet); - - // Wait for response - use shorter delay for faster response - // CH9329 typically responds within 5ms - std::thread::sleep(Duration::from_millis(5)); - - // Read response - let mut response_buf = [0u8; 128]; - match port.read(&mut response_buf) { - Ok(n) if n > 0 => { - trace!("CH9329 RX: {:02X?}", &response_buf[..n]); - if let Some(response) = Response::parse(&response_buf[..n]) { - if response.is_error { - if let Some(err) = response.error_code { - warn!("CH9329 error response: {}", err); - } - } - return Ok(response); + Err(err) => { + if let AppError::HidError { + reason, + error_code, + .. + } = err + { + runtime.set_error(reason, error_code); } - Err(AppError::Internal("Invalid CH9329 response".to_string())) } - Ok(_) => Err(AppError::Internal("No response from CH9329".to_string())), - Err(e) if e.kind() == std::io::ErrorKind::TimedOut => { - // Timeout is acceptable for some commands - debug!("CH9329 response timeout (may be normal)"); - Err(AppError::Internal("CH9329 response timeout".to_string())) - } - Err(e) => Err(AppError::Internal(format!( - "Failed to read from CH9329: {}", - e - ))), } - } else { - Err(AppError::Internal("CH9329 port not opened".to_string())) } } - 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 - // ======================================================================== - /// Get cached chip information pub fn get_chip_info(&self) -> Option { self.chip_info.read().clone() } - /// Query and update chip information pub fn query_chip_info(&self) -> Result { - let response = self.send_and_receive(cmd::GET_INFO, &[])?; - - info!( - "CH9329 GET_INFO response: cmd=0x{:02X}, data={:02X?}, is_error={}", - response.cmd, response.data, response.is_error - ); - - 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)); + if let Some(info) = self.get_chip_info() { + return Ok(info); } - self.update_chip_info_cache(&response) + let error = self.runtime.last_error.read().clone(); + Err(match error { + Some((reason, error_code)) => Self::backend_error(reason, error_code), + None => Self::backend_error("CH9329 info unavailable", "not_ready"), + }) } /// Get cached LED status @@ -789,57 +799,19 @@ impl Ch9329Backend { *self.led_status.read() } - /// Software reset the chip - /// - /// Sends reset command and waits for chip to reboot. - /// Use `try_reset_and_recover` for automatic recovery with verification. pub fn software_reset(&self) -> Result<()> { - info!("CH9329: Sending software reset command"); - self.send_packet_raw(cmd::RESET, &[])?; - - // Wait for chip to reset (2 seconds as per Python reference) - info!("CH9329: Waiting {}ms for chip to reset...", RESET_WAIT_MS); - std::thread::sleep(Duration::from_millis(RESET_WAIT_MS)); - - Ok(()) + self.send_packet(cmd::RESET, &[]) } - /// Force reset and verify recovery - /// - /// Public wrapper for try_reset_and_recover. - pub fn reset_and_verify(&self) -> Result<()> { - self.try_reset_and_recover() - } - - /// Restore factory default configuration pub fn restore_factory_defaults(&self) -> Result<()> { - info!("CH9329: Restoring factory defaults"); - let response = self.send_and_receive(cmd::SET_DEFAULT_CFG, &[])?; - - if response.is_success() { - Ok(()) - } else { - Err(AppError::Internal( - "Failed to restore factory defaults".to_string(), - )) - } + self.send_packet(cmd::SET_DEFAULT_CFG, &[]) } - // ======================================================================== - // HID Commands - // ======================================================================== - - /// Send keyboard report via CH9329 fn send_keyboard_report(&self, report: &KeyboardReport) -> Result<()> { - // CH9329 keyboard packet: 8 bytes (modifier, reserved, key1-6) let data = report.to_bytes(); self.send_packet(cmd::SEND_KB_GENERAL_DATA, &data) } - /// Send multimedia keyboard key - /// - /// For ACPI keys (Power/Sleep/Wake): data = [0x01, acpi_byte] - /// For other multimedia keys: data = [0x02, byte2, byte3, byte4] pub fn send_media_key(&self, data: &[u8]) -> Result<()> { if data.len() < 2 || data.len() > 4 { return Err(AppError::Internal( @@ -849,7 +821,6 @@ impl Ch9329Backend { self.send_packet(cmd::SEND_KB_MEDIA_DATA, data) } - /// Send ACPI key (Power, Sleep, Wake) pub fn send_acpi_key(&self, power: bool, sleep: bool, wake: bool) -> Result<()> { let mut byte = 0u8; if power { @@ -864,23 +835,15 @@ impl Ch9329Backend { self.send_media_key(&[0x01, byte]) } - /// Release all media keys pub fn release_media_keys(&self) -> Result<()> { self.send_media_key(&[0x02, 0x00, 0x00, 0x00]) } - /// Send relative mouse report via CH9329 - /// - /// Data format: [0x01, buttons, dx, dy, wheel] fn send_mouse_relative(&self, buttons: u8, dx: i8, dy: i8, wheel: i8) -> Result<()> { let data = [0x01, buttons, dx as u8, dy as u8, wheel as u8]; self.send_packet(cmd::SEND_MS_REL_DATA, &data) } - /// Send absolute mouse report via CH9329 - /// - /// Data format: [0x02, buttons, x_lo, x_hi, y_lo, y_hi, wheel] - /// Coordinate range: 0-4095 fn send_mouse_absolute(&self, buttons: u8, x: u16, y: u16, wheel: i8) -> Result<()> { let data = [ 0x02, @@ -891,22 +854,172 @@ impl Ch9329Backend { (y >> 8) as u8, wheel as u8, ]; - - // Use send_packet which has retry logic built-in self.send_packet(cmd::SEND_MS_ABS_DATA, &data)?; - trace!("CH9329 mouse: buttons=0x{:02X} pos=({},{})", buttons, x, y); - Ok(()) } - /// Send custom HID data pub fn send_custom_hid(&self, data: &[u8]) -> Result<()> { if data.len() > MAX_DATA_LEN { return Err(AppError::Internal("Custom HID data too long".to_string())); } self.send_packet(cmd::SEND_MY_HID_DATA, data) } + + fn worker_loop( + port_path: String, + baud_rate: u32, + address: u8, + rx: mpsc::Receiver, + chip_info: Arc>>, + led_status: Arc>, + runtime: Arc, + init_tx: mpsc::Sender>, + ) { + runtime.initialized.store(true, Ordering::Relaxed); + + let mut port = match Self::open_port(&port_path, baud_rate).and_then(|mut port| { + let info = Self::query_chip_info_on_port(port.as_mut(), address)?; + Ok((port, info)) + }) { + Ok((port, info)) => { + info!( + "CH9329 serial port opened: {} @ {} baud", + port_path, baud_rate + ); + Self::update_chip_info_cache(&chip_info, &led_status, info.clone()); + runtime.set_online(); + let _ = init_tx.send(Ok(info)); + port + } + Err(err) => { + if let AppError::HidError { + reason, + error_code, + .. + } = &err + { + runtime.set_error(reason.clone(), error_code.clone()); + } + let _ = init_tx.send(Err(err)); + runtime.initialized.store(false, Ordering::Relaxed); + return; + } + }; + + loop { + match rx.recv_timeout(Duration::from_millis(PROBE_INTERVAL_MS)) { + Ok(WorkerCommand::Packet { cmd, data }) => { + if let Err(err) = Self::xfer_packet(port.as_mut(), address, cmd, &data) { + if let AppError::HidError { + reason, + error_code, + .. + } = err + { + runtime.set_error(reason, error_code); + } + + Self::try_best_effort_reset(port.as_mut(), address); + + let Some(new_port) = Self::worker_reconnect_loop( + &rx, + &port_path, + baud_rate, + address, + &chip_info, + &led_status, + &runtime, + ) else { + break; + }; + port = new_port; + } else { + runtime.set_online(); + } + } + Ok(WorkerCommand::ResetState) => { + let reset_sequence = [ + (cmd::SEND_KB_GENERAL_DATA, vec![0; 8]), + (cmd::SEND_MS_ABS_DATA, vec![0x02, 0, 0, 0, 0, 0, 0]), + (cmd::SEND_KB_MEDIA_DATA, vec![0x02, 0x00, 0x00, 0x00]), + ]; + + let mut reset_failed = false; + for (cmd, data) in reset_sequence { + if let Err(err) = Self::xfer_packet(port.as_mut(), address, cmd, &data) { + if let AppError::HidError { + reason, + error_code, + .. + } = err + { + runtime.set_error(reason, error_code); + } + reset_failed = true; + Self::try_best_effort_reset(port.as_mut(), address); + break; + } + } + + if reset_failed { + let Some(new_port) = Self::worker_reconnect_loop( + &rx, + &port_path, + baud_rate, + address, + &chip_info, + &led_status, + &runtime, + ) else { + break; + }; + port = new_port; + } else { + runtime.set_online(); + } + } + Ok(WorkerCommand::Shutdown) => break, + Err(mpsc::RecvTimeoutError::Timeout) => { + match Self::query_chip_info_on_port(port.as_mut(), address) { + Ok(info) => { + Self::update_chip_info_cache(&chip_info, &led_status, info); + runtime.set_online(); + } + Err(err) => { + if let AppError::HidError { + reason, + error_code, + .. + } = err + { + runtime.set_error(reason, error_code); + } + + Self::try_best_effort_reset(port.as_mut(), address); + + let Some(new_port) = Self::worker_reconnect_loop( + &rx, + &port_path, + baud_rate, + address, + &chip_info, + &led_status, + &runtime, + ) else { + break; + }; + port = new_port; + } + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + + runtime.online.store(false, Ordering::Relaxed); + runtime.initialized.store(false, Ordering::Relaxed); + } } // ============================================================================ @@ -920,51 +1033,74 @@ impl HidBackend for Ch9329Backend { } async fn init(&self) -> Result<()> { - // Open serial port - let port = serialport::new(&self.port_path, self.baud_rate) - .timeout(Duration::from_millis(RESPONSE_TIMEOUT_MS)) - .open() - .map_err(|e| { - AppError::Internal(format!( - "Failed to open serial port {}: {}", - self.port_path, e + if self.worker_handle.lock().is_some() { + return Ok(()); + } + + let (tx, rx) = mpsc::channel(); + let (init_tx, init_rx) = mpsc::channel(); + let port_path = self.port_path.clone(); + let baud_rate = self.baud_rate; + let address = self.address; + let chip_info = self.chip_info.clone(); + let led_status = self.led_status.clone(); + let runtime = self.runtime.clone(); + + let handle = thread::Builder::new() + .name("ch9329-worker".to_string()) + .spawn(move || { + Self::worker_loop( + port_path, + baud_rate, + address, + rx, + chip_info, + led_status, + runtime, + init_tx, + ); + }) + .map_err(|e| AppError::Internal(format!("Failed to spawn CH9329 worker: {}", e)))?; + + match init_rx.recv_timeout(Duration::from_millis(INIT_WAIT_MS)) { + Ok(Ok(info)) => { + info!( + "CH9329 chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}", + info.version, + if info.usb_connected { + "connected" + } else { + "disconnected" + }, + info.num_lock, + info.caps_lock, + info.scroll_lock + ); + *self.worker_tx.lock() = Some(tx); + *self.worker_handle.lock() = Some(handle); + self.mark_online(); + Ok(()) + } + Ok(Err(err)) => { + let _ = handle.join(); + self.record_error( + format!("CH9329 not responding on {} @ {} baud: {}", self.port_path, self.baud_rate, err), + "init_failed", + ); + Err(AppError::Internal(format!( + "CH9329 not responding on {} @ {} baud: {}", + self.port_path, self.baud_rate, err + ))) + } + Err(_) => { + let _ = tx.send(WorkerCommand::Shutdown); + let _ = handle.join(); + self.record_error("Timed out waiting for CH9329 worker init", "init_timeout"); + Err(AppError::Internal( + "Timed out waiting for CH9329 initialization".to_string(), )) - })?; - - *self.port.lock() = Some(port); - info!( - "CH9329 serial port opened: {} @ {} baud", - self.port_path, self.baud_rate - ); - - // Query chip info to verify connection - // If this fails, the device is not usable (wrong baud rate, not connected, etc.) - let info = self.query_chip_info().map_err(|e| { - // Close port on failure - *self.port.lock() = None; - AppError::Internal(format!( - "CH9329 not responding on {} @ {} baud: {}", - self.port_path, self.baud_rate, e - )) - })?; - - info!( - "CH9329 chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}", - info.version, - if info.usb_connected { - "connected" - } else { - "disconnected" - }, - info.num_lock, - info.caps_lock, - info.scroll_lock - ); - - // Initialize last success timestamp - *self.last_success.lock() = Some(Instant::now()); - - Ok(()) + } + } } async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> { @@ -1104,64 +1240,41 @@ impl HidBackend for Ch9329Backend { } async fn shutdown(&self) -> Result<()> { - // Reset before closing - self.reset().await?; - - // Close port - *self.port.lock() = None; + let _ = self.enqueue_command(WorkerCommand::ResetState); + let sender = self.worker_tx.lock().take(); + if let Some(sender) = sender { + let _ = sender.send(WorkerCommand::Shutdown); + } + if let Some(handle) = self.worker_handle.lock().take() { + let _ = handle.join(); + } + self.runtime.initialized.store(false, Ordering::Relaxed); + self.runtime.online.store(false, Ordering::Relaxed); + self.clear_error(); info!("CH9329 backend shutdown"); 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(), - }); + fn status(&self) -> HidBackendStatus { + let initialized = self.runtime.initialized.load(Ordering::Relaxed); + let mut online = initialized && self.runtime.online.load(Ordering::Relaxed); + let mut error = self.runtime.last_error.read().clone(); + + if initialized && !self.check_port_exists() { + online = false; + error = Some(( + format!("Serial port {} not found", self.port_path), + "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(), - }); + HidBackendStatus { + initialized, + online, + error: error.as_ref().map(|(reason, _)| reason.clone()), + error_code: error.as_ref().map(|(_, code)| code.clone()), } - - 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 { diff --git a/src/hid/mod.rs b/src/hid/mod.rs index 3a0f2fda..7b28a882 100644 --- a/src/hid/mod.rs +++ b/src/hid/mod.rs @@ -16,13 +16,11 @@ pub mod ch9329; pub mod consumer; pub mod datachannel; pub mod keymap; -pub mod monitor; pub mod otg; pub mod types; pub mod websocket; -pub use backend::{HidBackend, HidBackendType}; -pub use monitor::{HidHealthMonitor, HidHealthStatus, HidMonitorConfig}; +pub use backend::{HidBackend, HidBackendStatus, HidBackendType}; pub use otg::LedState; pub use types::{ ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, @@ -33,7 +31,7 @@ pub use types::{ #[derive(Debug, Clone)] pub struct HidInfo { /// Backend name - pub name: &'static str, + pub name: String, /// Whether backend is initialized pub initialized: bool, /// Whether absolute mouse positioning is supported @@ -42,12 +40,84 @@ pub struct HidInfo { pub screen_resolution: Option<(u32, u32)>, } +/// Unified HID runtime state used by snapshots and events. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HidRuntimeState { + /// Whether a backend is configured and expected to exist. + pub available: bool, + /// Stable backend key: "otg", "ch9329", "none". + pub backend: String, + /// Whether the backend is currently initialized and operational. + pub initialized: bool, + /// Whether the backend is currently online. + pub online: bool, + /// Whether absolute mouse positioning is supported. + pub supports_absolute_mouse: bool, + /// Screen resolution for absolute mouse mode. + pub screen_resolution: Option<(u32, u32)>, + /// Device path associated with the backend, if any. + pub device: Option, + /// Current user-facing error, if any. + pub error: Option, + /// Current programmatic error code, if any. + pub error_code: Option, +} + +impl HidRuntimeState { + fn from_backend_type(backend_type: &HidBackendType) -> Self { + Self { + available: !matches!(backend_type, HidBackendType::None), + backend: backend_type.name_str().to_string(), + initialized: false, + online: false, + supports_absolute_mouse: false, + screen_resolution: None, + device: device_for_backend_type(backend_type), + error: None, + error_code: None, + } + } + + fn from_backend(backend_type: &HidBackendType, backend: &dyn HidBackend) -> Self { + let status = backend.status(); + Self { + available: !matches!(backend_type, HidBackendType::None), + backend: backend_type.name_str().to_string(), + initialized: status.initialized, + online: status.online, + supports_absolute_mouse: backend.supports_absolute_mouse(), + screen_resolution: backend.screen_resolution(), + device: device_for_backend_type(backend_type), + error: status.error, + error_code: status.error_code, + } + } + + fn with_error( + backend_type: &HidBackendType, + current: &Self, + reason: impl Into, + error_code: impl Into, + ) -> Self { + let mut next = current.clone(); + next.available = !matches!(backend_type, HidBackendType::None); + next.backend = backend_type.name_str().to_string(); + next.initialized = false; + next.online = false; + next.device = device_for_backend_type(backend_type); + next.error = Some(reason.into()); + next.error_code = Some(error_code.into()); + next + } +} + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use tokio::sync::RwLock; use tracing::{info, warn}; +use tokio::sync::RwLock; use crate::error::{AppError, Result}; +use crate::events::{EventBus, SystemEvent}; use crate::otg::OtgService; use std::time::Duration; use tokio::sync::mpsc; @@ -56,7 +126,6 @@ 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 { @@ -75,9 +144,9 @@ pub struct HidController { /// Backend type (mutable for reload) backend_type: Arc>, /// Event bus for broadcasting state changes (optional) - events: tokio::sync::RwLock>>, - /// Health monitor for error tracking and recovery - monitor: Arc, + events: Arc>>>, + /// Unified HID runtime state. + runtime_state: Arc>, /// HID event queue sender (non-blocking) hid_tx: mpsc::Sender, /// HID event queue receiver (moved into worker on first start) @@ -88,10 +157,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, + /// Backend initialization fast flag + backend_available: Arc, } impl HidController { @@ -103,24 +170,23 @@ impl HidController { Self { otg_service, backend: Arc::new(RwLock::new(None)), - backend_type: Arc::new(RwLock::new(backend_type)), - events: tokio::sync::RwLock::new(None), - monitor: Arc::new(HidHealthMonitor::with_defaults()), + backend_type: Arc::new(RwLock::new(backend_type.clone())), + events: Arc::new(tokio::sync::RwLock::new(None)), + runtime_state: Arc::new(RwLock::new(HidRuntimeState::from_backend_type( + &backend_type, + ))), hid_tx, hid_rx: Mutex::new(Some(hid_rx)), 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), + backend_available: Arc::new(AtomicBool::new(false)), } } /// Set event bus for broadcasting state changes - pub async fn set_event_bus(&self, events: Arc) { - *self.events.write().await = Some(events.clone()); - // Also set event bus on the monitor for health notifications - self.monitor.set_event_bus(events).await; + pub async fn set_event_bus(&self, events: Arc) { + *self.events.write().await = Some(events); } /// Initialize the HID backend @@ -157,13 +223,27 @@ impl HidController { } }; - backend.init().await?; + if let Err(e) = backend.init().await { + self.backend_available.store(false, Ordering::Release); + let error_state = { + let backend_type = self.backend_type.read().await.clone(); + let current = self.runtime_state.read().await.clone(); + HidRuntimeState::with_error( + &backend_type, + ¤t, + format!("Failed to initialize HID backend: {}", e), + "init_failed", + ) + }; + self.apply_runtime_state(error_state).await; + return Err(e); + } + *self.backend.write().await = Some(backend); - self.backend_available.store(true, Ordering::Release); + self.sync_runtime_state_from_backend().await; // Start HID event worker (once) self.start_event_worker().await; - self.start_health_checker().await; info!("HID backend initialized: {:?}", backend_type); Ok(()) @@ -172,14 +252,25 @@ 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; + if let Some(backend) = self.backend.write().await.take() { + if let Err(e) = backend.shutdown().await { + warn!("Error shutting down HID backend: {}", e); + } + } self.backend_available.store(false, Ordering::Release); + let backend_type = self.backend_type.read().await.clone(); + let mut shutdown_state = HidRuntimeState::from_backend_type(&backend_type); + if matches!(backend_type, HidBackendType::None) { + shutdown_state.available = false; + } else { + shutdown_state.error = Some("HID backend stopped".to_string()); + shutdown_state.error_code = Some("shutdown".to_string()); + } + self.apply_runtime_state(shutdown_state).await; // If OTG backend, notify OtgService to disable HID - let backend_type = self.backend_type.read().await.clone(); if matches!(backend_type, HidBackendType::Otg) { if let Some(ref otg_service) = self.otg_service { info!("Disabling HID functions in OtgService"); @@ -241,7 +332,7 @@ impl HidController { /// Check if backend is available pub async fn is_available(&self) -> bool { - self.backend.read().await.is_some() + self.backend_available.load(Ordering::Acquire) } /// Get backend type @@ -251,60 +342,40 @@ impl HidController { /// Get backend info pub async fn info(&self) -> Option { - let backend = self.backend.read().await; - backend.as_ref().map(|b| HidInfo { - name: b.name(), - initialized: true, - supports_absolute_mouse: b.supports_absolute_mouse(), - screen_resolution: b.screen_resolution(), + let state = self.runtime_state.read().await.clone(); + if !state.available { + return None; + } + + Some(HidInfo { + name: state.backend, + initialized: state.initialized, + supports_absolute_mouse: state.supports_absolute_mouse, + screen_resolution: state.screen_resolution, }) } + /// Get current HID runtime state snapshot. + pub async fn snapshot(&self) -> HidRuntimeState { + self.runtime_state.read().await.clone() + } + /// Get current state as SystemEvent pub async fn current_state_event(&self) -> crate::events::SystemEvent { - let backend = self.backend.read().await; - let backend_type = self.backend_type().await; - let (backend_name, initialized) = match backend.as_ref() { - Some(b) => (b.name(), true), - None => (backend_type.name_str(), false), - }; - - // Include error information from monitor - let (error, error_code) = match self.monitor.status().await { - HidHealthStatus::Error { - reason, error_code, .. - } => (Some(reason), Some(error_code)), - _ => (None, None), - }; - - crate::events::SystemEvent::HidStateChanged { - backend: backend_name.to_string(), - initialized, - error, - error_code, + let state = self.snapshot().await; + SystemEvent::HidStateChanged { + backend: state.backend, + initialized: state.initialized, + online: state.online, + error: state.error, + error_code: state.error_code, } } - /// Get the health monitor reference - pub fn monitor(&self) -> &Arc { - &self.monitor - } - - /// Get current health status - pub async fn health_status(&self) -> HidHealthStatus { - self.monitor.status().await - } - - /// Check if the HID backend is healthy - pub async fn is_healthy(&self) -> bool { - self.monitor.is_healthy().await - } - /// Reload the HID backend with new type 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() { @@ -403,27 +474,21 @@ impl HidController { *self.backend.write().await = new_backend; + if matches!(new_backend_type, HidBackendType::None) { + *self.backend_type.write().await = HidBackendType::None; + self.apply_runtime_state(HidRuntimeState::from_backend_type(&HidBackendType::None)) + .await; + return Ok(()); + } + if self.backend.read().await.is_some() { 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(); - // Reset monitor state on successful reload - self.monitor.reset().await; - - // Publish HID state changed event - let backend_name = new_backend_type.name_str().to_string(); - self.publish_event(crate::events::SystemEvent::HidStateChanged { - backend: backend_name, - initialized: true, - error: None, - error_code: None, - }) - .await; + self.sync_runtime_state_from_backend().await; Ok(()) } else { @@ -433,14 +498,14 @@ impl HidController { // Update backend_type even on failure (to reflect the attempted change) *self.backend_type.write().await = new_backend_type.clone(); - // Publish event with initialized=false - self.publish_event(crate::events::SystemEvent::HidStateChanged { - backend: new_backend_type.name_str().to_string(), - initialized: false, - error: Some("Failed to initialize HID backend".to_string()), - error_code: Some("init_failed".to_string()), - }) - .await; + let current = self.runtime_state.read().await.clone(); + let error_state = HidRuntimeState::with_error( + &new_backend_type, + ¤t, + "Failed to initialize HID backend", + "init_failed", + ); + self.apply_runtime_state(error_state).await; Err(AppError::Internal( "Failed to reload HID backend".to_string(), @@ -448,11 +513,22 @@ impl HidController { } } - /// Publish event to event bus if available - async fn publish_event(&self, event: crate::events::SystemEvent) { - if let Some(events) = self.events.read().await.as_ref() { - events.publish(event); - } + async fn apply_runtime_state(&self, next: HidRuntimeState) { + apply_runtime_state(&self.runtime_state, &self.events, next).await; + } + + async fn sync_runtime_state_from_backend(&self) { + let backend_opt = self.backend.read().await.clone(); + let backend_type = self.backend_type.read().await.clone(); + + let next = match backend_opt.as_ref() { + Some(backend) => HidRuntimeState::from_backend(&backend_type, backend.as_ref()), + None => HidRuntimeState::from_backend_type(&backend_type), + }; + + self.backend_available + .store(next.initialized, Ordering::Release); + self.apply_runtime_state(next).await; } async fn start_event_worker(&self) { @@ -468,8 +544,10 @@ impl HidController { }; let backend = self.backend.clone(); - let monitor = self.monitor.clone(); let backend_type = self.backend_type.clone(); + let runtime_state = self.runtime_state.clone(); + let events = self.events.clone(); + let backend_available = self.backend_available.clone(); let pending_move = self.pending_move.clone(); let pending_move_flag = self.pending_move_flag.clone(); @@ -481,7 +559,15 @@ impl HidController { None => break, }; - process_hid_event(event, &backend, &monitor, &backend_type).await; + process_hid_event( + event, + &backend, + &backend_type, + &runtime_state, + &events, + backend_available.as_ref(), + ) + .await; // After each event, flush latest move if pending if pending_move_flag.swap(false, Ordering::AcqRel) { @@ -490,8 +576,10 @@ impl HidController { process_hid_event( HidEvent::Mouse(move_event), &backend, - &monitor, &backend_type, + &runtime_state, + &events, + backend_available.as_ref(), ) .await; } @@ -502,87 +590,6 @@ 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(()), @@ -625,8 +632,10 @@ impl HidController { async fn process_hid_event( event: HidEvent, backend: &Arc>>>, - monitor: &Arc, backend_type: &Arc>, + runtime_state: &Arc>, + events: &Arc>>>, + backend_available: &AtomicBool, ) { let backend_opt = backend.read().await.clone(); let backend = match backend_opt { @@ -634,13 +643,14 @@ async fn process_hid_event( None => return, }; + let backend_for_send = backend.clone(); let result = tokio::task::spawn_blocking(move || { futures::executor::block_on(async move { match event { - HidEvent::Keyboard(ev) => backend.send_keyboard(ev).await, - HidEvent::Mouse(ev) => backend.send_mouse(ev).await, - HidEvent::Consumer(ev) => backend.send_consumer(ev).await, - HidEvent::Reset => backend.reset().await, + HidEvent::Keyboard(ev) => backend_for_send.send_keyboard(ev).await, + HidEvent::Mouse(ev) => backend_for_send.send_mouse(ev).await, + HidEvent::Consumer(ev) => backend_for_send.send_consumer(ev).await, + HidEvent::Reset => backend_for_send.reset().await, } }) }) @@ -652,27 +662,16 @@ async fn process_hid_event( }; match result { - Ok(_) => { - if monitor.is_error().await { - let backend_type = backend_type.read().await; - monitor.report_recovered(backend_type.name_str()).await; - } - } + Ok(_) => {} Err(e) => { - if let AppError::HidError { - ref backend, - ref reason, - ref error_code, - } = e - { - if error_code != "eagain_retry" { - monitor - .report_error(backend, None, reason, error_code) - .await; - } - } + warn!("HID event processing failed: {}", e); } } + + let backend_kind = backend_type.read().await.clone(); + let next = HidRuntimeState::from_backend(&backend_kind, backend.as_ref()); + backend_available.store(next.initialized, Ordering::Release); + apply_runtime_state(runtime_state, events, next).await; } impl Default for HidController { @@ -680,3 +679,40 @@ impl Default for HidController { Self::new(HidBackendType::None, None) } } + +fn device_for_backend_type(backend_type: &HidBackendType) -> Option { + match backend_type { + HidBackendType::Ch9329 { port, .. } => Some(port.clone()), + _ => None, + } +} + +async fn apply_runtime_state( + runtime_state: &Arc>, + events: &Arc>>>, + next: HidRuntimeState, +) { + let changed = { + let mut guard = runtime_state.write().await; + if *guard == next { + false + } else { + *guard = next.clone(); + true + } + }; + + if !changed { + return; + } + + if let Some(events) = events.read().await.as_ref() { + events.publish(SystemEvent::HidStateChanged { + backend: next.backend, + initialized: next.initialized, + online: next.online, + error: next.error, + error_code: next.error_code, + }); + } +} diff --git a/src/hid/monitor.rs b/src/hid/monitor.rs deleted file mode 100644 index b5eedd72..00000000 --- a/src/hid/monitor.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! HID device health monitoring -//! -//! This module provides health monitoring for HID devices, including: -//! - Device connectivity checks -//! - Automatic reconnection on failure -//! - Error tracking and notification -//! - Log throttling to prevent log flooding - -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; -use tracing::{debug, warn}; - -use crate::events::{EventBus, SystemEvent}; -use crate::utils::LogThrottler; - -/// HID health status -#[derive(Debug, Clone, PartialEq, Default)] -pub enum HidHealthStatus { - /// Device is healthy and operational - #[default] - Healthy, - /// Device has an error, attempting recovery - Error { - /// Human-readable error reason - reason: String, - /// Error code for programmatic handling - error_code: String, - /// Number of recovery attempts made - retry_count: u32, - }, - /// Device is disconnected - Disconnected, -} - -/// HID health monitor configuration -#[derive(Debug, Clone)] -pub struct HidMonitorConfig { - /// Health check interval in milliseconds - pub check_interval_ms: u64, - /// Retry interval when device is lost (milliseconds) - pub retry_interval_ms: u64, - /// Maximum retry attempts before giving up (0 = infinite) - pub max_retries: u32, - /// Log throttle interval in seconds - pub log_throttle_secs: u64, - /// Recovery cooldown in milliseconds (suppress logs after recovery) - pub recovery_cooldown_ms: u64, -} - -impl Default for HidMonitorConfig { - fn default() -> Self { - Self { - check_interval_ms: 1000, - retry_interval_ms: 1000, - max_retries: 0, // infinite retry - log_throttle_secs: 5, - recovery_cooldown_ms: 1000, // 1 second cooldown after recovery - } - } -} - -/// HID health monitor -/// -/// Monitors HID device health and manages error recovery. -/// Publishes WebSocket events when device status changes. -pub struct HidHealthMonitor { - /// Current health status - status: RwLock, - /// Event bus for notifications - events: RwLock>>, - /// Log throttler to prevent log flooding - throttler: LogThrottler, - /// Configuration - config: HidMonitorConfig, - /// Whether monitoring is active (reserved for future use) - #[allow(dead_code)] - running: AtomicBool, - /// Current retry count - retry_count: AtomicU32, - /// Last error code (for change detection) - last_error_code: RwLock>, - /// Last recovery timestamp (milliseconds since start, for cooldown) - last_recovery_ms: AtomicU64, - /// Start instant for timing - start_instant: Instant, -} - -impl HidHealthMonitor { - /// Create a new HID health monitor with the specified configuration - pub fn new(config: HidMonitorConfig) -> Self { - let throttle_secs = config.log_throttle_secs; - Self { - status: RwLock::new(HidHealthStatus::Healthy), - events: RwLock::new(None), - throttler: LogThrottler::with_secs(throttle_secs), - config, - running: AtomicBool::new(false), - retry_count: AtomicU32::new(0), - last_error_code: RwLock::new(None), - last_recovery_ms: AtomicU64::new(0), - start_instant: Instant::now(), - } - } - - /// Create a new HID health monitor with default configuration - pub fn with_defaults() -> Self { - Self::new(HidMonitorConfig::default()) - } - - /// Set the event bus for broadcasting state changes - pub async fn set_event_bus(&self, events: Arc) { - *self.events.write().await = Some(events); - } - - /// Report an error from HID operations - /// - /// This method is called when an HID operation fails. It: - /// 1. Updates the health status - /// 2. Logs the error (with throttling and cooldown respect) - /// 3. Publishes a WebSocket event if the error is new or changed - /// - /// # Arguments - /// - /// * `backend` - The HID backend type ("otg" or "ch9329") - /// * `device` - The device path (if known) - /// * `reason` - Human-readable error description - /// * `error_code` - Error code for programmatic handling - pub async fn report_error( - &self, - backend: &str, - device: Option<&str>, - reason: &str, - error_code: &str, - ) { - let count = self.retry_count.fetch_add(1, Ordering::Relaxed) + 1; - - // Check if we're in cooldown period after recent recovery - let current_ms = self.start_instant.elapsed().as_millis() as u64; - let last_recovery = self.last_recovery_ms.load(Ordering::Relaxed); - let in_cooldown = - last_recovery > 0 && current_ms < last_recovery + self.config.recovery_cooldown_ms; - - // Check if error code changed - let error_changed = { - let last = self.last_error_code.read().await; - last.as_ref().map(|s| s.as_str()) != Some(error_code) - }; - - // Log with throttling (skip if in cooldown period unless error type changed) - let throttle_key = format!("hid_{}_{}", backend, error_code); - if !in_cooldown && (error_changed || self.throttler.should_log(&throttle_key)) { - warn!( - "HID {} error: {} (code: {}, attempt: {})", - backend, reason, error_code, count - ); - } - - // Update last error code - *self.last_error_code.write().await = Some(error_code.to_string()); - - // Update status - *self.status.write().await = HidHealthStatus::Error { - reason: reason.to_string(), - error_code: error_code.to_string(), - retry_count: count, - }; - - // Publish event (only if error changed or first occurrence, and not in cooldown) - if !in_cooldown && (error_changed || count == 1) { - if let Some(ref events) = *self.events.read().await { - events.publish(SystemEvent::HidDeviceLost { - backend: backend.to_string(), - device: device.map(|s| s.to_string()), - reason: reason.to_string(), - error_code: error_code.to_string(), - }); - } - } - } - - /// Report that a reconnection attempt is starting - /// - /// Publishes a reconnecting event to notify clients. - /// - /// # Arguments - /// - /// * `backend` - The HID backend type - pub async fn report_reconnecting(&self, backend: &str) { - let attempt = self.retry_count.load(Ordering::Relaxed); - - // Only publish every 5 attempts to avoid event spam - if attempt == 1 || attempt.is_multiple_of(5) { - debug!("HID {} reconnecting, attempt {}", backend, attempt); - - if let Some(ref events) = *self.events.read().await { - events.publish(SystemEvent::HidReconnecting { - backend: backend.to_string(), - attempt, - }); - } - } - } - - /// Report that the device has recovered - /// - /// This method is called when the HID device successfully reconnects. - /// It resets the error state and publishes a recovery event. - /// - /// # Arguments - /// - /// * `backend` - The HID backend type - pub async fn report_recovered(&self, backend: &str) { - let prev_status = self.status.read().await.clone(); - - // Only report recovery if we were in an error state - if prev_status != HidHealthStatus::Healthy { - let retry_count = self.retry_count.load(Ordering::Relaxed); - - // Set cooldown timestamp - let current_ms = self.start_instant.elapsed().as_millis() as u64; - self.last_recovery_ms.store(current_ms, Ordering::Relaxed); - - // Only log and publish events if there were multiple retries - // (avoid log spam for transient single-retry recoveries) - if retry_count > 1 { - debug!("HID {} recovered after {} retries", backend, retry_count); - - // Publish recovery event - if let Some(ref events) = *self.events.read().await { - events.publish(SystemEvent::HidRecovered { - backend: backend.to_string(), - }); - - // Also publish state changed to indicate healthy state - events.publish(SystemEvent::HidStateChanged { - backend: backend.to_string(), - initialized: true, - error: None, - error_code: None, - }); - } - } - - // Reset state (always reset, even for single-retry recoveries) - self.retry_count.store(0, Ordering::Relaxed); - *self.last_error_code.write().await = None; - *self.status.write().await = HidHealthStatus::Healthy; - } - } - - /// Get the current health status - pub async fn status(&self) -> HidHealthStatus { - self.status.read().await.clone() - } - - /// Get the current retry count - pub fn retry_count(&self) -> u32 { - self.retry_count.load(Ordering::Relaxed) - } - - /// Check if the monitor is in an error state - pub async fn is_error(&self) -> bool { - matches!(*self.status.read().await, HidHealthStatus::Error { .. }) - } - - /// Check if the monitor is healthy - pub async fn is_healthy(&self) -> bool { - matches!(*self.status.read().await, HidHealthStatus::Healthy) - } - - /// Reset the monitor to healthy state without publishing events - /// - /// This is useful during initialization. - pub async fn reset(&self) { - self.retry_count.store(0, Ordering::Relaxed); - *self.last_error_code.write().await = None; - *self.status.write().await = HidHealthStatus::Healthy; - self.throttler.clear_all(); - } - - /// Get the configuration - pub fn config(&self) -> &HidMonitorConfig { - &self.config - } - - /// Check if we should continue retrying - /// - /// Returns `false` if max_retries is set and we've exceeded it. - pub fn should_retry(&self) -> bool { - if self.config.max_retries == 0 { - return true; // Infinite retry - } - self.retry_count.load(Ordering::Relaxed) < self.config.max_retries - } - - /// Get the retry interval - pub fn retry_interval(&self) -> Duration { - Duration::from_millis(self.config.retry_interval_ms) - } -} - -impl Default for HidHealthMonitor { - fn default() -> Self { - Self::with_defaults() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_initial_status() { - let monitor = HidHealthMonitor::with_defaults(); - assert!(monitor.is_healthy().await); - assert!(!monitor.is_error().await); - assert_eq!(monitor.retry_count(), 0); - } - - #[tokio::test] - async fn test_report_error() { - let monitor = HidHealthMonitor::with_defaults(); - - monitor - .report_error("otg", Some("/dev/hidg0"), "Device not found", "enoent") - .await; - - assert!(monitor.is_error().await); - assert_eq!(monitor.retry_count(), 1); - - if let HidHealthStatus::Error { - reason, - error_code, - retry_count, - } = monitor.status().await - { - assert_eq!(reason, "Device not found"); - assert_eq!(error_code, "enoent"); - assert_eq!(retry_count, 1); - } else { - panic!("Expected Error status"); - } - } - - #[tokio::test] - async fn test_report_recovered() { - let monitor = HidHealthMonitor::with_defaults(); - - // First report an error - monitor - .report_error("ch9329", None, "Port not found", "port_not_found") - .await; - assert!(monitor.is_error().await); - - // Then report recovery - monitor.report_recovered("ch9329").await; - assert!(monitor.is_healthy().await); - assert_eq!(monitor.retry_count(), 0); - } - - #[tokio::test] - async fn test_retry_count_increments() { - let monitor = HidHealthMonitor::with_defaults(); - - for i in 1..=5 { - monitor.report_error("otg", None, "Error", "io_error").await; - assert_eq!(monitor.retry_count(), i); - } - } - - #[tokio::test] - async fn test_should_retry_infinite() { - let monitor = HidHealthMonitor::new(HidMonitorConfig { - max_retries: 0, // infinite - ..Default::default() - }); - - for _ in 0..100 { - monitor.report_error("otg", None, "Error", "io_error").await; - assert!(monitor.should_retry()); - } - } - - #[tokio::test] - async fn test_should_retry_limited() { - let monitor = HidHealthMonitor::new(HidMonitorConfig { - max_retries: 3, - ..Default::default() - }); - - assert!(monitor.should_retry()); - - monitor.report_error("otg", None, "Error", "io_error").await; - assert!(monitor.should_retry()); // 1 < 3 - - monitor.report_error("otg", None, "Error", "io_error").await; - assert!(monitor.should_retry()); // 2 < 3 - - monitor.report_error("otg", None, "Error", "io_error").await; - assert!(!monitor.should_retry()); // 3 >= 3 - } - - #[tokio::test] - async fn test_reset() { - let monitor = HidHealthMonitor::with_defaults(); - - monitor.report_error("otg", None, "Error", "io_error").await; - assert!(monitor.is_error().await); - - monitor.reset().await; - assert!(monitor.is_healthy().await); - assert_eq!(monitor.retry_count(), 0); - } -} diff --git a/src/hid/otg.rs b/src/hid/otg.rs index ca26f3d5..c1c6f9bd 100644 --- a/src/hid/otg.rs +++ b/src/hid/otg.rs @@ -28,7 +28,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use tracing::{debug, info, trace, warn}; -use super::backend::HidBackend; +use super::backend::{HidBackend, HidBackendStatus}; use super::keymap; use super::types::{ ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType, @@ -134,8 +134,12 @@ pub struct OtgBackend { screen_resolution: parking_lot::RwLock>, /// UDC name for state checking (e.g., "fcc00000.usb") udc_name: parking_lot::RwLock>, + /// Whether the backend has been initialized. + initialized: AtomicBool, /// Whether the device is currently online (UDC configured and devices accessible) online: AtomicBool, + /// Last backend error state. + last_error: parking_lot::RwLock>, /// Last error log time for throttling (using parking_lot for sync) last_error_log: parking_lot::Mutex, /// Error count since last successful operation (for log throttling) @@ -167,13 +171,29 @@ impl OtgBackend { led_state: parking_lot::RwLock::new(LedState::default()), screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))), udc_name: parking_lot::RwLock::new(None), + initialized: AtomicBool::new(false), online: AtomicBool::new(false), + last_error: parking_lot::RwLock::new(None), last_error_log: parking_lot::Mutex::new(std::time::Instant::now()), error_count: AtomicU8::new(0), eagain_count: AtomicU8::new(0), }) } + fn clear_error(&self) { + *self.last_error.write() = None; + } + + fn record_error(&self, reason: impl Into, error_code: impl Into) { + self.online.store(false, Ordering::Relaxed); + *self.last_error.write() = Some((reason.into(), error_code.into())); + } + + fn mark_online(&self) { + self.online.store(true, Ordering::Relaxed); + self.clear_error(); + } + /// Log throttled error message (max once per second) fn log_throttled_error(&self, msg: &str) { let mut last_log = self.last_error_log.lock(); @@ -308,12 +328,13 @@ impl OtgBackend { let path = match path_opt { Some(p) => p, None => { - self.online.store(false, Ordering::Relaxed); - return Err(AppError::HidError { + let err = AppError::HidError { backend: "otg".to_string(), reason: "Device disabled".to_string(), error_code: "disabled".to_string(), - }); + }; + self.record_error("Device disabled", "disabled"); + return Err(err); } }; @@ -328,10 +349,11 @@ impl OtgBackend { ); *dev = None; } - self.online.store(false, Ordering::Relaxed); + let reason = format!("Device not found: {}", path.display()); + self.record_error(reason.clone(), "enoent"); return Err(AppError::HidError { backend: "otg".to_string(), - reason: format!("Device not found: {}", path.display()), + reason, error_code: "enoent".to_string(), }); } @@ -346,12 +368,16 @@ impl OtgBackend { } Err(e) => { warn!("Failed to reopen HID device {}: {}", path.display(), e); + self.record_error( + format!("Failed to reopen HID device {}: {}", path.display(), e), + "not_opened", + ); return Err(e); } } } - self.online.store(true, Ordering::Relaxed); + self.mark_online(); Ok(()) } @@ -372,8 +398,8 @@ impl OtgBackend { } /// Convert I/O error to HidError with appropriate error code - fn io_error_to_hid_error(e: std::io::Error, operation: &str) -> AppError { - let error_code = match e.raw_os_error() { + fn io_error_code(e: &std::io::Error) -> &'static str { + match e.raw_os_error() { Some(32) => "epipe", // EPIPE - broken pipe Some(108) => "eshutdown", // ESHUTDOWN - transport endpoint shutdown Some(11) => "eagain", // EAGAIN - resource temporarily unavailable @@ -382,7 +408,11 @@ impl OtgBackend { Some(5) => "eio", // EIO - I/O error Some(2) => "enoent", // ENOENT - no such file or directory _ => "io_error", - }; + } + } + + fn io_error_to_hid_error(e: std::io::Error, operation: &str) -> AppError { + let error_code = Self::io_error_code(&e); AppError::HidError { backend: "otg".to_string(), @@ -438,7 +468,7 @@ impl OtgBackend { let data = report.to_bytes(); match self.write_with_timeout(file, &data) { Ok(true) => { - self.online.store(true, Ordering::Relaxed); + self.mark_online(); self.reset_error_count(); debug!("Sent keyboard report: {:02X?}", data); Ok(()) @@ -454,10 +484,13 @@ impl OtgBackend { match error_code { Some(108) => { // ESHUTDOWN - endpoint closed, need to reopen device - self.online.store(false, Ordering::Relaxed); self.eagain_count.store(0, Ordering::Relaxed); debug!("Keyboard ESHUTDOWN, closing for recovery"); *dev = None; + self.record_error( + format!("Failed to write keyboard report: {}", e), + "eshutdown", + ); Err(Self::io_error_to_hid_error( e, "Failed to write keyboard report", @@ -469,9 +502,12 @@ impl OtgBackend { Ok(()) } _ => { - self.online.store(false, Ordering::Relaxed); self.eagain_count.store(0, Ordering::Relaxed); warn!("Keyboard write error: {}", e); + self.record_error( + format!("Failed to write keyboard report: {}", e), + Self::io_error_code(&e), + ); Err(Self::io_error_to_hid_error( e, "Failed to write keyboard report", @@ -507,7 +543,7 @@ impl OtgBackend { let data = [buttons, dx as u8, dy as u8, wheel as u8]; match self.write_with_timeout(file, &data) { Ok(true) => { - self.online.store(true, Ordering::Relaxed); + self.mark_online(); self.reset_error_count(); trace!("Sent relative mouse report: {:02X?}", data); Ok(()) @@ -521,10 +557,13 @@ impl OtgBackend { match error_code { Some(108) => { - self.online.store(false, Ordering::Relaxed); self.eagain_count.store(0, Ordering::Relaxed); debug!("Relative mouse ESHUTDOWN, closing for recovery"); *dev = None; + self.record_error( + format!("Failed to write mouse report: {}", e), + "eshutdown", + ); Err(Self::io_error_to_hid_error( e, "Failed to write mouse report", @@ -535,9 +574,12 @@ impl OtgBackend { Ok(()) } _ => { - self.online.store(false, Ordering::Relaxed); self.eagain_count.store(0, Ordering::Relaxed); warn!("Relative mouse write error: {}", e); + self.record_error( + format!("Failed to write mouse report: {}", e), + Self::io_error_code(&e), + ); Err(Self::io_error_to_hid_error( e, "Failed to write mouse report", @@ -580,7 +622,7 @@ impl OtgBackend { ]; match self.write_with_timeout(file, &data) { Ok(true) => { - self.online.store(true, Ordering::Relaxed); + self.mark_online(); self.reset_error_count(); Ok(()) } @@ -593,10 +635,13 @@ impl OtgBackend { match error_code { Some(108) => { - self.online.store(false, Ordering::Relaxed); self.eagain_count.store(0, Ordering::Relaxed); debug!("Absolute mouse ESHUTDOWN, closing for recovery"); *dev = None; + self.record_error( + format!("Failed to write mouse report: {}", e), + "eshutdown", + ); Err(Self::io_error_to_hid_error( e, "Failed to write mouse report", @@ -607,9 +652,12 @@ impl OtgBackend { Ok(()) } _ => { - self.online.store(false, Ordering::Relaxed); self.eagain_count.store(0, Ordering::Relaxed); warn!("Absolute mouse write error: {}", e); + self.record_error( + format!("Failed to write mouse report: {}", e), + Self::io_error_code(&e), + ); Err(Self::io_error_to_hid_error( e, "Failed to write mouse report", @@ -648,7 +696,7 @@ impl OtgBackend { // Send release (0x0000) let release = [0u8, 0u8]; let _ = self.write_with_timeout(file, &release); - self.online.store(true, Ordering::Relaxed); + self.mark_online(); self.reset_error_count(); Ok(()) } @@ -660,9 +708,12 @@ impl OtgBackend { let error_code = e.raw_os_error(); match error_code { Some(108) => { - self.online.store(false, Ordering::Relaxed); debug!("Consumer control ESHUTDOWN, closing for recovery"); *dev = None; + self.record_error( + format!("Failed to write consumer report: {}", e), + "eshutdown", + ); Err(Self::io_error_to_hid_error( e, "Failed to write consumer report", @@ -673,8 +724,11 @@ impl OtgBackend { Ok(()) } _ => { - self.online.store(false, Ordering::Relaxed); warn!("Consumer control write error: {}", e); + self.record_error( + format!("Failed to write consumer report: {}", e), + Self::io_error_code(&e), + ); Err(Self::io_error_to_hid_error( e, "Failed to write consumer report", @@ -812,7 +866,8 @@ impl HidBackend for OtgBackend { } // Mark as online if all devices opened successfully - self.online.store(true, Ordering::Relaxed); + self.initialized.store(true, Ordering::Relaxed); + self.mark_online(); Ok(()) } @@ -935,33 +990,40 @@ impl HidBackend for OtgBackend { *self.consumer_dev.lock() = None; // Gadget cleanup is handled by OtgService, not here + self.initialized.store(false, Ordering::Relaxed); + self.online.store(false, Ordering::Relaxed); + self.clear_error(); info!("OTG backend shutdown"); Ok(()) } - fn health_check(&self) -> Result<()> { - if !self.check_devices_exist() { + fn status(&self) -> HidBackendStatus { + let initialized = self.initialized.load(Ordering::Relaxed); + let mut online = initialized && self.online.load(Ordering::Relaxed); + let mut error = self.last_error.read().clone(); + + if initialized && !self.check_devices_exist() { + online = false; 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(), - }); + error = Some(( + format!("HID device node missing: {}", missing.join(", ")), + "enoent".to_string(), + )); + } else if initialized && !self.is_udc_configured() { + online = false; + error = Some(( + "UDC is not in configured state".to_string(), + "udc_not_configured".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(), - }); + HidBackendStatus { + initialized, + online, + error: error.as_ref().map(|(reason, _)| reason.clone()), + error_code: error.as_ref().map(|(_, code)| code.clone()), } - - self.online.store(true, Ordering::Relaxed); - Ok(()) } fn supports_absolute_mouse(&self) -> bool { diff --git a/src/msd/controller.rs b/src/msd/controller.rs index 5d38396d..4830c088 100644 --- a/src/msd/controller.rs +++ b/src/msd/controller.rs @@ -131,9 +131,7 @@ impl MsdController { /// Set event bus for broadcasting state changes pub async fn set_event_bus(&self, events: std::sync::Arc) { - *self.events.write().await = Some(events.clone()); - // Also set event bus on the monitor for health notifications - self.monitor.set_event_bus(events).await; + *self.events.write().await = Some(events); } /// Publish an event to the event bus @@ -230,15 +228,6 @@ impl MsdController { self.monitor.report_recovered().await; } - // Publish events - self.publish_event(crate::events::SystemEvent::MsdImageMounted { - image_id: image.id.clone(), - image_name: image.name.clone(), - size: image.size, - cdrom, - }) - .await; - self.publish_event(crate::events::SystemEvent::MsdStateChanged { mode: MsdMode::Image, connected: true, @@ -351,10 +340,6 @@ impl MsdController { drop(state); drop(_op_guard); - // Publish events - self.publish_event(crate::events::SystemEvent::MsdImageUnmounted) - .await; - self.publish_event(crate::events::SystemEvent::MsdStateChanged { mode: MsdMode::None, connected: false, diff --git a/src/msd/monitor.rs b/src/msd/monitor.rs index fd4a9f96..b667e48e 100644 --- a/src/msd/monitor.rs +++ b/src/msd/monitor.rs @@ -3,15 +3,13 @@ //! This module provides health monitoring for MSD operations, including: //! - ConfigFS operation error tracking //! - Image mount/unmount error tracking -//! - Error notification +//! - Error state tracking //! - Log throttling to prevent log flooding use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::Arc; use tokio::sync::RwLock; use tracing::{info, warn}; -use crate::events::{EventBus, SystemEvent}; use crate::utils::LogThrottler; /// MSD health status @@ -46,13 +44,10 @@ impl Default for MsdMonitorConfig { /// MSD health monitor /// -/// Monitors MSD operation health and manages error notifications. -/// Publishes WebSocket events when operation status changes. +/// Monitors MSD operation health and manages error state. pub struct MsdHealthMonitor { /// Current health status status: RwLock, - /// Event bus for notifications - events: RwLock>>, /// Log throttler to prevent log flooding throttler: LogThrottler, /// Configuration @@ -73,7 +68,6 @@ impl MsdHealthMonitor { let throttle_secs = config.log_throttle_secs; Self { status: RwLock::new(MsdHealthStatus::Healthy), - events: RwLock::new(None), throttler: LogThrottler::with_secs(throttle_secs), config, running: AtomicBool::new(false), @@ -87,17 +81,12 @@ impl MsdHealthMonitor { Self::new(MsdMonitorConfig::default()) } - /// Set the event bus for broadcasting state changes - pub async fn set_event_bus(&self, events: Arc) { - *self.events.write().await = Some(events); - } - /// Report an error from MSD operations /// /// This method is called when an MSD operation fails. It: /// 1. Updates the health status /// 2. Logs the error (with throttling) - /// 3. Publishes a WebSocket event if the error is new or changed + /// 3. Updates in-memory error state /// /// # Arguments /// @@ -129,22 +118,12 @@ impl MsdHealthMonitor { reason: reason.to_string(), error_code: error_code.to_string(), }; - - // Publish event (only if error changed or first occurrence) - if error_changed || count == 1 { - if let Some(ref events) = *self.events.read().await { - events.publish(SystemEvent::MsdError { - reason: reason.to_string(), - error_code: error_code.to_string(), - }); - } - } } /// Report that the MSD has recovered from error /// /// This method is called when an MSD operation succeeds after errors. - /// It resets the error state and publishes a recovery event. + /// It resets the error state. pub async fn report_recovered(&self) { let prev_status = self.status.read().await.clone(); @@ -158,11 +137,6 @@ impl MsdHealthMonitor { self.throttler.clear_all(); *self.last_error_code.write().await = None; *self.status.write().await = MsdHealthStatus::Healthy; - - // Publish recovery event - if let Some(ref events) = *self.events.read().await { - events.publish(SystemEvent::MsdRecovered); - } } } diff --git a/src/state.rs b/src/state.rs index 18f4ead1..814ec9cc 100644 --- a/src/state.rs +++ b/src/state.rs @@ -178,32 +178,17 @@ impl AppState { /// Collect HID device information async fn collect_hid_info(&self) -> HidDeviceInfo { - let info = self.hid.info().await; - let backend_type = self.hid.backend_type().await; + let state = self.hid.snapshot().await; - match info { - Some(hid_info) => HidDeviceInfo { - available: true, - backend: hid_info.name.to_string(), - initialized: hid_info.initialized, - supports_absolute_mouse: hid_info.supports_absolute_mouse, - device: match backend_type { - crate::hid::HidBackendType::Ch9329 { ref port, .. } => Some(port.clone()), - _ => None, - }, - error: None, - }, - None => HidDeviceInfo { - available: false, - backend: backend_type.name_str().to_string(), - initialized: false, - supports_absolute_mouse: false, - device: match backend_type { - crate::hid::HidBackendType::Ch9329 { ref port, .. } => Some(port.clone()), - _ => None, - }, - error: Some("HID backend not available".to_string()), - }, + HidDeviceInfo { + available: state.available, + backend: state.backend, + initialized: state.initialized, + online: state.online, + supports_absolute_mouse: state.supports_absolute_mouse, + device: state.device, + error: state.error, + error_code: state.error_code, } } @@ -213,6 +198,7 @@ impl AppState { let msd = msd_guard.as_ref()?; let state = msd.state().await; + let error = msd.monitor().error_message().await; Some(MsdDeviceInfo { available: state.available, mode: match state.mode { @@ -223,7 +209,7 @@ impl AppState { .to_string(), connected: state.connected, image_id: state.current_image.map(|img| img.id), - error: None, + error, }) } diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index f2f7e7bc..70229ea9 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -2311,8 +2311,12 @@ pub struct HidStatus { pub available: bool, pub backend: String, pub initialized: bool, + pub online: bool, pub supports_absolute_mouse: bool, pub screen_resolution: Option<(u32, u32)>, + pub device: Option, + pub error: Option, + pub error_code: Option, } #[derive(Serialize, Clone, Copy, PartialEq, Eq)] @@ -3073,19 +3077,17 @@ pub async fn hid_otg_self_check(State(state): State>) -> Json>) -> Json { - let info = state.hid.info().await; + let hid = state.hid.snapshot().await; Json(HidStatus { - available: info.is_some(), - backend: info - .as_ref() - .map(|i| i.name.to_string()) - .unwrap_or_else(|| "none".to_string()), - initialized: info.as_ref().map(|i| i.initialized).unwrap_or(false), - supports_absolute_mouse: info - .as_ref() - .map(|i| i.supports_absolute_mouse) - .unwrap_or(false), - screen_resolution: info.and_then(|i| i.screen_resolution), + available: hid.available, + backend: hid.backend, + initialized: hid.initialized, + online: hid.online, + supports_absolute_mouse: hid.supports_absolute_mouse, + screen_resolution: hid.screen_resolution, + device: hid.device, + error: hid.error, + error_code: hid.error_code, }) } diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 427e66ae..4bb92cbe 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -327,8 +327,12 @@ export const hidApi = { available: boolean backend: string initialized: boolean + online: boolean supports_absolute_mouse: boolean screen_resolution: [number, number] | null + device: string | null + error: string | null + error_code: string | null }>('/hid/status'), otgSelfCheck: () => diff --git a/web/src/components/AudioConfigPopover.vue b/web/src/components/AudioConfigPopover.vue index 3e97890e..2a0f377a 100644 --- a/web/src/components/AudioConfigPopover.vue +++ b/web/src/components/AudioConfigPopover.vue @@ -123,8 +123,7 @@ async function applyConfig() { } await audioApi.start() - // Note: handleAudioStateChanged in ConsoleView will handle the connection - // when it receives the audio.state_changed event with streaming=true + // ConsoleView will react when system.device_info reflects streaming=true. } catch (startError) { // Audio start failed - config was saved but streaming not started console.info('[AudioConfig] Audio start failed:', startError) diff --git a/web/src/composables/useConsoleEvents.ts b/web/src/composables/useConsoleEvents.ts index 192952f8..b45ffc90 100644 --- a/web/src/composables/useConsoleEvents.ts +++ b/web/src/composables/useConsoleEvents.ts @@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n' import { toast } from 'vue-sonner' import { useSystemStore } from '@/stores/system' import { useWebSocket } from '@/composables/useWebSocket' -import { getUnifiedAudio } from '@/composables/useUnifiedAudio' export interface ConsoleEventHandlers { onStreamConfigChanging?: (data: { reason?: string }) => void @@ -20,119 +19,13 @@ export interface ConsoleEventHandlers { onStreamReconnecting?: (data: { device: string; attempt: number }) => void onStreamRecovered?: (data: { device: string }) => void onDeviceInfo?: (data: any) => void - onAudioStateChanged?: (data: { streaming: boolean; device: string | null }) => void } export function useConsoleEvents(handlers: ConsoleEventHandlers) { const { t } = useI18n() const systemStore = useSystemStore() 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: { - 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 - - 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 }), - duration: 5000, - }) - } - - function handleHidReconnecting(data: { backend: string; attempt: number }) { - if (data.attempt === 1 || data.attempt % 5 === 0) { - toast.info(t('hid.reconnecting'), { - description: t('hid.reconnectingDesc', { attempt: data.attempt }), - duration: 3000, - }) - } - } - - function handleHidRecovered(data: { backend: string }) { - 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, - }) - } - // Stream device monitoring handlers function handleStreamDeviceLost(data: { device: string; reason: string }) { if (systemStore.stream) { @@ -177,93 +70,8 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) { handlers.onStreamStateChanged?.(data) } - // Audio device monitoring handlers - function handleAudioDeviceLost(data: { device?: string; reason: string; error_code: string }) { - if (systemStore.audio) { - systemStore.audio.streaming = false - systemStore.audio.error = data.reason - } - toast.error(t('audio.deviceLost'), { - description: t('audio.deviceLostDesc', { device: data.device || 'default', reason: data.reason }), - duration: 5000, - }) - } - - function handleAudioReconnecting(data: { attempt: number }) { - if (data.attempt === 1 || data.attempt % 5 === 0) { - toast.info(t('audio.reconnecting'), { - description: t('audio.reconnectingDesc', { attempt: data.attempt }), - duration: 3000, - }) - } - } - - function handleAudioRecovered(data: { device?: string }) { - if (systemStore.audio) { - systemStore.audio.error = null - } - toast.success(t('audio.recovered'), { - description: t('audio.recoveredDesc', { device: data.device || 'default' }), - duration: 3000, - }) - } - - async function handleAudioStateChanged(data: { streaming: boolean; device: string | null }) { - if (!data.streaming) { - unifiedAudio.disconnect() - return - } - handlers.onAudioStateChanged?.(data) - } - - // MSD event handlers - function handleMsdStateChanged(_data: { mode: string; connected: boolean }) { - systemStore.fetchMsdState().catch(() => null) - } - - function handleMsdImageMounted(data: { image_id: string; image_name: string; size: number; cdrom: boolean }) { - toast.success(t('msd.imageMounted', { name: data.image_name }), { - description: `${(data.size / 1024 / 1024).toFixed(2)} MB - ${data.cdrom ? 'CD-ROM' : 'Disk'}`, - duration: 3000, - }) - systemStore.fetchMsdState().catch(() => null) - } - - function handleMsdImageUnmounted() { - toast.info(t('msd.imageUnmounted'), { - duration: 2000, - }) - systemStore.fetchMsdState().catch(() => null) - } - - function handleMsdError(data: { reason: string; error_code: string }) { - if (systemStore.msd) { - systemStore.msd.error = data.reason - } - toast.error(t('msd.error'), { - description: t('msd.errorDesc', { reason: data.reason }), - duration: 5000, - }) - } - - function handleMsdRecovered() { - if (systemStore.msd) { - systemStore.msd.error = null - } - toast.success(t('msd.recovered'), { - description: t('msd.recoveredDesc'), - duration: 3000, - }) - } - // Subscribe to all events function subscribe() { - // HID events - on('hid.state_changed', handleHidStateChanged) - on('hid.device_lost', handleHidDeviceLost) - on('hid.reconnecting', handleHidReconnecting) - on('hid.recovered', handleHidRecovered) - // Stream events on('stream.config_changing', handlers.onStreamConfigChanging ?? noop) on('stream.config_applied', handlers.onStreamConfigApplied ?? noop) @@ -277,19 +85,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) { on('stream.reconnecting', handleStreamReconnecting) on('stream.recovered', handleStreamRecovered) - // Audio events - on('audio.state_changed', handleAudioStateChanged) - on('audio.device_lost', handleAudioDeviceLost) - on('audio.reconnecting', handleAudioReconnecting) - on('audio.recovered', handleAudioRecovered) - - // MSD events - on('msd.state_changed', handleMsdStateChanged) - on('msd.image_mounted', handleMsdImageMounted) - on('msd.image_unmounted', handleMsdImageUnmounted) - on('msd.error', handleMsdError) - on('msd.recovered', handleMsdRecovered) - // System events on('system.device_info', handlers.onDeviceInfo ?? noop) @@ -299,11 +94,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) { // Unsubscribe from all events function unsubscribe() { - off('hid.state_changed', handleHidStateChanged) - off('hid.device_lost', handleHidDeviceLost) - off('hid.reconnecting', handleHidReconnecting) - off('hid.recovered', handleHidRecovered) - off('stream.config_changing', handlers.onStreamConfigChanging ?? noop) off('stream.config_applied', handlers.onStreamConfigApplied ?? noop) off('stream.stats_update', handlers.onStreamStatsUpdate ?? noop) @@ -316,17 +106,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) { off('stream.reconnecting', handleStreamReconnecting) off('stream.recovered', handleStreamRecovered) - off('audio.state_changed', handleAudioStateChanged) - off('audio.device_lost', handleAudioDeviceLost) - off('audio.reconnecting', handleAudioReconnecting) - off('audio.recovered', handleAudioRecovered) - - off('msd.state_changed', handleMsdStateChanged) - off('msd.image_mounted', handleMsdImageMounted) - off('msd.image_unmounted', handleMsdImageUnmounted) - off('msd.error', handleMsdError) - off('msd.recovered', handleMsdRecovered) - off('system.device_info', handlers.onDeviceInfo ?? noop) } diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index d39bf39a..6e03a24b 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -363,14 +363,21 @@ export default { recoveredDesc: '{backend} HID device reconnected successfully', errorHints: { udcNotConfigured: 'Target host has not finished USB enumeration yet', + disabled: 'HID backend is disabled', hidDeviceMissing: 'HID gadget device node is missing, try restarting HID service', + notOpened: 'HID device is not open, 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', + noResponseWithCmd: 'No response from CH9329, check baud rate and power (cmd {cmd})', + invalidConfig: 'Serial port parameters are invalid, check device path and baud rate', protocolError: 'CH9329 replied with invalid protocol data', - healthCheckFailed: 'Background health check failed', + deviceDisconnected: 'HID device disconnected, check cable and host port', 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', + serialError: 'Serial communication error, check CH9329 wiring and config', + initFailed: 'CH9329 initialization failed, check serial settings and power', + shutdown: 'HID backend has stopped', }, }, audio: { diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index cd026baa..64e9ec0c 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -363,14 +363,21 @@ export default { recoveredDesc: '{backend} HID 设备已成功重连', errorHints: { udcNotConfigured: '被控机尚未完成 USB 枚举', + disabled: 'HID 后端已禁用', hidDeviceMissing: '未找到 HID 设备节点,可尝试重启 HID 服务', + notOpened: 'HID 设备尚未打开,可尝试重启 HID 服务', portNotFound: '找不到串口设备,请检查 CH9329 接线与设备路径', noResponse: 'CH9329 无响应,请检查波特率与供电', + noResponseWithCmd: 'CH9329 无响应,请检查波特率与供电(命令 {cmd})', + invalidConfig: '串口参数无效,请检查设备路径与波特率配置', protocolError: 'CH9329 返回了无效协议数据', - healthCheckFailed: '后台健康检查失败', + deviceDisconnected: 'HID 设备已断开,请检查线缆与接口', ioError: '检测到 I/O 通信异常', otgIoError: 'OTG 链路不稳定,请检查 USB 线和被控机接口', ch9329IoError: 'CH9329 串口链路不稳定,请检查接线与供电', + serialError: '串口通信异常,请检查 CH9329 接线与配置', + initFailed: 'CH9329 初始化失败,请检查串口参数与供电', + shutdown: 'HID 后端已停止', }, }, audio: { diff --git a/web/src/stores/system.ts b/web/src/stores/system.ts index f4a6ce39..37772fc0 100644 --- a/web/src/stores/system.ts +++ b/web/src/stores/system.ts @@ -32,6 +32,7 @@ interface HidState { available: boolean backend: string initialized: boolean + online: boolean supportsAbsoluteMouse: boolean device: string | null error: string | null @@ -86,9 +87,11 @@ export interface HidDeviceInfo { available: boolean backend: string initialized: boolean + online: boolean supports_absolute_mouse: boolean device: string | null error: string | null + error_code?: string | null } export interface MsdDeviceInfo { @@ -183,10 +186,11 @@ export const useSystemStore = defineStore('system', () => { available: state.available, backend: state.backend, initialized: state.initialized, + online: state.online, supportsAbsoluteMouse: state.supports_absolute_mouse, - device: null, - error: null, - errorCode: null, + device: state.device ?? null, + error: state.error ?? null, + errorCode: state.error_code ?? null, } return state } catch (e) { @@ -286,11 +290,11 @@ export const useSystemStore = defineStore('system', () => { available: data.hid.available, backend: data.hid.backend, initialized: data.hid.initialized, + online: data.hid.online, 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, + errorCode: data.hid.error_code ?? null, } // Update MSD state (optional) @@ -360,28 +364,6 @@ 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, @@ -406,7 +388,6 @@ 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 5ccab274..0f23a22d 100644 --- a/web/src/views/ConsoleView.vue +++ b/web/src/views/ConsoleView.vue @@ -81,7 +81,6 @@ const consoleEvents = useConsoleEvents({ onStreamDeviceLost: handleStreamDeviceLost, onStreamRecovered: handleStreamRecovered, onDeviceInfo: handleDeviceInfo, - onAudioStateChanged: handleAudioStateChanged, }) // Video mode state @@ -251,8 +250,8 @@ const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error' if (hidWs.hidUnavailable.value) return 'disconnected' // Normal status based on system state - if (hid?.available && hid.initialized) return 'connected' - if (hid?.available && !hid.initialized) return 'connecting' + if (hid?.available && hid.online) return 'connected' + if (hid?.available && hid.initialized) return 'connecting' return 'disconnected' }) @@ -264,29 +263,54 @@ const hidQuickInfo = computed(() => { return mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative') }) -function hidErrorHint(errorCode?: string | null, backend?: string | null): string { +function extractCh9329Command(reason?: string | null): string | null { + if (!reason) return null + const match = reason.match(/cmd 0x([0-9a-f]{2})/i) + const cmd = match?.[1] + return cmd ? `0x${cmd.toUpperCase()}` : null +} + +function hidErrorHint(errorCode?: string | null, backend?: string | null, reason?: string | null): string { + const ch9329Command = extractCh9329Command(reason) + switch (errorCode) { case 'udc_not_configured': return t('hid.errorHints.udcNotConfigured') + case 'disabled': + return t('hid.errorHints.disabled') case 'enoent': return t('hid.errorHints.hidDeviceMissing') + case 'not_opened': + return t('hid.errorHints.notOpened') case 'port_not_found': - case 'port_not_opened': return t('hid.errorHints.portNotFound') + case 'invalid_config': + return t('hid.errorHints.invalidConfig') case 'no_response': - return t('hid.errorHints.noResponse') + return t(ch9329Command ? 'hid.errorHints.noResponseWithCmd' : 'hid.errorHints.noResponse', { + cmd: ch9329Command ?? '', + }) 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 'enxio': + case 'enodev': + return t('hid.errorHints.deviceDisconnected') case 'eio': case 'epipe': case 'eshutdown': + case 'io_error': + case 'write_failed': + case 'read_failed': if (backend === 'otg') return t('hid.errorHints.otgIoError') if (backend === 'ch9329') return t('hid.errorHints.ch9329IoError') return t('hid.errorHints.ioError') + case 'serial_error': + return t('hid.errorHints.serialError') + case 'init_failed': + return t('hid.errorHints.initFailed') + case 'shutdown': + return t('hid.errorHints.shutdown') default: return '' } @@ -294,8 +318,8 @@ function hidErrorHint(errorCode?: string | null, backend?: string | null): strin 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})` + const hint = hidErrorHint(errorCode, backend, reason) + if (hint) return hint if (reason) return reason return hint || t('common.error') } @@ -314,6 +338,7 @@ const hidDetails = computed(() => { { 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.error ? 'error' : hid.initialized ? 'ok' : 'warning' }, + { label: t('statusCard.online'), value: hid.online ? t('statusCard.yes') : t('statusCard.no'), status: hid.online ? 'ok' : hid.initialized ? 'warning' : 'error' }, { label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' }, ] @@ -932,8 +957,22 @@ async function restoreInitialMode(serverMode: VideoMode) { } function handleDeviceInfo(data: any) { + const prevAudioStreaming = systemStore.audio?.streaming ?? false + const prevAudioDevice = systemStore.audio?.device ?? null systemStore.updateFromDeviceInfo(data) + const nextAudioStreaming = systemStore.audio?.streaming ?? false + const nextAudioDevice = systemStore.audio?.device ?? null + if ( + prevAudioStreaming !== nextAudioStreaming || + prevAudioDevice !== nextAudioDevice + ) { + void handleAudioStateChanged({ + streaming: nextAudioStreaming, + device: nextAudioDevice, + }) + } + // Skip mode sync if video config is being changed // This prevents false-positive mode changes during config switching if (data.video?.config_changing) {