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