feat: initial xserial-core with transport, protocol, frame, and pipeline layers

- transport: async Serial/TCP/UDP via Connection enum + Transport trait
- protocol: TextDecoder (UTF-8/Latin-1/ASCII), HexDecoder, PlotDecoder (binary samples)
- frame: LineFramer, FixedLengthFramer, LengthPrefixedFramer, CobsFramer
- pipeline: MultiPipeline for parallel multi-protocol parsing
- 212 tests (182 unit + 18 integration + 1 doctest), clippy clean
This commit is contained in:
2026-06-07 19:20:19 +08:00
commit 60c60a8044
28 changed files with 11162 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

5917
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

54
Cargo.toml Normal file
View File

@@ -0,0 +1,54 @@
[workspace]
resolver = "2"
members = [
"crates/xserial-core",
"crates/xserial-client",
"crates/xserial-tui",
"crates/xserial-gui",
]
[workspace.dependencies]
# ── 异步运行时 ──
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
# ── 串口 ──
serialport = "4.9"
tokio-serial = "5.4"
# ── 协议解析 ──
bytes = "1"
nom = "8"
# ── 序列化 ──
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# ── 错误处理 ──
thiserror = "2"
# ── 日志 ──
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
# ── 工具 ──
hex = "0.4"
# ── 图像 ──
image = { version = "0.25", default-features = false, features = ["png"] }
# ── CLI ──
clap = { version = "4", features = ["derive"] }
# ── TUI ──
ratatui = "0.30"
crossterm = "0.28"
# ── GUI ──
egui = "0.34"
eframe = "0.34"
egui_plot = "0.35"
# ── Lua (Step 6 启用) ──
# mlua = { version = "0.11", features = ["luajit", "async", "serde", "send", "macros", "vendored"] }

View File

@@ -0,0 +1,9 @@
[package]
name = "xserial-client"
version = "0.1.0"
edition = "2024"
[dependencies]
xserial-core = { path = "../xserial-core" }
tokio = { workspace = true }
tracing = { workspace = true }

View File

View File

@@ -0,0 +1,17 @@
[package]
name = "xserial-core"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { workspace = true }
async-trait = { workspace = true }
serialport = { workspace = true }
tokio-serial = { workspace = true }
bytes = { workspace = true }
nom = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
hex = "0.4"

View File

@@ -0,0 +1,27 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Serial port error: {0}")]
Serial(#[from] serialport::Error),
#[error("Transport not connected")]
NotConnected,
#[error("Connection failed: {0}")]
ConnectionFailed(String),
#[error("Unsupported operation: {0}")]
Unsupported(String),
#[error("Protocol error: {0}")]
Protocol(String),
#[error("Script error: {0}")]
Script(String),
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -0,0 +1,267 @@
use super::Framer;
/// COBS (Consistent Overhead Byte Stuffing) framer.
///
/// Frames are delimited by `0x00`. Everything between two `0x00` bytes
/// is a COBS-encoded packet. The framer accumulates until a `0x00` is
/// seen, decodes the COBS data, and yields the original payload.
///
/// Reference: <https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing>
#[derive(Debug, Clone)]
pub struct CobsFramer {
buf: Vec<u8>,
max_frame: usize,
}
impl Default for CobsFramer {
fn default() -> Self {
Self {
buf: Vec::new(),
max_frame: 1024 * 1024,
}
}
}
impl CobsFramer {
pub fn new(max_frame: usize) -> Self {
Self {
buf: Vec::new(),
max_frame,
}
}
}
impl Framer for CobsFramer {
fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
let mut frames = Vec::new();
for &byte in data {
if byte == 0x00 {
if !self.buf.is_empty() {
// Decode failed (corrupt packet) — discard and continue
if let Some(decoded) = cobs_decode(&self.buf) {
frames.push(decoded);
}
self.buf.clear();
}
// Consecutive 0x00 bytes produce empty frames
// (skip them — no payload to decode)
} else {
if self.buf.len() < self.max_frame {
self.buf.push(byte);
}
// Exceeding max_frame: drop silently (avoid OOM)
}
}
frames
}
fn flush(&mut self) -> Option<Vec<u8>> {
if self.buf.is_empty() {
None
} else {
let data = std::mem::take(&mut self.buf);
cobs_decode(&data).or(Some(data))
}
}
fn reset(&mut self) {
self.buf.clear();
}
fn pending_len(&self) -> usize {
self.buf.len()
}
}
/// Decode a COBS-encoded packet (without the trailing 0x00 delimiter).
///
/// 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>> {
if encoded.is_empty() {
return Some(Vec::new());
}
let mut out = Vec::with_capacity(encoded.len());
let mut pos = 0;
while pos < encoded.len() {
let code = encoded[pos] as usize;
if code == 0 {
// Invalid: overhead byte should never be 0
return None;
}
pos += 1;
if code > 1 {
let copy_start = pos;
let desired_end = pos + code - 1;
if desired_end > encoded.len() {
return None;
}
out.extend_from_slice(&encoded[copy_start..desired_end]);
pos = desired_end;
}
// If the code byte was < 0xFF, the next byte (if any) in the
// original stream was 0x00 → insert it.
if code < 0xFF && pos < encoded.len() {
out.push(0x00);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
// ── cobs_decode unit tests ──────────────────────────────────────
#[test]
fn cobs_decode_no_zeros() {
let decoded = cobs_decode(&[0x06, 0x68, 0x65, 0x6C, 0x6C, 0x6F]).unwrap();
assert_eq!(decoded, b"hello");
}
#[test]
fn cobs_decode_single_zero() {
let decoded = cobs_decode(&[0x03, 0x11, 0x22, 0x02, 0x33]).unwrap();
assert_eq!(decoded, vec![0x11, 0x22, 0x00, 0x33]);
}
#[test]
fn cobs_decode_leading_zero() {
let decoded = cobs_decode(&[0x01, 0x01, 0x01, 0x01]).unwrap();
assert_eq!(decoded, vec![0x00, 0x00, 0x00]);
}
#[test]
fn cobs_decode_all_zeros() {
// Input: [0x00, 0x00, 0x00] → encoded: [0x01, 0x01, 0x01, 0x01]
let decoded = cobs_decode(&[0x01, 0x01, 0x01, 0x01]).unwrap();
assert_eq!(decoded, vec![0x00, 0x00, 0x00]);
}
#[test]
fn cobs_decode_empty() {
let decoded = cobs_decode(&[]).unwrap();
assert!(decoded.is_empty());
}
#[test]
fn cobs_decode_invalid_zero_code() {
assert!(cobs_decode(&[0x00, 0x01]).is_none());
}
#[test]
fn cobs_decode_truncated() {
// Claims 5 bytes follow but only 3 remain
assert!(cobs_decode(&[0x06, 0x01, 0x02, 0x03]).is_none());
}
#[test]
fn cobs_decode_full_254_block() {
// 254 non-zero bytes
let payload: Vec<u8> = (1u8..=254).collect();
let mut encoded = vec![0xFF];
encoded.extend_from_slice(&payload);
let decoded = cobs_decode(&encoded).unwrap();
assert_eq!(decoded, payload);
}
// ── CobsFramer integration tests ─────────────────────────────────
#[test]
fn cobs_framer_single_packet() {
let mut f = CobsFramer::default();
let frames = f.feed(&[0x06, b'h', b'e', b'l', b'l', b'o', 0x00]);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
#[test]
fn cobs_framer_two_packets() {
let mut f = CobsFramer::default();
let mut data = vec![0x04, b'f', b'o', b'o', 0x00];
data.extend_from_slice(&[0x04, b'b', b'a', b'r', 0x00]);
let frames = f.feed(&data);
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], b"foo");
assert_eq!(frames[1], b"bar");
}
#[test]
fn cobs_framer_split_across_chunks() {
let mut f = CobsFramer::default();
let frames = f.feed(&[0x06, b'h', b'e']);
assert!(frames.is_empty());
let frames = f.feed(&[b'l', b'l', b'o', 0x00]);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
#[test]
fn cobs_framer_consecutive_zeros() {
let mut f = CobsFramer::default();
let frames = f.feed(&[0x00, 0x00, 0x03, b'a', b'b', 0x00]);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"ab");
}
#[test]
fn cobs_framer_corrupt_packet_discarded() {
let mut f = CobsFramer::default();
let frames = f.feed(&[0x06, 0x01, 0x02, 0x00]);
assert!(frames.is_empty());
}
#[test]
fn cobs_framer_flush_partial() {
let mut f = CobsFramer::default();
f.feed(&[0x03, b'h', b'e']);
let flushed = f.flush().unwrap();
assert_eq!(flushed, b"he");
}
#[test]
fn cobs_framer_flush_empty() {
let mut f = CobsFramer::default();
assert_eq!(f.flush(), None);
}
#[test]
fn cobs_framer_reset() {
let mut f = CobsFramer::default();
f.feed(&[0x05, b'h', b'e']);
assert!(f.pending_len() > 0);
f.reset();
assert_eq!(f.pending_len(), 0);
}
#[test]
fn cobs_framer_max_frame() {
let mut f = CobsFramer::new(3);
f.feed(&[0x05, b'a', b'b', b'c', b'd', b'e']);
assert_eq!(f.pending_len(), 3);
}
#[test]
fn cobs_framer_empty_payload_packet() {
let mut f = CobsFramer::default();
let frames = f.feed(&[0x01, 0x00]);
assert_eq!(frames.len(), 1);
assert!(frames[0].is_empty());
}
#[test]
fn cobs_framer_pending_len() {
let mut f = CobsFramer::default();
f.feed(&[0x05, b'h', b'e']);
assert_eq!(f.pending_len(), 3);
}
}

View File

@@ -0,0 +1,151 @@
use super::Framer;
/// Fixed-length framer — every N bytes forms one frame.
#[derive(Debug, Clone)]
pub struct FixedLengthFramer {
buf: Vec<u8>,
frame_len: usize,
}
impl FixedLengthFramer {
pub fn new(frame_len: usize) -> Self {
assert!(frame_len > 0, "frame_len must be > 0");
Self {
buf: Vec::new(),
frame_len,
}
}
pub fn frame_len(&self) -> usize {
self.frame_len
}
}
impl Framer for FixedLengthFramer {
fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
let mut frames = Vec::new();
self.buf.extend_from_slice(data);
while self.buf.len() >= self.frame_len {
let frame: Vec<u8> = self.buf.drain(..self.frame_len).collect();
frames.push(frame);
}
frames
}
fn flush(&mut self) -> Option<Vec<u8>> {
if self.buf.is_empty() {
None
} else {
Some(std::mem::take(&mut self.buf))
}
}
fn reset(&mut self) {
self.buf.clear();
}
fn pending_len(&self) -> usize {
self.buf.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_exact_one_frame() {
let mut f = FixedLengthFramer::new(5);
let frames = f.feed(b"hello");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
#[test]
fn fixed_multiple_frames() {
let mut f = FixedLengthFramer::new(3);
let frames = f.feed(b"abcdefghi");
assert_eq!(frames.len(), 3);
assert_eq!(frames[0], b"abc");
assert_eq!(frames[1], b"def");
assert_eq!(frames[2], b"ghi");
}
#[test]
fn fixed_partial_buffered() {
let mut f = FixedLengthFramer::new(5);
let frames = f.feed(b"abc");
assert!(frames.is_empty());
assert_eq!(f.pending_len(), 3);
let frames = f.feed(b"de");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"abcde");
}
#[test]
fn fixed_more_than_one_frame_in_chunk() {
let mut f = FixedLengthFramer::new(4);
let frames = f.feed(b"abcdefghij"); // 10 bytes → 2 full + 2 pending
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], b"abcd");
assert_eq!(frames[1], b"efgh");
assert_eq!(f.pending_len(), 2);
}
#[test]
fn fixed_flush_partial() {
let mut f = FixedLengthFramer::new(5);
f.feed(b"xy");
let flushed = f.flush();
assert_eq!(flushed, Some(b"xy".to_vec()));
}
#[test]
fn fixed_flush_empty() {
let mut f = FixedLengthFramer::new(3);
f.feed(b"abc");
assert_eq!(f.flush(), None);
}
#[test]
fn fixed_reset() {
let mut f = FixedLengthFramer::new(4);
f.feed(b"ab");
assert_eq!(f.pending_len(), 2);
f.reset();
assert_eq!(f.pending_len(), 0);
}
#[test]
fn fixed_empty_feed() {
let mut f = FixedLengthFramer::new(5);
let frames = f.feed(b"");
assert!(frames.is_empty());
}
#[test]
fn fixed_exact_multiple_drains() {
let mut f = FixedLengthFramer::new(2);
let frames = f.feed(b"abcd");
assert_eq!(frames.len(), 2);
assert!(f.pending_len() == 0);
let more = f.feed(b"ef");
assert_eq!(more.len(), 1);
assert_eq!(more[0], b"ef");
}
#[test]
#[should_panic(expected = "frame_len must be > 0")]
fn fixed_zero_frame_len_panics() {
FixedLengthFramer::new(0);
}
#[test]
fn fixed_frame_len_accessor() {
let f = FixedLengthFramer::new(128);
assert_eq!(f.frame_len(), 128);
}
}

View File

