初步添加了标定支持,需要完善和测试
This commit is contained in:
19
src-tauri/program.log2026-04-06
Normal file
19
src-tauri/program.log2026-04-06
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[2026-04-06T07:28:34Z [37mDEBUG[0m tauri_demo_lib::log] logging initialized
|
||||||
|
[2026-04-06T07:28:34Z [37mDEBUG[0m JE_Skin] logging initialized
|
||||||
|
[2026-04-06T07:29:01Z [37mDEBUG[0m tauri_demo_lib::log] logging initialized
|
||||||
|
[2026-04-06T07:29:01Z [37mDEBUG[0m JE_Skin] logging initialized
|
||||||
|
[2026-04-06T07:29:25Z [37mDEBUG[0m tao::platform_impl::platform::event_loop::runner] NewEvents emitted without explicit RedrawEventsCleared
|
||||||
|
[2026-04-06T07:29:25Z [37mDEBUG[0m tao::platform_impl::platform::event_loop::runner] RedrawEventsCleared emitted without explicit MainEventsCleared
|
||||||
|
[2026-04-06T07:29:27Z [37mDEBUG[0m mio_serial] opening serial port in synchronous blocking mode
|
||||||
|
[2026-04-06T07:29:27Z [37mDEBUG[0m mio_serial] switching COM1 to asynchronous mode
|
||||||
|
[2026-04-06T07:29:27Z [37mDEBUG[0m mio_serial] reading serial port settings
|
||||||
|
[2026-04-06T07:29:27Z [37mDEBUG[0m mio_serial] closing synchronous port to re-open in FILE_FLAG_OVERLAPPED mode
|
||||||
|
[2026-04-06T07:29:27Z [37mDEBUG[0m mio_serial] re-setting serial port parameters to original values from synchronous port
|
||||||
|
[2026-04-06T07:29:36Z [37mDEBUG[0m tao::platform_impl::platform::event_loop::runner] NewEvents emitted without explicit RedrawEventsCleared
|
||||||
|
[2026-04-06T07:29:36Z [37mDEBUG[0m tao::platform_impl::platform::event_loop::runner] RedrawEventsCleared emitted without explicit MainEventsCleared
|
||||||
|
[2026-04-06T07:30:02Z [37mDEBUG[0m tauri_demo_lib::serial_core::codecs::tactile_a] unexpected payload length: expected 168, got 20746, buffer_len=178
|
||||||
|
[2026-04-06T07:30:07Z [37mDEBUG[0m tauri_demo_lib::serial_core::codecs::tactile_a] unexpected payload length: expected 168, got 20746, buffer_len=178
|
||||||
|
[2026-04-06T07:30:12Z [37mDEBUG[0m tao::platform_impl::platform::event_loop::runner] NewEvents emitted without explicit RedrawEventsCleared
|
||||||
|
[2026-04-06T07:30:12Z [37mDEBUG[0m tao::platform_impl::platform::event_loop::runner] RedrawEventsCleared emitted without explicit MainEventsCleared
|
||||||
|
[2026-04-06T07:30:14Z [37mDEBUG[0m tauri_demo_lib::log] logging initialized
|
||||||
|
[2026-04-06T07:30:14Z [37mDEBUG[0m JE_Skin] logging initialized
|
||||||
BIN
src-tauri/resource/round_finish_once.mp3
Normal file
BIN
src-tauri/resource/round_finish_once.mp3
Normal file
Binary file not shown.
150
src-tauri/src/commands/calibration.rs
Normal file
150
src-tauri/src/commands/calibration.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
109
src-tauri/src/serial_core/calibration_session.rs
Normal file
109
src-tauri/src/serial_core/calibration_session.rs
Normal 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>>>;
|
||||||
@@ -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 mod test;
|
||||||
pub type TestRecording = Recording<TestFrame>;
|
pub type TestRecording = Recording<TestFrame>;
|
||||||
@@ -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)
|
||||||
@@ -218,14 +228,13 @@ impl Codec<TactileAFrame> for TactileACodec {
|
|||||||
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)?;
|
||||||
|
if let Some(li) = self.limit {
|
||||||
|
if li > packet.data.iter().sum() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
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))
|
||||||
|
}
|
||||||
|
} 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)?;
|
||||||
|
if let Some(li) = self.limit {
|
||||||
|
if li > packet.data.iter().sum() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
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))
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +60,10 @@ 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()?;
|
||||||
|
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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%
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
HudSummary,
|
HudSummary,
|
||||||
LocaleCode,
|
LocaleCode,
|
||||||
PressureColorMapPreset,
|
PressureColorMapPreset,
|
||||||
StageStatusTone
|
StageStatusTone,
|
||||||
} from "$lib/types/hud";
|
} from "$lib/types/hud";
|
||||||
|
|
||||||
export let title = "";
|
export let title = "";
|
||||||
@@ -58,6 +58,15 @@
|
|||||||
export let replayFileName = "";
|
export let replayFileName = "";
|
||||||
export let replayFrameInfo = "";
|
export let replayFrameInfo = "";
|
||||||
export let showPrecisionTestPanel = false;
|
export let showPrecisionTestPanel = false;
|
||||||
|
export let showCalibrationPanel = false;
|
||||||
|
|
||||||
|
type CalibrationMethodId = "coarse";
|
||||||
|
|
||||||
|
interface CalibrationMethodOption {
|
||||||
|
id: CalibrationMethodId;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
let stagePlaneEl: HTMLDivElement | undefined;
|
let stagePlaneEl: HTMLDivElement | undefined;
|
||||||
let topOverlayEl: HTMLDivElement | undefined;
|
let topOverlayEl: HTMLDivElement | undefined;
|
||||||
@@ -70,10 +79,18 @@
|
|||||||
let rightRailScale = 1;
|
let rightRailScale = 1;
|
||||||
let summarySide: "left" | "right" = "left";
|
let summarySide: "left" | "right" = "left";
|
||||||
let replaySide: "left" | "right" = "right";
|
let replaySide: "left" | "right" = "right";
|
||||||
|
let calibrationRoundsByMethod: Record<CalibrationMethodId, number> = {
|
||||||
|
coarse: 3,
|
||||||
|
};
|
||||||
|
|
||||||
const minRailScale = 0.2;
|
const minRailScale = 0.2;
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
configclose: void;
|
configclose: void;
|
||||||
|
calibrationclose: void;
|
||||||
|
calibrationstart: {
|
||||||
|
methodId: CalibrationMethodId;
|
||||||
|
rounds: number;
|
||||||
|
};
|
||||||
replaytoggle: void;
|
replaytoggle: void;
|
||||||
replaystop: void;
|
replaystop: void;
|
||||||
replayseek: number;
|
replayseek: number;
|
||||||
@@ -83,10 +100,32 @@
|
|||||||
|
|
||||||
$: summarySide = leftPanels.length <= rightPanels.length ? "left" : "right";
|
$: summarySide = leftPanels.length <= rightPanels.length ? "left" : "right";
|
||||||
$: replaySide = summarySide === "left" ? "right" : "left";
|
$: replaySide = summarySide === "left" ? "right" : "left";
|
||||||
$: replayToggleButtonText = replayIsPlaying ? replayPauseLabel : replayPlayLabel;
|
$: replayToggleButtonText = replayIsPlaying
|
||||||
$: replayProgressPercent = Math.round(Math.min(1, Math.max(0, replayProgress)) * 100);
|
? replayPauseLabel
|
||||||
|
: replayPlayLabel;
|
||||||
|
$: replayProgressPercent = Math.round(
|
||||||
|
Math.min(1, Math.max(0, replayProgress)) * 100,
|
||||||
|
);
|
||||||
$: splitMatrixTitle = locale === "zh-CN" ? "数字矩阵" : "Matrix";
|
$: splitMatrixTitle = locale === "zh-CN" ? "数字矩阵" : "Matrix";
|
||||||
$: splitMatrixHint = locale === "zh-CN" ? "实时压力数据 / 数字矩阵" : "Live pressure matrix";
|
$: splitMatrixHint =
|
||||||
|
locale === "zh-CN" ? "实时压力数据 / 数字矩阵" : "Live pressure matrix";
|
||||||
|
$: calibrationMethodLabel = locale === "zh-CN" ? "标定方法" : "Calibration Method";
|
||||||
|
$: calibrationRoundsLabel = locale === "zh-CN" ? "期望标定轮次" : "Target Rounds";
|
||||||
|
$: calibrationStartLabel = locale === "zh-CN" ? "启动标定" : "Start Calibration";
|
||||||
|
$: calibrationPanelHint =
|
||||||
|
locale === "zh-CN"
|
||||||
|
? "先选择标定方法并设置轮次,再启动标定。"
|
||||||
|
: "Select a calibration method, set target rounds, then start.";
|
||||||
|
$: calibrationMethodOptions = [
|
||||||
|
{
|
||||||
|
id: "coarse",
|
||||||
|
label: locale === "zh-CN" ? "粗标定" : "Coarse Calibration",
|
||||||
|
description:
|
||||||
|
locale === "zh-CN"
|
||||||
|
? "快速分轮采样,适合初步校准。"
|
||||||
|
: "Fast multi-round sampling for initial calibration.",
|
||||||
|
},
|
||||||
|
] satisfies CalibrationMethodOption[];
|
||||||
|
|
||||||
function toPxNumber(rawValue: string): number {
|
function toPxNumber(rawValue: string): number {
|
||||||
const value = Number.parseFloat(rawValue);
|
const value = Number.parseFloat(rawValue);
|
||||||
@@ -114,14 +153,32 @@
|
|||||||
const planeRect = stagePlaneEl.getBoundingClientRect();
|
const planeRect = stagePlaneEl.getBoundingClientRect();
|
||||||
const overlayRect = topOverlayEl.getBoundingClientRect();
|
const overlayRect = topOverlayEl.getBoundingClientRect();
|
||||||
const overlayBottom = overlayRect.bottom - planeRect.top;
|
const overlayBottom = overlayRect.bottom - planeRect.top;
|
||||||
const upperTopLimit = Math.max(72, Math.round(stagePlaneEl.clientHeight * 0.34));
|
const upperTopLimit = Math.max(
|
||||||
panelZoneTopPx = clamp(Math.round(overlayBottom + 8), 56, upperTopLimit);
|
72,
|
||||||
|
Math.round(stagePlaneEl.clientHeight * 0.34),
|
||||||
|
);
|
||||||
|
panelZoneTopPx = clamp(
|
||||||
|
Math.round(overlayBottom + 8),
|
||||||
|
56,
|
||||||
|
upperTopLimit,
|
||||||
|
);
|
||||||
|
|
||||||
const panelZoneBottomPx = panelZoneEl ? toPxNumber(getComputedStyle(panelZoneEl).bottom) : 0;
|
const panelZoneBottomPx = panelZoneEl
|
||||||
const zoneHeight = Math.max(0, stagePlaneEl.clientHeight - panelZoneTopPx - panelZoneBottomPx);
|
? toPxNumber(getComputedStyle(panelZoneEl).bottom)
|
||||||
|
: 0;
|
||||||
|
const zoneHeight = Math.max(
|
||||||
|
0,
|
||||||
|
stagePlaneEl.clientHeight - panelZoneTopPx - panelZoneBottomPx,
|
||||||
|
);
|
||||||
|
|
||||||
leftRailScale = calculateScale(zoneHeight, leftStackEl?.scrollHeight ?? 0);
|
leftRailScale = calculateScale(
|
||||||
rightRailScale = calculateScale(zoneHeight, rightStackEl?.scrollHeight ?? 0);
|
zoneHeight,
|
||||||
|
leftStackEl?.scrollHeight ?? 0,
|
||||||
|
);
|
||||||
|
rightRailScale = calculateScale(
|
||||||
|
zoneHeight,
|
||||||
|
rightStackEl?.scrollHeight ?? 0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitReplayToggle(): void {
|
function emitReplayToggle(): void {
|
||||||
@@ -148,6 +205,40 @@
|
|||||||
dispatch("replayclose");
|
dispatch("replayclose");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCalibrationRounds(value: number): number {
|
||||||
|
const safeValue = Number.isFinite(value) ? Math.round(value) : 1;
|
||||||
|
return clamp(safeValue, 1, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calibrationRounds(methodId: CalibrationMethodId): number {
|
||||||
|
return calibrationRoundsByMethod[methodId] ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCalibrationRoundsInput(
|
||||||
|
event: Event,
|
||||||
|
methodId: CalibrationMethodId,
|
||||||
|
): void {
|
||||||
|
const target = event.currentTarget as HTMLInputElement;
|
||||||
|
const nextValue = Number(target.value);
|
||||||
|
calibrationRoundsByMethod = {
|
||||||
|
...calibrationRoundsByMethod,
|
||||||
|
[methodId]: normalizeCalibrationRounds(nextValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitCalibrationStart(methodId: CalibrationMethodId): void {
|
||||||
|
const rounds = normalizeCalibrationRounds(calibrationRounds(methodId));
|
||||||
|
calibrationRoundsByMethod = {
|
||||||
|
...calibrationRoundsByMethod,
|
||||||
|
[methodId]: rounds,
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch("calibrationstart", {
|
||||||
|
methodId,
|
||||||
|
rounds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
recomputePanelLayout();
|
recomputePanelLayout();
|
||||||
|
|
||||||
@@ -187,7 +278,7 @@
|
|||||||
bind:this={stagePlaneEl}
|
bind:this={stagePlaneEl}
|
||||||
style="--panel-zone-top-dyn: {panelZoneTopPx}px; --rail-scale-left: {leftRailScale}; --rail-scale-right: {rightRailScale};"
|
style="--panel-zone-top-dyn: {panelZoneTopPx}px; --rail-scale-left: {leftRailScale}; --rail-scale-right: {rightRailScale};"
|
||||||
>
|
>
|
||||||
{#if !showPrecisionTestPanel}
|
{#if !showPrecisionTestPanel && !showCalibrationPanel}
|
||||||
<div class="stage-top-overlay" bind:this={topOverlayEl}>
|
<div class="stage-top-overlay" bind:this={topOverlayEl}>
|
||||||
<div class="stage-meta">
|
<div class="stage-meta">
|
||||||
<p class="meta-label">WebGL2 Stage</p>
|
<p class="meta-label">WebGL2 Stage</p>
|
||||||
@@ -234,6 +325,89 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if showCalibrationPanel}
|
||||||
|
<div class="split-calibration-wrap">
|
||||||
|
<section class="split-panel split-matrix-panel">
|
||||||
|
<header class="split-panel-head">
|
||||||
|
<p>{splitMatrixTitle}</p>
|
||||||
|
<span>{splitMatrixHint}</span>
|
||||||
|
</header>
|
||||||
|
<div class="split-panel-body">
|
||||||
|
{#key `${matrixRows}x${matrixCols}:${colorMapPreset}:calibration-split`}
|
||||||
|
<PressureMatrixViewer
|
||||||
|
{pressureMatrix}
|
||||||
|
{matrixRows}
|
||||||
|
{matrixCols}
|
||||||
|
{rangeMin}
|
||||||
|
{rangeMax}
|
||||||
|
{colorMapPreset}
|
||||||
|
showStatsPanel={true}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="split-panel split-calibration-panel">
|
||||||
|
<header class="split-panel-head is-interactive">
|
||||||
|
<div class="split-panel-title">
|
||||||
|
<p>{locale === "zh-CN" ? "校准控制" : "Calibration Control"}</p>
|
||||||
|
<span>{calibrationPanelHint}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="split-close-btn"
|
||||||
|
on:click={() => dispatch("calibrationclose")}
|
||||||
|
aria-label={locale === "zh-CN" ? "关闭校准" : "Close calibration"}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="split-panel-body calibration-panel-body">
|
||||||
|
<div class="calibration-content">
|
||||||
|
<p class="calibration-label">{calibrationMethodLabel}</p>
|
||||||
|
<div class="calibration-method-list">
|
||||||
|
{#each calibrationMethodOptions as method (method.id)}
|
||||||
|
<section class="calibration-method-row">
|
||||||
|
<div class="calibration-method-main">
|
||||||
|
<p class="calibration-method-name">{method.label}</p>
|
||||||
|
<p class="calibration-method-desc">{method.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calibration-param-group">
|
||||||
|
<label
|
||||||
|
class="calibration-label"
|
||||||
|
for={`calibration-rounds-${method.id}`}
|
||||||
|
>
|
||||||
|
{calibrationRoundsLabel}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`calibration-rounds-${method.id}`}
|
||||||
|
class="calibration-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value={calibrationRounds(method.id)}
|
||||||
|
on:change={(event) =>
|
||||||
|
handleCalibrationRoundsInput(event, method.id)}
|
||||||
|
on:input={(event) =>
|
||||||
|
handleCalibrationRoundsInput(event, method.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="calibration-button"
|
||||||
|
on:click={() => emitCalibrationStart(method.id)}
|
||||||
|
>
|
||||||
|
{calibrationStartLabel}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="canvas-wrap">
|
<div class="canvas-wrap">
|
||||||
{#key `${matrixRows}x${matrixCols}:${colorMapPreset}`}
|
{#key `${matrixRows}x${matrixCols}:${colorMapPreset}`}
|
||||||
@@ -250,7 +424,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showConfigPanel && !showPrecisionTestPanel}
|
{#if showConfigPanel && !showPrecisionTestPanel && !showCalibrationPanel}
|
||||||
<div class="config-panel-wrap">
|
<div class="config-panel-wrap">
|
||||||
<ConfigPanel
|
<ConfigPanel
|
||||||
bind:matrixRows
|
bind:matrixRows
|
||||||
@@ -275,7 +449,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !showPrecisionTestPanel}
|
{#if !showPrecisionTestPanel && !showCalibrationPanel}
|
||||||
<div class="panel-zone" bind:this={panelZoneEl}>
|
<div class="panel-zone" bind:this={panelZoneEl}>
|
||||||
<aside class="side-rail left-rail">
|
<aside class="side-rail left-rail">
|
||||||
<div class="rail-stack" bind:this={leftStackEl}>
|
<div class="rail-stack" bind:this={leftStackEl}>
|
||||||
@@ -283,8 +457,18 @@
|
|||||||
<div
|
<div
|
||||||
class="panel-motion-shell"
|
class="panel-motion-shell"
|
||||||
animate:flip={{ duration: 280 }}
|
animate:flip={{ duration: 280 }}
|
||||||
in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }}
|
in:fly={{
|
||||||
out:fly={{ x: -180, duration: 280, opacity: 0.06, easing: cubicIn }}
|
x: -180,
|
||||||
|
duration: 340,
|
||||||
|
opacity: 0.08,
|
||||||
|
easing: cubicOut,
|
||||||
|
}}
|
||||||
|
out:fly={{
|
||||||
|
x: -180,
|
||||||
|
duration: 280,
|
||||||
|
opacity: 0.06,
|
||||||
|
easing: cubicIn,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SignalChart {panel} panelIndex={index} />
|
<SignalChart {panel} panelIndex={index} />
|
||||||
</div>
|
</div>
|
||||||
@@ -293,8 +477,18 @@
|
|||||||
{#if summary.points.length > 0 && summarySide === "left"}
|
{#if summary.points.length > 0 && summarySide === "left"}
|
||||||
<div
|
<div
|
||||||
class="panel-motion-shell"
|
class="panel-motion-shell"
|
||||||
in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }}
|
in:fly={{
|
||||||
out:fly={{ x: -180, duration: 280, opacity: 0.06, easing: cubicIn }}
|
x: -180,
|
||||||
|
duration: 340,
|
||||||
|
opacity: 0.08,
|
||||||
|
easing: cubicOut,
|
||||||
|
}}
|
||||||
|
out:fly={{
|
||||||
|
x: -180,
|
||||||
|
duration: 280,
|
||||||
|
opacity: 0.06,
|
||||||
|
easing: cubicIn,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SummaryCurve
|
<SummaryCurve
|
||||||
{summary}
|
{summary}
|
||||||
@@ -314,8 +508,18 @@
|
|||||||
<div
|
<div
|
||||||
class="panel-motion-shell"
|
class="panel-motion-shell"
|
||||||
animate:flip={{ duration: 280 }}
|
animate:flip={{ duration: 280 }}
|
||||||
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
|
in:fly={{
|
||||||
out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }}
|
x: 180,
|
||||||
|
duration: 340,
|
||||||
|
opacity: 0.08,
|
||||||
|
easing: cubicOut,
|
||||||
|
}}
|
||||||
|
out:fly={{
|
||||||
|
x: 180,
|
||||||
|
duration: 280,
|
||||||
|
opacity: 0.06,
|
||||||
|
easing: cubicIn,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SignalChart {panel} panelIndex={index} />
|
<SignalChart {panel} panelIndex={index} />
|
||||||
</div>
|
</div>
|
||||||
@@ -324,8 +528,18 @@
|
|||||||
{#if summary.points.length > 0 && summarySide === "right"}
|
{#if summary.points.length > 0 && summarySide === "right"}
|
||||||
<div
|
<div
|
||||||
class="panel-motion-shell"
|
class="panel-motion-shell"
|
||||||
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
|
in:fly={{
|
||||||
out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }}
|
x: 180,
|
||||||
|
duration: 340,
|
||||||
|
opacity: 0.08,
|
||||||
|
easing: cubicOut,
|
||||||
|
}}
|
||||||
|
out:fly={{
|
||||||
|
x: 180,
|
||||||
|
duration: 280,
|
||||||
|
opacity: 0.06,
|
||||||
|
easing: cubicIn,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SummaryCurve
|
<SummaryCurve
|
||||||
{summary}
|
{summary}
|
||||||
@@ -341,28 +555,56 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if replayHasData && !showPrecisionTestPanel}
|
{#if replayHasData && !showPrecisionTestPanel && !showCalibrationPanel}
|
||||||
<aside class="replay-floating-panel" class:is-left={replaySide === "left"} class:is-right={replaySide === "right"}>
|
<aside
|
||||||
|
class="replay-floating-panel"
|
||||||
|
class:is-left={replaySide === "left"}
|
||||||
|
class:is-right={replaySide === "right"}
|
||||||
|
>
|
||||||
<div class="replay-panel-head">
|
<div class="replay-panel-head">
|
||||||
<div class="replay-panel-title-group">
|
<div class="replay-panel-title-group">
|
||||||
<p class="replay-panel-label">{replaySectionLabel}</p>
|
<p class="replay-panel-label">
|
||||||
<p class="replay-panel-file" title={replayFileName}>{replayFileName}</p>
|
{replaySectionLabel}
|
||||||
|
</p>
|
||||||
|
<p class="replay-panel-file" title={replayFileName}>
|
||||||
|
{replayFileName}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="replay-panel-head-actions">
|
<div class="replay-panel-head-actions">
|
||||||
{#if replayFrameInfo}
|
{#if replayFrameInfo}
|
||||||
<p class="replay-panel-frame">{replayFrameInfo}</p>
|
<p class="replay-panel-frame">
|
||||||
|
{replayFrameInfo}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="button" class="replay-close-btn" aria-label="Close replay" on:click={emitReplayClose}>×</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="replay-close-btn"
|
||||||
|
aria-label="Close replay"
|
||||||
|
on:click={emitReplayClose}>×</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="replay-panel-controls">
|
<div class="replay-panel-controls">
|
||||||
<div class="replay-panel-actions">
|
<div class="replay-panel-actions">
|
||||||
<button type="button" class="replay-action-btn" on:click={emitReplayToggle}>{replayToggleButtonText}</button>
|
<button
|
||||||
<button type="button" class="replay-action-btn is-stop" on:click={emitReplayStop}>{replayStopLabel}</button>
|
type="button"
|
||||||
|
class="replay-action-btn"
|
||||||
|
on:click={emitReplayToggle}
|
||||||
|
>{replayToggleButtonText}</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="replay-action-btn is-stop"
|
||||||
|
on:click={emitReplayStop}
|
||||||
|
>{replayStopLabel}</button
|
||||||
|
>
|
||||||
<label class="replay-speed-select">
|
<label class="replay-speed-select">
|
||||||
<span>{replaySpeedLabel}</span>
|
<span>{replaySpeedLabel}</span>
|
||||||
<select value={replaySpeed} on:change={emitReplaySpeed}>
|
<select
|
||||||
|
value={replaySpeed}
|
||||||
|
on:change={emitReplaySpeed}
|
||||||
|
>
|
||||||
<option value={0.5}>0.5x</option>
|
<option value={0.5}>0.5x</option>
|
||||||
<option value={1}>1x</option>
|
<option value={1}>1x</option>
|
||||||
<option value={1.5}>1.5x</option>
|
<option value={1.5}>1.5x</option>
|
||||||
@@ -373,13 +615,20 @@
|
|||||||
|
|
||||||
<label class="replay-progress-slider">
|
<label class="replay-progress-slider">
|
||||||
<span>{replayProgressLabel}</span>
|
<span>{replayProgressLabel}</span>
|
||||||
<input type="range" min="0" max="100" step="1" value={replayProgressPercent} on:input={emitReplaySeek} />
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value={replayProgressPercent}
|
||||||
|
on:input={emitReplaySeek}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !showPrecisionTestPanel}
|
{#if !showPrecisionTestPanel && !showCalibrationPanel}
|
||||||
<div class="stage-bottom-overlay">
|
<div class="stage-bottom-overlay">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
@@ -394,6 +643,30 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stage-shell {
|
||||||
|
position: relative;
|
||||||
|
block-size: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0.72rem;
|
||||||
|
border: 1px solid rgb(var(--hud-border-rgb) / 0.2);
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
170deg,
|
||||||
|
rgb(var(--hud-surface-rgb) / 0.86) 0%,
|
||||||
|
rgb(var(--hud-surface-deep-rgb) / 0.96) 58%,
|
||||||
|
rgb(var(--hud-surface-alt-rgb) / 0.9) 100%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
circle at 50% 0,
|
||||||
|
rgb(var(--hud-glow-rgb) / 0.04),
|
||||||
|
transparent 48%
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.08),
|
||||||
|
inset 0 -36px 72px rgb(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.stage-shell {
|
.stage-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
@@ -521,6 +794,129 @@
|
|||||||
gap: clamp(0.45rem, 1vw, 0.9rem);
|
gap: clamp(0.45rem, 1vw, 0.9rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.split-calibration-wrap {
|
||||||
|
position: absolute;
|
||||||
|
inset: clamp(0.46rem, 1vw, 0.82rem);
|
||||||
|
z-index: 6;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
|
||||||
|
gap: clamp(0.45rem, 1vw, 0.9rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-calibration-panel {
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-panel-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 2.1rem 0.2rem 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.66rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgb(var(--hud-text-dim-rgb) / 0.82);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-input {
|
||||||
|
inline-size: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: rgb(var(--hud-surface-rgb) / 0.8);
|
||||||
|
border: 1px solid rgb(var(--hud-border-rgb) / 0.3);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: rgb(var(--hud-text-main-rgb) / 0.96);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-input:focus {
|
||||||
|
border-color: rgb(var(--hud-cyan-rgb) / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-button {
|
||||||
|
min-inline-size: 8.3rem;
|
||||||
|
min-block-size: 2.45rem;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
background: linear-gradient(135deg, rgb(var(--hud-cyan-rgb) / 0.9), rgb(var(--hud-lime-rgb) / 0.9));
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: rgb(0 0 0 / 0.9);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-button:hover {
|
||||||
|
background: linear-gradient(135deg, rgb(var(--hud-cyan-rgb) / 1), rgb(var(--hud-lime-rgb) / 1));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0 20px rgb(var(--hud-cyan-rgb) / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-method-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-method-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(7.6rem, 9.2rem) auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.62rem;
|
||||||
|
padding: 0.64rem 0.66rem;
|
||||||
|
background: rgb(var(--hud-surface-rgb) / 0.56);
|
||||||
|
border: 1px solid rgb(var(--hud-border-rgb) / 0.25);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-method-row:hover {
|
||||||
|
background: rgb(var(--hud-surface-alt-rgb) / 0.8);
|
||||||
|
border-color: rgb(var(--hud-border-rgb) / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-method-main {
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-method-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgb(var(--hud-text-main-rgb) / 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-method-desc {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgb(var(--hud-text-dim-rgb) / 0.82);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-param-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.32rem;
|
||||||
|
}
|
||||||
|
|
||||||
.split-panel {
|
.split-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
@@ -546,6 +942,46 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.split-panel-head.is-interactive {
|
||||||
|
left: 0.52rem;
|
||||||
|
right: 0.52rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6rem;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-panel-title {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-close-btn {
|
||||||
|
inline-size: 1.74rem;
|
||||||
|
block-size: 1.74rem;
|
||||||
|
border: 1px solid rgb(var(--hud-orange-rgb) / 0.4);
|
||||||
|
border-radius: 0.32rem;
|
||||||
|
background: rgb(var(--hud-surface-deep-rgb) / 0.88);
|
||||||
|
color: rgb(var(--hud-orange-rgb) / 0.96);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 180ms ease,
|
||||||
|
box-shadow 180ms ease,
|
||||||
|
color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-close-btn:hover {
|
||||||
|
border-color: rgb(var(--hud-orange-rgb) / 0.64);
|
||||||
|
color: rgb(var(--hud-text-main-rgb) / 0.98);
|
||||||
|
box-shadow: 0 0 10px rgb(var(--hud-orange-rgb) / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
.split-panel-head p {
|
.split-panel-head p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: rgb(var(--hud-text-main-rgb) / 0.96);
|
color: rgb(var(--hud-text-main-rgb) / 0.96);
|
||||||
@@ -861,6 +1297,10 @@
|
|||||||
.split-game-wrap {
|
.split-game-wrap {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.split-calibration-wrap {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-height: 900px) {
|
@media (max-height: 900px) {
|
||||||
@@ -886,7 +1326,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.replay-floating-panel {
|
.replay-floating-panel {
|
||||||
top: calc(var(--panel-zone-top-dyn, var(--panel-zone-top)) + 0.1rem);
|
top: calc(
|
||||||
|
var(--panel-zone-top-dyn, var(--panel-zone-top)) + 0.1rem
|
||||||
|
);
|
||||||
padding: 0.58rem 0.64rem;
|
padding: 0.58rem 0.64rem;
|
||||||
gap: 0.44rem;
|
gap: 0.44rem;
|
||||||
}
|
}
|
||||||
@@ -894,7 +1336,9 @@
|
|||||||
|
|
||||||
@media (max-width: 920px) {
|
@media (max-width: 920px) {
|
||||||
.config-panel-wrap {
|
.config-panel-wrap {
|
||||||
left: calc(var(--rail-width) + var(--safe-gap) + var(--rail-edge-inset));
|
left: calc(
|
||||||
|
var(--rail-width) + var(--safe-gap) + var(--rail-edge-inset)
|
||||||
|
);
|
||||||
right: auto;
|
right: auto;
|
||||||
max-inline-size: min(21rem, 54vw);
|
max-inline-size: min(21rem, 54vw);
|
||||||
}
|
}
|
||||||
@@ -909,5 +1353,19 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: minmax(0, 0.92fr) minmax(0, 1.08fr);
|
grid-template-rows: minmax(0, 0.92fr) minmax(0, 1.08fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.split-calibration-wrap {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(0, 1fr) minmax(0, auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-method-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-button {
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user