472 lines
13 KiB
Rust
472 lines
13 KiB
Rust
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<Mutex<TactileARecording>>;
|
|
|
|
#[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<i32>,
|
|
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<SerialImportFrame>,
|
|
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<Option<SerialSession>>,
|
|
last_record: Mutex<Option<SharedTactileRecording>>,
|
|
}
|
|
|
|
pub async fn shutdown_active_session(
|
|
state: &SerialConnectionState,
|
|
) -> Result<Option<(String, SharedTactileRecording)>, 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<Vec<String>, 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<SerialConnectResponse, SerialError> {
|
|
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::<SerialConnectionState>();
|
|
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<SerialConnectResponse, SerialError> {
|
|
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<SerialExportResponse, SerialError> {
|
|
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<SerialRecordStateResponse, SerialError> {
|
|
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<SerialExportResponse, SerialError> {
|
|
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<SerialImportResponse, SerialError> {
|
|
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<SerialImportResponse, SerialError> {
|
|
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<SharedTactileRecording, SerialError> {
|
|
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<usize, SerialError> {
|
|
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<usize, SerialError> {
|
|
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<PathBuf, SerialError> {
|
|
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<PathBuf, SerialError> {
|
|
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<PathBuf> {
|
|
let path = PathBuf::from(raw_path);
|
|
if path.is_absolute() {
|
|
Ok(path)
|
|
} else {
|
|
Ok(std::env::current_dir()?.join(path))
|
|
}
|
|
}
|