初步添加了标定支持,需要完善和测试

This commit is contained in:
lennlouisgeek
2026-04-07 01:46:37 +08:00
parent aeb17f194c
commit 770d713d03
19 changed files with 1599 additions and 489 deletions

View File

@@ -0,0 +1,19 @@
[2026-04-06T07:28:34Z DEBUG tauri_demo_lib::log] logging initialized
[2026-04-06T07:28:34Z DEBUG JE_Skin] logging initialized
[2026-04-06T07:29:01Z DEBUG tauri_demo_lib::log] logging initialized
[2026-04-06T07:29:01Z DEBUG JE_Skin] logging initialized
[2026-04-06T07:29:25Z DEBUG tao::platform_impl::platform::event_loop::runner] NewEvents emitted without explicit RedrawEventsCleared
[2026-04-06T07:29:25Z DEBUG tao::platform_impl::platform::event_loop::runner] RedrawEventsCleared emitted without explicit MainEventsCleared
[2026-04-06T07:29:27Z DEBUG mio_serial] opening serial port in synchronous blocking mode
[2026-04-06T07:29:27Z DEBUG mio_serial] switching COM1 to asynchronous mode
[2026-04-06T07:29:27Z DEBUG mio_serial] reading serial port settings
[2026-04-06T07:29:27Z DEBUG mio_serial] closing synchronous port to re-open in FILE_FLAG_OVERLAPPED mode
[2026-04-06T07:29:27Z DEBUG mio_serial] re-setting serial port parameters to original values from synchronous port
[2026-04-06T07:29:36Z DEBUG tao::platform_impl::platform::event_loop::runner] NewEvents emitted without explicit RedrawEventsCleared
[2026-04-06T07:29:36Z DEBUG tao::platform_impl::platform::event_loop::runner] RedrawEventsCleared emitted without explicit MainEventsCleared
[2026-04-06T07:30:02Z DEBUG tauri_demo_lib::serial_core::codecs::tactile_a] unexpected payload length: expected 168, got 20746, buffer_len=178
[2026-04-06T07:30:07Z DEBUG tauri_demo_lib::serial_core::codecs::tactile_a] unexpected payload length: expected 168, got 20746, buffer_len=178
[2026-04-06T07:30:12Z DEBUG tao::platform_impl::platform::event_loop::runner] NewEvents emitted without explicit RedrawEventsCleared
[2026-04-06T07:30:12Z DEBUG tao::platform_impl::platform::event_loop::runner] RedrawEventsCleared emitted without explicit MainEventsCleared
[2026-04-06T07:30:14Z DEBUG tauri_demo_lib::log] logging initialized
[2026-04-06T07:30:14Z DEBUG JE_Skin] logging initialized

Binary file not shown.

View File

@@ -0,0 +1,150 @@
// src-tauri/src/commands/calibration.rs
use crate::commands::serial::SerialConnectionState;
use crate::serial_core::calibration_session::{CalibrationProgress, CalibrationSession};
use crate::serial_core::codecs::tactile_a::TactileACsvExporter;
use crate::serial_core::error::SerialError;
use crate::serial_core::record::{write_csv, CsvExporter};
use crate::serial_core::serial::{run_serial_with_calibration, PollMode, TactileAPollRequester};
use log::info;
use serde::Serialize;
use std::fs::File;
use std::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::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;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CalibrationResponse {
pub success: bool,
pub message: String,
pub progress: Option<CalibrationProgress>,
}
struct CalibrationSessionData {
cancel: CancellationToken,
task: JoinHandle<()>,
}
#[tauri::command]
pub async fn serial_calibrate_with_coarse(
app: AppHandle,
port: String,
target_frames: usize,
max_rounds: usize,
state: State<'_, SerialConnectionState>,
) -> Result<CalibrationResponse, SerialError> {
let port_name = port.trim().to_string();
if port_name.is_empty() {
return Err(SerialError::InvalidConfig);
}
// 检查是否有活跃的标定会话
{
let calibration_session = state
.calibration_session
.lock()
.map_err(|_| SerialError::StateError)?;
if calibration_session.is_some() {
return Err(SerialError::AlreadyConnected);
}
}
// 创建新的标定会话
let mut session = CalibrationSession::new(target_frames, max_rounds);
session.start();
let cancel = CancellationToken::new();
let session_started_at = Instant::now();
let task_cancel = cancel.clone();
let task_app = app.clone();
// let task_port_name = port_name.clone();
let progress = session.get_progress();
let session_for_state = session.clone();
let port = tokio_serial::new(&port_name, 921600)
.open_native_async()
.map_err(|_| SerialError::OpenError)?;
let _ = tauri::async_runtime::spawn(async move {
// 这里调用新的标定处理函数
if let Err(error) = run_serial_with_calibration(
task_app.clone(),
port,
session_started_at,
task_cancel,
session,
)
.await
{
eprintln!("标定任务异常退出: {error}");
}
});
// 保存标定会话状态
let mut calibration_session = state
.calibration_session
.lock()
.map_err(|_| SerialError::StateError)?;
*calibration_session = Some(session_for_state);
Ok(CalibrationResponse {
success: true,
message: "标定已开始".to_string(),
progress: Some(progress),
})
}
#[tauri::command]
pub async fn serial_calibrate_add_weight(
state: State<'_, SerialConnectionState>,
) -> Result<CalibrationResponse, SerialError> {
let mut calibration_session = state
.calibration_session
.lock()
.map_err(|_| SerialError::StateError)?;
if let Some(session) = calibration_session.as_mut() {
match session.weight_added() {
Ok(_) => Ok(CalibrationResponse {
success: true,
message: "配重已添加,继续标定".to_string(),
progress: Some(session.get_progress()),
}),
Err(e) => Err(SerialError::StateError),
}
} else {
Err(SerialError::StateError)
}
}
#[tauri::command]
pub fn serial_calibrate_status(
state: State<'_, SerialConnectionState>,
) -> Result<CalibrationResponse, SerialError> {
let calibration_session = state
.calibration_session
.lock()
.map_err(|_| SerialError::StateError)?;
if let Some(session) = calibration_session.as_ref() {
Ok(CalibrationResponse {
success: true,
message: "标定状态".to_string(),
progress: Some(session.get_progress()),
})
} else {
Ok(CalibrationResponse {
success: false,
message: "没有活跃的标定会话".to_string(),
progress: None,
})
}
}

View File

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

View File

