Add mixed text/plot transport and improve plot UI
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user