diff --git a/src/config/schema/hid.rs b/src/config/schema/hid.rs index 8e852492..c7fdbcf4 100644 --- a/src/config/schema/hid.rs +++ b/src/config/schema/hid.rs @@ -34,6 +34,38 @@ impl Default for OtgDescriptorConfig { } } +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Ch9329DescriptorConfig { + pub vendor_id: u16, + pub product_id: u16, + pub manufacturer: String, + pub product: String, + pub serial_number: Option, +} + +impl Default for Ch9329DescriptorConfig { + fn default() -> Self { + Self { + vendor_id: 0x1a86, + product_id: 0xe129, + manufacturer: "WCH.CN".to_string(), + product: "CH9329".to_string(), + serial_number: None, + } + } +} + +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Ch9329DescriptorState { + pub descriptor: Ch9329DescriptorConfig, + pub manufacturer_enabled: bool, + pub product_enabled: bool, + pub serial_enabled: bool, + pub config_mode_available: bool, +} + #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] @@ -191,6 +223,10 @@ pub struct HidConfig { pub otg_keyboard_leds: bool, pub ch9329_port: String, pub ch9329_baudrate: u32, + #[serde(default)] + pub ch9329_hybrid_mouse: bool, + #[serde(default)] + pub ch9329_descriptor: Ch9329DescriptorConfig, pub mouse_absolute: bool, } @@ -206,6 +242,8 @@ impl Default for HidConfig { otg_keyboard_leds: false, ch9329_port: "/dev/ttyUSB0".to_string(), ch9329_baudrate: 9600, + ch9329_hybrid_mouse: false, + ch9329_descriptor: Ch9329DescriptorConfig::default(), mouse_absolute: true, } } diff --git a/src/diagnostics/linux.rs b/src/diagnostics/linux.rs index d703a8b8..e989100a 100644 --- a/src/diagnostics/linux.rs +++ b/src/diagnostics/linux.rs @@ -363,7 +363,7 @@ mod tests { fn parse_cpu_model_from_model_name_field() { let input = "processor\t: 0\nmodel name\t: Intel(R) Xeon(R)\n"; assert_eq!( - parse_cpu_model_from_cpuinfo_content(input), + parse_cpu_model_from_cpuinfo_content(Some(input)), Some("Intel(R) Xeon(R)".to_string()) ); } @@ -372,7 +372,7 @@ mod tests { fn parse_cpu_model_from_model_field() { let input = "processor\t: 0\nModel\t\t: Raspberry Pi 4 Model B Rev 1.4\n"; assert_eq!( - parse_cpu_model_from_cpuinfo_content(input), + parse_cpu_model_from_cpuinfo_content(Some(input)), Some("Raspberry Pi 4 Model B Rev 1.4".to_string()) ); } diff --git a/src/hid/backend.rs b/src/hid/backend.rs index c5a6e406..ac9c7b48 100644 --- a/src/hid/backend.rs +++ b/src/hid/backend.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::watch; use super::types::{ConsumerEvent, KeyboardEvent, MouseEvent}; +use crate::config::{Ch9329DescriptorConfig, Ch9329DescriptorState}; use crate::error::Result; use crate::events::LedState; @@ -21,6 +22,8 @@ pub enum HidBackendType { port: String, #[serde(default = "default_ch9329_baud_rate")] baud_rate: u32, + #[serde(default)] + hybrid_mouse: bool, }, #[default] None, @@ -63,6 +66,21 @@ pub trait HidBackend: Send + Sync { )) } + async fn apply_ch9329_descriptor( + &self, + _descriptor: &Ch9329DescriptorConfig, + ) -> Result { + Err(crate::error::AppError::BadRequest( + "CH9329 descriptor configuration is not supported by this backend".to_string(), + )) + } + + async fn read_ch9329_descriptor(&self) -> Result { + Err(crate::error::AppError::BadRequest( + "CH9329 descriptor reading is not supported by this backend".to_string(), + )) + } + async fn reset(&self) -> Result<()>; async fn shutdown(&self) -> Result<()>; diff --git a/src/hid/ch9329.rs b/src/hid/ch9329.rs index 25cc781a..4a1a2e18 100644 --- a/src/hid/ch9329.rs +++ b/src/hid/ch9329.rs @@ -14,7 +14,7 @@ use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU8, Ordering}; use std::sync::{mpsc, Arc}; use std::thread; use std::time::{Duration, Instant}; -use tokio::sync::watch; +use tokio::sync::{oneshot, watch}; use tracing::{info, trace, warn}; use super::backend::{HidBackend, HidBackendRuntimeSnapshot}; @@ -23,6 +23,7 @@ use super::ch9329_proto::{ DEFAULT_ADDR, DEFAULT_BAUD_RATE, MAX_PACKET_SIZE, }; use super::types::{KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType}; +use crate::config::{Ch9329DescriptorConfig, Ch9329DescriptorState}; use crate::error::{AppError, Result}; use crate::events::LedState; @@ -38,6 +39,101 @@ const INIT_WAIT_MS: u64 = 3000; const RECONNECT_COMMAND_POLL_MS: u64 = 100; +const PARAM_CFG_LEN: usize = 50; +const PARAM_CFG_VID_PID_OFFSET: usize = 11; +const PARAM_CFG_STRING_FLAGS_OFFSET: usize = 36; +const DESCRIPTOR_READ_RETRIES: usize = 3; +const DESCRIPTOR_RETRY_DELAY_MS: u64 = 80; +const DESCRIPTOR_APPLY_RESET_WAIT_MS: u64 = 3000; +const USB_STRING_MAX_LEN: usize = 23; +const USB_STRING_FLAG_ENABLE: u8 = 0x80; +const USB_STRING_FLAG_MANUFACTURER: u8 = 0x04; +const USB_STRING_FLAG_PRODUCT: u8 = 0x02; +const USB_STRING_FLAG_SERIAL: u8 = 0x01; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UsbStringType { + Manufacturer = 0x00, + Product = 0x01, + Serial = 0x02, +} + +impl UsbStringType { + fn as_u8(self) -> u8 { + self as u8 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParameterConfig { + bytes: [u8; PARAM_CFG_LEN], +} + +impl ParameterConfig { + fn from_response(data: &[u8]) -> Result { + let bytes: [u8; PARAM_CFG_LEN] = data.try_into().map_err(|_| { + Ch9329Backend::backend_error( + "Invalid CH9329 parameter config length", + "invalid_response", + ) + })?; + Self::validate_parameter_layout(&bytes)?; + Ok(Self { bytes }) + } + + fn validate_parameter_layout(bytes: &[u8; PARAM_CFG_LEN]) -> Result<()> { + const VALID_WORK_MODES: [u8; 8] = [0x00, 0x01, 0x02, 0x03, 0x80, 0x81, 0x82, 0x83]; + const VALID_SERIAL_MODES: [u8; 6] = [0x00, 0x01, 0x02, 0x80, 0x81, 0x82]; + + if !VALID_WORK_MODES.contains(&bytes[0]) || !VALID_SERIAL_MODES.contains(&bytes[1]) { + return Err(Ch9329Backend::backend_error( + format!( + "CH9329 did not return parameter config; enter protocol configuration mode by pulling SET low before reading or writing descriptors; response [{}]: {}", + bytes.len(), + Ch9329Backend::hex_bytes(bytes), + ), + "invalid_response", + )); + } + + Ok(()) + } + + fn set_vid_pid(&mut self, vendor_id: u16, product_id: u16) { + let offset = PARAM_CFG_VID_PID_OFFSET; + self.bytes[offset..offset + 2].copy_from_slice(&vendor_id.to_le_bytes()); + self.bytes[offset + 2..offset + 4].copy_from_slice(&product_id.to_le_bytes()); + } + + fn set_string_flags(&mut self, descriptor: &Ch9329DescriptorConfig) { + let mut flags = self.bytes[PARAM_CFG_STRING_FLAGS_OFFSET] & 0x78; + flags |= USB_STRING_FLAG_ENABLE | USB_STRING_FLAG_MANUFACTURER | USB_STRING_FLAG_PRODUCT; + if descriptor + .serial_number + .as_ref() + .is_some_and(|s| !s.is_empty()) + { + flags |= USB_STRING_FLAG_SERIAL; + } + self.bytes[PARAM_CFG_STRING_FLAGS_OFFSET] = flags; + } + + fn descriptor_base(&self) -> Ch9329DescriptorConfig { + let offset = PARAM_CFG_VID_PID_OFFSET; + Ch9329DescriptorConfig { + vendor_id: u16::from_le_bytes([self.bytes[offset], self.bytes[offset + 1]]), + product_id: u16::from_le_bytes([self.bytes[offset + 2], self.bytes[offset + 3]]), + manufacturer: String::new(), + product: String::new(), + serial_number: None, + } + } + + fn string_flags(&self) -> u8 { + self.bytes[PARAM_CFG_STRING_FLAGS_OFFSET] + } +} + struct Ch9329RuntimeState { initialized: AtomicBool, online: AtomicBool, @@ -109,7 +205,17 @@ impl Ch9329RuntimeState { } enum WorkerCommand { - Packet { cmd: u8, data: Vec }, + Packet { + cmd: u8, + data: Vec, + }, + ApplyDescriptor { + descriptor: Ch9329DescriptorConfig, + result_tx: oneshot::Sender>, + }, + ReadDescriptor { + result_tx: oneshot::Sender>, + }, ResetState, Shutdown, } @@ -128,6 +234,7 @@ pub struct Ch9329Backend { last_abs_x: Arc, last_abs_y: Arc, relative_mouse_active: Arc, + hybrid_mouse: bool, runtime: Arc, } @@ -137,6 +244,10 @@ impl Ch9329Backend { } pub fn with_baud_rate(port_path: &str, baud_rate: u32) -> Result { + Self::with_options(port_path, baud_rate, false) + } + + pub fn with_options(port_path: &str, baud_rate: u32, hybrid_mouse: bool) -> Result { Ok(Self { port_path: port_path.to_string(), baud_rate, @@ -151,6 +262,7 @@ impl Ch9329Backend { last_abs_x: Arc::new(AtomicU16::new(0)), last_abs_y: Arc::new(AtomicU16::new(0)), relative_mouse_active: Arc::new(AtomicBool::new(false)), + hybrid_mouse, runtime: Arc::new(Ch9329RuntimeState::new()), }) } @@ -221,10 +333,16 @@ impl Ch9329Backend { )); } - serialport::new(port_path, baud_rate) + let port = serialport::new(port_path, baud_rate) .timeout(Duration::from_millis(RESPONSE_TIMEOUT_MS)) .open() - .map_err(|e| Self::serial_error_to_hid_error(port_path, e, "Failed to open serial port")) + .map_err(|e| { + Self::serial_error_to_hid_error(port_path, e, "Failed to open serial port") + })?; + + let _ = port.clear(serialport::ClearBuffer::All); + + Ok(port) } fn write_packet( @@ -246,6 +364,8 @@ impl Ch9329Backend { cmd: u8, data: &[u8], ) -> Result { + let _ = port.clear(serialport::ClearBuffer::Input); + Self::write_packet(port, address, cmd, data)?; let mut pending = Vec::with_capacity(128); @@ -305,11 +425,7 @@ impl Ch9329Backend { } } - fn query_chip_info_on_port( - port: &mut dyn serialport::SerialPort, - address: u8, - ) -> Result { - let response = Self::xfer_packet(port, address, cmd::GET_INFO, &[])?; + fn ensure_success(response: Response) -> Result { if response.is_error { let reason = response .error_code @@ -317,11 +433,272 @@ impl Ch9329Backend { .unwrap_or_else(|| "CH9329 returned error response".to_string()); return Err(Self::backend_error(reason, "protocol_error")); } + Ok(response) + } + + fn query_chip_info_on_port( + port: &mut dyn serialport::SerialPort, + address: u8, + ) -> Result { + let response = Self::ensure_success(Self::xfer_packet(port, address, cmd::GET_INFO, &[])?)?; ChipInfo::from_response(&response.data) .ok_or_else(|| Self::backend_error("Failed to parse chip info", "invalid_response")) } + fn read_parameter_config_on_port( + port: &mut dyn serialport::SerialPort, + address: u8, + ) -> Result { + let mut last_error = None; + for attempt in 0..DESCRIPTOR_READ_RETRIES { + let result = + Self::ensure_success(Self::xfer_packet(port, address, cmd::GET_PARA_CFG, &[])?) + .and_then(|response| ParameterConfig::from_response(&response.data)); + + match result { + Ok(config) => return Ok(config), + Err(err) + if attempt + 1 < DESCRIPTOR_READ_RETRIES && Self::is_invalid_response(&err) => + { + last_error = Some(err); + thread::sleep(Duration::from_millis(DESCRIPTOR_RETRY_DELAY_MS)); + } + Err(err) => return Err(err), + } + } + + Err(last_error.unwrap_or_else(|| { + Self::backend_error("Failed to read CH9329 parameter config", "invalid_response") + })) + } + + fn write_parameter_config_on_port( + port: &mut dyn serialport::SerialPort, + address: u8, + config: &ParameterConfig, + ) -> Result<()> { + Self::ensure_success(Self::xfer_packet( + port, + address, + cmd::SET_PARA_CFG, + &config.bytes, + )?)?; + Ok(()) + } + + fn hex_bytes(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().saturating_mul(3).saturating_sub(1)); + for (index, byte) in data.iter().enumerate() { + if index > 0 { + out.push(' '); + } + use std::fmt::Write as _; + let _ = write!(&mut out, "{:02x}", byte); + } + out + } + + fn write_usb_string_on_port( + port: &mut dyn serialport::SerialPort, + address: u8, + string_type: UsbStringType, + value: &str, + ) -> Result<()> { + let data = Self::build_usb_string_data(string_type, value)?; + Self::ensure_success(Self::xfer_packet( + port, + address, + cmd::SET_USB_STRING, + &data, + )?)?; + Ok(()) + } + + fn read_usb_string_on_port( + port: &mut dyn serialport::SerialPort, + address: u8, + string_type: UsbStringType, + ) -> Result { + let data = [string_type.as_u8()]; + let response = Self::ensure_success(Self::xfer_packet( + port, + address, + cmd::GET_USB_STRING, + &data, + )?)?; + Self::parse_usb_string_response(&response.data) + } + + fn read_usb_string_with_retry_on_port( + port: &mut dyn serialport::SerialPort, + address: u8, + string_type: UsbStringType, + ) -> Result { + let mut last_error = None; + for attempt in 0..DESCRIPTOR_READ_RETRIES { + match Self::read_usb_string_on_port(port, address, string_type) { + Ok(value) => return Ok(value), + Err(err) + if attempt + 1 < DESCRIPTOR_READ_RETRIES && Self::is_invalid_response(&err) => + { + last_error = Some(err); + thread::sleep(Duration::from_millis(DESCRIPTOR_RETRY_DELAY_MS)); + } + Err(err) => return Err(err), + } + } + + Err(last_error.unwrap_or_else(|| { + Self::backend_error("Failed to read CH9329 USB string", "invalid_response") + })) + } + + fn build_usb_string_data(string_type: UsbStringType, value: &str) -> Result> { + let value = value.as_bytes(); + if value.len() > USB_STRING_MAX_LEN { + return Err(Self::backend_error( + "CH9329 USB string is too long", + "invalid_config", + )); + } + + let mut data = Vec::with_capacity(2 + value.len()); + data.push(string_type.as_u8()); + data.push(value.len() as u8); + data.extend_from_slice(value); + Ok(data) + } + + fn parse_usb_string_response(data: &[u8]) -> Result { + if data.len() < 2 { + return Err(Self::backend_error( + "Invalid CH9329 USB string response length", + "invalid_response", + )); + } + + let len = data[1] as usize; + if data.len() < 2 + len || len > USB_STRING_MAX_LEN { + return Err(Self::backend_error( + "Invalid CH9329 USB string response payload", + "invalid_response", + )); + } + + String::from_utf8(data[2..2 + len].to_vec()).map_err(|_| { + Self::backend_error("Invalid CH9329 USB string encoding", "invalid_response") + }) + } + + fn read_device_descriptor_on_port( + port: &mut dyn serialport::SerialPort, + address: u8, + ) -> Result { + let config = Self::read_parameter_config_on_port(port, address)?; + let flags = config.string_flags(); + let strings_enabled = flags & USB_STRING_FLAG_ENABLE != 0; + let manufacturer_enabled = strings_enabled && flags & USB_STRING_FLAG_MANUFACTURER != 0; + let product_enabled = strings_enabled && flags & USB_STRING_FLAG_PRODUCT != 0; + let serial_enabled = strings_enabled && flags & USB_STRING_FLAG_SERIAL != 0; + let mut descriptor = config.descriptor_base(); + + descriptor.manufacturer = if manufacturer_enabled { + Self::read_usb_string_with_retry_on_port(port, address, UsbStringType::Manufacturer)? + } else { + String::new() + }; + descriptor.product = if product_enabled { + Self::read_usb_string_with_retry_on_port(port, address, UsbStringType::Product)? + } else { + String::new() + }; + descriptor.serial_number = if serial_enabled { + let value = + Self::read_usb_string_with_retry_on_port(port, address, UsbStringType::Serial)?; + if value.is_empty() { + None + } else { + Some(value) + } + } else { + None + }; + + Ok(Ch9329DescriptorState { + descriptor, + manufacturer_enabled, + product_enabled, + serial_enabled, + config_mode_available: true, + }) + } + + fn apply_device_descriptor_on_port( + port: &mut dyn serialport::SerialPort, + address: u8, + descriptor: &Ch9329DescriptorConfig, + ) -> Result { + let mut config = Self::read_parameter_config_on_port(&mut *port, address)?; + config.set_vid_pid(descriptor.vendor_id, descriptor.product_id); + config.set_string_flags(descriptor); + Self::write_parameter_config_on_port(&mut *port, address, &config)?; + Self::write_usb_string_on_port( + &mut *port, + address, + UsbStringType::Manufacturer, + &descriptor.manufacturer, + )?; + Self::write_usb_string_on_port( + &mut *port, + address, + UsbStringType::Product, + &descriptor.product, + )?; + if let Some(serial_number) = descriptor.serial_number.as_deref() { + if !serial_number.is_empty() { + Self::write_usb_string_on_port( + &mut *port, + address, + UsbStringType::Serial, + serial_number, + )?; + } + } + + Self::try_best_effort_reset(port, address); + thread::sleep(Duration::from_millis(DESCRIPTOR_APPLY_RESET_WAIT_MS)); + Self::query_chip_info_on_port(port, address)?; + + Ok(Ch9329DescriptorState { + descriptor: descriptor.clone(), + manufacturer_enabled: true, + product_enabled: true, + serial_enabled: descriptor + .serial_number + .as_ref() + .is_some_and(|value| !value.is_empty()), + config_mode_available: true, + }) + } + + pub fn apply_device_descriptor( + port_path: &str, + baud_rate: u32, + descriptor: &Ch9329DescriptorConfig, + ) -> Result { + let mut port = Self::open_port(port_path, baud_rate)?; + Self::apply_device_descriptor_on_port(port.as_mut(), DEFAULT_ADDR, descriptor) + } + + pub fn read_device_descriptor( + port_path: &str, + baud_rate: u32, + ) -> Result { + let mut port = Self::open_port(port_path, baud_rate)?; + Self::read_device_descriptor_on_port(port.as_mut(), DEFAULT_ADDR) + } + fn open_ready_port( port_path: &str, baud_rate: u32, @@ -344,6 +721,41 @@ impl Ch9329Backend { } } + fn is_invalid_response(err: &AppError) -> bool { + matches!( + err, + AppError::HidError { + backend, + error_code, + .. + } if backend == "ch9329" && error_code == "invalid_response" + ) + } + + fn should_recover_descriptor_error(err: &AppError) -> bool { + match err { + AppError::HidError { + backend, + error_code, + .. + } if backend == "ch9329" => matches!( + error_code.as_str(), + "no_response" + | "write_failed" + | "read_failed" + | "io_error" + | "device_unavailable" + | "serial_error" + | "enxio" + | "enodev" + | "epipe" + | "eshutdown" + ), + AppError::HidError { .. } => false, + _ => true, + } + } + fn update_chip_info_cache( chip_info: &Arc>>, led_status: &Arc>, @@ -398,13 +810,29 @@ impl Ch9329Backend { Ok(WorkerCommand::Shutdown) | Err(mpsc::RecvTimeoutError::Disconnected) => { return false; } - Ok(_) | Err(mpsc::RecvTimeoutError::Timeout) => {} + Ok(command) => Self::reject_command_while_reconnecting(command), + Err(mpsc::RecvTimeoutError::Timeout) => {} } } } + fn reject_command_while_reconnecting(command: WorkerCommand) { + let err = || Self::backend_error("CH9329 is reconnecting", "reconnecting"); + match command { + WorkerCommand::ApplyDescriptor { result_tx, .. } + | WorkerCommand::ReadDescriptor { result_tx } => { + let _ = result_tx.send(Err(err())); + } + WorkerCommand::Packet { .. } | WorkerCommand::ResetState | WorkerCommand::Shutdown => {} + } + } + fn release_state_on_port(port: &mut dyn serialport::SerialPort, address: u8) -> Result<()> { - let reset_sequence = [(cmd::SEND_KB_GENERAL_DATA, vec![0; 8])]; + let reset_sequence = [ + (cmd::SEND_KB_GENERAL_DATA, vec![0; 8]), + (cmd::SEND_MS_REL_DATA, vec![0x01, 0, 0, 0, 0]), + (cmd::SEND_MS_ABS_DATA, vec![0x02, 0, 0, 0, 0, 0, 0]), + ]; for (cmd, data) in reset_sequence { Self::xfer_packet(port, address, cmd, &data)?; @@ -465,6 +893,39 @@ impl Ch9329Backend { } } + fn recover_worker_port( + mut port: Box, + rx: &mpsc::Receiver, + port_path: &str, + baud_rate: u32, + address: u8, + chip_info: &Arc>>, + led_status: &Arc>, + runtime: &Arc, + ) -> Option> { + Self::try_best_effort_reset(port.as_mut(), address); + drop(port); + Self::worker_reconnect_loop( + rx, port_path, baud_rate, address, chip_info, led_status, runtime, + ) + } + + fn finish_oneshot_command( + runtime: &Arc, + result: Result, + result_tx: oneshot::Sender>, + ) -> bool { + let success = result.is_ok(); + if let Err(ref err) = result { + Self::record_runtime_error(runtime, err); + } + let _ = result_tx.send(result); + if success { + runtime.set_online(); + } + success + } + fn send_keyboard_report(&self, report: &KeyboardReport) -> Result<()> { let data = report.to_bytes(); self.send_packet(cmd::SEND_KB_GENERAL_DATA, &data) @@ -503,6 +964,18 @@ impl Ch9329Backend { Ok(()) } + fn should_send_button_wheel_relative(&self) -> bool { + self.hybrid_mouse || self.relative_mouse_active.load(Ordering::Relaxed) + } + + fn absolute_move_buttons(&self, buttons: u8) -> u8 { + if self.hybrid_mouse { + 0 + } else { + buttons + } + } + fn worker_loop( port_path: String, baud_rate: u32, @@ -552,28 +1025,24 @@ impl Ch9329Backend { }; loop { + let recover_port = |port| { + Self::recover_worker_port( + port, + &rx, + &port_path, + baud_rate, + address, + &chip_info, + &led_status, + &runtime, + ) + }; + match rx.recv_timeout(Duration::from_millis(PROBE_INTERVAL_MS)) { Ok(WorkerCommand::Packet { cmd, data }) => { if let Err(err) = Self::xfer_packet(port.as_mut(), address, cmd, &data) { - if let AppError::HidError { - reason, error_code, .. - } = err - { - runtime.set_error(reason, error_code); - } - - Self::try_best_effort_reset(port.as_mut(), address); - drop(port); - - let Some(new_port) = Self::worker_reconnect_loop( - &rx, - &port_path, - baud_rate, - address, - &chip_info, - &led_status, - &runtime, - ) else { + Self::record_runtime_error(&runtime, &err); + let Some(new_port) = recover_port(port) else { break; }; port = new_port; @@ -581,6 +1050,36 @@ impl Ch9329Backend { runtime.set_online(); } } + Ok(WorkerCommand::ApplyDescriptor { + descriptor, + result_tx, + }) => { + let result = + Self::apply_device_descriptor_on_port(port.as_mut(), address, &descriptor); + let should_recover = result + .as_ref() + .is_err_and(Self::should_recover_descriptor_error); + if !Self::finish_oneshot_command(&runtime, result, result_tx) && should_recover + { + let Some(new_port) = recover_port(port) else { + break; + }; + port = new_port; + } + } + Ok(WorkerCommand::ReadDescriptor { result_tx }) => { + let result = Self::read_device_descriptor_on_port(port.as_mut(), address); + let should_recover = result + .as_ref() + .is_err_and(Self::should_recover_descriptor_error); + if !Self::finish_oneshot_command(&runtime, result, result_tx) && should_recover + { + let Some(new_port) = recover_port(port) else { + break; + }; + port = new_port; + } + } Ok(WorkerCommand::ResetState) => { Self::clear_local_state( &keyboard_state, @@ -591,17 +1090,7 @@ impl Ch9329Backend { ); if let Err(err) = Self::release_state_on_port(port.as_mut(), address) { Self::record_runtime_error(&runtime, &err); - Self::try_best_effort_reset(port.as_mut(), address); - drop(port); - let Some(new_port) = Self::worker_reconnect_loop( - &rx, - &port_path, - baud_rate, - address, - &chip_info, - &led_status, - &runtime, - ) else { + let Some(new_port) = recover_port(port) else { break; }; port = new_port; @@ -619,25 +1108,8 @@ impl Ch9329Backend { runtime.set_online(); } Err(err) => { - if let AppError::HidError { - reason, error_code, .. - } = err - { - runtime.set_error(reason, error_code); - } - - Self::try_best_effort_reset(port.as_mut(), address); - drop(port); - - let Some(new_port) = Self::worker_reconnect_loop( - &rx, - &port_path, - baud_rate, - address, - &chip_info, - &led_status, - &runtime, - ) else { + Self::record_runtime_error(&runtime, &err); + let Some(new_port) = recover_port(port) else { break; }; port = new_port; @@ -797,14 +1269,14 @@ impl HidBackend for Ch9329Backend { let y = ((event.y.clamp(0, 32767) as u32) * CH9329_MOUSE_RESOLUTION / 32768) as u16; self.last_abs_x.store(x, Ordering::Relaxed); self.last_abs_y.store(y, Ordering::Relaxed); - self.send_mouse_absolute(buttons, x, y, 0)?; + self.send_mouse_absolute(self.absolute_move_buttons(buttons), x, y, 0)?; } MouseEventType::Down => { if let Some(button) = event.button { let bit = button.to_hid_bit(); let new_buttons = self.mouse_buttons.fetch_or(bit, Ordering::Relaxed) | bit; trace!("Mouse down: {:?} buttons=0x{:02X}", button, new_buttons); - if self.relative_mouse_active.load(Ordering::Relaxed) { + if self.should_send_button_wheel_relative() { self.send_mouse_relative(new_buttons, 0, 0, 0)?; } else { let x = self.last_abs_x.load(Ordering::Relaxed); @@ -818,7 +1290,7 @@ impl HidBackend for Ch9329Backend { let bit = button.to_hid_bit(); let new_buttons = self.mouse_buttons.fetch_and(!bit, Ordering::Relaxed) & !bit; trace!("Mouse up: {:?} buttons=0x{:02X}", button, new_buttons); - if self.relative_mouse_active.load(Ordering::Relaxed) { + if self.should_send_button_wheel_relative() { self.send_mouse_relative(new_buttons, 0, 0, 0)?; } else { let x = self.last_abs_x.load(Ordering::Relaxed); @@ -828,7 +1300,7 @@ impl HidBackend for Ch9329Backend { } } MouseEventType::Scroll => { - if self.relative_mouse_active.load(Ordering::Relaxed) { + if self.should_send_button_wheel_relative() { self.send_mouse_relative(buttons, 0, 0, event.scroll)?; } else { let x = self.last_abs_x.load(Ordering::Relaxed); @@ -841,6 +1313,28 @@ impl HidBackend for Ch9329Backend { Ok(()) } + async fn apply_ch9329_descriptor( + &self, + descriptor: &Ch9329DescriptorConfig, + ) -> Result { + let (result_tx, result_rx) = oneshot::channel(); + self.enqueue_command(WorkerCommand::ApplyDescriptor { + descriptor: descriptor.clone(), + result_tx, + })?; + result_rx + .await + .map_err(|_| Self::backend_error("CH9329 worker stopped", "worker_stopped"))? + } + + async fn read_ch9329_descriptor(&self) -> Result { + let (result_tx, result_rx) = oneshot::channel(); + self.enqueue_command(WorkerCommand::ReadDescriptor { result_tx })?; + result_rx + .await + .map_err(|_| Self::backend_error("CH9329 worker stopped", "worker_stopped"))? + } + async fn reset(&self) -> Result<()> { { let mut state = self.keyboard_state.lock(); @@ -854,6 +1348,7 @@ impl HidBackend for Ch9329Backend { self.last_abs_x.store(0, Ordering::Relaxed); self.last_abs_y.store(0, Ordering::Relaxed); self.relative_mouse_active.store(false, Ordering::Relaxed); + self.send_mouse_relative(0, 0, 0, 0)?; self.send_mouse_absolute(0, 0, 0, 0)?; let _ = self.release_media_keys(); @@ -933,8 +1428,8 @@ impl HidBackend for Ch9329Backend { #[cfg(test)] mod tests { - use super::ch9329_proto::{build_packet, calculate_checksum}; use super::*; + use crate::hid::ch9329_proto::{build_packet, calculate_checksum, Ch9329Error}; #[test] fn test_packet_building() { @@ -1030,4 +1525,143 @@ mod tests { assert_eq!(Ch9329Error::from(0xE1), Ch9329Error::Timeout); assert_eq!(Ch9329Error::from(0xE4), Ch9329Error::ChecksumError); } + + #[test] + fn test_parameter_config_updates_vid_pid_and_string_flags() { + let mut raw = [0u8; PARAM_CFG_LEN]; + raw[0] = 0x80; + raw[1] = 0x80; + raw[PARAM_CFG_STRING_FLAGS_OFFSET] = 0x7F; + let mut config = ParameterConfig::from_response(&raw).unwrap(); + let descriptor = Ch9329DescriptorConfig { + vendor_id: 0x1209, + product_id: 0x9329, + manufacturer: "One-KVM".to_string(), + product: "One-KVM HID".to_string(), + serial_number: Some("ABC123".to_string()), + }; + + config.set_vid_pid(descriptor.vendor_id, descriptor.product_id); + config.set_string_flags(&descriptor); + + assert_eq!( + &config.bytes[PARAM_CFG_VID_PID_OFFSET..PARAM_CFG_VID_PID_OFFSET + 4], + &[0x09, 0x12, 0x29, 0x93] + ); + assert_eq!( + config.bytes[PARAM_CFG_STRING_FLAGS_OFFSET], + 0x78 | USB_STRING_FLAG_ENABLE + | USB_STRING_FLAG_MANUFACTURER + | USB_STRING_FLAG_PRODUCT + | USB_STRING_FLAG_SERIAL + ); + } + + #[test] + fn test_parameter_config_disables_custom_serial_when_empty() { + let mut raw = [0u8; PARAM_CFG_LEN]; + raw[0] = 0x80; + raw[1] = 0x80; + let mut config = ParameterConfig::from_response(&raw).unwrap(); + let descriptor = Ch9329DescriptorConfig { + vendor_id: 0x1a86, + product_id: 0xe129, + manufacturer: "WCH.CN".to_string(), + product: "CH9329".to_string(), + serial_number: None, + }; + + config.set_string_flags(&descriptor); + + assert_eq!( + config.bytes[PARAM_CFG_STRING_FLAGS_OFFSET], + USB_STRING_FLAG_ENABLE | USB_STRING_FLAG_MANUFACTURER | USB_STRING_FLAG_PRODUCT + ); + } + + #[test] + fn test_parameter_config_parses_vid_pid_and_string_flags() { + let mut raw = [0u8; PARAM_CFG_LEN]; + raw[0] = 0x80; + raw[1] = 0x80; + raw[PARAM_CFG_VID_PID_OFFSET..PARAM_CFG_VID_PID_OFFSET + 4] + .copy_from_slice(&[0x86, 0x1a, 0x29, 0xe1]); + raw[PARAM_CFG_STRING_FLAGS_OFFSET] = + USB_STRING_FLAG_ENABLE | USB_STRING_FLAG_MANUFACTURER | USB_STRING_FLAG_SERIAL; + + let config = ParameterConfig::from_response(&raw).unwrap(); + let descriptor = config.descriptor_base(); + + assert_eq!(descriptor.vendor_id, 0x1a86); + assert_eq!(descriptor.product_id, 0xe129); + assert_eq!( + config.string_flags(), + USB_STRING_FLAG_ENABLE | USB_STRING_FLAG_MANUFACTURER | USB_STRING_FLAG_SERIAL + ); + } + + #[test] + fn test_parameter_config_rejects_usb_string_descriptor_response() { + let mut raw = [0u8; PARAM_CFG_LEN]; + raw[..24].copy_from_slice(b"W\0C\0H\0 \0U\0A\0R\0T\0 \0T\0O\0 \0"); + + let err = ParameterConfig::from_response(&raw).unwrap_err(); + + assert!(err.to_string().contains("protocol configuration mode")); + } + + #[test] + fn test_usb_string_data() { + let data = Ch9329Backend::build_usb_string_data(UsbStringType::Product, "One-KVM").unwrap(); + + assert_eq!( + data, + vec![0x01, 7, b'O', b'n', b'e', b'-', b'K', b'V', b'M'] + ); + } + + #[test] + fn test_usb_string_data_rejects_overlong_value() { + let err = Ch9329Backend::build_usb_string_data( + UsbStringType::Manufacturer, + "x".repeat(24).as_str(), + ) + .unwrap_err(); + + assert!(err.to_string().contains("too long")); + } + + #[test] + fn test_usb_string_response_parsing() { + let value = Ch9329Backend::parse_usb_string_response(&[ + UsbStringType::Manufacturer.as_u8(), + 7, + b'O', + b'n', + b'e', + b'-', + b'K', + b'V', + b'M', + ]) + .unwrap(); + + assert_eq!(value, "One-KVM"); + } + + #[test] + fn test_hybrid_mouse_routes_buttons_and_wheel_to_relative_reports() { + let backend = Ch9329Backend::with_options("/dev/null", DEFAULT_BAUD_RATE, true).unwrap(); + + assert!(backend.should_send_button_wheel_relative()); + assert_eq!(backend.absolute_move_buttons(0x07), 0); + } + + #[test] + fn test_default_mouse_mode_preserves_absolute_report_buttons() { + let backend = Ch9329Backend::with_baud_rate("/dev/null", DEFAULT_BAUD_RATE).unwrap(); + + assert!(!backend.should_send_button_wheel_relative()); + assert_eq!(backend.absolute_move_buttons(0x07), 0x07); + } } diff --git a/src/hid/ch9329_proto.rs b/src/hid/ch9329_proto.rs index ef1bb0cb..c0594f30 100644 --- a/src/hid/ch9329_proto.rs +++ b/src/hid/ch9329_proto.rs @@ -17,6 +17,10 @@ pub mod cmd { pub const SEND_KB_MEDIA_DATA: u8 = 0x03; pub const SEND_MS_ABS_DATA: u8 = 0x04; pub const SEND_MS_REL_DATA: u8 = 0x05; + pub const GET_PARA_CFG: u8 = 0x08; + pub const SET_PARA_CFG: u8 = 0x09; + pub const GET_USB_STRING: u8 = 0x0A; + pub const SET_USB_STRING: u8 = 0x0B; pub const RESET: u8 = 0x0F; } diff --git a/src/hid/factory.rs b/src/hid/factory.rs index 4812d8dc..f92afbd5 100644 --- a/src/hid/factory.rs +++ b/src/hid/factory.rs @@ -39,13 +39,19 @@ impl HidBackendFactory { async fn create(&self, backend_type: &HidBackendType) -> Result>> { match backend_type { HidBackendType::Otg => self.create_otg_backend().await.map(Some), - HidBackendType::Ch9329 { port, baud_rate } => { + HidBackendType::Ch9329 { + port, + baud_rate, + hybrid_mouse, + } => { info!( - "Initializing CH9329 HID backend on {} @ {} baud", - port, baud_rate + "Initializing CH9329 HID backend on {} @ {} baud, hybrid_mouse={}", + port, baud_rate, hybrid_mouse ); - Ok(Some(Arc::new(ch9329::Ch9329Backend::with_baud_rate( - port, *baud_rate, + Ok(Some(Arc::new(ch9329::Ch9329Backend::with_options( + port, + *baud_rate, + *hybrid_mouse, )?))) } HidBackendType::None => { diff --git a/src/hid/mod.rs b/src/hid/mod.rs index ab25237a..12554586 100644 --- a/src/hid/mod.rs +++ b/src/hid/mod.rs @@ -98,6 +98,7 @@ use std::time::Duration; use tokio::sync::RwLock; use tracing::{info, warn}; +use crate::config::{Ch9329DescriptorConfig, Ch9329DescriptorState}; use crate::error::{AppError, Result}; use crate::events::EventBus; #[cfg(unix)] @@ -287,6 +288,36 @@ impl HidController { self.runtime_state.read().await.clone() } + async fn ch9329_backend(&self) -> Result> { + if !matches!( + *self.backend_type.read().await, + HidBackendType::Ch9329 { .. } + ) { + return Err(AppError::BadRequest( + "Current HID backend is not CH9329".to_string(), + )); + } + self.backend + .read() + .await + .clone() + .ok_or_else(|| AppError::BadRequest("CH9329 backend not available".to_string())) + } + + pub async fn apply_ch9329_descriptor( + &self, + descriptor: &Ch9329DescriptorConfig, + ) -> Result { + self.ch9329_backend() + .await? + .apply_ch9329_descriptor(descriptor) + .await + } + + pub async fn read_ch9329_descriptor(&self) -> Result { + self.ch9329_backend().await?.read_ch9329_descriptor().await + } + pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> { info!("Reloading HID backend: {:?}", new_backend_type); self.backend_available.store(false, Ordering::Release); diff --git a/src/main.rs b/src/main.rs index 7e37434b..53f7cd32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -305,6 +305,7 @@ async fn main() -> anyhow::Result<()> { config::HidBackend::Ch9329 => HidBackendType::Ch9329 { port: config.hid.ch9329_port.clone(), baud_rate: config.hid.ch9329_baudrate, + hybrid_mouse: config.hid.ch9329_hybrid_mouse, }, config::HidBackend::None => HidBackendType::None, }; diff --git a/src/runtime/android.rs b/src/runtime/android.rs index 1f1c9202..a654a88e 100644 --- a/src/runtime/android.rs +++ b/src/runtime/android.rs @@ -315,6 +315,7 @@ async fn build_app_state( config::HidBackend::Ch9329 => HidBackendType::Ch9329 { port: config.hid.ch9329_port.clone(), baud_rate: config.hid.ch9329_baudrate, + hybrid_mouse: config.hid.ch9329_hybrid_mouse, }, config::HidBackend::None => HidBackendType::None, }; diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index 76e7e7a5..63374ff8 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -33,6 +33,7 @@ fn hid_backend_type(config: &HidConfig) -> crate::hid::HidBackendType { HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 { port: config.ch9329_port.clone(), baud_rate: config.ch9329_baudrate, + hybrid_mouse: config.ch9329_hybrid_mouse, }, HidBackend::None => crate::hid::HidBackendType::None, } @@ -179,10 +180,12 @@ pub async fn apply_hid_config( old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds(); let endpoint_budget_changed = old_config.resolved_otg_endpoint_limit() != new_config.resolved_otg_endpoint_limit(); + let ch9329_runtime_changed = old_config.ch9329_hybrid_mouse != new_config.ch9329_hybrid_mouse; if old_config.backend == new_config.backend && old_config.ch9329_port == new_config.ch9329_port && old_config.ch9329_baudrate == new_config.ch9329_baudrate + && !ch9329_runtime_changed && old_config.otg_udc == new_config.otg_udc && !descriptor_changed && !hid_functions_changed diff --git a/src/web/handlers/config/hid.rs b/src/web/handlers/config/hid.rs index c875bb76..22b78e8a 100644 --- a/src/web/handlers/config/hid.rs +++ b/src/web/handlers/config/hid.rs @@ -1,7 +1,7 @@ use axum::{extract::State, Json}; use std::sync::Arc; -use crate::config::HidConfig; +use crate::config::{HidBackend, HidConfig}; use crate::error::Result; use crate::state::AppState; @@ -21,10 +21,20 @@ pub async fn update_hid_config( let _apply_guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?; let old_hid_config = state.config.get().hid.clone(); + let mut staged_hid_config = old_hid_config.clone(); + req.apply_to(&mut staged_hid_config); + let descriptor_update = req + .ch9329_descriptor + .as_ref() + .map(|_| staged_hid_config.ch9329_descriptor.clone()); + if descriptor_update.is_some() { + staged_hid_config.ch9329_descriptor = old_hid_config.ch9329_descriptor.clone(); + } + state .config .update(|config| { - req.apply_to(&mut config.hid); + config.hid = staged_hid_config.clone(); config.enforce_invariants(); }) .await?; @@ -39,5 +49,21 @@ pub async fn update_hid_config( ) .await?; + if let Some(descriptor) = descriptor_update { + if new_hid_config.backend != HidBackend::Ch9329 { + return Ok(Json(new_hid_config)); + } + + let actual = state.hid.apply_ch9329_descriptor(&descriptor).await?; + state + .config + .update(|config| { + config.hid.ch9329_descriptor = actual.descriptor.clone(); + config.enforce_invariants(); + }) + .await?; + return Ok(Json(state.config.get().hid.clone())); + } + Ok(Json(new_hid_config)) } diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index da04b0ab..bd4802ea 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -292,12 +292,63 @@ impl OtgHidFunctionsUpdate { } } +#[typeshare] +#[derive(Debug, Deserialize)] +pub struct Ch9329DescriptorConfigUpdate { + pub vendor_id: Option, + pub product_id: Option, + pub manufacturer: Option, + pub product: Option, + pub serial_number: Option, +} + +impl Ch9329DescriptorConfigUpdate { + pub fn validate(&self) -> crate::error::Result<()> { + Self::validate_optional_string("Manufacturer", self.manufacturer.as_deref())?; + Self::validate_optional_string("Product", self.product.as_deref())?; + Self::validate_optional_string("Serial number", self.serial_number.as_deref())?; + Ok(()) + } + + fn validate_optional_string(label: &str, value: Option<&str>) -> crate::error::Result<()> { + if let Some(value) = value { + if value.as_bytes().len() > 23 { + return Err(AppError::BadRequest(format!( + "{} string too long (max 23 bytes for CH9329)", + label + ))); + } + } + Ok(()) + } + + pub fn apply_to(&self, config: &mut Ch9329DescriptorConfig) { + if let Some(v) = self.vendor_id { + config.vendor_id = v; + } + if let Some(v) = self.product_id { + config.product_id = v; + } + if let Some(ref v) = self.manufacturer { + config.manufacturer = v.clone(); + } + if let Some(ref v) = self.product { + config.product = v.clone(); + } + if let Some(ref v) = self.serial_number { + config.serial_number = if v.is_empty() { None } else { Some(v.clone()) }; + } + } +} + #[typeshare] #[derive(Debug, Deserialize)] pub struct HidConfigUpdate { pub backend: Option, pub ch9329_port: Option, pub ch9329_baudrate: Option, + pub ch9329_hybrid_mouse: Option, + pub ch9329_descriptor: Option, pub otg_udc: Option, pub otg_descriptor: Option, pub otg_profile: Option, @@ -320,6 +371,9 @@ impl HidConfigUpdate { if let Some(ref desc) = self.otg_descriptor { desc.validate()?; } + if let Some(ref desc) = self.ch9329_descriptor { + desc.validate()?; + } Ok(()) } @@ -333,6 +387,12 @@ impl HidConfigUpdate { if let Some(baudrate) = self.ch9329_baudrate { config.ch9329_baudrate = baudrate; } + if let Some(enabled) = self.ch9329_hybrid_mouse { + config.ch9329_hybrid_mouse = enabled; + } + if let Some(ref desc) = self.ch9329_descriptor { + desc.apply_to(&mut config.ch9329_descriptor); + } if let Some(ref udc) = self.otg_udc { config.otg_udc = Some(udc.clone()); } diff --git a/src/web/handlers/hid_api.rs b/src/web/handlers/hid_api.rs index 00597b2d..5794c216 100644 --- a/src/web/handlers/hid_api.rs +++ b/src/web/handlers/hid_api.rs @@ -1,4 +1,11 @@ use super::*; +use crate::error::AppError; + +#[derive(Deserialize)] +pub struct Ch9329DescriptorQuery { + pub port: Option, + pub baud_rate: Option, +} #[derive(Serialize)] pub struct HidStatus { @@ -51,3 +58,57 @@ pub async fn hid_reset(State(state): State>) -> Result>, + Query(query): Query, +) -> Result> { + let config = state.config.get(); + let hid = &config.hid; + let port = query.port.as_deref().filter(|port| !port.trim().is_empty()); + let baud_rate = query.baud_rate; + + let descriptor_result = match (port, baud_rate) { + (Some(port), Some(baud_rate)) + if port != hid.ch9329_port || baud_rate != hid.ch9329_baudrate => + { + crate::hid::ch9329::Ch9329Backend::read_device_descriptor(port, baud_rate) + } + _ => state.hid.read_ch9329_descriptor().await, + }; + + let descriptor = match descriptor_result { + Ok(descriptor) => descriptor, + Err(err) if is_ch9329_config_mode_unavailable(&err) => cached_ch9329_descriptor(hid), + Err(err) => return Err(err), + }; + Ok(Json(descriptor)) +} + +fn is_ch9329_config_mode_unavailable(err: &AppError) -> bool { + matches!( + err, + AppError::HidError { + backend, + error_code, + .. + } if backend == "ch9329" && error_code == "invalid_response" + ) +} + +fn cached_ch9329_descriptor( + hid: &crate::config::HidConfig, +) -> crate::config::Ch9329DescriptorState { + let descriptor = hid.ch9329_descriptor.clone(); + crate::config::Ch9329DescriptorState { + manufacturer_enabled: !descriptor.manufacturer.is_empty(), + product_enabled: !descriptor.product.is_empty(), + serial_enabled: descriptor + .serial_number + .as_ref() + .is_some_and(|value| !value.is_empty()), + config_mode_available: false, + descriptor, + } +} diff --git a/src/web/handlers/setup.rs b/src/web/handlers/setup.rs index e8dde11c..f39e082a 100644 --- a/src/web/handlers/setup.rs +++ b/src/web/handlers/setup.rs @@ -170,6 +170,7 @@ pub async fn setup_init( crate::config::HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 { port: new_config.hid.ch9329_port.clone(), baud_rate: new_config.hid.ch9329_baudrate, + hybrid_mouse: new_config.hid.ch9329_hybrid_mouse, }, crate::config::HidBackend::None => crate::hid::HidBackendType::None, }; diff --git a/src/web/routes.rs b/src/web/routes.rs index e2deddfb..f5547ded 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -73,6 +73,10 @@ pub fn create_router(state: Arc) -> Router { .route("/webrtc/close", post(handlers::webrtc_close_session)) // HID endpoints .route("/hid/status", get(handlers::hid_status)) + .route( + "/hid/ch9329/descriptor", + get(handlers::hid_ch9329_descriptor), + ) .route("/hid/reset", post(handlers::hid_reset)) // WebSocket HID endpoint (for MJPEG mode) .route("/ws/hid", any(ws_hid_handler)) diff --git a/web/src/api/index.ts b/web/src/api/index.ts index e9ffc237..e837a756 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -1,5 +1,5 @@ import { request, ApiError } from './request' -import type { CanonicalKey } from '@/types/generated' +import type { CanonicalKey, Ch9329DescriptorState } from '@/types/generated' import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket' const API_BASE = '/api' @@ -435,6 +435,14 @@ export const hidApi = { reset: () => request<{ success: boolean }>('/hid/reset', { method: 'POST' }), + ch9329Descriptor: (params?: { port?: string; baudRate?: number }) => { + const query = new URLSearchParams() + if (params?.port) query.set('port', params.port) + if (params?.baudRate) query.set('baud_rate', String(params.baudRate)) + const suffix = query.toString() + return request(`/hid/ch9329/descriptor${suffix ? `?${suffix}` : ''}`) + }, + consumer: async (usage: number) => { await ensureHidConnection() await hidWs.sendConsumer({ usage }) diff --git a/web/src/api/request.ts b/web/src/api/request.ts index 7341b4a8..dd409398 100644 --- a/web/src/api/request.ts +++ b/web/src/api/request.ts @@ -23,6 +23,10 @@ function t(key: string, params?: Record): string { return String(i18n.global.t(key, params as any)) } +function hasTranslation(key: string): boolean { + return i18n.global.te(key) +} + export class ApiError extends Error { status: number @@ -52,9 +56,73 @@ function getToastKey(endpoint: string, config?: ApiRequestConfig): string { function getErrorMessage(data: unknown, fallback: string): string { if (data && typeof data === 'object') { const message = (data as any).message - if (typeof message === 'string' && message.trim()) return message + if (typeof message === 'string' && message.trim()) return localizeBackendErrorMessage(message) } - return fallback + return localizeBackendErrorMessage(fallback) +} + +function extractCh9329Command(reason: string): string { + const match = reason.match(/cmd 0x([0-9a-f]{2})/i) + const cmd = match?.[1] + return cmd ? `0x${cmd.toUpperCase()}` : '' +} + +function localizeHidErrorMessage(raw: string): string | null { + const match = raw.match(/^HID error \[([^\]]+)\]: (.*) \(code: ([^)]+)\)$/) + if (!match) return null + + const backend = match[1] ?? '' + const reason = match[2] ?? '' + const code = match[3] ?? '' + const command = extractCh9329Command(reason) + + const keyByCode: Record = { + udc_not_configured: 'hid.errorHints.udcNotConfigured', + disabled: 'hid.errorHints.disabled', + enoent: 'hid.errorHints.hidDeviceMissing', + not_opened: 'hid.errorHints.notOpened', + port_not_found: 'hid.errorHints.portNotFound', + invalid_config: 'hid.errorHints.invalidConfig', + no_response: command ? 'hid.errorHints.noResponseWithCmd' : 'hid.errorHints.noResponse', + protocol_error: 'hid.errorHints.protocolError', + invalid_response: 'hid.errorHints.protocolError', + enxio: 'hid.errorHints.deviceDisconnected', + enodev: 'hid.errorHints.deviceDisconnected', + serial_error: 'hid.errorHints.serialError', + init_failed: 'hid.errorHints.initFailed', + shutdown: 'hid.errorHints.shutdown', + reconnecting: 'hid.errorHints.reconnecting', + worker_stopped: 'hid.errorHints.workerStopped', + } + + const ioErrorCodes = new Set([ + 'eio', + 'epipe', + 'eshutdown', + 'io_error', + 'write_failed', + 'read_failed', + 'device_unavailable', + ]) + + const key = keyByCode[code] + ?? (ioErrorCodes.has(code) + ? backend === 'otg' + ? 'hid.errorHints.otgIoError' + : backend === 'ch9329' + ? 'hid.errorHints.ch9329IoError' + : 'hid.errorHints.ioError' + : '') + + if (key && hasTranslation(key)) { + return t(key, { cmd: command }) + } + + return t('hid.errorHints.backendError', { backend }) +} + +function localizeBackendErrorMessage(raw: string): string { + return localizeHidErrorMessage(raw) ?? raw } export async function request( diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 49fe2ac9..979df5a7 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -415,6 +415,9 @@ export default { serialError: 'Serial communication error, check CH9329 wiring and config', initFailed: 'CH9329 initialization failed, check serial settings and power', shutdown: 'HID backend has stopped', + reconnecting: 'CH9329 is reconnecting. Try again shortly', + workerStopped: 'CH9329 background communication has stopped. Check the device connection, then restart HID service or save HID settings again', + backendError: '{backend} HID backend error, check device connection and configuration', }, }, audio: { @@ -727,6 +730,18 @@ export default { hidBackend: 'HID Backend', serialDevice: 'Serial Device', baudRate: 'Baud Rate', + ch9329Options: 'CH9329 Options', + ch9329OptionsDesc: 'Configure runtime compatibility for the CH9329 serial HID chip', + ch9329HybridMouse: 'Linux Absolute Mouse Compatibility', + ch9329HybridMouseDesc: 'Keep absolute movement on absolute packets, but send buttons and wheel through relative packets', + ch9329Descriptor: 'CH9329 USB Device Descriptor', + ch9329DescriptorDesc: 'Read USB identification fields from the CH9329 chip before editing', + ch9329DescriptorLoading: 'Reading CH9329 descriptor...', + ch9329DescriptorLoadFailed: 'Failed to read CH9329 descriptor', + ch9329ConfigModeUnavailable: 'CH9329 configuration mode is unavailable. Pull SET low to read or write chip parameters; showing the last saved descriptor.', + ch9329DescriptorReadRequired: 'Read the CH9329 descriptor successfully before saving', + ch9329DescriptorWarning: 'Saving writes CH9329 parameters; changes may not show until the device is power-cycled or reconnected', + ch9329StringLengthWarning: 'CH9329 strings are limited to 23 bytes', otgHidProfile: 'OTG HID Functions', otgHidProfileDesc: 'Select which HID functions are exposed to the host', otgEndpointBudget: 'Max Endpoints', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index a25fc309..d052af71 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -414,6 +414,9 @@ export default { serialError: '串口通信异常,请检查 CH9329 接线与配置', initFailed: 'CH9329 初始化失败,请检查串口参数与供电', shutdown: 'HID 后端已停止', + reconnecting: 'CH9329 正在重连,请稍后重试', + workerStopped: 'CH9329 后台通信已停止,请检查设备连接后重启 HID 服务或重新保存 HID 设置', + backendError: '{backend} HID 后端异常,请检查设备连接与配置', }, }, audio: { @@ -726,6 +729,18 @@ export default { hidBackend: 'HID 后端', serialDevice: '串口设备', baudRate: '波特率', + ch9329Options: 'CH9329 选项', + ch9329OptionsDesc: '配置 CH9329 串口 HID 芯片的运行兼容性', + ch9329HybridMouse: 'Linux 绝对鼠标兼容模式', + ch9329HybridMouseDesc: '绝对移动仍使用绝对鼠标包,点击和滚轮改用相对鼠标包发送', + ch9329Descriptor: 'CH9329 USB 设备描述符', + ch9329DescriptorDesc: '先从 CH9329 芯片读取 USB 标识信息,读取成功后再修改', + ch9329DescriptorLoading: '正在读取 CH9329 描述符...', + ch9329DescriptorLoadFailed: '读取 CH9329 描述符失败', + ch9329ConfigModeUnavailable: 'CH9329 配置模式不可用。读取或写入芯片参数需要将 SET 拉低;当前显示上次保存的描述符。', + ch9329DescriptorReadRequired: '需要先成功读取 CH9329 描述符才能保存', + ch9329DescriptorWarning: '保存会写入 CH9329 参数;需要重新上电或重新插拔后才会变化', + ch9329StringLengthWarning: 'CH9329 字符串最长为 23 字节', otgHidProfile: 'OTG HID 功能', otgHidProfileDesc: '选择对目标主机暴露的 HID 功能', otgEndpointBudget: '最大端点数量', diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 191d13fc..3f45a473 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -54,6 +54,22 @@ export interface OtgHidFunctions { consumer: boolean; } +export interface Ch9329DescriptorConfig { + vendor_id: number; + product_id: number; + manufacturer: string; + product: string; + serial_number?: string; +} + +export interface Ch9329DescriptorState { + descriptor: Ch9329DescriptorConfig; + manufacturer_enabled: boolean; + product_enabled: boolean; + serial_enabled: boolean; + config_mode_available: boolean; +} + export interface HidConfig { backend: HidBackend; otg_udc?: string; @@ -64,6 +80,8 @@ export interface HidConfig { otg_keyboard_leds?: boolean; ch9329_port: string; ch9329_baudrate: number; + ch9329_hybrid_mouse?: boolean; + ch9329_descriptor?: Ch9329DescriptorConfig; mouse_absolute: boolean; } @@ -175,6 +193,11 @@ export interface EasytierConfig { virtual_ip?: string; } +export enum FrpcConfigMode { + Quick = "quick", + Full = "full", +} + export enum FrpProxyType { Tcp = "tcp", Udp = "udp", @@ -185,11 +208,6 @@ export enum FrpProxyType { Xtcp = "xtcp", } -export enum FrpcConfigMode { - Quick = "quick", - Full = "full", -} - export interface FrpcConfig { enabled: boolean; config_mode: FrpcConfigMode; @@ -302,6 +320,14 @@ export interface AuthConfigUpdate { single_user_allow_multiple_sessions?: boolean; } +export interface Ch9329DescriptorConfigUpdate { + vendor_id?: number; + product_id?: number; + manufacturer?: string; + product?: string; + serial_number?: string; +} + export interface EasytierConfigUpdate { enabled?: boolean; network_name?: string; @@ -310,23 +336,6 @@ export interface EasytierConfigUpdate { virtual_ip?: string; } -export interface FrpcConfigUpdate { - enabled?: boolean; - config_mode?: FrpcConfigMode; - proxy_name?: string; - proxy_type?: FrpProxyType; - server_addr?: string; - server_port?: number; - token?: string; - local_ip?: string; - local_port?: number; - remote_port?: number; - custom_domain?: string; - secret_key?: string; - tls?: boolean; - custom_toml?: string; -} - export type ExtensionStatus = | { state: "unavailable", data?: undefined } | { state: "stopped", data?: undefined } @@ -382,6 +391,23 @@ export interface ExtensionsStatus { frpc: FrpcInfo; } +export interface FrpcConfigUpdate { + enabled?: boolean; + config_mode?: FrpcConfigMode; + proxy_name?: string; + proxy_type?: FrpProxyType; + server_addr?: string; + server_port?: number; + token?: string; + local_ip?: string; + local_port?: number; + remote_port?: number | null; + custom_domain?: string | null; + secret_key?: string; + tls?: boolean; + custom_toml?: string; +} + export interface GostcConfigUpdate { enabled?: boolean; addr?: string; @@ -409,6 +435,8 @@ export interface HidConfigUpdate { backend?: HidBackend; ch9329_port?: string; ch9329_baudrate?: number; + ch9329_hybrid_mouse?: boolean; + ch9329_descriptor?: Ch9329DescriptorConfigUpdate; otg_udc?: string; otg_descriptor?: OtgDescriptorConfigUpdate; otg_profile?: OtgHidProfile; diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index ec61946c..7adf0f77 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -42,6 +42,8 @@ import type { OtgEndpointBudget, OtgHidProfile, OtgHidFunctions, + Ch9329DescriptorConfig, + Ch9329DescriptorState, } from '@/types/generated' import { FrpProxyType, FrpcConfigMode } from '@/types/generated' import { formatFpsLabel, toConfigFps } from '@/lib/fps' @@ -561,6 +563,7 @@ const config = ref({ consumer: true, } as OtgHidFunctions, hid_otg_keyboard_leds: false, + hid_ch9329_hybrid_mouse: false, msd_enabled: false, msd_dir: '', encoder_backend: 'auto', @@ -953,12 +956,135 @@ const otgProductIdHex = ref('0104') const otgManufacturer = ref('One-KVM') const otgProduct = ref('One-KVM USB Device') const otgSerialNumber = ref('') +const ch9329VendorIdHex = ref('1a86') +const ch9329ProductIdHex = ref('e129') +const ch9329Manufacturer = ref('WCH.CN') +const ch9329Product = ref('CH9329') +const ch9329SerialNumber = ref('') +const ch9329DescriptorLoaded = ref(false) +const ch9329DescriptorLoading = ref(false) +const ch9329DescriptorError = ref('') +const ch9329DescriptorSource = ref<{ port: string; baudrate: number } | null>(null) +const ch9329DescriptorBaseline = ref<{ + vendorId: string + productId: string + manufacturer: string + product: string + serialNumber: string +} | null>(null) +const utf8Encoder = new TextEncoder() const validateHex = (event: Event, _field: string) => { const input = event.target as HTMLInputElement input.value = input.value.replace(/[^0-9a-fA-F]/g, '').toLowerCase() } +function utf8ByteLength(value: string): number { + return utf8Encoder.encode(value).length +} + +function applyCh9329DescriptorForm(descriptor: Ch9329DescriptorConfig, defaults = false) { + ch9329VendorIdHex.value = descriptor.vendor_id?.toString(16).padStart(4, '0') || '1a86' + ch9329ProductIdHex.value = descriptor.product_id?.toString(16).padStart(4, '0') || 'e129' + ch9329Manufacturer.value = descriptor.manufacturer || (defaults ? 'WCH.CN' : '') + ch9329Product.value = descriptor.product || (defaults ? 'CH9329' : '') + ch9329SerialNumber.value = descriptor.serial_number || '' +} + +function applyCh9329DescriptorState(state: Ch9329DescriptorState) { + applyCh9329DescriptorForm(state.descriptor) + ch9329DescriptorBaseline.value = currentCh9329DescriptorForm() + if (!state.config_mode_available) { + ch9329DescriptorError.value = t('settings.ch9329ConfigModeUnavailable') + } +} + +function currentCh9329DescriptorForm() { + return { + vendorId: ch9329VendorIdHex.value.toLowerCase().padStart(4, '0'), + productId: ch9329ProductIdHex.value.toLowerCase().padStart(4, '0'), + manufacturer: ch9329Manufacturer.value, + product: ch9329Product.value, + serialNumber: ch9329SerialNumber.value, + } +} + +function currentCh9329DescriptorSource() { + return { + port: config.value.hid_serial_device || '', + baudrate: Number(config.value.hid_serial_baudrate) || 9600, + } +} + +function clearCh9329DescriptorState() { + ch9329DescriptorLoaded.value = false + ch9329DescriptorLoading.value = false + ch9329DescriptorError.value = '' + ch9329DescriptorSource.value = null + ch9329DescriptorBaseline.value = null +} + +const isCh9329DescriptorSourceCurrent = computed(() => { + if (config.value.hid_backend !== 'ch9329') return false + const source = ch9329DescriptorSource.value + if (!source) return false + const current = currentCh9329DescriptorSource() + return source.port === current.port && source.baudrate === current.baudrate +}) + +async function loadCh9329Descriptor() { + if (config.value.hid_backend !== 'ch9329') return + const source = currentCh9329DescriptorSource() + ch9329DescriptorLoading.value = true + ch9329DescriptorLoaded.value = false + ch9329DescriptorSource.value = null + ch9329DescriptorError.value = '' + try { + const state = await hidApi.ch9329Descriptor({ + port: source.port, + baudRate: source.baudrate, + }) + applyCh9329DescriptorState(state) + ch9329DescriptorLoaded.value = true + ch9329DescriptorSource.value = source + } catch (e) { + ch9329DescriptorError.value = e instanceof Error ? e.message : t('settings.ch9329DescriptorLoadFailed') + } finally { + ch9329DescriptorLoading.value = false + } +} + +const isCh9329DescriptorValid = computed(() => { + if (config.value.hid_backend !== 'ch9329') return true + return utf8ByteLength(ch9329Manufacturer.value) <= 23 + && utf8ByteLength(ch9329Product.value) <= 23 + && utf8ByteLength(ch9329SerialNumber.value) <= 23 +}) + +const canEditCh9329Descriptor = computed(() => + config.value.hid_backend === 'ch9329' + && ch9329DescriptorLoaded.value + && isCh9329DescriptorSourceCurrent.value + && !ch9329DescriptorLoading.value +) + +const isCh9329DescriptorDirty = computed(() => { + if (!canEditCh9329Descriptor.value || !ch9329DescriptorBaseline.value) return false + const current = currentCh9329DescriptorForm() + const baseline = ch9329DescriptorBaseline.value + return current.vendorId !== baseline.vendorId + || current.productId !== baseline.productId + || current.manufacturer !== baseline.manufacturer + || current.product !== baseline.product + || current.serialNumber !== baseline.serialNumber +}) + +const isHidSettingsValid = computed(() => + isHidFunctionSelectionValid.value + && isOtgEndpointBudgetValid.value + && isCh9329DescriptorValid.value +) + watch(() => config.value.msd_enabled, (enabled) => { if (!enabled && activeSection.value === 'msd') { activeSection.value = 'hid' @@ -1265,13 +1391,23 @@ async function saveConfig() { } if (activeSection.value === 'hid') { - if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) { + if (!isHidSettingsValid.value) { return } const hidUpdate: any = { backend: config.value.hid_backend as any, ch9329_port: config.value.hid_serial_device || undefined, ch9329_baudrate: config.value.hid_serial_baudrate, + ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse, + } + if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) { + hidUpdate.ch9329_descriptor = { + vendor_id: parseInt(ch9329VendorIdHex.value, 16) || 0x1a86, + product_id: parseInt(ch9329ProductIdHex.value, 16) || 0xe129, + manufacturer: ch9329Manufacturer.value, + product: ch9329Product.value, + serial_number: ch9329SerialNumber.value || '', + } } if (config.value.hid_backend === 'otg') { hidUpdate.otg_descriptor = { @@ -1287,9 +1423,11 @@ async function saveConfig() { hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds } await configStore.updateHid(hidUpdate) - await configStore.updateMsd({ - enabled: config.value.hid_backend === 'otg' && config.value.msd_enabled, - }) + if (config.value.hid_backend === 'otg') { + await configStore.updateMsd({ enabled: config.value.msd_enabled }) + } else { + await configStore.updateMsd({ enabled: false }) + } } if (activeSection.value === 'msd') { @@ -1298,7 +1436,9 @@ async function saveConfig() { }) } - await loadSectionData(activeSection.value) + if (activeSection.value !== 'hid') { + await loadSectionData(activeSection.value) + } saved.value = true setTimeout(() => (saved.value = false), 2000) } catch { @@ -1335,6 +1475,7 @@ async function loadConfig() { consumer: hid.otg_functions?.consumer ?? true, } as OtgHidFunctions, hid_otg_keyboard_leds: hid.otg_keyboard_leds ?? false, + hid_ch9329_hybrid_mouse: hid.ch9329_hybrid_mouse ?? false, msd_enabled: msd.enabled || false, msd_dir: msd.msd_dir || '', encoder_backend: stream.encoder || 'auto', @@ -1351,6 +1492,16 @@ async function loadConfig() { otgProduct.value = hid.otg_descriptor.product || 'One-KVM USB Device' otgSerialNumber.value = hid.otg_descriptor.serial_number || '' } + if (hid.ch9329_descriptor) { + if (hid.backend !== 'ch9329') { + applyCh9329DescriptorForm(hid.ch9329_descriptor, true) + } + } + if (hid.backend === 'ch9329') { + await loadCh9329Descriptor() + } else { + clearCh9329DescriptorState() + } } catch { } @@ -2323,8 +2474,22 @@ watch(updateChannel, async () => { watch(() => config.value.hid_backend, () => { otgSelfCheckResult.value = null otgSelfCheckError.value = '' + if (config.value.hid_backend === 'ch9329') { + void loadCh9329Descriptor() + } else { + clearCh9329DescriptorState() + } }) +watch( + () => [config.value.hid_serial_device, config.value.hid_serial_baudrate], + () => { + if (config.value.hid_backend === 'ch9329' && !isCh9329DescriptorSourceCurrent.value) { + clearCh9329DescriptorState() + } + }, +) + watch(() => route.query.tab, (tab) => { const section = normalizeSettingsSection(tab) if (section && activeSection.value !== section) { @@ -2725,6 +2890,109 @@ watch(isWindows, () => { + +