diff --git a/src-tauri/program.log2026-04-06 b/src-tauri/program.log2026-04-06 new file mode 100644 index 0000000..60c7095 --- /dev/null +++ b/src-tauri/program.log2026-04-06 @@ -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 diff --git a/src-tauri/resource/round_finish_once.mp3 b/src-tauri/resource/round_finish_once.mp3 new file mode 100644 index 0000000..4a8b364 Binary files /dev/null and b/src-tauri/resource/round_finish_once.mp3 differ diff --git a/src-tauri/src/commands/calibration.rs b/src-tauri/src/commands/calibration.rs new file mode 100644 index 0000000..b8f868b --- /dev/null +++ b/src-tauri/src/commands/calibration.rs @@ -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, +} + +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 { + 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 { + 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 { + 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, + }) + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b5e49fa..a4d46c9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod calibration; pub mod file_explorer; pub mod serial; pub mod window; diff --git a/src-tauri/src/commands/serial.rs b/src-tauri/src/commands/serial.rs index 2441060..77d0dda 100644 --- a/src-tauri/src/commands/serial.rs +++ b/src-tauri/src/commands/serial.rs @@ -1,3 +1,4 @@ +use crate::serial_core::calibration_session::CalibrationSession; use crate::serial_core::codecs::tactile_a::{ export_recording_csv, TactileACodec, TactileACsvImporter, TactileAHandler, }; @@ -23,7 +24,6 @@ const DEFAULT_TACTILE_REPLY_TIMEOUT_MS: u64 = 140; type SharedTactileRecording = Arc>; - #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SerialConnectResponse { @@ -74,7 +74,8 @@ struct SerialSession { #[derive(Default)] pub struct SerialConnectionState { session: Mutex>, - last_record: Mutex> + last_record: Mutex>, + pub calibration_session: Mutex>, } #[tauri::command] @@ -176,7 +177,7 @@ pub async fn serial_connect( port: port_name.clone(), cancel, task, - current_record + current_record, }); Ok(SerialConnectResponse { @@ -190,6 +191,24 @@ pub async fn serial_connect( pub async fn serial_disconnect( state: State<'_, SerialConnectionState>, ) -> Result { + 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, SerialError> { let session = { let mut guard = state.session.lock().map_err(|_| SerialError::StateError)?; guard.take() @@ -202,18 +221,15 @@ pub async fn serial_disconnect( current_record, }) = session else { - return Ok(SerialConnectResponse { - port: String::new(), - connected: false, - message: "already disconnected".to_string(), - }); + return Ok(None); }; cancel.cancel(); let _ = task.await; - let frame_count = current_record.lock().map(|record| { - record.frames.len() - }).unwrap_or(0); + let frame_count = current_record + .lock() + .map(|record| record.frames.len()) + .unwrap_or(0); info!("last_record has {} frames", frame_count); @@ -221,12 +237,7 @@ pub async fn serial_disconnect( *last_record = Some(current_record); } - - Ok(SerialConnectResponse { - port, - connected: false, - message: "disconnected".to_string(), - }) + Ok(Some(port)) } #[tauri::command] @@ -290,7 +301,10 @@ pub fn serial_export_csv_to_path( } #[tauri::command] -pub fn serial_import_csv(file_name: String, csv_content: String) -> Result { +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())) @@ -347,7 +361,10 @@ fn resolve_record_for_export( 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) } @@ -368,7 +385,10 @@ fn snapshot_record_frame_count( .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 { return Ok(0); }; diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 8b40618..8e4ca13 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -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 { app.get_webview_window("main") @@ -25,8 +26,11 @@ pub fn win_toggle_maximize(app: AppHandle) -> Result<(), String> { } #[tauri::command] -pub fn win_close(app: AppHandle) -> Result<(), String> { - main_window(&app)? - .close() - .map_err(|error| error.to_string()) +pub async fn win_close(app: AppHandle, state: State<'_, SerialConnectionState>) -> Result<(), String> { + disconnect_active_session(state.inner()) + .await + .map_err(|error| error.to_string())?; + + app.exit(0); + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ffd5864..f6d5ef5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,9 @@ pub fn run() { commands::serial::serial_export_csv_to_path, commands::serial::serial_import_csv, 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_toggle_maximize, commands::window::win_close diff --git a/src-tauri/src/log.rs b/src-tauri/src/log.rs index 3ffb5be..e4a4fa5 100644 --- a/src-tauri/src/log.rs +++ b/src-tauri/src/log.rs @@ -1,12 +1,40 @@ use fern::{Dispatch, colors::{Color, ColoredLevelConfig}}; use log::{debug}; +use std::fs; +use std::path::PathBuf; 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() { let colors_line = ColoredLevelConfig::new() .error(Color::Red) .warn(Color::Yellow) .info(Color::Green) - .debug(Color::White) + .debug(Color::BrightBlue) .trace(Color::BrightBlack); let colors_level = colors_line.info(Color::Green); @@ -38,6 +66,9 @@ pub fn setup_logger() { // .apply() // .unwrap(); + let log_dir = ensure_log_dir(); + let log_file_base = log_dir.join("program.log"); + let file_config = fern::Dispatch::new() .format(move |out, message, record| { out.finish( @@ -51,7 +82,7 @@ pub fn setup_logger() { ); }) .level(level) - .chain(fern::DateBased::new("program.log", "%Y-%m-%d")); + .chain(fern::DateBased::new(log_file_base, "%Y-%m-%d")); Dispatch::new() .level(log::LevelFilter::Debug) diff --git a/src-tauri/src/serial_core/calibration_session.rs b/src-tauri/src/serial_core/calibration_session.rs new file mode 100644 index 0000000..04beb75 --- /dev/null +++ b/src-tauri/src/serial_core/calibration_session.rs @@ -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>, +} + +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) -> 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>>; diff --git a/src-tauri/src/serial_core/codecs/mod.rs b/src-tauri/src/serial_core/codecs/mod.rs index d4b0944..369948c 100644 --- a/src-tauri/src/serial_core/codecs/mod.rs +++ b/src-tauri/src/serial_core/codecs/mod.rs @@ -1,5 +1,6 @@ use crate::serial_core::{frame::TestFrame, record::Recording}; -pub mod test; + pub mod tactile_a; -pub type TestRecording = Recording; \ No newline at end of file +pub mod test; +pub type TestRecording = Recording; diff --git a/src-tauri/src/serial_core/codecs/tactile_a.rs b/src-tauri/src/serial_core/codecs/tactile_a.rs index 1c0e9f3..9f6f6f9 100644 --- a/src-tauri/src/serial_core/codecs/tactile_a.rs +++ b/src-tauri/src/serial_core/codecs/tactile_a.rs @@ -8,13 +8,15 @@ use crate::serial_core::{ codec::Codec, frame::{TactileAFrame, TactileAFrameStatusCode}, }; +use anyhow::anyhow; use async_trait::async_trait; use csv::StringRecord; -use anyhow::anyhow; -use std::io::Read; use log::debug; +use std::io::Read; +use std::os::raw; const FRAME_BUFFER_MIN_LENGTH: usize = 15; +const IGNOR_RAW_DATA_VAL: i32 = 10; pub struct TactileACodec { buffer: Vec, @@ -24,6 +26,7 @@ pub struct TactileACodec { pub struct TactileACsvExporter { channels: usize, + limit: Option, } pub struct TactileACsvImporter { @@ -77,7 +80,14 @@ impl TactileACodec { let vals: Vec = data .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::>(); Ok(vals) @@ -216,16 +226,15 @@ impl Codec for TactileACodec { req_bytes.push(f.meta.device_addr); req_bytes.push(f.meta.extend_code); req_bytes.push(f.meta.func_code); - + req_bytes.extend_from_slice(f.meta.start_addr.to_le_bytes().as_slice()); - req_bytes.extend_from_slice((f.meta.except_data_len as u16).to_le_bytes().as_slice()); + req_bytes + .extend_from_slice((f.meta.except_data_len as u16).to_le_bytes().as_slice()); let checksum = calc_crc8_itu(req_bytes.as_slice()); req_bytes.push(checksum); Ok(req_bytes) } - _ => { - Err(CodecError::InvalidFrameType) - } + _ => Err(CodecError::InvalidFrameType), } } } @@ -245,8 +254,18 @@ impl FrameHandler for TactileAHandler { } impl TactileACsvExporter { - fn new(channels: usize) -> Self { - TactileACsvExporter { channels } + pub fn new(channels: usize) -> Self { + 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 for TactileACsvExporter { fn csv_row( &self, item: &RecordedFrame, - ) -> anyhow::Result> { + ) -> anyhow::Result>> { let packet = TactileADataPacket::try_from(&item.frame)?; - let mut row: Vec = packet.data.iter().map(|x| x.to_string()).collect(); - row.push(packet.dts_ms.to_string()); - Ok(row) + if let Some(li) = self.limit { + if li > packet.data.iter().sum() { + Ok(None) + } else { + let mut row: Vec = packet.data.iter().map(|x| x.to_string()).collect(); + row.push(packet.dts_ms.to_string()); + Ok(Some(row)) + } + } else { + let mut row: Vec = 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 for TactileACsvExporter { header } - fn csv_row( - &self, - item: &RecordedFrame, - ) -> anyhow::Result> { + fn csv_row(&self, item: &RecordedFrame) -> anyhow::Result>> { let rep = match &item.frame { 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 mut row: Vec = packet.data.iter().map(|x| x.to_string()).collect(); - row.push(packet.dts_ms.to_string()); - Ok(row) + if let Some(li) = self.limit { + if li > packet.data.iter().sum() { + Ok(None) + } else { + let mut row: Vec = packet.data.iter().map(|x| x.to_string()).collect(); + row.push(packet.dts_ms.to_string()); + Ok(Some(row)) + } + } else { + let mut row: Vec = 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); 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::()?); } @@ -357,7 +397,10 @@ impl CsvImporter for TactileACsvImporter { } } -pub fn export_recording_csv(recording: &Recording, writer: W) -> anyhow::Result<()> +pub fn export_recording_csv( + recording: &Recording, + writer: W, +) -> anyhow::Result<()> where W: std::io::Write, { diff --git a/src-tauri/src/serial_core/codecs/test.rs b/src-tauri/src/serial_core/codecs/test.rs index ad4fc60..07ac193 100644 --- a/src-tauri/src/serial_core/codecs/test.rs +++ b/src-tauri/src/serial_core/codecs/test.rs @@ -1,15 +1,12 @@ -use std::io::Read; -use std::time::Instant; -use crate::serial_core::frame::{FrameHandler}; +use crate::serial_core::frame::FrameHandler; +use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording}; +use crate::serial_core::utils::{elapsed_millis, usize_to_u16_be_bytes}; use crate::serial_core::{codec::Codec, error::CodecError, frame::TestFrame}; use anyhow::anyhow; use async_trait::async_trait; use csv::StringRecord; -use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording}; -use crate::serial_core::utils::{ - elapsed_millis, - usize_to_u16_be_bytes -}; +use std::io::Read; +use std::time::Instant; pub struct TestCodec { buffer: Vec, } @@ -23,7 +20,11 @@ impl TestCodec { } impl Codec for TestCodec { - fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result, CodecError> { + fn decode( + &mut self, + input: &[u8], + session_started_at: Instant, + ) -> Result, CodecError> { self.buffer.extend_from_slice(input); let mut frames = Vec::new(); @@ -126,7 +127,7 @@ pub struct TestCsvImporter { #[derive(Clone)] pub struct TestDataPacket { pub data: Vec, - pub dts_ms: u64 + pub dts_ms: u64, } impl TryFrom<&TestFrame> for TestDataPacket { @@ -134,7 +135,10 @@ impl TryFrom<&TestFrame> for TestDataPacket { fn try_from(frame: &TestFrame) -> Result { let data = parse_data_frame(&frame.payload)?; let dts = frame.dts_ms; - Ok(TestDataPacket { data: data, dts_ms: dts }) + Ok(TestDataPacket { + data: data, + dts_ms: dts, + }) } } // impl From for TestDataPacket { @@ -145,14 +149,17 @@ impl TryFrom<&TestFrame> for TestDataPacket { // } // } - impl CsvExporter for TestCsvExporter { type Error = CodecError; fn csv_header(&self, recording: &Recording) -> Vec { let channel_nb = recording .frames .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); let mut header: Vec = Vec::new(); for i in 0..channel_nb { @@ -163,11 +170,11 @@ impl CsvExporter for TestCsvExporter { header } - fn csv_row(&self, item: &RecordedFrame) -> anyhow::Result> { + fn csv_row(&self, item: &RecordedFrame) -> anyhow::Result>> { let packet: TestDataPacket = TestDataPacket::try_from(&item.frame)?; let mut row: Vec = packet.data.iter().map(|&x| x.to_string()).collect(); 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{ + fn parse_record(&mut self, record: StringRecord) -> anyhow::Result { if self.channels == 0 { return Err(anyhow!("csv header is missing channel columns")); } @@ -191,7 +198,9 @@ impl TestCsvImporter { 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"))?; + let cell = record + .get(index) + .ok_or_else(|| anyhow!("missing channel cell"))?; data.push(cell.parse::()?); } @@ -226,7 +235,6 @@ impl CsvImporter for TestCsvImporter { } } - pub fn export_recording_csv(recording: &Recording, writer: W) -> anyhow::Result<()> where W: std::io::Write, diff --git a/src-tauri/src/serial_core/frame.rs b/src-tauri/src/serial_core/frame.rs index 42d23a6..610a051 100644 --- a/src-tauri/src/serial_core/frame.rs +++ b/src-tauri/src/serial_core/frame.rs @@ -1,16 +1,17 @@ use anyhow::Result; use async_trait::async_trait; -#[derive(Debug, Clone, PartialEq, Eq)] +use serde::Serialize; +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct TestFrame { pub header: [u8; 2], pub cmd: u8, pub length: usize, pub payload: Vec, 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 header: [u8; 2], pub payload_len: usize, @@ -25,33 +26,37 @@ pub struct TactileAFrameMetaData { // pub dts_ms: u64, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct TactileAReqFrame { pub meta: TactileAFrameMetaData, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct TactileARepFrame { pub meta: TactileAFrameMetaData, pub status: TactileAFrameStatusCode, pub payload: Vec, - pub dts_ms: u64 + pub dts_ms: u64, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum TactileAFrameStatusCode { Success, - Failure + Failure, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum TactileAFrame { Req(TactileAReqFrame), - Rep(TactileARepFrame) + Rep(TactileARepFrame), } +// TODO: filter +// pub trait FrameFilter { +// fn apply(&self) +// } + #[async_trait] pub trait FrameHandler: Send { async fn on_frame(&mut self, frame: &F) -> Result>>; } - diff --git a/src-tauri/src/serial_core/mod.rs b/src-tauri/src/serial_core/mod.rs index a3adf2c..b27817c 100644 --- a/src-tauri/src/serial_core/mod.rs +++ b/src-tauri/src/serial_core/mod.rs @@ -3,15 +3,15 @@ use crate::serial_core::{ record::Recording, }; +pub mod calibration_session; pub mod codec; pub mod codecs; pub mod error; pub mod frame; pub mod model; -pub mod serial; pub mod record; +pub mod serial; pub mod utils; - pub type TestRecording = Recording; pub type TactileARecording = Recording; diff --git a/src-tauri/src/serial_core/record.rs b/src-tauri/src/serial_core/record.rs index 7a20d35..e57a821 100644 --- a/src-tauri/src/serial_core/record.rs +++ b/src-tauri/src/serial_core/record.rs @@ -1,31 +1,49 @@ -#[derive(Clone)] +use serde::Serialize; + +#[derive(Clone, Serialize, Debug)] pub struct FrameTiming { pub pts_ms: Option, pub dts_ms: u64, } -#[derive(Clone)] +#[derive(Clone, Serialize, Debug)] pub struct RecordedFrame { pub timing: FrameTiming, - pub frame: F + pub frame: F, } #[derive(Clone, Default)] pub struct Recording { - pub frames: Vec> + pub frames: Vec>, + pub count: usize, + pub except_count: Option, } impl Recording { - pub fn new() -> Recording { Self { frames: Vec::new() } } + pub fn new() -> Recording { + Self { + frames: Vec::new(), + count: 0, + except_count: None, + } + } + pub fn with_except_count(except_count: usize) -> Recording { + Self { + frames: Vec::new(), + count: 0, + except_count: Some(except_count), + } + } pub fn push(&mut self, ite: RecordedFrame) { self.frames.push(ite); } + pub fn check_frame_need_record(ite: RecordedFrame) {} } pub trait CsvExporter { type Error: std::error::Error + Send + Sync + 'static; fn csv_header(&self, recording: &Recording) -> Vec; - fn csv_row(&self, item: &RecordedFrame) -> anyhow::Result>; + fn csv_row(&self, item: &RecordedFrame) -> anyhow::Result>>; } // TODO: CsvImporter @@ -33,11 +51,7 @@ pub trait CsvImporter

{ fn load(&mut self, reader: R) -> anyhow::Result>; } -pub fn write_csv( - recording: &Recording, - exporter: &E, - writer: W, -) -> anyhow::Result<()> +pub fn write_csv(recording: &Recording, exporter: &E, writer: W) -> anyhow::Result<()> where E: CsvExporter, W: std::io::Write, @@ -46,8 +60,9 @@ where let mut wrt = csv::Writer::from_writer(writer); wrt.write_record(header)?; for f in &recording.frames { - let row = exporter.csv_row(f)?; - wrt.write_record(&row)?; + if let Some(row) = exporter.csv_row(f)? { + wrt.write_record(&row)?; + } } wrt.flush()?; diff --git a/src-tauri/src/serial_core/serial.rs b/src-tauri/src/serial_core/serial.rs index 8308d90..3f54395 100644 --- a/src-tauri/src/serial_core/serial.rs +++ b/src-tauri/src/serial_core/serial.rs @@ -1,23 +1,29 @@ +use crate::serial_core::calibration_session::*; use crate::serial_core::codec::Codec; use crate::serial_core::codecs::tactile_a::TactileACodec; use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame}; use crate::serial_core::model::{HudChartState, HudPacket}; use crate::serial_core::record::Recording; +use crate::serial_core::record::{FrameTiming, RecordedFrame}; 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 tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::time::{self, Duration, MissedTickBehavior}; use tokio_serial::SerialStream; use tokio_util::sync::CancellationToken; -use std::future::pending; -use std::sync::{Arc, Mutex}; -use std::time::Instant; -use log::{info, debug}; -use crate::serial_core::record::{FrameTiming, RecordedFrame}; - +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; +use crate::serial_core::codecs::tactile_a::TactileAHandler; pub enum PollMode { Disable, - Enabled(Box>) + Enabled(Box>), } pub trait SerialFrame: Clone + Send + 'static { @@ -169,11 +175,19 @@ where F: SerialFrame, C: Codec + Send + 'static, H: FrameHandler + Send + 'static, - T: Into + T: Into, { run_serial_with_poll( - app, port, codec, handler, session_started_at, recording, cancel, PollMode::Disable - ).await + app, + port, + codec, + handler, + session_started_at, + recording, + cancel, + PollMode::Disable, + ) + .await } pub async fn run_serial_with_poll( @@ -184,7 +198,7 @@ pub async fn run_serial_with_poll( session_started_at: Instant, recording: Arc>>, cancel: CancellationToken, - poll_mode: PollMode + poll_mode: PollMode, ) -> Result<()> where F: SerialFrame, @@ -192,15 +206,13 @@ where H: FrameHandler + Send + 'static, T: Into, { + info!("run_serial_with_poll"); let mut requester = match poll_mode { PollMode::Disable => None, PollMode::Enabled(r) => Some(r), }; - let mut poll_interval = requester - .as_ref() - .and_then(|r| r.poll_interval()) - .map(|d| { + let mut poll_interval = requester.as_ref().and_then(|r| r.poll_interval()).map(|d| { let mut it = time::interval(d); it.set_missed_tick_behavior(MissedTickBehavior::Skip); it @@ -211,7 +223,6 @@ where let mut prune_interval = time::interval(Duration::from_millis(450)); prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); - loop { tokio::select! { _ = cancel.cancelled() => break, @@ -227,6 +238,7 @@ where if r.should_request() { if let Some(req) = r.next_request()? { let bytes = codec.encode(&req)?; + debug!("send {:02X?}", bytes); port.write_all(&bytes).await?; } } @@ -281,3 +293,155 @@ where } 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::>()); + + 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::(); + 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>>, +) -> 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(()) +} diff --git a/src/app.html b/src/app.html index 92e7e33..64c806e 100644 --- a/src/app.html +++ b/src/app.html @@ -2,7 +2,7 @@ - + Tauri + SvelteKit + Typescript App %sveltekit.head% diff --git a/src/lib/components/CenterStage.svelte b/src/lib/components/CenterStage.svelte index 5a316a1..20794ec 100644 --- a/src/lib/components/CenterStage.svelte +++ b/src/lib/components/CenterStage.svelte @@ -1,183 +1,274 @@

@@ -187,7 +278,7 @@ bind:this={stagePlaneEl} style="--panel-zone-top-dyn: {panelZoneTopPx}px; --rail-scale-left: {leftRailScale}; --rail-scale-right: {rightRailScale};" > - {#if !showPrecisionTestPanel} + {#if !showPrecisionTestPanel && !showCalibrationPanel}

WebGL2 Stage

@@ -234,6 +325,89 @@ />
+ {:else if showCalibrationPanel} +
+
+
+

{splitMatrixTitle}

+ {splitMatrixHint} +
+
+ {#key `${matrixRows}x${matrixCols}:${colorMapPreset}:calibration-split`} + + {/key} +
+
+ +
+
+
+

{locale === "zh-CN" ? "校准控制" : "Calibration Control"}

+ {calibrationPanelHint} +
+ +
+
+
+

{calibrationMethodLabel}

+
+ {#each calibrationMethodOptions as method (method.id)} +
+
+

{method.label}

+

{method.description}

+
+ +
+ + + handleCalibrationRoundsInput(event, method.id)} + on:input={(event) => + handleCalibrationRoundsInput(event, method.id)} + /> +
+ + +
+ {/each} +
+
+
+
+
{:else}
{#key `${matrixRows}x${matrixCols}:${colorMapPreset}`} @@ -250,149 +424,248 @@
{/if} - {#if showConfigPanel && !showPrecisionTestPanel} -
- dispatch("configclose")} - /> + {#if showConfigPanel && !showPrecisionTestPanel && !showCalibrationPanel} +
+ dispatch("configclose")} + /> +
+ {/if} + + {#if !showPrecisionTestPanel && !showCalibrationPanel} +
+ + + +
+ {/if} + + {#if replayHasData && !showPrecisionTestPanel && !showCalibrationPanel} + + {/if} + + {#if !showPrecisionTestPanel && !showCalibrationPanel} +
+ +
+ {/if}
- {/if} - - {#if !showPrecisionTestPanel} -
- - - -
- {/if} - - {#if replayHasData && !showPrecisionTestPanel} - - {/if} - - {#if !showPrecisionTestPanel} -
- -
- {/if} - - + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f3c47a0..686d5e8 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -40,6 +40,18 @@ dtsMs: number; } + type CalibrationMethodId = "coarse"; + + interface CalibrationStartPayload { + methodId: CalibrationMethodId; + rounds: number; + } + + interface CalibrationInvokeResult { + success: boolean; + message: string; + } + const copyByLocale: Record = { "zh-CN": { appName: "JE-Skin", @@ -159,6 +171,7 @@ const summaryPointsPerSeries = 42; const signalRenderTickMs = 1200; const replayDefaultFrameMs = 40; + const defaultCalibrationTargetFrames = 100; const showSignalPanels = false; const mockToneCycle: SignalTone[] = ["cyan", "lime", "orange", "violet", "gold", "rose"]; @@ -205,6 +218,7 @@ let activeConfigLinkId = "stream-on"; let isConfigPanelOpen = false; let isPrecisionTestOpen = false; + let isCalibrationTestOpen = false; let hasSignalData = false; let signalPanels: HudSignalPanel[] = buildInactivePanels(); let summary: HudSummary = buildEmptySummary(); @@ -233,7 +247,7 @@ let fileExplorerFileName = ""; $: uiCopy = copyByLocale[locale]; - $: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen, isPrecisionTestOpen); + $: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen, isPrecisionTestOpen, isCalibrationTestOpen); $: stageStatusText = webglStatusTone === "ok" ? uiCopy.runtimeReady : uiCopy.runtimeFallback; $: leftSignalPanels = signalPanels.filter((panel) => panel.side === "left"); $: rightSignalPanels = signalPanels.filter((panel) => panel.side === "right"); @@ -975,7 +989,8 @@ currentLocale: LocaleCode, activeId: string, isSettingsOpen: boolean, - isPrecisionOpen: boolean + isPrecisionOpen: boolean, + isCalibrationOpen: boolean ): HudConfigLink[] { const labels = currentLocale === "zh-CN" @@ -1011,7 +1026,7 @@ id: "calibrate", label: labels.calibrate, tone: "cyan", - active: activeId === "calibrate" + active: isCalibrationOpen }, { id: "precision-test", @@ -1460,19 +1475,80 @@ if (event.detail === "precision-test") { isPrecisionTestOpen = !isPrecisionTestOpen; isConfigPanelOpen = false; + isCalibrationTestOpen = false; + return; + } + + if (event.detail === "calibrate") { + isCalibrationTestOpen = !isCalibrationTestOpen; + isConfigPanelOpen = false; + isPrecisionTestOpen = false; return; } if (event.detail === "settings") { isPrecisionTestOpen = false; + isCalibrationTestOpen = false; isConfigPanelOpen = !isConfigPanelOpen; return; } isPrecisionTestOpen = false; + isCalibrationTestOpen = false; isConfigPanelOpen = false; activeConfigLinkId = event.detail; - console.info("[hud] config link clicked:", event.detail); + } + + async function handleCalibrationStart(event: CustomEvent): Promise { + 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("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): Promise { @@ -1623,6 +1699,7 @@ {pressureMatrix} showConfigPanel={isConfigPanelOpen} showPrecisionTestPanel={isPrecisionTestOpen} + showCalibrationPanel={isCalibrationTestOpen} {summary} on:replaytoggle={handleReplayToggle} on:replaystop={handleReplayStop} @@ -1630,8 +1707,10 @@ on:replayspeed={handleReplaySpeed} on:replayclose={handleReplayClose} on:configclose={() => (isConfigPanelOpen = false)} + on:calibrationclose={() => (isCalibrationTestOpen = false)} + on:calibrationstart={handleCalibrationStart} > - {#if !isPrecisionTestOpen} + {#if !isPrecisionTestOpen && !isCalibrationTestOpen}

Range