@@ -0,0 +1,370 @@
use super::{Endian, Framer};
/// Configuration for the length-prefixed framer.
#[derive(Debug, Clone)]
pub struct LengthConfig {
/// Size of the length field in bytes: 1, 2, or 4.
pub len_bytes: usize,
pub endian: Endian,
/// When true, the length value includes the length field itself.
pub length_includes_self: bool,
/// Maximum payload size (0 = unlimited).
pub max_payload: usize,
}
impl Default for LengthConfig {
fn default() -> Self {
Self {
len_bytes: 2,
endian: Endian::Big,
length_includes_self: false,
max_payload: 1024 * 1024,
}
}
}
#[derive(Debug, Clone)]
enum State {
ReadingLength,
ReadingPayload { expected: usize },
}
#[derive(Debug, Clone)]
pub struct LengthPrefixedFramer {
buf: Vec<u8>,
config: LengthConfig,
state: State,
}
impl LengthPrefixedFramer {
pub fn new(config: LengthConfig) -> Self {
assert!(
matches!(config.len_bytes, 1 | 2 | 4),
"len_bytes must be 1, 2, or 4"
);
Self {
buf: Vec::new(),
config,
state: State::ReadingLength,
}
}
pub fn config(&self) -> &LengthConfig {
&self.config
}
fn parse_length(&self, bytes: &[u8]) -> usize {
let raw = match self.config.len_bytes {
1 => bytes[0] as usize,
2 => {
let b = [bytes[0], bytes[1]];
match self.config.endian {
Endian::Big => u16::from_be_bytes(b) as usize,
Endian::Little => u16::from_le_bytes(b) as usize,
}
}
4 => {
let b = [bytes[0], bytes[1], bytes[2], bytes[3]];
match self.config.endian {
Endian::Big => u32::from_be_bytes(b) as usize,
Endian::Little => u32::from_le_bytes(b) as usize,
}
}
_ => unreachable!(),
};
if self.config.length_includes_self {
raw.saturating_sub(self.config.len_bytes)
} else {
raw
}
}
}
impl Framer for LengthPrefixedFramer {
fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
let mut frames = Vec::new();
self.buf.extend_from_slice(data);
loop {
match self.state {
State::ReadingLength => {
if self.buf.len() < self.config.len_bytes {
break;
}
let payload_len = self.parse_length(&self.buf[..self.config.len_bytes]);
// Discard length, re-sync on corrupt frame
if self.config.max_payload > 0 && payload_len > self.config.max_payload {
self.buf.drain(..self.config.len_bytes);
continue;
}
self.buf.drain(..self.config.len_bytes);
if payload_len == 0 {
frames.push(Vec::new());
} else {
self.state = State::ReadingPayload {
expected: payload_len,
};
}
}
State::ReadingPayload { expected } => {
if self.buf.len() < expected {
break;
}
let frame: Vec<u8> = self.buf.drain(..expected).collect();
frames.push(frame);
self.state = State::ReadingLength;
}
}
}
frames
}
fn flush(&mut self) -> Option<Vec<u8>> {
let remainder = std::mem::take(&mut self.buf);
self.state = State::ReadingLength;
if remainder.is_empty() {
None
} else {
Some(remainder)
}
}
fn reset(&mut self) {
self.buf.clear();
self.state = State::ReadingLength;
}
fn pending_len(&self) -> usize {
self.buf.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn be2(len: u16) -> Vec<u8> {
len.to_be_bytes().to_vec()
}
fn le2(len: u16) -> Vec<u8> {
len.to_le_bytes().to_vec()
}
// ── 2-byte BE, length excludes self ───────────────────────────────
#[test]
fn length_single_frame() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
let mut data = be2(5);
data.extend_from_slice(b"hello");
let frames = f.feed(&data);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
#[test]
fn length_two_frames_in_one_chunk() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
let mut data = Vec::new();
data.extend(&be2(3));
data.extend_from_slice(b"foo");
data.extend(&be2(3));
data.extend_from_slice(b"bar");
let frames = f.feed(&data);
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], b"foo");
assert_eq!(frames[1], b"bar");
}
#[test]
fn length_split_header() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
// Feed first half of 2-byte length header
assert!(f.feed(&be2(5)[..1]).is_empty());
assert_eq!(f.pending_len(), 1);
let mut rest = be2(5)[1..].to_vec();
rest.extend_from_slice(b"hello");
let frames = f.feed(&rest);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
#[test]
fn length_split_payload() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
let frames = f.feed(&be2(5));
assert!(frames.is_empty());
assert_eq!(f.pending_len(), 0); // length consumed, waiting for payload
let frames = f.feed(b"hel");
assert!(frames.is_empty());
let frames = f.feed(b"lo");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
#[test]
fn length_zero_payload() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
let frames = f.feed(&be2(0));
assert_eq!(frames.len(), 1);
assert!(frames[0].is_empty());
}
#[test]
fn length_chain_zero_then_data() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
let mut data = Vec::new();
data.extend(&be2(0));
data.extend(&be2(3));
data.extend_from_slice(b"foo");
let frames = f.feed(&data);
assert_eq!(frames.len(), 2);
assert!(frames[0].is_empty());
assert_eq!(frames[1], b"foo");
}
// ── 2-byte LE ────────────────────────────────────────────────────
#[test]
fn length_little_endian() {
let cfg = LengthConfig {
endian: Endian::Little,
..LengthConfig::default()
};
let mut f = LengthPrefixedFramer::new(cfg);
let mut data = le2(5);
data.extend_from_slice(b"hello");
let frames = f.feed(&data);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
// ── 1-byte length ────────────────────────────────────────────────
#[test]
fn length_one_byte() {
let cfg = LengthConfig {
len_bytes: 1,
..LengthConfig::default()
};
let mut f = LengthPrefixedFramer::new(cfg);
let data = [5, b'h', b'e', b'l', b'l', b'o'];
let frames = f.feed(&data);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
// ── 4-byte length ────────────────────────────────────────────────
#[test]
fn length_four_byte() {
let cfg = LengthConfig {
len_bytes: 4,
..LengthConfig::default()
};
let mut f = LengthPrefixedFramer::new(cfg);
let mut data = 5u32.to_be_bytes().to_vec();
data.extend_from_slice(b"hello");
let frames = f.feed(&data);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
// ── length_includes_self ─────────────────────────────────────────
#[test]
fn length_includes_self_enabled() {
let cfg = LengthConfig {
length_includes_self: true,
..LengthConfig::default()
};
let mut f = LengthPrefixedFramer::new(cfg);
let mut data = be2(5);
data.extend_from_slice(b"foo");
let frames = f.feed(&data);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"foo");
}
#[test]
fn length_includes_self_zero_payload() {
let cfg = LengthConfig {
length_includes_self: true,
..LengthConfig::default()
};
let mut f = LengthPrefixedFramer::new(cfg);
let frames = f.feed(&be2(2));
assert_eq!(frames.len(), 1);
assert!(frames[0].is_empty());
}
// ── max_payload safety ───────────────────────────────────────────
#[test]
fn length_max_payload_exceeded_skips() {
let cfg = LengthConfig {
max_payload: 10,
..LengthConfig::default()
};
let mut f = LengthPrefixedFramer::new(cfg);
// Corrupt frame: claims 200 bytes, actual payload is 0xFF bytes
let mut data = be2(200);
data.extend_from_slice(&vec![0xFFu8; 200]);
// Followed by valid frame
data.extend(&be2(3));
data.extend_from_slice(b"foo");
let frames = f.feed(&data);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"foo");
}
// ── flush / reset ────────────────────────────────────────────────
#[test]
fn length_flush_partial() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
f.feed(&be2(10));
f.feed(b"abc");
let flushed = f.flush();
assert_eq!(flushed, Some(b"abc".to_vec()));
}
#[test]
fn length_flush_empty() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
assert_eq!(f.flush(), None);
}
#[test]
fn length_reset_mid_frame() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
f.feed(&be2(50));
f.feed(b"partial");
assert!(f.pending_len() > 0);
f.reset();
assert_eq!(f.pending_len(), 0);
// Should work normally after reset
let mut data = be2(3);
data.extend_from_slice(b"foo");
let frames = f.feed(&data);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"foo");
}
#[test]
fn length_pending_len_reflects_buffer() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
f.feed(&be2(5));
assert_eq!(f.pending_len(), 0);
f.feed(b"he");
assert_eq!(f.pending_len(), 2);
}
}

View File

@@ -0,0 +1,218 @@
use super::Framer;
/// Line-delimited framer.
///
/// Splits incoming bytes on `\n` (LF). Optional `\r` (CR) stripping is
/// controlled via [`LineConfig::strip_cr`].
#[derive(Debug, Clone)]
pub struct LineConfig {
pub strip_cr: bool,
pub max_line_len: usize,
}
impl Default for LineConfig {
fn default() -> Self {
Self {
strip_cr: true,
max_line_len: 1024 * 1024,
}
}
}
#[derive(Debug, Clone)]
pub struct LineFramer {
buf: Vec<u8>,
config: LineConfig,
}
impl LineFramer {
pub fn new(config: LineConfig) -> Self {
Self {
buf: Vec::new(),
config,
}
}
pub fn config(&self) -> &LineConfig {
&self.config
}
}
impl Framer for LineFramer {
fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
let mut frames = Vec::new();
self.buf.extend_from_slice(data);
while let Some(pos) = self.buf.iter().position(|&b| b == b'\n') {
let mut line = self.buf.drain(..=pos).collect::<Vec<u8>>();
line.pop();
if self.config.strip_cr && line.last() == Some(&b'\r') {
line.pop();
}
if line.len() <= self.config.max_line_len {
frames.push(line);
}
}
frames
}
fn flush(&mut self) -> Option<Vec<u8>> {
if self.buf.is_empty() {
None
} else {
Some(std::mem::take(&mut self.buf))
}
}
fn reset(&mut self) {
self.buf.clear();
}
fn pending_len(&self) -> usize {
self.buf.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn line_single_complete() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"hello\n");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
#[test]
fn line_multiple_in_one_chunk() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"aaa\nbbb\nccc\n");
assert_eq!(frames.len(), 3);
assert_eq!(frames[0], b"aaa");
assert_eq!(frames[1], b"bbb");
assert_eq!(frames[2], b"ccc");
}
#[test]
fn line_split_across_chunks() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"hel");
assert!(frames.is_empty());
let frames = f.feed(b"lo\n");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
#[test]
fn line_crlf_stripping() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"hello\r\nworld\r\n");
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], b"hello");
assert_eq!(frames[1], b"world");
}
#[test]
fn line_crlf_no_strip() {
let cfg = LineConfig {
strip_cr: false,
..LineConfig::default()
};
let mut f = LineFramer::new(cfg);
let frames = f.feed(b"hello\r\n");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello\r");
}
#[test]
fn line_empty_lines() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"\n\n\n");
assert_eq!(frames.len(), 3);
assert!(frames[0].is_empty());
assert!(frames[1].is_empty());
assert!(frames[2].is_empty());
}
#[test]
fn line_no_newline_buffered() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"incomplete");
assert!(frames.is_empty());
assert_eq!(f.pending_len(), 10);
}
#[test]
fn line_flush_partial() {
let mut f = LineFramer::new(LineConfig::default());
f.feed(b"partial data");
let flushed = f.flush();
assert_eq!(flushed, Some(b"partial data".to_vec()));
assert_eq!(f.pending_len(), 0);
}
#[test]
fn line_flush_empty() {
let mut f = LineFramer::new(LineConfig::default());
assert_eq!(f.flush(), None);
}
#[test]
fn line_reset() {
let mut f = LineFramer::new(LineConfig::default());
f.feed(b"some data");
assert!(f.pending_len() > 0);
f.reset();
assert_eq!(f.pending_len(), 0);
}
#[test]
fn line_max_len_filtered() {
let cfg = LineConfig {
max_line_len: 5,
..LineConfig::default()
};
let mut f = LineFramer::new(cfg);
let frames = f.feed(b"short\nvery long line\nok\n");
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], b"short");
assert_eq!(frames[1], b"ok");
}
#[test]
fn line_feed_then_flush_chain() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"complete\npartial");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"complete");
let flushed = f.flush();
assert_eq!(flushed, Some(b"partial".to_vec()));
let frames = f.feed(b"new\n");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"new");
}
#[test]
fn line_binary_data_with_embedded_newlines() {
let mut f = LineFramer::new(LineConfig::default());
let data = [0x00, 0x01, b'\n', 0xFF, 0xFE, b'\n', 0x7F];
let frames = f.feed(&data);
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], &[0x00, 0x01]);
assert_eq!(frames[1], &[0xFF, 0xFE]);
}
#[test]
fn line_lf_only_with_strip_cr() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"line1\nline2\n");
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], b"line1");
assert_eq!(frames[1], b"line2");
}
}

View File

@@ -0,0 +1,35 @@
pub mod line;
pub mod fixed;
pub mod length;
pub mod cobs;
pub use crate::protocol::Endian;
/// Stateful byte-stream framer.
///
/// A `Framer` accumulates raw bytes from a transport layer and yields
/// complete frames once a boundary is detected. Each call to [`feed`]
/// may return zero, one, or many frames depending on how much data has
/// arrived.
///
/// [`feed`]: Framer::feed
pub trait Framer {
/// Feed a chunk of newly arrived bytes.
///
/// Any complete frames that can be extracted are returned. The
/// framer keeps incomplete trailing data in its internal buffer so
/// it can be completed on the next call.
fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>>;
/// Drain any remaining buffered data as a final frame.
///
/// This is typically called when the transport disconnects or the
/// user explicitly wants to flush a partial frame.
fn flush(&mut self) -> Option<Vec<u8>>;
/// Discard all buffered state and start fresh.
fn reset(&mut self);
/// Number of bytes currently buffered in the incomplete frame.
fn pending_len(&self) -> usize;
}

View File

@@ -0,0 +1,7 @@
pub mod error;
pub mod transport;
pub mod protocol;
pub mod frame;
pub mod pipeline;
pub use error::{Error, Result};

View File

