diff --git a/crates/xserial-client/src/config.rs b/crates/xserial-client/src/config.rs index b007145..3872156 100644 --- a/crates/xserial-client/src/config.rs +++ b/crates/xserial-client/src/config.rs @@ -5,10 +5,12 @@ use xserial_core::frame::{ fixed::FixedLengthFramer, length::{LengthConfig, LengthPrefixedFramer}, line::{LineConfig, LineFramer}, + mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer}, }; use xserial_core::protocol::{ ProtocolDecoder, hex::{HexConfig, HexDecoder}, + mixed::{MixedTextPlotConfig as MixedDecoderConfig, MixedTextPlotDecoder}, plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType}, text::{TextDecoder, TextEncoding}, }; @@ -39,6 +41,14 @@ pub enum FramerConfig { #[serde(default = "default_max_frame")] max_frame: usize, }, + MixedTextPlot { + #[serde(default = "default_true")] + strip_cr: bool, + #[serde(default = "default_max_line")] + max_line_len: usize, + #[serde(default = "default_max_frame")] + max_plot_frame: usize, + }, } fn default_true() -> bool { @@ -80,6 +90,15 @@ impl FramerConfig { max_payload, })), FramerConfig::Cobs { max_frame } => Box::new(CobsFramer::new(max_frame)), + FramerConfig::MixedTextPlot { + strip_cr, + max_line_len, + max_plot_frame, + } => Box::new(MixedTextPlotFramer::new(MixedFramerConfig { + strip_cr, + max_line_len, + max_plot_frame, + })), } } } @@ -109,6 +128,10 @@ pub enum DecoderConfig { #[serde(default)] format: PlotFormat, }, + MixedTextPlot { + #[serde(default)] + encoding: TextEncoding, + }, } fn default_sep() -> String { @@ -144,6 +167,9 @@ impl DecoderConfig { channels, format, })), + DecoderConfig::MixedTextPlot { encoding } => { + Box::new(MixedTextPlotDecoder::new(MixedDecoderConfig { encoding })) + } } } } @@ -240,6 +266,25 @@ mod tests { assert!(matches!(cfg, FramerConfig::Cobs { max_frame: 8192 })); } + #[test] + fn framer_mixed_serde() { + let json = + r#"{"MixedTextPlot":{"strip_cr":true,"max_line_len":4096,"max_plot_frame":8192}}"#; + let cfg: FramerConfig = serde_json::from_str(json).unwrap(); + match cfg { + FramerConfig::MixedTextPlot { + strip_cr, + max_line_len, + max_plot_frame, + } => { + assert!(strip_cr); + assert_eq!(max_line_len, 4096); + assert_eq!(max_plot_frame, 8192); + } + _ => panic!("expected MixedTextPlot"), + } + } + #[test] fn decoder_text_serde() { let cfg: DecoderConfig = serde_json::from_str(r#"{"Text":{"encoding":"Latin1"}}"#).unwrap(); @@ -290,6 +335,16 @@ mod tests { } } + #[test] + fn decoder_mixed_serde() { + let cfg: DecoderConfig = + serde_json::from_str(r#"{"MixedTextPlot":{"encoding":"Utf8"}}"#).unwrap(); + match cfg { + DecoderConfig::MixedTextPlot { encoding } => assert_eq!(encoding, TextEncoding::Utf8), + _ => panic!("expected MixedTextPlot"), + } + } + #[test] fn session_config_full_roundtrip() { let json = r#"{ @@ -327,6 +382,11 @@ mod tests { max_payload: 65536, }, FramerConfig::Cobs { max_frame: 1024 }, + FramerConfig::MixedTextPlot { + strip_cr: true, + max_line_len: 256, + max_plot_frame: 1024, + }, ] { let f = cfg.build(); assert_eq!(f.pending_len(), 0); @@ -365,5 +425,13 @@ mod tests { .name(), "Plot" ); + assert_eq!( + DecoderConfig::MixedTextPlot { + encoding: TextEncoding::Utf8 + } + .build() + .name(), + "MixedTextPlot" + ); } } diff --git a/crates/xserial-client/src/history.rs b/crates/xserial-client/src/history.rs index f0a4d19..32d80cf 100644 --- a/crates/xserial-client/src/history.rs +++ b/crates/xserial-client/src/history.rs @@ -31,6 +31,12 @@ impl RingBuffer { pub fn clear(&mut self) { self.buf.clear(); } + pub fn set_limit(&mut self, limit: usize) { + self.limit = limit; + while self.buf.len() > self.limit { + self.buf.pop_front(); + } + } pub fn drain_recent(&self, count: usize) -> Vec where T: Clone, diff --git a/crates/xserial-core/src/frame/cobs.rs b/crates/xserial-core/src/frame/cobs.rs index c3b350f..ec45828 100644 --- a/crates/xserial-core/src/frame/cobs.rs +++ b/crates/xserial-core/src/frame/cobs.rs @@ -87,7 +87,39 @@ impl Framer for CobsFramer { /// /// Returns `None` if the encoded data is invalid (e.g. an overhead byte /// points past the end of the buffer). -fn cobs_decode(encoded: &[u8]) -> Option> { +pub fn cobs_encode(payload: &[u8]) -> Vec { + if payload.is_empty() { + return vec![0x01]; + } + + let mut out = Vec::with_capacity(payload.len() + (payload.len() / 254) + 1); + let mut code_index = 0usize; + let mut code = 1u8; + out.push(0); + + for &byte in payload { + if byte == 0 { + out[code_index] = code; + code_index = out.len(); + out.push(0); + code = 1; + } else { + out.push(byte); + code = code.saturating_add(1); + if code == 0xFF { + out[code_index] = code; + code_index = out.len(); + out.push(0); + code = 1; + } + } + } + + out[code_index] = code; + out +} + +pub fn cobs_decode(encoded: &[u8]) -> Option> { if encoded.is_empty() { return Some(Vec::new()); } @@ -127,6 +159,14 @@ fn cobs_decode(encoded: &[u8]) -> Option> { mod tests { use super::*; + #[test] + fn cobs_roundtrip_with_zeros() { + let payload = [0x11, 0x00, 0x22, 0x33, 0x00, 0x44]; + let encoded = cobs_encode(&payload); + let decoded = cobs_decode(&encoded).unwrap(); + assert_eq!(decoded, payload); + } + // ── cobs_decode unit tests ────────────────────────────────────── #[test] diff --git a/crates/xserial-core/src/frame/mixed.rs b/crates/xserial-core/src/frame/mixed.rs new file mode 100644 index 0000000..0181bda --- /dev/null +++ b/crates/xserial-core/src/frame/mixed.rs @@ -0,0 +1,215 @@ +use super::Framer; +use crate::frame::cobs::cobs_decode; +use crate::protocol::mixed::{ + MIXED_FRAME_KIND_PLOT, MIXED_FRAME_KIND_TEXT, MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER, +}; + +#[derive(Debug, Clone)] +pub struct MixedTextPlotConfig { + pub strip_cr: bool, + pub max_line_len: usize, + pub max_plot_frame: usize, +} + +impl Default for MixedTextPlotConfig { + fn default() -> Self { + Self { + strip_cr: true, + max_line_len: 1024 * 1024, + max_plot_frame: 1024 * 1024, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + Text, + Escape, + Plot, +} + +#[derive(Debug, Clone)] +pub struct MixedTextPlotFramer { + state: State, + text_buf: Vec, + plot_buf: Vec, + plot_overflow: bool, + config: MixedTextPlotConfig, +} + +impl MixedTextPlotFramer { + pub fn new(config: MixedTextPlotConfig) -> Self { + Self { + state: State::Text, + text_buf: Vec::new(), + plot_buf: Vec::new(), + plot_overflow: false, + config, + } + } + + fn push_text_byte(&mut self, byte: u8, frames: &mut Vec>) { + if byte == b'\n' { + let mut line = std::mem::take(&mut self.text_buf); + if self.config.strip_cr && line.last() == Some(&b'\r') { + line.pop(); + } + if line.len() <= self.config.max_line_len { + frames.push(tag_text_frame(line)); + } + return; + } + + if self.text_buf.len() < self.config.max_line_len.saturating_add(1) { + self.text_buf.push(byte); + } + } + + fn finish_plot_frame(&mut self, frames: &mut Vec>) { + if self.plot_overflow || self.plot_buf.is_empty() { + self.plot_buf.clear(); + self.plot_overflow = false; + return; + } + + if let Some(decoded) = cobs_decode(&self.plot_buf) { + if decoded.len() <= self.config.max_plot_frame { + frames.push(tag_plot_frame(decoded)); + } + } + self.plot_buf.clear(); + self.plot_overflow = false; + } +} + +impl Framer for MixedTextPlotFramer { + fn feed(&mut self, data: &[u8]) -> Vec> { + let mut frames = Vec::new(); + + for &byte in data { + match self.state { + State::Text => { + if byte == MIXED_PLOT_ESCAPE { + self.state = State::Escape; + } else { + self.push_text_byte(byte, &mut frames); + } + } + State::Escape => { + self.state = State::Text; + match byte { + MIXED_PLOT_ESCAPE => self.push_text_byte(MIXED_PLOT_ESCAPE, &mut frames), + MIXED_PLOT_MARKER => { + self.plot_buf.clear(); + self.plot_overflow = false; + self.state = State::Plot; + } + _ => { + self.push_text_byte(MIXED_PLOT_ESCAPE, &mut frames); + self.push_text_byte(byte, &mut frames); + } + } + } + State::Plot => { + if byte == 0x00 { + self.finish_plot_frame(&mut frames); + self.state = State::Text; + } else if !self.plot_overflow { + let max_encoded_len = self + .config + .max_plot_frame + .saturating_add(self.config.max_plot_frame / 254) + .saturating_add(8); + if self.plot_buf.len() < max_encoded_len { + self.plot_buf.push(byte); + } else { + self.plot_overflow = true; + } + } + } + } + } + + frames + } + + fn flush(&mut self) -> Option> { + if matches!(self.state, State::Escape) { + self.text_buf.push(MIXED_PLOT_ESCAPE); + } + + self.state = State::Text; + self.plot_buf.clear(); + self.plot_overflow = false; + + if self.text_buf.is_empty() { + None + } else { + Some(tag_text_frame(std::mem::take(&mut self.text_buf))) + } + } + + fn reset(&mut self) { + self.state = State::Text; + self.text_buf.clear(); + self.plot_buf.clear(); + self.plot_overflow = false; + } + + fn pending_len(&self) -> usize { + self.text_buf.len() + self.plot_buf.len() + } +} + +fn tag_text_frame(mut payload: Vec) -> Vec { + let mut frame = Vec::with_capacity(payload.len() + 1); + frame.push(MIXED_FRAME_KIND_TEXT); + frame.append(&mut payload); + frame +} + +fn tag_plot_frame(mut payload: Vec) -> Vec { + let mut frame = Vec::with_capacity(payload.len() + 1); + frame.push(MIXED_FRAME_KIND_PLOT); + frame.append(&mut payload); + frame +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frame::cobs::cobs_encode; + + #[test] + fn mixed_text_lines_pass_through() { + let mut framer = MixedTextPlotFramer::new(MixedTextPlotConfig::default()); + let frames = framer.feed(b"hello\nworld\n"); + assert_eq!(frames, vec![b"\0hello".to_vec(), b"\0world".to_vec()]); + } + + #[test] + fn mixed_escaped_rs_stays_in_text() { + let mut framer = MixedTextPlotFramer::new(MixedTextPlotConfig::default()); + let frames = framer.feed(&[b'o', b'k', MIXED_PLOT_ESCAPE, MIXED_PLOT_ESCAPE, b'\n']); + assert_eq!(frames, vec![vec![0x00, b'o', b'k', MIXED_PLOT_ESCAPE]]); + } + + #[test] + fn mixed_extracts_plot_packet() { + let mut framer = MixedTextPlotFramer::new(MixedTextPlotConfig::default()); + let payload = [b'X', b'P', 1, 0, 8, 0, 1, 1, 0, 4, 0, 0, 0, 0, 0x80, 0x3f]; + let encoded = cobs_encode(&payload); + let mut stream = vec![MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER]; + stream.extend_from_slice(&encoded); + stream.push(0x00); + let frames = framer.feed(&stream); + assert_eq!(frames, vec![[vec![0x01], payload.to_vec()].concat()]); + } + + #[test] + fn mixed_ignores_truncated_plot_on_flush() { + let mut framer = MixedTextPlotFramer::new(MixedTextPlotConfig::default()); + let _ = framer.feed(&[MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER, 0x03, b'a']); + assert_eq!(framer.flush(), None); + } +} diff --git a/crates/xserial-core/src/frame/mod.rs b/crates/xserial-core/src/frame/mod.rs index 3733f68..f0fd957 100644 --- a/crates/xserial-core/src/frame/mod.rs +++ b/crates/xserial-core/src/frame/mod.rs @@ -2,6 +2,7 @@ pub mod cobs; pub mod fixed; pub mod length; pub mod line; +pub mod mixed; pub use crate::protocol::Endian; diff --git a/crates/xserial-core/src/protocol/mixed.rs b/crates/xserial-core/src/protocol/mixed.rs new file mode 100644 index 0000000..bc35530 --- /dev/null +++ b/crates/xserial-core/src/protocol/mixed.rs @@ -0,0 +1,221 @@ +use super::plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType}; +use super::text::{TextDecoder, TextEncoding}; +use super::{DecodedData, Endian, ProtocolDecoder}; + +pub const MIXED_PLOT_ESCAPE: u8 = 0x1E; +pub const MIXED_PLOT_MARKER: u8 = b'P'; +pub const MIXED_FRAME_KIND_TEXT: u8 = 0x00; +pub const MIXED_FRAME_KIND_PLOT: u8 = 0x01; + +const PLOT_PACKET_MAGIC: &[u8; 2] = b"XP"; +const PLOT_PACKET_VERSION: u8 = 1; +const PLOT_PACKET_HEADER_LEN: usize = 13; + +#[derive(Debug, Clone, Copy)] +pub struct MixedTextPlotConfig { + pub encoding: TextEncoding, +} + +impl Default for MixedTextPlotConfig { + fn default() -> Self { + Self { + encoding: TextEncoding::Utf8, + } + } +} + +#[derive(Debug, Clone)] +pub struct MixedTextPlotDecoder { + config: MixedTextPlotConfig, +} + +impl MixedTextPlotDecoder { + pub fn new(config: MixedTextPlotConfig) -> Self { + Self { config } + } + + pub fn build_plot_packet( + sample_type: SampleType, + endian: Endian, + channels: usize, + format: PlotFormat, + samples_per_channel: usize, + payload: &[u8], + ) -> Vec { + let mut packet = Vec::with_capacity(PLOT_PACKET_HEADER_LEN + payload.len()); + packet.extend_from_slice(PLOT_PACKET_MAGIC); + packet.push(PLOT_PACKET_VERSION); + packet.push(plot_format_to_u8(format)); + packet.push(sample_type_to_u8(sample_type)); + packet.push(match endian { + Endian::Little => 0, + Endian::Big => 1, + }); + packet.push(channels.min(u8::MAX as usize) as u8); + packet.extend_from_slice(&(samples_per_channel.min(u16::MAX as usize) as u16).to_le_bytes()); + packet.extend_from_slice(&(payload.len().min(u32::MAX as usize) as u32).to_le_bytes()); + packet.extend_from_slice(payload); + packet + } + + fn decode_plot(&self, frame: &[u8]) -> Option { + if frame.len() < PLOT_PACKET_HEADER_LEN { + return None; + } + if &frame[..2] != PLOT_PACKET_MAGIC { + return None; + } + if frame[2] != PLOT_PACKET_VERSION { + return None; + } + + let format = plot_format_from_u8(frame[3])?; + let sample_type = sample_type_from_u8(frame[4])?; + let endian = match frame[5] { + 0 => Endian::Little, + 1 => Endian::Big, + _ => return None, + }; + let channels = frame[6] as usize; + let samples_per_channel = u16::from_le_bytes([frame[7], frame[8]]) as usize; + let payload_len = u32::from_le_bytes([frame[9], frame[10], frame[11], frame[12]]) as usize; + let payload = frame.get(PLOT_PACKET_HEADER_LEN..PLOT_PACKET_HEADER_LEN + payload_len)?; + + let num_channels = match format { + PlotFormat::XY => 2, + _ => channels.max(1), + }; + if matches!(format, PlotFormat::XY) && channels != 2 { + return None; + } + + let expected_len = sample_type + .byte_size() + .checked_mul(num_channels)? + .checked_mul(samples_per_channel)?; + if payload.len() != expected_len { + return None; + } + + let decoder = PlotDecoder::new(PlotConfig { + sample_type, + endian, + channels: num_channels, + format, + }); + decoder.decode(payload) + } +} + +impl ProtocolDecoder for MixedTextPlotDecoder { + fn name(&self) -> &str { + "MixedTextPlot" + } + + fn decode(&self, frame: &[u8]) -> Option { + let (&kind, payload) = frame.split_first()?; + match kind { + MIXED_FRAME_KIND_TEXT => TextDecoder::new(self.config.encoding).decode(payload), + MIXED_FRAME_KIND_PLOT => self.decode_plot(payload), + _ => None, + } + } +} + +fn plot_format_to_u8(format: PlotFormat) -> u8 { + match format { + PlotFormat::Interleaved => 0, + PlotFormat::Block => 1, + PlotFormat::XY => 2, + } +} + +fn plot_format_from_u8(value: u8) -> Option { + match value { + 0 => Some(PlotFormat::Interleaved), + 1 => Some(PlotFormat::Block), + 2 => Some(PlotFormat::XY), + _ => None, + } +} + +fn sample_type_to_u8(sample_type: SampleType) -> u8 { + match sample_type { + SampleType::I8 => 0, + SampleType::U8 => 1, + SampleType::I16 => 2, + SampleType::U16 => 3, + SampleType::I32 => 4, + SampleType::U32 => 5, + SampleType::I64 => 6, + SampleType::U64 => 7, + SampleType::F32 => 8, + SampleType::F64 => 9, + } +} + +fn sample_type_from_u8(value: u8) -> Option { + match value { + 0 => Some(SampleType::I8), + 1 => Some(SampleType::U8), + 2 => Some(SampleType::I16), + 3 => Some(SampleType::U16), + 4 => Some(SampleType::I32), + 5 => Some(SampleType::U32), + 6 => Some(SampleType::I64), + 7 => Some(SampleType::U64), + 8 => Some(SampleType::F32), + 9 => Some(SampleType::F64), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mixed_decoder_decodes_text_frame() { + let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default()); + let frame = b"\0hello"; + assert!(matches!(decoder.decode(frame), Some(DecodedData::Text(ref s)) if s == "hello")); + } + + #[test] + fn mixed_decoder_decodes_plot_frame() { + let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default()); + let payload = [0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x40]; + let packet = MixedTextPlotDecoder::build_plot_packet( + SampleType::F32, + Endian::Little, + 1, + PlotFormat::Interleaved, + 2, + &payload, + ); + let frame = [vec![MIXED_FRAME_KIND_PLOT], packet].concat(); + let decoded = decoder.decode(&frame).unwrap(); + match decoded { + DecodedData::Plot(frame) => { + assert_eq!(frame.channels.len(), 1); + assert_eq!(frame.channels[0], vec![1.0, 2.0]); + } + other => panic!("expected Plot, got {other:?}"), + } + } + + #[test] + fn mixed_decoder_rejects_bad_payload_length() { + let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default()); + let packet = MixedTextPlotDecoder::build_plot_packet( + SampleType::F32, + Endian::Little, + 1, + PlotFormat::Interleaved, + 2, + &[0x00, 0x00, 0x80, 0x3f], + ); + let frame = [vec![MIXED_FRAME_KIND_PLOT], packet].concat(); + assert!(decoder.decode(&frame).is_none()); + } +} diff --git a/crates/xserial-core/src/protocol/mod.rs b/crates/xserial-core/src/protocol/mod.rs index 8ca7fc6..0bbdbe0 100644 --- a/crates/xserial-core/src/protocol/mod.rs +++ b/crates/xserial-core/src/protocol/mod.rs @@ -1,4 +1,5 @@ pub mod hex; +pub mod mixed; pub mod plot; pub mod text; diff --git a/crates/xserial-core/src/protocol/plot.rs b/crates/xserial-core/src/protocol/plot.rs index 7a97507..352773c 100644 --- a/crates/xserial-core/src/protocol/plot.rs +++ b/crates/xserial-core/src/protocol/plot.rs @@ -78,6 +78,7 @@ pub struct PlotFrame { pub channels: Vec>, pub raw: Vec, pub sample_type: SampleType, + pub format: PlotFormat, } impl PlotFrame { @@ -245,6 +246,7 @@ impl ProtocolDecoder for PlotDecoder { channels, raw: frame.to_vec(), sample_type: self.config.sample_type, + format: self.config.format, })) } } @@ -490,6 +492,7 @@ mod tests { channels: vec![], raw: vec![], sample_type: SampleType::U8, + format: PlotFormat::Interleaved, }; assert_eq!(frame.sample_count(), 0); } @@ -507,6 +510,7 @@ mod tests { match result { DecodedData::Plot(frame) => { assert_eq!(frame.raw, data); + assert_eq!(frame.format, PlotFormat::Interleaved); } other => panic!("expected Plot, got {:?}", other), } diff --git a/crates/xserial-core/src/transport/mod.rs b/crates/xserial-core/src/transport/mod.rs index 783d36a..ec564cb 100644 --- a/crates/xserial-core/src/transport/mod.rs +++ b/crates/xserial-core/src/transport/mod.rs @@ -4,7 +4,9 @@ use tokio::io::{AsyncRead, AsyncWrite}; use tracing::info; use crate::error::Result; -use crate::transport::serial::SerialTransport; +use crate::transport::serial::{ + SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport, +}; use crate::transport::tcp::TcpTransport; use crate::transport::udp::UdpTransport; @@ -12,6 +14,22 @@ pub mod serial; pub mod tcp; pub mod udp; +fn default_serial_data_bits() -> SerialDataBits { + SerialDataBits::Eight +} + +fn default_serial_parity() -> SerialParity { + SerialParity::None +} + +fn default_serial_stop_bits() -> SerialStopBits { + SerialStopBits::One +} + +fn default_serial_flow_control() -> SerialFlowControl { + SerialFlowControl::None +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum TransportType { Serial, @@ -34,6 +52,14 @@ pub enum TransportConfig { Serial { port: String, baud_rate: u32, + #[serde(default = "default_serial_data_bits")] + data_bits: SerialDataBits, + #[serde(default = "default_serial_parity")] + parity: SerialParity, + #[serde(default = "default_serial_stop_bits")] + stop_bits: SerialStopBits, + #[serde(default = "default_serial_flow_control")] + flow_control: SerialFlowControl, }, Tcp { addr: String, @@ -63,9 +89,21 @@ pub enum Connection { impl Connection { pub fn new(config: TransportConfig) -> Self { match config { - TransportConfig::Serial { port, baud_rate } => { - Connection::Serial(SerialTransport::new(port, baud_rate)) - } + TransportConfig::Serial { + port, + baud_rate, + data_bits, + parity, + stop_bits, + flow_control, + } => Connection::Serial(SerialTransport::new( + port, + baud_rate, + data_bits, + parity, + stop_bits, + flow_control, + )), TransportConfig::Tcp { addr } => Connection::Tcp(TcpTransport::new(addr)), TransportConfig::Udp { bind_addr, @@ -170,6 +208,20 @@ impl AsyncWrite for Connection { #[cfg(test)] mod tests { use super::*; + use crate::transport::serial::{ + SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, + }; + + fn serial_config(port: &str, baud_rate: u32) -> TransportConfig { + TransportConfig::Serial { + port: port.into(), + baud_rate, + data_bits: SerialDataBits::Eight, + parity: SerialParity::None, + stop_bits: SerialStopBits::One, + flow_control: SerialFlowControl::None, + } + } #[test] fn test_transport_type_display() { @@ -256,6 +308,10 @@ mod tests { let cfg = TransportConfig::Serial { port: "COM1".into(), baud_rate: 115200, + data_bits: SerialDataBits::Eight, + parity: SerialParity::None, + stop_bits: SerialStopBits::One, + flow_control: SerialFlowControl::None, }; let _ = format!("{:?}", cfg); @@ -273,10 +329,7 @@ mod tests { #[test] fn test_transport_config_clone() { - let original = TransportConfig::Serial { - port: "COM1".into(), - baud_rate: 115200, - }; + let original = serial_config("COM1", 115200); let cloned = original.clone(); assert_eq!(format!("{:?}", original), format!("{:?}", cloned)); @@ -296,13 +349,14 @@ mod tests { #[test] fn test_transport_config_serialize_serial() { - let cfg = TransportConfig::Serial { - port: "COM1".into(), - baud_rate: 115200, - }; + let cfg = serial_config("COM1", 115200); let json = serde_json::to_string(&cfg).unwrap(); assert!(json.contains("COM1")); assert!(json.contains("115200")); + assert!(json.contains("data_bits")); + assert!(json.contains("parity")); + assert!(json.contains("stop_bits")); + assert!(json.contains("flow_control")); } #[test] @@ -310,9 +364,44 @@ mod tests { let json = r#"{"Serial":{"port":"COM1","baud_rate":9600}}"#; let cfg: TransportConfig = serde_json::from_str(json).unwrap(); match cfg { - TransportConfig::Serial { port, baud_rate } => { + TransportConfig::Serial { + port, + baud_rate, + data_bits, + parity, + stop_bits, + flow_control, + } => { assert_eq!(port, "COM1"); assert_eq!(baud_rate, 9600); + assert_eq!(data_bits, SerialDataBits::Eight); + assert_eq!(parity, SerialParity::None); + assert_eq!(stop_bits, SerialStopBits::One); + assert_eq!(flow_control, SerialFlowControl::None); + } + _ => panic!("expected Serial variant"), + } + } + + #[test] + fn test_transport_config_deserialize_serial_with_explicit_options() { + let json = r#"{"Serial":{"port":"COM2","baud_rate":57600,"data_bits":"Seven","parity":"Even","stop_bits":"Two","flow_control":"Hardware"}}"#; + let cfg: TransportConfig = serde_json::from_str(json).unwrap(); + match cfg { + TransportConfig::Serial { + port, + baud_rate, + data_bits, + parity, + stop_bits, + flow_control, + } => { + assert_eq!(port, "COM2"); + assert_eq!(baud_rate, 57600); + assert_eq!(data_bits, SerialDataBits::Seven); + assert_eq!(parity, SerialParity::Even); + assert_eq!(stop_bits, SerialStopBits::Two); + assert_eq!(flow_control, SerialFlowControl::Hardware); } _ => panic!("expected Serial variant"), } @@ -393,10 +482,7 @@ mod tests { #[test] fn test_transport_config_roundtrip() { let configs = vec![ - TransportConfig::Serial { - port: "COM3".into(), - baud_rate: 57600, - }, + serial_config("COM3", 57600), TransportConfig::Tcp { addr: "10.0.0.1:9999".into(), }, @@ -425,10 +511,7 @@ mod tests { #[test] fn test_connection_new_serial() { - let conn = Connection::new(TransportConfig::Serial { - port: "COM1".into(), - baud_rate: 115200, - }); + let conn = Connection::new(serial_config("COM1", 115200)); assert_eq!(conn.transport_type(), TransportType::Serial); assert_eq!(conn.name(), "COM1"); assert!(!conn.is_connected()); @@ -468,10 +551,7 @@ mod tests { #[test] fn test_connection_is_connected_default() { - let serial = Connection::new(TransportConfig::Serial { - port: "COM1".into(), - baud_rate: 9600, - }); + let serial = Connection::new(serial_config("COM1", 9600)); let tcp = Connection::new(TransportConfig::Tcp { addr: "127.0.0.1:8080".into(), }); @@ -486,10 +566,7 @@ mod tests { #[test] fn test_connection_debug() { - let serial = Connection::new(TransportConfig::Serial { - port: "COM1".into(), - baud_rate: 115200, - }); + let serial = Connection::new(serial_config("COM1", 115200)); let tcp = Connection::new(TransportConfig::Tcp { addr: "127.0.0.1:8080".into(), }); @@ -515,10 +592,7 @@ mod tests { } fn serial_conn() -> Connection { - Connection::new(TransportConfig::Serial { - port: "COM1".into(), - baud_rate: 115200, - }) + Connection::new(serial_config("COM1", 115200)) } fn tcp_conn() -> Connection { diff --git a/crates/xserial-core/src/transport/serial.rs b/crates/xserial-core/src/transport/serial.rs index fffedb4..02a8177 100644 --- a/crates/xserial-core/src/transport/serial.rs +++ b/crates/xserial-core/src/transport/serial.rs @@ -1,24 +1,107 @@ use async_trait::async_trait; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; -use tokio_serial::{SerialPortBuilderExt, SerialStream}; +use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits}; use tracing::{debug, info, warn}; use super::{Transport, TransportType}; use crate::error::Result; +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum SerialDataBits { + Five, + Six, + Seven, + Eight, +} + +impl From for DataBits { + fn from(value: SerialDataBits) -> Self { + match value { + SerialDataBits::Five => DataBits::Five, + SerialDataBits::Six => DataBits::Six, + SerialDataBits::Seven => DataBits::Seven, + SerialDataBits::Eight => DataBits::Eight, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum SerialParity { + None, + Odd, + Even, +} + +impl From for Parity { + fn from(value: SerialParity) -> Self { + match value { + SerialParity::None => Parity::None, + SerialParity::Odd => Parity::Odd, + SerialParity::Even => Parity::Even, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum SerialStopBits { + One, + Two, +} + +impl From for StopBits { + fn from(value: SerialStopBits) -> Self { + match value { + SerialStopBits::One => StopBits::One, + SerialStopBits::Two => StopBits::Two, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum SerialFlowControl { + None, + Software, + Hardware, +} + +impl From for FlowControl { + fn from(value: SerialFlowControl) -> Self { + match value { + SerialFlowControl::None => FlowControl::None, + SerialFlowControl::Software => FlowControl::Software, + SerialFlowControl::Hardware => FlowControl::Hardware, + } + } +} + #[derive(Debug)] pub struct SerialTransport { port: Option, port_name: String, baud_rate: u32, + data_bits: SerialDataBits, + parity: SerialParity, + stop_bits: SerialStopBits, + flow_control: SerialFlowControl, } impl SerialTransport { - pub fn new(port_name: String, baud_rate: u32) -> Self { + pub fn new( + port_name: String, + baud_rate: u32, + data_bits: SerialDataBits, + parity: SerialParity, + stop_bits: SerialStopBits, + flow_control: SerialFlowControl, + ) -> Self { Self { port: None, port_name, baud_rate, + data_bits, + parity, + stop_bits, + flow_control, } } @@ -39,6 +122,22 @@ impl SerialTransport { pub fn baud_rate(&self) -> u32 { self.baud_rate } + + pub fn data_bits(&self) -> SerialDataBits { + self.data_bits + } + + pub fn parity(&self) -> SerialParity { + self.parity + } + + pub fn stop_bits(&self) -> SerialStopBits { + self.stop_bits + } + + pub fn flow_control(&self) -> SerialFlowControl { + self.flow_control + } } impl AsyncRead for SerialTransport { @@ -113,11 +212,20 @@ impl Transport for SerialTransport { } info!( - "Opening serial port {} at {} baud", - self.port_name, self.baud_rate + "Opening serial port {} at {} baud ({:?}, {:?}, {:?}, {:?})", + self.port_name, + self.baud_rate, + self.data_bits, + self.parity, + self.stop_bits, + self.flow_control ); let port = tokio_serial::new(&self.port_name, self.baud_rate) + .data_bits(self.data_bits.into()) + .parity(self.parity.into()) + .stop_bits(self.stop_bits.into()) + .flow_control(self.flow_control.into()) .open_native_async() .map_err(|e| { crate::error::Error::ConnectionFailed(format!( @@ -167,33 +275,72 @@ mod tests { #[test] fn test_new() { - let transport = SerialTransport::new("COM1".into(), 115200); + let transport = SerialTransport::new( + "COM1".into(), + 115200, + SerialDataBits::Eight, + SerialParity::None, + SerialStopBits::One, + SerialFlowControl::None, + ); assert_eq!(transport.port_name(), "COM1"); assert_eq!(transport.baud_rate(), 115200); } #[test] fn test_new_different_params() { - let transport = SerialTransport::new("COM3".into(), 9600); + let transport = SerialTransport::new( + "COM3".into(), + 9600, + SerialDataBits::Seven, + SerialParity::Even, + SerialStopBits::Two, + SerialFlowControl::Hardware, + ); assert_eq!(transport.port_name(), "COM3"); assert_eq!(transport.baud_rate(), 9600); + assert_eq!(transport.data_bits(), SerialDataBits::Seven); + assert_eq!(transport.parity(), SerialParity::Even); + assert_eq!(transport.stop_bits(), SerialStopBits::Two); + assert_eq!(transport.flow_control(), SerialFlowControl::Hardware); } #[test] fn test_name() { - let transport = SerialTransport::new("COM1".into(), 115200); + let transport = SerialTransport::new( + "COM1".into(), + 115200, + SerialDataBits::Eight, + SerialParity::None, + SerialStopBits::One, + SerialFlowControl::None, + ); assert_eq!(transport.name(), "COM1"); } #[test] fn test_transport_type() { - let transport = SerialTransport::new("COM1".into(), 115200); + let transport = SerialTransport::new( + "COM1".into(), + 115200, + SerialDataBits::Eight, + SerialParity::None, + SerialStopBits::One, + SerialFlowControl::None, + ); assert_eq!(transport.transport_type(), TransportType::Serial); } #[test] fn test_is_connected_false_by_default() { - let transport = SerialTransport::new("COM1".into(), 115200); + let transport = SerialTransport::new( + "COM1".into(), + 115200, + SerialDataBits::Eight, + SerialParity::None, + SerialStopBits::One, + SerialFlowControl::None, + ); assert!(!transport.is_connected()); } @@ -214,7 +361,14 @@ mod tests { #[tokio::test] async fn test_poll_read_not_connected() { - let mut transport = SerialTransport::new("COM1".into(), 115200); + let mut transport = SerialTransport::new( + "COM1".into(), + 115200, + SerialDataBits::Eight, + SerialParity::None, + SerialStopBits::One, + SerialFlowControl::None, + ); let pinned = Pin::new(&mut transport); let mut buf_data = [0u8; 16]; let mut buf = ReadBuf::new(&mut buf_data); @@ -230,7 +384,14 @@ mod tests { #[tokio::test] async fn test_poll_write_not_connected() { - let mut transport = SerialTransport::new("COM1".into(), 115200); + let mut transport = SerialTransport::new( + "COM1".into(), + 115200, + SerialDataBits::Eight, + SerialParity::None, + SerialStopBits::One, + SerialFlowControl::None, + ); let pinned = Pin::new(&mut transport); let data = b"hello"; let waker = noop_waker(); @@ -243,7 +404,14 @@ mod tests { #[tokio::test] async fn test_poll_flush_not_connected() { - let mut transport = SerialTransport::new("COM1".into(), 115200); + let mut transport = SerialTransport::new( + "COM1".into(), + 115200, + SerialDataBits::Eight, + SerialParity::None, + SerialStopBits::One, + SerialFlowControl::None, + ); let pinned = Pin::new(&mut transport); let waker = noop_waker(); let mut cx = Context::from_waker(&waker); @@ -255,7 +423,14 @@ mod tests { #[tokio::test] async fn test_poll_shutdown_not_connected() { - let mut transport = SerialTransport::new("COM1".into(), 115200); + let mut transport = SerialTransport::new( + "COM1".into(), + 115200, + SerialDataBits::Eight, + SerialParity::None, + SerialStopBits::One, + SerialFlowControl::None, + ); let pinned = Pin::new(&mut transport); let waker = noop_waker(); let mut cx = Context::from_waker(&waker); @@ -269,19 +444,40 @@ mod tests { #[test] fn test_transport_name() { - let transport = SerialTransport::new("COM1".into(), 115200); + let transport = SerialTransport::new( + "COM1".into(), + 115200, + SerialDataBits::Eight, + SerialParity::None, + SerialStopBits::One, + SerialFlowControl::None, + ); assert_eq!(transport.name(), "COM1"); } #[test] fn test_transport_transport_type() { - let transport = SerialTransport::new("COM1".into(), 115200); + let transport = SerialTransport::new( + "COM1".into(), + 115200, + SerialDataBits::Eight, + SerialParity::None, + SerialStopBits::One, + SerialFlowControl::None, + ); assert_eq!(transport.transport_type(), TransportType::Serial); } #[test] fn test_transport_is_connected() { - let transport = SerialTransport::new("COM1".into(), 115200); + let transport = SerialTransport::new( + "COM1".into(), + 115200, + SerialDataBits::Eight, + SerialParity::None, + SerialStopBits::One, + SerialFlowControl::None, + ); assert!(!transport.is_connected()); } } diff --git a/crates/xserial-core/tests/pipeline.rs b/crates/xserial-core/tests/pipeline.rs index d63ffcc..f6c9217 100644 --- a/crates/xserial-core/tests/pipeline.rs +++ b/crates/xserial-core/tests/pipeline.rs @@ -6,17 +6,23 @@ use tokio::time::timeout; use xserial_core::frame::{ Endian, Framer, + cobs::cobs_encode as raw_cobs_encode, cobs::CobsFramer, fixed::FixedLengthFramer, length::{LengthConfig, LengthPrefixedFramer}, line::{LineConfig, LineFramer}, + mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer}, }; use xserial_core::protocol::{ DecodedData, ProtocolDecoder, hex::{HexConfig, HexDecoder}, + mixed::{MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER, MixedTextPlotConfig, MixedTextPlotDecoder}, plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType}, text::{TextDecoder, TextEncoding}, }; +use xserial_core::transport::serial::{ + SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, +}; use xserial_core::transport::{Connection, TransportConfig, TransportType}; const TEST_TIMEOUT: Duration = Duration::from_secs(5); @@ -256,6 +262,58 @@ async fn tcp_fixed_plot_f32() { server.await.unwrap(); } +#[tokio::test] +async fn tcp_mixed_text_and_plot_single_stream() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let payload = [ + 0x00, 0x00, 0x80, 0x3f, // 1.0 + 0x00, 0x00, 0x00, 0x40, // 2.0 + ]; + let packet = MixedTextPlotDecoder::build_plot_packet( + SampleType::F32, + Endian::Little, + 1, + PlotFormat::Interleaved, + 2, + &payload, + ); + let mut mixed = b"status ok\n".to_vec(); + mixed.push(MIXED_PLOT_ESCAPE); + mixed.push(MIXED_PLOT_MARKER); + mixed.extend_from_slice(&raw_cobs_encode(&packet)); + mixed.push(0x00); + mixed.extend_from_slice(b"done\n"); + stream.write_all(&mixed).await.unwrap(); + }); + + let mut conn = Connection::new(TransportConfig::Tcp { addr }); + conn.connect().await.unwrap(); + + let mut framer = MixedTextPlotFramer::new(MixedFramerConfig::default()); + let frames = read_to_framer(&mut conn, &mut framer).await; + conn.disconnect().await.unwrap(); + + let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default()); + let results: Vec = frames + .iter() + .filter_map(|f| decoder.decode(f)) + .collect(); + + assert_eq!(results.len(), 3); + assert!(matches!(&results[0], DecodedData::Text(s) if s == "status ok")); + match &results[1] { + DecodedData::Plot(frame) => assert_eq!(frame.channels[0], vec![1.0, 2.0]), + other => panic!("expected Plot, got {other:?}"), + } + assert!(matches!(&results[2], DecodedData::Text(s) if s == "done")); + + server.await.unwrap(); +} + #[tokio::test] async fn tcp_fixed_plot_two_channel_u16() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -629,6 +687,10 @@ async fn connection_transport_type_dispatch() { let serial = Connection::new(TransportConfig::Serial { port: "COM1".into(), baud_rate: 115200, + data_bits: SerialDataBits::Eight, + parity: SerialParity::None, + stop_bits: SerialStopBits::One, + flow_control: SerialFlowControl::None, }); let tcp = Connection::new(TransportConfig::Tcp { addr: "127.0.0.1:8080".into(), diff --git a/crates/xserial-gui/src/app.rs b/crates/xserial-gui/src/app.rs index 3d9a2ed..8c98348 100644 --- a/crates/xserial-gui/src/app.rs +++ b/crates/xserial-gui/src/app.rs @@ -1,7 +1,8 @@ use std::sync::mpsc; +use std::time::Duration; -use crate::buffers::{HexBuffer, TextBuffer}; -use crate::panels::{config, console, hex_view, sidebar}; +use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer}; +use crate::panels::{config, console, hex_view, plot_view, sidebar}; use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder}; use xserial_client::SessionManager; use xserial_client::config::SessionConfig; @@ -31,6 +32,7 @@ impl ConnectionStatus { pub enum View { Text, Hex, + Plot, } #[derive(Clone, Copy, PartialEq)] @@ -52,6 +54,8 @@ pub struct SessionTab { pub status: ConnectionStatus, pub console: TextBuffer, pub hex: HexBuffer, + pub plot: PlotBuffer, + pub plot_view: plot_view::PlotViewState, pub view: View, pub auto_reconnect: bool, pub send_input: String, @@ -131,8 +135,9 @@ impl XserialApp { let history_limit = session_config.history_limit; tab.session_config = session_config.clone(); tab.auto_reconnect = session_config.auto_reconnect; - tab.console = TextBuffer::new(history_limit); - tab.hex = HexBuffer::new(history_limit); + tab.console.set_limit(history_limit); + tab.hex.set_limit(history_limit); + tab.plot.set_limit(history_limit); tab.status = ConnectionStatus::Connecting; tab.send_status = Some(String::from("Session reconfigured")); } @@ -173,7 +178,8 @@ impl XserialApp { match &entry.data { DecodedData::Text(_) => tab.console.push(&entry), DecodedData::Hex(_) => tab.hex.push(&entry), - DecodedData::Binary(_) | DecodedData::Plot(_) => {} + DecodedData::Plot(_) => tab.plot.push(&entry), + DecodedData::Binary(_) => {} } } } @@ -181,6 +187,15 @@ impl XserialApp { } } + fn wants_live_plot_repaint(&self) -> bool { + self.tabs + .get(self.active) + .map(|tab| { + matches!(tab.view, View::Plot) && matches!(tab.status, ConnectionStatus::Connected) + }) + .unwrap_or(false) + } + fn render_config_window(&mut self, ctx: &egui::Context) { if !self.config_open { return; @@ -215,6 +230,8 @@ impl XserialApp { status: ConnectionStatus::Connecting, console: TextBuffer::new(history_limit), hex: HexBuffer::new(history_limit), + plot: PlotBuffer::new(history_limit), + plot_view: plot_view::PlotViewState::default(), view: View::Text, auto_reconnect, send_input: String::new(), @@ -296,6 +313,12 @@ impl XserialApp { if ui.selectable_label(tab.view == View::Hex, "Hex").clicked() { tab.view = View::Hex; } + if ui + .selectable_label(tab.view == View::Plot, "Plot") + .clicked() + { + tab.view = View::Plot; + } }); if render_session_controls(ui, &manager, tab) { @@ -331,6 +354,7 @@ impl XserialApp { |ui| match tab.view { View::Text => console::render(ui, &tab.console, *display), View::Hex => hex_view::render(ui, &tab.hex, *display), + View::Plot => plot_view::render(ui, &tab.plot, tab.id, &mut tab.plot_view), }, ); @@ -354,6 +378,9 @@ impl eframe::App for XserialApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.drain_events(); + if self.wants_live_plot_repaint() { + ui.ctx().request_repaint_after(Duration::from_millis(16)); + } self.render_config_window(ui.ctx()); self.render_sidebar(ui); self.render_main_panel(ui); @@ -404,6 +431,13 @@ fn render_session_controls( configure_clicked = true; } + if ui.button("Clear").clicked() { + tab.console.clear(); + tab.hex.clear(); + tab.plot.clear(); + tab.send_status = Some(String::from("Cleared")); + } + let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect"); if response.changed() { if let Some(handle) = manager.get(tab.id) { diff --git a/crates/xserial-gui/src/buffers.rs b/crates/xserial-gui/src/buffers.rs index a1a5e6b..a59d32b 100644 --- a/crates/xserial-gui/src/buffers.rs +++ b/crates/xserial-gui/src/buffers.rs @@ -1,7 +1,9 @@ +use std::collections::VecDeque; use std::time::{Duration, Instant}; use xserial_client::RingBuffer; use xserial_client::event::DecodedEntry; use xserial_core::protocol::DecodedData; +use xserial_core::protocol::plot::{PlotFormat, PlotFrame}; #[derive(Clone)] pub enum LineDirection { @@ -52,6 +54,14 @@ impl TextBuffer { pub fn iter(&self) -> impl Iterator { self.lines.iter() } + + pub fn clear(&mut self) { + self.lines.clear(); + } + + pub fn set_limit(&mut self, limit: usize) { + self.lines.set_limit(limit); + } } #[derive(Clone)] @@ -128,4 +138,339 @@ impl HexBuffer { pub fn iter(&self) -> impl Iterator { self.lines.iter() } + + pub fn clear(&mut self) { + self.lines.clear(); + } + + pub fn set_limit(&mut self, limit: usize) { + self.lines.set_limit(limit); + } +} + +pub struct PlotSeries { + pub name: String, + points: VecDeque<[f64; 2]>, + next_x: f64, +} + +impl PlotSeries { + fn new(name: String) -> Self { + Self { + name, + points: VecDeque::new(), + next_x: 0.0, + } + } + + fn push_samples(&mut self, samples: &[f64], limit: usize) { + for sample in samples { + if !sample.is_finite() { + continue; + } + self.points.push_back([self.next_x, *sample]); + self.next_x += 1.0; + } + while self.points.len() > limit { + self.points.pop_front(); + } + } + + pub fn points(&self) -> impl Iterator + '_ { + self.points.iter().copied() + } + + pub fn render_points_time_series(&self, x_min: f64, x_max: f64, max_points: usize) -> Vec<[f64; 2]> { + if self.points.is_empty() || max_points == 0 { + return Vec::new(); + } + + let visible: Vec<[f64; 2]> = self + .points + .iter() + .copied() + .filter(|point| point[0] >= x_min && point[0] <= x_max) + .collect(); + if visible.len() <= max_points { + return visible; + } + + let bucket_count = (max_points / 2).max(1); + let width = (x_max - x_min).max(1.0); + let bucket_width = width / bucket_count as f64; + let mut rendered = Vec::with_capacity(bucket_count * 2); + let mut start = 0usize; + + for bucket_index in 0..bucket_count { + let bucket_start = x_min + bucket_width * bucket_index as f64; + let bucket_end = if bucket_index + 1 == bucket_count { + x_max + } else { + bucket_start + bucket_width + }; + + let mut min_point: Option<[f64; 2]> = None; + let mut max_point: Option<[f64; 2]> = None; + + while start < visible.len() { + let point = visible[start]; + if point[0] > bucket_end { + break; + } + if point[0] >= bucket_start { + match min_point { + Some(current) if current[1] <= point[1] => {} + _ => min_point = Some(point), + } + match max_point { + Some(current) if current[1] >= point[1] => {} + _ => max_point = Some(point), + } + } + start += 1; + } + + match (min_point, max_point) { + (Some(a), Some(b)) if a[0] <= b[0] => { + rendered.push(a); + if a != b { + rendered.push(b); + } + } + (Some(a), Some(b)) => { + rendered.push(b); + if a != b { + rendered.push(a); + } + } + (Some(a), None) | (None, Some(a)) => rendered.push(a), + (None, None) => {} + } + } + + if rendered.len() > max_points { + let stride = rendered.len().div_ceil(max_points); + rendered.into_iter().step_by(stride).collect() + } else { + rendered + } + } + + pub fn render_points_xy(&self, max_points: usize) -> Vec<[f64; 2]> { + if self.points.is_empty() || max_points == 0 { + return Vec::new(); + } + if self.points.len() <= max_points { + return self.points.iter().copied().collect(); + } + + let stride = self.points.len().div_ceil(max_points); + self.points.iter().copied().step_by(stride).collect() + } +} + +pub enum PlotSeriesKind { + TimeSeries, + XY, +} + +pub struct PlotBuffer { + limit: usize, + kind: PlotSeriesKind, + series: Vec, +} + +impl PlotBuffer { + pub fn new(limit: usize) -> Self { + Self { + limit, + kind: PlotSeriesKind::TimeSeries, + series: Vec::new(), + } + } + + pub fn push(&mut self, entry: &DecodedEntry) { + if let DecodedData::Plot(frame) = &entry.data { + self.push_frame(&entry.pipeline_name, frame); + } + } + + fn push_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) { + self.kind = match frame.format { + PlotFormat::XY => PlotSeriesKind::XY, + PlotFormat::Interleaved | PlotFormat::Block => PlotSeriesKind::TimeSeries, + }; + + if matches!(frame.format, PlotFormat::XY) { + self.push_xy_frame(pipeline_name, frame); + return; + } + + for (index, channel) in frame.channels.iter().enumerate() { + let series_name = if frame.channels.len() == 1 { + pipeline_name.to_owned() + } else { + format!("{pipeline_name}:ch{}", index + 1) + }; + + if let Some(series) = self + .series + .iter_mut() + .find(|series| series.name == series_name) + { + series.push_samples(channel, self.limit); + } else { + let mut series = PlotSeries::new(series_name); + series.push_samples(channel, self.limit); + self.series.push(series); + } + } + } + + fn push_xy_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) { + if frame.channels.len() < 2 { + return; + } + + let x = &frame.channels[0]; + let y = &frame.channels[1]; + let len = x.len().min(y.len()); + let series_name = format!("{pipeline_name}:xy"); + + let series = if let Some(series) = self + .series + .iter_mut() + .find(|series| series.name == series_name) + { + series + } else { + self.series.push(PlotSeries::new(series_name)); + self.series.last_mut().expect("just pushed") + }; + + for index in 0..len { + if !x[index].is_finite() || !y[index].is_finite() { + continue; + } + series.points.push_back([x[index], y[index]]); + } + while series.points.len() > self.limit { + series.points.pop_front(); + } + } + + pub fn clear(&mut self) { + self.series.clear(); + } + + pub fn set_limit(&mut self, limit: usize) { + self.limit = limit; + for series in &mut self.series { + while series.points.len() > self.limit { + series.points.pop_front(); + } + } + } + + pub fn is_empty(&self) -> bool { + self.series.is_empty() + } + + pub fn kind(&self) -> &PlotSeriesKind { + &self.kind + } + + pub fn iter(&self) -> impl Iterator { + self.series.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use xserial_client::event::DecodedEntry; + use xserial_core::protocol::DecodedData; + use xserial_core::protocol::plot::{PlotFrame, SampleType}; + + fn plot_entry() -> DecodedEntry { + DecodedEntry { + pipeline_name: String::from("plot"), + data: DecodedData::Plot(PlotFrame { + channels: vec![vec![1.0, 2.0, 3.0], vec![10.0, 20.0, 30.0]], + raw: vec![], + sample_type: SampleType::F32, + format: PlotFormat::Interleaved, + }), + } + } + + fn xy_plot_entry() -> DecodedEntry { + DecodedEntry { + pipeline_name: String::from("xy"), + data: DecodedData::Plot(PlotFrame { + channels: vec![vec![0.0, 1.0, 2.0], vec![10.0, 20.0, 30.0]], + raw: vec![], + sample_type: SampleType::F32, + format: PlotFormat::XY, + }), + } + } + + #[test] + fn plot_buffer_creates_series_per_channel() { + let mut buffer = PlotBuffer::new(8); + buffer.push(&plot_entry()); + + let series: Vec<_> = buffer.iter().collect(); + assert_eq!(series.len(), 2); + assert_eq!(series[0].name, "plot:ch1"); + assert_eq!(series[1].name, "plot:ch2"); + assert_eq!(series[0].points().count(), 3); + assert_eq!(series[1].points().count(), 3); + } + + #[test] + fn plot_buffer_limit_and_clear_work() { + let mut buffer = PlotBuffer::new(2); + buffer.push(&plot_entry()); + assert_eq!(buffer.iter().next().unwrap().points().count(), 2); + + buffer.clear(); + assert!(buffer.is_empty()); + } + + #[test] + fn plot_buffer_xy_uses_xy_points() { + let mut buffer = PlotBuffer::new(8); + buffer.push(&xy_plot_entry()); + + assert!(matches!(buffer.kind(), PlotSeriesKind::XY)); + let series: Vec<_> = buffer.iter().collect(); + assert_eq!(series.len(), 1); + let points: Vec<_> = series[0].points().collect(); + assert_eq!(points, vec![[0.0, 10.0], [1.0, 20.0], [2.0, 30.0]]); + } + + #[test] + fn plot_series_time_series_downsamples_to_budget() { + let mut series = PlotSeries::new(String::from("plot")); + series.push_samples(&(0..100).map(|n| n as f64).collect::>(), 200); + + let rendered = series.render_points_time_series(0.0, 99.0, 16); + assert!(!rendered.is_empty()); + assert!(rendered.len() <= 16); + assert!(rendered.iter().all(|point| point[0] >= 0.0 && point[0] <= 99.0)); + } + + #[test] + fn plot_series_xy_downsamples_to_budget() { + let mut series = PlotSeries::new(String::from("xy")); + for n in 0..100 { + series.points.push_back([n as f64, (n * 2) as f64]); + } + + let rendered = series.render_points_xy(10); + assert!(rendered.len() <= 10); + assert_eq!(rendered.first().copied(), Some([0.0, 0.0])); + } } diff --git a/crates/xserial-gui/src/panels/config.rs b/crates/xserial-gui/src/panels/config.rs index d882c6d..4960078 100644 --- a/crates/xserial-gui/src/panels/config.rs +++ b/crates/xserial-gui/src/panels/config.rs @@ -1,6 +1,10 @@ use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui}; use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig}; +use xserial_core::protocol::plot::PlotFormat; use xserial_core::transport::TransportConfig; +use xserial_core::transport::serial::{ + SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, +}; // ── 表单数据结构(简化版,方便 UI 渲染)──────────────────────────── @@ -9,14 +13,27 @@ pub struct ConfigForm { pub transport: TransportChoice, pub port: String, pub baud: u32, + pub serial_data_bits: SerialDataBits, + pub serial_parity: SerialParity, + pub serial_stop_bits: SerialStopBits, + pub serial_flow_control: SerialFlowControl, pub addr: String, pub udp_bind: String, pub udp_remote: String, - pub pipelines: Vec<(String, FramerChoice, DecoderChoice)>, + pub pipelines: Vec, pub history: usize, pub auto_reconnect: bool, } +#[derive(Clone)] +pub struct PipelineForm { + pub name: String, + pub framer: FramerChoice, + pub decoder: DecoderChoice, + pub plot_channels: usize, + pub plot_format: PlotFormat, +} + #[derive(Clone, PartialEq)] pub enum TransportChoice { Serial, @@ -28,6 +45,7 @@ pub enum TransportChoice { pub enum FramerChoice { Line, Fixed(usize), + MixedTextPlot, } #[derive(Clone, PartialEq)] @@ -35,6 +53,7 @@ pub enum DecoderChoice { Text, Hex, Plot, + MixedTextPlot, } impl Default for ConfigForm { @@ -43,10 +62,20 @@ impl Default for ConfigForm { transport: TransportChoice::Tcp, port: "/dev/ttyUSB0".into(), baud: 115200, + serial_data_bits: SerialDataBits::Eight, + serial_parity: SerialParity::None, + serial_stop_bits: SerialStopBits::One, + serial_flow_control: SerialFlowControl::None, addr: "127.0.0.1:8080".into(), udp_bind: "0.0.0.0:9000".into(), udp_remote: String::new(), - pipelines: vec![("text".into(), FramerChoice::Line, DecoderChoice::Text)], + pipelines: vec![PipelineForm { + name: "text".into(), + framer: FramerChoice::Line, + decoder: DecoderChoice::Text, + plot_channels: 1, + plot_format: PlotFormat::Interleaved, + }], history: 10000, auto_reconnect: false, } @@ -69,6 +98,22 @@ impl ConfigForm { TransportConfig::Serial { baud_rate, .. } => *baud_rate, _ => 115200, }, + serial_data_bits: match &config.transport { + TransportConfig::Serial { data_bits, .. } => *data_bits, + _ => SerialDataBits::Eight, + }, + serial_parity: match &config.transport { + TransportConfig::Serial { parity, .. } => *parity, + _ => SerialParity::None, + }, + serial_stop_bits: match &config.transport { + TransportConfig::Serial { stop_bits, .. } => *stop_bits, + _ => SerialStopBits::One, + }, + serial_flow_control: match &config.transport { + TransportConfig::Serial { flow_control, .. } => *flow_control, + _ => SerialFlowControl::None, + }, addr: match &config.transport { TransportConfig::Tcp { addr } => addr.clone(), _ => String::from("127.0.0.1:8080"), @@ -88,6 +133,7 @@ impl ConfigForm { let framer = match &pipeline.framer { FramerConfig::Line { .. } => FramerChoice::Line, FramerConfig::Fixed { frame_len } => FramerChoice::Fixed(*frame_len), + FramerConfig::MixedTextPlot { .. } => FramerChoice::MixedTextPlot, FramerConfig::Length { .. } | FramerConfig::Cobs { .. } => { FramerChoice::Line } @@ -96,8 +142,23 @@ impl ConfigForm { DecoderConfig::Text { .. } => DecoderChoice::Text, DecoderConfig::Hex { .. } => DecoderChoice::Hex, DecoderConfig::Plot { .. } => DecoderChoice::Plot, + DecoderConfig::MixedTextPlot { .. } => DecoderChoice::MixedTextPlot, }; - (pipeline.name.clone(), framer, decoder) + let plot_channels = match &pipeline.decoder { + DecoderConfig::Plot { channels, .. } => *channels, + _ => 1, + }; + let plot_format = match &pipeline.decoder { + DecoderConfig::Plot { format, .. } => *format, + _ => PlotFormat::Interleaved, + }; + PipelineForm { + name: pipeline.name.clone(), + framer, + decoder, + plot_channels, + plot_format, + } }) .collect(), history: config.history_limit, @@ -112,6 +173,10 @@ impl ConfigForm { TransportChoice::Serial => TransportConfig::Serial { port: self.port.clone(), baud_rate: self.baud, + data_bits: self.serial_data_bits, + parity: self.serial_parity, + stop_bits: self.serial_stop_bits, + flow_control: self.serial_flow_control, }, TransportChoice::Tcp => TransportConfig::Tcp { addr: self.addr.clone(), @@ -128,16 +193,21 @@ impl ConfigForm { pipelines: self .pipelines .iter() - .map(|(name, fc, dc)| PipelineConfig { - name: name.clone(), - framer: match fc { + .map(|pipeline| PipelineConfig { + name: pipeline.name.clone(), + framer: match &pipeline.framer { FramerChoice::Line => FramerConfig::Line { strip_cr: true, max_line_len: 1_048_576, }, FramerChoice::Fixed(n) => FramerConfig::Fixed { frame_len: *n }, + FramerChoice::MixedTextPlot => FramerConfig::MixedTextPlot { + strip_cr: true, + max_line_len: 1_048_576, + max_plot_frame: 1_048_576, + }, }, - decoder: match dc { + decoder: match &pipeline.decoder { DecoderChoice::Text => DecoderConfig::Text { encoding: xserial_core::protocol::text::TextEncoding::Utf8, }, @@ -150,8 +220,11 @@ impl ConfigForm { DecoderChoice::Plot => DecoderConfig::Plot { sample_type: xserial_core::protocol::plot::SampleType::F32, endian: xserial_core::protocol::Endian::Little, - channels: 1, - format: xserial_core::protocol::plot::PlotFormat::Interleaved, + channels: pipeline.plot_channels.max(1), + format: pipeline.plot_format, + }, + DecoderChoice::MixedTextPlot => DecoderConfig::MixedTextPlot { + encoding: xserial_core::protocol::text::TextEncoding::Utf8, }, }, }) @@ -166,6 +239,7 @@ impl ConfigForm { pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option { let mut result = None; + let mut validation_errors = Vec::new(); ScrollArea::vertical().show(ui, |ui| { ui.heading("Transport"); @@ -194,6 +268,106 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option< ui.label("Baud:"); ui.add(DragValue::new(&mut form.baud).range(300..=12_000_000)); }); + ui.horizontal(|ui| { + ui.label("Data bits:"); + ComboBox::from_id_salt("serial_data_bits") + .selected_text(match form.serial_data_bits { + SerialDataBits::Five => "5", + SerialDataBits::Six => "6", + SerialDataBits::Seven => "7", + SerialDataBits::Eight => "8", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut form.serial_data_bits, + SerialDataBits::Five, + "5", + ); + ui.selectable_value( + &mut form.serial_data_bits, + SerialDataBits::Six, + "6", + ); + ui.selectable_value( + &mut form.serial_data_bits, + SerialDataBits::Seven, + "7", + ); + ui.selectable_value( + &mut form.serial_data_bits, + SerialDataBits::Eight, + "8", + ); + }); + }); + ui.horizontal(|ui| { + ui.label("Parity:"); + ComboBox::from_id_salt("serial_parity") + .selected_text(match form.serial_parity { + SerialParity::None => "None", + SerialParity::Odd => "Odd", + SerialParity::Even => "Even", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut form.serial_parity, + SerialParity::None, + "None", + ); + ui.selectable_value(&mut form.serial_parity, SerialParity::Odd, "Odd"); + ui.selectable_value( + &mut form.serial_parity, + SerialParity::Even, + "Even", + ); + }); + }); + ui.horizontal(|ui| { + ui.label("Stop bits:"); + ComboBox::from_id_salt("serial_stop_bits") + .selected_text(match form.serial_stop_bits { + SerialStopBits::One => "1", + SerialStopBits::Two => "2", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut form.serial_stop_bits, + SerialStopBits::One, + "1", + ); + ui.selectable_value( + &mut form.serial_stop_bits, + SerialStopBits::Two, + "2", + ); + }); + }); + ui.horizontal(|ui| { + ui.label("Flow:"); + ComboBox::from_id_salt("serial_flow_control") + .selected_text(match form.serial_flow_control { + SerialFlowControl::None => "None", + SerialFlowControl::Software => "Software", + SerialFlowControl::Hardware => "Hardware", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut form.serial_flow_control, + SerialFlowControl::None, + "None", + ); + ui.selectable_value( + &mut form.serial_flow_control, + SerialFlowControl::Software, + "Software", + ); + ui.selectable_value( + &mut form.serial_flow_control, + SerialFlowControl::Hardware, + "Hardware", + ); + }); + }); } TransportChoice::Tcp => { ui.horizontal(|ui| { @@ -219,47 +393,60 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option< let mut remove = None; // 管道列表 - for (i, (name, fc, dc)) in form.pipelines.iter_mut().enumerate() { + for (i, pipeline) in form.pipelines.iter_mut().enumerate() { ui.group(|ui| { ui.horizontal(|ui| { ui.label("Name:"); - ui.add(TextEdit::singleline(name).desired_width(60.0)); + ui.add(TextEdit::singleline(&mut pipeline.name).desired_width(60.0)); // Framer 选择 - let fsel = match fc { + let fsel = match &pipeline.framer { FramerChoice::Line => "Line".to_string(), FramerChoice::Fixed(n) => format!("Fixed({})", n), + FramerChoice::MixedTextPlot => "MixedTextPlot".to_string(), }; ComboBox::from_id_salt(format!("f{}", i)) .selected_text(fsel) .show_ui(ui, |ui| { if ui.selectable_label(false, "Line").clicked() { - *fc = FramerChoice::Line; + pipeline.framer = FramerChoice::Line; } if ui.selectable_label(false, "Fixed").clicked() { - *fc = FramerChoice::Fixed(8); + pipeline.framer = FramerChoice::Fixed(8); + } + if ui.selectable_label(false, "MixedTextPlot").clicked() { + pipeline.framer = FramerChoice::MixedTextPlot; + pipeline.decoder = DecoderChoice::MixedTextPlot; } }); - if let FramerChoice::Fixed(n) = fc { + if let FramerChoice::Fixed(n) = &mut pipeline.framer { ui.add(DragValue::new(n).range(1..=65536)); } // Decoder 选择 ComboBox::from_id_salt(format!("d{}", i)) - .selected_text(match dc { + .selected_text(match pipeline.decoder { DecoderChoice::Text => "Text", DecoderChoice::Hex => "Hex", DecoderChoice::Plot => "Plot", + DecoderChoice::MixedTextPlot => "MixedTextPlot", }) .show_ui(ui, |ui| { if ui.selectable_label(false, "Text").clicked() { - *dc = DecoderChoice::Text; + pipeline.decoder = DecoderChoice::Text; } if ui.selectable_label(false, "Hex").clicked() { - *dc = DecoderChoice::Hex; + pipeline.decoder = DecoderChoice::Hex; } if ui.selectable_label(false, "Plot").clicked() { - *dc = DecoderChoice::Plot; + if matches!(pipeline.framer, FramerChoice::Line) { + pipeline.framer = FramerChoice::Fixed(256); + } + pipeline.decoder = DecoderChoice::Plot; + } + if ui.selectable_label(false, "MixedTextPlot").clicked() { + pipeline.framer = FramerChoice::MixedTextPlot; + pipeline.decoder = DecoderChoice::MixedTextPlot; } }); @@ -267,6 +454,88 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option< remove = Some(i); } }); + + if matches!(pipeline.decoder, DecoderChoice::Plot) { + ui.horizontal(|ui| { + ui.label("Plot channels:"); + ui.add(DragValue::new(&mut pipeline.plot_channels).range(1..=32)); + }); + ui.horizontal(|ui| { + ui.label("Plot format:"); + ComboBox::from_id_salt(format!("plot_format{}", i)) + .selected_text(match pipeline.plot_format { + PlotFormat::Interleaved => "Interleaved", + PlotFormat::Block => "Block", + PlotFormat::XY => "XY", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut pipeline.plot_format, + PlotFormat::Interleaved, + "Interleaved", + ); + ui.selectable_value( + &mut pipeline.plot_format, + PlotFormat::Block, + "Block", + ); + ui.selectable_value( + &mut pipeline.plot_format, + PlotFormat::XY, + "XY", + ); + }); + }); + + if matches!(pipeline.plot_format, PlotFormat::XY) { + pipeline.plot_channels = 2; + } + + match &pipeline.framer { + FramerChoice::Line => { + let message = "Plot decoder requires Fixed framer for raw binary samples"; + ui.colored_label(egui::Color32::RED, message); + validation_errors + .push(format!("pipeline '{}': {message}", pipeline.name)); + } + FramerChoice::Fixed(n) + if *n % (pipeline.plot_channels.max(1) * 4) != 0 => + { + let message = format!( + "Plot decoder currently expects interleaved f32 data, so frame length must be a multiple of {} bytes", + pipeline.plot_channels.max(1) * 4 + ); + ui.colored_label(egui::Color32::RED, &message); + validation_errors + .push(format!("pipeline '{}': {message}", pipeline.name)); + } + FramerChoice::MixedTextPlot => { + let message = "Raw Plot decoder cannot be used with MixedTextPlot framer"; + ui.colored_label(egui::Color32::RED, message); + validation_errors + .push(format!("pipeline '{}': {message}", pipeline.name)); + } + FramerChoice::Fixed(_) => {} + } + + if matches!(pipeline.plot_format, PlotFormat::XY) && pipeline.plot_channels != 2 + { + let message = "XY plot requires exactly 2 channels"; + ui.colored_label(egui::Color32::RED, message); + validation_errors.push(format!( + "pipeline '{}': {message}", + pipeline.name + )); + } + } + + if matches!(pipeline.decoder, DecoderChoice::MixedTextPlot) + && !matches!(pipeline.framer, FramerChoice::MixedTextPlot) + { + let message = "MixedTextPlot decoder requires MixedTextPlot framer"; + ui.colored_label(egui::Color32::RED, message); + validation_errors.push(format!("pipeline '{}': {message}", pipeline.name)); + } }); } @@ -275,11 +544,13 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option< } if ui.button("+ Add Pipeline").clicked() { - form.pipelines.push(( - format!("p{}", form.pipelines.len()), - FramerChoice::Line, - DecoderChoice::Text, - )); + form.pipelines.push(PipelineForm { + name: format!("p{}", form.pipelines.len()), + framer: FramerChoice::Line, + decoder: DecoderChoice::Text, + plot_channels: 1, + plot_format: PlotFormat::Interleaved, + }); } ui.separator(); @@ -290,8 +561,13 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option< ui.checkbox(&mut form.auto_reconnect, "Auto reconnect"); ui.separator(); + for error in &validation_errors { + ui.colored_label(egui::Color32::RED, error); + } if ui.button(submit_label).clicked() { - result = Some(form.to_session_config()); + if validation_errors.is_empty() { + result = Some(form.to_session_config()); + } } }); diff --git a/crates/xserial-gui/src/panels/mod.rs b/crates/xserial-gui/src/panels/mod.rs index 38cf6a1..1d0c696 100644 --- a/crates/xserial-gui/src/panels/mod.rs +++ b/crates/xserial-gui/src/panels/mod.rs @@ -1,4 +1,5 @@ pub mod config; pub mod console; pub mod hex_view; +pub mod plot_view; pub mod sidebar; diff --git a/crates/xserial-gui/src/panels/plot_view.rs b/crates/xserial-gui/src/panels/plot_view.rs new file mode 100644 index 0000000..9ea070d --- /dev/null +++ b/crates/xserial-gui/src/panels/plot_view.rs @@ -0,0 +1,199 @@ +use crate::buffers::{PlotBuffer, PlotSeriesKind}; +use egui::{Button, Ui, vec2}; +use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints}; + +pub struct PlotViewState { + pub lock_x: bool, + pub lock_y: bool, + pub box_zoom: bool, + pub follow_latest: bool, + pub last_bounds: Option, +} + +impl Default for PlotViewState { + fn default() -> Self { + Self { + lock_x: false, + lock_y: false, + box_zoom: false, + follow_latest: false, + last_bounds: None, + } + } +} + +pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotViewState) { + if buf.is_empty() { + ui.label("no plot data"); + return; + } + + let mut auto_fit = false; + let mut reset_view = false; + let mut zoom_x = 1.0_f32; + let mut zoom_y = 1.0_f32; + + ui.horizontal_wrapped(|ui| { + if ui.button("Auto Fit").clicked() { + auto_fit = true; + } + if ui.button("Reset View").clicked() { + reset_view = true; + } + ui.separator(); + ui.checkbox(&mut state.lock_x, "Lock X"); + ui.checkbox(&mut state.lock_y, "Lock Y"); + ui.checkbox(&mut state.box_zoom, "Box Zoom"); + if matches!(buf.kind(), PlotSeriesKind::TimeSeries) { + ui.checkbox(&mut state.follow_latest, "Follow Latest"); + } else { + state.follow_latest = false; + } + ui.separator(); + if ui + .add_enabled(!state.lock_x, Button::new("X-")) + .on_hover_text("Zoom out X axis") + .clicked() + { + zoom_x = 0.8; + } + if ui + .add_enabled(!state.lock_x, Button::new("X+")) + .on_hover_text("Zoom in X axis") + .clicked() + { + zoom_x = 1.25; + } + if ui + .add_enabled(!state.lock_y, Button::new("Y-")) + .on_hover_text("Zoom out Y axis") + .clicked() + { + zoom_y = 0.8; + } + if ui + .add_enabled(!state.lock_y, Button::new("Y+")) + .on_hover_text("Zoom in Y axis") + .clicked() + { + zoom_y = 1.25; + } + }); + + if let Some(bounds) = state.last_bounds { + ui.horizontal_wrapped(|ui| { + ui.monospace(format!( + "X [{:.3}, {:.3}] Y [{:.3}, {:.3}]", + bounds.min()[0], + bounds.max()[0], + bounds.min()[1], + bounds.max()[1], + )); + }); + } + + let data_bounds = plot_bounds(buf); + let mut plot = Plot::new(format!("plot_view_{session_id}")) + .legend(Legend::default()) + .allow_boxed_zoom(state.box_zoom) + .allow_zoom([!state.lock_x, !state.lock_y]) + .allow_drag([!state.lock_x, !state.lock_y]) + .allow_axis_zoom_drag([!state.lock_x, !state.lock_y]) + .allow_scroll([ + !state.lock_x && matches!(buf.kind(), PlotSeriesKind::TimeSeries), + !state.lock_y && matches!(buf.kind(), PlotSeriesKind::TimeSeries), + ]); + if reset_view { + plot = plot.reset(); + } + + plot.show(ui, |plot_ui| { + let current_bounds = plot_ui.plot_bounds(); + state.last_bounds = current_bounds.is_finite().then_some(current_bounds); + let pixel_width = plot_ui.response().rect.width().max(64.0); + let max_render_points = (pixel_width as usize).saturating_mul(2); + + if auto_fit { + if let Some(bounds) = data_bounds { + plot_ui.set_auto_bounds(false); + plot_ui.set_plot_bounds(bounds); + state.last_bounds = Some(bounds); + } + } else if state.follow_latest && matches!(buf.kind(), PlotSeriesKind::TimeSeries) { + if let Some(bounds) = data_bounds { + let current_width = current_bounds.width(); + let data_width = bounds.width(); + let x_range = if current_width.is_finite() && current_width > 0.0 && data_width > current_width { + (bounds.max()[0] - current_width)..=bounds.max()[0] + } else { + bounds.min()[0]..=bounds.max()[0] + }; + plot_ui.set_auto_bounds([false, plot_ui.auto_bounds().y]); + plot_ui.set_plot_bounds_x(x_range.clone()); + + let mut followed = current_bounds; + followed.set_x(&PlotBounds::from_min_max([*x_range.start(), current_bounds.min()[1]], [*x_range.end(), current_bounds.max()[1]])); + state.last_bounds = Some(followed); + } + } else if zoom_x != 1.0 || zoom_y != 1.0 { + let center = plot_ui.plot_bounds().center(); + plot_ui.zoom_bounds(vec2(zoom_x, zoom_y), center); + } + + if let Some(bounds) = data_bounds { + if !bounds.is_valid_x() { + plot_ui.set_auto_bounds([true, plot_ui.auto_bounds().y]); + } + if !bounds.is_valid_y() { + plot_ui.set_auto_bounds([plot_ui.auto_bounds().x, true]); + } + } + + for series in buf.iter() { + let sampled_points = match buf.kind() { + PlotSeriesKind::TimeSeries => { + series.render_points_time_series( + current_bounds.min()[0], + current_bounds.max()[0], + max_render_points, + ) + } + PlotSeriesKind::XY => series.render_points_xy(max_render_points), + }; + let points = PlotPoints::from_iter(sampled_points); + plot_ui.line(Line::new(series.name.clone(), points)); + } + }); +} + +fn plot_bounds(buf: &PlotBuffer) -> Option { + let mut bounds = PlotBounds::NOTHING; + + for series in buf.iter() { + for [x, y] in series.points() { + if x.is_finite() && y.is_finite() { + bounds.extend_with(&[x, y].into()); + } + } + } + + if !bounds.is_finite() { + return None; + } + + if bounds.width() <= 0.0 { + let center_x = bounds.center().x; + bounds.set_x_center_width(center_x, 1.0); + } else { + bounds.expand_x(bounds.width() * 0.05); + } + + if bounds.height() <= 0.0 { + let center_y = bounds.center().y; + bounds.set_y_center_height(center_y, 1.0); + } else { + bounds.expand_y(bounds.height() * 0.1); + } + + Some(bounds) +} diff --git a/tools/gen_plot.py b/tools/gen_plot.py deleted file mode 100755 index 6553978..0000000 --- a/tools/gen_plot.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -""" -SerialPlot 协议测试数据生成器 - -协议格式: - [Sync: AA 55] [Size: 2B LE] [Payload: N×2B i16 LE] [Checksum: 1B LSB sum of payload] - -用法: - python3 gen_plot.py /dev/pts/X --channels 2 --wave sine - python3 gen_plot.py /dev/pts/X --channels 2 --wave sine --freq 0.3 # 慢波,易观察 - python3 gen_plot.py /dev/pts/X --channels 3 --wave saw --freq 0.2 -""" -import struct -import sys -import time -import math -import argparse - - -def checksum(data: bytes) -> int: - return sum(data) & 0xFF - - -def make_frame(channel_data: list[list[int]]) -> bytes: - num_channels = len(channel_data) - num_samples = len(channel_data[0]) - payload = bytearray() - for i in range(num_samples): - for ch in range(num_channels): - payload.extend(struct.pack('> 8) & 0xFF) - frame.extend(payload) - frame.append(checksum(payload)) - return bytes(frame) - - -def generate_wave( - num_channels: int, - num_samples: int, - wave_type: str, - freq: float, - amplitude: int, -) -> list[list[int]]: - channels = [] - for ch in range(num_channels): - phase_offset = ch * (2 * math.pi / num_channels) - samples = [] - for i in range(num_samples): - t = i / num_samples - if wave_type == 'sine': - angle = 2 * math.pi * t * freq + phase_offset - value = math.sin(angle) - elif wave_type == 'saw': - value = 2 * ((t * freq) % 1.0) - 1.0 - elif wave_type == 'square': - value = 1.0 if ((t * freq) % 2.0) < 1.0 else -1.0 - else: - value = 0.0 - samples.append(int(value * amplitude)) - channels.append(samples) - return channels - - -def main(): - parser = argparse.ArgumentParser(description='SerialPlot test data generator') - parser.add_argument('port', help='Serial port or file (e.g. /dev/pts/3)') - parser.add_argument('--channels', type=int, default=2, help='Number of channels') - parser.add_argument('--samples', type=int, default=200, help='Samples per frame') - parser.add_argument('--wave', choices=['sine', 'saw', 'square'], default='sine') - parser.add_argument('--freq', type=float, default=0.5, - help='Wave frequency (cycles per frame; 0.5 = half a sine wave, easy to observe)') - parser.add_argument('--amplitude', type=int, default=8000, - help='Amplitude (i16 range, max 32767)') - parser.add_argument('--interval', type=float, default=0.05, - help='Interval between frames (seconds)') - parser.add_argument('--frames', type=int, default=0, - help='Number of frames (0=infinite)') - args = parser.parse_args() - - count = 0 - try: - with open(args.port, 'wb') as f: - print(f"Generating {args.channels}-channel {args.wave} wave → {args.port}") - print(f" {args.samples} samples/frame, freq={args.freq} cycles/frame") - print(f" amplitude={args.amplitude}, interval={args.interval}s") - print(f" Protocol: sync=AA55, i16 LE, 1B size+checksum") - print() - - while args.frames == 0 or count < args.frames: - count += 1 - data = generate_wave(args.channels, args.samples, args.wave, args.freq, args.amplitude) - frame = make_frame(data) - f.write(frame) - f.flush() - - vals = [data[ch][0] for ch in range(args.channels)] - vals_str = ", ".join(f"ch{ch}={v:+6d}" for ch, v in enumerate(vals)) - print(f" Frame #{count}: {vals_str} ... ({len(frame)} bytes)", end='\r') - - time.sleep(args.interval) - except KeyboardInterrupt: - print(f"\nDone. {count} frames sent.") - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == '__main__': - main() diff --git a/tools/test_plot.py b/tools/test_plot.py new file mode 100755 index 0000000..d33b58c --- /dev/null +++ b/tools/test_plot.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""TCP server that sends plot waveform frames for the current xserial GUI.""" + +import argparse +import math +import socket +import struct +import threading +import time + +PLOT_ESCAPE = 0x1E +PLOT_MARKER = ord("P") + + +def build_frame( + sample_index: int, + channels: int, + plot_format: str, + samples_per_channel: int, + amplitude: float, + frequency_hz: float, + sample_rate_hz: float, +) -> bytes: + values = [] + if plot_format == "xy": + for offset in range(samples_per_channel): + t = (sample_index + offset) / sample_rate_hz + x = amplitude * math.cos(2 * math.pi * frequency_hz * t) + y = amplitude * math.sin(2 * math.pi * frequency_hz * t) + values.extend([x, y]) + return struct.pack(f"<{len(values)}f", *values) + + for offset in range(samples_per_channel): + t = (sample_index + offset) / sample_rate_hz + for ch in range(channels): + phase = 2 * math.pi * ch / max(channels, 1) + value = amplitude * math.sin(2 * math.pi * frequency_hz * t + phase) + values.append(value) + return struct.pack(f"<{len(values)}f", *values) + + +def cobs_encode(payload: bytes) -> bytes: + if not payload: + return b"\x01" + + out = bytearray([0]) + code_index = 0 + code = 1 + for byte in payload: + if byte == 0: + out[code_index] = code + code_index = len(out) + out.append(0) + code = 1 + else: + out.append(byte) + code += 1 + if code == 0xFF: + out[code_index] = code + code_index = len(out) + out.append(0) + code = 1 + out[code_index] = code + return bytes(out) + + +def build_plot_packet( + payload: bytes, + channels: int, + samples_per_channel: int, + plot_format: str, +) -> bytes: + format_id = {"interleaved": 0, "block": 1, "xy": 2}[plot_format] + header = bytearray() + header.extend(b"XP") + header.append(1) # version + header.append(format_id) + header.append(8) # f32 + header.append(0) # little-endian + header.append(channels) + header.extend(struct.pack(" bytes: + packet = build_plot_packet( + payload=payload, + channels=channels, + samples_per_channel=samples_per_channel, + plot_format=plot_format, + ) + return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00" + +def main(): + p = argparse.ArgumentParser(description="xserial plot test server") + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=8080) + p.add_argument("--channels", type=int, default=1) + p.add_argument( + "--format", + choices=("interleaved", "xy"), + default="interleaved", + help="plot format (default: interleaved)", + ) + p.add_argument( + "--rate", + type=float, + default=1024, + help="samples/sec per channel (default: 1024)", + ) + p.add_argument("--freq", type=float, default=1.0, help="sine Hz") + p.add_argument("--amp", type=float, default=100) + p.add_argument( + "--text-interval", + type=float, + default=1.0, + help="seconds between status text messages (default: 1.0)", + ) + p.add_argument( + "--framelen", + type=int, + default=0, + help="fixed frame bytes (0=one sample per channel per frame)", + ) + p.add_argument( + "--wire-format", + choices=("mixed", "raw"), + default="mixed", + help="mixed sends text+plot on one connection; raw sends plot only", + ) + args = p.parse_args() + + sample_size = 4 # f32 + + if args.channels <= 0: + raise SystemExit("--channels must be >= 1") + if args.format == "xy" and args.channels != 2: + raise SystemExit("--format xy requires --channels 2") + if args.rate <= 0: + raise SystemExit("--rate must be positive") + if args.text_interval <= 0: + raise SystemExit("--text-interval must be positive") + if args.framelen < 0: + raise SystemExit("--framelen must be >= 0") + + bytes_per_sample_group = args.channels * sample_size + frame_bytes = args.framelen or bytes_per_sample_group + if frame_bytes % bytes_per_sample_group != 0: + raise SystemExit( + f"--framelen must be a multiple of {bytes_per_sample_group} bytes " + f"for {args.channels} channel f32 interleaved data" + ) + samples_per_channel = frame_bytes // bytes_per_sample_group + frame_interval = samples_per_channel / args.rate + frame_rate = args.rate / samples_per_channel + + def sample_values(sample_index: int) -> list[float]: + if args.format == "xy": + t = sample_index / args.rate + return [ + args.amp * math.cos(2 * math.pi * args.freq * t), + args.amp * math.sin(2 * math.pi * args.freq * t), + ] + + t = sample_index / args.rate + values = [] + for ch in range(args.channels): + phase = 2 * math.pi * ch / max(args.channels, 1) + values.append(args.amp * math.sin(2 * math.pi * args.freq * t + phase)) + return values + + print(f"Listening {args.host}:{args.port} — Ctrl+C to stop") + print("GUI:") + print(f" transport = TCP {args.host}:{args.port}") + if args.wire_format == "mixed": + print(" framer = MixedTextPlot") + print(" decoder = MixedTextPlot\n") + else: + print(f" framer = Fixed({frame_bytes})") + print( + f" decoder = Plot(f32, little-endian, {args.channels} channel, {args.format})\n" + ) + print(f"sending {samples_per_channel} samples/frame at {args.rate:.1f} samples/sec") + print(f"effective frame rate: {frame_rate:.2f} fps\n") + + stop_event = threading.Event() + + def serve_stream() -> None: + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((args.host, args.port)) + srv.listen(1) + srv.settimeout(0.5) + try: + while not stop_event.is_set(): + try: + conn, addr = srv.accept() + except socket.timeout: + continue + print(f"[conn +] {addr}") + + started_at = time.perf_counter() + frame_count = 0 + text_count = 0 + sample_index = 0 + next_text_at = started_at + try: + while not stop_event.is_set(): + plot_payload = build_frame( + sample_index=sample_index, + channels=args.channels, + plot_format=args.format, + samples_per_channel=samples_per_channel, + amplitude=args.amp, + frequency_hz=args.freq, + sample_rate_hz=args.rate, + ) + if args.wire_format == "mixed": + frame = build_mixed_plot_frame( + payload=plot_payload, + channels=args.channels, + samples_per_channel=samples_per_channel, + plot_format=args.format, + ) + else: + frame = plot_payload + conn.sendall(frame) + + now = time.perf_counter() + if args.wire_format == "mixed" and now >= next_text_at: + values = sample_values(sample_index) + joined = ", ".join( + f"ch{index + 1}={value:8.3f}" + for index, value in enumerate(values) + ) + line = ( + f"t={now - started_at:7.3f}s " + f"sample={sample_index:7d} {joined}\n" + ) + conn.sendall(line.encode("utf-8")) + text_count += 1 + next_text_at += args.text_interval + + sample_index += samples_per_channel + frame_count += 1 + + elapsed = time.perf_counter() - started_at + wait = frame_count * frame_interval - elapsed + if wait > 0: + time.sleep(wait) + except (BrokenPipeError, ConnectionResetError): + print(f"[conn -] {addr} ({frame_count} plot frames, {text_count} text lines)") + finally: + conn.close() + finally: + srv.close() + + stream_thread = threading.Thread(target=serve_stream, daemon=True) + stream_thread.start() + + try: + while True: + time.sleep(0.5) + except KeyboardInterrupt: + print("\nstopped.") + finally: + stop_event.set() + stream_thread.join(timeout=1.0) + +if __name__ == "__main__": + main()