Add mixed text/plot transport and improve plot UI
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ impl<T> RingBuffer<T> {
|
||||
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<T>
|
||||
where
|
||||
T: Clone,
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
pub fn cobs_encode(payload: &[u8]) -> Vec<u8> {
|
||||
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<Vec<u8>> {
|
||||
if encoded.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
@@ -127,6 +159,14 @@ fn cobs_decode(encoded: &[u8]) -> Option<Vec<u8>> {
|
||||
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]
|
||||
|
||||
215
crates/xserial-core/src/frame/mixed.rs
Normal file
215
crates/xserial-core/src/frame/mixed.rs
Normal file
@@ -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<u8>,
|
||||
plot_buf: Vec<u8>,
|
||||
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<Vec<u8>>) {
|
||||
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<Vec<u8>>) {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<u8>) -> Vec<u8> {
|
||||
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<u8>) -> Vec<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod cobs;
|
||||
pub mod fixed;
|
||||
pub mod length;
|
||||
pub mod line;
|
||||
pub mod mixed;
|
||||
|
||||
pub use crate::protocol::Endian;
|
||||
|
||||
|
||||
221
crates/xserial-core/src/protocol/mixed.rs
Normal file
221
crates/xserial-core/src/protocol/mixed.rs
Normal file
@@ -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<u8> {
|
||||
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<DecodedData> {
|
||||
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<DecodedData> {
|
||||
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<PlotFormat> {
|
||||
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<SampleType> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod hex;
|
||||
pub mod mixed;
|
||||
pub mod plot;
|
||||
pub mod text;
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ pub struct PlotFrame {
|
||||
pub channels: Vec<Vec<f64>>,
|
||||
pub raw: Vec<u8>,
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<SerialDataBits> 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<SerialParity> 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<SerialStopBits> 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<SerialFlowControl> 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<SerialStream>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DecodedData> = 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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Item = &ConsoleLine> {
|
||||
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<Item = &HexLine> {
|
||||
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<Item = [f64; 2]> + '_ {
|
||||
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<PlotSeries>,
|
||||
}
|
||||
|
||||
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<Item = &PlotSeries> {
|
||||
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::<Vec<_>>(), 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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PipelineForm>,
|
||||
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<SessionConfig> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod console;
|
||||
pub mod hex_view;
|
||||
pub mod plot_view;
|
||||
pub mod sidebar;
|
||||
|
||||
199
crates/xserial-gui/src/panels/plot_view.rs
Normal file
199
crates/xserial-gui/src/panels/plot_view.rs
Normal file
@@ -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<PlotBounds>,
|
||||
}
|
||||
|
||||
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<PlotBounds> {
|
||||
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)
|
||||
}
|
||||
@@ -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('<h', channel_data[ch][i]))
|
||||
|
||||
frame = bytearray([0xAA, 0x55])
|
||||
frame.append(len(payload) & 0xFF)
|
||||
frame.append((len(payload) >> 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()
|
||||
278
tools/test_plot.py
Executable file
278
tools/test_plot.py
Executable file
@@ -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("<H", samples_per_channel))
|
||||
header.extend(struct.pack("<I", len(payload)))
|
||||
header.extend(payload)
|
||||
return bytes(header)
|
||||
|
||||
|
||||
def build_mixed_plot_frame(
|
||||
payload: bytes,
|
||||
channels: int,
|
||||
samples_per_channel: int,
|
||||
plot_format: str,
|
||||
) -> 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()
|
||||
Reference in New Issue
Block a user