@@ -0,0 +1,430 @@
use crate::frame::Framer;
use crate::protocol::{DecodedData, ProtocolDecoder};
/// A self-contained framing + decoding pipeline.
///
/// Each pipeline has its own `Framer` (with independent internal buffer)
/// and its own `ProtocolDecoder`. Feed the same byte chunk to multiple
/// pipelines and each will extract and decode only the frames it understands.
pub struct Pipeline {
name: String,
framer: Box<dyn Framer>,
decoder: Box<dyn ProtocolDecoder>,
}
impl Pipeline {
pub fn new(
name: impl Into<String>,
framer: Box<dyn Framer>,
decoder: Box<dyn ProtocolDecoder>,
) -> Self {
Self {
name: name.into(),
framer,
decoder,
}
}
pub fn name(&self) -> &str {
&self.name
}
fn feed_inner(&mut self, data: &[u8]) -> Vec<DecodedData> {
self.framer
.feed(data)
.into_iter()
.filter_map(|frame| self.decoder.decode(&frame))
.collect()
}
fn flush_inner(&mut self) -> Vec<DecodedData> {
self.framer
.flush()
.into_iter()
.filter_map(|frame| self.decoder.decode(&frame))
.collect()
}
fn reset_inner(&mut self) {
self.framer.reset();
}
}
/// One decoded result from a pipeline, tagged with the pipeline name.
#[derive(Debug, Clone)]
pub struct PipelineResult {
pub pipeline_name: String,
pub data: DecodedData,
}
/// Manages multiple [`Pipeline`]s, feeding the same byte stream to all of them.
///
/// ```
/// use xserial_core::pipeline::{MultiPipeline, Pipeline};
/// use xserial_core::frame::line::{LineFramer, LineConfig};
/// use xserial_core::frame::fixed::FixedLengthFramer;
/// use xserial_core::protocol::text::{TextDecoder, TextEncoding};
/// use xserial_core::protocol::hex::{HexDecoder, HexConfig};
///
/// let mut mp = MultiPipeline::new();
/// mp.add(
/// Pipeline::new("text",
/// Box::new(LineFramer::new(LineConfig::default())),
/// Box::new(TextDecoder::new(TextEncoding::Utf8)),
/// )
/// );
/// mp.add(
/// Pipeline::new("hex",
/// Box::new(FixedLengthFramer::new(8)),
/// Box::new(HexDecoder::new(HexConfig::default())),
/// )
/// );
///
/// let results = mp.feed(b"hello\n");
/// // "text" pipeline produced one line, "hex" pipeline is still buffering
/// ```
pub struct MultiPipeline {
pipelines: Vec<Pipeline>,
}
impl MultiPipeline {
pub fn new() -> Self {
Self {
pipelines: Vec::new(),
}
}
pub fn add(&mut self, pipeline: Pipeline) {
self.pipelines.push(pipeline);
}
pub fn pipeline_count(&self) -> usize {
self.pipelines.len()
}
/// Feed a chunk of bytes to every pipeline.
///
/// Returns all decoded results from all pipelines, in the order the
/// pipelines were added. Pipelines that produced no complete frames
/// (or whose decoder returned `None`) are simply absent from the output.
pub fn feed(&mut self, data: &[u8]) -> Vec<PipelineResult> {
let mut results = Vec::new();
for p in &mut self.pipelines {
let name = p.name.clone();
for decoded in p.feed_inner(data) {
results.push(PipelineResult {
pipeline_name: name.clone(),
data: decoded,
});
}
}
results
}
/// Flush every pipeline.
///
/// Call after the transport disconnects to drain any incomplete
/// frames still buffered inside the framers.
pub fn flush(&mut self) -> Vec<PipelineResult> {
let mut results = Vec::new();
for p in &mut self.pipelines {
let name = p.name.clone();
for decoded in p.flush_inner() {
results.push(PipelineResult {
pipeline_name: name.clone(),
data: decoded,
});
}
}
results
}
/// Reset every pipeline to a clean state (discards all buffered data).
pub fn reset(&mut self) {
for p in &mut self.pipelines {
p.reset_inner();
}
}
/// Return `(name, pending_bytes)` for each pipeline that has buffered data.
pub fn pending_bytes(&self) -> Vec<(&str, usize)> {
self.pipelines
.iter()
.map(|p| (p.name.as_str(), p.framer.pending_len()))
.filter(|(_, len)| *len > 0)
.collect()
}
}
impl Default for MultiPipeline {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frame::fixed::FixedLengthFramer;
use crate::frame::length::{LengthConfig, LengthPrefixedFramer};
use crate::frame::line::{LineConfig, LineFramer};
use crate::protocol::hex::{HexConfig, HexDecoder};
use crate::protocol::plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType};
use crate::protocol::text::{TextDecoder, TextEncoding};
use crate::protocol::Endian;
#[test]
fn multi_text_and_hex_same_stream() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"hex",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(HexDecoder::new(HexConfig::default())),
));
// Both pipelines use LineFramer → both see "hello\n" and "42\n"
let results = mp.feed(b"hello\n42\n");
assert_eq!(results.len(), 4);
assert_eq!(results[0].pipeline_name, "text");
assert!(matches!(&results[0].data, DecodedData::Text(s) if s == "hello"));
assert_eq!(results[1].pipeline_name, "text");
assert!(matches!(&results[1].data, DecodedData::Text(s) if s == "42"));
assert_eq!(results[2].pipeline_name, "hex");
assert!(matches!(&results[2].data, DecodedData::Hex(s) if s == "68 65 6c 6c 6f"));
assert_eq!(results[3].pipeline_name, "hex");
assert!(matches!(&results[3].data, DecodedData::Hex(s) if s == "34 32"));
}
#[test]
fn multi_text_success_hex_silent_on_binary_trash() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"hex",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(HexDecoder::new(HexConfig::default())),
));
let results = mp.feed(b"valid\n\xff\xfe\xfd\n");
// Text pipeline: "valid" ok, binary line fails UTF-8 → only 1 result
// Hex pipeline: both lines decode successfully → 2 results
assert_eq!(results.len(), 3);
assert_eq!(results[0].pipeline_name, "text");
assert!(matches!(&results[0].data, DecodedData::Text(s) if s == "valid"));
assert_eq!(results[1].pipeline_name, "hex");
assert!(matches!(&results[1].data, DecodedData::Hex(_)));
assert_eq!(results[2].pipeline_name, "hex");
assert!(matches!(&results[2].data, DecodedData::Hex(_)));
}
#[test]
fn multi_different_framers_per_pipeline() {
let mut mp = MultiPipeline::new();
// Text uses LineFramer, hex uses FixedLengthFramer(4)
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"hex",
Box::new(FixedLengthFramer::new(4)),
Box::new(HexDecoder::new(HexConfig::default())),
));
let results = mp.feed(b"abc\n1234");
// text: sees "abc\n" → produces "abc"
// hex: sees 8 bytes → produces 2 fixed frames: "abc\n" and "1234"
assert_eq!(results.len(), 3);
assert_eq!(results[0].pipeline_name, "text");
assert!(matches!(&results[0].data, DecodedData::Text(s) if s == "abc"));
let hex_results: Vec<_> = results
.iter()
.filter(|r| r.pipeline_name == "hex")
.collect();
assert_eq!(hex_results.len(), 2);
}
#[test]
fn multi_flush_drains_all() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"hex",
Box::new(FixedLengthFramer::new(4)),
Box::new(HexDecoder::new(HexConfig::default())),
));
// "partial" = 7 bytes. Line: no \n → buffers. Fixed(4): 1 frame "part", 3 left.
mp.feed(b"partial");
let flushed = mp.flush();
// Line flush → "partial" as text
// Fixed flush → "ial" as hex ("69 61 6c")
assert_eq!(flushed.len(), 2);
assert_eq!(flushed[0].pipeline_name, "text");
assert!(matches!(&flushed[0].data, DecodedData::Text(s) if s == "partial"));
assert_eq!(flushed[1].pipeline_name, "hex");
assert!(matches!(&flushed[1].data, DecodedData::Hex(s) if s == "69 61 6c"));
}
#[test]
fn multi_reset_clears_all_state() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"hex",
Box::new(FixedLengthFramer::new(4)),
Box::new(HexDecoder::new(HexConfig::default())),
));
mp.feed(b"unused data here that never completes");
assert_eq!(mp.pending_bytes().len(), 2);
mp.reset();
assert!(mp.pending_bytes().is_empty());
// After reset, pipelines work normally
let results = mp.feed(b"ok\nabcd");
assert!(!results.is_empty());
}
#[test]
fn multi_pending_bytes_reporting() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"t",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"h",
Box::new(FixedLengthFramer::new(100)),
Box::new(HexDecoder::new(HexConfig::default())),
));
mp.feed(b"hello world");
let pending: Vec<_> = mp.pending_bytes();
assert_eq!(pending.len(), 2);
// Both have the same 11 bytes buffered
assert_eq!(pending[0], ("t", 11));
assert_eq!(pending[1], ("h", 11));
}
#[test]
fn multi_empty_feed_no_results() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
let results = mp.feed(b"");
assert!(results.is_empty());
}
#[test]
fn multi_no_pipelines() {
let mut mp = MultiPipeline::new();
let results = mp.feed(b"data");
assert!(results.is_empty());
assert_eq!(mp.pipeline_count(), 0);
}
#[test]
fn multi_pipeline_count() {
let mut mp = MultiPipeline::new();
assert_eq!(mp.pipeline_count(), 0);
mp.add(Pipeline::new(
"a",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
assert_eq!(mp.pipeline_count(), 1);
}
#[test]
fn multi_text_and_plot_mixed_stream() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"plot",
Box::new(FixedLengthFramer::new(8)), // 2 × f32 LE per frame
Box::new(PlotDecoder::new(PlotConfig {
sample_type: SampleType::F32,
endian: Endian::Little,
channels: 1,
format: PlotFormat::Interleaved,
})),
));
// 10 bytes of text + 8 bytes of f32 samples = 18 total.
// FixedLengthFramer(8) extracts 2 frames. The first frame is ASCII
// bytes which happen to decode as valid (but meaningless) f32 values
// — PlotDecoder can't reject them. This is by design: garbage-in,
// garbage-out; the UI layer decides what to display.
let mut data = b"status ok\n".to_vec();
data.extend_from_slice(&1.0f32.to_le_bytes());
data.extend_from_slice(&2.0f32.to_le_bytes());
let results = mp.feed(&data);
// text: 1 line, plot: 2 fixed frames (first = garbage f32, second = real)
assert_eq!(results.len(), 3);
assert_eq!(results[0].pipeline_name, "text");
assert!(matches!(&results[0].data, DecodedData::Text(s) if s == "status ok"));
}
#[test]
fn multi_length_prefixed_and_line_stacked() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"line",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
// Second pipeline uses a different framer on the same bytes.
// The length framer sees "he" (from "hello\n") as a bogus length header
// and silently produces no frames — that's expected.
mp.add(Pipeline::new(
"len",
Box::new(LengthPrefixedFramer::new(LengthConfig::default())),
Box::new(HexDecoder::new(HexConfig::default())),
));
let mut data = b"hello\n".to_vec();
data.extend_from_slice(&3u16.to_be_bytes());
data.extend_from_slice(b"abc");
let results = mp.feed(&data);
// Only the line pipeline produces output; length pipeline silently
// ignores bytes that don't form valid length-prefixed frames.
assert_eq!(results.len(), 1);
assert!(matches!(&results[0].data, DecodedData::Text(s) if s == "hello"));
}
}

View File