@@ -1,3 +1,4 @@
use crate::serial_core::calibration_session::CalibrationSession;
use crate::serial_core::codecs::tactile_a::{ use crate::serial_core::codecs::tactile_a::{
export_recording_csv, TactileACodec, TactileACsvImporter, TactileAHandler, export_recording_csv, TactileACodec, TactileACsvImporter, TactileAHandler,
}; };
@@ -23,7 +24,6 @@ const DEFAULT_TACTILE_REPLY_TIMEOUT_MS: u64 = 140;
type SharedTactileRecording = Arc<Mutex<TactileARecording>>; type SharedTactileRecording = Arc<Mutex<TactileARecording>>;
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SerialConnectResponse { pub struct SerialConnectResponse {
@@ -74,7 +74,8 @@ struct SerialSession {
#[derive(Default)] #[derive(Default)]
pub struct SerialConnectionState { pub struct SerialConnectionState {
session: Mutex<Option<SerialSession>>, session: Mutex<Option<SerialSession>>,
last_record: Mutex<Option<SharedTactileRecording>> last_record: Mutex<Option<SharedTactileRecording>>,
pub calibration_session: Mutex<Option<CalibrationSession>>,
} }
#[tauri::command] #[tauri::command]
@@ -176,7 +177,7 @@ pub async fn serial_connect(
port: port_name.clone(), port: port_name.clone(),
cancel, cancel,
task, task,
current_record current_record,
}); });
Ok(SerialConnectResponse { Ok(SerialConnectResponse {
@@ -190,6 +191,24 @@ pub async fn serial_connect(
pub async fn serial_disconnect( pub async fn serial_disconnect(
state: State<'_, SerialConnectionState>, state: State<'_, SerialConnectionState>,
) -> Result<SerialConnectResponse, SerialError> { ) -> Result<SerialConnectResponse, SerialError> {
let Some(port) = disconnect_active_session(state.inner()).await? else {
return Ok(SerialConnectResponse {
port: String::new(),
connected: false,
message: "already disconnected".to_string(),
});
};
Ok(SerialConnectResponse {
port,
connected: false,
message: "disconnected".to_string(),
})
}
pub(crate) async fn disconnect_active_session(
state: &SerialConnectionState,
) -> Result<Option<String>, SerialError> {
let session = { let session = {
let mut guard = state.session.lock().map_err(|_| SerialError::StateError)?; let mut guard = state.session.lock().map_err(|_| SerialError::StateError)?;
guard.take() guard.take()
@@ -202,18 +221,15 @@ pub async fn serial_disconnect(
current_record, current_record,
}) = session }) = session
else { else {
return Ok(SerialConnectResponse { return Ok(None);
port: String::new(),
connected: false,
message: "already disconnected".to_string(),
});
}; };
cancel.cancel(); cancel.cancel();
let _ = task.await; let _ = task.await;
let frame_count = current_record.lock().map(|record| { let frame_count = current_record
record.frames.len() .lock()
}).unwrap_or(0); .map(|record| record.frames.len())
.unwrap_or(0);
info!("last_record has {} frames", frame_count); info!("last_record has {} frames", frame_count);
@@ -221,12 +237,7 @@ pub async fn serial_disconnect(
*last_record = Some(current_record); *last_record = Some(current_record);
} }
Ok(Some(port))
Ok(SerialConnectResponse {
port,
connected: false,
message: "disconnected".to_string(),
})
} }
#[tauri::command] #[tauri::command]
@@ -290,7 +301,10 @@ pub fn serial_export_csv_to_path(
} }
#[tauri::command] #[tauri::command]
pub fn serial_import_csv(file_name: String, csv_content: String) -> Result<SerialImportResponse, SerialError> { pub fn serial_import_csv(
file_name: String,
csv_content: String,
) -> Result<SerialImportResponse, SerialError> {
let mut importer = TactileACsvImporter::new(file_name.as_str()); let mut importer = TactileACsvImporter::new(file_name.as_str());
let packets = importer let packets = importer
.load(Cursor::new(csv_content.into_bytes())) .load(Cursor::new(csv_content.into_bytes()))
@@ -347,7 +361,10 @@ fn resolve_record_for_export(
return Ok(recording); return Ok(recording);
} }
let last_record = state.last_record.lock().map_err(|_| SerialError::StateError)?; let last_record = state
.last_record
.lock()
.map_err(|_| SerialError::StateError)?;
last_record.clone().ok_or(SerialError::NoRecordedData) last_record.clone().ok_or(SerialError::NoRecordedData)
} }
@@ -368,7 +385,10 @@ fn snapshot_record_frame_count(
.map_err(|_| SerialError::StateError); .map_err(|_| SerialError::StateError);
} }
let last_record = state.last_record.lock().map_err(|_| SerialError::StateError)?; let last_record = state
.last_record
.lock()
.map_err(|_| SerialError::StateError)?;
let Some(record) = last_record.as_ref() else { let Some(record) = last_record.as_ref() else {
return Ok(0); return Ok(0);
}; };

View File

