diff --git a/Cargo.lock b/Cargo.lock index 321ce58..eedda2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -870,6 +870,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -879,6 +894,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1272,11 +1296,14 @@ version = "0.5.0" dependencies = [ "anyhow", "bytemuck", + "crc", + "crossbeam-channel", "eframe", "egui_extras", "env_logger", "glam", "image", + "log", "serialport", ] diff --git a/Cargo.toml b/Cargo.toml index f080f24..54fddd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,4 +11,7 @@ glam = "0.32.1" image = { version = "0.25.10", features = ["png", "jpeg"] } anyhow = "1.0.102" serialport = "4.9.0" -egui_extras = { version = "0.34.2", features = ["image"] } \ No newline at end of file +egui_extras = { version = "0.34.2", features = ["image"] } +crossbeam-channel = "0.5.15" +crc = "3.4.0" +log = "0.4.29" diff --git a/eskin-finger-sdk b/eskin-finger-sdk index 7053750..aa1b312 160000 --- a/eskin-finger-sdk +++ b/eskin-finger-sdk @@ -1 +1 @@ -Subproject commit 705375085f17c79a6fbba32c18fb7630da0b67a7 +Subproject commit aa1b312290eaceba3cb383469dd0013b7f87597c diff --git a/src/app.rs b/src/app.rs index d697dea..83d3f7f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,25 +1,29 @@ -use std::time::Instant; - use eframe::{egui, egui_wgpu}; +use std::sync::Arc; +use crate::connection::ConnectionManager; use crate::theme::{ENGINEERING_DARK, apply_fonts, apply_theme}; use crate::{ matrix::{MATRIX_COLS, MATRIX_ROWS}, - render::{BackgroundRenderResources, WgpuBackgroundCallback}, + render::{BackgroundRenderResources, PRESSURE_CELL_COUNT, WgpuBackgroundCallback}, ui::{ ConfigPanelState, ConnectPanelState, FloatingPanelState, draw_config_panel, draw_connect_panel, draw_scene_panel, draw_stats_panel, }, }; +const DATA_LOG_EVERY_FRAMES: u64 = 30; + pub struct EskinDesktopApp { connect_panel: FloatingPanelState, connect_state: ConnectPanelState, + connection: Arc, + pressure_matrix: [f32; PRESSURE_CELL_COUNT], + data_log_frame: u64, scene_panel: FloatingPanelState, config_panel: FloatingPanelState, config_state: ConfigPanelState, stats_panel: FloatingPanelState, - started_at: Instant, } impl EskinDesktopApp { @@ -46,15 +50,19 @@ impl EskinDesktopApp { Self { connect_panel: FloatingPanelState::new([0.0, 0.0], [0.0, 0.0]), connect_state: ConnectPanelState::default(), + connection: Arc::new(ConnectionManager::new()), + pressure_matrix: [0.0; PRESSURE_CELL_COUNT], + data_log_frame: 0, scene_panel: FloatingPanelState::new([16.0, 48.0], [16.0, 48.0]), config_panel: FloatingPanelState::new([840.0, 48.0], [128.0, 48.0]), config_state: ConfigPanelState::default(), stats_panel: FloatingPanelState::new([16.0, 520.0], [240.0, 48.0]), - started_at: Instant::now(), } } fn draw_wgpu_background(&mut self, ui: &mut egui::Ui) { + self.update_pressure_matrix(); + let rect = ui.max_rect(); let width = rect.width().max(1.0); let height = rect.height().max(1.0); @@ -64,11 +72,26 @@ impl EskinDesktopApp { WgpuBackgroundCallback { width, height, - time: self.started_at.elapsed().as_secs_f32(), + pressure: self.pressure_matrix, }, )); } + fn update_pressure_matrix(&mut self) { + if let Some(sample) = self.connection.take_latest_sample() { + normalize_pressure_sample( + &sample.matrix, + sample.rows, + sample.cols, + &mut self.pressure_matrix, + ); + self.data_log_frame += 1; + if self.data_log_frame % DATA_LOG_EVERY_FRAMES == 0 { + log_pressure_sample(&sample.matrix, sample.rows, sample.cols); + } + } + } + fn draw_toolbar(&mut self, ui: &mut egui::Ui) { egui::Panel::top("main_menu").show_inside(ui, |ui| { ui.horizontal(|ui| { @@ -81,13 +104,63 @@ impl EskinDesktopApp { } fn draw_floating_panels(&mut self, ctx: &egui::Context) { - draw_connect_panel(ctx, &mut self.connect_panel, &mut self.connect_state); + draw_connect_panel( + ctx, + &mut self.connect_panel, + &mut self.connect_state, + &self.connection, + ); draw_scene_panel(ctx, &mut self.scene_panel); draw_config_panel(ctx, &mut self.config_panel, &mut self.config_state); draw_stats_panel(ctx, &mut self.stats_panel); } } +fn log_pressure_sample(raw: &[u32], rows: u32, cols: u32) { + let max = raw.iter().copied().max().unwrap_or(0); + let sum: u64 = raw.iter().map(|value| *value as u64).sum(); + let non_zero = raw.iter().filter(|value| **value != 0).count(); + let preview = raw + .iter() + .take(12) + .map(u32::to_string) + .collect::>() + .join(", "); + + println!( + "[pressure] {rows}x{cols} cells={} non_zero={non_zero} max={max} sum={sum} first=[{preview}]", + raw.len() + ); +} + +fn normalize_pressure_sample( + raw: &[u32], + rows: u32, + cols: u32, + normalized: &mut [f32; PRESSURE_CELL_COUNT], +) { + normalized.fill(0.0); + let max_value = raw.iter().copied().max().unwrap_or(0); + + if max_value == 0 { + return; + } + + let src_cols = cols.max(1); + let copy_rows = MATRIX_ROWS.min(rows); + let copy_cols = MATRIX_COLS.min(cols); + + for row in 0..copy_rows { + for col in 0..copy_cols { + let src_index = (row * src_cols + col) as usize; + let dst_index = (row * MATRIX_COLS + col) as usize; + if let Some(value) = raw.get(src_index) { + normalized[dst_index] = (*value as f32 / max_value as f32).clamp(0.0, 1.0); + } + } + } +} + impl eframe::App for EskinDesktopApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..cb070f2 --- /dev/null +++ b/src/connection.rs @@ -0,0 +1,197 @@ +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use crossbeam_channel::{self, Receiver, Sender, TryRecvError}; + +use crate::serial_core::serial::{run_serial_loop, SerialPortReadWrite}; + +/// Connection state visible to the UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionState { + Disconnected, + Connecting, + Connected, + Streaming, + Error, +} + +/// A pressure sample forwarded to the render layer. +pub struct PressureSample { + pub matrix: Vec, + pub rows: u32, + pub cols: u32, +} + +struct Session { + cancel_tx: Sender<()>, + handle: JoinHandle<()>, + sample_rx: Receiver>, +} + +/// Thread-safe connection manager that the UI and renderer can share. +pub struct ConnectionManager { + state: Arc>, + session: Arc>>, + latest_sample: Arc>>, +} + +impl ConnectionManager { + pub fn new() -> Self { + Self { + state: Arc::new(Mutex::new(ConnectionState::Disconnected)), + session: Arc::new(Mutex::new(None)), + latest_sample: Arc::new(Mutex::new(None)), + } + } + + pub fn state(&self) -> ConnectionState { + *self.state.lock().unwrap() + } + + pub fn set_state(&self, new_state: ConnectionState) { + *self.state.lock().unwrap() = new_state; + } + + /// Connect to the given serial port and start streaming in a background thread. + pub fn connect(&self, port_name: &str, rows: u32, cols: u32) { + self.disconnect(); + self.set_state(ConnectionState::Connecting); + + let port = port_name.to_owned(); + let state = Arc::clone(&self.state); + let session_guard = Arc::clone(&self.session); + let latest_sample = Arc::clone(&self.latest_sample); + let (cancel_tx, cancel_rx) = crossbeam_channel::bounded::<()>(1); + let (sample_tx, sample_rx) = crossbeam_channel::bounded::>(16); + + let handle = thread::spawn(move || { + let result = run_device_loop( + &port, + rows, + cols, + &state, + &cancel_rx, + &sample_tx, + &latest_sample, + ); + if let Err(e) = result { + eprintln!("[connection] device loop error: {e}"); + *state.lock().unwrap() = ConnectionState::Error; + } + }); + + *session_guard.lock().unwrap() = Some(Session { + cancel_tx, + handle, + sample_rx, + }); + } + + /// Disconnect from the device, stopping the background thread. + pub fn disconnect(&self) { + let session = { + let mut guard = self.session.lock().unwrap(); + guard.take() + }; + + if let Some(session) = session { + let _ = session.cancel_tx.send(()); + let _ = session.handle.join(); + } + + self.set_state(ConnectionState::Disconnected); + *self.latest_sample.lock().unwrap() = None; + } + + /// Drain pending samples (non-blocking) and return the last one. + pub fn take_latest_sample(&self) -> Option { + let session = self.session.lock().unwrap(); + if let Some(ref session) = *session { + let mut last = None; + loop { + match session.sample_rx.try_recv() { + Ok(vals) => { + let rows = 12u32; + let cols = 7u32; + let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect(); + last = Some(PressureSample { + matrix, + rows, + cols, + }); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => break, + } + } + if last.is_some() { + return last; + } + } + None + } +} + +impl Default for ConnectionManager { + fn default() -> Self { + Self::new() + } +} + +/// The blocking device loop that runs on a background thread. +fn run_device_loop( + port_name: &str, + rows: u32, + cols: u32, + state: &Arc>, + cancel_rx: &Receiver<()>, + sample_tx: &Sender>, + latest_sample: &Arc>>, +) -> Result<(), Box> { + let port = serialport::new(port_name, 921_600) + .timeout(Duration::from_millis(100)) + .open()?; + + *state.lock().unwrap() = ConnectionState::Connected; + + let mut rw = SerialPortReadWrite::new(port); + *state.lock().unwrap() = ConnectionState::Streaming; + + // We need to also forward samples to latest_sample + let (inner_tx, inner_rx) = crossbeam_channel::bounded::>(16); + let latest = Arc::clone(latest_sample); + let outer_tx = sample_tx.clone(); + + // Bridge thread: reads from inner channel, forwards to both sample_tx and latest_sample + let bridge_cancel = cancel_rx.clone(); + let bridge_handle = thread::spawn(move || { + loop { + if bridge_cancel.try_recv().is_ok() { + break; + } + match inner_rx.try_recv() { + Ok(vals) => { + // Store latest + let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect(); + *latest.lock().unwrap() = Some(PressureSample { + matrix, + rows, + cols, + }); + // Forward + let _ = outer_tx.try_send(vals); + } + Err(TryRecvError::Empty) => { + std::thread::sleep(Duration::from_millis(1)); + } + Err(TryRecvError::Disconnected) => break, + } + } + }); + + run_serial_loop(&mut rw, rows as usize, cols as usize, cancel_rx, &inner_tx); + + let _ = bridge_handle.join(); + Ok(()) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 7257c48..2bc0274 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ mod app; +mod connection; mod matrix; mod render; +mod serial_core; mod theme; mod ui; mod utils; diff --git a/src/matrix.rs b/src/matrix.rs index 91fce8a..5f88cdf 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -84,9 +84,9 @@ pub fn glyph_world_position( rows: u32, cols: u32, layout: &MatrixLayout, - time: f32, + pressure: f32, ) -> ([f32; 4], f32) { - let normalized = demo_pressure(row, col, time); + let normalized = pressure.clamp(0.0, 1.0); let height = BASE_HEIGHT + normalized.powf(0.9) * HEIGHT_SCALE; let x = (col as f32 - cols as f32 / 2.0 + 0.5) * layout.cell_spacing; let z = (row as f32 - rows as f32 / 2.0 + 0.5) * layout.cell_spacing; @@ -102,14 +102,6 @@ pub fn glyph_world_position( ) } -fn demo_pressure(row: u32, col: u32, time: f32) -> f32 { - let seed = ((row * 17 + col * 31) as f32).sin() * 43_758.547; - let phase = seed.fract() * std::f32::consts::TAU; - let slow = 0.5 + 0.5 * (time * 1.35 + phase).sin(); - let row_wave = 0.5 + 0.5 * (time * 0.82 + row as f32 * 0.54 + col as f32 * 0.19).sin(); - (slow * row_wave).powf(0.72).clamp(0.0, 1.0) -} - fn fit_camera_distance(board_width: f32, board_depth: f32, board_padding: f32, aspect: f32) -> f32 { let padded_width = board_width + board_padding * 2.0; let padded_depth = board_depth + board_padding * 2.0; diff --git a/src/render.rs b/src/render.rs index a0ddd11..657adf5 100644 --- a/src/render.rs +++ b/src/render.rs @@ -6,10 +6,13 @@ use eframe::{ use crate::matrix::{MatrixLayout, build_view_projection, glyph_world_position}; +pub const PRESSURE_CELL_COUNT: usize = + (crate::matrix::MATRIX_ROWS * crate::matrix::MATRIX_COLS) as usize; + pub struct WgpuBackgroundCallback { pub width: f32, pub height: f32, - pub time: f32, + pub pressure: [f32; PRESSURE_CELL_COUNT], } impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback { @@ -22,7 +25,7 @@ impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback { resources: &mut egui_wgpu::CallbackResources, ) -> Vec { let resources: &mut BackgroundRenderResources = resources.get_mut().unwrap(); - resources.prepare(queue, self.width, self.height, self.time); + resources.prepare(queue, self.width, self.height, &self.pressure); Vec::new() } @@ -123,7 +126,7 @@ impl BackgroundRenderResources { usage: wgpu::BufferUsages::VERTEX, }); - let glyph_instances = build_glyph_instances(rows, cols, &layout, 0.0); + let glyph_instances = build_glyph_instances(rows, cols, &layout, &[]); let glyph_instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("Pressure Glyph Instance Buffer"), contents: bytemuck::cast_slice(&glyph_instances), @@ -145,7 +148,7 @@ impl BackgroundRenderResources { } } - fn prepare(&mut self, queue: &wgpu::Queue, width: f32, height: f32, time: f32) { + fn prepare(&mut self, queue: &wgpu::Queue, width: f32, height: f32, pressure: &[f32]) { let aspect = width / height.max(1.0); self.uniform = MatrixUniform::new(width, height, build_view_projection(aspect, &self.layout)); @@ -155,7 +158,13 @@ impl BackgroundRenderResources { bytemuck::cast_slice(&[self.uniform]), ); - self.glyph_instances = build_glyph_instances(self.rows, self.cols, &self.layout, time); + update_glyph_instances( + &mut self.glyph_instances, + self.rows, + self.cols, + &self.layout, + pressure, + ); queue.write_buffer( &self.glyph_instance_buffer, 0, @@ -246,14 +255,16 @@ fn build_glyph_instances( rows: u32, cols: u32, layout: &MatrixLayout, - time: f32, + pressure: &[f32], ) -> Vec { let mut instances = Vec::with_capacity((rows * cols) as usize); for row in 0..rows { for col in 0..cols { + let index = (row * cols + col) as usize; + let normalized = pressure.get(index).copied().unwrap_or(0.0); let (world_position, normalized) = - glyph_world_position(row, col, rows, cols, layout, time); + glyph_world_position(row, col, rows, cols, layout, normalized); instances.push(GlyphInstance { world_position, style: [normalized, 0.0, 0.0, 0.0], @@ -264,6 +275,27 @@ fn build_glyph_instances( instances } +fn update_glyph_instances( + instances: &mut [GlyphInstance], + rows: u32, + cols: u32, + layout: &MatrixLayout, + pressure: &[f32], +) { + for row in 0..rows { + for col in 0..cols { + let index = (row * cols + col) as usize; + let normalized = pressure.get(index).copied().unwrap_or(0.0); + let (world_position, normalized) = + glyph_world_position(row, col, rows, cols, layout, normalized); + if let Some(instance) = instances.get_mut(index) { + instance.world_position = world_position; + instance.style = [normalized, 0.0, 0.0, 0.0]; + } + } + } +} + #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] struct MatrixUniform { diff --git a/src/serial_core/codec.rs b/src/serial_core/codec.rs new file mode 100644 index 0000000..d47430a --- /dev/null +++ b/src/serial_core/codec.rs @@ -0,0 +1,7 @@ +use crate::serial_core::error::CodecError; +use std::time::Instant; + +pub trait Codec { + fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result, CodecError>; + fn encode(&self, frame: &F) -> Result, CodecError>; +} \ No newline at end of file diff --git a/src/serial_core/codecs/mod.rs b/src/serial_core/codecs/mod.rs new file mode 100644 index 0000000..9538446 --- /dev/null +++ b/src/serial_core/codecs/mod.rs @@ -0,0 +1 @@ +pub mod tactile_a; \ No newline at end of file diff --git a/src/serial_core/codecs/tactile_a.rs b/src/serial_core/codecs/tactile_a.rs new file mode 100644 index 0000000..b13d171 --- /dev/null +++ b/src/serial_core/codecs/tactile_a.rs @@ -0,0 +1,188 @@ +use crate::serial_core::codec::Codec; +use crate::serial_core::error::CodecError; +use crate::serial_core::frame::{ + TactileAFrame, TactileAFrameMetaData, TactileAFrameStatusCode, TactileARepFrame, + TactileAReqFrame, +}; +use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis}; + +const FRAME_BUFFER_MIN_LENGTH: usize = 15; + +pub struct TactileACodec { + buffer: Vec, + expected_data_len: usize, +} + +impl TactileACodec { + pub fn new(cols: usize, rows: usize) -> TactileACodec { + Self { + buffer: Vec::new(), + expected_data_len: cols * rows * 2, + } + } + + pub fn parse_data_frame(data: &[u8]) -> Result, CodecError> { + if data.len() % 2 != 0 { + return Err(CodecError::InvalidLength); + } + + let vals: Vec = data + .chunks_exact(2) + .map(|chunk| { + let raw = u16::from_le_bytes([chunk[0], chunk[1]]) as i32; + if raw < 15 { + 0 + } else { + raw + } + }) + .collect::>(); + + Ok(vals) + } + + pub fn build_req_frame(cols: usize, rows: usize) -> TactileAFrame { + let header = [0x55, 0xAA]; + let payload_len: usize = 9; + let device_addr: u8 = 0x34; + let extend_code: u8 = 0x00; + let func_code: u8 = 0xFB; + let start_addr: u32 = 7168; + let except_data_len: usize = cols * rows * 2; + let checksum: u8 = 0; + TactileAFrame::Req(TactileAReqFrame { + meta: TactileAFrameMetaData { + header, + payload_len, + device_addr, + extend_code, + func_code, + start_addr, + except_data_len, + checksum, + }, + }) + } +} + +impl Codec for TactileACodec { + fn decode( + &mut self, + input: &[u8], + session_started_at: std::time::Instant, + ) -> Result, CodecError> { + self.buffer.extend_from_slice(input); + let mut frames: Vec = Vec::new(); + + loop { + if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH { + break; + } + + // Search for response header: [0xAA, 0x55] + let header_pos = self.buffer.windows(2).position(|w| w == [0xAA, 0x55]); + + let Some(pos) = header_pos else { + self.buffer.clear(); + break; + }; + if pos > 0 { + self.buffer.drain(0..pos); + } + + if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH { + break; + } + + let header = [self.buffer[0], self.buffer[1]]; + let payload_len = u16::from_le_bytes([self.buffer[2], self.buffer[3]]) as usize; + let device_addr = self.buffer[4]; + let extend_code = self.buffer[5]; + let func_code = self.buffer[6]; + let start_addr = u32::from_le_bytes([ + self.buffer[7], + self.buffer[8], + self.buffer[9], + self.buffer[10], + ]); + let except_data_len = u16::from_le_bytes([self.buffer[11], self.buffer[12]]) as usize; + let status = match self.buffer[13] { + 0 => TactileAFrameStatusCode::Success, + _ => TactileAFrameStatusCode::Failure, + }; + + if except_data_len != self.expected_data_len { + log::debug!( + "unexpected payload length: expected {}, got {}, buffer_len={}", + self.expected_data_len, + except_data_len, + self.buffer.len() + ); + self.buffer.drain(0..1); + continue; + } + + let frame_length = except_data_len + FRAME_BUFFER_MIN_LENGTH; + if self.buffer.len() < frame_length { + break; + } + + let need_check_data = self.buffer[0..14 + except_data_len].to_vec(); + let payload = self.buffer[14..14 + except_data_len].to_vec(); + let crc8_itu_alg = crc::Crc::::new(&crc::CRC_8_I_432_1); + let checksum = crc8_itu_alg.checksum(&need_check_data.as_slice()); + if self.buffer[frame_length - 1] != checksum { + log::debug!( + "checksum mismatch: expected {:02X}, got {:02X}, frame_len={}", + checksum, + self.buffer[frame_length - 1], + frame_length + ); + self.buffer.drain(0..1); + continue; + } + + let dts_ms = elapsed_millis(session_started_at); + let meta = TactileAFrameMetaData { + header, + payload_len, + device_addr, + extend_code, + func_code, + start_addr, + except_data_len, + checksum, + }; + frames.push(TactileAFrame::Rep(TactileARepFrame { + meta, + status, + payload, + dts_ms, + })); + + self.buffer.drain(0..frame_length); + } + + Ok(frames) + } + + fn encode(&self, frame: &TactileAFrame) -> Result, CodecError> { + match frame { + TactileAFrame::Req(f) => { + let mut req_bytes: Vec = Vec::new(); + req_bytes.extend_from_slice(f.meta.header.as_slice()); + req_bytes.extend_from_slice((f.meta.payload_len as u16).to_le_bytes().as_slice()); + req_bytes.push(f.meta.device_addr); + req_bytes.push(f.meta.extend_code); + req_bytes.push(f.meta.func_code); + req_bytes.extend_from_slice(f.meta.start_addr.to_le_bytes().as_slice()); + req_bytes + .extend_from_slice((f.meta.except_data_len as u16).to_le_bytes().as_slice()); + let checksum = calc_crc8_itu(req_bytes.as_slice()); + req_bytes.push(checksum); + Ok(req_bytes) + } + _ => Err(CodecError::InvalidFrameType), + } + } +} \ No newline at end of file diff --git a/src/serial_core/error.rs b/src/serial_core/error.rs new file mode 100644 index 0000000..ef4f2d9 --- /dev/null +++ b/src/serial_core/error.rs @@ -0,0 +1,51 @@ +use std::fmt; + +#[derive(Debug)] +pub enum SerialError { + OpenError, + CloseError, + ScanError, + InvalidConfig, + AlreadyConnected, + StateError, + NoRecordedData, +} + +impl fmt::Display for SerialError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + SerialError::OpenError => write!(f, "Opening Error"), + SerialError::CloseError => write!(f, "Closing Error"), + SerialError::ScanError => write!(f, "Scan Error"), + SerialError::InvalidConfig => write!(f, "Invalid Config"), + SerialError::AlreadyConnected => write!(f, "Already Connected"), + SerialError::StateError => write!(f, "State Error"), + SerialError::NoRecordedData => write!(f, "No Recorded Data"), + } + } +} + +impl std::error::Error for SerialError {} + +#[derive(Debug)] +pub enum CodecError { + InvalidHeader, + InvalidTail, + InvalidLength, + InvalidFrameType, + PayloadTooLarge, +} + +impl fmt::Display for CodecError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + CodecError::InvalidHeader => write!(f, "Invalid Header"), + CodecError::InvalidTail => write!(f, "Invalid Tail"), + CodecError::InvalidLength => write!(f, "Invalid Length"), + CodecError::InvalidFrameType => write!(f, "Invalid Frame Type"), + CodecError::PayloadTooLarge => write!(f, "Payload too large"), + } + } +} + +impl std::error::Error for CodecError {} \ No newline at end of file diff --git a/src/serial_core/frame.rs b/src/serial_core/frame.rs new file mode 100644 index 0000000..b979fe2 --- /dev/null +++ b/src/serial_core/frame.rs @@ -0,0 +1,36 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TactileAFrameMetaData { + pub header: [u8; 2], + pub payload_len: usize, + pub device_addr: u8, + pub extend_code: u8, + pub func_code: u8, + pub start_addr: u32, + pub except_data_len: usize, + pub checksum: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TactileAReqFrame { + pub meta: TactileAFrameMetaData, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TactileARepFrame { + pub meta: TactileAFrameMetaData, + pub status: TactileAFrameStatusCode, + pub payload: Vec, + pub dts_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TactileAFrameStatusCode { + Success, + Failure, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TactileAFrame { + Req(TactileAReqFrame), + Rep(TactileARepFrame), +} \ No newline at end of file diff --git a/src/serial_core/mod.rs b/src/serial_core/mod.rs new file mode 100644 index 0000000..7774db1 --- /dev/null +++ b/src/serial_core/mod.rs @@ -0,0 +1,6 @@ +pub mod codec; +pub mod codecs; +pub mod error; +pub mod frame; +pub mod serial; +pub mod utils; \ No newline at end of file diff --git a/src/serial_core/serial.rs b/src/serial_core/serial.rs new file mode 100644 index 0000000..19cdfbe --- /dev/null +++ b/src/serial_core/serial.rs @@ -0,0 +1,96 @@ +use crate::serial_core::codec::Codec; +use crate::serial_core::codecs::tactile_a::TactileACodec; +use crate::serial_core::frame::TactileAFrame; +use crate::serial_core::utils::elapsed_millis; +use crossbeam_channel::{Receiver, Sender, TryRecvError}; +use std::io::{Read, Write}; +use std::time::{Duration, Instant}; + +const POLL_INTERVAL_MS: u64 = 10; + +/// Runs the serial polling loop on the calling (background) thread. +/// Sends decoded pressure matrix data (Vec) to the output channel. +pub fn run_serial_loop( + port: &mut dyn ReadWrite, + rows: usize, + cols: usize, + cancel_rx: &Receiver<()>, + sample_tx: &Sender>, +) { + let session_started_at = Instant::now(); + let mut codec = TactileACodec::new(cols, rows); + let req_frame = TactileACodec::build_req_frame(cols, rows); + let mut buffer = [0u8; 1024]; + let mut poll_interval = Duration::from_millis(POLL_INTERVAL_MS); + + loop { + // Check cancel + if cancel_rx.try_recv().is_ok() { + break; + } + + // Send poll request + if let Ok(req_bytes) = codec.encode(&req_frame) { + let _ = port.write_all(&req_bytes); + } + + // Read response with poll interval + let deadline = Instant::now() + poll_interval; + loop { + if Instant::now() >= deadline { + break; + } + + match port.read(&mut buffer) { + Ok(n) if n > 0 => { + if let Ok(frames) = codec.decode(&buffer[..n], session_started_at) { + for frame in frames { + if let TactileAFrame::Rep(rep) = frame { + if let Ok(vals) = TactileACodec::parse_data_frame(&rep.payload) { + let _ = sample_tx.try_send(vals); + } + } + } + } + } + Ok(_) => { + std::thread::sleep(Duration::from_millis(1)); + } + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => { + continue; + } + Err(e) => { + eprintln!("[serial] read error: {e}"); + return; + } + } + } + } +} + +/// Trait abstracting read+write for the serial port +pub trait ReadWrite: Send { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result; + fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()>; +} + +/// Wrapper for serialport's Box +pub struct SerialPortReadWrite { + inner: Box, +} + +impl SerialPortReadWrite { + pub fn new(port: Box) -> Self { + Self { inner: port } + } +} + +impl ReadWrite for SerialPortReadWrite { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.inner.read(buf) + } + + fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> { + self.inner.write_all(buf) + } +} \ No newline at end of file diff --git a/src/serial_core/utils.rs b/src/serial_core/utils.rs new file mode 100644 index 0000000..9e5e264 --- /dev/null +++ b/src/serial_core/utils.rs @@ -0,0 +1,10 @@ +use std::time::Instant; + +pub fn calc_crc8_itu(c: &[u8]) -> u8 { + let crc8_itu_alg = crc::Crc::::new(&crc::CRC_8_I_432_1); + crc8_itu_alg.checksum(c) +} + +pub fn elapsed_millis(start_at: Instant) -> u64 { + start_at.elapsed().as_millis() as u64 +} \ No newline at end of file diff --git a/src/theme.rs b/src/theme.rs index 718dda9..db64ae7 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -84,34 +84,41 @@ pub fn apply_fonts(ctx: &egui::Context) { egui::FontData::from_static(include_bytes!("../static/Hack-Bold.ttf")).into(), ); - if let Ok(font_data) = std::fs::read(r"C:\Windows\Fonts\msyh.ttc") { - fonts.font_data.insert( - "Microsoft-YaHei".to_owned(), - egui::FontData::from_owned(font_data).into(), - ); + let has_yahei = std::fs::read(r"C:\Windows\Fonts\msyh.ttc") + .or_else(|_| std::fs::read(r"C:\Windows\Fonts\msyhbd.ttc")) + .map(|font_data| { + fonts.font_data.insert( + "Microsoft-YaHei".to_owned(), + egui::FontData::from_owned(font_data).into(), + ); + }) + .is_ok(); + + fonts + .families + .entry(egui::FontFamily::Proportional) + .or_default() + .insert(0, "Hack-Bold".to_owned()); + if has_yahei { + fonts + .families + .entry(egui::FontFamily::Proportional) + .or_default() + .push("Microsoft-YaHei".to_owned()); } - fonts - .families - .entry(egui::FontFamily::Proportional) - .or_default() - .insert(0, "Hack-Bold".to_owned()); - fonts - .families - .entry(egui::FontFamily::Proportional) - .or_default() - .push("Microsoft-YaHei".to_owned()); - fonts .families .entry(egui::FontFamily::Monospace) .or_default() .insert(0, "Hack-Bold".to_owned()); - fonts - .families - .entry(egui::FontFamily::Monospace) - .or_default() - .push("Microsoft-YaHei".to_owned()); + if has_yahei { + fonts + .families + .entry(egui::FontFamily::Monospace) + .or_default() + .push("Microsoft-YaHei".to_owned()); + } ctx.set_fonts(fonts); } diff --git a/src/ui.rs b/src/ui.rs index f14ec64..e2f8852 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,7 @@ use eframe::egui; use crate::{ + connection::{ConnectionManager, ConnectionState}, theme::{ENGINEERING_DARK, accent_text, dim_text, group_frame, panel_frame, tag_button}, utils::serial_enum, }; @@ -29,6 +30,7 @@ pub struct ConfigPanelState { pub struct ConnectPanelState { pub mode: SerialMode, pub port: Vec, + pub selected_port: String, pub duration: u8, pub manual: bool, pub rows: u8, @@ -87,9 +89,11 @@ impl Default for ConfigPanelState { impl Default for ConnectPanelState { fn default() -> Self { let port = serial_enum().unwrap(); + let selected_port = port.first().cloned().unwrap_or_default(); Self { mode: SerialMode::SingleModule, port, + selected_port, duration: 10, manual: false, rows: 12, @@ -119,19 +123,18 @@ pub fn draw_connect_panel( ctx: &egui::Context, panel: &mut FloatingPanelState, config: &mut ConnectPanelState, + connection: &ConnectionManager, ) { + let conn_state = connection.state(); + let is_connected = matches!( + conn_state, + ConnectionState::Connected | ConnectionState::Streaming + ); + draw_center_floating_panel(ctx, panel, "connect_center_panel", 64.0, |ui| { ui.set_min_width(320.0); ui.vertical(|ui| { - // ui.horizontal_centered(|ui| { - // mode_button(ui, &mut config.mode, SerialMode::SingleModule, "单模块"); - // ui.add_space(8.0); - // mode_button(ui, &mut config.mode, SerialMode::Manual, "全手"); - // ui.add_space(8.0); - // mode_button(ui, &mut config.mode, SerialMode::Model, "模型"); - // }); - let button_width = 96.0; let gap = 8.0; let total_width = button_width * 3.0 + gap * 2.0; @@ -161,18 +164,39 @@ pub fn draw_connect_panel( ui.colored_label(ENGINEERING_DARK.text, "串口"); egui::ComboBox::from_id_salt("connect_ports") .width(130.0) - .selected_text( - config - .port - .first() - .map(String::as_str) - .unwrap_or("无可用串口"), - ) + .selected_text(if config.selected_port.is_empty() { + "无可用串口".to_owned() + } else { + config.selected_port.clone() + }) .show_ui(ui, |ui| { for port in &config.port { - let _ = ui.selectable_label(false, port); + let label = port.clone(); + ui.selectable_value( + &mut config.selected_port, + port.clone(), + label, + ); } }); + if ui + .add( + egui::Button::new("⟳") + .fill(ENGINEERING_DARK.panel_strong) + .stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border_soft)) + .min_size(egui::vec2(24.0, 20.0)), + ) + .on_hover_text("刷新串口") + .clicked() + { + if let Ok(ports) = serial_enum() { + if !ports.contains(&config.selected_port) { + config.selected_port = + ports.first().cloned().unwrap_or_default(); + } + config.port = ports; + } + } ui.add_space(10.0); ui.label("频率"); @@ -203,6 +227,65 @@ pub fn draw_connect_panel( }); }) }); + + ui.add_space(8.0); + + // Connection status and button row + ui.horizontal(|ui| { + let status_text = match conn_state { + ConnectionState::Disconnected => "未连接", + ConnectionState::Connecting => "连接中...", + ConnectionState::Connected => "已连接", + ConnectionState::Streaming => "采集中", + ConnectionState::Error => "连接错误", + }; + let status_color = match conn_state { + ConnectionState::Disconnected => ENGINEERING_DARK.text_dim, + ConnectionState::Connecting => egui::Color32::from_rgb(200, 180, 60), + ConnectionState::Connected => egui::Color32::from_rgb(158, 184, 101), + ConnectionState::Streaming => egui::Color32::from_rgb(100, 200, 255), + ConnectionState::Error => egui::Color32::from_rgb(255, 98, 82), + }; + ui.colored_label(status_color, status_text); + + let used = 100.0; + let remaining = (ui.available_width() - used).max(0.0); + ui.add_space(remaining); + + let btn_label = if is_connected { "断开" } else { "连接" }; + let btn_fill = if is_connected { + egui::Color32::from_rgb(180, 60, 60) + } else { + ENGINEERING_DARK.accent + }; + let btn_stroke = if is_connected { + egui::Stroke::new(1.0, egui::Color32::from_rgb(220, 80, 80)) + } else { + egui::Stroke::new(1.0, ENGINEERING_DARK.accent_hot) + }; + + if ui + .add( + egui::Button::new( + egui::RichText::new(btn_label).color(egui::Color32::WHITE), + ) + .fill(btn_fill) + .stroke(btn_stroke) + .min_size(egui::vec2(96.0, 28.0)), + ) + .clicked() + { + if is_connected { + connection.disconnect(); + } else if !config.selected_port.is_empty() { + connection.connect( + &config.selected_port, + config.rows as u32, + config.cols as u32, + ); + } + } + }); }); }); }