@@ -0,0 +1,229 @@
use super::{DecodedData, ProtocolDecoder};
use crate::protocol::Endian;
#[derive(Debug, Clone)]
pub struct HexConfig {
pub uppercase: bool,
pub separator: String,
pub bytes_per_group: usize,
pub endian: Endian,
}
impl Default for HexConfig {
fn default() -> Self {
Self {
uppercase: false,
separator: String::from(" "),
bytes_per_group: 1,
endian: Endian::Big,
}
}
}
#[derive(Debug, Clone)]
pub struct HexDecoder {
config: HexConfig,
}
impl HexDecoder {
pub fn new(config: HexConfig) -> Self {
Self { config }
}
pub fn config(&self) -> &HexConfig {
&self.config
}
}
impl ProtocolDecoder for HexDecoder {
fn name(&self) -> &str {
"Hex"
}
fn decode(&self, frame: &[u8]) -> Option<DecodedData> {
if frame.is_empty() {
return Some(DecodedData::Hex(String::new()));
}
let group_size = self.config.bytes_per_group.max(1);
let mut result = String::new();
let chunks: Vec<&[u8]> = frame.chunks(group_size).collect();
let total = chunks.len();
for (i, chunk) in chunks.iter().enumerate() {
if i > 0 {
result.push_str(&self.config.separator);
}
let is_last = i == total - 1;
let mut display: Vec<u8> = chunk.to_vec();
if !is_last || chunk.len() == group_size {
// Complete group — apply endian reversal
if self.config.endian == Endian::Little {
display.reverse();
}
}
// Incomplete trailing group — display bytes in original order
for b in &display {
let hex_byte = if self.config.uppercase {
format!("{:02X}", b)
} else {
format!("{:02x}", b)
};
result.push_str(&hex_byte);
}
}
Some(DecodedData::Hex(result))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_default_single_byte_lower() {
let d = HexDecoder::new(HexConfig::default());
let result = d.decode(&[0x0a, 0x1b, 0xff]);
assert!(matches!(result, Some(DecodedData::Hex(ref s)) if s == "0a 1b ff"));
}
#[test]
fn hex_uppercase() {
let cfg = HexConfig {
uppercase: true,
..HexConfig::default()
};
let d = HexDecoder::new(cfg);
let result = d.decode(&[0x0a, 0x1b, 0xff]);
assert!(matches!(result, Some(DecodedData::Hex(ref s)) if s == "0A 1B FF"));
}
#[test]
fn hex_no_separator() {
let cfg = HexConfig {
separator: String::new(),
..HexConfig::default()
};
let d = HexDecoder::new(cfg);
let result = d.decode(&[0xde, 0xad, 0xbe, 0xef]);
assert!(matches!(result, Some(DecodedData::Hex(ref s)) if s == "deadbeef"));
}
#[test]
fn hex_custom_separator() {
let cfg = HexConfig {
separator: String::from(":"),
..HexConfig::default()
};
let d = HexDecoder::new(cfg);
let result = d.decode(&[0x01, 0x02, 0x03]);
assert!(matches!(result, Some(DecodedData::Hex(ref s)) if s == "01:02:03"));
}
#[test]
fn hex_group_u16_big_endian() {
let cfg = HexConfig {
bytes_per_group: 2,
endian: Endian::Big,
..HexConfig::default()
};
let d = HexDecoder::new(cfg);
let result = d.decode(&[0x12, 0x34, 0x56, 0x78]);
assert!(matches!(result, Some(DecodedData::Hex(ref s)) if s == "1234 5678"));
}
#[test]
fn hex_group_u16_little_endian() {
let cfg = HexConfig {
bytes_per_group: 2,
endian: Endian::Little,
..HexConfig::default()
};
let d = HexDecoder::new(cfg);
let result = d.decode(&[0x12, 0x34, 0x56, 0x78]);
assert!(matches!(result, Some(DecodedData::Hex(ref s)) if s == "3412 7856"));
}
#[test]
fn hex_group_u32_little_endian() {
let cfg = HexConfig {
bytes_per_group: 4,
endian: Endian::Little,
..HexConfig::default()
};
let d = HexDecoder::new(cfg);
let result = d.decode(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
assert!(matches!(result, Some(DecodedData::Hex(ref s)) if s == "04030201 08070605"));
}
#[test]
fn hex_incomplete_trailing_group_no_reversal() {
let cfg = HexConfig {
bytes_per_group: 2,
endian: Endian::Little,
..HexConfig::default()
};
let d = HexDecoder::new(cfg);
// 5 bytes: two complete groups + 1 trailing byte
let result = d.decode(&[0x12, 0x34, 0x56, 0x78, 0x9a]);
assert!(matches!(result, Some(DecodedData::Hex(ref s)) if s == "3412 7856 9a"));
}
#[test]
fn hex_empty_input() {
let d = HexDecoder::new(HexConfig::default());
let result = d.decode(&[]);
assert!(matches!(result, Some(DecodedData::Hex(ref s)) if s.is_empty()));
}
#[test]
fn hex_single_byte() {
let d = HexDecoder::new(HexConfig::default());
let result = d.decode(&[0x42]);
assert!(matches!(result, Some(DecodedData::Hex(ref s)) if s == "42"));
}
#[test]
fn hex_zero_bytes() {
let d = HexDecoder::new(HexConfig::default());
let result = d.decode(&[0x00, 0x00, 0x00]);
assert!(matches!(result, Some(DecodedData::Hex(ref s)) if s == "00 00 00"));
}
#[test]
fn hex_group_size_one_never_reverses() {
let cfg = HexConfig {
bytes_per_group: 1,
endian: Endian::Little,
..HexConfig::default()
};
let d = HexDecoder::new(cfg);
let result = d.decode(&[0x01, 0x02, 0x03]);
assert!(matches!(result, Some(DecodedData::Hex(ref s)) if s == "01 02 03"));
}
#[test]
fn hex_name() {
let d = HexDecoder::new(HexConfig::default());
assert_eq!(d.name(), "Hex");
}
#[test]
fn hex_config_accessor() {
let cfg = HexConfig {
bytes_per_group: 4,
endian: Endian::Little,
uppercase: true,
separator: String::from("-"),
};
let d = HexDecoder::new(cfg.clone());
assert_eq!(d.config().bytes_per_group, 4);
assert_eq!(d.config().endian, Endian::Little);
assert!(d.config().uppercase);
assert_eq!(d.config().separator, "-");
}
}

View File

@@ -0,0 +1,41 @@
pub mod text;
pub mod hex;
pub mod plot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Endian {
Little,
Big,
}
/// Decoded result from a protocol decoder.
#[derive(Debug, Clone)]
pub enum DecodedData {
Text(String),
Hex(String),
Plot(plot::PlotFrame),
Binary(Vec<u8>),
}
impl DecodedData {
pub fn summary(&self) -> String {
match self {
DecodedData::Text(s) => s.clone(),
DecodedData::Hex(s) => s.clone(),
DecodedData::Plot(frame) => {
format!(
"Plot: {} channels × {} samples ({})",
frame.channels.len(),
frame.sample_count(),
frame.sample_type.name(),
)
}
DecodedData::Binary(v) => format!("Binary: {} bytes", v.len()),
}
}
}
pub trait ProtocolDecoder: Send + Sync {
fn name(&self) -> &str;
fn decode(&self, frame: &[u8]) -> Option<DecodedData>;
}

View File

@@ -0,0 +1,508 @@
use super::{DecodedData, ProtocolDecoder};
use crate::protocol::Endian;
/// Numeric type of samples in a plot frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SampleType {
I8,
U8,
I16,
U16,
I32,
U32,
I64,
U64,
F32,
F64,
}
impl SampleType {
pub fn name(&self) -> &str {
match self {
SampleType::I8 => "i8",
SampleType::U8 => "u8",
SampleType::I16 => "i16",
SampleType::U16 => "u16",
SampleType::I32 => "i32",
SampleType::U32 => "u32",
SampleType::I64 => "i64",
SampleType::U64 => "u64",
SampleType::F32 => "f32",
SampleType::F64 => "f64",
}
}
pub fn byte_size(&self) -> usize {
match self {
SampleType::I8 | SampleType::U8 => 1,
SampleType::I16 | SampleType::U16 => 2,
SampleType::I32 | SampleType::U32 | SampleType::F32 => 4,
SampleType::I64 | SampleType::U64 | SampleType::F64 => 8,
}
}
}
/// Channel layout for multi-channel plot data.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlotFormat {
/// Interleaved: ch0_s0, ch1_s0, ch0_s1, ch1_s1, ...
Interleaved,
/// Block: ch0_s0, ch0_s1, ..., ch1_s0, ch1_s1, ...
Block,
/// XY pairs: x0, y0, x1, y1, ... (equivalent to 2 interleaved channels).
XY,
}
/// Configuration for the plot protocol decoder.
#[derive(Debug, Clone)]
pub struct PlotConfig {
pub sample_type: SampleType,
pub endian: Endian,
pub channels: usize,
pub format: PlotFormat,
}
impl Default for PlotConfig {
fn default() -> Self {
Self {
sample_type: SampleType::F32,
endian: Endian::Little,
channels: 1,
format: PlotFormat::Interleaved,
}
}
}
/// A decoded plot frame containing numeric channel data.
#[derive(Debug, Clone)]
pub struct PlotFrame {
pub channels: Vec<Vec<f64>>,
pub raw: Vec<u8>,
pub sample_type: SampleType,
}
impl PlotFrame {
pub fn sample_count(&self) -> usize {
self.channels.first().map_or(0, |c| c.len())
}
}
/// Plot protocol decoder.
///
/// Interprets binary frames as numeric samples and organizes them into
/// channels according to the configured layout.
#[derive(Debug, Clone)]
pub struct PlotDecoder {
config: PlotConfig,
}
impl PlotDecoder {
pub fn new(config: PlotConfig) -> Self {
Self { config }
}
pub fn config(&self) -> &PlotConfig {
&self.config
}
fn read_one_sample(&self, data: &[u8]) -> Option<f64> {
let size = self.config.sample_type.byte_size();
if data.len() < size {
return None;
}
let bytes = &data[..size];
let raw = match self.config.sample_type {
SampleType::I8 => bytes[0] as i8 as f64,
SampleType::U8 => bytes[0] as f64,
SampleType::I16 => {
let b = [bytes[0], bytes[1]];
let val = match self.config.endian {
Endian::Big => i16::from_be_bytes(b),
Endian::Little => i16::from_le_bytes(b),
};
val as f64
}
SampleType::U16 => {
let b = [bytes[0], bytes[1]];
let val = match self.config.endian {
Endian::Big => u16::from_be_bytes(b),
Endian::Little => u16::from_le_bytes(b),
};
val as f64
}
SampleType::I32 => {
let b = [bytes[0], bytes[1], bytes[2], bytes[3]];
let val = match self.config.endian {
Endian::Big => i32::from_be_bytes(b),
Endian::Little => i32::from_le_bytes(b),
};
val as f64
}
SampleType::U32 => {
let b = [bytes[0], bytes[1], bytes[2], bytes[3]];
let val = match self.config.endian {
Endian::Big => u32::from_be_bytes(b),
Endian::Little => u32::from_le_bytes(b),
};
val as f64
}
SampleType::I64 => {
let b = [
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5], bytes[6], bytes[7],
];
let val = match self.config.endian {
Endian::Big => i64::from_be_bytes(b),
Endian::Little => i64::from_le_bytes(b),
};
val as f64
}
SampleType::U64 => {
let b = [
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5], bytes[6], bytes[7],
];
let val = match self.config.endian {
Endian::Big => u64::from_be_bytes(b),
Endian::Little => u64::from_le_bytes(b),
};
val as f64
}
SampleType::F32 => {
let b = [bytes[0], bytes[1], bytes[2], bytes[3]];
let val = match self.config.endian {
Endian::Big => f32::from_be_bytes(b),
Endian::Little => f32::from_le_bytes(b),
};
val as f64
}
SampleType::F64 => {
let b = [
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5], bytes[6], bytes[7],
];
match self.config.endian {
Endian::Big => f64::from_be_bytes(b),
Endian::Little => f64::from_le_bytes(b),
}
}
};
Some(raw)
}
}
impl ProtocolDecoder for PlotDecoder {
fn name(&self) -> &str {
"Plot"
}
fn decode(&self, frame: &[u8]) -> Option<DecodedData> {
let sample_size = self.config.sample_type.byte_size();
let num_channels = match self.config.format {
PlotFormat::XY => 2,
_ => self.config.channels.max(1),
};
let total_samples = frame.len() / sample_size;
if total_samples == 0 {
return None;
}
let samples_per_channel = total_samples / num_channels;
if samples_per_channel == 0 {
return None;
}
// Read all samples
let mut flat: Vec<f64> = Vec::with_capacity(total_samples);
let mut offset = 0;
while offset + sample_size <= frame.len() {
let val = self.read_one_sample(&frame[offset..])?;
flat.push(val);
offset += sample_size;
}
// Distribute into channels
let mut channels: Vec<Vec<f64>> = vec![Vec::with_capacity(samples_per_channel); num_channels];
match self.config.format {
PlotFormat::Interleaved | PlotFormat::XY => {
for (i, val) in flat.into_iter().enumerate() {
let ch = i % num_channels;
if channels[ch].len() < samples_per_channel {
channels[ch].push(val);
}
}
}
PlotFormat::Block => {
for (ch, channel) in channels.iter_mut().enumerate() {
let start = ch * samples_per_channel;
let end = start + samples_per_channel;
if end <= flat.len() {
channel.extend_from_slice(&flat[start..end]);
}
}
}
}
Some(DecodedData::Plot(PlotFrame {
channels,
raw: frame.to_vec(),
sample_type: self.config.sample_type,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sample_type_names() {
assert_eq!(SampleType::I8.name(), "i8");
assert_eq!(SampleType::U16.name(), "u16");
assert_eq!(SampleType::F32.name(), "f32");
assert_eq!(SampleType::F64.name(), "f64");
}
#[test]
fn sample_type_sizes() {
assert_eq!(SampleType::I8.byte_size(), 1);
assert_eq!(SampleType::U8.byte_size(), 1);
assert_eq!(SampleType::I16.byte_size(), 2);
assert_eq!(SampleType::U16.byte_size(), 2);
assert_eq!(SampleType::I32.byte_size(), 4);
assert_eq!(SampleType::F32.byte_size(), 4);
assert_eq!(SampleType::I64.byte_size(), 8);
assert_eq!(SampleType::F64.byte_size(), 8);
}
#[test]
fn plot_single_channel_u8() {
let cfg = PlotConfig {
sample_type: SampleType::U8,
channels: 1,
..PlotConfig::default()
};
let d = PlotDecoder::new(cfg);
let data = vec![10u8, 20, 30, 40];
let result = d.decode(&data).unwrap();
match result {
DecodedData::Plot(frame) => {
assert_eq!(frame.channels.len(), 1);
assert_eq!(frame.channels[0], vec![10.0, 20.0, 30.0, 40.0]);
assert_eq!(frame.sample_count(), 4);
}
other => panic!("expected Plot, got {:?}", other),
}
}
#[test]
fn plot_single_channel_i16_le() {
let cfg = PlotConfig {
sample_type: SampleType::I16,
endian: Endian::Little,
channels: 1,
..PlotConfig::default()
};
let d = PlotDecoder::new(cfg);
let data = vec![0x00, 0x80, 0xff, 0x7f]; // -32768, 32767 in LE
let result = d.decode(&data).unwrap();
match result {
DecodedData::Plot(frame) => {
assert_eq!(frame.channels.len(), 1);
assert_eq!(frame.channels[0], vec![-32768.0, 32767.0]);
}
other => panic!("expected Plot, got {:?}", other),
}
}
#[test]
fn plot_single_channel_f32_le() {
let cfg = PlotConfig {
sample_type: SampleType::F32,
endian: Endian::Little,
channels: 1,
..PlotConfig::default()
};
let d = PlotDecoder::new(cfg);
// 1.0, -2.5 in f32 LE
let mut data = Vec::new();
data.extend_from_slice(&1.0f32.to_le_bytes());
data.extend_from_slice(&(-2.5f32).to_le_bytes());
let result = d.decode(&data).unwrap();
match result {
DecodedData::Plot(frame) => {
assert_eq!(frame.channels.len(), 1);
assert_eq!(frame.channels[0].len(), 2);
assert!((frame.channels[0][0] - 1.0).abs() < 1e-6);
assert!((frame.channels[0][1] - (-2.5)).abs() < 1e-6);
}
other => panic!("expected Plot, got {:?}", other),
}
}
#[test]
fn plot_two_channels_interleaved_u16_le() {
let cfg = PlotConfig {
sample_type: SampleType::U16,
endian: Endian::Little,
channels: 2,
format: PlotFormat::Interleaved,
};
let d = PlotDecoder::new(cfg);
// ch0: 100, 300 ch1: 200, 400
let data = vec![
100u16.to_le_bytes(), 200u16.to_le_bytes(),
300u16.to_le_bytes(), 400u16.to_le_bytes(),
].into_iter().flatten().collect::<Vec<u8>>();
let result = d.decode(&data).unwrap();
match result {
DecodedData::Plot(frame) => {
assert_eq!(frame.channels.len(), 2);
assert_eq!(frame.channels[0], vec![100.0, 300.0]);
assert_eq!(frame.channels[1], vec![200.0, 400.0]);
}
other => panic!("expected Plot, got {:?}", other),
}
}
#[test]
fn plot_two_channels_block_u16_be() {
let cfg = PlotConfig {
sample_type: SampleType::U16,
endian: Endian::Big,
channels: 2,
format: PlotFormat::Block,
};
let d = PlotDecoder::new(cfg);
// ch0: 10, 20 ch1: 30, 40
let data = vec![
10u16.to_be_bytes(), 20u16.to_be_bytes(),
30u16.to_be_bytes(), 40u16.to_be_bytes(),
].into_iter().flatten().collect::<Vec<u8>>();
let result = d.decode(&data).unwrap();
match result {
DecodedData::Plot(frame) => {
assert_eq!(frame.channels.len(), 2);
assert_eq!(frame.channels[0], vec![10.0, 20.0]);
assert_eq!(frame.channels[1], vec![30.0, 40.0]);
}
other => panic!("expected Plot, got {:?}", other),
}
}
#[test]
fn plot_xy_format() {
let cfg = PlotConfig {
sample_type: SampleType::U16,
endian: Endian::Little,
channels: 0, // ignored for XY
format: PlotFormat::XY,
};
let d = PlotDecoder::new(cfg);
// (x,y) pairs: (10, 100), (20, 200)
let data = vec![
10u16.to_le_bytes(), 100u16.to_le_bytes(),
20u16.to_le_bytes(), 200u16.to_le_bytes(),
].into_iter().flatten().collect::<Vec<u8>>();
let result = d.decode(&data).unwrap();
match result {
DecodedData::Plot(frame) => {
assert_eq!(frame.channels.len(), 2);
assert_eq!(frame.channels[0], vec![10.0, 20.0]); // X
assert_eq!(frame.channels[1], vec![100.0, 200.0]); // Y
}
other => panic!("expected Plot, got {:?}", other),
}
}
#[test]
fn plot_empty_frame_returns_none() {
let d = PlotDecoder::new(PlotConfig::default());
assert!(d.decode(&[]).is_none());
}
#[test]
fn plot_too_few_bytes_returns_none() {
let cfg = PlotConfig {
sample_type: SampleType::F64,
channels: 1,
..PlotConfig::default()
};
let d = PlotDecoder::new(cfg);
assert!(d.decode(&[0x00, 0x01, 0x02]).is_none());
}
#[test]
fn plot_insufficient_for_channels_returns_none() {
let cfg = PlotConfig {
sample_type: SampleType::U8,
endian: Endian::Little,
channels: 3,
format: PlotFormat::Interleaved,
};
let d = PlotDecoder::new(cfg);
// 2 bytes for 3 channels → not enough for 1 full sample per channel
assert!(d.decode(&[1, 2]).is_none());
}
#[test]
fn plot_trailing_partial_sample_ignored() {
let cfg = PlotConfig {
sample_type: SampleType::U32,
channels: 1,
..PlotConfig::default()
};
let d = PlotDecoder::new(cfg);
// 7 bytes: 1 full u32 (4 bytes) + 3 trailing bytes
let data = vec![1u32.to_le_bytes().to_vec(), vec![0xff; 3]].concat();
let result = d.decode(&data).unwrap();
match result {
DecodedData::Plot(frame) => {
assert_eq!(frame.channels.len(), 1);
assert_eq!(frame.channels[0], vec![1.0]);
}
other => panic!("expected Plot, got {:?}", other),
}
}
#[test]
fn plot_name() {
let d = PlotDecoder::new(PlotConfig::default());
assert_eq!(d.name(), "Plot");
}
#[test]
fn plot_frame_sample_count_empty() {
let frame = PlotFrame {
channels: vec![],
raw: vec![],
sample_type: SampleType::U8,
};
assert_eq!(frame.sample_count(), 0);
}
#[test]
fn plot_raw_preserved() {
let cfg = PlotConfig {
sample_type: SampleType::U8,
channels: 1,
..PlotConfig::default()
};
let d = PlotDecoder::new(cfg);
let data = vec![1, 2, 3];
let result = d.decode(&data).unwrap();
match result {
DecodedData::Plot(frame) => {
assert_eq!(frame.raw, data);
}
other => panic!("expected Plot, got {:?}", other),
}
}
#[test]
fn plot_decoder_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<PlotDecoder>();
}
}

View File

@@ -0,0 +1,145 @@
use super::{DecodedData, ProtocolDecoder};
/// Supported text encodings for the text protocol decoder.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextEncoding {
/// Standard UTF-8. Returns `None` from `decode()` on invalid byte sequences.
Utf8,
/// ISO-8859-1 / Latin-1. Every byte maps 1:1 to a Unicode code point.
/// This encoding never fails.
Latin1,
/// 7-bit ASCII. Returns `None` from `decode()` if any byte has the high bit set.
Ascii,
}
/// Text protocol decoder.
///
/// Converts raw byte frames to UTF-8 strings using the configured encoding.
#[derive(Debug, Clone)]
pub struct TextDecoder {
encoding: TextEncoding,
}
impl TextDecoder {
pub fn new(encoding: TextEncoding) -> Self {
Self { encoding }
}
pub fn encoding(&self) -> TextEncoding {
self.encoding
}
}
impl ProtocolDecoder for TextDecoder {
fn name(&self) -> &str {
"Text"
}
fn decode(&self, frame: &[u8]) -> Option<DecodedData> {
let text = match self.encoding {
TextEncoding::Utf8 => String::from_utf8(frame.to_vec()).ok()?,
TextEncoding::Latin1 => frame.iter().map(|&b| b as char).collect(),
TextEncoding::Ascii => {
if frame.iter().any(|&b| b >= 128) {
return None;
}
frame.iter().map(|&b| b as char).collect()
}
};
Some(DecodedData::Text(text))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_utf8_valid() {
let d = TextDecoder::new(TextEncoding::Utf8);
let result = d.decode(b"hello");
assert!(matches!(result, Some(DecodedData::Text(ref s)) if s == "hello"));
}
#[test]
fn text_utf8_chinese() {
let d = TextDecoder::new(TextEncoding::Utf8);
let result = d.decode("你好世界".as_bytes());
assert!(matches!(result, Some(DecodedData::Text(ref s)) if s == "你好世界"));
}
#[test]
fn text_utf8_invalid_returns_none() {
let d = TextDecoder::new(TextEncoding::Utf8);
let invalid = vec![0xff, 0xfe, 0xfd];
assert!(d.decode(&invalid).is_none());
}
#[test]
fn text_utf8_empty() {
let d = TextDecoder::new(TextEncoding::Utf8);
assert!(matches!(d.decode(b""), Some(DecodedData::Text(ref s)) if s.is_empty()));
}
#[test]
fn text_latin1_all_bytes() {
let d = TextDecoder::new(TextEncoding::Latin1);
let data: Vec<u8> = (0..=255).collect();
let result = d.decode(&data).unwrap();
if let DecodedData::Text(s) = result {
assert_eq!(s.chars().count(), 256);
// Verify character code points match original bytes
for (i, ch) in s.chars().enumerate() {
assert_eq!(ch as u32, i as u32);
}
} else {
panic!("expected Text");
}
}
#[test]
fn text_latin1_empty() {
let d = TextDecoder::new(TextEncoding::Latin1);
assert!(matches!(d.decode(b""), Some(DecodedData::Text(ref s)) if s.is_empty()));
}
#[test]
fn text_ascii_valid() {
let d = TextDecoder::new(TextEncoding::Ascii);
let result = d.decode(b"Hello World 123!");
assert!(matches!(result, Some(DecodedData::Text(ref s)) if s == "Hello World 123!"));
}
#[test]
fn text_ascii_high_bit_returns_none() {
let d = TextDecoder::new(TextEncoding::Ascii);
assert!(d.decode(&[0x80]).is_none());
assert!(d.decode(&[b'A', 0xff, b'B']).is_none());
}
#[test]
fn text_ascii_boundary() {
let d = TextDecoder::new(TextEncoding::Ascii);
assert!(d.decode(&[0x7f]).is_some());
assert!(d.decode(&[0x80]).is_none());
}
#[test]
fn text_name() {
assert_eq!(TextDecoder::new(TextEncoding::Utf8).name(), "Text");
assert_eq!(TextDecoder::new(TextEncoding::Latin1).name(), "Text");
assert_eq!(TextDecoder::new(TextEncoding::Ascii).name(), "Text");
}
#[test]
fn text_encoding_accessor() {
let d = TextDecoder::new(TextEncoding::Latin1);
assert_eq!(d.encoding(), TextEncoding::Latin1);
}
#[test]
fn text_decoder_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<TextDecoder>();
}
}

View File

@@ -0,0 +1,767 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncWrite};
use crate::transport::serial::SerialTransport;
use crate::transport::tcp::TcpTransport;
use crate::transport::udp::UdpTransport;
use crate::error::Result;
pub mod serial;
pub mod tcp;
pub mod udp;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransportType {
Serial,
Tcp,
Udp
}
impl std::fmt::Display for TransportType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TransportType::Serial => write!(f, "Serial"),
TransportType::Tcp => write!(f, "Tcp"),
TransportType::Udp => write!(f, "Udp")
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TransportConfig {
Serial {
port: String,
baud_rate: u32
},
Tcp {
addr: String
},
Udp {
bind_addr: String,
remote_addr: Option<String>
}
}
#[async_trait]
pub trait Transport: AsyncRead + AsyncWrite + Send + Sync + Unpin {
fn name(&self) -> &str;
fn transport_type(&self) -> TransportType;
fn is_connected(&self) -> bool;
async fn connect(&mut self) -> Result<()>;
async fn disconnect(&mut self) -> Result<()>;
}
#[derive(Debug)]
pub enum Connection {
Serial(SerialTransport),
Tcp(TcpTransport),
Udp(UdpTransport),
}
impl Connection {
pub fn new(config: TransportConfig) -> Self {
match config {
TransportConfig::Serial { port, baud_rate } => {
Connection::Serial(SerialTransport::new(port, baud_rate))
}
TransportConfig::Tcp { addr } => Connection::Tcp(TcpTransport::new(addr)),
TransportConfig::Udp {
bind_addr,
remote_addr,
} => Connection::Udp(UdpTransport::new(bind_addr, remote_addr)),
}
}
pub fn transport_type(&self) -> TransportType {
match self {
Connection::Serial(_) => TransportType::Serial,
Connection::Tcp(_) => TransportType::Tcp,
Connection::Udp(_) => TransportType::Udp,
}
}
pub fn name(&self) -> &str {
match self {
Connection::Serial(t) => t.name(),
Connection::Tcp(t) => t.name(),
Connection::Udp(t) => t.name(),
}
}
pub fn is_connected(&self) -> bool {
match self {
Connection::Serial(t) => t.is_connected(),
Connection::Tcp(t) => t.is_connected(),
Connection::Udp(t) => t.is_connected(),
}
}
pub async fn connect(&mut self) -> Result<()> {
match self {
Connection::Serial(t) => t.connect().await,
Connection::Tcp(t) => t.connect().await,
Connection::Udp(t) => t.connect().await,
}
}
pub async fn disconnect(&mut self) -> Result<()> {
match self {
Connection::Serial(t) => t.disconnect().await,
Connection::Tcp(t) => t.disconnect().await,
Connection::Udp(t) => t.disconnect().await,
}
}
}
impl AsyncRead for Connection {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match self.get_mut() {
Connection::Serial(t) => std::pin::Pin::new(t).poll_read(cx, buf),
Connection::Tcp(t) => std::pin::Pin::new(t).poll_read(cx, buf),
Connection::Udp(t) => std::pin::Pin::new(t).poll_read(cx, buf),
}
}
}
impl AsyncWrite for Connection {
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
match self.get_mut() {
Connection::Serial(t) => std::pin::Pin::new(t).poll_write(cx, buf),
Connection::Tcp(t) => std::pin::Pin::new(t).poll_write(cx, buf),
Connection::Udp(t) => std::pin::Pin::new(t).poll_write(cx, buf),
}
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match self.get_mut() {
Connection::Serial(t) => std::pin::Pin::new(t).poll_flush(cx),
Connection::Tcp(t) => std::pin::Pin::new(t).poll_flush(cx),
Connection::Udp(t) => std::pin::Pin::new(t).poll_flush(cx),
}
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match self.get_mut() {
Connection::Serial(t) => std::pin::Pin::new(t).poll_shutdown(cx),
Connection::Tcp(t) => std::pin::Pin::new(t).poll_shutdown(cx),
Connection::Udp(t) => std::pin::Pin::new(t).poll_shutdown(cx),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transport_type_display() {
assert_eq!(TransportType::Serial.to_string(), "Serial");
assert_eq!(TransportType::Tcp.to_string(), "Tcp");
assert_eq!(TransportType::Udp.to_string(), "Udp");
}
#[test]
fn test_transport_type_debug() {
let _ = format!("{:?}", TransportType::Serial);
let _ = format!("{:?}", TransportType::Tcp);
let _ = format!("{:?}", TransportType::Udp);
}
#[test]
fn test_transport_type_eq() {
assert_eq!(TransportType::Serial, TransportType::Serial);
assert_eq!(TransportType::Tcp, TransportType::Tcp);
assert_eq!(TransportType::Udp, TransportType::Udp);
assert_ne!(TransportType::Serial, TransportType::Tcp);
assert_ne!(TransportType::Serial, TransportType::Udp);
assert_ne!(TransportType::Tcp, TransportType::Udp);
}
#[test]
fn test_transport_type_copy_clone() {
let original = TransportType::Serial;
let cloned = original;
assert_eq!(original, cloned);
let original = TransportType::Tcp;
let cloned = original.clone();
assert_eq!(original, cloned);
let original = TransportType::Udp;
let cloned = original.clone();
assert_eq!(original, cloned);
}
#[test]
fn test_transport_type_serialize() {
assert_eq!(
serde_json::to_string(&TransportType::Serial).unwrap(),
"\"Serial\""
);
assert_eq!(
serde_json::to_string(&TransportType::Tcp).unwrap(),
"\"Tcp\""
);
assert_eq!(
serde_json::to_string(&TransportType::Udp).unwrap(),
"\"Udp\""
);
}
#[test]
fn test_transport_type_deserialize() {
let t: TransportType = serde_json::from_str("\"Serial\"").unwrap();
assert_eq!(t, TransportType::Serial);
let t: TransportType = serde_json::from_str("\"Tcp\"").unwrap();
assert_eq!(t, TransportType::Tcp);
let t: TransportType = serde_json::from_str("\"Udp\"").unwrap();
assert_eq!(t, TransportType::Udp);
}
#[test]
fn test_transport_type_roundtrip() {
for original in [TransportType::Serial, TransportType::Tcp, TransportType::Udp] {
let json = serde_json::to_string(&original).unwrap();
let roundtripped: TransportType = serde_json::from_str(&json).unwrap();
assert_eq!(original, roundtripped);
}
}
#[test]
fn test_transport_config_debug() {
let cfg = TransportConfig::Serial {
port: "COM1".into(),
baud_rate: 115200,
};
let _ = format!("{:?}", cfg);
let cfg = TransportConfig::Tcp {
addr: "127.0.0.1:8080".into(),
};
let _ = format!("{:?}", cfg);
let cfg = TransportConfig::Udp {
bind_addr: "0.0.0.0:9000".into(),
remote_addr: Some("192.168.1.1:9001".into()),
};
let _ = format!("{:?}", cfg);
}
#[test]
fn test_transport_config_clone() {
let original = TransportConfig::Serial {
port: "COM1".into(),
baud_rate: 115200,
};
let cloned = original.clone();
assert_eq!(format!("{:?}", original), format!("{:?}", cloned));
let original = TransportConfig::Tcp {
addr: "127.0.0.1:8080".into(),
};
let cloned = original.clone();
assert_eq!(format!("{:?}", original), format!("{:?}", cloned));
let original = TransportConfig::Udp {
bind_addr: "0.0.0.0:9000".into(),
remote_addr: Some("192.168.1.1:9001".into()),
};
let cloned = original.clone();
assert_eq!(format!("{:?}", original), format!("{:?}", cloned));
}
#[test]
fn test_transport_config_serialize_serial() {
let cfg = TransportConfig::Serial {
port: "COM1".into(),
baud_rate: 115200,
};
let json = serde_json::to_string(&cfg).unwrap();
assert!(json.contains("COM1"));
assert!(json.contains("115200"));
}
#[test]
fn test_transport_config_deserialize_serial() {
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 } => {
assert_eq!(port, "COM1");
assert_eq!(baud_rate, 9600);
}
_ => panic!("expected Serial variant"),
}
}
#[test]
fn test_transport_config_serialize_tcp() {
let cfg = TransportConfig::Tcp {
addr: "127.0.0.1:8080".into(),
};
let json = serde_json::to_string(&cfg).unwrap();
assert!(json.contains("127.0.0.1:8080"));
}
#[test]
fn test_transport_config_deserialize_tcp() {
let json = r#"{"Tcp":{"addr":"127.0.0.1:8080"}}"#;
let cfg: TransportConfig = serde_json::from_str(json).unwrap();
match cfg {
TransportConfig::Tcp { addr } => {
assert_eq!(addr, "127.0.0.1:8080");
}
_ => panic!("expected Tcp variant"),
}
}
#[test]
fn test_transport_config_serialize_udp_with_remote() {
let cfg = TransportConfig::Udp {
bind_addr: "0.0.0.0:9000".into(),
remote_addr: Some("192.168.1.1:9001".into()),
};
let json = serde_json::to_string(&cfg).unwrap();
assert!(json.contains("0.0.0.0:9000"));
assert!(json.contains("192.168.1.1:9001"));
}
#[test]
fn test_transport_config_serialize_udp_without_remote() {
let cfg = TransportConfig::Udp {
bind_addr: "0.0.0.0:9000".into(),
remote_addr: None,
};
let json = serde_json::to_string(&cfg).unwrap();
assert!(json.contains("0.0.0.0:9000"));
assert!(json.contains("null"));
}
#[test]
fn test_transport_config_deserialize_udp() {
let json = r#"{"Udp":{"bind_addr":"0.0.0.0:9000","remote_addr":"192.168.1.1:9001"}}"#;
let cfg: TransportConfig = serde_json::from_str(json).unwrap();
match cfg {
TransportConfig::Udp {
bind_addr,
remote_addr,
} => {
assert_eq!(bind_addr, "0.0.0.0:9000");
assert_eq!(remote_addr, Some("192.168.1.1:9001".into()));
}
_ => panic!("expected Udp variant"),
}
let json = r#"{"Udp":{"bind_addr":"0.0.0.0:9000","remote_addr":null}}"#;
let cfg: TransportConfig = serde_json::from_str(json).unwrap();
match cfg {
TransportConfig::Udp {
bind_addr,
remote_addr,
} => {
assert_eq!(bind_addr, "0.0.0.0:9000");
assert_eq!(remote_addr, None);
}
_ => panic!("expected Udp variant"),
}
}
#[test]
fn test_transport_config_roundtrip() {
let configs = vec![
TransportConfig::Serial {
port: "COM3".into(),
baud_rate: 57600,
},
TransportConfig::Tcp {
addr: "10.0.0.1:9999".into(),
},
TransportConfig::Udp {
bind_addr: "0.0.0.0:7000".into(),
remote_addr: Some("10.0.0.2:7001".into()),
},
TransportConfig::Udp {
bind_addr: "127.0.0.1:8000".into(),
remote_addr: None,
},
];
for original in &configs {
let json = serde_json::to_string(original).unwrap();
let roundtripped: TransportConfig = serde_json::from_str(&json).unwrap();
assert_eq!(
serde_json::to_string(&roundtripped).unwrap(),
json,
"roundtrip serialized forms must match"
);
}
}
// ── Connection tests ──────────────────────────────────────────────
#[test]
fn test_connection_new_serial() {
let conn = Connection::new(TransportConfig::Serial {
port: "COM1".into(),
baud_rate: 115200,
});
assert_eq!(conn.transport_type(), TransportType::Serial);
assert_eq!(conn.name(), "COM1");
assert!(!conn.is_connected());
}
#[test]
fn test_connection_new_tcp() {
let conn = Connection::new(TransportConfig::Tcp {
addr: "192.168.1.1:8080".into(),
});
assert_eq!(conn.transport_type(), TransportType::Tcp);
assert_eq!(conn.name(), "192.168.1.1:8080");
assert!(!conn.is_connected());
}
#[test]
fn test_connection_new_udp_with_remote() {
let conn = Connection::new(TransportConfig::Udp {
bind_addr: "0.0.0.0:9000".into(),
remote_addr: Some("192.168.1.1:9001".into()),
});
assert_eq!(conn.transport_type(), TransportType::Udp);
assert_eq!(conn.name(), "0.0.0.0:9000");
assert!(!conn.is_connected());
}
#[test]
fn test_connection_new_udp_without_remote() {
let conn = Connection::new(TransportConfig::Udp {
bind_addr: "127.0.0.1:0".into(),
remote_addr: None,
});
assert_eq!(conn.transport_type(), TransportType::Udp);
assert_eq!(conn.name(), "127.0.0.1:0");
assert!(!conn.is_connected());
}
#[test]
fn test_connection_is_connected_default() {
let serial = Connection::new(TransportConfig::Serial {
port: "COM1".into(),
baud_rate: 9600,
});
let tcp = Connection::new(TransportConfig::Tcp {
addr: "127.0.0.1:8080".into(),
});
let udp = Connection::new(TransportConfig::Udp {
bind_addr: "0.0.0.0:0".into(),
remote_addr: None,
});
assert!(!serial.is_connected());
assert!(!tcp.is_connected());
assert!(!udp.is_connected());
}
#[test]
fn test_connection_debug() {
let serial = Connection::new(TransportConfig::Serial {
port: "COM1".into(),
baud_rate: 115200,
});
let tcp = Connection::new(TransportConfig::Tcp {
addr: "127.0.0.1:8080".into(),
});
let udp = Connection::new(TransportConfig::Udp {
bind_addr: "0.0.0.0:0".into(),
remote_addr: None,
});
let _ = format!("{:?}", serial);
let _ = format!("{:?}", tcp);
let _ = format!("{:?}", udp);
}
fn noop_waker() -> std::task::Waker {
use std::task::{RawWaker, RawWakerVTable};
unsafe fn clone(_: *const ()) -> RawWaker {
RawWaker::new(std::ptr::null(), &VTABLE)
}
unsafe fn wake(_: *const ()) {}
unsafe fn wake_by_ref(_: *const ()) {}
unsafe fn drop(_: *const ()) {}
static VTABLE: RawWakerVTable =
RawWakerVTable::new(clone, wake, wake_by_ref, drop);
unsafe { std::task::Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }
}
fn serial_conn() -> Connection {
Connection::new(TransportConfig::Serial {
port: "COM1".into(),
baud_rate: 115200,
})
}
fn tcp_conn() -> Connection {
Connection::new(TransportConfig::Tcp {
addr: "127.0.0.1:8080".into(),
})
}
fn udp_conn() -> Connection {
Connection::new(TransportConfig::Udp {
bind_addr: "127.0.0.1:0".into(),
remote_addr: None,
})
}
#[test]
fn test_connection_poll_read_not_connected() {
use tokio::io::ReadBuf;
use std::pin::Pin;
use std::task::{Context, Poll};
for mut conn in [serial_conn(), tcp_conn(), udp_conn()] {
let pinned = Pin::new(&mut conn);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
let mut buf_data = [0u8; 16];
let mut buf = ReadBuf::new(&mut buf_data);
match pinned.poll_read(&mut cx, &mut buf) {
Poll::Ready(Err(e)) => {
assert_eq!(e.kind(), std::io::ErrorKind::NotConnected);
}
other => panic!("expected Poll::Ready(Err(NotConnected)), got {:?}", other),
}
}
}
#[test]
fn test_connection_poll_write_not_connected() {
use std::pin::Pin;
use std::task::{Context, Poll};
for mut conn in [serial_conn(), tcp_conn(), udp_conn()] {
let pinned = Pin::new(&mut conn);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match pinned.poll_write(&mut cx, b"hello") {
Poll::Ready(Err(e)) => {
assert_eq!(e.kind(), std::io::ErrorKind::NotConnected);
}
other => panic!("expected Poll::Ready(Err(NotConnected)), got {:?}", other),
}
}
}
#[test]
fn test_connection_poll_flush_not_connected() {
use std::pin::Pin;
use std::task::{Context, Poll};
for mut conn in [serial_conn(), tcp_conn(), udp_conn()] {
let pinned = Pin::new(&mut conn);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match pinned.poll_flush(&mut cx) {
Poll::Ready(Ok(())) => {}
other => panic!("expected Poll::Ready(Ok(())), got {:?}", other),
}
}
}
#[test]
fn test_connection_poll_shutdown_not_connected() {
use std::pin::Pin;
use std::task::{Context, Poll};
for mut conn in [serial_conn(), tcp_conn(), udp_conn()] {
let pinned = Pin::new(&mut conn);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match pinned.poll_shutdown(&mut cx) {
Poll::Ready(Ok(())) => {}
other => panic!("expected Poll::Ready(Ok(())), got {:?}", other),
}
}
}
#[tokio::test]
async fn test_connection_tcp_connect_and_disconnect() {
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let mut conn = Connection::new(TransportConfig::Tcp { addr });
assert!(!conn.is_connected());
conn.connect().await.unwrap();
assert!(conn.is_connected());
conn.disconnect().await.unwrap();
assert!(!conn.is_connected());
}
#[tokio::test]
async fn test_connection_udp_connect_and_disconnect_without_remote() {
let mut conn = Connection::new(TransportConfig::Udp {
bind_addr: "127.0.0.1:0".into(),
remote_addr: None,
});
assert!(!conn.is_connected());
conn.connect().await.unwrap();
assert!(conn.is_connected());
conn.disconnect().await.unwrap();
assert!(!conn.is_connected());
}
#[tokio::test]
async fn test_connection_udp_connect_and_disconnect_with_remote() {
use tokio::net::UdpSocket;
let remote = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let remote_addr = remote.local_addr().unwrap().to_string();
let mut conn = Connection::new(TransportConfig::Udp {
bind_addr: "127.0.0.1:0".into(),
remote_addr: Some(remote_addr),
});
assert!(!conn.is_connected());
conn.connect().await.unwrap();
assert!(conn.is_connected());
conn.disconnect().await.unwrap();
assert!(!conn.is_connected());
}
#[tokio::test]
async fn test_connection_tcp_double_connect_is_noop() {
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
assert!(conn.is_connected());
let result = conn.connect().await;
assert!(result.is_ok(), "double connect should be a no-op");
assert!(conn.is_connected());
}
#[tokio::test]
async fn test_connection_tcp_disconnect_without_connect() {
let mut conn = Connection::new(TransportConfig::Tcp {
addr: "127.0.0.1:8080".into(),
});
let result = conn.disconnect().await;
assert!(result.is_ok(), "disconnect without connect should be safe");
assert!(!conn.is_connected());
}
#[tokio::test]
async fn test_connection_tcp_connect_to_unreachable() {
let mut conn = Connection::new(TransportConfig::Tcp {
addr: "127.0.0.1:1".into(),
});
let result = conn.connect().await;
assert!(result.is_err());
assert!(!conn.is_connected());
}
#[tokio::test]
async fn test_connection_udp_connect_bind_failure() {
let mut conn = Connection::new(TransportConfig::Udp {
bind_addr: "invalid_addr".into(),
remote_addr: None,
});
let result = conn.connect().await;
assert!(result.is_err());
assert!(!conn.is_connected());
}
#[tokio::test]
async fn test_connection_udp_double_connect_is_noop() {
let mut conn = Connection::new(TransportConfig::Udp {
bind_addr: "127.0.0.1:0".into(),
remote_addr: None,
});
conn.connect().await.unwrap();
assert!(conn.is_connected());
let result = conn.connect().await;
assert!(result.is_ok());
assert!(conn.is_connected());
}
#[tokio::test]
async fn test_connection_tcp_async_read_write() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
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();
AsyncWriteExt::write_all(&mut stream, b"hello").await.unwrap();
let mut buf = [0u8; 5];
AsyncReadExt::read_exact(&mut stream, &mut buf).await.unwrap();
assert_eq!(&buf, b"world");
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut buf = [0u8; 5];
AsyncReadExt::read_exact(&mut conn, &mut buf).await.unwrap();
assert_eq!(&buf, b"hello");
AsyncWriteExt::write_all(&mut conn, b"world").await.unwrap();
conn.disconnect().await.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn test_connection_udp_async_read_write_with_remote() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UdpSocket;
let remote = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let remote_addr = remote.local_addr().unwrap().to_string();
let mut conn = Connection::new(TransportConfig::Udp {
bind_addr: "127.0.0.1:0".into(),
remote_addr: Some(remote_addr),
});
conn.connect().await.unwrap();
AsyncWriteExt::write_all(&mut conn, b"hello").await.unwrap();
let mut buf = [0u8; 1024];
let (n, from) = remote.recv_from(&mut buf).await.unwrap();
assert_eq!(&buf[..n], b"hello");
remote.send_to(b"world", from).await.unwrap();
let mut read_buf = [0u8; 5];
AsyncReadExt::read_exact(&mut conn, &mut read_buf).await.unwrap();
assert_eq!(&read_buf, b"world");
}
}

