From 5c98aea7e37582d140b6df745aadd323699032a4 Mon Sep 17 00:00:00 2001
From: mofeng-git
Date: Sun, 14 Jun 2026 20:59:23 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Linux=20=E7=BB=9D?=
=?UTF-8?q?=E5=AF=B9=E9=BC=A0=E6=A0=87=E5=85=BC=E5=AE=B9=E6=A8=A1=E5=BC=8F?=
=?UTF-8?q?=20#266=EF=BC=9B=E6=96=B0=E5=A2=9E=20CH9329=20=E6=8F=8F?=
=?UTF-8?q?=E8=BF=B0=E7=AC=A6=E8=AE=BE=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/config/schema/hid.rs | 38 ++
src/diagnostics/linux.rs | 4 +-
src/hid/backend.rs | 18 +
src/hid/ch9329.rs | 764 ++++++++++++++++++++++++++++---
src/hid/ch9329_proto.rs | 4 +
src/hid/factory.rs | 16 +-
src/hid/mod.rs | 31 ++
src/main.rs | 1 +
src/runtime/android.rs | 1 +
src/web/handlers/config/apply.rs | 3 +
src/web/handlers/config/hid.rs | 30 +-
src/web/handlers/config/types.rs | 60 +++
src/web/handlers/hid_api.rs | 61 +++
src/web/handlers/setup.rs | 1 +
src/web/routes.rs | 4 +
web/src/api/index.ts | 10 +-
web/src/api/request.ts | 72 ++-
web/src/i18n/en-US.ts | 15 +
web/src/i18n/zh-CN.ts | 15 +
web/src/types/generated.ts | 72 ++-
web/src/views/SettingsView.vue | 288 +++++++++++-
21 files changed, 1403 insertions(+), 105 deletions(-)
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
+
+
+ {{ t('settings.ch9329StringLengthWarning') }}
+
+
+
+ {{ t('settings.ch9329DescriptorLoading') }}
+
{{ t('settings.unsavedChangesHint') }}
-