exchange tast to tactilea

This commit is contained in:
lennlouisgeek
2026-04-03 00:47:36 +08:00
parent a686d19e61
commit 7688986ad7
15 changed files with 1842 additions and 147 deletions

View File

@@ -0,0 +1,208 @@
use serde::Serialize;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use tauri::{AppHandle, Manager};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileExplorerRoot {
pub label: String,
pub path: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileExplorerEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub size_bytes: Option<u64>,
pub modified_ms: Option<u128>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileExplorerListResponse {
pub current_path: String,
pub parent_path: Option<String>,
pub roots: Vec<FileExplorerRoot>,
pub entries: Vec<FileExplorerEntry>,
}
#[tauri::command]
pub fn file_explorer_list(
app: AppHandle,
path: Option<String>,
extensions: Option<Vec<String>>,
) -> Result<FileExplorerListResponse, String> {
let current_path = resolve_start_path(&app, path)?;
let extension_filter = normalize_extensions(extensions);
let mut entries = fs::read_dir(&current_path)
.map_err(|err| format!("Failed to read '{}': {err}", current_path.display()))?
.filter_map(Result::ok)
.filter_map(|entry| {
let file_type = entry.file_type().ok()?;
let metadata = entry.metadata().ok();
let is_dir = file_type.is_dir();
let path = entry.path();
if !is_dir && !extension_filter.is_empty() {
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.unwrap_or_default();
if !extension_filter.contains(&extension) {
return None;
}
}
let name = entry.file_name().to_string_lossy().to_string();
let size_bytes = if is_dir {
None
} else {
metadata.as_ref().map(|value| value.len())
};
let modified_ms = metadata
.as_ref()
.and_then(|value| value.modified().ok())
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
.map(|value| value.as_millis());
Some(FileExplorerEntry {
name,
path: path.display().to_string(),
is_dir,
size_bytes,
modified_ms,
})
})
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
if left.is_dir != right.is_dir {
return right.is_dir.cmp(&left.is_dir);
}
left.name
.to_ascii_lowercase()
.cmp(&right.name.to_ascii_lowercase())
});
Ok(FileExplorerListResponse {
current_path: current_path.display().to_string(),
parent_path: current_path.parent().map(|parent| parent.display().to_string()),
roots: collect_roots(&app),
entries,
})
}
fn normalize_extensions(extensions: Option<Vec<String>>) -> HashSet<String> {
extensions
.unwrap_or_default()
.into_iter()
.map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase())
.filter(|value| !value.is_empty())
.collect()
}
fn resolve_start_path(app: &AppHandle, raw_path: Option<String>) -> Result<PathBuf, String> {
if let Some(value) = raw_path {
let trimmed = value.trim();
if trimmed.is_empty() {
return resolve_default_path(app);
}
let mut candidate = PathBuf::from(trimmed);
if candidate.is_relative() {
candidate = std::env::current_dir()
.map_err(|err| format!("Failed to read current dir: {err}"))?
.join(candidate);
}
if !candidate.exists() {
return Err(format!("Path does not exist: {}", candidate.display()));
}
if candidate.is_file() {
return candidate
.parent()
.map(|parent| parent.to_path_buf())
.ok_or_else(|| format!("No parent directory for {}", candidate.display()));
}
return Ok(candidate);
}
resolve_default_path(app)
}
fn resolve_default_path(app: &AppHandle) -> Result<PathBuf, String> {
if let Ok(path) = app.path().desktop_dir() {
return Ok(path);
}
if let Ok(path) = app.path().document_dir() {
return Ok(path);
}
if let Ok(path) = app.path().download_dir() {
return Ok(path);
}
if let Ok(path) = app.path().home_dir() {
return Ok(path);
}
std::env::current_dir().map_err(|err| format!("Failed to resolve default path: {err}"))
}
fn collect_roots(app: &AppHandle) -> Vec<FileExplorerRoot> {
let mut roots = Vec::new();
let mut seen = HashSet::new();
let mut push_root = |label: &str, path: PathBuf| {
let normalized = path.display().to_string();
if normalized.is_empty() || !Path::new(&normalized).exists() {
return;
}
if seen.insert(normalized.clone()) {
roots.push(FileExplorerRoot {
label: label.to_string(),
path: normalized,
});
}
};
if let Ok(path) = app.path().desktop_dir() {
push_root("Desktop", path);
}
if let Ok(path) = app.path().document_dir() {
push_root("Documents", path);
}
if let Ok(path) = app.path().download_dir() {
push_root("Downloads", path);
}
if let Ok(path) = app.path().home_dir() {
push_root("Home", path);
}
#[cfg(target_os = "windows")]
{
for letter in b'A'..=b'Z' {
let drive = format!("{}:\\", letter as char);
let drive_path = PathBuf::from(&drive);
if drive_path.exists() {
push_root(&format!("{}:", letter as char), drive_path);
}
}
}
#[cfg(not(target_os = "windows"))]
{
push_root("Root", PathBuf::from("/"));
}
roots
}