View File

@@ -0,0 +1,286 @@
use async_trait::async_trait;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio_serial::{SerialPortBuilderExt, SerialStream};
use tracing::{debug, info, warn};
use super::{Transport, TransportType};
use crate::error::Result;
#[derive(Debug)]
pub struct SerialTransport {
port: Option<SerialStream>,
port_name: String,
baud_rate: u32,
}
impl SerialTransport {
pub fn new(port_name: String, baud_rate: u32) -> Self {
Self {
port: None,
port_name,
baud_rate,
}
}
pub fn list_ports() -> Vec<serialport::SerialPortInfo> {
match serialport::available_ports() {
Ok(ports) => ports,
Err(e) => {
warn!("Failed to enumerate serial ports: {}", e);
vec![]
}
}
}
pub fn port_name(&self) -> &str {
&self.port_name
}
pub fn baud_rate(&self) -> u32 {
self.baud_rate
}
}
impl AsyncRead for SerialTransport {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match &mut self.get_mut().port {
Some(port) => std::pin::Pin::new(port).poll_read(cx, buf),
None => std::task::Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"Serial port not open",
))),
}
}
}
impl AsyncWrite for SerialTransport {
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
match &mut self.get_mut().port {
Some(port) => std::pin::Pin::new(port).poll_write(cx, buf),
None => std::task::Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"Serial port not open",
))),
}
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match &mut self.get_mut().port {
Some(port) => std::pin::Pin::new(port).poll_flush(cx),
None => std::task::Poll::Ready(Ok(())),
}
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match &mut self.get_mut().port {
Some(port) => std::pin::Pin::new(port).poll_shutdown(cx),
None => std::task::Poll::Ready(Ok(())),
}
}
}
#[async_trait]
impl Transport for SerialTransport {
fn name(&self) -> &str {
&self.port_name
}
fn transport_type(&self) -> TransportType {
TransportType::Serial
}
fn is_connected(&self) -> bool {
self.port.is_some()
}
async fn connect(&mut self) -> Result<()> {
if self.is_connected() {
return Ok(())
}
info!("Opening serial port {} at {} baud", self.port_name, self.baud_rate);
let port = tokio_serial::new(&self.port_name, self.baud_rate)
.open_native_async()
.map_err(|e| crate::error::Error::ConnectionFailed(format!(
"Failed to open {}: {}",
self.port_name, e
)))?;
debug!("Serial port {} opened successfully", self.port_name);
self.port = Some(port);
Ok(())
}
async fn disconnect(&mut self) -> Result<()> {
if let Some(port) = self.port.take() {
debug!("Closing serial port {}", self.port_name);
drop(port);
debug!("Serial port {} closed", self.port_name);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::ErrorKind;
use std::pin::Pin;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
use tokio::io::ReadBuf;
fn noop_waker() -> Waker {
unsafe fn raw_clone(_: *const ()) -> RawWaker {
RawWaker::new(std::ptr::null(), &RAW_VTABLE)
}
unsafe fn raw_wake(_: *const ()) {}
unsafe fn raw_wake_by_ref(_: *const ()) {}
unsafe fn raw_drop(_: *const ()) {}
static RAW_VTABLE: RawWakerVTable = RawWakerVTable::new(
raw_clone,
raw_wake,
raw_wake_by_ref,
raw_drop,
);
unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &RAW_VTABLE)) }
}
// ── Constructor and accessor tests ──
#[test]
fn test_new() {
let transport = SerialTransport::new("COM1".into(), 115200);
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);
assert_eq!(transport.port_name(), "COM3");
assert_eq!(transport.baud_rate(), 9600);
}
#[test]
fn test_name() {
let transport = SerialTransport::new("COM1".into(), 115200);
assert_eq!(transport.name(), "COM1");
}
#[test]
fn test_transport_type() {
let transport = SerialTransport::new("COM1".into(), 115200);
assert_eq!(transport.transport_type(), TransportType::Serial);
}
#[test]
fn test_is_connected_false_by_default() {
let transport = SerialTransport::new("COM1".into(), 115200);
assert!(!transport.is_connected());
}
// ── list_ports tests ──
#[test]
fn test_list_ports_does_not_panic() {
let _ = SerialTransport::list_ports();
}
#[test]
fn test_list_ports_returns_vec() {
let ports: Vec<serialport::SerialPortInfo> = SerialTransport::list_ports();
let _ = ports;
}
// ── AsyncRead tests (not connected) ──
#[tokio::test]
async fn test_poll_read_not_connected() {
let mut transport = SerialTransport::new("COM1".into(), 115200);
let pinned = Pin::new(&mut transport);
let mut buf_data = [0u8; 16];
let mut buf = ReadBuf::new(&mut buf_data);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match pinned.poll_read(&mut cx, &mut buf) {
Poll::Ready(Err(e)) => assert_eq!(e.kind(), ErrorKind::NotConnected),
other => panic!("expected Poll::Ready(Err(NotConnected)), got {:?}", other),
}
}
// ── AsyncWrite tests (not connected) ──
#[tokio::test]
async fn test_poll_write_not_connected() {
let mut transport = SerialTransport::new("COM1".into(), 115200);
let pinned = Pin::new(&mut transport);
let data = b"hello";
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match pinned.poll_write(&mut cx, data) {
Poll::Ready(Err(e)) => assert_eq!(e.kind(), ErrorKind::NotConnected),
other => panic!("expected Poll::Ready(Err(NotConnected)), got {:?}", other),
}
}
#[tokio::test]
async fn test_poll_flush_not_connected() {
let mut transport = SerialTransport::new("COM1".into(), 115200);
let pinned = Pin::new(&mut transport);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match pinned.poll_flush(&mut cx) {
Poll::Ready(Ok(())) => {}
other => panic!("expected Poll::Ready(Ok(())), got {:?}", other),
}
}
#[tokio::test]
async fn test_poll_shutdown_not_connected() {
let mut transport = SerialTransport::new("COM1".into(), 115200);
let pinned = Pin::new(&mut transport);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match pinned.poll_shutdown(&mut cx) {
Poll::Ready(Ok(())) => {}
other => panic!("expected Poll::Ready(Ok(())), got {:?}", other),
}
}
// ── Transport trait method tests ──
#[test]
fn test_transport_name() {
let transport = SerialTransport::new("COM1".into(), 115200);
assert_eq!(transport.name(), "COM1");
}
#[test]
fn test_transport_transport_type() {
let transport = SerialTransport::new("COM1".into(), 115200);
assert_eq!(transport.transport_type(), TransportType::Serial);
}
#[test]
fn test_transport_is_connected() {
let transport = SerialTransport::new("COM1".into(), 115200);
assert!(!transport.is_connected());
}
}

