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

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

@@ -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<Mutex<TactileARecording>>;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SerialConnectResponse {
@@ -74,7 +74,8 @@ struct SerialSession {
#[derive(Default)]
pub struct SerialConnectionState {
session: Mutex<Option<SerialSession>>,
last_record: Mutex<Option<SharedTactileRecording>>
last_record: Mutex<Option<SharedTactileRecording>>,
pub calibration_session: Mutex<Option<CalibrationSession>>,
}
#[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<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 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<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 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);
};

View File

@@ -1,4 +1,5 @@
use tauri::{AppHandle, Manager, WebviewWindow};
use crate::commands::serial::{disconnect_active_session, SerialConnectionState};
use tauri::{AppHandle, Manager, State, WebviewWindow};
fn main_window(app: &AppHandle) -> Result<WebviewWindow, String> {
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(())
}

View File

@@ -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

View File

@@ -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)

View File

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

View File

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

View File

@@ -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<u8>,
@@ -24,6 +26,7 @@ pub struct TactileACodec {
pub struct TactileACsvExporter {
channels: usize,
limit: Option<i32>,
}
pub struct TactileACsvImporter {
@@ -77,7 +80,14 @@ impl TactileACodec {
let vals: Vec<i32> = 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::<Vec<i32>>();
Ok(vals)
@@ -218,14 +228,13 @@ impl Codec<TactileAFrame> for TactileACodec {
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<TactileAFrame, i32> 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<TactileARepFrame> for TactileACsvExporter {
fn csv_row(
&self,
item: &RecordedFrame<TactileARepFrame>,
) -> anyhow::Result<Vec<String>> {
) -> anyhow::Result<Option<Vec<String>>> {
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();
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
}
fn csv_row(
&self,
item: &RecordedFrame<TactileAFrame>,
) -> anyhow::Result<Vec<String>> {
fn csv_row(&self, item: &RecordedFrame<TactileAFrame>) -> anyhow::Result<Option<Vec<String>>> {
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)?;
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();
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);
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>()?);
}
@@ -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
W: std::io::Write,
{

View File

@@ -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<u8>,
}
@@ -23,7 +20,11 @@ impl 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);
let mut frames = Vec::new();
@@ -126,7 +127,7 @@ pub struct TestCsvImporter {
#[derive(Clone)]
pub struct TestDataPacket {
pub data: Vec<i32>,
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<TestDataPacket, Self::Error> {
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<TestFrame> for TestDataPacket {
@@ -145,14 +149,17 @@ impl TryFrom<&TestFrame> for TestDataPacket {
// }
// }
impl CsvExporter<TestFrame> for TestCsvExporter {
type Error = CodecError;
fn csv_header(&self, recording: &Recording<TestFrame>) -> Vec<String> {
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<String> = Vec::new();
for i in 0..channel_nb {
@@ -163,11 +170,11 @@ impl CsvExporter<TestFrame> for TestCsvExporter {
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 mut row: Vec<String> = 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<TestDataPacket>{
fn parse_record(&mut self, record: StringRecord) -> anyhow::Result<TestDataPacket> {
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::<i32>()?);
}
@@ -226,7 +235,6 @@ impl CsvImporter<TestDataPacket> for TestCsvImporter {
}
}
pub fn export_recording_csv<W>(recording: &Recording<TestFrame>, writer: W) -> anyhow::Result<()>
where
W: std::io::Write,

View File

@@ -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<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 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<u8>,
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<F> {
// fn apply(&self)
// }
#[async_trait]
pub trait FrameHandler<F, T>: Send {
async fn on_frame(&mut self, frame: &F) -> Result<Option<Vec<T>>>;
}

View File

@@ -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<TestFrame>;
pub type TactileARecording = Recording<TactileAFrame>;

View File

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

View File

@@ -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<F> {
Disable,
Enabled(Box<dyn PollRequester<F>>)
Enabled(Box<dyn PollRequester<F>>),
}
pub trait SerialFrame: Clone + Send + 'static {
@@ -169,11 +175,19 @@ where
F: SerialFrame,
C: Codec<F> + Send + 'static,
H: FrameHandler<F, T> + Send + 'static,
T: Into<i32>
T: Into<i32>,
{
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<C, H, T, F>(
@@ -184,7 +198,7 @@ pub async fn run_serial_with_poll<C, H, T, F>(
session_started_at: Instant,
recording: Arc<Mutex<Recording<F>>>,
cancel: CancellationToken,
poll_mode: PollMode<F>
poll_mode: PollMode<F>,
) -> Result<()>
where
F: SerialFrame,
@@ -192,15 +206,13 @@ where
H: FrameHandler<F, T> + Send + 'static,
T: Into<i32>,
{
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::<Vec<i32>>());
let recorded_frame = RecordedFrame {
timing: FrameTiming { pts_ms: None, dts_ms: frame.dts_ms() },
frame: frame.clone(),
};
{
let mut record = recording
.lock()
.map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
record.push(recorded_frame.clone());
}
let display_values = if let Some(vals) = decode_res.as_ref() {
let summary = vals.iter().copied().sum::<i32>();
chart_state.record_summary(summary as f32);
chart_state.record_pressure_matrix(vals.as_slice());
Some(vec![summary])
} else {
None
};
if let Some(packet) = frame.to_hud_packet(&mut chart_state, display_values.as_deref()) {
app.emit("hud_stream", packet)?;
}
// 检查是否达到目标帧数
let should_export = calibration_session.add_frame(recorded_frame);
if should_export {
// 导出数据
export_calibration_data(&app, &calibration_session, &recording).await?;
// 发送语音提示(这里用事件代替,前端可以播放语音)
app.emit("calibration_voice_prompt", "请添加配重")?;
// 更新状态
calibration_session.export_completed();
if let Ok(mut record) = recording.lock() {
record.frames.clear();
}
}
}
}
}
}
Ok(())
}
use crate::serial_core::codecs::tactile_a::TactileACsvExporter;
use crate::serial_core::record::write_csv;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::Manager;
async fn export_calibration_data(
app: &AppHandle,
calibration_session: &CalibrationSession,
recording: &Arc<Mutex<Recording<TactileAFrame>>>,
) -> Result<()> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
let filename = format!(
"calibration_round{}_{}.csv",
calibration_session.current_round, timestamp
);
// 创建导出目录
let mut output_dir = match app.path().desktop_dir() {
Ok(path) => path,
Err(_) => std::env::current_dir()?,
};
output_dir.push("calibration_data");
std::fs::create_dir_all(&output_dir)?;
let output_path = output_dir.join(&filename);
let file = File::create(&output_path)?;
// 使用现有的导出逻辑
let recording_lock = recording
.lock()
.map_err(|_| anyhow::anyhow!("Recording poisoned"))?;
let exporter = TactileACsvExporter::with_coarse_calibration(
DEFAULT_TACTILE_COLS * DEFAULT_TACTILE_ROWS,
7 * 12 * 10,
);
write_csv(&recording_lock, &exporter, file)?;
info!("标定数据已导出到: {}", output_path.display());
Ok(())
}

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<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" />
<title>Tauri + SvelteKit + Typescript App</title>
%sveltekit.head%

View File

@@ -15,7 +15,7 @@
HudSummary,
LocaleCode,
PressureColorMapPreset,
StageStatusTone
StageStatusTone,
} from "$lib/types/hud";
export let title = "";
@@ -58,6 +58,15 @@
export let replayFileName = "";
export let replayFrameInfo = "";
export let showPrecisionTestPanel = false;
export let showCalibrationPanel = false;
type CalibrationMethodId = "coarse";
interface CalibrationMethodOption {
id: CalibrationMethodId;
label: string;
description: string;
}
let stagePlaneEl: HTMLDivElement | undefined;
let topOverlayEl: HTMLDivElement | undefined;
@@ -70,10 +79,18 @@
let rightRailScale = 1;
let summarySide: "left" | "right" = "left";
let replaySide: "left" | "right" = "right";
let calibrationRoundsByMethod: Record<CalibrationMethodId, number> = {
coarse: 3,
};
const minRailScale = 0.2;
const dispatch = createEventDispatcher<{
configclose: void;
calibrationclose: void;
calibrationstart: {
methodId: CalibrationMethodId;
rounds: number;
};
replaytoggle: void;
replaystop: void;
replayseek: number;
@@ -83,10 +100,32 @@
$: summarySide = leftPanels.length <= rightPanels.length ? "left" : "right";
$: replaySide = summarySide === "left" ? "right" : "left";
$: replayToggleButtonText = replayIsPlaying ? replayPauseLabel : replayPlayLabel;
$: replayProgressPercent = Math.round(Math.min(1, Math.max(0, replayProgress)) * 100);
$: replayToggleButtonText = replayIsPlaying
? replayPauseLabel
: replayPlayLabel;
$: replayProgressPercent = Math.round(
Math.min(1, Math.max(0, replayProgress)) * 100,
);
$: 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 {
const value = Number.parseFloat(rawValue);
@@ -114,14 +153,32 @@
const planeRect = stagePlaneEl.getBoundingClientRect();
const overlayRect = topOverlayEl.getBoundingClientRect();
const overlayBottom = overlayRect.bottom - planeRect.top;
const upperTopLimit = Math.max(72, Math.round(stagePlaneEl.clientHeight * 0.34));
panelZoneTopPx = clamp(Math.round(overlayBottom + 8), 56, upperTopLimit);
const upperTopLimit = Math.max(
72,
Math.round(stagePlaneEl.clientHeight * 0.34),
);
panelZoneTopPx = clamp(
Math.round(overlayBottom + 8),
56,
upperTopLimit,
);
const panelZoneBottomPx = panelZoneEl ? toPxNumber(getComputedStyle(panelZoneEl).bottom) : 0;
const zoneHeight = Math.max(0, stagePlaneEl.clientHeight - panelZoneTopPx - panelZoneBottomPx);
const panelZoneBottomPx = panelZoneEl
? toPxNumber(getComputedStyle(panelZoneEl).bottom)
: 0;
const zoneHeight = Math.max(
0,
stagePlaneEl.clientHeight - panelZoneTopPx - panelZoneBottomPx,
);
leftRailScale = calculateScale(zoneHeight, leftStackEl?.scrollHeight ?? 0);
rightRailScale = calculateScale(zoneHeight, rightStackEl?.scrollHeight ?? 0);
leftRailScale = calculateScale(
zoneHeight,
leftStackEl?.scrollHeight ?? 0,
);
rightRailScale = calculateScale(
zoneHeight,
rightStackEl?.scrollHeight ?? 0,
);
}
function emitReplayToggle(): void {
@@ -148,6 +205,40 @@
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(() => {
recomputePanelLayout();
@@ -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}
<div class="stage-top-overlay" bind:this={topOverlayEl}>
<div class="stage-meta">
<p class="meta-label">WebGL2 Stage</p>
@@ -234,6 +325,89 @@
/>
</section>
</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}
<div class="canvas-wrap">
{#key `${matrixRows}x${matrixCols}:${colorMapPreset}`}
@@ -250,7 +424,7 @@
</div>
{/if}
{#if showConfigPanel && !showPrecisionTestPanel}
{#if showConfigPanel && !showPrecisionTestPanel && !showCalibrationPanel}
<div class="config-panel-wrap">
<ConfigPanel
bind:matrixRows
@@ -275,7 +449,7 @@
</div>
{/if}
{#if !showPrecisionTestPanel}
{#if !showPrecisionTestPanel && !showCalibrationPanel}
<div class="panel-zone" bind:this={panelZoneEl}>
<aside class="side-rail left-rail">
<div class="rail-stack" bind:this={leftStackEl}>
@@ -283,8 +457,18 @@
<div
class="panel-motion-shell"
animate:flip={{ duration: 280 }}
in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: -180, duration: 280, opacity: 0.06, easing: cubicIn }}
in:fly={{
x: -180,
duration: 340,
opacity: 0.08,
easing: cubicOut,
}}
out:fly={{
x: -180,
duration: 280,
opacity: 0.06,
easing: cubicIn,
}}
>
<SignalChart {panel} panelIndex={index} />
</div>
@@ -293,8 +477,18 @@
{#if summary.points.length > 0 && summarySide === "left"}
<div
class="panel-motion-shell"
in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: -180, duration: 280, opacity: 0.06, easing: cubicIn }}
in:fly={{
x: -180,
duration: 340,
opacity: 0.08,
easing: cubicOut,
}}
out:fly={{
x: -180,
duration: 280,
opacity: 0.06,
easing: cubicIn,
}}
>
<SummaryCurve
{summary}
@@ -314,8 +508,18 @@
<div
class="panel-motion-shell"
animate:flip={{ duration: 280 }}
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }}
in:fly={{
x: 180,
duration: 340,
opacity: 0.08,
easing: cubicOut,
}}
out:fly={{
x: 180,
duration: 280,
opacity: 0.06,
easing: cubicIn,
}}
>
<SignalChart {panel} panelIndex={index} />
</div>
@@ -324,8 +528,18 @@
{#if summary.points.length > 0 && summarySide === "right"}
<div
class="panel-motion-shell"
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }}
in:fly={{
x: 180,
duration: 340,
opacity: 0.08,
easing: cubicOut,
}}
out:fly={{
x: 180,
duration: 280,
opacity: 0.06,
easing: cubicIn,
}}
>
<SummaryCurve
{summary}
@@ -341,28 +555,56 @@
</div>
{/if}
{#if replayHasData && !showPrecisionTestPanel}
<aside class="replay-floating-panel" class:is-left={replaySide === "left"} class:is-right={replaySide === "right"}>
{#if replayHasData && !showPrecisionTestPanel && !showCalibrationPanel}
<aside
class="replay-floating-panel"
class:is-left={replaySide === "left"}
class:is-right={replaySide === "right"}
>
<div class="replay-panel-head">
<div class="replay-panel-title-group">
<p class="replay-panel-label">{replaySectionLabel}</p>
<p class="replay-panel-file" title={replayFileName}>{replayFileName}</p>
<p class="replay-panel-label">
{replaySectionLabel}
</p>
<p class="replay-panel-file" title={replayFileName}>
{replayFileName}
</p>
</div>
<div class="replay-panel-head-actions">
{#if replayFrameInfo}
<p class="replay-panel-frame">{replayFrameInfo}</p>
<p class="replay-panel-frame">
{replayFrameInfo}
</p>
{/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 class="replay-panel-controls">
<div class="replay-panel-actions">
<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>
<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">
<span>{replaySpeedLabel}</span>
<select value={replaySpeed} on:change={emitReplaySpeed}>
<select
value={replaySpeed}
on:change={emitReplaySpeed}
>
<option value={0.5}>0.5x</option>
<option value={1}>1x</option>
<option value={1.5}>1.5x</option>
@@ -373,13 +615,20 @@
<label class="replay-progress-slider">
<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>
</div>
</aside>
{/if}
{#if !showPrecisionTestPanel}
{#if !showPrecisionTestPanel && !showCalibrationPanel}
<div class="stage-bottom-overlay">
<slot />
</div>
@@ -394,6 +643,30 @@
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 {
position: relative;
block-size: 100%;
@@ -521,6 +794,129 @@
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 {
position: relative;
min-block-size: 0;
@@ -546,6 +942,46 @@
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 {
margin: 0;
color: rgb(var(--hud-text-main-rgb) / 0.96);
@@ -861,6 +1297,10 @@
.split-game-wrap {
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) {
@@ -886,7 +1326,9 @@
}
.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;
gap: 0.44rem;
}
@@ -894,7 +1336,9 @@
@media (max-width: 920px) {
.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;
max-inline-size: min(21rem, 54vw);
}
@@ -909,5 +1353,19 @@
grid-template-columns: 1fr;
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>

View File

@@ -40,6 +40,18 @@
dtsMs: number;
}
type CalibrationMethodId = "coarse";
interface CalibrationStartPayload {
methodId: CalibrationMethodId;
rounds: number;
}
interface CalibrationInvokeResult {
success: boolean;
message: string;
}
const copyByLocale: Record<LocaleCode, HudCopy> = {
"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<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> {
@@ -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}
<section class="range-scale" aria-label="Signal Range">
<p class="range-label">Range</p>
<div class="range-track">