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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
5917
Cargo.lock
generated
Normal file
5917
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
54
Cargo.toml
Normal file
54
Cargo.toml
Normal 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"] }
|
||||||
9
crates/xserial-client/Cargo.toml
Normal file
9
crates/xserial-client/Cargo.toml
Normal 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 }
|
||||||
0
crates/xserial-client/src/lib.rs
Normal file
0
crates/xserial-client/src/lib.rs
Normal file
17
crates/xserial-core/Cargo.toml
Normal file
17
crates/xserial-core/Cargo.toml
Normal 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"
|
||||||
27
crates/xserial-core/src/error.rs
Normal file
27
crates/xserial-core/src/error.rs
Normal 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>;
|
||||||
267
crates/xserial-core/src/frame/cobs.rs
Normal file
267
crates/xserial-core/src/frame/cobs.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
crates/xserial-core/src/frame/fixed.rs
Normal file
151
crates/xserial-core/src/frame/fixed.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
370
crates/xserial-core/src/frame/length.rs
Normal file
370
crates/xserial-core/src/frame/length.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
218
crates/xserial-core/src/frame/line.rs
Normal file
218
crates/xserial-core/src/frame/line.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
35
crates/xserial-core/src/frame/mod.rs
Normal file
35
crates/xserial-core/src/frame/mod.rs
Normal 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;
|
||||||
|
}
|
||||||
7
crates/xserial-core/src/lib.rs
Normal file
7
crates/xserial-core/src/lib.rs
Normal 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};
|
||||||
430
crates/xserial-core/src/pipeline.rs
Normal file
430
crates/xserial-core/src/pipeline.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
229
crates/xserial-core/src/protocol/hex.rs
Normal file
229
crates/xserial-core/src/protocol/hex.rs
Normal 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, "-");
|
||||||
|
}
|
||||||
|
}
|
||||||
41
crates/xserial-core/src/protocol/mod.rs
Normal file
41
crates/xserial-core/src/protocol/mod.rs
Normal 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>;
|
||||||
|
}
|
||||||
508
crates/xserial-core/src/protocol/plot.rs
Normal file
508
crates/xserial-core/src/protocol/plot.rs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
145
crates/xserial-core/src/protocol/text.rs
Normal file
145
crates/xserial-core/src/protocol/text.rs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
767
crates/xserial-core/src/transport/mod.rs
Normal file
767
crates/xserial-core/src/transport/mod.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
286
crates/xserial-core/src/transport/serial.rs
Normal file
286
crates/xserial-core/src/transport/serial.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
325
crates/xserial-core/src/transport/tcp.rs
Normal file
325
crates/xserial-core/src/transport/tcp.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
426
crates/xserial-core/src/transport/udp.rs
Normal file
426
crates/xserial-core/src/transport/udp.rs
Normal 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][..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
775
crates/xserial-core/tests/pipeline.rs
Normal file
775
crates/xserial-core/tests/pipeline.rs
Normal 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
|
||||||
|
}
|
||||||
18
crates/xserial-gui/Cargo.toml
Normal file
18
crates/xserial-gui/Cargo.toml
Normal 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 }
|
||||||
3
crates/xserial-gui/src/main.rs
Normal file
3
crates/xserial-gui/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("xserial-gui");
|
||||||
|
}
|
||||||
21
crates/xserial-tui/Cargo.toml
Normal file
21
crates/xserial-tui/Cargo.toml
Normal 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 }
|
||||||
3
crates/xserial-tui/src/main.rs
Normal file
3
crates/xserial-tui/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
|
||||||
|
}
|
||||||
112
tools/gen_plot.py
Executable file
112
tools/gen_plot.py
Executable 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()
|
||||||
Reference in New Issue
Block a user