View File

@@ -0,0 +1,325 @@
use async_trait::async_trait;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::net::TcpStream;
use tracing::{debug, info};
use super::{Transport, TransportType};
use crate::error::Result;
#[derive(Debug)]
pub struct TcpTransport {
stream: Option<TcpStream>,
addr: String
}
impl TcpTransport {
pub fn new(addr: String) -> Self {
Self {
stream: None,
addr
}
}
pub fn addr(&self) -> &str {
&self.addr
}
}
impl AsyncRead for TcpTransport {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match &mut self.get_mut().stream {
Some(stream) => std::pin::Pin::new(stream).poll_read(cx, buf),
None => std::task::Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"TCP not connected",
))),
}
}
}
impl AsyncWrite for TcpTransport {
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
match &mut self.get_mut().stream {
Some(stream) => std::pin::Pin::new(stream).poll_write(cx, buf),
None => std::task::Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"TCP not connected",
))),
}
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match &mut self.get_mut().stream {
Some(stream) => std::pin::Pin::new(stream).poll_flush(cx),
None => std::task::Poll::Ready(Ok(())),
}
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match &mut self.get_mut().stream {
Some(stream) => std::pin::Pin::new(stream).poll_shutdown(cx),
None => std::task::Poll::Ready(Ok(())),
}
}
}
#[async_trait]
impl Transport for TcpTransport {
fn name(&self) -> &str {
&self.addr
}
fn transport_type(&self) -> TransportType {
TransportType::Tcp
}
fn is_connected(&self) -> bool {
self.stream.is_some()
}
async fn connect(&mut self) -> Result<()> {
if self.is_connected() {
return Ok(());
}
info!("Connecting to TCP {}", self.addr);
let stream = TcpStream::connect(&self.addr).await.map_err(|e| {
crate::error::Error::ConnectionFailed(format!(
"Failed to connect to {}: {}",
self.addr, e
))
})?;
debug!("TCP connection to {} established", self.addr);
self.stream = Some(stream);
Ok(())
}
async fn disconnect(&mut self) -> Result<()> {
if let Some(stream) = self.stream.take() {
debug!("Closing TCP connection {}", self.addr);
drop(stream);
info!("TCP connection {} closed", self.addr);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use std::pin::Pin;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadBuf};
use tokio::net::TcpListener;
// ── Helper: noop waker for poll tests ─────────────────────────────
fn noop_waker() -> Waker {
unsafe fn noop_raw_clone(data: *const ()) -> RawWaker {
RawWaker::new(data, &NOOP_VTABLE)
}
unsafe fn noop_raw_wake(_data: *const ()) {}
unsafe fn noop_raw_wake_by_ref(_data: *const ()) {}
unsafe fn noop_raw_drop(_data: *const ()) {}
static NOOP_VTABLE: RawWakerVTable = RawWakerVTable::new(
noop_raw_clone,
noop_raw_wake,
noop_raw_wake_by_ref,
noop_raw_drop,
);
unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &NOOP_VTABLE)) }
}
// ── Sync accessor tests ───────────────────────────────────────────
#[test]
fn test_new() {
let transport = TcpTransport::new("127.0.0.1:8080".into());
assert_eq!(transport.addr(), "127.0.0.1:8080");
}
#[test]
fn test_name() {
let transport = TcpTransport::new("127.0.0.1:8080".into());
assert_eq!(transport.name(), transport.addr());
assert_eq!(transport.name(), "127.0.0.1:8080");
}
#[test]
fn test_transport_type() {
let transport = TcpTransport::new("127.0.0.1:8080".into());
assert_eq!(transport.transport_type(), TransportType::Tcp);
}
#[test]
fn test_is_connected_false_by_default() {
let transport = TcpTransport::new("127.0.0.1:8080".into());
assert!(!transport.is_connected());
}
// ── Poll error-state tests (no real connection) ───────────────────
#[test]
fn test_poll_read_not_connected() {
let mut transport = TcpTransport::new("127.0.0.1:8080".into());
let pinned = Pin::new(&mut transport);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
let mut buf_data = [0u8; 16];
let mut buf = ReadBuf::new(&mut buf_data);
match pinned.poll_read(&mut cx, &mut buf) {
Poll::Ready(Err(e)) => assert_eq!(e.kind(), std::io::ErrorKind::NotConnected),
other => panic!("expected Poll::Ready(Err(NotConnected)), got {:?}", other),
}
}
#[test]
fn test_poll_write_not_connected() {
let mut transport = TcpTransport::new("127.0.0.1:8080".into());
let pinned = Pin::new(&mut transport);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match pinned.poll_write(&mut cx, b"hello") {
Poll::Ready(Err(e)) => assert_eq!(e.kind(), std::io::ErrorKind::NotConnected),
other => panic!("expected Poll::Ready(Err(NotConnected)), got {:?}", other),
}
}
#[test]
fn test_poll_flush_not_connected() {
let mut transport = TcpTransport::new("127.0.0.1:8080".into());
let pinned = Pin::new(&mut transport);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match pinned.poll_flush(&mut cx) {
Poll::Ready(Ok(())) => {} // expected
other => panic!("expected Poll::Ready(Ok(())), got {:?}", other),
}
}
#[test]
fn test_poll_shutdown_not_connected() {
let mut transport = TcpTransport::new("127.0.0.1:8080".into());
let pinned = Pin::new(&mut transport);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match pinned.poll_shutdown(&mut cx) {
Poll::Ready(Ok(())) => {} // expected
other => panic!("expected Poll::Ready(Ok(())), got {:?}", other),
}
}
// ── Async connect/disconnect tests ────────────────────────────────
#[tokio::test]
async fn test_connect_and_disconnect() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let mut transport = TcpTransport::new(addr);
assert!(!transport.is_connected());
transport.connect().await.unwrap();
assert!(transport.is_connected());
transport.disconnect().await.unwrap();
assert!(!transport.is_connected());
}
#[tokio::test]
async fn test_double_connect_is_noop() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let mut transport = TcpTransport::new(addr);
transport.connect().await.unwrap();
assert!(transport.is_connected());
// Second connect should be a safe no-op
let result = transport.connect().await;
assert!(result.is_ok(), "double connect should return Ok");
assert!(transport.is_connected());
}
#[tokio::test]
async fn test_connect_to_unreachable_addr() {
let mut transport = TcpTransport::new("127.0.0.1:1".into());
let result = transport.connect().await;
assert!(result.is_err());
match result.unwrap_err() {
Error::ConnectionFailed(_) => {} // expected
other => panic!("expected ConnectionFailed, got {:?}", other),
}
assert!(!transport.is_connected());
}
#[tokio::test]
async fn test_disconnect_without_connect() {
let mut transport = TcpTransport::new("127.0.0.1:8080".into());
let result = transport.disconnect().await;
assert!(result.is_ok(), "disconnect without connect should be a safe no-op");
assert!(!transport.is_connected());
}
// ── Async read/write with real connection ─────────────────────────
#[tokio::test]
async fn test_async_read_write() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
// Server: accept one connection, write "hello", read 5 bytes back, verify "world"
let server_handle = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let written = tokio::io::AsyncWriteExt::write_all(&mut stream, b"hello").await;
assert!(written.is_ok(), "server write_all hello failed");
let mut buf = [0u8; 5];
let read = tokio::io::AsyncReadExt::read_exact(&mut stream, &mut buf).await;
assert!(read.is_ok(), "server read_exact failed");
assert_eq!(&buf, b"world");
});
// Client: connect, read "hello", write "world", disconnect
let mut transport = TcpTransport::new(addr);
transport.connect().await.unwrap();
let mut read_buf = [0u8; 5];
AsyncReadExt::read_exact(&mut transport, &mut read_buf)
.await
.unwrap();
assert_eq!(&read_buf, b"hello");
AsyncWriteExt::write_all(&mut transport, b"world")
.await
.unwrap();
transport.disconnect().await.unwrap();
// Ensure server task completed without panic
server_handle.await.unwrap();
}
}

