use crate::serial_core::codecs::tactile_a::{ export_recording_csv, TactileACodec, TactileACsvImporter, TactileAHandler, }; use crate::serial_core::error::SerialError; use crate::serial_core::record::CsvImporter; use crate::serial_core::serial::{PollMode, TactileAPollRequester}; use crate::serial_core::{serial, TactileARecording}; use log::info; use serde::Serialize; use std::fs::File; use std::io::Cursor; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{async_runtime::JoinHandle, AppHandle, Manager, State}; use tokio_serial::{available_ports, SerialPortBuilderExt}; use tokio_util::sync::CancellationToken; const DEFAULT_TACTILE_COLS: usize = 7; const DEFAULT_TACTILE_ROWS: usize = 12; const DEFAULT_TACTILE_POLL_INTERVAL_MS: u64 = 10; const DEFAULT_TACTILE_REPLY_TIMEOUT_MS: u64 = 140; type SharedTactileRecording = Arc>; #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SerialConnectResponse { pub port: String, pub connected: bool, pub message: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SerialExportResponse { pub path: String, pub frame_count: usize, pub message: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SerialImportFrame { pub data: Vec, pub dts_ms: u64, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SerialImportResponse { pub file_name: String, pub frame_count: usize, pub channel_count: usize, pub frames: Vec, pub message: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SerialRecordStateResponse { pub has_data: bool, pub frame_count: usize, } struct SerialSession { port: String, cancel: CancellationToken, task: JoinHandle<()>, current_record: SharedTactileRecording, } #[derive(Default)] pub struct SerialConnectionState { session: Mutex>, last_record: Mutex>, } pub async fn shutdown_active_session( state: &SerialConnectionState, ) -> Result, SerialError> { let session = { let mut guard = state.session.lock().map_err(|_| SerialError::StateError)?; guard.take() }; let Some(SerialSession { port, cancel, task, current_record, }) = session else { return Ok(None); }; cancel.cancel(); let _ = task.await; let frame_count = current_record .lock() .map(|record| record.frames.len()) .unwrap_or(0); info!("last_record has {} frames", frame_count); if let Ok(mut last_record) = state.last_record.lock() { *last_record = Some(current_record.clone()); } Ok(Some((port, current_record))) } #[tauri::command] pub fn serial_enum() -> Result, SerialError> { let ports = available_ports() .map_err(|_| SerialError::ScanError)? .into_iter() .filter_map(|p| { let name = p.port_name; #[cfg(unix)] if !name.contains("USB") { return None; } Some(name) }) .collect(); Ok(ports) } #[tauri::command] pub async fn serial_connect( app: AppHandle, port: String, state: State<'_, SerialConnectionState>, ) -> Result { let port_name = port.trim().to_string(); if port_name.is_empty() { return Err(SerialError::InvalidConfig); } { let session = state.session.lock().map_err(|_| SerialError::StateError)?; if session.is_some() { return Err(SerialError::AlreadyConnected); } } let cancel = CancellationToken::new(); let current_record = Arc::new(Mutex::new(TactileARecording::new())); let task_record = current_record.clone(); let task_cancel = cancel.clone(); let task_app = app.clone(); let task_port_name = port_name.clone(); let port = tokio_serial::new(&port_name, 921600) .open_native_async() .map_err(|_| SerialError::OpenError)?; let session_started_at = Instant::now(); let task = tauri::async_runtime::spawn(async move { let codec = TactileACodec::new(DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS); let handler = TactileAHandler; let poll_mode = PollMode::Enabled(Box::new(TactileAPollRequester::new( Duration::from_millis(DEFAULT_TACTILE_POLL_INTERVAL_MS), DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS, Duration::from_millis(DEFAULT_TACTILE_REPLY_TIMEOUT_MS), ))); if let Err(error) = serial::run_serial_with_poll( task_app.clone(), port, codec, handler, session_started_at, task_record.clone(), task_cancel, poll_mode, ) .await { eprintln!("serial task exited with error: {error}"); } let manager = task_app.state::(); if let Ok(mut last_record) = manager.last_record.lock() { *last_record = Some(task_record); } let mut session = match manager.session.lock() { Ok(session) => session, Err(_) => return, }; { let should_clear = session .as_ref() .map(|current| current.port.as_str() == task_port_name.as_str()) .unwrap_or(false); if should_clear { session.take(); } } }); let mut session = state.session.lock().map_err(|_| SerialError::StateError)?; if session.is_some() { cancel.cancel(); task.abort(); return Err(SerialError::AlreadyConnected); } *session = Some(SerialSession { port: port_name.clone(), cancel, task, current_record, }); Ok(SerialConnectResponse { port: port_name, connected: true, message: "connected".to_string(), }) } #[tauri::command] pub async fn serial_disconnect( state: State<'_, SerialConnectionState>, ) -> Result { let Some((port, _current_record)) = shutdown_active_session(&state).await? else { return Ok(SerialConnectResponse { port: String::new(), connected: false, message: "already disconnected".to_string(), }); }; Ok(SerialConnectResponse { port, connected: false, message: "disconnected".to_string(), }) } #[tauri::command] pub fn serial_export_csv( app: AppHandle, state: State<'_, SerialConnectionState>, ) -> Result { let mut output_dir = match app.path().desktop_dir() { Ok(path) => path, Err(_) => std::env::current_dir().map_err(|_| SerialError::ExportError)?, }; let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) .unwrap_or_default(); output_dir.push(format!("joyson_export_{timestamp}.csv")); let record = resolve_record_for_export(&state)?; let frame_count = write_record_to_csv(record, &output_dir)?; let path = output_dir.display().to_string(); info!("csv exported to {path}, frame_count={frame_count}"); Ok(SerialExportResponse { path, frame_count, message: "exported".to_string(), }) } #[tauri::command] pub fn serial_has_record_data( state: State<'_, SerialConnectionState>, ) -> Result { let frame_count = snapshot_record_frame_count(&state)?; Ok(SerialRecordStateResponse { has_data: frame_count > 0, frame_count, }) } #[tauri::command] pub fn serial_export_csv_to_path( file_path: String, state: State<'_, SerialConnectionState>, ) -> Result { let output_path = resolve_export_path(file_path)?; let record = resolve_record_for_export(&state)?; let frame_count = write_record_to_csv(record, &output_path)?; let path = output_path.display().to_string(); info!("csv exported to {path}, frame_count={frame_count}"); Ok(SerialExportResponse { path, frame_count, message: "exported".to_string(), }) } #[tauri::command] pub fn serial_import_csv( file_name: String, csv_content: String, ) -> Result { let mut importer = TactileACsvImporter::new(file_name.as_str()); let packets = importer .load(Cursor::new(csv_content.into_bytes())) .map_err(|_| SerialError::ImportError)?; if packets.is_empty() { return Err(SerialError::NoRecordedData); } let channel_count = packets.first().map(|item| item.data.len()).unwrap_or(0); let frame_count = packets.len(); let frames = packets .into_iter() .map(|packet| SerialImportFrame { data: packet.data, dts_ms: packet.dts_ms, }) .collect(); Ok(SerialImportResponse { file_name, frame_count, channel_count, frames, message: "imported".to_string(), }) } #[tauri::command] pub fn serial_import_csv_from_path(file_path: String) -> Result { let path = resolve_import_path(file_path)?; let file_name = path .file_name() .and_then(|value| value.to_str()) .map(ToOwned::to_owned) .unwrap_or_else(|| "import.csv".to_string()); let bytes = std::fs::read(&path).map_err(|_| SerialError::ImportError)?; let csv_content = String::from_utf8_lossy(&bytes).to_string(); serial_import_csv(file_name, csv_content) } fn resolve_record_for_export( state: &State<'_, SerialConnectionState>, ) -> Result { let current_record = { let session = state.session.lock().map_err(|_| SerialError::StateError)?; session .as_ref() .map(|current_session| current_session.current_record.clone()) }; if let Some(recording) = current_record { return Ok(recording); } let last_record = state .last_record .lock() .map_err(|_| SerialError::StateError)?; last_record.clone().ok_or(SerialError::NoRecordedData) } fn snapshot_record_frame_count( state: &State<'_, SerialConnectionState>, ) -> Result { let current_record = { let session = state.session.lock().map_err(|_| SerialError::StateError)?; session .as_ref() .map(|current_session| current_session.current_record.clone()) }; if let Some(record) = current_record { return record .lock() .map(|recording| recording.frames.len()) .map_err(|_| SerialError::StateError); } let last_record = state .last_record .lock() .map_err(|_| SerialError::StateError)?; let Some(record) = last_record.as_ref() else { return Ok(0); }; record .lock() .map(|recording| recording.frames.len()) .map_err(|_| SerialError::StateError) } fn write_record_to_csv( record: SharedTactileRecording, output_path: &Path, ) -> Result { if let Some(parent) = output_path.parent() { if !parent.exists() { return Err(SerialError::ExportError); } } let mut file = File::create(output_path).map_err(|_| SerialError::ExportError)?; let frame_count = { let recording = record.lock().map_err(|_| SerialError::StateError)?; if recording.frames.is_empty() { return Err(SerialError::NoRecordedData); } export_recording_csv(&recording, &mut file).map_err(|_| SerialError::ExportError)?; recording.frames.len() }; Ok(frame_count) } fn resolve_export_path(raw_path: String) -> Result { let trimmed = raw_path.trim(); if trimmed.is_empty() { return Err(SerialError::ExportError); } let mut path = resolve_absolute_path(trimmed).map_err(|_| SerialError::ExportError)?; if path.extension().is_none() { path.set_extension("csv"); } if path.file_name().is_none() { return Err(SerialError::ExportError); } Ok(path) } fn resolve_import_path(raw_path: String) -> Result { let trimmed = raw_path.trim(); if trimmed.is_empty() { return Err(SerialError::ImportError); } let path = resolve_absolute_path(trimmed).map_err(|_| SerialError::ImportError)?; if !path.exists() || !path.is_file() { return Err(SerialError::ImportError); } Ok(path) } fn resolve_absolute_path(raw_path: &str) -> std::io::Result { let path = PathBuf::from(raw_path); if path.is_absolute() { Ok(path) } else { Ok(std::env::current_dir()?.join(path)) } }