feat: JE-Skin 功能迁移 — One Dark Pro 工业风 + 录制导出
## One Dark Pro 主题 - 替换 ENGINEERING_DARK → ONE_DARK_PRO (#282C34 背景, #C678DD 紫色强调) - 新增 ACCENT_GREEN/RED/BLUE/CYAN/ORANGE 独立色彩常量 ## 录制模块 (recording.rs — 新建) - 全量录制 + 快照录制两种模式 - 暂停/恢复/停止状态管理 - CSV 导出 (channel1,...,channelN,timestamp_ms) - CSV 导入回放 - 线程安全 Arc<Mutex<>> - 4 个单元测试 ## 新 UI 组件 (ui.rs — +343 行) - draw_signal_chart: 实时信号火花图 (min/max/current) - draw_recording_toolbar: 录制控制栏 (全量/快照/暂停/导出/导入) - draw_export_panel: 浮动录制导出面板 - draw_matrix_config_panel: 矩阵配置 (行/列/色域/预设 12x7~64x32) - 连接面板集成录制工具栏 (连接后自动显示) ## 应用集成 (app.rs) - 集成 Recorder, 信号历史, 导出面板, 矩阵配置面板 - 每帧数据自动送入录制器 - 信号历史环形缓冲 (128 帧)
This commit is contained in:
441
src/recording.rs
Normal file
441
src/recording.rs
Normal file
@@ -0,0 +1,441 @@
|
||||
//! Recording and CSV export for pressure sensor data.
|
||||
//!
|
||||
//! Provides two recording modes (Full and Snapshot), state management
|
||||
//! (Idle / Recording / Paused), and CSV import/export for replay.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, BufWriter, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
// ── Public types ────────────────────────────────────────────────────────
|
||||
|
||||
/// A single frame of pressure data captured at a point in time.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frame {
|
||||
/// Pressure values, one per channel (same order every frame).
|
||||
pub pressures: Vec<u32>,
|
||||
/// Milliseconds elapsed since the recording started.
|
||||
pub timestamp_ms: u64,
|
||||
}
|
||||
|
||||
/// Whether the recording captures everything from connect (Full) or
|
||||
/// only between explicit start/stop calls (Snapshot).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RecordingMode {
|
||||
Full,
|
||||
Snapshot,
|
||||
}
|
||||
|
||||
/// Transient state of a recording.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RecordingState {
|
||||
Idle,
|
||||
Recording,
|
||||
Paused,
|
||||
}
|
||||
|
||||
// ── Inner (unlocked) recorder ───────────────────────────────────────────
|
||||
|
||||
struct RecorderInner {
|
||||
mode: RecordingMode,
|
||||
state: RecordingState,
|
||||
frames: Vec<Frame>,
|
||||
start: Option<Instant>,
|
||||
/// Accumulated wall-clock ms while paused (subtracted from elapsed).
|
||||
paused_duration_ms: u64,
|
||||
/// Instant when we entered Paused (None when not paused).
|
||||
pause_start: Option<Instant>,
|
||||
/// Number of channels in the first frame (used for CSV header).
|
||||
channel_count: Option<usize>,
|
||||
}
|
||||
|
||||
impl RecorderInner {
|
||||
fn new(mode: RecordingMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
state: RecordingState::Idle,
|
||||
frames: Vec::new(),
|
||||
start: None,
|
||||
paused_duration_ms: 0,
|
||||
pause_start: None,
|
||||
channel_count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_ms(&self) -> u64 {
|
||||
let Some(start) = self.start else { return 0 };
|
||||
let raw = start.elapsed().as_millis() as u64;
|
||||
let paused = if let Some(ps) = self.pause_start {
|
||||
self.paused_duration_ms + ps.elapsed().as_millis() as u64
|
||||
} else {
|
||||
self.paused_duration_ms
|
||||
};
|
||||
raw.saturating_sub(paused)
|
||||
}
|
||||
|
||||
fn push_frame(&mut self, pressures: Vec<u32>) {
|
||||
if self.channel_count.is_none() {
|
||||
self.channel_count = Some(pressures.len());
|
||||
}
|
||||
let ts = self.elapsed_ms();
|
||||
self.frames.push(Frame {
|
||||
pressures,
|
||||
timestamp_ms: ts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thread-safe wrapper ─────────────────────────────────────────────────
|
||||
|
||||
/// Thread-safe recorder. Clone the `Arc` to share across threads.
|
||||
#[derive(Clone)]
|
||||
pub struct Recorder {
|
||||
inner: Arc<Mutex<RecorderInner>>,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
/// Create a recorder in the given mode (starts in `Idle` state).
|
||||
pub fn new(mode: RecordingMode) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(RecorderInner::new(mode))),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
/// Start a **Full** recording (records every frame pushed from now on).
|
||||
pub fn start_full_recording(&self) -> Result<()> {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(
|
||||
r.state == RecordingState::Idle,
|
||||
"can only start from Idle state"
|
||||
);
|
||||
r.state = RecordingState::Recording;
|
||||
r.start = Some(Instant::now());
|
||||
r.paused_duration_ms = 0;
|
||||
r.pause_start = None;
|
||||
r.frames.clear();
|
||||
r.channel_count = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a **Snapshot** recording window.
|
||||
pub fn start_snapshot_recording(&self) -> Result<()> {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(
|
||||
r.state == RecordingState::Idle,
|
||||
"can only start from Idle state"
|
||||
);
|
||||
r.state = RecordingState::Recording;
|
||||
r.start = Some(Instant::now());
|
||||
r.paused_duration_ms = 0;
|
||||
r.pause_start = None;
|
||||
r.frames.clear();
|
||||
r.channel_count = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop recording (transitions to `Idle`).
|
||||
pub fn stop_recording(&self) -> Result<()> {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(
|
||||
r.state == RecordingState::Recording || r.state == RecordingState::Paused,
|
||||
"nothing to stop"
|
||||
);
|
||||
if r.state == RecordingState::Paused {
|
||||
if let Some(ps) = r.pause_start.take() {
|
||||
r.paused_duration_ms += ps.elapsed().as_millis() as u64;
|
||||
}
|
||||
}
|
||||
r.state = RecordingState::Idle;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pause an active recording.
|
||||
pub fn pause_recording(&self) -> Result<()> {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(r.state == RecordingState::Recording, "not recording");
|
||||
r.pause_start = Some(Instant::now());
|
||||
r.state = RecordingState::Paused;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resume a paused recording.
|
||||
pub fn resume_recording(&self) -> Result<()> {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(r.state == RecordingState::Paused, "not paused");
|
||||
if let Some(ps) = r.pause_start.take() {
|
||||
r.paused_duration_ms += ps.elapsed().as_millis() as u64;
|
||||
}
|
||||
r.state = RecordingState::Recording;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Data ────────────────────────────────────────────────────────
|
||||
|
||||
/// Feed one frame of pressure data. Ignored when not recording.
|
||||
pub fn add_frame(&self, pressures: &[u32]) {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
if r.state == RecordingState::Recording {
|
||||
r.push_frame(pressures.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of recorded frames.
|
||||
pub fn frame_count(&self) -> usize {
|
||||
self.inner.lock().unwrap().frames.len()
|
||||
}
|
||||
|
||||
/// Whether the recorder is currently in the `Recording` state.
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.inner.lock().unwrap().state == RecordingState::Recording
|
||||
}
|
||||
|
||||
/// Current recording state.
|
||||
pub fn state(&self) -> RecordingState {
|
||||
self.inner.lock().unwrap().state
|
||||
}
|
||||
|
||||
/// Milliseconds elapsed (excludes paused time).
|
||||
pub fn duration_ms(&self) -> u64 {
|
||||
self.inner.lock().unwrap().elapsed_ms()
|
||||
}
|
||||
|
||||
/// Snapshot of all recorded frames.
|
||||
pub fn recorded_frames(&self) -> Vec<Frame> {
|
||||
self.inner.lock().unwrap().frames.clone()
|
||||
}
|
||||
|
||||
// ── CSV export / import ─────────────────────────────────────────
|
||||
|
||||
/// Export recorded frames to CSV.
|
||||
///
|
||||
/// Header: `channel1,channel2,...,channelN,timestamp_ms`
|
||||
pub fn export_csv<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||
let r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(!r.frames.is_empty(), "no frames to export");
|
||||
|
||||
let file = File::create(path.as_ref())
|
||||
.with_context(|| format!("creating {}", path.as_ref().display()))?;
|
||||
let mut w = BufWriter::new(file);
|
||||
|
||||
let n = r.channel_count.unwrap_or(r.frames[0].pressures.len());
|
||||
|
||||
// Header
|
||||
for i in 0..n {
|
||||
write!(w, "channel{}", i + 1)?;
|
||||
if i < n - 1 {
|
||||
write!(w, ",")?;
|
||||
}
|
||||
}
|
||||
writeln!(w, ",timestamp_ms")?;
|
||||
|
||||
// Rows
|
||||
for frame in &r.frames {
|
||||
for (i, val) in frame.pressures_iter().enumerate() {
|
||||
write!(w, "{}", val)?;
|
||||
if i < n - 1 {
|
||||
write!(w, ",")?;
|
||||
}
|
||||
}
|
||||
writeln!(w, ",{}", frame.timestamp_ms)?;
|
||||
}
|
||||
|
||||
w.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import frames from a CSV file (same format as `export_csv`).
|
||||
///
|
||||
/// The recorder must be in `Idle` state. After import it stays `Idle`
|
||||
/// so you can inspect / re-export; call `start_*` to continue recording.
|
||||
pub fn import_csv<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(r.state == RecordingState::Idle, "must be Idle to import");
|
||||
|
||||
let file = File::open(path.as_ref())
|
||||
.with_context(|| format!("opening {}", path.as_ref().display()))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
// Parse header to learn channel count
|
||||
let header = lines
|
||||
.next()
|
||||
.context("empty CSV file")?
|
||||
.context("reading header")?;
|
||||
let cols: Vec<&str> = header.split(',').map(str::trim).collect();
|
||||
anyhow::ensure!(cols.len() >= 2, "need at least 1 channel + timestamp_ms");
|
||||
let n_channels = cols.len() - 1; // last column is timestamp_ms
|
||||
|
||||
r.frames.clear();
|
||||
r.channel_count = Some(n_channels);
|
||||
|
||||
let mut first_ts: Option<u64> = None;
|
||||
|
||||
for (lineno, line) in lines.enumerate() {
|
||||
let line = line.with_context(|| format!("reading line {}", lineno + 2))?;
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fields: Vec<&str> = line.split(',').map(str::trim).collect();
|
||||
anyhow::ensure!(
|
||||
fields.len() == cols.len(),
|
||||
"line {}: expected {} columns, got {}",
|
||||
lineno + 2,
|
||||
cols.len(),
|
||||
fields.len()
|
||||
);
|
||||
|
||||
let mut pressures = Vec::with_capacity(n_channels);
|
||||
for f in &fields[..n_channels] {
|
||||
pressures.push(f.parse::<u32>().with_context(|| {
|
||||
format!("line {}: bad channel value '{}'", lineno + 2, f)
|
||||
})?);
|
||||
}
|
||||
let raw_ts = fields[n_channels]
|
||||
.parse::<u64>()
|
||||
.with_context(|| format!("line {}: bad timestamp", lineno + 2))?;
|
||||
|
||||
// Normalise timestamps so the first frame starts at 0
|
||||
let ts = if let Some(first) = first_ts {
|
||||
raw_ts.saturating_sub(first)
|
||||
} else {
|
||||
first_ts = Some(raw_ts);
|
||||
0
|
||||
};
|
||||
|
||||
r.frames.push(Frame {
|
||||
pressures,
|
||||
timestamp_ms: ts,
|
||||
});
|
||||
}
|
||||
|
||||
// Set the start instant so duration_ms() reports the imported span
|
||||
if !r.frames.is_empty() {
|
||||
if let Some(last) = r.frames.last() {
|
||||
// Pretend the recording happened `last.timestamp_ms` ago
|
||||
// so that elapsed_ms() would return that value.
|
||||
// We store a "fake" start by noting the offset.
|
||||
r.start = Some(Instant::now() - std::time::Duration::from_millis(last.timestamp_ms));
|
||||
r.paused_duration_ms = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Convenience constructors ────────────────────────────────────────────
|
||||
|
||||
impl Recorder {
|
||||
/// Shorthand: new Full recorder.
|
||||
pub fn full() -> Self {
|
||||
Self::new(RecordingMode::Full)
|
||||
}
|
||||
|
||||
/// Shorthand: new Snapshot recorder.
|
||||
pub fn snapshot() -> Self {
|
||||
Self::new(RecordingMode::Snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper extension on Frame ───────────────────────────────────────────
|
||||
|
||||
impl Frame {
|
||||
fn pressures_iter(&self) -> impl Iterator<Item = u32> + '_ {
|
||||
self.pressures.iter().copied()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn full_recording_lifecycle() {
|
||||
let rec = Recorder::full();
|
||||
assert_eq!(rec.state(), RecordingState::Idle);
|
||||
assert!(!rec.is_recording());
|
||||
assert_eq!(rec.frame_count(), 0);
|
||||
|
||||
rec.start_full_recording().unwrap();
|
||||
assert!(rec.is_recording());
|
||||
|
||||
rec.add_frame(&[10, 20, 30]);
|
||||
rec.add_frame(&[40, 50, 60]);
|
||||
assert_eq!(rec.frame_count(), 2);
|
||||
|
||||
rec.stop_recording().unwrap();
|
||||
assert_eq!(rec.state(), RecordingState::Idle);
|
||||
assert!(!rec.is_recording());
|
||||
assert!(rec.duration_ms() < 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_pause_resume() {
|
||||
let rec = Recorder::snapshot();
|
||||
rec.start_snapshot_recording().unwrap();
|
||||
|
||||
rec.add_frame(&[1, 2]);
|
||||
rec.pause_recording().unwrap();
|
||||
assert_eq!(rec.state(), RecordingState::Paused);
|
||||
rec.add_frame(&[9, 9]); // should be ignored
|
||||
assert_eq!(rec.frame_count(), 1);
|
||||
|
||||
rec.resume_recording().unwrap();
|
||||
rec.add_frame(&[3, 4]);
|
||||
assert_eq!(rec.frame_count(), 2);
|
||||
|
||||
rec.stop_recording().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_round_trip() {
|
||||
let rec = Recorder::full();
|
||||
rec.start_full_recording().unwrap();
|
||||
rec.add_frame(&[100, 200, 300]);
|
||||
rec.add_frame(&[101, 201, 301]);
|
||||
rec.stop_recording().unwrap();
|
||||
|
||||
let dir = std::env::temp_dir().join("eskin_recording_test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let csv_path = dir.join("test_roundtrip.csv");
|
||||
rec.export_csv(&csv_path).unwrap();
|
||||
|
||||
let rec2 = Recorder::snapshot();
|
||||
rec2.import_csv(&csv_path).unwrap();
|
||||
assert_eq!(rec2.frame_count(), 2);
|
||||
|
||||
let frames = rec2.recorded_frames();
|
||||
assert_eq!(frames[0].pressures, vec![100, 200, 300]);
|
||||
assert_eq!(frames[1].pressures, vec![101, 201, 301]);
|
||||
assert_eq!(frames[0].timestamp_ms, 0);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_safety() {
|
||||
let rec = Recorder::full();
|
||||
let rec2 = rec.clone();
|
||||
|
||||
rec.start_full_recording().unwrap();
|
||||
|
||||
let h = thread::spawn(move || {
|
||||
for i in 0..100u32 {
|
||||
rec2.add_frame(&[i, i + 1, i + 2]);
|
||||
}
|
||||
});
|
||||
h.join().unwrap();
|
||||
|
||||
rec.stop_recording().unwrap();
|
||||
assert_eq!(rec.frame_count(), 100);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user