View File

@@ -0,0 +1,426 @@
use std::pin::Pin;
use std::task::{Context, Poll};
use async_trait::async_trait;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::net::UdpSocket;
use tracing::{debug, info};
use super::{Transport, TransportType};
use crate::error::Result;
#[derive(Debug)]
pub struct UdpTransport {
socket: Option<UdpSocket>,
bind_addr: String,
remote_addr: Option<String>,
read_buf: Vec<u8>,
read_pos: usize,
temp_recv_buf: Vec<u8>,
}
impl UdpTransport {
pub fn new(bind_addr: String, remote_addr: Option<String>) -> Self {
Self {
socket: None,
bind_addr,
remote_addr,
read_buf: Vec::new(),
read_pos: 0,
temp_recv_buf: vec![0u8; 65536], // 初始化一次,重复使用
}
}
pub fn bind_addr(&self) -> &str {
&self.bind_addr
}
}
impl AsyncRead for UdpTransport {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
let this = self.get_mut();
// 优先处理遗留的内部缓冲数据
if this.read_pos < this.read_buf.len() {
let remaining = &this.read_buf[this.read_pos..];
let to_copy = std::cmp::min(remaining.len(), buf.remaining());
buf.put_slice(&remaining[..to_copy]);
this.read_pos += to_copy;
// 如果缓冲区数据已被全部读完,清空它以便后续复用,避免无止境增长
if this.read_pos == this.read_buf.len() {
this.read_buf.clear();
this.read_pos = 0;
}
return Poll::Ready(Ok(()));
}
// 如果内部缓冲为空,尝试从 Socket 读取新的 UDP 包
let socket = match this.socket.as_ref() {
Some(s) => s,
None => {
return Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"UDP socket is not connected",
)));
}
};
// 使用复用的 temp_recv_buf包裹为 tokio 需要的 ReadBuf
let mut temp_buf = ReadBuf::new(&mut this.temp_recv_buf);
// 如果配置了 remote_addr说明 socket 已经 connect 过,可以用 poll_recv
// 否则只能用 poll_recv_from丢弃掉远端地址信息
let poll_result = if this.remote_addr.is_some() {
socket.poll_recv(cx, &mut temp_buf)
} else {
// poll_recv_from 会返回 (usize, SocketAddr),我们将其映射回统一的 () 类型
socket.poll_recv_from(cx, &mut temp_buf).map(|res| res.map(|_| ()))
};
match poll_result {
Poll::Ready(Ok(())) => {
let filled = temp_buf.filled();
let to_copy = std::cmp::min(filled.len(), buf.remaining());
buf.put_slice(&filled[..to_copy]);
// 核心逻辑:如果本次读取到的 UDP 包大于外面提供的 buf 的剩余空间
// 将没装下的剩余部分放进内部的 read_buf 留作下次 poll_read 使用
if to_copy < filled.len() {
this.read_buf.clear(); // 确保安全清空
this.read_buf.extend_from_slice(&filled[to_copy..]);
this.read_pos = 0;
}
Poll::Ready(Ok(()))
}
Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
Poll::Pending => Poll::Pending,
}
}
}
impl AsyncWrite for UdpTransport {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
let this = self.get_mut();
let socket = match this.socket.as_ref() {
Some(s) => s,
None => {
return Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"UDP socket is not connected",
)));
}
};
// AsyncWrite 是不带目标地址的流式写入接口。
// 因此必须绑定了远端地址 (remote_addr) 才能确切知道把 UDP 包发送给谁。
if this.remote_addr.is_some() {
socket.poll_send(cx, buf)
} else {
Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Remote address must be set to use AsyncWrite for UDP",
)))
}
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
// UDP 是无连接的数据报协议,没有缓冲区需要手动 flush
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
// UDP 没有 TCP 的四次挥手过程,直接 Ready
Poll::Ready(Ok(()))
}
}
#[async_trait]
impl Transport for UdpTransport {
fn name(&self) -> &str {
&self.bind_addr
}
fn transport_type(&self) -> TransportType {
TransportType::Udp
}
fn is_connected(&self) -> bool {
self.socket.is_some()
}
async fn connect(&mut self) -> Result<()> {
if self.is_connected() {
return Ok(());
}
info!("Binding UDP socket to {}", self.bind_addr);
let socket = UdpSocket::bind(&self.bind_addr).await.map_err(|e| {
crate::error::Error::ConnectionFailed(format!(
"Failed to bind UDP {}: {}",
self.bind_addr, e
))
})?;
if let Some(ref remote) = self.remote_addr {
socket.connect(remote).await.map_err(|e| {
crate::error::Error::ConnectionFailed(format!(
"Failed to connect UDP to {}: {}",
remote, e
))
})?;
debug!("UDP connected to {}", remote);
}
debug!("UDP socket bound to {}", self.bind_addr);
self.socket = Some(socket);
Ok(())
}
async fn disconnect(&mut self) -> Result<()> {
if let Some(socket) = self.socket.take() {
debug!("Closing UDP socket {}", self.bind_addr);
drop(socket);
info!("UDP socket {} closed", self.bind_addr);
}
self.read_buf.clear();
self.read_pos = 0;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use std::sync::LazyLock;
use std::task::{RawWaker, RawWakerVTable, Waker};
use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadBuf};
use tokio::net::UdpSocket;
static NOOP_VTABLE: RawWakerVTable = RawWakerVTable::new(
|_| RawWaker::new(std::ptr::null(), &NOOP_VTABLE),
|_| {},
|_| {},
|_| {},
);
fn noop_waker() -> Waker {
unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &NOOP_VTABLE)) }
}
static NOOP_WAKER: LazyLock<Waker> = LazyLock::new(noop_waker);
fn noop_context() -> Context<'static> {
Context::from_waker(&NOOP_WAKER)
}
#[test]
fn test_new_with_remote() {
let t = UdpTransport::new("127.0.0.1:9000".into(), Some("127.0.0.1:9001".into()));
assert_eq!(t.bind_addr(), "127.0.0.1:9000");
}
#[test]
fn test_new_without_remote() {
let t = UdpTransport::new("0.0.0.0:0".into(), None);
assert_eq!(t.bind_addr(), "0.0.0.0:0");
}
#[test]
fn test_name() {
let t = UdpTransport::new("192.168.1.1:8888".into(), None);
assert_eq!(t.name(), "192.168.1.1:8888");
}
#[test]
fn test_transport_type() {
let t = UdpTransport::new("127.0.0.1:0".into(), None);
assert_eq!(t.transport_type(), TransportType::Udp);
}
#[test]
fn test_is_connected_false_by_default() {
let t = UdpTransport::new("127.0.0.1:0".into(), None);
assert!(!t.is_connected());
}
#[test]
fn test_poll_read_not_connected() {
let mut t = UdpTransport::new("127.0.0.1:0".into(), None);
let mut buf_data = [0u8; 64];
let mut buf = ReadBuf::new(&mut buf_data);
let mut cx = noop_context();
let result = Pin::new(&mut t).poll_read(&mut cx, &mut buf);
match result {
Poll::Ready(Err(ref e)) => assert_eq!(e.kind(), std::io::ErrorKind::NotConnected),
other => panic!(
"expected Poll::Ready(Err(NotConnected)), got {other:?}"
),
}
}
#[test]
fn test_poll_write_not_connected() {
let mut t = UdpTransport::new("127.0.0.1:0".into(), None);
let mut cx = noop_context();
let data = b"test";
let result = Pin::new(&mut t).poll_write(&mut cx, data);
match result {
Poll::Ready(Err(ref e)) => assert_eq!(e.kind(), std::io::ErrorKind::NotConnected),
other => panic!(
"expected Poll::Ready(Err(NotConnected)), got {other:?}"
),
}
}
#[test]
fn test_poll_flush_not_connected() {
let mut t = UdpTransport::new("127.0.0.1:0".into(), None);
let mut cx = noop_context();
let result = Pin::new(&mut t).poll_flush(&mut cx);
assert!(matches!(result, Poll::Ready(Ok(()))));
}
#[test]
fn test_poll_shutdown_not_connected() {
let mut t = UdpTransport::new("127.0.0.1:0".into(), None);
let mut cx = noop_context();
let result = Pin::new(&mut t).poll_shutdown(&mut cx);
assert!(matches!(result, Poll::Ready(Ok(()))));
}
#[tokio::test]
async fn test_connect_and_disconnect_without_remote() {
let mut t = UdpTransport::new("127.0.0.1:0".into(), None);
assert!(!t.is_connected());
t.connect().await.expect("connect should succeed");
assert!(t.is_connected());
t.disconnect().await.expect("disconnect should succeed");
assert!(!t.is_connected());
}
#[tokio::test]
async fn test_connect_and_disconnect_with_remote() {
let remote = UdpSocket::bind("127.0.0.1:0").await.expect("remote bind");
let remote_addr = remote.local_addr().unwrap().to_string();
let mut t = UdpTransport::new("127.0.0.1:0".into(), Some(remote_addr));
t.connect().await.expect("connect with remote should succeed");
assert!(t.is_connected());
t.disconnect().await.expect("disconnect should succeed");
assert!(!t.is_connected());
}
#[tokio::test]
async fn test_double_connect_is_noop() {
let mut t = UdpTransport::new("127.0.0.1:0".into(), None);
t.connect().await.expect("first connect");
assert!(t.is_connected());
t.connect().await.expect("second connect (idempotent)");
assert!(t.is_connected());
}
#[tokio::test]
async fn test_connect_bind_failure() {
let mut t = UdpTransport::new("invalid_addr".into(), None);
let result = t.connect().await;
match result {
Err(Error::ConnectionFailed(_)) => {}
other => panic!("expected ConnectionFailed error, got {other:?}"),
}
}
#[tokio::test]
async fn test_async_read_write_with_remote() {
let remote = UdpSocket::bind("127.0.0.1:0").await.expect("remote bind");
let remote_addr = remote.local_addr().unwrap().to_string();
let mut t = UdpTransport::new("127.0.0.1:0".into(), Some(remote_addr));
t.connect().await.expect("connect");
AsyncWriteExt::write_all(&mut t, b"hello")
.await
.expect("write_all hello");
let mut buf = [0u8; 1024];
let (n, transport_addr) = remote.recv_from(&mut buf).await.expect("remote recv_from");
assert_eq!(&buf[..n], b"hello");
remote
.send_to(b"world", transport_addr)
.await
.expect("remote send_to");
let mut read_buf = [0u8; 5];
AsyncReadExt::read_exact(&mut t, &mut read_buf)
.await
.expect("read_exact world");
assert_eq!(&read_buf, b"world");
}
#[tokio::test]
async fn test_write_fails_without_remote() {
let mut t = UdpTransport::new("127.0.0.1:0".into(), None);
t.connect().await.expect("connect (binds but no remote)");
let result = AsyncWriteExt::write_all(&mut t, b"test").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
#[tokio::test]
async fn test_oversized_datagram_buffering() {
let remote = UdpSocket::bind("127.0.0.1:0").await.expect("remote bind");
let remote_addr = remote.local_addr().unwrap().to_string();
let mut t = UdpTransport::new("127.0.0.1:0".into(), Some(remote_addr));
t.connect().await.expect("connect");
AsyncWriteExt::write_all(&mut t, b"x")
.await
.expect("write ping");
let mut ping_buf = [0u8; 1];
let (_, transport_addr) = remote
.recv_from(&mut ping_buf)
.await
.expect("remote recv ping");
let large_data: Vec<u8> = vec![b'A'; 100];
remote
.send_to(&large_data, transport_addr)
.await
.expect("send large datagram");
let mut small_buf = [0u8; 10];
AsyncReadExt::read_exact(&mut t, &mut small_buf)
.await
.expect("read 10 bytes");
assert_eq!(&small_buf, b"AAAAAAAAAA");
let mut rest_buf = [0u8; 90];
AsyncReadExt::read_exact(&mut t, &mut rest_buf)
.await
.expect("read 90 bytes");
assert_eq!(&rest_buf[..], &vec![b'A'; 90][..]);
}
}

View File