View File

@@ -1,2 +1,3 @@
pub mod file_explorer;
pub mod serial;
pub mod window;

View File

@@ -1,19 +1,27 @@
use crate::serial_core::codecs::test::{export_recording_csv, TestCodec, TestCsvImporter, TestHandler};
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;
use crate::serial_core::{TestRecording, serial};
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::{Instant, SystemTime, UNIX_EPOCH};
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;
type SharedTestRecording = Arc<Mutex<TestRecording>>;
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)]
@@ -49,17 +57,24 @@ pub struct SerialImportResponse {
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: SharedTestRecording,
current_record: SharedTactileRecording,
}
#[derive(Default)]
pub struct SerialConnectionState {
session: Mutex<Option<SerialSession>>,
last_record: Mutex<Option<SharedTestRecording>>
last_record: Mutex<Option<SharedTactileRecording>>
}
#[tauri::command]
@@ -92,7 +107,7 @@ pub async fn serial_connect(
}
let cancel = CancellationToken::new();
let current_record = Arc::new(Mutex::new(TestRecording::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();
@@ -104,10 +119,16 @@ pub async fn serial_connect(
let session_started_at = Instant::now();
let task = tauri::async_runtime::spawn(async move {
let codec = TestCodec::new();
let handler = TestHandler;
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(
if let Err(error) = serial::run_serial_with_poll(
task_app.clone(),
port,
codec,
@@ -115,6 +136,7 @@ pub async fn serial_connect(
session_started_at,
task_record.clone(),
task_cancel,
poll_mode,
)
.await
{
@@ -212,20 +234,6 @@ pub fn serial_export_csv(
app: AppHandle,
state: State<'_, SerialConnectionState>,
) -> Result<SerialExportResponse, SerialError> {
let current_record = {
let session = state.session.lock().map_err(|_| SerialError::StateError)?;
session
.as_ref()
.map(|current_session| current_session.current_record.clone())
};
let record = if let Some(recording) = current_record {
recording
} else {
let last_record = state.last_record.lock().map_err(|_| SerialError::StateError)?;
last_record.clone().ok_or(SerialError::NoRecordedData)?
};
let mut output_dir = match app.path().desktop_dir() {
Ok(path) => path,
Err(_) => std::env::current_dir().map_err(|_| SerialError::ExportError)?,
@@ -237,17 +245,8 @@ pub fn serial_export_csv(
.unwrap_or_default();
output_dir.push(format!("joyson_export_{timestamp}.csv"));
let mut file = File::create(&output_dir).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()
};
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}");
@@ -259,9 +258,40 @@ pub fn serial_export_csv(
})
}
#[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 = TestCsvImporter::new(file_name.as_str());
let mut importer = TactileACsvImporter::new(file_name.as_str());
let packets = importer
.load(Cursor::new(csv_content.into_bytes()))
.map_err(|_| SerialError::ImportError)?;
@@ -288,3 +318,128 @@ pub fn serial_import_csv(file_name: String, csv_content: String) -> Result<Seria
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))
}
}

View File

@@ -9,11 +9,15 @@ pub fn run() {
.manage(SerialConnectionState::default())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
commands::file_explorer::file_explorer_list,
commands::serial::serial_enum,
commands::serial::serial_connect,
commands::serial::serial_disconnect,
commands::serial::serial_export_csv,
commands::serial::serial_has_record_data,
commands::serial::serial_export_csv_to_path,
commands::serial::serial_import_csv,
commands::serial::serial_import_csv_from_path,
commands::window::win_minimize,
commands::window::win_toggle_maximize,
commands::window::win_close

View File

@@ -1,16 +1,18 @@
use crate::serial_core::error::CodecError;
use crate::serial_core::frame::{
FrameHandler, TactileAFrameMetaData, TactileARepFrame, TactileAReqFrame, TestFrame,
FrameHandler, TactileAFrameMetaData, TactileARepFrame, TactileAReqFrame,
};
use crate::serial_core::record::Recording;
use crate::serial_core::record::{self, CsvExporter};
use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording};
use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis, usize_to_u16_le_bytes};
use crate::serial_core::{
codec::Codec,
frame::{TactileAFrame, TactileAFrameStatusCode},
};
use async_trait::async_trait;
use std::time::Instant;
use csv::StringRecord;
use anyhow::anyhow;
use std::io::Read;
const FRAME_BUFFER_MIN_LENGTH: usize = 15;
pub struct TactileACodec {
@@ -234,7 +236,7 @@ impl CsvExporter<TactileARepFrame> for TactileACsvExporter {
fn csv_row(
&self,
item: &record::RecordedFrame<TactileARepFrame>,
item: &RecordedFrame<TactileARepFrame>,
) -> anyhow::Result<Vec<String>> {
let packet = TactileADataPacket::try_from(&item.frame)?;
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect();
@@ -243,5 +245,103 @@ impl CsvExporter<TactileARepFrame> for TactileACsvExporter {
}
}
#[cfg(test)]
mod tests {}
impl CsvExporter<TactileAFrame> for TactileACsvExporter {
type Error = CodecError;
fn csv_header(&self, _recording: &Recording<TactileAFrame>) -> Vec<String> {
let mut header: Vec<String> = Vec::new();
for i in 0..self.channels {
header.push(format!("channel{}", i + 1));
}
header.push("dts".to_string());
header
}
fn csv_row(
&self,
item: &RecordedFrame<TactileAFrame>,
) -> anyhow::Result<Vec<String>> {
let rep = match &item.frame {
TactileAFrame::Rep(rep) => rep,
TactileAFrame::Req(_) => return Err(anyhow!("request frame cannot be exported to csv row")),
};
let packet = TactileADataPacket::try_from(rep)?;
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect();
row.push(packet.dts_ms.to_string());
Ok(row)
}
}
impl TactileACsvImporter {
pub fn new(_path: &str) -> TactileACsvImporter {
Self {
channels: 0,
data_row: 0,
packets: Vec::new(),
}
}
fn parse_record(&mut self, record: StringRecord) -> anyhow::Result<TactileADataPacket> {
if self.channels == 0 {
return Err(anyhow!("csv header is missing channel columns"));
}
if record.len() < self.channels + 1 {
return Err(anyhow!("csv row has insufficient columns"));
}
let mut data = Vec::with_capacity(self.channels);
for index in 0..self.channels {
let cell = record.get(index).ok_or_else(|| anyhow!("missing channel cell"))?;
data.push(cell.parse::<i32>()?);
}
let dts_cell = record
.get(self.channels)
.ok_or_else(|| anyhow!("missing dts cell"))?;
let dts_ms = dts_cell.parse::<u64>()?;
Ok(TactileADataPacket {
data: data,
dts_ms: dts_ms,
})
}
}
impl CsvImporter<TactileADataPacket> for TactileACsvImporter {
fn load<R: Read>(&mut self, reader: R) -> anyhow::Result<Vec<TactileADataPacket>> {
let mut rdr = csv::Reader::from_reader(reader);
let headers = rdr.headers()?.clone();
self.channels = headers.len().saturating_sub(1);
self.data_row = 0;
self.packets.clear();
for record in rdr.records() {
let record = record?;
let packet = self.parse_record(record)?;
self.packets.push(packet);
self.data_row += 1;
}
Ok(self.packets.clone())
}
}
pub fn export_recording_csv<W>(recording: &Recording<TactileAFrame>, writer: W) -> anyhow::Result<()>
where
W: std::io::Write,
{
let channel_nb = recording
.frames
.iter()
.find_map(|frame| match &frame.frame {
TactileAFrame::Rep(rep) => Some(rep.payload.len() / 2),
TactileAFrame::Req(_) => None,
})
.unwrap_or(0);
let exporter = TactileACsvExporter::new(channel_nb);
write_csv(recording, &exporter, writer)
}

View File

@@ -4,7 +4,6 @@ use crate::serial_core::frame::{FrameHandler};
use crate::serial_core::{codec::Codec, error::CodecError, frame::TestFrame};
use anyhow::anyhow;
use async_trait::async_trait;
use chrono::Local;
use csv::StringRecord;
use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording};
use crc::{Crc, CRC_8_SMBUS};
@@ -233,9 +232,7 @@ pub fn export_recording_csv<W>(recording: &Recording<TestFrame>, writer: W) -> a
where
W: std::io::Write,
{
let now = Local::now();
let filename = format!("joyson_{}", now.format("%Y%m%d_%H%M%S"));
write_csv(recording, &TestCsvExporter, &filename)
write_csv(recording, &TestCsvExporter, writer)
}
#[cfg(test)]

View File

@@ -1,4 +1,7 @@
use crate::serial_core::{frame::TestFrame, record::Recording};
use crate::serial_core::{
frame::{TactileAFrame, TestFrame},
record::Recording,
};
pub mod codec;
pub mod codecs;
@@ -10,6 +13,7 @@ pub mod record;
pub mod utils;
pub type TestRecording = Recording<TestFrame>;
pub type TactileARecording = Recording<TactileAFrame>;
pub struct SerialConnection {
pub port: String,

View File

@@ -1,8 +1,3 @@
use std::fs::{write, File};
use std::io;
use anyhow::{Result, anyhow};
use csv::Reader;
#[derive(Clone)]
pub struct FrameTiming {
pub pts_ms: Option<u64>,
@@ -38,20 +33,17 @@ pub trait CsvImporter<P> {
fn load<R: std::io::Read>(&mut self, reader: R) -> anyhow::Result<Vec<P>>;
}
pub fn write_csv<F, E>(
pub fn write_csv<F, E, W>(
recording: &Recording<F>,
exporter: &E,
path: &str
// mut writer: W,
writer: W,
) -> anyhow::Result<()>
where
E: CsvExporter<F>,
// W: std::io::Write
W: std::io::Write,
{
let header = exporter.csv_header(&recording);
// let mut wrt = csv::Writer::from_writer(io::stdout());
let mut wrt = csv::Writer::from_path(format!("{}.csv", path))?;
let mut wrt = csv::Writer::from_writer(writer);
wrt.write_record(header)?;
for f in &recording.frames {
let row = exporter.csv_row(f)?;

View File

@@ -54,10 +54,24 @@ impl SerialFrame for TactileAFrame {
fn to_hud_packet(
&self,
_chart_state: &mut HudChartState,
_display_values: Option<&[i32]>,
chart_state: &mut HudChartState,
display_values: Option<&[i32]>,
) -> Option<HudPacket> {
None
match self {
TactileAFrame::Req(_) => None,
TactileAFrame::Rep(rep) => {
let proxy = TestFrame {
header: rep.meta.header,
cmd: rep.meta.func_code,
length: rep.meta.except_data_len,
payload: rep.payload.clone(),
checksum: rep.meta.checksum,
dts_ms: rep.dts_ms,
};
Some(chart_state.apply_frame(&proxy, display_values))
}
}
}
}