Wire live serial data into matrix renderer

This commit is contained in:
lennlouisgeek
2026-05-20 01:23:02 +08:00
parent a7b617419d
commit d2c9fad556
18 changed files with 874 additions and 63 deletions

27
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -12,3 +12,6 @@ image = { version = "0.25.10", features = ["png", "jpeg"] }
anyhow = "1.0.102"
serialport = "4.9.0"
egui_extras = { version = "0.34.2", features = ["image"] }
crossbeam-channel = "0.5.15"
crc = "3.4.0"
log = "0.4.29"

Submodule eskin-finger-sdk updated: 705375085f...aa1b312290

View File

@@ -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<ConnectionManager>,
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::<Vec<_>>()
.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();

197
src/connection.rs Normal file
View File

@@ -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<u32>,
pub rows: u32,
pub cols: u32,
}
struct Session {
cancel_tx: Sender<()>,
handle: JoinHandle<()>,
sample_rx: Receiver<Vec<i32>>,
}
/// Thread-safe connection manager that the UI and renderer can share.
pub struct ConnectionManager {
state: Arc<Mutex<ConnectionState>>,
session: Arc<Mutex<Option<Session>>>,
latest_sample: Arc<Mutex<Option<PressureSample>>>,
}
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::<Vec<i32>>(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<PressureSample> {
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<Mutex<ConnectionState>>,
cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>,
latest_sample: &Arc<Mutex<Option<PressureSample>>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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::<Vec<i32>>(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(())
}

View File

@@ -1,6 +1,8 @@
mod app;
mod connection;
mod matrix;
mod render;
mod serial_core;
mod theme;
mod ui;
mod utils;

View File

@@ -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;

View File

@@ -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<wgpu::CommandBuffer> {
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<GlyphInstance> {
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 {

7
src/serial_core/codec.rs Normal file
View File

@@ -0,0 +1,7 @@
use crate::serial_core::error::CodecError;
use std::time::Instant;
pub trait Codec<F> {
fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result<Vec<F>, CodecError>;
fn encode(&self, frame: &F) -> Result<Vec<u8>, CodecError>;
}

View File

@@ -0,0 +1 @@
pub mod tactile_a;

View File

@@ -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<u8>,
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<Vec<i32>, CodecError> {
if data.len() % 2 != 0 {
return Err(CodecError::InvalidLength);
}
let vals: Vec<i32> = 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::<Vec<i32>>();
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<TactileAFrame> for TactileACodec {
fn decode(
&mut self,
input: &[u8],
session_started_at: std::time::Instant,
) -> Result<Vec<TactileAFrame>, CodecError> {
self.buffer.extend_from_slice(input);
let mut frames: Vec<TactileAFrame> = 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::<u8>::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<Vec<u8>, CodecError> {
match frame {
TactileAFrame::Req(f) => {
let mut req_bytes: Vec<u8> = 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),
}
}
}

51
src/serial_core/error.rs Normal file
View File

@@ -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 {}

36
src/serial_core/frame.rs Normal file
View File

@@ -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<u8>,
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),
}

6
src/serial_core/mod.rs Normal file
View File

@@ -0,0 +1,6 @@
pub mod codec;
pub mod codecs;
pub mod error;
pub mod frame;
pub mod serial;
pub mod utils;

96
src/serial_core/serial.rs Normal file
View File

@@ -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<i32>) to the output channel.
pub fn run_serial_loop(
port: &mut dyn ReadWrite,
rows: usize,
cols: usize,
cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>,
) {
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<usize>;
fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()>;
}
/// Wrapper for serialport's Box<dyn SerialPort>
pub struct SerialPortReadWrite {
inner: Box<dyn serialport::SerialPort>,
}
impl SerialPortReadWrite {
pub fn new(port: Box<dyn serialport::SerialPort>) -> Self {
Self { inner: port }
}
}
impl ReadWrite for SerialPortReadWrite {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buf)
}
fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
self.inner.write_all(buf)
}
}

10
src/serial_core/utils.rs Normal file
View File

@@ -0,0 +1,10 @@
use std::time::Instant;
pub fn calc_crc8_itu(c: &[u8]) -> u8 {
let crc8_itu_alg = crc::Crc::<u8>::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
}

View File

@@ -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") {
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());
if has_yahei {
fonts
.families
.entry(egui::FontFamily::Monospace)
.or_default()
.push("Microsoft-YaHei".to_owned());
}
ctx.set_fonts(fonts);
}

115
src/ui.rs
View File

@@ -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<String>,
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,
);
}
}
});
});
});
}