Merge branch 'display'

# Conflicts:
#	.gitignore
#	package-lock.json
#	package.json
#	src-tauri/Cargo.lock
#	src-tauri/Cargo.toml
#	src-tauri/tauri.conf.json
#	src/routes/+page.svelte
This commit is contained in:
lenn
2026-04-29 16:08:46 +08:00
39 changed files with 6144 additions and 853 deletions

View File

@@ -0,0 +1,53 @@
//! DevKit Tauri 命令
//!
//! 仅在 `devkit` feature 启用时编译。
use tauri::State;
#[cfg(feature = "devkit")]
use tauri::AppHandle;
use crate::devkit::{DevKitConfig, DevKitState, DevKitStatusSnapshot, ExportProcessResult};
#[tauri::command]
pub fn devkit_status(state: State<'_, DevKitState>) -> DevKitStatusSnapshot {
state.status()
}
#[tauri::command]
pub async fn devkit_start(
app: AppHandle,
state: State<'_, DevKitState>,
port: Option<u16>,
) -> Result<DevKitStatusSnapshot, String> {
let target_port = port.unwrap_or(50051);
state.start(app, target_port).await?;
Ok(state.status())
}
#[tauri::command]
pub async fn devkit_stop(state: State<'_, DevKitState>) -> Result<DevKitStatusSnapshot, String> {
state.stop().await?;
Ok(state.status())
}
#[tauri::command]
pub fn devkit_get_config(state: State<'_, DevKitState>) -> DevKitConfig {
state.get_config()
}
#[tauri::command]
pub fn devkit_set_config(state: State<'_, DevKitState>, config: DevKitConfig) -> Result<DevKitConfig, String> {
state.set_config(config)?;
Ok(state.get_config())
}
#[tauri::command]
pub async fn devkit_process_export(
state: State<'_, DevKitState>,
csv_path: String,
save_as_xlsx: Option<bool>,
) -> Result<ExportProcessResult, String> {
let config = state.get_config();
let use_xlsx = save_as_xlsx.unwrap_or(config.save_as_xlsx);
state.process_export(&csv_path, use_xlsx).await
}

View File

@@ -1,3 +1,6 @@
pub mod file_explorer;
pub mod serial;
pub mod window;
#[cfg(feature = "devkit")]
pub mod devkit;

View File

