first commit
This commit is contained in:
6
src-tauri/src/serial_core/codec.rs
Normal file
6
src-tauri/src/serial_core/codec.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use crate::serial_core::error::CodecError;
|
||||
use std::time::Instant;
|
||||
pub trait Codec<F> {
|
||||
fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result<Vec<F>, CodecError>;
|
||||
fn encode(&self, frame: &F) -> Result<Vec<u8>, CodecError>;
|
||||
}
|
||||
4
src-tauri/src/serial_core/codecs/mod.rs
Normal file
4
src-tauri/src/serial_core/codecs/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
use crate::serial_core::{frame::TestFrame, record::Recording};
|
||||
|
||||
pub mod test;
|
||||
pub type TestRecording = Recording<TestFrame>;
|
||||
258
src-tauri/src/serial_core/codecs/test.rs
Normal file
258
src-tauri/src/serial_core/codecs/test.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use std::io::Read;
|
||||
use std::time::Instant;
|
||||
use crate::serial_core::frame::{crc8, usize_to_u16_be_bytes, FrameHandler};
|
||||
use crate::serial_core::{codec::Codec, error::CodecError, frame::TestFrame};
|
||||
use anyhow::anyhow;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Local;
|
||||
use csv::StringRecord;
|
||||
use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording};
|
||||
|
||||
pub struct TestCodec {
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct TestHandler;
|
||||
|
||||
impl TestCodec {
|
||||
pub fn new() -> TestCodec {
|
||||
Self { buffer: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Codec<TestFrame> for TestCodec {
|
||||
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();
|
||||
|
||||
loop {
|
||||
if self.buffer.len() < 6 {
|
||||
break;
|
||||
}
|
||||
|
||||
let header_pos = self.buffer.windows(2).position(|w| w == [0xAA, 0x55]);
|
||||
|
||||
let Some(pos) = header_pos else {
|
||||
self.buffer.clear();
|
||||
break;
|
||||
};
|
||||
if pos > 0 {
|
||||
self.buffer.drain(0..pos);
|
||||
}
|
||||
|
||||
if self.buffer.len() < 6 {
|
||||
break;
|
||||
}
|
||||
|
||||
let cmd = self.buffer[2];
|
||||
let length_bytes = [self.buffer[3], self.buffer[4]];
|
||||
let length = u16::from_be_bytes(length_bytes) as usize;
|
||||
let frame_length = (length + 6) as usize;
|
||||
if self.buffer.len() < frame_length {
|
||||
break;
|
||||
}
|
||||
let payload = self.buffer[5..5 + length].to_vec();
|
||||
let checksum = crc8(payload.as_slice());
|
||||
if self.buffer[frame_length - 1] != checksum {
|
||||
self.buffer.drain(0..1);
|
||||
continue;
|
||||
}
|
||||
let dts = elapsed_millis(session_started_at);
|
||||
println!("dts_ms: {dts}");
|
||||
frames.push(TestFrame {
|
||||
header: [0xAA, 0x55],
|
||||
cmd: cmd,
|
||||
length: length,
|
||||
payload: payload,
|
||||
checksum: checksum,
|
||||
dts_ms: dts,
|
||||
});
|
||||
|
||||
self.buffer.drain(0..frame_length);
|
||||
}
|
||||
|
||||
Ok(frames)
|
||||
}
|
||||
fn encode(&self, frame: &TestFrame) -> Result<Vec<u8>, CodecError> {
|
||||
let _ = u16::try_from(frame.payload.len()).map_err(|_| CodecError::PayloadTooLarge)?;
|
||||
let mut out = Vec::with_capacity(6 + frame.length);
|
||||
out.extend_from_slice(&frame.header);
|
||||
out.push(frame.cmd);
|
||||
out.extend_from_slice(&usize_to_u16_be_bytes(frame.length));
|
||||
out.extend_from_slice(&frame.payload);
|
||||
out.push(frame.checksum);
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FrameHandler<TestFrame, i32> for TestHandler {
|
||||
async fn on_frame(&mut self, frame: &TestFrame) -> anyhow::Result<Option<Vec<i32>>> {
|
||||
match frame.cmd {
|
||||
0x01 => {
|
||||
let vals = parse_data_frame(&frame.payload)?;
|
||||
Ok(Some(vals))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_data_frame(data: &[u8]) -> Result<Vec<i32>, CodecError> {
|
||||
if data.len() % 2 != 0 {
|
||||
return Err(CodecError::InvalidLength);
|
||||
}
|
||||
|
||||
let vals: Vec<i32> = data
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]) as i32)
|
||||
.collect::<Vec<i32>>();
|
||||
|
||||
Ok(vals)
|
||||
}
|
||||
|
||||
fn elapsed_millis(start_at: Instant) -> u64 {
|
||||
start_at.elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
pub struct TestCsvExporter;
|
||||
pub struct TestCsvImporter {
|
||||
channels: usize,
|
||||
data_row: usize,
|
||||
packets: Vec<TestDataPacket>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TestDataPacket {
|
||||
pub data: Vec<i32>,
|
||||
pub dts_ms: u64
|
||||
}
|
||||
|
||||
impl TryFrom<&TestFrame> for TestDataPacket {
|
||||
type Error = CodecError;
|
||||
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 })
|
||||
}
|
||||
}
|
||||
// impl From<TestFrame> for TestDataPacket {
|
||||
// fn from(frame: TestFrame) -> Self {
|
||||
// let data = parse_data_frame(&frame.payload)?;
|
||||
// let dts = frame.dts_ms;
|
||||
// TestDataPacket { data: data, dts_ms: dts }
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
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()))
|
||||
.unwrap_or(0);
|
||||
let mut header: Vec<String> = Vec::new();
|
||||
for i in 0..channel_nb {
|
||||
header.push(format!("channel{}", i + 1));
|
||||
}
|
||||
header.push("dts".to_string());
|
||||
|
||||
header
|
||||
}
|
||||
|
||||
fn csv_row(&self, item: &RecordedFrame<TestFrame>) -> anyhow::Result<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)
|
||||
}
|
||||
}
|
||||
|
||||
impl TestCsvImporter {
|
||||
pub fn new(_path: &str) -> TestCsvImporter {
|
||||
Self {
|
||||
channels: 0,
|
||||
data_row: 0,
|
||||
packets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_record(&mut self, record: StringRecord) -> anyhow::Result<TestDataPacket>{
|
||||
if self.channels == 0 {
|
||||
return Err(anyhow!("csv header is missing channel columns"));
|
||||
}
|
||||
|
||||
if record.len() < self.channels + 1 {
|
||||
return Err(anyhow!("csv row has insufficient columns"));
|
||||
}
|
||||
|
||||
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"))?;
|
||||
data.push(cell.parse::<i32>()?);
|
||||
}
|
||||
|
||||
let dts_cell = record
|
||||
.get(self.channels)
|
||||
.ok_or_else(|| anyhow!("missing dts cell"))?;
|
||||
let dts_ms = dts_cell.parse::<u64>()?;
|
||||
|
||||
Ok(TestDataPacket {
|
||||
data: data,
|
||||
dts_ms: dts_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CsvImporter<TestDataPacket> for TestCsvImporter {
|
||||
fn load<R: Read>(&mut self, reader: R) -> anyhow::Result<Vec<TestDataPacket>> {
|
||||
let mut rdr = csv::Reader::from_reader(reader);
|
||||
let headers = rdr.headers()?.clone();
|
||||
self.channels = headers.len().saturating_sub(1);
|
||||
self.data_row = 0;
|
||||
self.packets.clear();
|
||||
|
||||
for record in rdr.records() {
|
||||
let record = record?;
|
||||
let packet = self.parse_record(record)?;
|
||||
self.packets.push(packet);
|
||||
self.data_row += 1;
|
||||
}
|
||||
|
||||
Ok(self.packets.clone())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn export_recording_csv<W>(recording: &Recording<TestFrame>, writer: W) -> anyhow::Result<()>
|
||||
where
|
||||
W: std::io::Write,
|
||||
{
|
||||
let now = Local::now();
|
||||
let filename = format!("joyson_{}", now.format("%Y%m%d_%H%M%S"));
|
||||
write_csv(recording, &TestCsvExporter, &filename)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use csv::Reader;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn test_read_csv_basic() -> anyhow::Result<()> {
|
||||
let mut rdr = Reader::from_path("recording_20260329_125238.csv")?;
|
||||
let headers = rdr.headers()?;
|
||||
println!("headers: {:?}", headers);
|
||||
|
||||
for result in rdr.records() {
|
||||
let record = result?;
|
||||
println!("record: {:?}", record);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
52
src-tauri/src/serial_core/error.rs
Normal file
52
src-tauri/src/serial_core/error.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use serde::Serialize;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub enum SerialError {
|
||||
OpenError,
|
||||
CloseError,
|
||||
ScanError,
|
||||
InvalidConfig,
|
||||
AlreadyConnected,
|
||||
StateError,
|
||||
NoRecordedData,
|
||||
ExportError,
|
||||
ImportError,
|
||||
}
|
||||
|
||||
impl fmt::Display for SerialError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
SerialError::OpenError => write!(f, "Opening Error"),
|
||||
SerialError::CloseError => write!(f, "Closing Error"),
|
||||
SerialError::ScanError => write!(f, "Scan Error"),
|
||||
SerialError::InvalidConfig => write!(f, "Invalid Config"),
|
||||
SerialError::AlreadyConnected => write!(f, "Already Connected"),
|
||||
SerialError::StateError => write!(f, "State Error"),
|
||||
SerialError::NoRecordedData => write!(f, "No Recorded Data"),
|
||||
SerialError::ExportError => write!(f, "Export Error"),
|
||||
SerialError::ImportError => write!(f, "Import Error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CodecError {
|
||||
InvalidHeader,
|
||||
InvalidTail,
|
||||
InvalidLength,
|
||||
PayloadTooLarge,
|
||||
}
|
||||
|
||||
impl fmt::Display for CodecError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
CodecError::InvalidHeader => write!(f, "Invalid Header"),
|
||||
CodecError::InvalidTail => write!(f, "Invalid Tail"),
|
||||
CodecError::InvalidLength => write!(f, "Invalid Length"),
|
||||
CodecError::PayloadTooLarge => write!(f, "Payload too large"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CodecError {}
|
||||
43
src-tauri/src/serial_core/frame.rs
Normal file
43
src-tauri/src/serial_core/frame.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TestFrame {
|
||||
pub header: [u8; 2],
|
||||
pub cmd: u8,
|
||||
pub length: usize,
|
||||
pub payload: Vec<u8>,
|
||||
pub checksum: u8,
|
||||
pub dts_ms: u64
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[async_trait]
|
||||
pub trait FrameHandler<F, T>: Send {
|
||||
async fn on_frame(&mut self, frame: &F) -> Result<Option<Vec<T>>>;
|
||||
}
|
||||
|
||||
pub fn usize_to_u16_be_bytes(n: usize) -> [u8; 2] {
|
||||
(n as u16).to_be_bytes()
|
||||
}
|
||||
|
||||
pub fn usize_to_u16_le_bytes(n: usize) -> [u8; 2] {
|
||||
(n as u16).to_be_bytes()
|
||||
}
|
||||
|
||||
pub fn crc8(data: &[u8]) -> u8 {
|
||||
let mut crc: u8 = 0x00;
|
||||
|
||||
for &byte in data {
|
||||
crc ^= byte;
|
||||
for _ in 0..8 {
|
||||
if (crc & 0x80) != 0 {
|
||||
crc = (crc << 1) ^ 0x07;
|
||||
} else {
|
||||
crc <<= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crc
|
||||
}
|
||||
27
src-tauri/src/serial_core/mod.rs
Normal file
27
src-tauri/src/serial_core/mod.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use crate::serial_core::{frame::TestFrame, record::Recording};
|
||||
|
||||
pub mod codec;
|
||||
pub mod codecs;
|
||||
pub mod error;
|
||||
pub mod frame;
|
||||
pub mod model;
|
||||
pub mod serial;
|
||||
pub mod record;
|
||||
|
||||
pub type TestRecording = Recording<TestFrame>;
|
||||
|
||||
pub struct SerialConnection {
|
||||
pub port: String,
|
||||
}
|
||||
|
||||
pub fn connect(port: &str) -> Result<SerialConnection, String> {
|
||||
let port = port.trim();
|
||||
|
||||
if port.is_empty() {
|
||||
return Err("Serial port is required".to_string());
|
||||
}
|
||||
|
||||
Ok(SerialConnection {
|
||||
port: port.to_string(),
|
||||
})
|
||||
}
|
||||
500
src-tauri/src/serial_core/model.rs
Normal file
500
src-tauri/src/serial_core/model.rs
Normal file
@@ -0,0 +1,500 @@
|
||||
use crate::serial_core::frame::TestFrame;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
const MAX_POINTS: usize = 28;
|
||||
const MAX_SUMMARY_POINTS: usize = 42;
|
||||
const PANEL_STALE_AFTER: Duration = Duration::from_millis(2400);
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HudPacket {
|
||||
pub ts: u64,
|
||||
pub panels: Vec<HudSignalPanel>,
|
||||
pub summary: HudSummary,
|
||||
pub pressure_matrix: Option<Vec<f32>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HudSummary {
|
||||
pub label: String,
|
||||
pub points: Vec<f32>,
|
||||
pub latest: Option<f32>,
|
||||
pub min: Option<f32>,
|
||||
pub max: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum HudPanelSide {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum HudTone {
|
||||
Cyan,
|
||||
Lime,
|
||||
Orange,
|
||||
Violet,
|
||||
Gold,
|
||||
Rose,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HudSignalPanel {
|
||||
pub id: String,
|
||||
pub code: String,
|
||||
pub title: String,
|
||||
pub side: HudPanelSide,
|
||||
pub active: bool,
|
||||
pub series: Vec<HudSignalSeries>,
|
||||
pub icons: Vec<HudSignalIcon>,
|
||||
pub latest: Option<f32>,
|
||||
pub min: Option<f32>,
|
||||
pub max: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HudSignalSeries {
|
||||
pub id: String,
|
||||
pub tone: HudTone,
|
||||
pub points: Vec<f32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HudSignalIcon {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub tone: HudTone,
|
||||
}
|
||||
|
||||
struct HudPanelUpdate {
|
||||
source_id: String,
|
||||
values: Vec<f32>,
|
||||
}
|
||||
|
||||
struct PanelEntry {
|
||||
panel: HudSignalPanel,
|
||||
last_seen: Instant,
|
||||
}
|
||||
|
||||
pub struct HudChartState {
|
||||
panels: HashMap<String, PanelEntry>,
|
||||
order: Vec<String>,
|
||||
summary_points: Vec<f32>,
|
||||
pressure_matrix: Option<Vec<f32>>,
|
||||
last_frame_seen: Option<Instant>,
|
||||
}
|
||||
|
||||
impl HudChartState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
panels: HashMap::new(),
|
||||
order: Vec::new(),
|
||||
summary_points: Vec::new(),
|
||||
pressure_matrix: None,
|
||||
last_frame_seen: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_summary(&mut self, value: f32) {
|
||||
push_summary_point(&mut self.summary_points, value);
|
||||
}
|
||||
|
||||
pub fn record_pressure_matrix(&mut self, values: &[i32]) {
|
||||
if values.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.pressure_matrix = Some(values.iter().map(|value| *value as f32).collect());
|
||||
}
|
||||
|
||||
pub fn apply_frame(&mut self, frame: &TestFrame, decoded_values: Option<&[i32]>) -> HudPacket {
|
||||
let now = Instant::now();
|
||||
self.last_frame_seen = Some(now);
|
||||
|
||||
for update in expand_frame_updates(frame, decoded_values) {
|
||||
self.apply_update(update, now);
|
||||
}
|
||||
|
||||
self.prune_stale_at(now);
|
||||
self.snapshot()
|
||||
}
|
||||
|
||||
pub fn prune_stale(&mut self) -> Option<HudPacket> {
|
||||
let before = self.panels.len();
|
||||
let summary_points_before = self.summary_points.len();
|
||||
self.prune_stale_at(Instant::now());
|
||||
|
||||
if before == self.panels.len() && summary_points_before == self.summary_points.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(self.snapshot())
|
||||
}
|
||||
|
||||
fn apply_update(&mut self, update: HudPanelUpdate, now: Instant) {
|
||||
if update.values.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.panels.contains_key(&update.source_id) {
|
||||
let next_side = side_for_index(self.order.len());
|
||||
self.order.push(update.source_id.clone());
|
||||
self.panels.insert(
|
||||
update.source_id.clone(),
|
||||
PanelEntry {
|
||||
panel: build_panel(&update.source_id, next_side, update.values.len()),
|
||||
last_seen: now,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let entry = self
|
||||
.panels
|
||||
.get_mut(&update.source_id)
|
||||
.expect("panel entry should exist after insertion");
|
||||
|
||||
entry.last_seen = now;
|
||||
entry.panel.active = true;
|
||||
ensure_panel_channels(&mut entry.panel, update.values.len());
|
||||
|
||||
for (index, value) in update.values.into_iter().enumerate() {
|
||||
if let Some(series) = entry.panel.series.get_mut(index) {
|
||||
push_point(&mut series.points, value);
|
||||
}
|
||||
}
|
||||
|
||||
refresh_panel_stats(&mut entry.panel);
|
||||
}
|
||||
|
||||
fn prune_stale_at(&mut self, now: Instant) {
|
||||
self.panels
|
||||
.retain(|_, entry| now.duration_since(entry.last_seen) <= PANEL_STALE_AFTER);
|
||||
self.order.retain(|id| self.panels.contains_key(id));
|
||||
|
||||
let summary_stale = self
|
||||
.last_frame_seen
|
||||
.map(|last_seen| now.duration_since(last_seen) > PANEL_STALE_AFTER)
|
||||
.unwrap_or(false);
|
||||
|
||||
if summary_stale {
|
||||
self.summary_points.clear();
|
||||
self.pressure_matrix = None;
|
||||
self.last_frame_seen = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&mut self) -> HudPacket {
|
||||
self.rebalance_sides();
|
||||
|
||||
let panels = self
|
||||
.order
|
||||
.iter()
|
||||
.filter_map(|id| self.panels.get(id).map(|entry| entry.panel.clone()))
|
||||
.collect();
|
||||
|
||||
HudPacket {
|
||||
ts: now_millis(),
|
||||
panels,
|
||||
summary: build_summary(&self.summary_points),
|
||||
pressure_matrix: self.pressure_matrix.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rebalance_sides(&mut self) {
|
||||
for (index, id) in self.order.iter().enumerate() {
|
||||
if let Some(entry) = self.panels.get_mut(id) {
|
||||
entry.panel.side = side_for_index(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HudChartState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_panel(source_id: &str, side: HudPanelSide, channel_count: usize) -> HudSignalPanel {
|
||||
HudSignalPanel {
|
||||
id: format!("panel-{source_id}"),
|
||||
code: source_id.to_string(),
|
||||
title: format!("Source {source_id}"),
|
||||
side,
|
||||
active: true,
|
||||
series: build_panel_series(source_id, channel_count, &[]),
|
||||
icons: build_panel_icons(source_id, channel_count),
|
||||
latest: None,
|
||||
min: None,
|
||||
max: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_frame_updates(frame: &TestFrame, decoded_values: Option<&[i32]>) -> Vec<HudPanelUpdate> {
|
||||
if let Some(values) = decoded_values {
|
||||
if values.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
return vec![HudPanelUpdate {
|
||||
source_id: format_source_id(frame.cmd),
|
||||
values: values.iter().map(|value| *value as f32).collect(),
|
||||
}];
|
||||
}
|
||||
|
||||
let chunks = frame.payload.chunks_exact(4);
|
||||
|
||||
if !frame.payload.is_empty() && chunks.remainder().is_empty() {
|
||||
return chunks.map(build_update_from_chunk).collect();
|
||||
}
|
||||
|
||||
vec![HudPanelUpdate {
|
||||
source_id: format_source_id(frame.cmd),
|
||||
values: fallback_values(frame),
|
||||
}]
|
||||
}
|
||||
|
||||
fn build_update_from_chunk(chunk: &[u8]) -> HudPanelUpdate {
|
||||
HudPanelUpdate {
|
||||
source_id: format_source_id(chunk[0]),
|
||||
values: chunk[1..]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, byte)| normalize_value(*byte, tone_for_index(index)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_values(frame: &TestFrame) -> Vec<f32> {
|
||||
let mut bytes = frame.payload.clone();
|
||||
|
||||
if bytes.is_empty() {
|
||||
bytes.extend([
|
||||
frame.cmd,
|
||||
frame.length as u8,
|
||||
frame.checksum,
|
||||
frame.cmd.wrapping_add(frame.checksum),
|
||||
]);
|
||||
}
|
||||
|
||||
while bytes.len() < 3 {
|
||||
let previous = *bytes.last().unwrap_or(&frame.cmd);
|
||||
bytes.push(
|
||||
previous
|
||||
.wrapping_add(frame.cmd)
|
||||
.wrapping_add(bytes.len() as u8),
|
||||
);
|
||||
}
|
||||
|
||||
bytes
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, byte)| normalize_value(byte, tone_for_index(index)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_value(byte: u8, tone: HudTone) -> f32 {
|
||||
let base = (byte as f32 / 255.0) * 100.0;
|
||||
let offset = match tone {
|
||||
HudTone::Cyan => 6.0,
|
||||
HudTone::Lime => 0.0,
|
||||
HudTone::Orange => -6.0,
|
||||
HudTone::Violet => 10.0,
|
||||
HudTone::Gold => -10.0,
|
||||
HudTone::Rose => 3.0,
|
||||
};
|
||||
|
||||
(base + offset).clamp(0.0, 100.0)
|
||||
}
|
||||
|
||||
fn format_source_id(byte: u8) -> String {
|
||||
if byte.is_ascii_alphanumeric() {
|
||||
(byte as char).to_ascii_uppercase().to_string()
|
||||
} else {
|
||||
format!("CH{:02X}", byte)
|
||||
}
|
||||
}
|
||||
|
||||
fn side_for_index(index: usize) -> HudPanelSide {
|
||||
if index % 2 == 0 {
|
||||
HudPanelSide::Left
|
||||
} else {
|
||||
HudPanelSide::Right
|
||||
}
|
||||
}
|
||||
|
||||
fn push_point(points: &mut Vec<f32>, value: f32) {
|
||||
if points.len() >= MAX_POINTS {
|
||||
points.remove(0);
|
||||
}
|
||||
|
||||
points.push((value * 10.0).round() / 10.0);
|
||||
}
|
||||
|
||||
fn build_panel_series(
|
||||
source_id: &str,
|
||||
channel_count: usize,
|
||||
previous: &[HudSignalSeries],
|
||||
) -> Vec<HudSignalSeries> {
|
||||
(0..channel_count)
|
||||
.map(|index| HudSignalSeries {
|
||||
id: format!("{source_id}-series-{}", index + 1),
|
||||
tone: tone_for_index(index),
|
||||
points: previous
|
||||
.get(index)
|
||||
.map(|series| series.points.clone())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_panel_icons(source_id: &str, channel_count: usize) -> Vec<HudSignalIcon> {
|
||||
(0..channel_count)
|
||||
.map(|index| HudSignalIcon {
|
||||
id: format!("{source_id}-icon-{}", index + 1),
|
||||
label: if channel_count == 1 {
|
||||
"TOTAL".to_string()
|
||||
} else {
|
||||
format!("{source_id}-{}", index + 1)
|
||||
},
|
||||
tone: tone_for_index(index),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ensure_panel_channels(panel: &mut HudSignalPanel, channel_count: usize) {
|
||||
if panel.series.len() == channel_count && panel.icons.len() == channel_count {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.series = build_panel_series(&panel.code, channel_count, &panel.series);
|
||||
panel.icons = build_panel_icons(&panel.code, channel_count);
|
||||
}
|
||||
|
||||
fn refresh_panel_stats(panel: &mut HudSignalPanel) {
|
||||
let latest_values: Vec<f32> = panel
|
||||
.series
|
||||
.iter()
|
||||
.filter_map(|series| series.points.last().copied())
|
||||
.collect();
|
||||
|
||||
panel.latest = if latest_values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(latest_values.iter().sum::<f32>() / latest_values.len() as f32)
|
||||
};
|
||||
|
||||
panel.min = panel
|
||||
.series
|
||||
.iter()
|
||||
.flat_map(|series| series.points.iter().copied())
|
||||
.reduce(f32::min);
|
||||
|
||||
panel.max = panel
|
||||
.series
|
||||
.iter()
|
||||
.flat_map(|series| series.points.iter().copied())
|
||||
.reduce(f32::max);
|
||||
}
|
||||
|
||||
fn tone_for_index(index: usize) -> HudTone {
|
||||
match index % 6 {
|
||||
0 => HudTone::Cyan,
|
||||
1 => HudTone::Lime,
|
||||
2 => HudTone::Orange,
|
||||
3 => HudTone::Violet,
|
||||
4 => HudTone::Gold,
|
||||
_ => HudTone::Rose,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_summary_point(points: &mut Vec<f32>, value: f32) {
|
||||
if points.len() >= MAX_SUMMARY_POINTS {
|
||||
points.remove(0);
|
||||
}
|
||||
|
||||
points.push((value * 10.0).round() / 10.0);
|
||||
}
|
||||
|
||||
fn build_summary(points: &[f32]) -> HudSummary {
|
||||
HudSummary {
|
||||
label: "TOTAL".to_string(),
|
||||
points: points.to_vec(),
|
||||
latest: points.last().copied(),
|
||||
min: points.iter().copied().reduce(f32::min),
|
||||
max: points.iter().copied().reduce(f32::max),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod tests {
|
||||
// use super::*;
|
||||
//
|
||||
// fn sample_frame() -> TestFrame {
|
||||
// TestFrame {
|
||||
// header: [0xAA, 0x55],
|
||||
// cmd: 0x01,
|
||||
// length: 4,
|
||||
// payload: vec![0x00, 0x0A, 0x00, 0x14],
|
||||
// checksum: 0,
|
||||
//
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// #[test]
|
||||
// fn prune_stale_clears_panels_and_summary_after_timeout() {
|
||||
// let mut state = HudChartState::new();
|
||||
// let frame = sample_frame();
|
||||
//
|
||||
// state.record_summary(30.0);
|
||||
// let _ = state.apply_frame(&frame, Some(&[10, 20]));
|
||||
//
|
||||
// let stale_now = Instant::now();
|
||||
// let stale_seen = stale_now - PANEL_STALE_AFTER - Duration::from_millis(1);
|
||||
//
|
||||
// state.last_frame_seen = Some(stale_seen);
|
||||
//
|
||||
// for entry in state.panels.values_mut() {
|
||||
// entry.last_seen = stale_seen;
|
||||
// }
|
||||
//
|
||||
// let packet = state
|
||||
// .prune_stale()
|
||||
// .expect("stale data should emit an update");
|
||||
//
|
||||
// assert!(packet.panels.is_empty());
|
||||
// assert!(packet.summary.points.is_empty());
|
||||
// assert!(state.panels.is_empty());
|
||||
// assert!(state.summary_points.is_empty());
|
||||
// }
|
||||
//
|
||||
// #[test]
|
||||
// fn prune_stale_keeps_recent_summary_points() {
|
||||
// let mut state = HudChartState::new();
|
||||
// let frame = sample_frame();
|
||||
//
|
||||
// state.record_summary(30.0);
|
||||
// let _ = state.apply_frame(&frame, Some(&[10, 20]));
|
||||
//
|
||||
// state.last_frame_seen = Some(Instant::now());
|
||||
//
|
||||
// assert!(state.prune_stale().is_none());
|
||||
// assert_eq!(state.summary_points, vec![30.0]);
|
||||
// assert_eq!(state.panels.len(), 1);
|
||||
// }
|
||||
// }
|
||||
64
src-tauri/src/serial_core/record.rs
Normal file
64
src-tauri/src/serial_core/record.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use std::fs::{write, File};
|
||||
use std::io;
|
||||
use anyhow::{Result, anyhow};
|
||||
use csv::Reader;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FrameTiming {
|
||||
pub pts_ms: Option<u64>,
|
||||
pub dts_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RecordedFrame<F> {
|
||||
pub timing: FrameTiming,
|
||||
pub frame: F
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Recording<F> {
|
||||
pub frames: Vec<RecordedFrame<F>>
|
||||
}
|
||||
|
||||
impl<F> Recording<F> {
|
||||
pub fn new() -> Recording<F> { Self { frames: Vec::new() } }
|
||||
pub fn push(&mut self, ite: RecordedFrame<F>) {
|
||||
self.frames.push(ite);
|
||||
}
|
||||
}
|
||||
|
||||
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>>;
|
||||
}
|
||||
|
||||
// TODO: CsvImporter
|
||||
pub trait CsvImporter<P> {
|
||||
fn load<R: std::io::Read>(&mut self, reader: R) -> anyhow::Result<Vec<P>>;
|
||||
}
|
||||
|
||||
pub fn write_csv<F, E>(
|
||||
recording: &Recording<F>,
|
||||
exporter: &E,
|
||||
path: &str
|
||||
// mut writer: W,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
E: CsvExporter<F>,
|
||||
// W: std::io::Write
|
||||
{
|
||||
let header = exporter.csv_header(&recording);
|
||||
// let mut wrt = csv::Writer::from_writer(io::stdout());
|
||||
|
||||
let mut wrt = csv::Writer::from_path(format!("{}.csv", path))?;
|
||||
wrt.write_record(header)?;
|
||||
for f in &recording.frames {
|
||||
let row = exporter.csv_row(f)?;
|
||||
wrt.write_record(&row)?;
|
||||
}
|
||||
|
||||
wrt.flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
80
src-tauri/src/serial_core/serial.rs
Normal file
80
src-tauri/src/serial_core/serial.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use crate::serial_core::codec::Codec;
|
||||
use crate::serial_core::frame::{FrameHandler, TestFrame};
|
||||
use crate::serial_core::model::HudChartState;
|
||||
use anyhow::Result;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::time::{self, Duration, MissedTickBehavior};
|
||||
use tokio_serial::SerialStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
use log::info;
|
||||
use crate::serial_core::record::{FrameTiming, RecordedFrame};
|
||||
use crate::serial_core::TestRecording;
|
||||
|
||||
pub async fn run_serial<C, H, T>(
|
||||
app: AppHandle,
|
||||
mut port: SerialStream,
|
||||
mut codec: C,
|
||||
mut handler: H,
|
||||
session_started_at: Instant,
|
||||
recording: Arc<Mutex<TestRecording>>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()>
|
||||
where
|
||||
C: Codec<TestFrame> + Send + 'static,
|
||||
H: FrameHandler<TestFrame, T> + Send + 'static,
|
||||
T: Into<i32>,
|
||||
{
|
||||
let mut chart_state = HudChartState::new();
|
||||
let mut buffer = [0u8; 1024];
|
||||
let mut prune_interval = time::interval(Duration::from_millis(450));
|
||||
prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = 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 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let frames = codec.decode(&buffer[..n], session_started_at)?;
|
||||
for frame in frames {
|
||||
let decode_res = handler
|
||||
.on_frame(&frame)
|
||||
.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 },
|
||||
frame: frame.clone(),
|
||||
});
|
||||
|
||||
let display_values = if let Some(vals) = decode_res.as_ref() {
|
||||
let summary = vals.iter().copied().sum::<i32>();
|
||||
info!("dot value summary: {}", summary);
|
||||
chart_state.record_summary(summary as f32);
|
||||
chart_state.record_pressure_matrix(vals.as_slice());
|
||||
Some(vec![summary])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let packet = chart_state.apply_frame(&frame, display_values.as_deref());
|
||||
app.emit("hud_stream", packet)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user