mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-15 12:12:01 +08:00
feat: 新增 Linux 绝对鼠标兼容模式 #266;新增 CH9329 描述符设置
This commit is contained in:
@@ -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<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Ch9329DescriptorState> {
|
||||
Err(crate::error::AppError::BadRequest(
|
||||
"CH9329 descriptor configuration is not supported by this backend".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn read_ch9329_descriptor(&self) -> Result<Ch9329DescriptorState> {
|
||||
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<()>;
|
||||
|
||||
@@ -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<Self> {
|
||||
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<u8> },
|
||||
Packet {
|
||||
cmd: u8,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
ApplyDescriptor {
|
||||
descriptor: Ch9329DescriptorConfig,
|
||||
result_tx: oneshot::Sender<Result<Ch9329DescriptorState>>,
|
||||
},
|
||||
ReadDescriptor {
|
||||
result_tx: oneshot::Sender<Result<Ch9329DescriptorState>>,
|
||||
},
|
||||
ResetState,
|
||||
Shutdown,
|
||||
}
|
||||
@@ -128,6 +234,7 @@ pub struct Ch9329Backend {
|
||||
last_abs_x: Arc<AtomicU16>,
|
||||
last_abs_y: Arc<AtomicU16>,
|
||||
relative_mouse_active: Arc<AtomicBool>,
|
||||
hybrid_mouse: bool,
|
||||
runtime: Arc<Ch9329RuntimeState>,
|
||||
}
|
||||
|
||||
@@ -137,6 +244,10 @@ impl Ch9329Backend {
|
||||
}
|
||||
|
||||
pub fn with_baud_rate(port_path: &str, baud_rate: u32) -> Result<Self> {
|
||||
Self::with_options(port_path, baud_rate, false)
|
||||
}
|
||||
|
||||
pub fn with_options(port_path: &str, baud_rate: u32, hybrid_mouse: bool) -> Result<Self> {
|
||||
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<Response> {
|
||||
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<ChipInfo> {
|
||||
let response = Self::xfer_packet(port, address, cmd::GET_INFO, &[])?;
|
||||
fn ensure_success(response: Response) -> Result<Response> {
|
||||
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<ChipInfo> {
|
||||
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<ParameterConfig> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<Vec<u8>> {
|
||||
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<String> {
|
||||
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<Ch9329DescriptorState> {
|
||||
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<Ch9329DescriptorState> {
|
||||
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<Ch9329DescriptorState> {
|
||||
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<Ch9329DescriptorState> {
|
||||
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<RwLock<Option<ChipInfo>>>,
|
||||
led_status: &Arc<RwLock<LedStatus>>,
|
||||
@@ -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<dyn serialport::SerialPort>,
|
||||
rx: &mpsc::Receiver<WorkerCommand>,
|
||||
port_path: &str,
|
||||
baud_rate: u32,
|
||||
address: u8,
|
||||
chip_info: &Arc<RwLock<Option<ChipInfo>>>,
|
||||
led_status: &Arc<RwLock<LedStatus>>,
|
||||
runtime: &Arc<Ch9329RuntimeState>,
|
||||
) -> Option<Box<dyn serialport::SerialPort>> {
|
||||
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<T>(
|
||||
runtime: &Arc<Ch9329RuntimeState>,
|
||||
result: Result<T>,
|
||||
result_tx: oneshot::Sender<Result<T>>,
|
||||
) -> 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<Ch9329DescriptorState> {
|
||||
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<Ch9329DescriptorState> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,13 +39,19 @@ impl HidBackendFactory {
|
||||
async fn create(&self, backend_type: &HidBackendType) -> Result<Option<Arc<dyn HidBackend>>> {
|
||||
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 => {
|
||||
|
||||
@@ -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<Arc<dyn HidBackend>> {
|
||||
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<Ch9329DescriptorState> {
|
||||
self.ch9329_backend()
|
||||
.await?
|
||||
.apply_ch9329_descriptor(descriptor)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn read_ch9329_descriptor(&self) -> Result<Ch9329DescriptorState> {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -292,12 +292,63 @@ impl OtgHidFunctionsUpdate {
|
||||
}
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Ch9329DescriptorConfigUpdate {
|
||||
pub vendor_id: Option<u16>,
|
||||
pub product_id: Option<u16>,
|
||||
pub manufacturer: Option<String>,
|
||||
pub product: Option<String>,
|
||||
pub serial_number: Option<String>,
|
||||
}
|
||||
|
||||
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<HidBackend>,
|
||||
pub ch9329_port: Option<String>,
|
||||
pub ch9329_baudrate: Option<u32>,
|
||||
pub ch9329_hybrid_mouse: Option<bool>,
|
||||
pub ch9329_descriptor: Option<Ch9329DescriptorConfigUpdate>,
|
||||
pub otg_udc: Option<String>,
|
||||
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
|
||||
pub otg_profile: Option<OtgHidProfile>,
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
use super::*;
|
||||
use crate::error::AppError;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Ch9329DescriptorQuery {
|
||||
pub port: Option<String>,
|
||||
pub baud_rate: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HidStatus {
|
||||
@@ -51,3 +58,57 @@ pub async fn hid_reset(State(state): State<Arc<AppState>>) -> Result<Json<LoginR
|
||||
message: Some("HID state reset".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Read the CH9329 USB descriptor, falling back to the saved config when SET is not low.
|
||||
pub async fn hid_ch9329_descriptor(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<Ch9329DescriptorQuery>,
|
||||
) -> Result<Json<crate::config::Ch9329DescriptorState>> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -73,6 +73,10 @@ pub fn create_router(state: Arc<AppState>) -> 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))
|
||||
|
||||
@@ -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<Ch9329DescriptorState>(`/hid/ch9329/descriptor${suffix ? `?${suffix}` : ''}`)
|
||||
},
|
||||
|
||||
consumer: async (usage: number) => {
|
||||
await ensureHidConnection()
|
||||
await hidWs.sendConsumer({ usage })
|
||||
|
||||
@@ -23,6 +23,10 @@ function t(key: string, params?: Record<string, unknown>): 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<string, string> = {
|
||||
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<T>(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '最大端点数量',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, () => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<template v-if="config.hid_backend === 'ch9329'">
|
||||
<Separator class="my-4" />
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">{{ t('settings.ch9329Options') }}</h4>
|
||||
<p class="text-sm text-muted-foreground">{{ t('settings.ch9329OptionsDesc') }}</p>
|
||||
</div>
|
||||
<div class="space-y-3 rounded-md border border-border/60 p-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label>{{ t('settings.ch9329HybridMouse') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.ch9329HybridMouseDesc') }}</p>
|
||||
</div>
|
||||
<Switch v-model="config.hid_ch9329_hybrid_mouse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator class="my-4" />
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">{{ t('settings.ch9329Descriptor') }}</h4>
|
||||
<p class="text-sm text-muted-foreground">{{ t('settings.ch9329DescriptorDesc') }}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" :aria-label="t('common.refresh')" :disabled="ch9329DescriptorLoading" @click="loadCh9329Descriptor">
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': ch9329DescriptorLoading }" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="ch9329DescriptorLoading" class="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t('settings.ch9329DescriptorLoading') }}
|
||||
</p>
|
||||
<p v-else-if="ch9329DescriptorError" class="text-sm text-destructive">
|
||||
{{ ch9329DescriptorError }}
|
||||
</p>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="ch9329-vid">{{ t('settings.vendorId') }}</Label>
|
||||
<Input
|
||||
id="ch9329-vid"
|
||||
v-model="ch9329VendorIdHex"
|
||||
placeholder="1a86"
|
||||
maxlength="4"
|
||||
:disabled="!canEditCh9329Descriptor"
|
||||
@input="validateHex($event, 'ch9329-vid')"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="ch9329-pid">{{ t('settings.productId') }}</Label>
|
||||
<Input
|
||||
id="ch9329-pid"
|
||||
v-model="ch9329ProductIdHex"
|
||||
placeholder="e129"
|
||||
maxlength="4"
|
||||
:disabled="!canEditCh9329Descriptor"
|
||||
@input="validateHex($event, 'ch9329-pid')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="ch9329-manufacturer">{{ t('settings.manufacturer') }}</Label>
|
||||
<Input
|
||||
id="ch9329-manufacturer"
|
||||
v-model="ch9329Manufacturer"
|
||||
placeholder="WCH.CN"
|
||||
maxlength="23"
|
||||
:disabled="!canEditCh9329Descriptor"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="ch9329-product">{{ t('settings.productName') }}</Label>
|
||||
<Input
|
||||
id="ch9329-product"
|
||||
v-model="ch9329Product"
|
||||
placeholder="CH9329"
|
||||
maxlength="23"
|
||||
:disabled="!canEditCh9329Descriptor"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="ch9329-serial">{{ t('settings.serialNumber') }}</Label>
|
||||
<Input
|
||||
id="ch9329-serial"
|
||||
v-model="ch9329SerialNumber"
|
||||
:placeholder="t('settings.serialNumberAuto')"
|
||||
maxlength="23"
|
||||
:disabled="!canEditCh9329Descriptor"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!ch9329DescriptorLoading && !ch9329DescriptorLoaded && !ch9329DescriptorError" class="text-xs text-muted-foreground">
|
||||
{{ t('settings.ch9329DescriptorReadRequired') }}
|
||||
</p>
|
||||
<p v-if="!isCh9329DescriptorValid" class="text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('settings.ch9329StringLengthWarning') }}
|
||||
</p>
|
||||
<p class="text-sm text-amber-600 dark:text-amber-400">
|
||||
{{ t('settings.ch9329DescriptorWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- OTG Descriptor Settings -->
|
||||
<template v-if="config.hid_backend === 'otg'">
|
||||
<Separator class="my-4" />
|
||||
@@ -4678,8 +4946,16 @@ watch(isWindows, () => {
|
||||
<AlertTriangle class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ t('settings.otgFunctionMinWarning') }}</span>
|
||||
</p>
|
||||
<p v-else-if="activeSection === 'hid' && !isCh9329DescriptorValid" class="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5 min-w-0">
|
||||
<AlertTriangle class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ t('settings.ch9329StringLengthWarning') }}</span>
|
||||
</p>
|
||||
<p v-else-if="activeSection === 'hid' && config.hid_backend === 'ch9329' && ch9329DescriptorLoading" class="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5 min-w-0">
|
||||
<AlertTriangle class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ t('settings.ch9329DescriptorLoading') }}</span>
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted-foreground hidden sm:block">{{ t('settings.unsavedChangesHint') }}</p>
|
||||
<Button class="shrink-0 ml-auto" :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
|
||||
<Button class="shrink-0 ml-auto" :disabled="loading || (activeSection === 'hid' && !isHidSettingsValid)" @click="saveConfig">
|
||||
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user