@@ -23,7 +23,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 +73,7 @@ struct SerialSession {
#[derive(Default)]
pub struct SerialConnectionState {
session: Mutex<Option<SerialSession>>,
last_record: Mutex<Option<SharedTactileRecording>>
last_record: Mutex<Option<SharedTactileRecording>>,
}
pub async fn shutdown_active_session(
@@ -90,7 +89,8 @@ pub async fn shutdown_active_session(
cancel,
task,
current_record,
}) = session else {
}) = session
else {
return Ok(None);
};
@@ -116,7 +116,14 @@ pub fn serial_enum() -> Result<Vec<String>, SerialError> {
let ports = available_ports()
.map_err(|_| SerialError::ScanError)?
.into_iter()
.map(|info| info.port_name)
.filter_map(|p| {
let name = p.port_name;
#[cfg(unix)]
if !name.contains("USB") {
return None;
}
Some(name)
})
.collect();
Ok(ports)
@@ -210,7 +217,7 @@ pub async fn serial_connect(
port: port_name.clone(),
cancel,
task,
current_record
current_record,
});
Ok(SerialConnectResponse {
@@ -224,8 +231,7 @@ pub async fn serial_connect(
pub async fn serial_disconnect(
state: State<'_, SerialConnectionState>,
) -> Result<SerialConnectResponse, SerialError> {
let Some((port, _current_record)) = shutdown_active_session(&state).await?
else {
let Some((port, _current_record)) = shutdown_active_session(&state).await? else {
return Ok(SerialConnectResponse {
port: String::new(),
connected: false,
@@ -301,7 +307,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()))
@@ -358,7 +367,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)
}
@@ -379,7 +391,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

@@ -0,0 +1,295 @@
//! DevKit gRPC Client
//!
//! Rust 端作为 gRPC client
//! 1. 以 client-streaming 方式推送实时帧SensorPush.Upload
//! 2. 以 unary 方式发送导出文件路径做后处理ExportProcessor.ProcessFile
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use super::proto::sensor_push_client::SensorPushClient;
use super::proto::export_processor_client::ExportProcessorClient;
use super::proto::{ProcessRequest, SensorFrame};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DevKitPztAngleEvent {
seq: u64,
timestamp_ms: u64,
dts_ms: u32,
angle: f32,
}
// ── DevKit 配置 ────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DevKitConfig {
/// 导出过滤抬起:导出 CSV 后自动调用 Python 做梯度过滤
pub filter_lift_enabled: bool,
/// 以 xlsx 保存Python 处理后输出 xlsx 并删除源 CSV
pub save_as_xlsx: bool,
}
impl Default for DevKitConfig {
fn default() -> Self {
Self {
filter_lift_enabled: true,
save_as_xlsx: false,
}
}
}
impl DevKitConfig {
fn config_path() -> PathBuf {
let base = dirs::config_dir()
.or_else(|| dirs::data_dir())
.unwrap_or_else(|| PathBuf::from("."));
base.join("JE-Skin").join("devkit_config.json")
}
/// 从文件加载配置,失败则返回默认值
pub fn load() -> Self {
let path = Self::config_path();
match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Self::default(),
}
}
/// 保存配置到文件
pub fn save(&self) -> Result<(), String> {
let path = Self::config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config dir: {e}"))?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
std::fs::write(&path, json)
.map_err(|e| format!("Failed to write config: {e}"))?;
Ok(())
}
}
// ── 导出处理结果 ───────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportProcessResult {
pub ok: bool,
pub output_path: String,
pub groups_used: u32,
pub mean_value: f64,
pub threshold: f64,
pub rows_total: u32,
pub rows_kept: u32,
pub message: String,
}
// ── Tauri 状态 ─────────────────────────────────────────────────────
/// DevKit 全局状态,由 Tauri manage
#[derive(Clone)]
pub struct DevKitState {
pub running: Arc<AtomicBool>,
pub port: Arc<std::sync::Mutex<u16>>,
pub frame_count: Arc<AtomicU32>,
pub config: Arc<std::sync::Mutex<DevKitConfig>>,
frame_tx: Arc<std::sync::Mutex<Option<mpsc::Sender<SensorFrame>>>>,
client_handle: Arc<std::sync::Mutex<Option<JoinHandle<()>>>>,
}
impl Default for DevKitState {
fn default() -> Self {
Self {
running: Arc::new(AtomicBool::new(false)),
port: Arc::new(std::sync::Mutex::new(50051)),
frame_count: Arc::new(AtomicU32::new(0)),
config: Arc::new(std::sync::Mutex::new(DevKitConfig::load())),
frame_tx: Arc::new(std::sync::Mutex::new(None)),
client_handle: Arc::new(std::sync::Mutex::new(None)),
}
}
}
/// 前端查询到的状态快照
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DevKitStatusSnapshot {
pub enabled: bool,
pub running: bool,
pub port: u16,
pub frames_sent: u32,
pub config: DevKitConfig,
}
impl DevKitState {
pub fn status(&self) -> DevKitStatusSnapshot {
let cfg = self.config.lock().unwrap().clone();
DevKitStatusSnapshot {
enabled: true,
running: self.running.load(Ordering::SeqCst),
port: *self.port.lock().unwrap(),
frames_sent: self.frame_count.load(Ordering::SeqCst),
config: cfg,
}
}
/// 获取当前配置
pub fn get_config(&self) -> DevKitConfig {
self.config.lock().unwrap().clone()
}
/// 更新配置并持久化
pub fn set_config(&self, new_config: DevKitConfig) -> Result<(), String> {
new_config.save()?;
*self.config.lock().unwrap() = new_config;
Ok(())
}
/// 启动 gRPC client连接到 Python server 并开始推送数据
pub async fn start(&self, app: AppHandle, port: u16) -> Result<(), String> {
if self.running.load(Ordering::SeqCst) {
return Err("AlreadyRunning".into());
}
let addr = format!("http://127.0.0.1:{port}");
*self.port.lock().unwrap() = port;
self.running.store(true, Ordering::SeqCst);
self.frame_count.store(0, Ordering::SeqCst);
// mpsc channel: 主线程 send 帧 → gRPC task 推送给 Python
let (tx, rx) = mpsc::channel::<SensorFrame>(512);
*self.frame_tx.lock().unwrap() = Some(tx);
let running = Arc::clone(&self.running);
let frame_count = Arc::clone(&self.frame_count);
let app_handle = app.clone();
let handle = tokio::spawn(async move {
if let Err(e) = run_grpc_upload(app_handle, addr, rx, frame_count).await {
::log::error!("DevKit gRPC upload error: {e:?}");
}
running.store(false, Ordering::SeqCst);
});
*self.client_handle.lock().unwrap() = Some(handle);
::log::info!("DevKit gRPC client started, connecting to 127.0.0.1:{port}");
Ok(())
}
/// 停止 gRPC client
pub async fn stop(&self) -> Result<(), String> {
if !self.running.load(Ordering::SeqCst) {
return Err("NotRunning".into());
}
*self.frame_tx.lock().unwrap() = None;
if let Some(handle) = self.client_handle.lock().unwrap().take() {
handle.abort();
}
self.running.store(false, Ordering::SeqCst);
::log::info!("DevKit gRPC client stopped");
Ok(())
}
/// 推送一帧数据到 gRPC stream由主线程调用
pub fn push_frame(&self, frame: SensorFrame) {
if !self.running.load(Ordering::SeqCst) {
return;
}
if let Some(tx) = self.frame_tx.lock().unwrap().as_ref() {
let _ = tx.try_send(frame);
}
}
/// 调用 Python ExportProcessor.ProcessFile 做导出后处理unary
pub async fn process_export(
&self,
csv_path: &str,
save_as_xlsx: bool,
) -> Result<ExportProcessResult, String> {
let port = *self.port.lock().unwrap();
let addr = format!("http://127.0.0.1:{port}");
let mut client = ExportProcessorClient::connect(addr)
.await
.map_err(|e| format!("Failed to connect to DevKit server: {e}"))?;
let request = ProcessRequest {
csv_path: csv_path.to_string(),
save_as_xlsx,
};
let response = client
.process_file(request)
.await
.map_err(|e| format!("ProcessFile RPC failed: {e}"))?;
let resp = response.into_inner();
Ok(ExportProcessResult {
ok: resp.ok,
output_path: resp.output_path,
groups_used: resp.groups_used,
mean_value: resp.mean_value,
threshold: resp.threshold,
rows_total: resp.rows_total,
rows_kept: resp.rows_kept,
message: resp.message,
})
}
}
// ── gRPC Upload Client ─────────────────────────────────────────────
async fn run_grpc_upload(
app: AppHandle,
addr: String,
mut rx: mpsc::Receiver<SensorFrame>,
frame_count: Arc<AtomicU32>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut client = SensorPushClient::connect(addr.clone()).await?;
let stream = async_stream::stream! {
while let Some(frame) = rx.recv().await {
frame_count.fetch_add(1, Ordering::SeqCst);
yield frame;
}
};
let response = client.upload(stream).await?;
let mut inbound = response.into_inner();
while let Some(message) = inbound.message().await? {
if message.ok {
let payload = DevKitPztAngleEvent {
seq: message.seq,
timestamp_ms: message.timestamp_ms,
dts_ms: message.dts_ms,
angle: message.angle,
};
::log::debug!(
"python pzt angle: seq={} dts_ms={} angle={:.2}",
message.seq,
message.dts_ms,
message.angle
);
app.emit("devkit_pzt_angle", payload)?;
} else {
::log::warn!("DevKit PZT response error: {}", message.message);
}
}
::log::info!("DevKit upload stream closed");
Ok(())
}

View File

@@ -0,0 +1,13 @@
//! Develop Kit 模块
//!
//! 仅在 `devkit` feature 启用时编译。
//! Rust 端作为 gRPC client将传感器压力矩阵数据实时推送给 Python gRPC server。
mod client;
pub use client::{DevKitConfig, DevKitState, DevKitStatusSnapshot, ExportProcessResult};
// 导入 tonic 生成的 gRPC 代码
pub mod proto {
tonic::include_proto!("sensor_stream");
}

1250
src-tauri/src/lan_game.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,179 @@
mod commands;
pub mod serial_core;
mod lan_game;
pub mod log;
pub mod serial_core;
#[cfg(feature = "devkit")]
pub mod devkit;
use commands::serial::SerialConnectionState;
#[cfg(feature = "devkit")]
use tauri::Manager;
#[cfg(feature = "devkit")]
fn start_server_exe(exe_path: &std::path::Path) {
let mut command = std::process::Command::new(exe_path);
command.arg("--port").arg("50051");
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
match command.spawn() {
Ok(_) => ::log::info!("DevKit Python server launched: {}", exe_path.display()),
Err(error) => ::log::warn!("Failed to start DevKit Python server: {error}"),
}
}
#[cfg(feature = "devkit")]
fn is_local_port_open(port: u16) -> bool {
use std::net::{SocketAddr, TcpStream};
use std::time::Duration;
let addr = SocketAddr::from(([127, 0, 0, 1], port));
TcpStream::connect_timeout(&addr, Duration::from_millis(250)).is_ok()
}
#[cfg(feature = "devkit")]
fn find_server_exe(
resource_dir: &std::path::Path,
exe_name: &str,
) -> Option<std::path::PathBuf> {
let mut candidates = Vec::new();
candidates.push(resource_dir.join(exe_name));
if let Ok(current_exe) = std::env::current_exe() {
if let Some(parent) = current_exe.parent() {
candidates.push(parent.join(exe_name));
}
}
if let Ok(current_dir) = std::env::current_dir() {
candidates.push(current_dir.join("src-tauri").join("resources").join(exe_name));
candidates.push(current_dir.join("devkit").join("dist").join(exe_name));
candidates.push(current_dir.join("resources").join(exe_name));
}
candidates.into_iter().find(|path| path.exists())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
let builder = tauri::Builder::default()
.plugin(tauri_plugin_process::init())
.manage(SerialConnectionState::default())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
commands::file_explorer::file_explorer_list,
commands::serial::serial_enum,
commands::serial::serial_connect,
commands::serial::serial_disconnect,
commands::serial::serial_export_csv,
commands::serial::serial_has_record_data,
commands::serial::serial_export_csv_to_path,
commands::serial::serial_import_csv,
commands::serial::serial_import_csv_from_path,
commands::window::win_minimize,
commands::window::win_toggle_maximize,
commands::window::win_close
])
.plugin(tauri_plugin_opener::init());
#[cfg(not(any(target_os = "android", target_os = "ios")))]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
#[cfg(any(target_os = "android", target_os = "ios"))]
let builder = builder;
#[cfg(feature = "devkit")]
let builder = {
let devkit_state = devkit::DevKitState::default();
let devkit_state_clone = devkit_state.clone();
builder.manage(devkit_state).setup(move |app| {
tauri::async_runtime::spawn(async {
if let Err(error) = lan_game::serve().await {
::log::error!("LAN game server failed: {error:?}");
}
});
let resource_dir = app
.path()
.resource_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("./resources"));
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let devkit_port = 50051u16;
#[cfg(target_os = "windows")]
let exe_name = "je-skin-devkit-server.exe";
#[cfg(not(target_os = "windows"))]
let exe_name = "je-skin-devkit-server";
if is_local_port_open(devkit_port) {
::log::info!(
"DevKit port {} already in use, skipping Python server auto-start",
devkit_port
);
} else {
let server_exe = find_server_exe(&resource_dir, exe_name);
if let Some(exe_path) = server_exe {
start_server_exe(&exe_path);
tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
} else {
::log::info!("DevKit Python server not found, skipping auto-start");
}
}
if let Err(error) = devkit_state_clone.start(app_handle, devkit_port).await {
::log::warn!("DevKit auto-start failed: {error}");
} else {
::log::info!("DevKit gRPC client initialized for 127.0.0.1:{devkit_port}");
}
});
Ok(())
})
};
#[cfg(not(feature = "devkit"))]
let builder = builder.setup(|_app| {
tauri::async_runtime::spawn(async {
if let Err(error) = lan_game::serve().await {
::log::error!("LAN game server failed: {error:?}");
}
});
Ok(())
});
#[cfg(feature = "devkit")]
let builder = builder.invoke_handler(tauri::generate_handler![
commands::file_explorer::file_explorer_list,
commands::serial::serial_enum,
commands::serial::serial_connect,
commands::serial::serial_disconnect,
commands::serial::serial_export_csv,
commands::serial::serial_has_record_data,
commands::serial::serial_export_csv_to_path,
commands::serial::serial_import_csv,
commands::serial::serial_import_csv_from_path,
commands::window::win_minimize,
commands::window::win_toggle_maximize,
commands::window::win_close,
commands::devkit::devkit_status,
commands::devkit::devkit_start,
commands::devkit::devkit_stop,
commands::devkit::devkit_get_config,
commands::devkit::devkit_set_config,
commands::devkit::devkit_process_export
]);
#[cfg(not(feature = "devkit"))]
let builder = builder.invoke_handler(tauri::generate_handler![
commands::file_explorer::file_explorer_list,
commands::serial::serial_enum,
commands::serial::serial_connect,
commands::serial::serial_disconnect,
commands::serial::serial_export_csv,
commands::serial::serial_has_record_data,
commands::serial::serial_export_csv_to_path,
commands::serial::serial_import_csv,
commands::serial::serial_import_csv_from_path,
commands::window::win_minimize,
commands::window::win_toggle_maximize,
commands::window::win_close
]);
builder
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -1,6 +1,18 @@
use fern::{Dispatch, colors::{Color, ColoredLevelConfig}};
use log::{debug};
use std::time::SystemTime;
use fern::{
colors::{Color, ColoredLevelConfig},
Dispatch,
};
use log::debug;
use std::{path::{Path, PathBuf}, time::SystemTime};
fn log_directory() -> PathBuf {
let base_dir = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
.unwrap_or_else(std::env::temp_dir);
base_dir.join("JE-Skin").join("logs")
}
pub fn setup_logger() {
let colors_line = ColoredLevelConfig::new()
.error(Color::Red)
@@ -18,40 +30,40 @@ pub fn setup_logger() {
let console_config = fern::Dispatch::new()
.format(move |out, message, record| {
out.finish(
format_args!(
"{colors_line}[{data} {level} {target} {colors_line}] {message}\x1B[0m",
colors_line = format_args!(
"\x1B[{}m",
colors_line.get_color(&record.level()).to_fg_str()
),
data = humantime::format_rfc3339_seconds(SystemTime::now()),
target = record.target(),
level = colors_level.color(record.level()),
message = message,
)
);
out.finish(format_args!(
"{colors_line}[{data} {level} {target} {colors_line}] {message}\x1B[0m",
colors_line = format_args!(
"\x1B[{}m",
colors_line.get_color(&record.level()).to_fg_str()
),
data = humantime::format_rfc3339_seconds(SystemTime::now()),
target = record.target(),
level = colors_level.color(record.level()),
message = message,
));
})
.level(level)
.chain(std::io::stdout());
// .chain(fern::DateBased::new("program.log", "%Y-%m-%d"))
// .apply()
// .unwrap();
// .chain(fern::DateBased::new("program.log", "%Y-%m-%d"))
// .apply()
// .unwrap();
let log_dir = log_directory();
if let Err(error) = std::fs::create_dir_all(&log_dir) {
eprintln!("failed to create log_directory {}: {error}", log_dir.display());
}
// let log_path = std::env::temp_dir().join("program.log");
let file_config = fern::Dispatch::new()
.format(move |out, message, record| {
out.finish(
format_args!(
"[{data} {level} {target}] {message}",
data = humantime::format_rfc3339_seconds(SystemTime::now()),
target = record.target(),
level = colors_level.color(record.level()),
message = message,
)
);
out.finish(format_args!(
"[{data} {level} {target}] {message}",
data = humantime::format_rfc3339_seconds(SystemTime::now()),
target = record.target(),
level = colors_level.color(record.level()),
message = message,
));
})
.level(level)
.chain(fern::DateBased::new("program.log", "%Y-%m-%d"));
.chain(fern::DateBased::new(log_dir.join("program.log"), "%Y-%m-%d"));
Dispatch::new()
.level(log::LevelFilter::Debug)

View File

@@ -11,6 +11,8 @@ pub mod model;
pub mod serial;
pub mod record;
pub mod utils;
#[cfg(feature = "multi-dim")]
pub mod multi_dim_force;
pub type TestRecording = Recording<TestFrame>;
pub type TactileARecording = Recording<TactileAFrame>;

View File

@@ -0,0 +1,122 @@
use ndarray::Array2;
const TOTAL_PRESSURE_LOW_THRESHOLD: usize = 500;
const COP_STABILITY_FRAMES_REQUIRED: usize = 5;
const SENSOR_ROWS: usize = 12;
const SENSOR_COLS: usize = 7;
pub struct PztProcessor {
first_frame: Option<Vec<f32>>,
first_contact_cop_x: Option<f32>,
first_contact_cop_y: Option<f32>,
contact_initialized: bool,
total_pressure_low_counter: usize,
}
impl PztProcessor {
pub fn new() -> Self {
Self {
first_frame: None,
first_contact_cop_x: None,
first_contact_cop_y: None,
contact_initialized: false,
total_pressure_low_counter: 0,
}
}
fn subtract_baseline(&mut self, current_frame: &[f32]) -> Vec<f32> {
if self.first_frame.is_none() {
self.first_frame = Some(current_frame.to_vec());
}
let baseline = self.first_frame.as_ref().unwrap();
current_frame
.iter()
.zip(baseline.iter())
.map(|(c, b)| (c - b).max(0.0))
.collect()
}
fn reset_cop_state(&mut self) {
self.first_contact_cop_x = None;
self.first_contact_cop_y = None;
self.contact_initialized = false;
self.total_pressure_low_counter = 0;
}
fn compute_pressure_direction(&mut self, frame: &[f32]) -> (f32, f32) {
let frame2d = Array2::from_shape_vec((SENSOR_ROWS, SENSOR_COLS), frame.to_vec()).unwrap();
let total_pressure: f32 = frame2d.sum();
if total_pressure < TOTAL_PRESSURE_LOW_THRESHOLD as f32 {
self.total_pressure_low_counter += 1;
} else {
self.total_pressure_low_counter = 0;
}
if self.total_pressure_low_counter >= COP_STABILITY_FRAMES_REQUIRED {
self.reset_cop_state();
return (0.0, 0.0);
}
if total_pressure == 0.0 {
return (0.0, 0.0);
}
let mut sum_x = 0.0;
let mut sum_y = 0.0;
for r in 0..SENSOR_ROWS {
for c in 0..SENSOR_COLS {
let val = frame2d[(r, c)];
sum_x += val * c as f32;
sum_y += val * r as f32;
}
}
let cop_x = sum_x / total_pressure;
let cop_y = sum_y / total_pressure;
if !self.contact_initialized {
self.first_contact_cop_x = Some(cop_x);
self.first_contact_cop_y = Some(cop_y);
self.contact_initialized = true;
return (0.0, 0.0);
}
let dx = cop_x - self.first_contact_cop_x.unwrap();
let dy = cop_y - self.first_contact_cop_y.unwrap();
(dx, dy)
}
fn compute_vector_angle(x: f32, y: f32) -> (f32, f32) {
let epsilon = 1e-8;
let mag = (x * x + y * y).sqrt();
let mut angle = (y).atan2(x + epsilon).to_degrees();
if angle < 0.0 {
angle += 360.0;
}
(angle, mag)
}
fn compute_pzt_angle(px: f32, py: f32) -> (f32, f32) {
Self::compute_vector_angle(px, -py)
}
pub fn get_pzt_angle(&mut self, adc_data: &[f32]) -> Result<f32, &'static str> {
if adc_data.len() != 84 {
return Err("ADC data length must be 84");
}
let baseline = self.subtract_baseline(adc_data);
let (dx, dy) = self.compute_pressure_direction(&baseline);
let (angle, _) = Self::compute_pzt_angle(dx, dy);
Ok(angle)
}
pub fn reset_baseline(&mut self) {
self.first_frame = None;
self.reset_cop_state();
}
}

View File

@@ -2,21 +2,37 @@ 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};
#[cfg(feature = "multi-dim")]
use crate::serial_core::multi_dim_force::PztProcessor;
use crate::serial_core::record::Recording;
use crate::serial_core::record::{FrameTiming, RecordedFrame};
#[cfg(feature = "devkit")]
use crate::devkit::{proto::SensorFrame, DevKitState};
use anyhow::Result;
use log::debug;
use std::future::pending;
#[cfg(feature = "devkit")]
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tauri::{AppHandle, Emitter};
#[cfg(feature = "devkit")]
use tauri::Manager;
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 crate::serial_core::record::{FrameTiming, RecordedFrame};
const AUTO_SUB_INTERVAL: Duration = Duration::from_nanos(16_666_667);
pub enum PollMode<F> {
Disable,
Enabled(Box<dyn PollRequester<F>>)
Enabled(Box<dyn PollRequester<F>>),
}
struct PendingSubFrame<F> {
frame: F,
values: Vec<i32>,
}
pub trait SerialFrame: Clone + Send + 'static {
@@ -168,11 +184,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>(
@@ -183,7 +207,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,
@@ -196,21 +220,22 @@ where
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
});
let mut poll_sub_interval = time::interval(AUTO_SUB_INTERVAL);
poll_sub_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut chart_state = HudChartState::new();
let mut buffer = [0u8; 1024];
let mut prune_interval = time::interval(Duration::from_millis(450));
#[cfg(feature = "multi-dim")]
let mut pzt_processor = PztProcessor::new();
let mut pending_sub_frame: Option<PendingSubFrame<F>> = None;
prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = cancel.cancelled() => break,
@@ -236,6 +261,21 @@ where
app.emit("hud_stream", packet)?;
}
}
_ = poll_sub_interval.tick() => {
if let Some(pending) = pending_sub_frame.take() {
let display_values = build_display_values(
&mut chart_state,
pending.values.as_slice(),
);
if let Some(packet) = pending
.frame
.to_hud_packet(&mut chart_state, display_values.as_deref())
{
app.emit("hud_stream", packet)?;
}
}
}
read_result = port.read(&mut buffer) => {
let n = read_result?;
if n == 0 {
@@ -256,23 +296,38 @@ where
.await?
.map(|vals| vals.into_iter().map(Into::into).collect::<Vec<i32>>());
let mut record = recording.lock().map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
record.push(RecordedFrame{
timing: FrameTiming { pts_ms: None, dts_ms: frame.dts_ms() },
let mut record = recording
.lock()
.map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
record.push(RecordedFrame {
timing: FrameTiming {
pts_ms: None,
dts_ms: frame.dts_ms(),
},
frame: frame.clone(),
});
drop(record);
let display_values = if let Some(vals) = decode_res.as_ref() {
let summary = vals.iter().copied().sum::<i32>();
let force = raw_to_g1(summary as u32);
chart_state.record_summary(force as f32);
chart_state.record_pressure_matrix(vals.as_slice());
Some(vec![summary])
} else {
None
};
if let Some(vals) = decode_res {
#[cfg(feature = "multi-dim")]
{
let pzt_values = vals.iter().map(|value| *value as f32).collect::<Vec<f32>>();
if let Ok(angle) = pzt_processor.get_pzt_angle(&pzt_values) {
// debug!("pzt angle: {:.2}", angle);
}
}
#[cfg(feature = "devkit")]
{
let summary = vals.iter().copied().sum::<i32>();
let force = raw_to_g1(summary as u32);
push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force);
}
if let Some(packet) = frame.to_hud_packet(&mut chart_state, display_values.as_deref()) {
pending_sub_frame = Some(PendingSubFrame {
frame: frame.clone(),
values: vals,
});
} else if let Some(packet) = frame.to_hud_packet(&mut chart_state, None) {
app.emit("hud_stream", packet)?;
}
}
@@ -282,13 +337,73 @@ where
Ok(())
}
fn build_display_values(chart_state: &mut HudChartState, values: &[i32]) -> Option<Vec<i32>> {
let summary = values.iter().copied().sum::<i32>();
let force = raw_to_g1(summary as u32);
chart_state.record_summary(force as f32);
chart_state.record_pressure_matrix(values);
Some(vec![summary])
}
#[cfg(feature = "devkit")]
fn push_devkit_frame(app: &AppHandle, values: &[i32], dts_ms: u64, resultant_force: f64) {
let devkit_state = app.state::<DevKitState>();
if !devkit_state.running.load(Ordering::Relaxed) {
return;
}
let (rows, cols) = infer_matrix_shape(values.len());
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let seq = timestamp_ms;
let matrix = values
.iter()
.map(|value| (*value).max(0) as u32)
.collect::<Vec<_>>();
devkit_state.push_frame(SensorFrame {
seq,
timestamp_ms,
rows,
cols,
matrix,
resultant_force,
dts_ms: dts_ms as u32,
});
}
#[cfg(feature = "devkit")]
fn infer_matrix_shape(len: usize) -> (u32, u32) {
if len == 84 {
return (12, 7);
}
if len == 0 {
return (0, 0);
}
let mut best = (len, 1);
let mut factor = 1usize;
while factor * factor <= len {
if len % factor == 0 {
best = (len / factor, factor);
}
factor += 1;
}
(best.0 as u32, best.1 as u32)
}
fn raw_to_g1(raw: u32) -> f64 {
const X: [u32; 11] = [
0, 74602, 105503, 131459, 153512, 172041, 193794, 218947, 240580, 295118, 332346,
const X: [u32; 12] = [
0, 84402, 117218, 140176, 159126, 175812, 191484, 208758, 224703, 252448, 302361, 352703,
];
const Y: [f64; 11] = [
0.0, 160.0, 260.0, 360.0, 460.0, 560.0, 660.0, 860.0, 1060.0, 1560.0, 2060.0,
const Y: [f64; 12] = [
0.0, 160.0, 260.0, 360.0, 460.0, 560.0, 660.0, 760.0, 860.0, 1060.0, 1560.0, 2060.0,
];
let n = X.len();