@@ -0,0 +1,775 @@
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, UdpSocket};
use tokio::time::timeout;
use xserial_core::frame::{
cobs::CobsFramer,
fixed::FixedLengthFramer,
length::{LengthConfig, LengthPrefixedFramer},
line::{LineConfig, LineFramer},
Endian, Framer,
};
use xserial_core::protocol::{
hex::{HexConfig, HexDecoder},
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
text::{TextDecoder, TextEncoding},
DecodedData, ProtocolDecoder,
};
use xserial_core::transport::{Connection, TransportConfig, TransportType};
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
async fn read_to_framer(
conn: &mut Connection,
framer: &mut dyn Framer,
) -> Vec<Vec<u8>> {
let mut buf = [0u8; 4096];
let mut all_frames = Vec::new();
loop {
match timeout(TEST_TIMEOUT, AsyncReadExt::read(conn, &mut buf)).await {
Ok(Ok(0)) => break,
Ok(Ok(n)) => {
all_frames.extend(framer.feed(&buf[..n]));
}
Ok(Err(_)) => break,
Err(_) => break,
}
}
if let Some(rest) = framer.flush() {
all_frames.push(rest);
}
all_frames
}
// ══════════════════════════════════════════════════════════════════════
// TCP + LineFramer + TextDecoder
// ══════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn tcp_line_text_utf8() {
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();
stream.write_all(b"hello\nworld\n").await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = LineFramer::new(LineConfig::default());
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
let decoder = TextDecoder::new(TextEncoding::Utf8);
let results: Vec<String> = frames
.iter()
.filter_map(|f| decoder.decode(f))
.filter_map(|d| match d {
DecodedData::Text(s) => Some(s),
_ => None,
})
.collect();
assert_eq!(results, vec!["hello", "world"]);
server.await.unwrap();
}
#[tokio::test]
async fn tcp_line_text_crlf_stripping() {
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();
stream.write_all(b"line1\r\nline2\r\n").await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = LineFramer::new(LineConfig::default());
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
let decoder = TextDecoder::new(TextEncoding::Utf8);
let results: Vec<String> = frames
.iter()
.filter_map(|f| decoder.decode(f))
.filter_map(|d| match d {
DecodedData::Text(s) => Some(s),
_ => None,
})
.collect();
assert_eq!(results, vec!["line1", "line2"]);
server.await.unwrap();
}
#[tokio::test]
async fn tcp_line_text_chinese_utf8() {
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();
stream.write_all("你好\n世界\n".as_bytes()).await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = LineFramer::new(LineConfig::default());
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
let decoder = TextDecoder::new(TextEncoding::Utf8);
let results: Vec<String> = frames
.iter()
.filter_map(|f| decoder.decode(f))
.filter_map(|d| match d {
DecodedData::Text(s) => Some(s),
_ => None,
})
.collect();
assert_eq!(results, vec!["你好", "世界"]);
server.await.unwrap();
}
// ══════════════════════════════════════════════════════════════════════
// TCP + LengthPrefixedFramer + HexDecoder
// ══════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn tcp_length_hex() {
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();
// Frame 1: 3 bytes "foo"
stream.write_all(&3u16.to_be_bytes()).await.unwrap();
stream.write_all(b"foo").await.unwrap();
// Frame 2: 3 bytes "bar"
stream.write_all(&3u16.to_be_bytes()).await.unwrap();
stream.write_all(b"bar").await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = LengthPrefixedFramer::new(LengthConfig::default());
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
let decoder = HexDecoder::new(HexConfig::default());
let results: Vec<String> = frames
.iter()
.filter_map(|f| decoder.decode(f))
.filter_map(|d| match d {
DecodedData::Hex(s) => Some(s),
_ => None,
})
.collect();
assert_eq!(results, vec!["66 6f 6f", "62 61 72"]); // "foo", "bar" in hex
server.await.unwrap();
}
#[tokio::test]
async fn tcp_length_hex_little_endian() {
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();
stream.write_all(&3u16.to_le_bytes()).await.unwrap();
stream.write_all(b"xyz").await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = LengthPrefixedFramer::new(LengthConfig {
endian: Endian::Little,
..LengthConfig::default()
});
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
assert_eq!(frames.len(), 1);
let decoder = HexDecoder::new(HexConfig::default());
let result = decoder.decode(&frames[0]).unwrap();
assert!(matches!(result, DecodedData::Hex(ref s) if s == "78 79 7a"));
server.await.unwrap();
}
// ══════════════════════════════════════════════════════════════════════
// TCP + FixedLengthFramer + PlotDecoder
// ══════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn tcp_fixed_plot_f32() {
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();
// 3 samples × 4 bytes each = 12 bytes per frame
let samples: [f32; 3] = [1.0, -2.5, 3.14];
let mut data = Vec::new();
for s in &samples {
data.extend_from_slice(&s.to_le_bytes());
}
stream.write_all(&data).await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = FixedLengthFramer::new(12); // 3 × f32
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
let decoder = PlotDecoder::new(PlotConfig {
sample_type: SampleType::F32,
endian: Endian::Little,
channels: 1,
format: PlotFormat::Interleaved,
});
assert_eq!(frames.len(), 1);
let result = decoder.decode(&frames[0]).unwrap();
match result {
DecodedData::Plot(frame) => {
assert_eq!(frame.channels.len(), 1);
assert_eq!(frame.channels[0].len(), 3);
assert!((frame.channels[0][0] - 1.0).abs() < 1e-5);
assert!((frame.channels[0][1] - (-2.5)).abs() < 1e-5);
assert!((frame.channels[0][2] - 3.14).abs() < 1e-5);
}
other => panic!("expected Plot, got {:?}", other),
}
server.await.unwrap();
}
#[tokio::test]
async fn tcp_fixed_plot_two_channel_u16() {
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();
// 2 channels × 2 samples each × 2 bytes = 8 bytes
let samples: [u16; 4] = [100, 200, 300, 400];
let mut data = Vec::new();
for s in &samples {
data.extend_from_slice(&s.to_le_bytes());
}
stream.write_all(&data).await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = FixedLengthFramer::new(8);
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
let decoder = PlotDecoder::new(PlotConfig {
sample_type: SampleType::U16,
endian: Endian::Little,
channels: 2,
format: PlotFormat::Interleaved,
});
assert_eq!(frames.len(), 1);
let result = decoder.decode(&frames[0]).unwrap();
match result {
DecodedData::Plot(frame) => {
assert_eq!(frame.channels.len(), 2);
assert_eq!(frame.channels[0], vec![100.0, 300.0]);
assert_eq!(frame.channels[1], vec![200.0, 400.0]);
}
other => panic!("expected Plot, got {:?}", other),
}
server.await.unwrap();
}
// ══════════════════════════════════════════════════════════════════════
// UDP + CobsFramer + TextDecoder
// ══════════════════════════════════════════════════════════════════════
fn cobs_encode(data: &[u8]) -> Vec<u8> {
if data.is_empty() {
return vec![0x01, 0x00];
}
let mut out = Vec::new();
let mut block_start = 0;
for (i, &byte) in data.iter().enumerate() {
if byte == 0x00 || i - block_start == 254 {
let len = i - block_start + 1;
out.push(len as u8);
out.extend_from_slice(&data[block_start..i]);
block_start = i + if byte == 0x00 { 1 } else { 0 };
}
}
if block_start < data.len() {
let len = data.len() - block_start + 1;
out.push(len as u8);
out.extend_from_slice(&data[block_start..]);
}
out.push(0x00);
out
}
#[tokio::test]
async fn udp_cobs_text() {
let remote = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let remote_addr = remote.local_addr().unwrap().to_string();
let mut conn = Connection::new(TransportConfig::Udp {
bind_addr: "127.0.0.1:0".into(),
remote_addr: Some(remote_addr.clone()),
});
conn.connect().await.unwrap();
// Send a probe so the remote learns our actual port
AsyncWriteExt::write_all(&mut conn, b"x").await.unwrap();
let mut probe_buf = [0u8; 1];
let (_, conn_actual_addr) = remote.recv_from(&mut probe_buf).await.unwrap();
// Send COBS-encoded frames to the connection's actual port
let packet1 = cobs_encode(b"hello");
let packet2 = cobs_encode(b"world");
let mut combined = packet1;
combined.extend_from_slice(&packet2);
remote.send_to(&combined, conn_actual_addr).await.unwrap();
let mut framer = CobsFramer::default();
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
let decoder = TextDecoder::new(TextEncoding::Utf8);
let results: Vec<String> = frames
.iter()
.filter_map(|f| decoder.decode(f))
.filter_map(|d| match d {
DecodedData::Text(s) => Some(s),
_ => None,
})
.collect();
assert_eq!(results, vec!["hello", "world"]);
}
// ══════════════════════════════════════════════════════════════════════
// Connection lifecycle: connect → use → disconnect → reconnect → use
// ══════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn connection_reconnect_tcp() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
// First connection
let server1 = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
stream.write_all(b"first\n").await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp {
addr: addr.clone(),
});
conn.connect().await.unwrap();
let mut framer = LineFramer::new(LineConfig::default());
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
server1.await.unwrap();
let decoder = TextDecoder::new(TextEncoding::Utf8);
let results: Vec<String> = frames
.iter()
.filter_map(|f| decoder.decode(f))
.filter_map(|d| match d {
DecodedData::Text(s) => Some(s),
_ => None,
})
.collect();
assert_eq!(results, vec!["first"]);
// Framer state should not carry over (it was fully consumed)
framer.reset();
// Second connection
let listener2 = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr2 = listener2.local_addr().unwrap().to_string();
let server2 = tokio::spawn(async move {
let (mut stream, _) = listener2.accept().await.unwrap();
stream.write_all(b"second\n").await.unwrap();
});
let mut conn2 = Connection::new(TransportConfig::Tcp { addr: addr2 });
conn2.connect().await.unwrap();
let frames = read_to_framer(&mut conn2, &mut framer).await;
conn2.disconnect().await.unwrap();
server2.await.unwrap();
let results: Vec<String> = frames
.iter()
.filter_map(|f| decoder.decode(f))
.filter_map(|d| match d {
DecodedData::Text(s) => Some(s),
_ => None,
})
.collect();
assert_eq!(results, vec!["second"]);
}
// ══════════════════════════════════════════════════════════════════════
// Framer reset mid-stream
// ══════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn framer_reset_mid_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();
stream.write_all(b"garbage__data\nvalid\n").await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = LineFramer::new(LineConfig::default());
// Read exactly "garbage_" (8 bytes) — no newline yet
let mut buf = [0u8; 8];
let n = timeout(TEST_TIMEOUT, AsyncReadExt::read(&mut conn, &mut buf))
.await
.unwrap()
.unwrap();
let frames = framer.feed(&buf[..n]);
assert!(frames.is_empty());
assert!(framer.pending_len() > 0);
// Reset — discard the incomplete "garbage_"
framer.reset();
assert_eq!(framer.pending_len(), 0);
// Read remaining data
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
server.await.unwrap();
let decoder = TextDecoder::new(TextEncoding::Utf8);
let results: Vec<String> = frames
.iter()
.filter_map(|f| decoder.decode(f))
.filter_map(|d| match d {
DecodedData::Text(s) => Some(s),
_ => None,
})
.collect();
assert_eq!(results, vec!["_data", "valid"]);
}
// ══════════════════════════════════════════════════════════════════════
// Multi-frame burst + edge cases
// ══════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn tcp_many_frames_burst() {
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();
// 50 lines in one write
let mut data = String::new();
for i in 0..50 {
data.push_str(&format!("line_{}\n", i));
}
stream.write_all(data.as_bytes()).await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = LineFramer::new(LineConfig::default());
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
server.await.unwrap();
assert_eq!(frames.len(), 50);
let decoder = TextDecoder::new(TextEncoding::Utf8);
let results: Vec<String> = frames
.iter()
.filter_map(|f| decoder.decode(f))
.filter_map(|d| match d {
DecodedData::Text(s) => Some(s),
_ => None,
})
.collect();
assert_eq!(results.len(), 50);
for (i, line) in results.iter().enumerate() {
assert_eq!(line, &format!("line_{}", i));
}
}
#[tokio::test]
async fn tcp_trailing_data_flushed_on_disconnect() {
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();
stream.write_all(b"no_newline_at_end").await.unwrap();
// Close without sending \n
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = LineFramer::new(LineConfig::default());
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
server.await.unwrap();
// flush() in read_to_framer should capture the trailing data
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"no_newline_at_end");
}
#[tokio::test]
async fn empty_data_produces_no_frames() {
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 (stream, _) = listener.accept().await.unwrap();
drop(stream);
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = LineFramer::new(LineConfig::default());
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
server.await.unwrap();
assert!(frames.is_empty());
}
#[tokio::test]
async fn protocol_switching_on_same_connection() {
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();
// Send text line then length-prefixed binary
stream.write_all(b"text_mode\n").await.unwrap();
stream.write_all(&3u16.to_be_bytes()).await.unwrap();
stream.write_all(b"bin").await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
// Phase 1: text protocol
let mut line_framer = LineFramer::new(LineConfig::default());
let buf = read_chunk(&mut conn).await;
let frames = line_framer.feed(&buf);
let text_decoder = TextDecoder::new(TextEncoding::Utf8);
let results: Vec<_> = frames
.iter()
.filter_map(|f| text_decoder.decode(f))
.collect();
assert_eq!(results.len(), 1);
assert!(matches!(&results[0], DecodedData::Text(s) if s == "text_mode"));
// Phase 2: feed leftovers + remaining data to length-prefixed framer
let mut len_framer = LengthPrefixedFramer::new(LengthConfig::default());
let mut frames = if let Some(rest) = line_framer.flush() {
len_framer.feed(&rest)
} else {
Vec::new()
};
let rest_buf = read_remaining(&mut conn).await;
frames.extend(len_framer.feed(&rest_buf));
let hex_decoder = HexDecoder::new(HexConfig::default());
let results: Vec<_> = frames
.iter()
.filter_map(|f| hex_decoder.decode(f))
.collect();
assert_eq!(results.len(), 1);
assert!(matches!(&results[0], DecodedData::Hex(s) if s == "62 69 6e"));
server.await.unwrap();
}
// ══════════════════════════════════════════════════════════════════════
// TransportType dispatch via Connection enum
// ══════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn connection_transport_type_dispatch() {
let serial = Connection::new(TransportConfig::Serial {
port: "COM1".into(),
baud_rate: 115200,
});
let tcp = Connection::new(TransportConfig::Tcp {
addr: "127.0.0.1:8080".into(),
});
let udp = Connection::new(TransportConfig::Udp {
bind_addr: "0.0.0.0:0".into(),
remote_addr: None,
});
assert_eq!(serial.transport_type(), TransportType::Serial);
assert_eq!(tcp.transport_type(), TransportType::Tcp);
assert_eq!(udp.transport_type(), TransportType::Udp);
}
// ══════════════════════════════════════════════════════════════════════
// Multiple framers, one connection
// ══════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn multiple_framers_one_connection() {
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();
stream.write_all(b"a\nbb\nccc\n").await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut buf = [0u8; 256];
let n = AsyncReadExt::read(&mut conn, &mut buf).await.unwrap();
conn.disconnect().await.unwrap();
server.await.unwrap();
let data = &buf[..n];
// Framer 1: LineFramer
let mut line = LineFramer::new(LineConfig::default());
let line_frames = line.feed(data);
assert_eq!(line_frames.len(), 3);
// Framer 2: FixedLengthFramer (same bytes, different interpretation)
let mut fixed = FixedLengthFramer::new(3);
let fixed_frames = fixed.feed(data);
assert_eq!(fixed_frames.len(), 3);
assert_eq!(fixed_frames[0], b"a\nb");
assert_eq!(fixed_frames[1], b"b\nc");
assert_eq!(fixed_frames[2], b"cc\n");
}
// ══════════════════════════════════════════════════════════════════════
// Robustness: partial writes, slow consumer
// ══════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn tcp_partial_writes_line_framing() {
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();
// Simulate fragmented writes
stream.write_all(b"hel").await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
stream.write_all(b"lo\nwor").await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
stream.write_all(b"ld\n").await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = LineFramer::new(LineConfig::default());
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
server.await.unwrap();
let decoder = TextDecoder::new(TextEncoding::Utf8);
let results: Vec<String> = frames
.iter()
.filter_map(|f| decoder.decode(f))
.filter_map(|d| match d {
DecodedData::Text(s) => Some(s),
_ => None,
})
.collect();
assert_eq!(results, vec!["hello", "world"]);
}
#[tokio::test]
async fn decode_summary_on_full_pipeline() {
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();
stream.write_all(b"hello\n").await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = LineFramer::new(LineConfig::default());
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
server.await.unwrap();
let decoder = TextDecoder::new(TextEncoding::Utf8);
let decoded = decoder.decode(&frames[0]).unwrap();
assert_eq!(decoded.summary(), "hello");
}
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
async fn read_chunk(conn: &mut Connection) -> Vec<u8> {
let mut buf = [0u8; 4096];
match timeout(TEST_TIMEOUT, AsyncReadExt::read(conn, &mut buf)).await {
Ok(Ok(n)) => buf[..n].to_vec(),
_ => Vec::new(),
}
}
async fn read_remaining(conn: &mut Connection) -> Vec<u8> {
let mut all = Vec::new();
let mut buf = [0u8; 4096];
loop {
match timeout(TEST_TIMEOUT, AsyncReadExt::read(conn, &mut buf)).await {
Ok(Ok(0)) => break,
Ok(Ok(n)) => all.extend_from_slice(&buf[..n]),
_ => break,
}
}
all
}

View File

@@ -0,0 +1,18 @@
[package]
name = "xserial-gui"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "xserial-gui"
path = "src/main.rs"
[dependencies]
xserial-core = { path = "../xserial-core" }
xserial-client = { path = "../xserial-client" }
tokio = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true }
egui_plot = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

View File

@@ -0,0 +1,3 @@
fn main() {
println!("xserial-gui");
}

View File

@@ -0,0 +1,21 @@
[package]
name = "xserial-tui"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "xserial-tui"
path = "src/main.rs"
[dependencies]
xserial-core = { path = "../xserial-core" }
xserial-client = { path = "../xserial-client" }
tokio = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tracing-appender = { workspace = true }
clap = { workspace = true }
hex = { workspace = true }
image = { workspace = true }

View File

@@ -0,0 +1,3 @@
fn main() {
}

112
tools/gen_plot.py Executable file
View File

@@ -0,0 +1,112 @@
#!/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()