From 91307f1985381a32d1487494dcbc1b002d78ec1c Mon Sep 17 00:00:00 2001 From: lenn Date: Mon, 22 Jun 2026 17:44:07 +0800 Subject: [PATCH] =?UTF-8?q?refactor(serial):=20=E6=96=B0=E5=A2=9E=20hand?= =?UTF-8?q?=5Fgateway=20=E7=BC=96=E8=A7=A3=E7=A0=81=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=AD=A3=E6=A8=A1=E5=9D=97=E5=90=8D=E6=8B=BC?= =?UTF-8?q?=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/serial.rs | 97 +++-- .../src/serial_core/codecs/hand_gateway.rs | 390 ++++++++++++++++++ .../src/serial_core/codecs/hand_gatway.rs | 0 src-tauri/src/serial_core/codecs/mod.rs | 3 +- src-tauri/src/serial_core/codecs/tactile_a.rs | 8 +- src-tauri/src/serial_core/frame.rs | 43 +- src-tauri/src/serial_core/serial.rs | 64 ++- src/lib/components/ModelStage.svelte | 130 +----- src/routes/+page.svelte | 60 ++- 9 files changed, 625 insertions(+), 170 deletions(-) create mode 100644 src-tauri/src/serial_core/codecs/hand_gateway.rs delete mode 100644 src-tauri/src/serial_core/codecs/hand_gatway.rs diff --git a/src-tauri/src/commands/serial.rs b/src-tauri/src/commands/serial.rs index 8cb5df6..48e9e0d 100644 --- a/src-tauri/src/commands/serial.rs +++ b/src-tauri/src/commands/serial.rs @@ -1,3 +1,4 @@ +use crate::serial_core::codecs::hand_gateway::HandGatewayCodec; use crate::serial_core::codecs::tactile_a::{ export_recording_csv, TactileACodec, TactileACsvImporter, TactileAHandler, }; @@ -6,7 +7,7 @@ use crate::serial_core::record::CsvImporter; use crate::serial_core::serial::{PollMode, TactileAPollRequester}; use crate::serial_core::{serial, TactileARecording}; use log::info; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::fs::File; use std::io::Cursor; use std::path::{Path, PathBuf}; @@ -21,6 +22,10 @@ const DEFAULT_TACTILE_ROWS: usize = 12; const DEFAULT_TACTILE_POLL_INTERVAL_MS: u64 = 10; const DEFAULT_TACTILE_REPLY_TIMEOUT_MS: u64 = 140; +// MCU currently uses a fixed node layout. Update this list to match the firmware: +// one entry per Config bit, containing that node's number of u16 samples. +const DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS: &[u16] = &[84]; + type SharedTactileRecording = Arc>; #[derive(Serialize)] @@ -31,6 +36,13 @@ pub struct SerialConnectResponse { pub message: String, } +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SerialProtocol { + TactileA, + HandGateway, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SerialExportResponse { @@ -67,7 +79,7 @@ struct SerialSession { port: String, cancel: CancellationToken, task: JoinHandle<()>, - current_record: SharedTactileRecording, + current_record: Option, } #[derive(Default)] @@ -78,7 +90,7 @@ pub struct SerialConnectionState { pub async fn shutdown_active_session( state: &SerialConnectionState, -) -> Result, SerialError> { +) -> Result)>, SerialError> { let session = { let mut guard = state.session.lock().map_err(|_| SerialError::StateError)?; guard.take() @@ -98,13 +110,15 @@ pub async fn shutdown_active_session( let _ = task.await; let frame_count = current_record - .lock() - .map(|record| record.frames.len()) + .as_ref() + .and_then(|record| record.lock().ok().map(|record| record.frames.len())) .unwrap_or(0); info!("last_record has {} frames", frame_count); - if let Ok(mut last_record) = state.last_record.lock() { + if let (Some(current_record), Ok(mut last_record)) = + (current_record.as_ref(), state.last_record.lock()) + { *last_record = Some(current_record.clone()); } @@ -133,6 +147,7 @@ pub fn serial_enum() -> Result, SerialError> { pub async fn serial_connect( app: AppHandle, port: String, + protocol: SerialProtocol, state: State<'_, SerialConnectionState>, ) -> Result { let port_name = port.trim().to_string(); @@ -148,7 +163,10 @@ pub async fn serial_connect( } let cancel = CancellationToken::new(); - let current_record = Arc::new(Mutex::new(TactileARecording::new())); + let current_record = match protocol { + SerialProtocol::TactileA => Some(Arc::new(Mutex::new(TactileARecording::new()))), + SerialProtocol::HandGateway => None, + }; let task_record = current_record.clone(); let task_cancel = cancel.clone(); let task_app = app.clone(); @@ -160,32 +178,51 @@ pub async fn serial_connect( let session_started_at = Instant::now(); let task = tauri::async_runtime::spawn(async move { - let codec = TactileACodec::new(DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS); - let handler = TactileAHandler; - let poll_mode = PollMode::Enabled(Box::new(TactileAPollRequester::new( - Duration::from_millis(DEFAULT_TACTILE_POLL_INTERVAL_MS), - DEFAULT_TACTILE_COLS, - DEFAULT_TACTILE_ROWS, - Duration::from_millis(DEFAULT_TACTILE_REPLY_TIMEOUT_MS), - ))); + let run_result = match protocol { + SerialProtocol::TactileA => { + let codec = TactileACodec::new(DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS); + let handler = TactileAHandler; + let poll_mode = PollMode::Enabled(Box::new(TactileAPollRequester::new( + Duration::from_millis(DEFAULT_TACTILE_POLL_INTERVAL_MS), + DEFAULT_TACTILE_COLS, + DEFAULT_TACTILE_ROWS, + Duration::from_millis(DEFAULT_TACTILE_REPLY_TIMEOUT_MS), + ))); - if let Err(error) = serial::run_serial_with_poll( - task_app.clone(), - port, - codec, - handler, - session_started_at, - task_record.clone(), - task_cancel, - poll_mode, - ) - .await - { + serial::run_serial_with_poll( + task_app.clone(), + port, + codec, + handler, + session_started_at, + task_record + .clone() + .expect("tactile protocol must have a recording"), + task_cancel, + poll_mode, + ) + .await + } + SerialProtocol::HandGateway => { + let codec = HandGatewayCodec::new(DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS); + serial::run_hand_gateway_serial( + task_app.clone(), + port, + codec, + session_started_at, + task_cancel, + ) + .await + } + }; + + if let Err(error) = run_result { eprintln!("serial task exited with error: {error}"); } let manager = task_app.state::(); - if let Ok(mut last_record) = manager.last_record.lock() { + if let (Some(task_record), Ok(mut last_record)) = (task_record, manager.last_record.lock()) + { *last_record = Some(task_record); } @@ -360,7 +397,7 @@ fn resolve_record_for_export( let session = state.session.lock().map_err(|_| SerialError::StateError)?; session .as_ref() - .map(|current_session| current_session.current_record.clone()) + .and_then(|current_session| current_session.current_record.clone()) }; if let Some(recording) = current_record { @@ -381,7 +418,7 @@ fn snapshot_record_frame_count( let session = state.session.lock().map_err(|_| SerialError::StateError)?; session .as_ref() - .map(|current_session| current_session.current_record.clone()) + .and_then(|current_session| current_session.current_record.clone()) }; if let Some(record) = current_record { diff --git a/src-tauri/src/serial_core/codecs/hand_gateway.rs b/src-tauri/src/serial_core/codecs/hand_gateway.rs new file mode 100644 index 0000000..f7a46e1 --- /dev/null +++ b/src-tauri/src/serial_core/codecs/hand_gateway.rs @@ -0,0 +1,390 @@ +use crate::serial_core::codec::Codec; +use crate::serial_core::error::CodecError; +use crate::serial_core::frame::{ + HandGatewayDataRepFrame, HandGatewayFrame, HandGatewayFrameMetaData, HandGatewayFrameNode, +}; +use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis}; +use log::debug; +use std::time::Instant; + +const FRAME_HEADER: [u8; 2] = [0x55, 0xAA]; +const RESPONSE_DATA_CMD: u8 = 0x81; +const RESPONSE_DATA_FIXED_LENGTH: usize = 16; +const RESPONSE_DATA_PREFIX_LENGTH: usize = 19; +const MIN_FRAME_LENGTH: usize = 7; +const DEFAULT_MAX_FRAME_LENGTH: usize = 64 * 1024; + +/// Describes one fixed MCU data block. +/// +/// `config_mask` identifies the bit associated with this block in the protocol's +/// Config/ValidConfig fields. `sample_count` is the number of little-endian u16 +/// samples in the block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandGatewayNodeConfig { + pub config_mask: u32, + pub sample_count: usize, +} + +impl HandGatewayNodeConfig { + pub const fn new(config_mask: u32, sample_count: usize) -> Self { + Self { + config_mask, + sample_count, + } + } + + fn payload_len(&self, bytes_per_sample: usize) -> Option { + self.sample_count.checked_mul(bytes_per_sample) + } +} + +/// Host-side description of the configuration currently hard-coded in the MCU. +/// +/// Keep the node order identical to the MCU Config bit order. When the MCU gains +/// GET_CONFIG support, construct this value from that response instead of changing +/// the decoder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandGatewayConfig { + pub protocol_version: u8, + pub bytes_per_sample: usize, + pub max_frame_length: usize, + pub nodes: Vec, +} + +impl HandGatewayConfig { + pub fn new(nodes: Vec) -> Self { + Self { + protocol_version: 0x01, + bytes_per_sample: 2, + max_frame_length: DEFAULT_MAX_FRAME_LENGTH, + nodes, + } + } + + fn validate(&self) -> Result<(), CodecError> { + if self.bytes_per_sample == 0 || self.max_frame_length < MIN_FRAME_LENGTH { + return Err(CodecError::InvalidLength); + } + + let mut used_masks = 0u32; + for node in &self.nodes { + if node.config_mask == 0 + || node.config_mask.count_ones() != 1 + || used_masks & node.config_mask != 0 + || node.payload_len(self.bytes_per_sample).is_none() + { + return Err(CodecError::InvalidLength); + } + used_masks |= node.config_mask; + } + + Ok(()) + } +} + +pub struct HandGatewayCodec { + buffer: Vec, + config: HandGatewayConfig, +} + +impl HandGatewayCodec { + /// Convenience constructor for sequential Config bits (bit 0, bit 1, ...). + /// Each entry is the u16 sample count of one MCU node. + pub fn new(node_sample_counts: &[u16]) -> Self { + let nodes = node_sample_counts + .iter() + .enumerate() + .map(|(index, &sample_count)| { + let config_mask = 1u32.checked_shl(index as u32).unwrap_or(0); + HandGatewayNodeConfig::new(config_mask, usize::from(sample_count)) + }) + .collect(); + + Self { + buffer: Vec::new(), + config: HandGatewayConfig::new(nodes), + } + } + + pub fn with_config(config: HandGatewayConfig) -> Result { + config.validate()?; + Ok(Self { + buffer: Vec::new(), + config, + }) + } + + pub fn config(&self) -> &HandGatewayConfig { + &self.config + } + + pub fn parse_node_payload(data: &[u8]) -> Result, CodecError> { + if data.len() % 2 != 0 { + return Err(CodecError::InvalidLength); + } + + Ok(data + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect()) + } + + fn retain_possible_header_prefix(&mut self) { + if self.buffer.last() == Some(&FRAME_HEADER[0]) { + self.buffer.drain(..self.buffer.len() - 1); + } else { + self.buffer.clear(); + } + } + + fn parse_data_response( + &self, + frame: &[u8], + length: u16, + session_started_at: Instant, + ) -> Result { + let payload_len = usize::from(length) + .checked_sub(RESPONSE_DATA_FIXED_LENGTH) + .ok_or(CodecError::InvalidLength)?; + let payload_end = RESPONSE_DATA_PREFIX_LENGTH + .checked_add(payload_len) + .ok_or(CodecError::PayloadTooLarge)?; + + if payload_end + 1 != frame.len() { + return Err(CodecError::InvalidLength); + } + + let version = frame[5]; + if version != self.config.protocol_version { + return Err(CodecError::InvalidFrameType); + } + + let timestamp = u32::from_le_bytes(frame[6..10].try_into().unwrap()); + let config = u32::from_le_bytes(frame[10..14].try_into().unwrap()); + let valid_config = u32::from_le_bytes(frame[14..18].try_into().unwrap()); + let block_count = frame[18]; + + let active_nodes: Vec<&HandGatewayNodeConfig> = self + .config + .nodes + .iter() + .filter(|node| config & node.config_mask != 0) + .collect(); + + if active_nodes.len() != usize::from(block_count) { + return Err(CodecError::InvalidLength); + } + + let expected_payload_len = active_nodes.iter().try_fold(0usize, |total, node| { + let node_len = node + .payload_len(self.config.bytes_per_sample) + .ok_or(CodecError::PayloadTooLarge)?; + total + .checked_add(node_len) + .ok_or(CodecError::PayloadTooLarge) + })?; + + if expected_payload_len != payload_len { + return Err(CodecError::InvalidLength); + } + + let mut cursor = RESPONSE_DATA_PREFIX_LENGTH; + let mut nodes = Vec::with_capacity(active_nodes.len()); + for node in active_nodes { + let node_len = node + .payload_len(self.config.bytes_per_sample) + .ok_or(CodecError::PayloadTooLarge)?; + let next = cursor + node_len; + nodes.push(HandGatewayFrameNode { + config_mask: node.config_mask, + valid: valid_config & node.config_mask != 0, + payload: frame[cursor..next].to_vec(), + }); + cursor = next; + } + + Ok(HandGatewayFrame::DataRep(HandGatewayDataRepFrame { + meta: HandGatewayFrameMetaData { + header: FRAME_HEADER, + length, + cmd: RESPONSE_DATA_CMD, + version, + checksum: frame[frame.len() - 1], + }, + timestamp, + config, + valid_config, + block_count, + nodes, + dts_ms: elapsed_millis(session_started_at), + })) + } +} + +impl Codec for HandGatewayCodec { + fn decode( + &mut self, + input: &[u8], + session_started_at: Instant, + ) -> Result, CodecError> { + self.config.validate()?; + self.buffer.extend_from_slice(input); + let mut frames = Vec::new(); + + loop { + let Some(header_pos) = self + .buffer + .windows(FRAME_HEADER.len()) + .position(|window| window == FRAME_HEADER) + else { + self.retain_possible_header_prefix(); + break; + }; + + if header_pos > 0 { + self.buffer.drain(..header_pos); + } + + if self.buffer.len() < 4 { + break; + } + + let length = u16::from_le_bytes([self.buffer[2], self.buffer[3]]); + let frame_len = usize::from(length) + .checked_add(4) + .ok_or(CodecError::PayloadTooLarge)?; + + if frame_len < MIN_FRAME_LENGTH || frame_len > self.config.max_frame_length { + debug!("invalid hand gateway frame length: {frame_len}"); + self.buffer.drain(..1); + continue; + } + + if self.buffer.len() < frame_len { + break; + } + + let expected_checksum = calc_crc8_itu(&self.buffer[..frame_len - 1]); + let received_checksum = self.buffer[frame_len - 1]; + if expected_checksum != received_checksum { + debug!( + "hand gateway checksum mismatch: expected {expected_checksum:02X}, got {received_checksum:02X}" + ); + self.buffer.drain(..1); + continue; + } + + if self.buffer[4] == RESPONSE_DATA_CMD { + match self.parse_data_response( + &self.buffer[..frame_len], + length, + session_started_at, + ) { + Ok(frame) => frames.push(frame), + Err(error) => debug!("invalid hand gateway data response: {error}"), + } + } + + self.buffer.drain(..frame_len); + } + + Ok(frames) + } + + fn encode(&self, _frame: &HandGatewayFrame) -> Result, CodecError> { + Err(CodecError::InvalidFrameType) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_codec() -> HandGatewayCodec { + HandGatewayCodec::with_config(HandGatewayConfig::new(vec![ + HandGatewayNodeConfig::new(1 << 0, 2), + HandGatewayNodeConfig::new(1 << 1, 1), + ])) + .unwrap() + } + + fn data_response(config: u32, valid_config: u32, payload: &[u8]) -> Vec { + let block_count = config.count_ones() as u8; + let length = (RESPONSE_DATA_FIXED_LENGTH + payload.len()) as u16; + let mut frame = Vec::new(); + frame.extend_from_slice(&FRAME_HEADER); + frame.extend_from_slice(&length.to_le_bytes()); + frame.push(RESPONSE_DATA_CMD); + frame.push(0x01); + frame.extend_from_slice(&123_456u32.to_le_bytes()); + frame.extend_from_slice(&config.to_le_bytes()); + frame.extend_from_slice(&valid_config.to_le_bytes()); + frame.push(block_count); + frame.extend_from_slice(payload); + frame.push(calc_crc8_itu(&frame)); + frame + } + + #[test] + fn decodes_configured_nodes() { + let mut codec = test_codec(); + let bytes = data_response(0b11, 0b01, &[1, 0, 2, 0, 3, 0]); + let frames = codec.decode(&bytes, Instant::now()).unwrap(); + + let HandGatewayFrame::DataRep(frame) = &frames[0]; + assert_eq!(frame.timestamp, 123_456); + assert_eq!(frame.block_count, 2); + assert_eq!(frame.nodes.len(), 2); + assert_eq!(frame.nodes[0].config_mask, 1); + assert!(frame.nodes[0].valid); + assert_eq!( + HandGatewayCodec::parse_node_payload(&frame.nodes[0].payload).unwrap(), + [1, 2] + ); + assert!(!frame.nodes[1].valid); + assert_eq!( + HandGatewayCodec::parse_node_payload(&frame.nodes[1].payload).unwrap(), + [3] + ); + } + + #[test] + fn supports_split_and_sticky_packets() { + let mut codec = test_codec(); + let frame = data_response(0b01, 0b01, &[1, 0, 2, 0]); + let split = frame.len() / 2; + + assert!(codec + .decode(&frame[..split], Instant::now()) + .unwrap() + .is_empty()); + + let mut remainder_and_next = frame[split..].to_vec(); + remainder_and_next.extend_from_slice(&frame); + let frames = codec.decode(&remainder_and_next, Instant::now()).unwrap(); + assert_eq!(frames.len(), 2); + } + + #[test] + fn resynchronizes_after_noise_and_bad_crc() { + let mut codec = test_codec(); + let valid = data_response(0b10, 0b10, &[9, 0]); + let mut bad = valid.clone(); + let last = bad.len() - 1; + bad[last] ^= 0xFF; + + let mut bytes = vec![0x13, 0x55, 0x12]; + bytes.extend_from_slice(&bad); + bytes.extend_from_slice(&valid); + + let frames = codec.decode(&bytes, Instant::now()).unwrap(); + assert_eq!(frames.len(), 1); + } + + #[test] + fn rejects_payload_that_does_not_match_fixed_config() { + let mut codec = test_codec(); + let bytes = data_response(0b01, 0b01, &[1, 0]); + assert!(codec.decode(&bytes, Instant::now()).unwrap().is_empty()); + } +} diff --git a/src-tauri/src/serial_core/codecs/hand_gatway.rs b/src-tauri/src/serial_core/codecs/hand_gatway.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src-tauri/src/serial_core/codecs/mod.rs b/src-tauri/src/serial_core/codecs/mod.rs index d4b0944..b0b9d40 100644 --- a/src-tauri/src/serial_core/codecs/mod.rs +++ b/src-tauri/src/serial_core/codecs/mod.rs @@ -2,4 +2,5 @@ use crate::serial_core::{frame::TestFrame, record::Recording}; pub mod test; pub mod tactile_a; -pub type TestRecording = Recording; \ No newline at end of file +pub mod hand_gateway; +pub type TestRecording = Recording; diff --git a/src-tauri/src/serial_core/codecs/tactile_a.rs b/src-tauri/src/serial_core/codecs/tactile_a.rs index c0fc242..cf9ff40 100644 --- a/src-tauri/src/serial_core/codecs/tactile_a.rs +++ b/src-tauri/src/serial_core/codecs/tactile_a.rs @@ -14,7 +14,7 @@ use anyhow::anyhow; use std::io::Read; use log::debug; -const FRAME_BUFFER_MIN_LENGTH: usize = 15; +const FINGER_FRAME_BUFFER_MIN_LENGTH: usize = 15; pub struct TactileACodec { buffer: Vec, @@ -123,7 +123,7 @@ impl Codec for TactileACodec { let mut frames: Vec = Vec::new(); loop { - if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH { + if self.buffer.len() < FINGER_FRAME_BUFFER_MIN_LENGTH { break; } @@ -137,7 +137,7 @@ impl Codec for TactileACodec { self.buffer.drain(0..pos); } - if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH { + if self.buffer.len() < FINGER_FRAME_BUFFER_MIN_LENGTH { break; } @@ -165,7 +165,7 @@ impl Codec for TactileACodec { continue; } - let frame_length = except_data_len + FRAME_BUFFER_MIN_LENGTH; + let frame_length = except_data_len + FINGER_FRAME_BUFFER_MIN_LENGTH; if self.buffer.len() < frame_length { break; } diff --git a/src-tauri/src/serial_core/frame.rs b/src-tauri/src/serial_core/frame.rs index 42d23a6..4732b84 100644 --- a/src-tauri/src/serial_core/frame.rs +++ b/src-tauri/src/serial_core/frame.rs @@ -7,7 +7,7 @@ pub struct TestFrame { pub length: usize, pub payload: Vec, pub checksum: u8, - pub dts_ms: u64 + pub dts_ms: u64, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -30,28 +30,59 @@ pub struct TactileAReqFrame { pub meta: TactileAFrameMetaData, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct TactileARepFrame { pub meta: TactileAFrameMetaData, pub status: TactileAFrameStatusCode, pub payload: Vec, - pub dts_ms: u64 + pub dts_ms: u64, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum TactileAFrameStatusCode { Success, - Failure + Failure, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum TactileAFrame { Req(TactileAReqFrame), - Rep(TactileARepFrame) + Rep(TactileARepFrame), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandGatewayFrameMetaData { + pub header: [u8; 2], + pub length: u16, + pub cmd: u8, + pub version: u8, + pub checksum: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandGatewayFrameNode { + pub config_mask: u32, + pub valid: bool, + pub payload: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandGatewayDataRepFrame { + pub meta: HandGatewayFrameMetaData, + pub timestamp: u32, + pub config: u32, + pub valid_config: u32, + pub block_count: u8, + pub nodes: Vec, + pub dts_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HandGatewayFrame { + DataRep(HandGatewayDataRepFrame), } #[async_trait] pub trait FrameHandler: Send { async fn on_frame(&mut self, frame: &F) -> Result>>; } - diff --git a/src-tauri/src/serial_core/serial.rs b/src-tauri/src/serial_core/serial.rs index 45112ee..0a485cb 100644 --- a/src-tauri/src/serial_core/serial.rs +++ b/src-tauri/src/serial_core/serial.rs @@ -1,6 +1,8 @@ +use crate::ad_solver::solve_for_x; #[cfg(feature = "devkit")] use crate::devkit::{proto::SensorFrame, DevKitState}; use crate::serial_core::codec::Codec; +use crate::serial_core::codecs::hand_gateway::HandGatewayCodec; use crate::serial_core::codecs::tactile_a::TactileACodec; use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame}; use crate::serial_core::model::{HudChartState, HudPacket, HudSpatialForce}; @@ -10,6 +12,7 @@ use crate::serial_core::record::Recording; use crate::serial_core::record::{FrameTiming, RecordedFrame}; use anyhow::Result; use log::debug; +use log::info; use std::future::pending; #[cfg(feature = "devkit")] use std::sync::atomic::Ordering; @@ -22,7 +25,6 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::time::{self, Duration, MissedTickBehavior}; use tokio_serial::SerialStream; use tokio_util::sync::CancellationToken; -use crate::ad_solver::solve_for_x; const AUTO_SUB_INTERVAL: Duration = Duration::from_nanos(16_666_667); pub enum PollMode { @@ -200,6 +202,61 @@ where .await } +/// Runs the hand gateway in passive mode. The MCU continuously uploads data, so +/// this loop never writes request frames to the serial port. +pub async fn run_hand_gateway_serial( + app: AppHandle, + mut port: SerialStream, + mut codec: HandGatewayCodec, + session_started_at: Instant, + cancel: CancellationToken, +) -> Result<()> { + let mut buffer = [0u8; 4096]; + + loop { + tokio::select! { + _ = cancel.cancelled() => break, + read_result = port.read(&mut buffer) => { + let n = read_result?; + if n == 0 { + tokio::task::yield_now().await; + continue; + } + + let raw = &buffer[..n]; + app.emit("hand_gateway_raw", raw.to_vec())?; + let raw_hex = raw + .iter() + .map(|byte| format!("{byte:02X}")) + .collect::>() + .join(" "); + info!("[hand_gateway][raw] {raw_hex}"); + + for frame in codec.decode(raw, session_started_at)? { + let crate::serial_core::frame::HandGatewayFrame::DataRep(frame) = frame; + info!( + "[hand_gateway][frame] timestamp_us={} config=0x{:08X} valid_config=0x{:08X} block_count={}", + frame.timestamp, + frame.config, + frame.valid_config, + frame.block_count, + ); + for node in frame.nodes { + info!( + "[hand_gateway][node] mask=0x{:08X} valid={} payload={:02X?}", + node.config_mask, + node.valid, + node.payload, + ); + } + } + } + } + } + + Ok(()) +} + pub async fn run_serial_with_poll( app: AppHandle, mut port: SerialStream, @@ -333,7 +390,7 @@ where { let summary = vals.iter().copied().sum::(); let force = raw_to_g1(summary as u32); - + push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force); } @@ -421,7 +478,8 @@ fn infer_matrix_shape(len: usize) -> (u32, u32) { fn raw_to_g1(raw: u32) -> f64 { const X: [u32; 13] = [ - 0, 16811, 41350, 79241, 94615, 127446, 149559, 175900, 195056, 237852, 267810, 322472, 378511, + 0, 16811, 41350, 79241, 94615, 127446, 149559, 175900, 195056, 237852, 267810, 322472, + 378511, ]; const Y: [f64; 13] = [ diff --git a/src/lib/components/ModelStage.svelte b/src/lib/components/ModelStage.svelte index 68e3df4..e4ba78f 100644 --- a/src/lib/components/ModelStage.svelte +++ b/src/lib/components/ModelStage.svelte @@ -19,44 +19,15 @@ const FLOOR_Y = -1.15; const MODEL_FLOOR_CLEARANCE = 0.035; - const MODEL_TARGET_HEIGHT = 7.4; + const MODEL_TARGET_HEIGHT = 6.55; const MODEL_MIN_SCALE = 0.02; const MODEL_MAX_SCALE = 80; - const FIXED_CAMERA_POSITION = new THREE.Vector3(0.8, 3.25, 12.2); - const FIXED_CAMERA_TARGET = new THREE.Vector3(0, 2.55, 0); + const FIXED_CAMERA_POSITION = new THREE.Vector3(-0.113, -0.149, 10.911); + const FIXED_CAMERA_TARGET = new THREE.Vector3(0, 2.35, 0); const MODEL_FRONT_ROTATION_Y = -Math.PI / 2; - const MODEL_UPRIGHT_ROTATION_Z = Math.PI * 1.5; + const MODEL_UPRIGHT_ROTATION_Z = Math.PI * 1.5 - THREE.MathUtils.degToRad(2.2); - $: copy = - locale === "zh-CN" - ? { - title: "3D 模型舱", - subtitle: "Dark Grid / Future Lab", - loading: "正在加载模型", - ready: "模型已载入", - missing: "等待模型文件", - error: "模型加载失败", - modelPath: "模型路径", - hint: "请使用 glTF 2.0 的 .glb/.gltf;旧版 glTF 1.0 需要先转换" - } - : { - title: "3D Model Bay", - subtitle: "Dark Grid / Future Lab", - loading: "Loading model", - ready: "Model loaded", - missing: "Waiting for model file", - error: "Model load failed", - modelPath: "Model path", - hint: "Use glTF 2.0 .glb/.gltf assets; older glTF 1.0 files need conversion first" - }; - $: statusText = - loadState === "ready" - ? copy.ready - : loadState === "missing" - ? copy.missing - : loadState === "error" - ? copy.error - : `${copy.loading} ${Math.round(loadProgress)}%`; + $: canvasLabel = locale === "zh-CN" ? "3D 模型" : "3D Model"; function disposeObject3D(object: THREE.Object3D): void { object.traverse((child) => { @@ -347,20 +318,9 @@
- + - -
-

