mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-29 14:46:33 +08:00
refactor: 重构部分事件检查逻辑,修复 ch9329 hid 状态显示异常
This commit is contained in:
@@ -141,9 +141,7 @@ impl AudioController {
|
||||
|
||||
/// Set event bus for publishing audio events
|
||||
pub async fn set_event_bus(&self, event_bus: Arc<EventBus>) {
|
||||
*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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AudioHealthStatus>,
|
||||
/// Event bus for notifications
|
||||
events: RwLock<Option<Arc<EventBus>>>,
|
||||
/// 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<EventBus>) {
|
||||
*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()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// Error message if any, None if OK
|
||||
pub error: Option<String>,
|
||||
/// Error code if any, None if OK
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Error code for programmatic handling: "epipe", "eagain", "port_not_found", etc.
|
||||
error_code: Option<String>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<Utc>,
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Audio Events
|
||||
// ============================================================================
|
||||
@@ -445,79 +364,6 @@ pub enum SystemEvent {
|
||||
device: Option<String>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// 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]
|
||||
|
||||
@@ -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<String>,
|
||||
/// Current programmatic error code, if any.
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
446
src/hid/mod.rs
446
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<String>,
|
||||
/// Current user-facing error, if any.
|
||||
pub error: Option<String>,
|
||||
/// Current programmatic error code, if any.
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
error_code: impl Into<String>,
|
||||
) -> 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<RwLock<HidBackendType>>,
|
||||
/// Event bus for broadcasting state changes (optional)
|
||||
events: tokio::sync::RwLock<Option<Arc<crate::events::EventBus>>>,
|
||||
/// Health monitor for error tracking and recovery
|
||||
monitor: Arc<HidHealthMonitor>,
|
||||
events: Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
|
||||
/// Unified HID runtime state.
|
||||
runtime_state: Arc<RwLock<HidRuntimeState>>,
|
||||
/// HID event queue sender (non-blocking)
|
||||
hid_tx: mpsc::Sender<HidEvent>,
|
||||
/// HID event queue receiver (moved into worker on first start)
|
||||
@@ -88,10 +157,8 @@ pub struct HidController {
|
||||
pending_move_flag: Arc<AtomicBool>,
|
||||
/// Worker task handle
|
||||
hid_worker: Mutex<Option<JoinHandle<()>>>,
|
||||
/// Health check task handle
|
||||
hid_health_checker: Mutex<Option<JoinHandle<()>>>,
|
||||
/// Backend availability fast flag
|
||||
backend_available: AtomicBool,
|
||||
/// Backend initialization fast flag
|
||||
backend_available: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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<crate::events::EventBus>) {
|
||||
*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<EventBus>) {
|
||||
*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<HidInfo> {
|
||||
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<HidHealthMonitor> {
|
||||
&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<RwLock<Option<Arc<dyn HidBackend>>>>,
|
||||
monitor: &Arc<HidHealthMonitor>,
|
||||
backend_type: &Arc<RwLock<HidBackendType>>,
|
||||
runtime_state: &Arc<RwLock<HidRuntimeState>>,
|
||||
events: &Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
|
||||
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<String> {
|
||||
match backend_type {
|
||||
HidBackendType::Ch9329 { port, .. } => Some(port.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_runtime_state(
|
||||
runtime_state: &Arc<RwLock<HidRuntimeState>>,
|
||||
events: &Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HidHealthStatus>,
|
||||
/// Event bus for notifications
|
||||
events: RwLock<Option<Arc<EventBus>>>,
|
||||
/// 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<Option<String>>,
|
||||
/// 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<EventBus>) {
|
||||
*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);
|
||||
}
|
||||
}
|
||||
144
src/hid/otg.rs
144
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<Option<(u32, u32)>>,
|
||||
/// UDC name for state checking (e.g., "fcc00000.usb")
|
||||
udc_name: parking_lot::RwLock<Option<String>>,
|
||||
/// 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<Option<(String, String)>>,
|
||||
/// Last error log time for throttling (using parking_lot for sync)
|
||||
last_error_log: parking_lot::Mutex<std::time::Instant>,
|
||||
/// 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<String>, error_code: impl Into<String>) {
|
||||
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 {
|
||||
|
||||
@@ -131,9 +131,7 @@ impl MsdController {
|
||||
|
||||
/// Set event bus for broadcasting state changes
|
||||
pub async fn set_event_bus(&self, events: std::sync::Arc<crate::events::EventBus>) {
|
||||
*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,
|
||||
|
||||
@@ -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<MsdHealthStatus>,
|
||||
/// Event bus for notifications
|
||||
events: RwLock<Option<Arc<EventBus>>>,
|
||||
/// 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<EventBus>) {
|
||||
*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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
src/state.rs
38
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
pub error: Option<String>,
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -3073,19 +3077,17 @@ pub async fn hid_otg_self_check(State(state): State<Arc<AppState>>) -> Json<OtgS
|
||||
|
||||
/// Get HID status
|
||||
pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, number>()
|
||||
|
||||
function hidErrorHint(errorCode?: string, backend?: string): string {
|
||||
switch (errorCode) {
|
||||
case 'udc_not_configured':
|
||||
return t('hid.errorHints.udcNotConfigured')
|
||||
case 'enoent':
|
||||
return t('hid.errorHints.hidDeviceMissing')
|
||||
case 'port_not_found':
|
||||
case 'port_not_opened':
|
||||
return t('hid.errorHints.portNotFound')
|
||||
case 'no_response':
|
||||
return t('hid.errorHints.noResponse')
|
||||
case 'protocol_error':
|
||||
case 'invalid_response':
|
||||
return t('hid.errorHints.protocolError')
|
||||
case 'health_check_failed':
|
||||
case 'health_check_join_failed':
|
||||
return t('hid.errorHints.healthCheckFailed')
|
||||
case 'eio':
|
||||
case 'epipe':
|
||||
case 'eshutdown':
|
||||
if (backend === 'otg') {
|
||||
return t('hid.errorHints.otgIoError')
|
||||
}
|
||||
if (backend === 'ch9329') {
|
||||
return t('hid.errorHints.ch9329IoError')
|
||||
}
|
||||
return t('hid.errorHints.ioError')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function formatHidReason(reason: string, errorCode?: string, backend?: string): string {
|
||||
const hint = hidErrorHint(errorCode, backend)
|
||||
if (!hint) return reason
|
||||
return `${reason} (${hint})`
|
||||
}
|
||||
|
||||
// HID event handlers
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<StatusDetail[]>(() => {
|
||||
{ label: t('statusCard.device'), value: hid.device || '-' },
|
||||
{ label: t('statusCard.backend'), value: hid.backend || t('common.unknown') },
|
||||
{ label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.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) {
|
||||
|
||||
Reference in New Issue
Block a user