@@ -1,4 +1,5 @@
use tauri::{AppHandle, Manager, WebviewWindow}; use crate::commands::serial::{disconnect_active_session, SerialConnectionState};
use tauri::{AppHandle, Manager, State, WebviewWindow};
fn main_window(app: &AppHandle) -> Result<WebviewWindow, String> { fn main_window(app: &AppHandle) -> Result<WebviewWindow, String> {
app.get_webview_window("main") app.get_webview_window("main")
@@ -25,8 +26,11 @@ pub fn win_toggle_maximize(app: AppHandle) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
pub fn win_close(app: AppHandle) -> Result<(), String> { pub async fn win_close(app: AppHandle, state: State<'_, SerialConnectionState>) -> Result<(), String> {
main_window(&app)? disconnect_active_session(state.inner())
.close() .await
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())?;
app.exit(0);
Ok(())
} }

View File

@@ -18,6 +18,9 @@ pub fn run() {
commands::serial::serial_export_csv_to_path, commands::serial::serial_export_csv_to_path,
commands::serial::serial_import_csv, commands::serial::serial_import_csv,
commands::serial::serial_import_csv_from_path, commands::serial::serial_import_csv_from_path,
commands::calibration::serial_calibrate_with_coarse,
commands::calibration::serial_calibrate_add_weight,
commands::calibration::serial_calibrate_status,
commands::window::win_minimize, commands::window::win_minimize,
commands::window::win_toggle_maximize, commands::window::win_toggle_maximize,
commands::window::win_close commands::window::win_close

View File

@@ -1,12 +1,40 @@
use fern::{Dispatch, colors::{Color, ColoredLevelConfig}}; use fern::{Dispatch, colors::{Color, ColoredLevelConfig}};
use log::{debug}; use log::{debug};
use std::fs;
use std::path::PathBuf;
use std::time::SystemTime; use std::time::SystemTime;
fn resolve_log_dir() -> PathBuf {
if let Some(override_dir) = std::env::var_os("JE_SKIN_LOG_DIR") {
return PathBuf::from(override_dir);
}
if cfg!(target_os = "windows") {
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
return PathBuf::from(local_app_data).join("JE-Skin").join("logs");
}
}
std::env::temp_dir().join("JE-Skin").join("logs")
}
fn ensure_log_dir() -> PathBuf {
let preferred = resolve_log_dir();
if fs::create_dir_all(&preferred).is_ok() {
return preferred;
}
let fallback = std::env::temp_dir().join("JE-Skin").join("logs");
let _ = fs::create_dir_all(&fallback);
fallback
}
pub fn setup_logger() { pub fn setup_logger() {
let colors_line = ColoredLevelConfig::new() let colors_line = ColoredLevelConfig::new()
.error(Color::Red) .error(Color::Red)
.warn(Color::Yellow) .warn(Color::Yellow)
.info(Color::Green) .info(Color::Green)
.debug(Color::White) .debug(Color::BrightBlue)
.trace(Color::BrightBlack); .trace(Color::BrightBlack);
let colors_level = colors_line.info(Color::Green); let colors_level = colors_line.info(Color::Green);
@@ -38,6 +66,9 @@ pub fn setup_logger() {
// .apply() // .apply()
// .unwrap(); // .unwrap();
let log_dir = ensure_log_dir();
let log_file_base = log_dir.join("program.log");
let file_config = fern::Dispatch::new() let file_config = fern::Dispatch::new()
.format(move |out, message, record| { .format(move |out, message, record| {
out.finish( out.finish(
@@ -51,7 +82,7 @@ pub fn setup_logger() {
); );
}) })
.level(level) .level(level)
.chain(fern::DateBased::new("program.log", "%Y-%m-%d")); .chain(fern::DateBased::new(log_file_base, "%Y-%m-%d"));
Dispatch::new() Dispatch::new()
.level(log::LevelFilter::Debug) .level(log::LevelFilter::Debug)

View File

@@ -0,0 +1,109 @@
use crate::serial_core::frame::TactileAFrame;
use crate::serial_core::record::{RecordedFrame, Recording};
use serde::Serialize;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum CalibrationState {
Idle,
CollectingData,
ExportingData,
WaitingForWeight,
Completed,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CalibrationSession {
pub state: CalibrationState,
pub target_frame: usize,
pub collected_frames: usize,
pub current_round: usize,
pub max_rounds: usize,
pub data: Vec<RecordedFrame<TactileAFrame>>,
}
impl CalibrationSession {
pub fn new(targt_frame: usize, max_round: usize) -> Self {
Self {
state: CalibrationState::Idle,
target_frame: targt_frame,
collected_frames: 0,
current_round: 1,
max_rounds: max_round,
data: Vec::new(),
}
}
pub fn start(&mut self) {
self.state = CalibrationState::CollectingData;
self.collected_frames = 0;
self.data.clear();
println!(
"标定第 {} 轮开始,目标收集 {} 个有效帧",
self.current_round, self.target_frame
);
}
pub fn add_frame(&mut self, frame: RecordedFrame<TactileAFrame>) -> bool {
if self.state != CalibrationState::CollectingData {
return false;
}
self.data.push(frame);
self.collected_frames += 1;
if self.collected_frames >= self.target_frame {
self.state = CalibrationState::ExportingData;
return true;
}
return false;
}
pub fn export_completed(&mut self) {
self.state = CalibrationState::WaitingForWeight;
println!("请修改配重,继续标定");
}
pub fn weight_added(&mut self) -> Result<(), String> {
if self.current_round >= self.max_rounds {
self.state = CalibrationState::Completed;
println!("标定完成,共 {}", self.current_round);
} else {
self.current_round += 1;
self.start();
}
Ok(())
}
pub fn get_progress(&self) -> CalibrationProgress {
CalibrationProgress {
state: self.state.clone(),
current_round: self.current_round,
max_rounds: self.max_rounds,
collected_frames: self.collected_frames,
target_frames: self.target_frame,
progress_percentage: if self.target_frame > 0 {
(self.collected_frames as f32 / self.target_frame as f32) * 100.0
} else {
0.0
},
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CalibrationProgress {
pub state: CalibrationState,
pub current_round: usize,
pub max_rounds: usize,
pub collected_frames: usize,
pub target_frames: usize,
pub progress_percentage: f32,
}
pub type SharedCalibrationSession = Arc<Mutex<Option<CalibrationSession>>>;

View File

@@ -1,5 +1,6 @@
use crate::serial_core::{frame::TestFrame, record::Recording}; use crate::serial_core::{frame::TestFrame, record::Recording};
pub mod test;
pub mod tactile_a; pub mod tactile_a;
pub type TestRecording = Recording<TestFrame>; pub mod test;
pub type TestRecording = Recording<TestFrame>;

View File

@@ -8,13 +8,15 @@ use crate::serial_core::{
codec::Codec, codec::Codec,
frame::{TactileAFrame, TactileAFrameStatusCode}, frame::{TactileAFrame, TactileAFrameStatusCode},
}; };
use anyhow::anyhow;
use async_trait::async_trait; use async_trait::async_trait;
use csv::StringRecord; use csv::StringRecord;
use anyhow::anyhow;
use std::io::Read;
use log::debug; use log::debug;
use std::io::Read;
use std::os::raw;
const FRAME_BUFFER_MIN_LENGTH: usize = 15; const FRAME_BUFFER_MIN_LENGTH: usize = 15;
const IGNOR_RAW_DATA_VAL: i32 = 10;
pub struct TactileACodec { pub struct TactileACodec {
buffer: Vec<u8>, buffer: Vec<u8>,
@@ -24,6 +26,7 @@ pub struct TactileACodec {
pub struct TactileACsvExporter { pub struct TactileACsvExporter {
channels: usize, channels: usize,
limit: Option<i32>,
} }
pub struct TactileACsvImporter { pub struct TactileACsvImporter {
@@ -77,7 +80,14 @@ impl TactileACodec {
let vals: Vec<i32> = data let vals: Vec<i32> = data
.chunks_exact(2) .chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]) as i32) .map(|chunk| {
let mut raw_val = u16::from_le_bytes([chunk[0], chunk[1]]) as i32;
println!("raw_val: {}", raw_val);
if raw_val < IGNOR_RAW_DATA_VAL {
raw_val = 0;
}
raw_val
})
.collect::<Vec<i32>>(); .collect::<Vec<i32>>();
Ok(vals) Ok(vals)
@@ -216,16 +226,15 @@ impl Codec<TactileAFrame> for TactileACodec {
req_bytes.push(f.meta.device_addr); req_bytes.push(f.meta.device_addr);
req_bytes.push(f.meta.extend_code); req_bytes.push(f.meta.extend_code);
req_bytes.push(f.meta.func_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.start_addr.to_le_bytes().as_slice());
req_bytes.extend_from_slice((f.meta.except_data_len as u16).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()); let checksum = calc_crc8_itu(req_bytes.as_slice());
req_bytes.push(checksum); req_bytes.push(checksum);
Ok(req_bytes) Ok(req_bytes)
} }
_ => { _ => Err(CodecError::InvalidFrameType),
Err(CodecError::InvalidFrameType)
}
} }
} }
} }
@@ -245,8 +254,18 @@ impl FrameHandler<TactileAFrame, i32> for TactileAHandler {
} }
impl TactileACsvExporter { impl TactileACsvExporter {
fn new(channels: usize) -> Self { pub fn new(channels: usize) -> Self {
TactileACsvExporter { channels } TactileACsvExporter {
channels,
limit: None,
}
}
pub fn with_coarse_calibration(channels: usize, li: i32) -> Self {
TactileACsvExporter {
channels,
limit: Some(li),
}
} }
} }
@@ -265,11 +284,21 @@ impl CsvExporter<TactileARepFrame> for TactileACsvExporter {
fn csv_row( fn csv_row(
&self, &self,
item: &RecordedFrame<TactileARepFrame>, item: &RecordedFrame<TactileARepFrame>,
) -> anyhow::Result<Vec<String>> { ) -> anyhow::Result<Option<Vec<String>>> {
let packet = TactileADataPacket::try_from(&item.frame)?; let packet = TactileADataPacket::try_from(&item.frame)?;
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect(); if let Some(li) = self.limit {
row.push(packet.dts_ms.to_string()); if li > packet.data.iter().sum() {
Ok(row) Ok(None)
} else {
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect();
row.push(packet.dts_ms.to_string());
Ok(Some(row))
}
} else {
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect();
row.push(packet.dts_ms.to_string());
Ok(Some(row))
}
} }
} }
@@ -286,19 +315,28 @@ impl CsvExporter<TactileAFrame> for TactileACsvExporter {
header header
} }
fn csv_row( fn csv_row(&self, item: &RecordedFrame<TactileAFrame>) -> anyhow::Result<Option<Vec<String>>> {
&self,
item: &RecordedFrame<TactileAFrame>,
) -> anyhow::Result<Vec<String>> {
let rep = match &item.frame { let rep = match &item.frame {
TactileAFrame::Rep(rep) => rep, TactileAFrame::Rep(rep) => rep,
TactileAFrame::Req(_) => return Err(anyhow!("request frame cannot be exported to csv row")), TactileAFrame::Req(_) => {
return Err(anyhow!("request frame cannot be exported to csv row"))
}
}; };
let packet = TactileADataPacket::try_from(rep)?; let packet = TactileADataPacket::try_from(rep)?;
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect(); if let Some(li) = self.limit {
row.push(packet.dts_ms.to_string()); if li > packet.data.iter().sum() {
Ok(row) Ok(None)
} else {
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect();
row.push(packet.dts_ms.to_string());
Ok(Some(row))
}
} else {
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect();
row.push(packet.dts_ms.to_string());
Ok(Some(row))
}
} }
} }
@@ -322,7 +360,9 @@ impl TactileACsvImporter {
let mut data = Vec::with_capacity(self.channels); let mut data = Vec::with_capacity(self.channels);
for index in 0..self.channels { for index in 0..self.channels {
let cell = record.get(index).ok_or_else(|| anyhow!("missing channel cell"))?; let cell = record
.get(index)
.ok_or_else(|| anyhow!("missing channel cell"))?;
data.push(cell.parse::<i32>()?); data.push(cell.parse::<i32>()?);
} }
@@ -357,7 +397,10 @@ impl CsvImporter<TactileADataPacket> for TactileACsvImporter {
} }
} }
pub fn export_recording_csv<W>(recording: &Recording<TactileAFrame>, writer: W) -> anyhow::Result<()> pub fn export_recording_csv<W>(
recording: &Recording<TactileAFrame>,
writer: W,
) -> anyhow::Result<()>
where where
W: std::io::Write, W: std::io::Write,
{ {

View File

@@ -1,15 +1,12 @@
use std::io::Read; use crate::serial_core::frame::FrameHandler;
use std::time::Instant; use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording};
use crate::serial_core::frame::{FrameHandler}; use crate::serial_core::utils::{elapsed_millis, usize_to_u16_be_bytes};
use crate::serial_core::{codec::Codec, error::CodecError, frame::TestFrame}; use crate::serial_core::{codec::Codec, error::CodecError, frame::TestFrame};
use anyhow::anyhow; use anyhow::anyhow;
use async_trait::async_trait; use async_trait::async_trait;
use csv::StringRecord; use csv::StringRecord;
use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording}; use std::io::Read;
use crate::serial_core::utils::{ use std::time::Instant;
elapsed_millis,
usize_to_u16_be_bytes
};
pub struct TestCodec { pub struct TestCodec {
buffer: Vec<u8>, buffer: Vec<u8>,
} }
@@ -23,7 +20,11 @@ impl TestCodec {
} }
impl Codec<TestFrame> for TestCodec { impl Codec<TestFrame> for TestCodec {
fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result<Vec<TestFrame>, CodecError> { fn decode(
&mut self,
input: &[u8],
session_started_at: Instant,
) -> Result<Vec<TestFrame>, CodecError> {
self.buffer.extend_from_slice(input); self.buffer.extend_from_slice(input);
let mut frames = Vec::new(); let mut frames = Vec::new();
@@ -126,7 +127,7 @@ pub struct TestCsvImporter {
#[derive(Clone)] #[derive(Clone)]
pub struct TestDataPacket { pub struct TestDataPacket {
pub data: Vec<i32>, pub data: Vec<i32>,
pub dts_ms: u64 pub dts_ms: u64,
} }
impl TryFrom<&TestFrame> for TestDataPacket { impl TryFrom<&TestFrame> for TestDataPacket {
@@ -134,7 +135,10 @@ impl TryFrom<&TestFrame> for TestDataPacket {
fn try_from(frame: &TestFrame) -> Result<TestDataPacket, Self::Error> { fn try_from(frame: &TestFrame) -> Result<TestDataPacket, Self::Error> {
let data = parse_data_frame(&frame.payload)?; let data = parse_data_frame(&frame.payload)?;
let dts = frame.dts_ms; let dts = frame.dts_ms;
Ok(TestDataPacket { data: data, dts_ms: dts }) Ok(TestDataPacket {
data: data,
dts_ms: dts,
})
} }
} }
// impl From<TestFrame> for TestDataPacket { // impl From<TestFrame> for TestDataPacket {
@@ -145,14 +149,17 @@ impl TryFrom<&TestFrame> for TestDataPacket {
// } // }
// } // }
impl CsvExporter<TestFrame> for TestCsvExporter { impl CsvExporter<TestFrame> for TestCsvExporter {
type Error = CodecError; type Error = CodecError;
fn csv_header(&self, recording: &Recording<TestFrame>) -> Vec<String> { fn csv_header(&self, recording: &Recording<TestFrame>) -> Vec<String> {
let channel_nb = recording let channel_nb = recording
.frames .frames
.iter() .iter()
.find_map(|frame| parse_data_frame(&frame.frame.payload).ok().map(|vals| vals.len())) .find_map(|frame| {
parse_data_frame(&frame.frame.payload)
.ok()
.map(|vals| vals.len())
})
.unwrap_or(0); .unwrap_or(0);
let mut header: Vec<String> = Vec::new(); let mut header: Vec<String> = Vec::new();
for i in 0..channel_nb { for i in 0..channel_nb {
@@ -163,11 +170,11 @@ impl CsvExporter<TestFrame> for TestCsvExporter {
header header
} }
fn csv_row(&self, item: &RecordedFrame<TestFrame>) -> anyhow::Result<Vec<String>> { fn csv_row(&self, item: &RecordedFrame<TestFrame>) -> anyhow::Result<Option<Vec<String>>> {
let packet: TestDataPacket = TestDataPacket::try_from(&item.frame)?; let packet: TestDataPacket = TestDataPacket::try_from(&item.frame)?;
let mut row: Vec<String> = packet.data.iter().map(|&x| x.to_string()).collect(); let mut row: Vec<String> = packet.data.iter().map(|&x| x.to_string()).collect();
row.push(packet.dts_ms.to_string()); row.push(packet.dts_ms.to_string());
Ok(row) Ok(Some(row))
} }
} }
@@ -180,7 +187,7 @@ impl TestCsvImporter {
} }
} }
fn parse_record(&mut self, record: StringRecord) -> anyhow::Result<TestDataPacket>{ fn parse_record(&mut self, record: StringRecord) -> anyhow::Result<TestDataPacket> {
if self.channels == 0 { if self.channels == 0 {
return Err(anyhow!("csv header is missing channel columns")); return Err(anyhow!("csv header is missing channel columns"));
} }
@@ -191,7 +198,9 @@ impl TestCsvImporter {
let mut data = Vec::with_capacity(self.channels); let mut data = Vec::with_capacity(self.channels);
for index in 0..self.channels { for index in 0..self.channels {
let cell = record.get(index).ok_or_else(|| anyhow!("missing channel cell"))?; let cell = record
.get(index)
.ok_or_else(|| anyhow!("missing channel cell"))?;
data.push(cell.parse::<i32>()?); data.push(cell.parse::<i32>()?);
} }
@@ -226,7 +235,6 @@ impl CsvImporter<TestDataPacket> for TestCsvImporter {
} }
} }
pub fn export_recording_csv<W>(recording: &Recording<TestFrame>, writer: W) -> anyhow::Result<()> pub fn export_recording_csv<W>(recording: &Recording<TestFrame>, writer: W) -> anyhow::Result<()>
where where
W: std::io::Write, W: std::io::Write,

View File

@@ -1,16 +1,17 @@
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, Eq)] use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TestFrame { pub struct TestFrame {
pub header: [u8; 2], pub header: [u8; 2],
pub cmd: u8, pub cmd: u8,
pub length: usize, pub length: usize,
pub payload: Vec<u8>, pub payload: Vec<u8>,
pub checksum: u8, pub checksum: u8,
pub dts_ms: u64 pub dts_ms: u64,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TactileAFrameMetaData { pub struct TactileAFrameMetaData {
pub header: [u8; 2], pub header: [u8; 2],
pub payload_len: usize, pub payload_len: usize,
@@ -25,33 +26,37 @@ pub struct TactileAFrameMetaData {
// pub dts_ms: u64, // pub dts_ms: u64,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TactileAReqFrame { pub struct TactileAReqFrame {
pub meta: TactileAFrameMetaData, pub meta: TactileAFrameMetaData,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TactileARepFrame { pub struct TactileARepFrame {
pub meta: TactileAFrameMetaData, pub meta: TactileAFrameMetaData,
pub status: TactileAFrameStatusCode, pub status: TactileAFrameStatusCode,
pub payload: Vec<u8>, pub payload: Vec<u8>,
pub dts_ms: u64 pub dts_ms: u64,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum TactileAFrameStatusCode { pub enum TactileAFrameStatusCode {
Success, Success,
Failure Failure,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum TactileAFrame { pub enum TactileAFrame {
Req(TactileAReqFrame), Req(TactileAReqFrame),
Rep(TactileARepFrame) Rep(TactileARepFrame),
} }
// TODO: filter
// pub trait FrameFilter<F> {
// fn apply(&self)
// }
#[async_trait] #[async_trait]
pub trait FrameHandler<F, T>: Send { pub trait FrameHandler<F, T>: Send {
async fn on_frame(&mut self, frame: &F) -> Result<Option<Vec<T>>>; async fn on_frame(&mut self, frame: &F) -> Result<Option<Vec<T>>>;
} }

View File

@@ -3,15 +3,15 @@ use crate::serial_core::{
record::Recording, record::Recording,
}; };
pub mod calibration_session;
pub mod codec; pub mod codec;
pub mod codecs; pub mod codecs;
pub mod error; pub mod error;
pub mod frame; pub mod frame;
pub mod model; pub mod model;
pub mod serial;
pub mod record; pub mod record;
pub mod serial;
pub mod utils; pub mod utils;
pub type TestRecording = Recording<TestFrame>; pub type TestRecording = Recording<TestFrame>;
pub type TactileARecording = Recording<TactileAFrame>; pub type TactileARecording = Recording<TactileAFrame>;

View File

@@ -1,31 +1,49 @@
#[derive(Clone)] use serde::Serialize;
#[derive(Clone, Serialize, Debug)]
pub struct FrameTiming { pub struct FrameTiming {
pub pts_ms: Option<u64>, pub pts_ms: Option<u64>,
pub dts_ms: u64, pub dts_ms: u64,
} }
#[derive(Clone)] #[derive(Clone, Serialize, Debug)]
pub struct RecordedFrame<F> { pub struct RecordedFrame<F> {
pub timing: FrameTiming, pub timing: FrameTiming,
pub frame: F pub frame: F,
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct Recording<F> { pub struct Recording<F> {
pub frames: Vec<RecordedFrame<F>> pub frames: Vec<RecordedFrame<F>>,
pub count: usize,
pub except_count: Option<usize>,
} }
impl<F> Recording<F> { impl<F> Recording<F> {
pub fn new() -> Recording<F> { Self { frames: Vec::new() } } pub fn new() -> Recording<F> {
Self {
frames: Vec::new(),
count: 0,
except_count: None,
}
}
pub fn with_except_count(except_count: usize) -> Recording<F> {
Self {
frames: Vec::new(),
count: 0,
except_count: Some(except_count),
}
}
pub fn push(&mut self, ite: RecordedFrame<F>) { pub fn push(&mut self, ite: RecordedFrame<F>) {
self.frames.push(ite); self.frames.push(ite);
} }
pub fn check_frame_need_record(ite: RecordedFrame<F>) {}
} }
pub trait CsvExporter<F> { pub trait CsvExporter<F> {
type Error: std::error::Error + Send + Sync + 'static; type Error: std::error::Error + Send + Sync + 'static;
fn csv_header(&self, recording: &Recording<F>) -> Vec<String>; fn csv_header(&self, recording: &Recording<F>) -> Vec<String>;
fn csv_row(&self, item: &RecordedFrame<F>) -> anyhow::Result<Vec<String>>; fn csv_row(&self, item: &RecordedFrame<F>) -> anyhow::Result<Option<Vec<String>>>;
} }
// TODO: CsvImporter // TODO: CsvImporter
@@ -33,11 +51,7 @@ pub trait CsvImporter<P> {
fn load<R: std::io::Read>(&mut self, reader: R) -> anyhow::Result<Vec<P>>; fn load<R: std::io::Read>(&mut self, reader: R) -> anyhow::Result<Vec<P>>;
} }
pub fn write_csv<F, E, W>( pub fn write_csv<F, E, W>(recording: &Recording<F>, exporter: &E, writer: W) -> anyhow::Result<()>
recording: &Recording<F>,
exporter: &E,
writer: W,
) -> anyhow::Result<()>
where where
E: CsvExporter<F>, E: CsvExporter<F>,
W: std::io::Write, W: std::io::Write,
@@ -46,8 +60,9 @@ where
let mut wrt = csv::Writer::from_writer(writer); let mut wrt = csv::Writer::from_writer(writer);
wrt.write_record(header)?; wrt.write_record(header)?;
for f in &recording.frames { for f in &recording.frames {
let row = exporter.csv_row(f)?; if let Some(row) = exporter.csv_row(f)? {
wrt.write_record(&row)?; wrt.write_record(&row)?;
}
} }
wrt.flush()?; wrt.flush()?;

View File

@@ -1,23 +1,29 @@
use crate::serial_core::calibration_session::*;
use crate::serial_core::codec::Codec; use crate::serial_core::codec::Codec;
use crate::serial_core::codecs::tactile_a::TactileACodec; use crate::serial_core::codecs::tactile_a::TactileACodec;
use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame}; use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame};
use crate::serial_core::model::{HudChartState, HudPacket}; use crate::serial_core::model::{HudChartState, HudPacket};
use crate::serial_core::record::Recording; use crate::serial_core::record::Recording;
use crate::serial_core::record::{FrameTiming, RecordedFrame};
use anyhow::Result; use anyhow::Result;
use log::{debug, info};
use std::fs::File;
use std::future::pending;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::{self, Duration, MissedTickBehavior}; use tokio::time::{self, Duration, MissedTickBehavior};
use tokio_serial::SerialStream; use tokio_serial::SerialStream;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use std::future::pending; const DEFAULT_TACTILE_COLS: usize = 7;
use std::sync::{Arc, Mutex}; const DEFAULT_TACTILE_ROWS: usize = 12;
use std::time::Instant; const DEFAULT_TACTILE_POLL_INTERVAL_MS: u64 = 10;
use log::{info, debug}; const DEFAULT_TACTILE_REPLY_TIMEOUT_MS: u64 = 140;
use crate::serial_core::record::{FrameTiming, RecordedFrame}; use crate::serial_core::codecs::tactile_a::TactileAHandler;
pub enum PollMode<F> { pub enum PollMode<F> {
Disable, Disable,
Enabled(Box<dyn PollRequester<F>>) Enabled(Box<dyn PollRequester<F>>),
} }
pub trait SerialFrame: Clone + Send + 'static { pub trait SerialFrame: Clone + Send + 'static {
@@ -169,11 +175,19 @@ where
F: SerialFrame, F: SerialFrame,
C: Codec<F> + Send + 'static, C: Codec<F> + Send + 'static,
H: FrameHandler<F, T> + Send + 'static, H: FrameHandler<F, T> + Send + 'static,
T: Into<i32> T: Into<i32>,
{ {
run_serial_with_poll( run_serial_with_poll(
app, port, codec, handler, session_started_at, recording, cancel, PollMode::Disable app,
).await port,
codec,
handler,
session_started_at,
recording,
cancel,
PollMode::Disable,
)
.await
} }
pub async fn run_serial_with_poll<C, H, T, F>( pub async fn run_serial_with_poll<C, H, T, F>(
@@ -184,7 +198,7 @@ pub async fn run_serial_with_poll<C, H, T, F>(
session_started_at: Instant, session_started_at: Instant,
recording: Arc<Mutex<Recording<F>>>, recording: Arc<Mutex<Recording<F>>>,
cancel: CancellationToken, cancel: CancellationToken,
poll_mode: PollMode<F> poll_mode: PollMode<F>,
) -> Result<()> ) -> Result<()>
where where
F: SerialFrame, F: SerialFrame,
@@ -192,15 +206,13 @@ where
H: FrameHandler<F, T> + Send + 'static, H: FrameHandler<F, T> + Send + 'static,
T: Into<i32>, T: Into<i32>,
{ {
info!("run_serial_with_poll");
let mut requester = match poll_mode { let mut requester = match poll_mode {
PollMode::Disable => None, PollMode::Disable => None,
PollMode::Enabled(r) => Some(r), PollMode::Enabled(r) => Some(r),
}; };
let mut poll_interval = requester let mut poll_interval = requester.as_ref().and_then(|r| r.poll_interval()).map(|d| {
.as_ref()
.and_then(|r| r.poll_interval())
.map(|d| {
let mut it = time::interval(d); let mut it = time::interval(d);
it.set_missed_tick_behavior(MissedTickBehavior::Skip); it.set_missed_tick_behavior(MissedTickBehavior::Skip);
it it
@@ -211,7 +223,6 @@ where
let mut prune_interval = time::interval(Duration::from_millis(450)); let mut prune_interval = time::interval(Duration::from_millis(450));
prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop { loop {
tokio::select! { tokio::select! {
_ = cancel.cancelled() => break, _ = cancel.cancelled() => break,
@@ -227,6 +238,7 @@ where
if r.should_request() { if r.should_request() {
if let Some(req) = r.next_request()? { if let Some(req) = r.next_request()? {
let bytes = codec.encode(&req)?; let bytes = codec.encode(&req)?;
debug!("send {:02X?}", bytes);
port.write_all(&bytes).await?; port.write_all(&bytes).await?;
} }
} }
@@ -281,3 +293,155 @@ where
} }
Ok(()) Ok(())
} }
// 在 src-tauri/src/serial_core/serial.rs 中添加
pub async fn run_serial_with_calibration(
app: AppHandle,
mut port: SerialStream,
session_started_at: Instant,
cancel: CancellationToken,
mut calibration_session: CalibrationSession,
) -> Result<()> {
let mut codec = TactileACodec::new(DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS);
let mut handler = TactileAHandler;
let mut requester = TactileAPollRequester::new(
Duration::from_millis(DEFAULT_TACTILE_POLL_INTERVAL_MS),
DEFAULT_TACTILE_COLS,
DEFAULT_TACTILE_ROWS,
Duration::from_millis(DEFAULT_TACTILE_REPLY_TIMEOUT_MS),
);
let mut poll_interval = time::interval(Duration::from_millis(DEFAULT_TACTILE_POLL_INTERVAL_MS));
poll_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut buffer = [0u8; 1024];
let recording = Arc::new(Mutex::new(Recording::new()));
let mut chart_state = HudChartState::new();
let mut prune_interval = time::interval(Duration::from_millis(450));
prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = cancel.cancelled() => break,
_ = poll_interval.tick() => {
if requester.should_request() {
if let Some(req) = requester.next_request()? {
let bytes = codec.encode(&req)?;
port.write_all(&bytes).await?;
}
}
}
_ = prune_interval.tick() => {
if let Some(packet) = chart_state.prune_stale() {
app.emit("hud_stream", packet)?;
}
}
read_result = port.read(&mut buffer) => {
let n = read_result?;
if n == 0 {
tokio::task::yield_now().await;
continue;
}
let frames = codec.decode(&buffer[..n], session_started_at)?;
for frame in frames {
requester.on_rx_frame(&frame);
let decode_res = handler
.on_frame(&frame)
.await?
.map(|vals| vals.into_iter().map(Into::into).collect::<Vec<i32>>());
let recorded_frame = RecordedFrame {
timing: FrameTiming { pts_ms: None, dts_ms: frame.dts_ms() },
frame: frame.clone(),
};
{
let mut record = recording
.lock()
.map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
record.push(recorded_frame.clone());
}
let display_values = if let Some(vals) = decode_res.as_ref() {
let summary = vals.iter().copied().sum::<i32>();
chart_state.record_summary(summary as f32);
chart_state.record_pressure_matrix(vals.as_slice());
Some(vec![summary])
} else {
None
};
if let Some(packet) = frame.to_hud_packet(&mut chart_state, display_values.as_deref()) {
app.emit("hud_stream", packet)?;
}
// 检查是否达到目标帧数
let should_export = calibration_session.add_frame(recorded_frame);
if should_export {
// 导出数据
export_calibration_data(&app, &calibration_session, &recording).await?;
// 发送语音提示(这里用事件代替,前端可以播放语音)
app.emit("calibration_voice_prompt", "请添加配重")?;
// 更新状态
calibration_session.export_completed();
if let Ok(mut record) = recording.lock() {
record.frames.clear();
}
}
}
}
}
}
Ok(())
}
use crate::serial_core::codecs::tactile_a::TactileACsvExporter;
use crate::serial_core::record::write_csv;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::Manager;
async fn export_calibration_data(
app: &AppHandle,
calibration_session: &CalibrationSession,
recording: &Arc<Mutex<Recording<TactileAFrame>>>,
) -> Result<()> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
let filename = format!(
"calibration_round{}_{}.csv",
calibration_session.current_round, timestamp
);
// 创建导出目录
let mut output_dir = match app.path().desktop_dir() {
Ok(path) => path,
Err(_) => std::env::current_dir()?,
};
output_dir.push("calibration_data");
std::fs::create_dir_all(&output_dir)?;
let output_path = output_dir.join(&filename);
let file = File::create(&output_path)?;
// 使用现有的导出逻辑
let recording_lock = recording
.lock()
.map_err(|_| anyhow::anyhow!("Recording poisoned"))?;
let exporter = TactileACsvExporter::with_coarse_calibration(
DEFAULT_TACTILE_COLS * DEFAULT_TACTILE_ROWS,
7 * 12 * 10,
);
write_csv(&recording_lock, &exporter, file)?;
info!("标定数据已导出到: {}", output_path.display());
Ok(())
}

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="data:," />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tauri + SvelteKit + Typescript App</title> <title>Tauri + SvelteKit + Typescript App</title>
%sveltekit.head% %sveltekit.head%

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,18 @@
dtsMs: number; dtsMs: number;
} }
type CalibrationMethodId = "coarse";
interface CalibrationStartPayload {
methodId: CalibrationMethodId;
rounds: number;
}
interface CalibrationInvokeResult {
success: boolean;
message: string;
}
const copyByLocale: Record<LocaleCode, HudCopy> = { const copyByLocale: Record<LocaleCode, HudCopy> = {
"zh-CN": { "zh-CN": {
appName: "JE-Skin", appName: "JE-Skin",
@@ -159,6 +171,7 @@
const summaryPointsPerSeries = 42; const summaryPointsPerSeries = 42;
const signalRenderTickMs = 1200; const signalRenderTickMs = 1200;
const replayDefaultFrameMs = 40; const replayDefaultFrameMs = 40;
const defaultCalibrationTargetFrames = 100;
const showSignalPanels = false; const showSignalPanels = false;
const mockToneCycle: SignalTone[] = ["cyan", "lime", "orange", "violet", "gold", "rose"]; const mockToneCycle: SignalTone[] = ["cyan", "lime", "orange", "violet", "gold", "rose"];
@@ -205,6 +218,7 @@
let activeConfigLinkId = "stream-on"; let activeConfigLinkId = "stream-on";
let isConfigPanelOpen = false; let isConfigPanelOpen = false;
let isPrecisionTestOpen = false; let isPrecisionTestOpen = false;
let isCalibrationTestOpen = false;
let hasSignalData = false; let hasSignalData = false;
let signalPanels: HudSignalPanel[] = buildInactivePanels(); let signalPanels: HudSignalPanel[] = buildInactivePanels();
let summary: HudSummary = buildEmptySummary(); let summary: HudSummary = buildEmptySummary();
@@ -233,7 +247,7 @@
let fileExplorerFileName = ""; let fileExplorerFileName = "";
$: uiCopy = copyByLocale[locale]; $: uiCopy = copyByLocale[locale];
$: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen, isPrecisionTestOpen); $: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen, isPrecisionTestOpen, isCalibrationTestOpen);
$: stageStatusText = webglStatusTone === "ok" ? uiCopy.runtimeReady : uiCopy.runtimeFallback; $: stageStatusText = webglStatusTone === "ok" ? uiCopy.runtimeReady : uiCopy.runtimeFallback;
$: leftSignalPanels = signalPanels.filter((panel) => panel.side === "left"); $: leftSignalPanels = signalPanels.filter((panel) => panel.side === "left");
$: rightSignalPanels = signalPanels.filter((panel) => panel.side === "right"); $: rightSignalPanels = signalPanels.filter((panel) => panel.side === "right");
@@ -975,7 +989,8 @@
currentLocale: LocaleCode, currentLocale: LocaleCode,
activeId: string, activeId: string,
isSettingsOpen: boolean, isSettingsOpen: boolean,
isPrecisionOpen: boolean isPrecisionOpen: boolean,
isCalibrationOpen: boolean
): HudConfigLink[] { ): HudConfigLink[] {
const labels = const labels =
currentLocale === "zh-CN" currentLocale === "zh-CN"
@@ -1011,7 +1026,7 @@
id: "calibrate", id: "calibrate",
label: labels.calibrate, label: labels.calibrate,
tone: "cyan", tone: "cyan",
active: activeId === "calibrate" active: isCalibrationOpen
}, },
{ {
id: "precision-test", id: "precision-test",
@@ -1460,19 +1475,80 @@
if (event.detail === "precision-test") { if (event.detail === "precision-test") {
isPrecisionTestOpen = !isPrecisionTestOpen; isPrecisionTestOpen = !isPrecisionTestOpen;
isConfigPanelOpen = false; isConfigPanelOpen = false;
isCalibrationTestOpen = false;
return;
}
if (event.detail === "calibrate") {
isCalibrationTestOpen = !isCalibrationTestOpen;
isConfigPanelOpen = false;
isPrecisionTestOpen = false;
return; return;
} }
if (event.detail === "settings") { if (event.detail === "settings") {
isPrecisionTestOpen = false; isPrecisionTestOpen = false;
isCalibrationTestOpen = false;
isConfigPanelOpen = !isConfigPanelOpen; isConfigPanelOpen = !isConfigPanelOpen;
return; return;
} }
isPrecisionTestOpen = false; isPrecisionTestOpen = false;
isCalibrationTestOpen = false;
isConfigPanelOpen = false; isConfigPanelOpen = false;
activeConfigLinkId = event.detail; activeConfigLinkId = event.detail;
console.info("[hud] config link clicked:", event.detail); }
async function handleCalibrationStart(event: CustomEvent<CalibrationStartPayload>): Promise<void> {
const targetRounds = clamp(Math.round(Number(event.detail.rounds) || 1), 1, 20);
if (!isTauriRuntime()) {
connectionNotice =
locale === "zh-CN"
? `当前运行环境不支持启动标定(目标 ${targetRounds} 轮)。`
: `Current runtime does not support calibration start (${targetRounds} rounds).`;
connectionNoticeTone = "warn";
return;
}
if (!serialPortValue) {
connectionNotice =
locale === "zh-CN" ? "请先选择串口,再启动标定。" : "Please select a serial port before starting calibration.";
connectionNoticeTone = "warn";
return;
}
if (event.detail.methodId !== "coarse") {
connectionNotice =
locale === "zh-CN" ? "当前标定方法暂未接入后端。" : "Selected calibration method is not wired to backend yet.";
connectionNoticeTone = "warn";
return;
}
try {
const result = await invoke<CalibrationInvokeResult>("serial_calibrate_with_coarse", {
port: serialPortValue,
targetFrames: defaultCalibrationTargetFrames,
maxRounds: targetRounds
});
if (result.success) {
connectionNotice =
locale === "zh-CN"
? `粗标定已启动:目标 ${targetRounds} 轮(每轮 ${defaultCalibrationTargetFrames} 帧)`
: `Coarse calibration started: ${targetRounds} rounds (${defaultCalibrationTargetFrames} frames/round)`;
connectionNoticeTone = "ok";
} else {
connectionNotice = result.message;
connectionNoticeTone = "warn";
}
} catch (error) {
const fallback =
locale === "zh-CN" ? "启动粗标定失败,请检查串口连接状态。" : "Failed to start coarse calibration.";
connectionNotice = normalizeInvokeError(error) || fallback;
connectionNoticeTone = "warn";
console.error("Calibration start failed:", error);
}
} }
async function handleWindowControl(event: CustomEvent<WindowControlAction>): Promise<void> { async function handleWindowControl(event: CustomEvent<WindowControlAction>): Promise<void> {
@@ -1623,6 +1699,7 @@
{pressureMatrix} {pressureMatrix}
showConfigPanel={isConfigPanelOpen} showConfigPanel={isConfigPanelOpen}
showPrecisionTestPanel={isPrecisionTestOpen} showPrecisionTestPanel={isPrecisionTestOpen}
showCalibrationPanel={isCalibrationTestOpen}
{summary} {summary}
on:replaytoggle={handleReplayToggle} on:replaytoggle={handleReplayToggle}
on:replaystop={handleReplayStop} on:replaystop={handleReplayStop}
@@ -1630,8 +1707,10 @@
on:replayspeed={handleReplaySpeed} on:replayspeed={handleReplaySpeed}
on:replayclose={handleReplayClose} on:replayclose={handleReplayClose}
on:configclose={() => (isConfigPanelOpen = false)} on:configclose={() => (isConfigPanelOpen = false)}
on:calibrationclose={() => (isCalibrationTestOpen = false)}
on:calibrationstart={handleCalibrationStart}
> >
{#if !isPrecisionTestOpen} {#if !isPrecisionTestOpen && !isCalibrationTestOpen}
<section class="range-scale" aria-label="Signal Range"> <section class="range-scale" aria-label="Signal Range">
<p class="range-label">Range</p> <p class="range-label">Range</p>
<div class="range-track"> <div class="range-track">