Wire live serial data into matrix renderer
This commit is contained in:
27
Cargo.lock
generated
27
Cargo.lock
generated
@@ -870,6 +870,21 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -879,6 +894,15 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -1272,11 +1296,14 @@ version = "0.5.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
|
"crc",
|
||||||
|
"crossbeam-channel",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui_extras",
|
"egui_extras",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"glam",
|
"glam",
|
||||||
"image",
|
"image",
|
||||||
|
"log",
|
||||||
"serialport",
|
"serialport",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ glam = "0.32.1"
|
|||||||
image = { version = "0.25.10", features = ["png", "jpeg"] }
|
image = { version = "0.25.10", features = ["png", "jpeg"] }
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
serialport = "4.9.0"
|
serialport = "4.9.0"
|
||||||
egui_extras = { version = "0.34.2", features = ["image"] }
|
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
87
src/app.rs
87
src/app.rs
@@ -1,25 +1,29 @@
|
|||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use eframe::{egui, egui_wgpu};
|
use eframe::{egui, egui_wgpu};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::connection::ConnectionManager;
|
||||||
use crate::theme::{ENGINEERING_DARK, apply_fonts, apply_theme};
|
use crate::theme::{ENGINEERING_DARK, apply_fonts, apply_theme};
|
||||||
use crate::{
|
use crate::{
|
||||||
matrix::{MATRIX_COLS, MATRIX_ROWS},
|
matrix::{MATRIX_COLS, MATRIX_ROWS},
|
||||||
render::{BackgroundRenderResources, WgpuBackgroundCallback},
|
render::{BackgroundRenderResources, PRESSURE_CELL_COUNT, WgpuBackgroundCallback},
|
||||||
ui::{
|
ui::{
|
||||||
ConfigPanelState, ConnectPanelState, FloatingPanelState, draw_config_panel,
|
ConfigPanelState, ConnectPanelState, FloatingPanelState, draw_config_panel,
|
||||||
draw_connect_panel, draw_scene_panel, draw_stats_panel,
|
draw_connect_panel, draw_scene_panel, draw_stats_panel,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DATA_LOG_EVERY_FRAMES: u64 = 30;
|
||||||
|
|
||||||
pub struct EskinDesktopApp {
|
pub struct EskinDesktopApp {
|
||||||
connect_panel: FloatingPanelState,
|
connect_panel: FloatingPanelState,
|
||||||
connect_state: ConnectPanelState,
|
connect_state: ConnectPanelState,
|
||||||
|
connection: Arc<ConnectionManager>,
|
||||||
|
pressure_matrix: [f32; PRESSURE_CELL_COUNT],
|
||||||
|
data_log_frame: u64,
|
||||||
scene_panel: FloatingPanelState,
|
scene_panel: FloatingPanelState,
|
||||||
config_panel: FloatingPanelState,
|
config_panel: FloatingPanelState,
|
||||||
config_state: ConfigPanelState,
|
config_state: ConfigPanelState,
|
||||||
stats_panel: FloatingPanelState,
|
stats_panel: FloatingPanelState,
|
||||||
started_at: Instant,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EskinDesktopApp {
|
impl EskinDesktopApp {
|
||||||
@@ -46,15 +50,19 @@ impl EskinDesktopApp {
|
|||||||
Self {
|
Self {
|
||||||
connect_panel: FloatingPanelState::new([0.0, 0.0], [0.0, 0.0]),
|
connect_panel: FloatingPanelState::new([0.0, 0.0], [0.0, 0.0]),
|
||||||
connect_state: ConnectPanelState::default(),
|
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]),
|
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_panel: FloatingPanelState::new([840.0, 48.0], [128.0, 48.0]),
|
||||||
config_state: ConfigPanelState::default(),
|
config_state: ConfigPanelState::default(),
|
||||||
stats_panel: FloatingPanelState::new([16.0, 520.0], [240.0, 48.0]),
|
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) {
|
fn draw_wgpu_background(&mut self, ui: &mut egui::Ui) {
|
||||||
|
self.update_pressure_matrix();
|
||||||
|
|
||||||
let rect = ui.max_rect();
|
let rect = ui.max_rect();
|
||||||
let width = rect.width().max(1.0);
|
let width = rect.width().max(1.0);
|
||||||
let height = rect.height().max(1.0);
|
let height = rect.height().max(1.0);
|
||||||
@@ -64,11 +72,26 @@ impl EskinDesktopApp {
|
|||||||
WgpuBackgroundCallback {
|
WgpuBackgroundCallback {
|
||||||
width,
|
width,
|
||||||
height,
|
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) {
|
fn draw_toolbar(&mut self, ui: &mut egui::Ui) {
|
||||||
egui::Panel::top("main_menu").show_inside(ui, |ui| {
|
egui::Panel::top("main_menu").show_inside(ui, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -81,13 +104,63 @@ impl EskinDesktopApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
|
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_scene_panel(ctx, &mut self.scene_panel);
|
||||||
draw_config_panel(ctx, &mut self.config_panel, &mut self.config_state);
|
draw_config_panel(ctx, &mut self.config_panel, &mut self.config_state);
|
||||||
draw_stats_panel(ctx, &mut self.stats_panel);
|
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 {
|
impl eframe::App for EskinDesktopApp {
|
||||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
|
|||||||
197
src/connection.rs
Normal file
197
src/connection.rs
Normal 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(())
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
mod app;
|
mod app;
|
||||||
|
mod connection;
|
||||||
mod matrix;
|
mod matrix;
|
||||||
mod render;
|
mod render;
|
||||||
|
mod serial_core;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|||||||
@@ -84,9 +84,9 @@ pub fn glyph_world_position(
|
|||||||
rows: u32,
|
rows: u32,
|
||||||
cols: u32,
|
cols: u32,
|
||||||
layout: &MatrixLayout,
|
layout: &MatrixLayout,
|
||||||
time: f32,
|
pressure: f32,
|
||||||
) -> ([f32; 4], 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 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 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;
|
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 {
|
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_width = board_width + board_padding * 2.0;
|
||||||
let padded_depth = board_depth + board_padding * 2.0;
|
let padded_depth = board_depth + board_padding * 2.0;
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ use eframe::{
|
|||||||
|
|
||||||
use crate::matrix::{MatrixLayout, build_view_projection, glyph_world_position};
|
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 struct WgpuBackgroundCallback {
|
||||||
pub width: f32,
|
pub width: f32,
|
||||||
pub height: f32,
|
pub height: f32,
|
||||||
pub time: f32,
|
pub pressure: [f32; PRESSURE_CELL_COUNT],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
|
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
|
||||||
@@ -22,7 +25,7 @@ impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
|
|||||||
resources: &mut egui_wgpu::CallbackResources,
|
resources: &mut egui_wgpu::CallbackResources,
|
||||||
) -> Vec<wgpu::CommandBuffer> {
|
) -> Vec<wgpu::CommandBuffer> {
|
||||||
let resources: &mut BackgroundRenderResources = resources.get_mut().unwrap();
|
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()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -123,7 +126,7 @@ impl BackgroundRenderResources {
|
|||||||
usage: wgpu::BufferUsages::VERTEX,
|
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 {
|
let glyph_instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
label: Some("Pressure Glyph Instance Buffer"),
|
label: Some("Pressure Glyph Instance Buffer"),
|
||||||
contents: bytemuck::cast_slice(&glyph_instances),
|
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);
|
let aspect = width / height.max(1.0);
|
||||||
self.uniform =
|
self.uniform =
|
||||||
MatrixUniform::new(width, height, build_view_projection(aspect, &self.layout));
|
MatrixUniform::new(width, height, build_view_projection(aspect, &self.layout));
|
||||||
@@ -155,7 +158,13 @@ impl BackgroundRenderResources {
|
|||||||
bytemuck::cast_slice(&[self.uniform]),
|
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(
|
queue.write_buffer(
|
||||||
&self.glyph_instance_buffer,
|
&self.glyph_instance_buffer,
|
||||||
0,
|
0,
|
||||||
@@ -246,14 +255,16 @@ fn build_glyph_instances(
|
|||||||
rows: u32,
|
rows: u32,
|
||||||
cols: u32,
|
cols: u32,
|
||||||
layout: &MatrixLayout,
|
layout: &MatrixLayout,
|
||||||
time: f32,
|
pressure: &[f32],
|
||||||
) -> Vec<GlyphInstance> {
|
) -> Vec<GlyphInstance> {
|
||||||
let mut instances = Vec::with_capacity((rows * cols) as usize);
|
let mut instances = Vec::with_capacity((rows * cols) as usize);
|
||||||
|
|
||||||
for row in 0..rows {
|
for row in 0..rows {
|
||||||
for col in 0..cols {
|
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) =
|
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 {
|
instances.push(GlyphInstance {
|
||||||
world_position,
|
world_position,
|
||||||
style: [normalized, 0.0, 0.0, 0.0],
|
style: [normalized, 0.0, 0.0, 0.0],
|
||||||
@@ -264,6 +275,27 @@ fn build_glyph_instances(
|
|||||||
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)]
|
#[repr(C)]
|
||||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
struct MatrixUniform {
|
struct MatrixUniform {
|
||||||
|
|||||||
7
src/serial_core/codec.rs
Normal file
7
src/serial_core/codec.rs
Normal 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>;
|
||||||
|
}
|
||||||
1
src/serial_core/codecs/mod.rs
Normal file
1
src/serial_core/codecs/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod tactile_a;
|
||||||
188
src/serial_core/codecs/tactile_a.rs
Normal file
188
src/serial_core/codecs/tactile_a.rs
Normal 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
51
src/serial_core/error.rs
Normal 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
36
src/serial_core/frame.rs
Normal 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
6
src/serial_core/mod.rs
Normal 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
96
src/serial_core/serial.rs
Normal 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
10
src/serial_core/utils.rs
Normal 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
|
||||||
|
}
|
||||||
49
src/theme.rs
49
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(),
|
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")
|
||||||
fonts.font_data.insert(
|
.or_else(|_| std::fs::read(r"C:\Windows\Fonts\msyhbd.ttc"))
|
||||||
"Microsoft-YaHei".to_owned(),
|
.map(|font_data| {
|
||||||
egui::FontData::from_owned(font_data).into(),
|
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
|
fonts
|
||||||
.families
|
.families
|
||||||
.entry(egui::FontFamily::Monospace)
|
.entry(egui::FontFamily::Monospace)
|
||||||
.or_default()
|
.or_default()
|
||||||
.insert(0, "Hack-Bold".to_owned());
|
.insert(0, "Hack-Bold".to_owned());
|
||||||
fonts
|
if has_yahei {
|
||||||
.families
|
fonts
|
||||||
.entry(egui::FontFamily::Monospace)
|
.families
|
||||||
.or_default()
|
.entry(egui::FontFamily::Monospace)
|
||||||
.push("Microsoft-YaHei".to_owned());
|
.or_default()
|
||||||
|
.push("Microsoft-YaHei".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
ctx.set_fonts(fonts);
|
ctx.set_fonts(fonts);
|
||||||
}
|
}
|
||||||
|
|||||||
115
src/ui.rs
115
src/ui.rs
@@ -1,6 +1,7 @@
|
|||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
connection::{ConnectionManager, ConnectionState},
|
||||||
theme::{ENGINEERING_DARK, accent_text, dim_text, group_frame, panel_frame, tag_button},
|
theme::{ENGINEERING_DARK, accent_text, dim_text, group_frame, panel_frame, tag_button},
|
||||||
utils::serial_enum,
|
utils::serial_enum,
|
||||||
};
|
};
|
||||||
@@ -29,6 +30,7 @@ pub struct ConfigPanelState {
|
|||||||
pub struct ConnectPanelState {
|
pub struct ConnectPanelState {
|
||||||
pub mode: SerialMode,
|
pub mode: SerialMode,
|
||||||
pub port: Vec<String>,
|
pub port: Vec<String>,
|
||||||
|
pub selected_port: String,
|
||||||
pub duration: u8,
|
pub duration: u8,
|
||||||
pub manual: bool,
|
pub manual: bool,
|
||||||
pub rows: u8,
|
pub rows: u8,
|
||||||
@@ -87,9 +89,11 @@ impl Default for ConfigPanelState {
|
|||||||
impl Default for ConnectPanelState {
|
impl Default for ConnectPanelState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let port = serial_enum().unwrap();
|
let port = serial_enum().unwrap();
|
||||||
|
let selected_port = port.first().cloned().unwrap_or_default();
|
||||||
Self {
|
Self {
|
||||||
mode: SerialMode::SingleModule,
|
mode: SerialMode::SingleModule,
|
||||||
port,
|
port,
|
||||||
|
selected_port,
|
||||||
duration: 10,
|
duration: 10,
|
||||||
manual: false,
|
manual: false,
|
||||||
rows: 12,
|
rows: 12,
|
||||||
@@ -119,19 +123,18 @@ pub fn draw_connect_panel(
|
|||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
panel: &mut FloatingPanelState,
|
panel: &mut FloatingPanelState,
|
||||||
config: &mut ConnectPanelState,
|
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| {
|
draw_center_floating_panel(ctx, panel, "connect_center_panel", 64.0, |ui| {
|
||||||
ui.set_min_width(320.0);
|
ui.set_min_width(320.0);
|
||||||
|
|
||||||
ui.vertical(|ui| {
|
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 button_width = 96.0;
|
||||||
let gap = 8.0;
|
let gap = 8.0;
|
||||||
let total_width = button_width * 3.0 + gap * 2.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, "串口");
|
ui.colored_label(ENGINEERING_DARK.text, "串口");
|
||||||
egui::ComboBox::from_id_salt("connect_ports")
|
egui::ComboBox::from_id_salt("connect_ports")
|
||||||
.width(130.0)
|
.width(130.0)
|
||||||
.selected_text(
|
.selected_text(if config.selected_port.is_empty() {
|
||||||
config
|
"无可用串口".to_owned()
|
||||||
.port
|
} else {
|
||||||
.first()
|
config.selected_port.clone()
|
||||||
.map(String::as_str)
|
})
|
||||||
.unwrap_or("无可用串口"),
|
|
||||||
)
|
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
for port in &config.port {
|
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.add_space(10.0);
|
||||||
|
|
||||||
ui.label("频率");
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user