//! CH9329 over UART — WCH *Serial Communication Protocol V1.0*. //! ```text //! ┌──────┬──────┬──────┬────────┬──────────────┬──────────┐ //! │Header│ ADDR │ CMD │ LEN │ DATA │ SUM │ //! ├──────┼──────┼──────┼────────┼──────────────┼──────────┤ //! │57 AB │ 00 │ xx │ N │ N bytes │Checksum │ //! └──────┴──────┴──────┴────────┴──────────────┴──────────┘ //! ``` //! Sum of all octets modulo 256 (including header). use async_trait::async_trait; use parking_lot::{Mutex, RwLock}; 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 tracing::{info, trace}; use super::backend::{HidBackend, HidBackendRuntimeSnapshot}; use super::ch9329_proto::{ build_packet, cmd, expected_response_cmd, try_extract_response, ChipInfo, LedStatus, Response, DEFAULT_ADDR, DEFAULT_BAUD_RATE, MAX_PACKET_SIZE, }; use super::types::{KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType}; use crate::error::{AppError, Result}; use crate::events::LedState; const RESPONSE_TIMEOUT_MS: u64 = 500; const CH9329_MOUSE_RESOLUTION: u32 = 4096; const PROBE_INTERVAL_MS: u64 = 100; const RECONNECT_DELAY_MS: u64 = 2000; const INIT_WAIT_MS: u64 = 3000; struct Ch9329RuntimeState { initialized: AtomicBool, online: AtomicBool, last_error: RwLock>, notify_tx: watch::Sender<()>, } impl Ch9329RuntimeState { fn new() -> Self { let (notify_tx, _notify_rx) = watch::channel(()); Self { initialized: AtomicBool::new(false), online: AtomicBool::new(false), last_error: RwLock::new(None), notify_tx, } } fn subscribe(&self) -> watch::Receiver<()> { self.notify_tx.subscribe() } fn notify(&self) { let _ = self.notify_tx.send(()); } fn clear_error(&self) { let mut guard = self.last_error.write(); if guard.is_some() { *guard = None; self.notify(); } } fn set_online(&self) { let was_online = self.online.swap(true, Ordering::Relaxed); let mut error = self.last_error.write(); let cleared_error = error.take().is_some(); drop(error); if !was_online || cleared_error { self.notify(); } } fn set_error(&self, reason: impl Into, error_code: impl Into) { let reason = reason.into(); let error_code = error_code.into(); let was_online = self.online.swap(false, Ordering::Relaxed); let mut error = self.last_error.write(); let changed = error.as_ref() != Some(&(reason.clone(), error_code.clone())); *error = Some((reason, error_code)); drop(error); if was_online || changed { self.notify(); } } fn set_initialized(&self, initialized: bool) { if self.initialized.swap(initialized, Ordering::Relaxed) != initialized { self.notify(); } } fn set_offline(&self) { if self.online.swap(false, Ordering::Relaxed) { self.notify(); } } } enum WorkerCommand { Packet { cmd: u8, data: Vec }, ResetState, Shutdown, } pub struct Ch9329Backend { port_path: String, baud_rate: u32, worker_tx: Mutex>>, worker_handle: Mutex>>, keyboard_state: Mutex, mouse_buttons: AtomicU8, screen_resolution: RwLock<(u32, u32)>, chip_info: Arc>>, led_status: Arc>, address: u8, last_abs_x: AtomicU16, last_abs_y: AtomicU16, relative_mouse_active: AtomicBool, runtime: Arc, } impl Ch9329Backend { pub fn new(port_path: &str) -> Result { Self::with_baud_rate(port_path, DEFAULT_BAUD_RATE) } pub fn with_baud_rate(port_path: &str, baud_rate: u32) -> Result { Ok(Self { port_path: port_path.to_string(), baud_rate, worker_tx: Mutex::new(None), worker_handle: Mutex::new(None), keyboard_state: Mutex::new(KeyboardReport::default()), mouse_buttons: AtomicU8::new(0), screen_resolution: RwLock::new((1920, 1080)), chip_info: Arc::new(RwLock::new(None)), led_status: Arc::new(RwLock::new(LedStatus::default())), address: DEFAULT_ADDR, last_abs_x: AtomicU16::new(0), last_abs_y: AtomicU16::new(0), relative_mouse_active: AtomicBool::new(false), runtime: Arc::new(Ch9329RuntimeState::new()), }) } fn record_error(&self, reason: impl Into, error_code: impl Into) { self.runtime.set_error(reason, error_code); } pub fn check_port_exists(&self) -> bool { #[cfg(windows)] { return crate::utils::list_serial_ports() .iter() .any(|port| port.eq_ignore_ascii_case(&self.port_path)); } #[cfg(not(windows))] std::path::Path::new(&self.port_path).exists() } fn serial_error_to_hid_error(e: serialport::Error, operation: &str) -> AppError { let error_code = match e.kind() { serialport::ErrorKind::NoDevice => "port_not_found", serialport::ErrorKind::InvalidInput => "invalid_config", serialport::ErrorKind::Io(_) => "io_error", _ => "serial_error", }; AppError::HidError { backend: "ch9329".to_string(), reason: format!("{}: {}", operation, e), error_code: error_code.to_string(), } } fn backend_error(reason: impl Into, error_code: impl Into) -> AppError { AppError::HidError { backend: "ch9329".to_string(), reason: reason.into(), error_code: error_code.into(), } } #[inline] fn open_port(port_path: &str, baud_rate: u32) -> Result> { #[cfg(not(windows))] if !std::path::Path::new(port_path).exists() { return Err(Self::backend_error( format!("Serial port {} not found", port_path), "port_not_found", )); } serialport::new(port_path, baud_rate) .timeout(Duration::from_millis(RESPONSE_TIMEOUT_MS)) .open() .map_err(|e| Self::serial_error_to_hid_error(e, "Failed to open serial port")) } fn write_packet( port: &mut dyn serialport::SerialPort, address: u8, cmd: u8, data: &[u8], ) -> Result<()> { let packet = build_packet(address, cmd, data); port.write_all(&packet).map_err(|e| { Self::backend_error(format!("Failed to write to CH9329: {}", e), "write_failed") })?; Ok(()) } fn xfer_packet( port: &mut dyn serialport::SerialPort, address: u8, cmd: u8, data: &[u8], ) -> Result { Self::write_packet(port, address, cmd, data)?; let mut pending = Vec::with_capacity(128); let deadline = Instant::now() + Duration::from_millis(RESPONSE_TIMEOUT_MS); let expected_ok = expected_response_cmd(cmd, false); let expected_err = expected_response_cmd(cmd, true); loop { let mut chunk = [0u8; 128]; match port.read(&mut chunk) { Ok(n) if n > 0 => { pending.extend_from_slice(&chunk[..n]); while let Some((response, consumed)) = try_extract_response(&pending) { pending.drain(..consumed); if response.cmd == expected_ok || response.cmd == expected_err { return Ok(response); } trace!( "CH9329 ignored out-of-order response: expected 0x{:02X}/0x{:02X}, got 0x{:02X}", expected_ok, expected_err, response.cmd ); } if pending.len() > MAX_PACKET_SIZE * 4 { let keep = MAX_PACKET_SIZE; pending.drain(..pending.len().saturating_sub(keep)); } } Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {} Err(e) => { return Err(Self::backend_error( format!("Failed to read from CH9329: {}", e), "read_failed", )); } } if Instant::now() >= deadline { return Err(Self::backend_error( format!("No matching response from CH9329 for cmd 0x{:02X}", cmd), "no_response", )); } thread::sleep(Duration::from_millis(1)); } } fn try_best_effort_reset(port: &mut dyn serialport::SerialPort, address: u8) { if let Err(err) = Self::write_packet(port, address, cmd::RESET, &[]) { trace!("CH9329 best-effort reset failed: {}", err); } } fn query_chip_info_on_port( port: &mut dyn serialport::SerialPort, address: u8, ) -> Result { let response = Self::xfer_packet(port, address, cmd::GET_INFO, &[])?; if response.is_error { let reason = response .error_code .map(|e| format!("CH9329 error response: {}", e)) .unwrap_or_else(|| "CH9329 returned error response".to_string()); return Err(Self::backend_error(reason, "protocol_error")); } ChipInfo::from_response(&response.data) .ok_or_else(|| Self::backend_error("Failed to parse chip info", "invalid_response")) } fn update_chip_info_cache( chip_info: &Arc>>, led_status: &Arc>, info: ChipInfo, ) -> bool { let next_led_status = LedStatus { num_lock: info.num_lock, caps_lock: info.caps_lock, scroll_lock: info.scroll_lock, }; *chip_info.write() = Some(info); let mut led_guard = led_status.write(); let changed = *led_guard != next_led_status; *led_guard = next_led_status; changed } fn enqueue_command(&self, command: WorkerCommand) -> Result<()> { let guard = self.worker_tx.lock(); let Some(sender) = guard.as_ref() else { self.record_error("CH9329 worker is not running", "worker_stopped"); return Err(Self::backend_error( "CH9329 worker is not running", "worker_stopped", )); }; sender.send(command).map_err(|_| { self.record_error("CH9329 worker stopped", "worker_stopped"); Self::backend_error("CH9329 worker stopped", "worker_stopped") }) } fn send_packet(&self, cmd: u8, data: &[u8]) -> Result<()> { self.enqueue_command(WorkerCommand::Packet { cmd, data: data.to_vec(), }) } fn worker_reconnect_loop( rx: &mpsc::Receiver, port_path: &str, baud_rate: u32, address: u8, chip_info: &Arc>>, led_status: &Arc>, runtime: &Arc, ) -> Option> { loop { match rx.recv_timeout(Duration::from_millis(RECONNECT_DELAY_MS)) { Ok(WorkerCommand::Shutdown) => return None, Ok(_) => continue, Err(mpsc::RecvTimeoutError::Disconnected) => return None, Err(mpsc::RecvTimeoutError::Timeout) => {} } match Self::open_port(port_path, baud_rate).and_then(|mut port| { let info = Self::query_chip_info_on_port(port.as_mut(), address)?; Ok((port, info)) }) { Ok((port, info)) => { info!( "CH9329 reconnected: {}, USB: {}", info.version, if info.usb_connected { "connected" } else { "disconnected" } ); if Self::update_chip_info_cache(chip_info, led_status, info) { runtime.notify(); } runtime.set_online(); return Some(port); } Err(err) => { if let AppError::HidError { reason, error_code, .. } = err { runtime.set_error(reason, error_code); } } } } } fn send_keyboard_report(&self, report: &KeyboardReport) -> Result<()> { let data = report.to_bytes(); self.send_packet(cmd::SEND_KB_GENERAL_DATA, &data) } pub fn send_media_key(&self, data: &[u8]) -> Result<()> { if data.len() < 2 || data.len() > 4 { return Err(AppError::Internal( "Invalid media key data length".to_string(), )); } self.send_packet(cmd::SEND_KB_MEDIA_DATA, data) } pub fn release_media_keys(&self) -> Result<()> { self.send_media_key(&[0x02, 0x00, 0x00, 0x00]) } fn send_mouse_relative(&self, buttons: u8, dx: i8, dy: i8, wheel: i8) -> Result<()> { let data = [0x01, buttons, dx as u8, dy as u8, wheel as u8]; self.send_packet(cmd::SEND_MS_REL_DATA, &data) } fn send_mouse_absolute(&self, buttons: u8, x: u16, y: u16, wheel: i8) -> Result<()> { let data = [ 0x02, buttons, (x & 0xFF) as u8, (x >> 8) as u8, (y & 0xFF) as u8, (y >> 8) as u8, wheel as u8, ]; self.send_packet(cmd::SEND_MS_ABS_DATA, &data)?; trace!("CH9329 mouse: buttons=0x{:02X} pos=({},{})", buttons, x, y); Ok(()) } fn worker_loop( port_path: String, baud_rate: u32, address: u8, rx: mpsc::Receiver, chip_info: Arc>>, led_status: Arc>, runtime: Arc, init_tx: mpsc::Sender>, ) { runtime.set_initialized(true); let mut port = match Self::open_port(&port_path, baud_rate).and_then(|mut port| { let info = Self::query_chip_info_on_port(port.as_mut(), address)?; Ok((port, info)) }) { Ok((port, info)) => { info!( "CH9329 serial port opened: {} @ {} baud", port_path, baud_rate ); if Self::update_chip_info_cache(&chip_info, &led_status, info.clone()) { runtime.notify(); } runtime.set_online(); let _ = init_tx.send(Ok(info)); port } Err(err) => { if let AppError::HidError { reason, error_code, .. } = &err { runtime.set_error(reason.clone(), error_code.clone()); } let _ = init_tx.send(Err(err)); runtime.set_initialized(false); return; } }; loop { match rx.recv_timeout(Duration::from_millis(PROBE_INTERVAL_MS)) { Ok(WorkerCommand::Packet { cmd, data }) => { if let Err(err) = Self::xfer_packet(port.as_mut(), address, cmd, &data) { if let AppError::HidError { reason, error_code, .. } = err { runtime.set_error(reason, error_code); } Self::try_best_effort_reset(port.as_mut(), address); let Some(new_port) = Self::worker_reconnect_loop( &rx, &port_path, baud_rate, address, &chip_info, &led_status, &runtime, ) else { break; }; port = new_port; } else { runtime.set_online(); } } Ok(WorkerCommand::ResetState) => { let reset_sequence = [ (cmd::SEND_KB_GENERAL_DATA, vec![0; 8]), (cmd::SEND_MS_ABS_DATA, vec![0x02, 0, 0, 0, 0, 0, 0]), (cmd::SEND_KB_MEDIA_DATA, vec![0x02, 0x00, 0x00, 0x00]), ]; let mut reset_failed = false; for (cmd, data) in reset_sequence { if let Err(err) = Self::xfer_packet(port.as_mut(), address, cmd, &data) { if let AppError::HidError { reason, error_code, .. } = err { runtime.set_error(reason, error_code); } reset_failed = true; Self::try_best_effort_reset(port.as_mut(), address); break; } } if reset_failed { let Some(new_port) = Self::worker_reconnect_loop( &rx, &port_path, baud_rate, address, &chip_info, &led_status, &runtime, ) else { break; }; port = new_port; } else { runtime.set_online(); } } Ok(WorkerCommand::Shutdown) => break, Err(mpsc::RecvTimeoutError::Timeout) => { match Self::query_chip_info_on_port(port.as_mut(), address) { Ok(info) => { if Self::update_chip_info_cache(&chip_info, &led_status, info) { runtime.notify(); } runtime.set_online(); } Err(err) => { if let AppError::HidError { reason, error_code, .. } = err { runtime.set_error(reason, error_code); } Self::try_best_effort_reset(port.as_mut(), address); let Some(new_port) = Self::worker_reconnect_loop( &rx, &port_path, baud_rate, address, &chip_info, &led_status, &runtime, ) else { break; }; port = new_port; } } } Err(mpsc::RecvTimeoutError::Disconnected) => break, } } runtime.set_offline(); runtime.set_initialized(false); } } #[async_trait] impl HidBackend for Ch9329Backend { async fn init(&self) -> Result<()> { if self.worker_handle.lock().is_some() { return Ok(()); } let (tx, rx) = mpsc::channel(); let (init_tx, init_rx) = mpsc::channel(); let port_path = self.port_path.clone(); let baud_rate = self.baud_rate; let address = self.address; let chip_info = self.chip_info.clone(); let led_status = self.led_status.clone(); let runtime = self.runtime.clone(); let handle = thread::Builder::new() .name("ch9329-worker".to_string()) .spawn(move || { Self::worker_loop( port_path, baud_rate, address, rx, chip_info, led_status, runtime, init_tx, ); }) .map_err(|e| AppError::Internal(format!("Failed to spawn CH9329 worker: {}", e)))?; match init_rx.recv_timeout(Duration::from_millis(INIT_WAIT_MS)) { Ok(Ok(info)) => { info!( "CH9329 chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}", info.version, if info.usb_connected { "connected" } else { "disconnected" }, info.num_lock, info.caps_lock, info.scroll_lock ); *self.worker_tx.lock() = Some(tx); *self.worker_handle.lock() = Some(handle); self.runtime.set_online(); Ok(()) } Ok(Err(err)) => { let _ = handle.join(); self.record_error( format!( "CH9329 not responding on {} @ {} baud: {}", self.port_path, self.baud_rate, err ), "init_failed", ); Err(AppError::Internal(format!( "CH9329 not responding on {} @ {} baud: {}", self.port_path, self.baud_rate, err ))) } Err(_) => { let _ = tx.send(WorkerCommand::Shutdown); let _ = handle.join(); self.record_error("Timed out waiting for CH9329 worker init", "init_timeout"); Err(AppError::Internal( "Timed out waiting for CH9329 initialization".to_string(), )) } } } async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> { let usb_key = event.key.to_hid_usage(); if event.key.is_modifier() { let mut state = self.keyboard_state.lock(); if let Some(bit) = event.key.modifier_bit() { match event.event_type { KeyEventType::Down => state.modifiers |= bit, KeyEventType::Up => state.modifiers &= !bit, } } let report = state.clone(); drop(state); self.send_keyboard_report(&report)?; } else { let mut state = self.keyboard_state.lock(); state.modifiers = event.modifiers.to_hid_byte(); match event.event_type { KeyEventType::Down => { state.add_key(usb_key); } KeyEventType::Up => { state.remove_key(usb_key); } } let report = state.clone(); drop(state); self.send_keyboard_report(&report)?; } Ok(()) } async fn send_mouse(&self, event: MouseEvent) -> Result<()> { let buttons = self.mouse_buttons.load(Ordering::Relaxed); match event.event_type { MouseEventType::Move => { self.relative_mouse_active.store(true, Ordering::Relaxed); let dx = event.x.clamp(-127, 127) as i8; let dy = event.y.clamp(-127, 127) as i8; self.send_mouse_relative(buttons, dx, dy, 0)?; } MouseEventType::MoveAbs => { self.relative_mouse_active.store(false, Ordering::Relaxed); let x = ((event.x.clamp(0, 32767) as u32) * CH9329_MOUSE_RESOLUTION / 32768) as u16; 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)?; } 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) { self.send_mouse_relative(new_buttons, 0, 0, 0)?; } else { let x = self.last_abs_x.load(Ordering::Relaxed); let y = self.last_abs_y.load(Ordering::Relaxed); self.send_mouse_absolute(new_buttons, x, y, 0)?; } } } MouseEventType::Up => { if let Some(button) = event.button { 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) { self.send_mouse_relative(new_buttons, 0, 0, 0)?; } else { let x = self.last_abs_x.load(Ordering::Relaxed); let y = self.last_abs_y.load(Ordering::Relaxed); self.send_mouse_absolute(new_buttons, x, y, 0)?; } } } MouseEventType::Scroll => { if self.relative_mouse_active.load(Ordering::Relaxed) { self.send_mouse_relative(buttons, 0, 0, event.scroll)?; } else { let x = self.last_abs_x.load(Ordering::Relaxed); let y = self.last_abs_y.load(Ordering::Relaxed); self.send_mouse_absolute(buttons, x, y, event.scroll)?; } } } Ok(()) } async fn reset(&self) -> Result<()> { { let mut state = self.keyboard_state.lock(); state.clear(); let report = state.clone(); drop(state); self.send_keyboard_report(&report)?; } self.mouse_buttons.store(0, Ordering::Relaxed); 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_absolute(0, 0, 0, 0)?; let _ = self.release_media_keys(); info!("CH9329 HID state reset"); Ok(()) } async fn shutdown(&self) -> Result<()> { let _ = self.enqueue_command(WorkerCommand::ResetState); let sender = self.worker_tx.lock().take(); if let Some(sender) = sender { let _ = sender.send(WorkerCommand::Shutdown); } if let Some(handle) = self.worker_handle.lock().take() { let _ = handle.join(); } self.runtime.set_offline(); self.runtime.set_initialized(false); self.runtime.clear_error(); info!("CH9329 backend shutdown"); Ok(()) } fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot { let initialized = self.runtime.initialized.load(Ordering::Relaxed); let mut online = initialized && self.runtime.online.load(Ordering::Relaxed); let mut error = self.runtime.last_error.read().clone(); #[cfg(windows)] let port_still_present = crate::utils::list_serial_ports() .iter() .any(|port| port.eq_ignore_ascii_case(&self.port_path)); #[cfg(not(windows))] let port_still_present = self.check_port_exists(); if initialized && !port_still_present { online = false; error = Some(( format!("Serial port {} not found", self.port_path), "port_not_found".to_string(), )); } HidBackendRuntimeSnapshot { initialized, online, supports_absolute_mouse: true, keyboard_leds_enabled: true, led_state: { let led = *self.led_status.read(); LedState { num_lock: led.num_lock, caps_lock: led.caps_lock, scroll_lock: led.scroll_lock, compose: false, kana: false, } }, screen_resolution: Some(*self.screen_resolution.read()), device: Some(self.port_path.clone()), error: error.as_ref().map(|(reason, _)| reason.clone()), error_code: error.as_ref().map(|(_, code)| code.clone()), } } fn subscribe_runtime(&self) -> watch::Receiver<()> { self.runtime.subscribe() } fn set_screen_resolution(&self, width: u32, height: u32) { *self.screen_resolution.write() = (width, height); self.runtime.notify(); } } #[cfg(test)] mod tests { use super::*; use super::ch9329_proto::{build_packet, calculate_checksum}; #[test] fn test_packet_building() { let packet = build_packet(DEFAULT_ADDR, cmd::GET_INFO, &[]); assert_eq!(packet, vec![0x57, 0xAB, 0x00, 0x01, 0x00, 0x03]); let data = [0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]; // 'A' key let packet = build_packet(DEFAULT_ADDR, cmd::SEND_KB_GENERAL_DATA, &data); assert_eq!(packet[0], 0x57); // Header assert_eq!(packet[1], 0xAB); // Header assert_eq!(packet[2], 0x00); // Address assert_eq!(packet[3], cmd::SEND_KB_GENERAL_DATA); // Command assert_eq!(packet[4], 8); // Length (8 data bytes) assert_eq!(&packet[5..13], &data); // Data let expected_checksum: u8 = packet[..13] .iter() .fold(0u8, |acc: u8, &x| acc.wrapping_add(x)); assert_eq!(packet[13], expected_checksum); } #[test] fn test_relative_mouse_packet() { let data = [0x01, 0x00, 50u8, 0x00, 0x00]; let packet = build_packet(DEFAULT_ADDR, cmd::SEND_MS_REL_DATA, &data); assert_eq!(packet[0], 0x57); assert_eq!(packet[1], 0xAB); assert_eq!(packet[2], 0x00); // Address assert_eq!(packet[3], 0x05); // CMD_SEND_MS_REL_DATA assert_eq!(packet[4], 5); // Length = 5 assert_eq!(packet[5], 0x01); // Mode marker assert_eq!(packet[6], 0x00); // Buttons assert_eq!(packet[7], 50); // X delta } #[test] fn test_checksum_calculation() { let packet = [0x57u8, 0xAB, 0x00, 0x01, 0x00]; let checksum = calculate_checksum(&packet); assert_eq!(checksum, 0x03); let packet = [ 0x57u8, 0xAB, 0x00, 0x02, 0x08, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, ]; let checksum = calculate_checksum(&packet); assert_eq!(checksum, 0x10); } #[test] fn test_response_parsing() { let response_bytes = [ 0x57, 0xAB, // Header 0x00, // Address 0x81, // Command (GET_INFO | 0x80 = success) 0x08, // Length 0x31, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, // Data 0xE0, // Checksum (calculated) ]; let _result = Response::parse(&response_bytes); } #[test] fn test_chip_info_parsing() { let data = [0x31, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00]; let info = ChipInfo::from_response(&data).unwrap(); assert_eq!(info.version, "V3.1"); assert_eq!(info.version_raw, 0x31); assert!(info.usb_connected); assert!(info.num_lock); assert!(info.caps_lock); assert!(!info.scroll_lock); } #[test] fn test_led_status() { let led = LedStatus::from(0x07); assert!(led.num_lock); assert!(led.caps_lock); assert!(led.scroll_lock); let led = LedStatus::from(0x00); assert!(!led.num_lock); assert!(!led.caps_lock); assert!(!led.scroll_lock); } #[test] fn test_error_codes() { assert_eq!(Ch9329Error::from(0x00), Ch9329Error::Success); assert_eq!(Ch9329Error::from(0xE1), Ch9329Error::Timeout); assert_eq!(Ch9329Error::from(0xE4), Ch9329Error::ChecksumError); } }