Add mixed text/plot transport and improve plot UI

This commit is contained in:
2026-06-09 00:51:34 +08:00
parent 25433038d5
commit d5b6bb52c8
18 changed files with 2101 additions and 192 deletions

View File

@@ -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]

View 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);
}
}

View File

@@ -2,6 +2,7 @@ pub mod cobs;
pub mod fixed;
pub mod length;
pub mod line;
pub mod mixed;
pub use crate::protocol::Endian;

View 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());
}
}

View File

@@ -1,4 +1,5 @@
pub mod hex;
pub mod mixed;
pub mod plot;
pub mod text;

View File

@@ -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),
}

View File

@@ -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 {

View File

@@ -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());
}
}

View File

@@ -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(),