{copy.subtitle}

-

{copy.title}

-
- - {statusText} -
-

{copy.modelPath}: {modelUrl}

-

{loadError || copy.hint}

-
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7bb9edb..6a6b5ce 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -306,6 +306,10 @@ return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; } + function serialProtocolForStage(mode: StageViewMode = stageViewMode): "tactileA" | "handGateway" { + return mode === "model3d" ? "handGateway" : "tactileA"; + } + function clearDevkitSpatialForce(): void { devkitSpatialForce = null; if (devkitSpatialForceClearTimer != null && typeof window !== "undefined") { @@ -1445,7 +1449,10 @@ connectionNotice = ""; try { - const result = await invoke("serial_connect", { port: event.detail }); + const result = await invoke("serial_connect", { + port: event.detail, + protocol: serialProtocolForStage() + }); connectionState = result.connected ? "online" : "offline"; serialPortValue = result.port; connectionNotice = ""; @@ -1800,17 +1807,48 @@ matrixDisplayMode = event.detail ? "dots" : "numeric"; } - function handleStageModeChange(event: CustomEvent): void { + async function handleStageModeChange(event: CustomEvent): Promise { + const previousMode = stageViewMode; stageViewMode = event.detail; if (stageViewMode === "model3d") { isPrecisionTestOpen = false; isConfigPanelOpen = false; } + + if ( + previousMode === stageViewMode || + connectionState !== "online" || + !serialPortValue || + !isTauriRuntime() + ) { + return; + } + + connectionState = "connecting"; + connectionNotice = ""; + try { + await invoke("serial_disconnect"); + const result = await invoke("serial_connect", { + port: serialPortValue, + protocol: serialProtocolForStage(stageViewMode) + }); + connectionState = result.connected ? "online" : "offline"; + serialPortValue = result.port; + clearHudPanels(); + console.info(`[serial] switched protocol to ${serialProtocolForStage(stageViewMode)}`); + } catch (error) { + connectionState = "offline"; + connectionNotice = resolveSerialNotice(error, "connect"); + connectionNoticeTone = "warn"; + clearHudPanels(); + console.error("Serial protocol switch failed:", error); + } } onMount(() => { let disposed = false; let unlistenHudStream: UnlistenFn | null = null; + let unlistenHandGatewayRaw: UnlistenFn | null = null; let unlistenDevkitPztAngle: UnlistenFn | null = null; let stopMockFeed: (() => void) | null = null; @@ -1835,6 +1873,23 @@ .catch((error) => { console.error("Failed to listen for hud_stream:", error); }); + void listen("hand_gateway_raw", (event) => { + const rawHex = event.payload + .map((byte) => byte.toString(16).padStart(2, "0").toUpperCase()) + .join(" "); + console.info(`[hand_gateway][raw] ${rawHex}`); + }) + .then((unlisten) => { + if (disposed) { + unlisten(); + return; + } + + unlistenHandGatewayRaw = unlisten; + }) + .catch((error) => { + console.error("Failed to listen for hand_gateway_raw:", error); + }); void listen("devkit_pzt_angle", (event) => { const angleDeg = Number(event.payload.angle); if (!Number.isFinite(angleDeg)) { @@ -1875,6 +1930,7 @@ clearDevkitSpatialForce(); stopMockFeed?.(); unlistenHudStream?.(); + unlistenHandGatewayRaw?.(); unlistenDevkitPztAngle?.(); if (devkitStatusTimer != null) { window.clearInterval(devkitStatusTimer);