Rewrite README in Chinese and English, add drone examples

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-12 21:56:20 +08:00
parent a2c7c0fa71
commit 4a80fbbe81
67 changed files with 1129 additions and 569 deletions

View File

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

View File

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

View File

@@ -0,0 +1,315 @@
use super::Framer;
use tracing::{debug, warn};
/// 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() {
let buf_len = self.buf.len();
if let Some(decoded) = cobs_decode(&self.buf) {
debug!(
encoded_len = buf_len,
decoded_len = decoded.len(),
"COBS frame decoded"
);
frames.push(decoded);
} else {
warn!(len = buf_len, "corrupt COBS packet discarded");
}
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).
pub fn cobs_encode(payload: &[u8]) -> Vec<u8> {
if payload.is_empty() {
return vec![0x01];
}
let mut out = Vec::with_capacity(payload.len() + (payload.len() / 254) + 1);
let mut code_index = 0usize;
let mut code = 1u8;
out.push(0);
for &byte in payload {
if byte == 0 {
out[code_index] = code;
code_index = out.len();
out.push(0);
code = 1;
} else {
out.push(byte);
code = code.saturating_add(1);
if code == 0xFF {
out[code_index] = code;
code_index = out.len();
out.push(0);
code = 1;
}
}
}
out[code_index] = code;
out
}
pub fn cobs_decode(encoded: &[u8]) -> Option<Vec<u8>> {
if encoded.is_empty() {
return Some(Vec::new());
}
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::*;
#[test]
fn cobs_roundtrip_with_zeros() {
let payload = [0x11, 0x00, 0x22, 0x33, 0x00, 0x44];
let encoded = cobs_encode(&payload);
let decoded = cobs_decode(&encoded).unwrap();
assert_eq!(decoded, payload);
}
// ── cobs_decode unit tests ──────────────────────────────────────
#[test]
fn cobs_decode_no_zeros() {
let decoded = cobs_decode(&[0x06, 0x68, 0x65, 0x6C, 0x6C, 0x6F]).unwrap();
assert_eq!(decoded, b"hello");
}
#[test]
fn cobs_decode_single_zero() {
let decoded = cobs_decode(&[0x03, 0x11, 0x22, 0x02, 0x33]).unwrap();
assert_eq!(decoded, vec![0x11, 0x22, 0x00, 0x33]);
}
#[test]
fn cobs_decode_leading_zero() {
let decoded = cobs_decode(&[0x01, 0x01, 0x01, 0x01]).unwrap();
assert_eq!(decoded, vec![0x00, 0x00, 0x00]);
}
#[test]
fn cobs_decode_all_zeros() {
// Input: [0x00, 0x00, 0x00] → encoded: [0x01, 0x01, 0x01, 0x01]
let decoded = cobs_decode(&[0x01, 0x01, 0x01, 0x01]).unwrap();
assert_eq!(decoded, vec![0x00, 0x00, 0x00]);
}
#[test]
fn cobs_decode_empty() {
let decoded = cobs_decode(&[]).unwrap();
assert!(decoded.is_empty());
}
#[test]
fn cobs_decode_invalid_zero_code() {
assert!(cobs_decode(&[0x00, 0x01]).is_none());
}
#[test]
fn cobs_decode_truncated() {
// Claims 5 bytes follow but only 3 remain
assert!(cobs_decode(&[0x06, 0x01, 0x02, 0x03]).is_none());
}
#[test]
fn cobs_decode_full_254_block() {
// 254 non-zero bytes
let payload: Vec<u8> = (1u8..=254).collect();
let mut encoded = vec![0xFF];
encoded.extend_from_slice(&payload);
let decoded = cobs_decode(&encoded).unwrap();
assert_eq!(decoded, payload);
}
// ── CobsFramer integration tests ─────────────────────────────────
#[test]
fn cobs_framer_single_packet() {
let mut f = CobsFramer::default();
let frames = f.feed(&[0x06, b'h', b'e', b'l', b'l', b'o', 0x00]);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
#[test]
fn cobs_framer_two_packets() {
let mut f = CobsFramer::default();
let mut data = vec![0x04, b'f', b'o', b'o', 0x00];
data.extend_from_slice(&[0x04, b'b', b'a', b'r', 0x00]);
let frames = f.feed(&data);
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], b"foo");
assert_eq!(frames[1], b"bar");
}
#[test]
fn cobs_framer_split_across_chunks() {
let mut f = CobsFramer::default();
let frames = f.feed(&[0x06, b'h', b'e']);
assert!(frames.is_empty());
let frames = f.feed(&[b'l', b'l', b'o', 0x00]);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
#[test]
fn cobs_framer_consecutive_zeros() {
let mut f = CobsFramer::default();
let frames = f.feed(&[0x00, 0x00, 0x03, b'a', b'b', 0x00]);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"ab");
}
#[test]
fn cobs_framer_corrupt_packet_discarded() {
let mut f = CobsFramer::default();
let frames = f.feed(&[0x06, 0x01, 0x02, 0x00]);
assert!(frames.is_empty());
}
#[test]
fn cobs_framer_flush_partial() {
let mut f = CobsFramer::default();
f.feed(&[0x03, b'h', b'e']);
let flushed = f.flush().unwrap();
assert_eq!(flushed, b"he");
}
#[test]
fn cobs_framer_flush_empty() {
let mut f = CobsFramer::default();
assert_eq!(f.flush(), None);
}
#[test]
fn cobs_framer_reset() {
let mut f = CobsFramer::default();
f.feed(&[0x05, b'h', b'e']);
assert!(f.pending_len() > 0);
f.reset();
assert_eq!(f.pending_len(), 0);
}
#[test]
fn cobs_framer_max_frame() {
let mut f = CobsFramer::new(3);
f.feed(&[0x05, b'a', b'b', b'c', b'd', b'e']);
assert_eq!(f.pending_len(), 3);
}
#[test]
fn cobs_framer_empty_payload_packet() {
let mut f = CobsFramer::default();
let frames = f.feed(&[0x01, 0x00]);
assert_eq!(frames.len(), 1);
assert!(frames[0].is_empty());
}
#[test]
fn cobs_framer_pending_len() {
let mut f = CobsFramer::default();
f.feed(&[0x05, b'h', b'e']);
assert_eq!(f.pending_len(), 3);
}
}

View File

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

View File

@@ -0,0 +1,377 @@
use super::{Endian, Framer};
use tracing::{debug, warn};
/// 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 {
warn!(
claimed = payload_len,
max = self.config.max_payload,
"corrupt length frame, skipping"
);
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();
debug!(len = frame.len(), "length-prefixed frame extracted");
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(&[0xFFu8; 200]);
// Followed by valid frame
data.extend(&be2(3));
data.extend_from_slice(b"foo");
let frames = f.feed(&data);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"foo");
}
// ── flush / reset ────────────────────────────────────────────────
#[test]
fn length_flush_partial() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
f.feed(&be2(10));
f.feed(b"abc");
let flushed = f.flush();
assert_eq!(flushed, Some(b"abc".to_vec()));
}
#[test]
fn length_flush_empty() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
assert_eq!(f.flush(), None);
}
#[test]
fn length_reset_mid_frame() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
f.feed(&be2(50));
f.feed(b"partial");
assert!(f.pending_len() > 0);
f.reset();
assert_eq!(f.pending_len(), 0);
// Should work normally after reset
let mut data = be2(3);
data.extend_from_slice(b"foo");
let frames = f.feed(&data);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"foo");
}
#[test]
fn length_pending_len_reflects_buffer() {
let mut f = LengthPrefixedFramer::new(LengthConfig::default());
f.feed(&be2(5));
assert_eq!(f.pending_len(), 0);
f.feed(b"he");
assert_eq!(f.pending_len(), 2);
}
}

View File

@@ -0,0 +1,226 @@
use super::Framer;
use tracing::debug;
/// 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);
}
}
if !frames.is_empty() {
debug!(
count = frames.len(),
pending = self.buf.len(),
"line frames extracted"
);
}
frames
}
fn flush(&mut self) -> Option<Vec<u8>> {
if self.buf.is_empty() {
None
} else {
Some(std::mem::take(&mut self.buf))
}
}
fn reset(&mut self) {
self.buf.clear();
}
fn pending_len(&self) -> usize {
self.buf.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn line_single_complete() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"hello\n");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
#[test]
fn line_multiple_in_one_chunk() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"aaa\nbbb\nccc\n");
assert_eq!(frames.len(), 3);
assert_eq!(frames[0], b"aaa");
assert_eq!(frames[1], b"bbb");
assert_eq!(frames[2], b"ccc");
}
#[test]
fn line_split_across_chunks() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"hel");
assert!(frames.is_empty());
let frames = f.feed(b"lo\n");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello");
}
#[test]
fn line_crlf_stripping() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"hello\r\nworld\r\n");
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], b"hello");
assert_eq!(frames[1], b"world");
}
#[test]
fn line_crlf_no_strip() {
let cfg = LineConfig {
strip_cr: false,
..LineConfig::default()
};
let mut f = LineFramer::new(cfg);
let frames = f.feed(b"hello\r\n");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"hello\r");
}
#[test]
fn line_empty_lines() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"\n\n\n");
assert_eq!(frames.len(), 3);
assert!(frames[0].is_empty());
assert!(frames[1].is_empty());
assert!(frames[2].is_empty());
}
#[test]
fn line_no_newline_buffered() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"incomplete");
assert!(frames.is_empty());
assert_eq!(f.pending_len(), 10);
}
#[test]
fn line_flush_partial() {
let mut f = LineFramer::new(LineConfig::default());
f.feed(b"partial data");
let flushed = f.flush();
assert_eq!(flushed, Some(b"partial data".to_vec()));
assert_eq!(f.pending_len(), 0);
}
#[test]
fn line_flush_empty() {
let mut f = LineFramer::new(LineConfig::default());
assert_eq!(f.flush(), None);
}
#[test]
fn line_reset() {
let mut f = LineFramer::new(LineConfig::default());
f.feed(b"some data");
assert!(f.pending_len() > 0);
f.reset();
assert_eq!(f.pending_len(), 0);
}
#[test]
fn line_max_len_filtered() {
let cfg = LineConfig {
max_line_len: 5,
..LineConfig::default()
};
let mut f = LineFramer::new(cfg);
let frames = f.feed(b"short\nvery long line\nok\n");
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], b"short");
assert_eq!(frames[1], b"ok");
}
#[test]
fn line_feed_then_flush_chain() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"complete\npartial");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"complete");
let flushed = f.flush();
assert_eq!(flushed, Some(b"partial".to_vec()));
let frames = f.feed(b"new\n");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"new");
}
#[test]
fn line_binary_data_with_embedded_newlines() {
let mut f = LineFramer::new(LineConfig::default());
let data = [0x00, 0x01, b'\n', 0xFF, 0xFE, b'\n', 0x7F];
let frames = f.feed(&data);
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], &[0x00, 0x01]);
assert_eq!(frames[1], &[0xFF, 0xFE]);
}
#[test]
fn line_lf_only_with_strip_cr() {
let mut f = LineFramer::new(LineConfig::default());
let frames = f.feed(b"line1\nline2\n");
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], b"line1");
assert_eq!(frames[1], b"line2");
}
}

View File

@@ -0,0 +1,215 @@
use super::Framer;
use crate::frame::cobs::cobs_decode;
use crate::protocol::mixed::{
MIXED_FRAME_KIND_PLOT, MIXED_FRAME_KIND_TEXT, MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER,
};
#[derive(Debug, Clone)]
pub struct MixedTextPlotConfig {
pub strip_cr: bool,
pub max_line_len: usize,
pub max_plot_frame: usize,
}
impl Default for MixedTextPlotConfig {
fn default() -> Self {
Self {
strip_cr: true,
max_line_len: 1024 * 1024,
max_plot_frame: 1024 * 1024,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Text,
Escape,
Plot,
}
#[derive(Debug, Clone)]
pub struct MixedTextPlotFramer {
state: State,
text_buf: Vec<u8>,
plot_buf: Vec<u8>,
plot_overflow: bool,
config: MixedTextPlotConfig,
}
impl MixedTextPlotFramer {
pub fn new(config: MixedTextPlotConfig) -> Self {
Self {
state: State::Text,
text_buf: Vec::new(),
plot_buf: Vec::new(),
plot_overflow: false,
config,
}
}
fn push_text_byte(&mut self, byte: u8, frames: &mut Vec<Vec<u8>>) {
if byte == b'\n' {
let mut line = std::mem::take(&mut self.text_buf);
if self.config.strip_cr && line.last() == Some(&b'\r') {
line.pop();
}
if line.len() <= self.config.max_line_len {
frames.push(tag_text_frame(line));
}
return;
}
if self.text_buf.len() < self.config.max_line_len.saturating_add(1) {
self.text_buf.push(byte);
}
}
fn finish_plot_frame(&mut self, frames: &mut Vec<Vec<u8>>) {
if self.plot_overflow || self.plot_buf.is_empty() {
self.plot_buf.clear();
self.plot_overflow = false;
return;
}
if let Some(decoded) = cobs_decode(&self.plot_buf)
&& decoded.len() <= self.config.max_plot_frame
{
frames.push(tag_plot_frame(decoded));
}
self.plot_buf.clear();
self.plot_overflow = false;
}
}
impl Framer for MixedTextPlotFramer {
fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
let mut frames = Vec::new();
for &byte in data {
match self.state {
State::Text => {
if byte == MIXED_PLOT_ESCAPE {
self.state = State::Escape;
} else {
self.push_text_byte(byte, &mut frames);
}
}
State::Escape => {
self.state = State::Text;
match byte {
MIXED_PLOT_ESCAPE => self.push_text_byte(MIXED_PLOT_ESCAPE, &mut frames),
MIXED_PLOT_MARKER => {
self.plot_buf.clear();
self.plot_overflow = false;
self.state = State::Plot;
}
_ => {
self.push_text_byte(MIXED_PLOT_ESCAPE, &mut frames);
self.push_text_byte(byte, &mut frames);
}
}
}
State::Plot => {
if byte == 0x00 {
self.finish_plot_frame(&mut frames);
self.state = State::Text;
} else if !self.plot_overflow {
let max_encoded_len = self
.config
.max_plot_frame
.saturating_add(self.config.max_plot_frame / 254)
.saturating_add(8);
if self.plot_buf.len() < max_encoded_len {
self.plot_buf.push(byte);
} else {
self.plot_overflow = true;
}
}
}
}
}
frames
}
fn flush(&mut self) -> Option<Vec<u8>> {
if matches!(self.state, State::Escape) {
self.text_buf.push(MIXED_PLOT_ESCAPE);
}
self.state = State::Text;
self.plot_buf.clear();
self.plot_overflow = false;
if self.text_buf.is_empty() {
None
} else {
Some(tag_text_frame(std::mem::take(&mut self.text_buf)))
}
}
fn reset(&mut self) {
self.state = State::Text;
self.text_buf.clear();
self.plot_buf.clear();
self.plot_overflow = false;
}
fn pending_len(&self) -> usize {
self.text_buf.len() + self.plot_buf.len()
}
}
fn tag_text_frame(mut payload: Vec<u8>) -> Vec<u8> {
let mut frame = Vec::with_capacity(payload.len() + 1);
frame.push(MIXED_FRAME_KIND_TEXT);
frame.append(&mut payload);
frame
}
fn tag_plot_frame(mut payload: Vec<u8>) -> Vec<u8> {
let mut frame = Vec::with_capacity(payload.len() + 1);
frame.push(MIXED_FRAME_KIND_PLOT);
frame.append(&mut payload);
frame
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frame::cobs::cobs_encode;
#[test]
fn mixed_text_lines_pass_through() {
let mut framer = MixedTextPlotFramer::new(MixedTextPlotConfig::default());
let frames = framer.feed(b"hello\nworld\n");
assert_eq!(frames, vec![b"\0hello".to_vec(), b"\0world".to_vec()]);
}
#[test]
fn mixed_escaped_rs_stays_in_text() {
let mut framer = MixedTextPlotFramer::new(MixedTextPlotConfig::default());
let frames = framer.feed(&[b'o', b'k', MIXED_PLOT_ESCAPE, MIXED_PLOT_ESCAPE, b'\n']);
assert_eq!(frames, vec![vec![0x00, b'o', b'k', MIXED_PLOT_ESCAPE]]);
}
#[test]
fn mixed_extracts_plot_packet() {
let mut framer = MixedTextPlotFramer::new(MixedTextPlotConfig::default());
let payload = [b'X', b'P', 1, 0, 8, 0, 1, 1, 0, 4, 0, 0, 0, 0, 0x80, 0x3f];
let encoded = cobs_encode(&payload);
let mut stream = vec![MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER];
stream.extend_from_slice(&encoded);
stream.push(0x00);
let frames = framer.feed(&stream);
assert_eq!(frames, vec![[vec![0x01], payload.to_vec()].concat()]);
}
#[test]
fn mixed_ignores_truncated_plot_on_flush() {
let mut framer = MixedTextPlotFramer::new(MixedTextPlotConfig::default());
let _ = framer.feed(&[MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER, 0x03, b'a']);
assert_eq!(framer.flush(), None);
}
}

View File

@@ -0,0 +1,36 @@
pub mod cobs;
pub mod fixed;
pub mod length;
pub mod line;
pub mod mixed;
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: Send {
/// Feed a chunk of newly arrived bytes.
///
/// Any complete frames that can be extracted are returned. The
/// framer keeps incomplete trailing data in its internal buffer so
/// it can be completed on the next call.
fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>>;
/// Drain any remaining buffered data as a final frame.
///
/// This is typically called when the transport disconnects or the
/// user explicitly wants to flush a partial frame.
fn flush(&mut self) -> Option<Vec<u8>>;
/// Discard all buffered state and start fresh.
fn reset(&mut self);
/// Number of bytes currently buffered in the incomplete frame.
fn pending_len(&self) -> usize;
}

View File

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

View File

@@ -0,0 +1,438 @@
use crate::frame::Framer;
use crate::protocol::{DecodedData, ProtocolDecoder};
use tracing::debug;
/// 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 pipeview_core::pipeline::{MultiPipeline, Pipeline};
/// use pipeview_core::frame::line::{LineFramer, LineConfig};
/// use pipeview_core::frame::fixed::FixedLengthFramer;
/// use pipeview_core::protocol::text::{TextDecoder, TextEncoding};
/// use pipeview_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,
});
}
}
if !results.is_empty() {
debug!(
count = results.len(),
bytes = data.len(),
"pipeline produced results"
);
}
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::Endian;
use crate::protocol::hex::{HexConfig, HexDecoder};
use crate::protocol::plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType};
use crate::protocol::text::{TextDecoder, TextEncoding};
#[test]
fn multi_text_and_hex_same_stream() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"hex",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(HexDecoder::new(HexConfig::default())),
));
// Both pipelines use LineFramer → both see "hello\n" and "42\n"
let results = mp.feed(b"hello\n42\n");
assert_eq!(results.len(), 4);
assert_eq!(results[0].pipeline_name, "text");
assert!(matches!(&results[0].data, DecodedData::Text(s) if s == "hello"));
assert_eq!(results[1].pipeline_name, "text");
assert!(matches!(&results[1].data, DecodedData::Text(s) if s == "42"));
assert_eq!(results[2].pipeline_name, "hex");
assert!(matches!(&results[2].data, DecodedData::Hex(s) if s == "68 65 6c 6c 6f"));
assert_eq!(results[3].pipeline_name, "hex");
assert!(matches!(&results[3].data, DecodedData::Hex(s) if s == "34 32"));
}
#[test]
fn multi_text_success_hex_silent_on_binary_trash() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"hex",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(HexDecoder::new(HexConfig::default())),
));
let results = mp.feed(b"valid\n\xff\xfe\xfd\n");
// Text pipeline: "valid" ok, binary line fails UTF-8 → only 1 result
// Hex pipeline: both lines decode successfully → 2 results
assert_eq!(results.len(), 3);
assert_eq!(results[0].pipeline_name, "text");
assert!(matches!(&results[0].data, DecodedData::Text(s) if s == "valid"));
assert_eq!(results[1].pipeline_name, "hex");
assert!(matches!(&results[1].data, DecodedData::Hex(_)));
assert_eq!(results[2].pipeline_name, "hex");
assert!(matches!(&results[2].data, DecodedData::Hex(_)));
}
#[test]
fn multi_different_framers_per_pipeline() {
let mut mp = MultiPipeline::new();
// Text uses LineFramer, hex uses FixedLengthFramer(4)
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"hex",
Box::new(FixedLengthFramer::new(4)),
Box::new(HexDecoder::new(HexConfig::default())),
));
let results = mp.feed(b"abc\n1234");
// text: sees "abc\n" → produces "abc"
// hex: sees 8 bytes → produces 2 fixed frames: "abc\n" and "1234"
assert_eq!(results.len(), 3);
assert_eq!(results[0].pipeline_name, "text");
assert!(matches!(&results[0].data, DecodedData::Text(s) if s == "abc"));
let hex_results: Vec<_> = results
.iter()
.filter(|r| r.pipeline_name == "hex")
.collect();
assert_eq!(hex_results.len(), 2);
}
#[test]
fn multi_flush_drains_all() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"hex",
Box::new(FixedLengthFramer::new(4)),
Box::new(HexDecoder::new(HexConfig::default())),
));
// "partial" = 7 bytes. Line: no \n → buffers. Fixed(4): 1 frame "part", 3 left.
mp.feed(b"partial");
let flushed = mp.flush();
// Line flush → "partial" as text
// Fixed flush → "ial" as hex ("69 61 6c")
assert_eq!(flushed.len(), 2);
assert_eq!(flushed[0].pipeline_name, "text");
assert!(matches!(&flushed[0].data, DecodedData::Text(s) if s == "partial"));
assert_eq!(flushed[1].pipeline_name, "hex");
assert!(matches!(&flushed[1].data, DecodedData::Hex(s) if s == "69 61 6c"));
}
#[test]
fn multi_reset_clears_all_state() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"hex",
Box::new(FixedLengthFramer::new(4)),
Box::new(HexDecoder::new(HexConfig::default())),
));
mp.feed(b"unused data here that never completes");
assert_eq!(mp.pending_bytes().len(), 2);
mp.reset();
assert!(mp.pending_bytes().is_empty());
// After reset, pipelines work normally
let results = mp.feed(b"ok\nabcd");
assert!(!results.is_empty());
}
#[test]
fn multi_pending_bytes_reporting() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"t",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"h",
Box::new(FixedLengthFramer::new(100)),
Box::new(HexDecoder::new(HexConfig::default())),
));
mp.feed(b"hello world");
let pending: Vec<_> = mp.pending_bytes();
assert_eq!(pending.len(), 2);
// Both have the same 11 bytes buffered
assert_eq!(pending[0], ("t", 11));
assert_eq!(pending[1], ("h", 11));
}
#[test]
fn multi_empty_feed_no_results() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
let results = mp.feed(b"");
assert!(results.is_empty());
}
#[test]
fn multi_no_pipelines() {
let mut mp = MultiPipeline::new();
let results = mp.feed(b"data");
assert!(results.is_empty());
assert_eq!(mp.pipeline_count(), 0);
}
#[test]
fn multi_pipeline_count() {
let mut mp = MultiPipeline::new();
assert_eq!(mp.pipeline_count(), 0);
mp.add(Pipeline::new(
"a",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
assert_eq!(mp.pipeline_count(), 1);
}
#[test]
fn multi_text_and_plot_mixed_stream() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"text",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
mp.add(Pipeline::new(
"plot",
Box::new(FixedLengthFramer::new(8)), // 2 × f32 LE per frame
Box::new(PlotDecoder::new(PlotConfig {
sample_type: SampleType::F32,
endian: Endian::Little,
channels: 1,
format: PlotFormat::Interleaved,
})),
));
// 10 bytes of text + 8 bytes of f32 samples = 18 total.
// FixedLengthFramer(8) extracts 2 frames. The first frame is ASCII
// bytes which happen to decode as valid (but meaningless) f32 values
// — PlotDecoder can't reject them. This is by design: garbage-in,
// garbage-out; the UI layer decides what to display.
let mut data = b"status ok\n".to_vec();
data.extend_from_slice(&1.0f32.to_le_bytes());
data.extend_from_slice(&2.0f32.to_le_bytes());
let results = mp.feed(&data);
// text: 1 line, plot: 2 fixed frames (first = garbage f32, second = real)
assert_eq!(results.len(), 3);
assert_eq!(results[0].pipeline_name, "text");
assert!(matches!(&results[0].data, DecodedData::Text(s) if s == "status ok"));
}
#[test]
fn multi_length_prefixed_and_line_stacked() {
let mut mp = MultiPipeline::new();
mp.add(Pipeline::new(
"line",
Box::new(LineFramer::new(LineConfig::default())),
Box::new(TextDecoder::new(TextEncoding::Utf8)),
));
// Second pipeline uses a different framer on the same bytes.
// The length framer sees "he" (from "hello\n") as a bogus length header
// and silently produces no frames — that's expected.
mp.add(Pipeline::new(
"len",
Box::new(LengthPrefixedFramer::new(LengthConfig::default())),
Box::new(HexDecoder::new(HexConfig::default())),
));
let mut data = b"hello\n".to_vec();
data.extend_from_slice(&3u16.to_be_bytes());
data.extend_from_slice(b"abc");
let results = mp.feed(&data);
// Only the line pipeline produces output; length pipeline silently
// ignores bytes that don't form valid length-prefixed frames.
assert_eq!(results.len(), 1);
assert!(matches!(&results[0].data, DecodedData::Text(s) if s == "hello"));
}
}

View File

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

View File

@@ -0,0 +1,222 @@
use super::plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType};
use super::text::{TextDecoder, TextEncoding};
use super::{DecodedData, Endian, ProtocolDecoder};
pub const MIXED_PLOT_ESCAPE: u8 = 0x1E;
pub const MIXED_PLOT_MARKER: u8 = b'P';
pub const MIXED_FRAME_KIND_TEXT: u8 = 0x00;
pub const MIXED_FRAME_KIND_PLOT: u8 = 0x01;
const PLOT_PACKET_MAGIC: &[u8; 2] = b"XP";
const PLOT_PACKET_VERSION: u8 = 1;
const PLOT_PACKET_HEADER_LEN: usize = 13;
#[derive(Debug, Clone, Copy)]
pub struct MixedTextPlotConfig {
pub encoding: TextEncoding,
}
impl Default for MixedTextPlotConfig {
fn default() -> Self {
Self {
encoding: TextEncoding::Utf8,
}
}
}
#[derive(Debug, Clone)]
pub struct MixedTextPlotDecoder {
config: MixedTextPlotConfig,
}
impl MixedTextPlotDecoder {
pub fn new(config: MixedTextPlotConfig) -> Self {
Self { config }
}
pub fn build_plot_packet(
sample_type: SampleType,
endian: Endian,
channels: usize,
format: PlotFormat,
samples_per_channel: usize,
payload: &[u8],
) -> Vec<u8> {
let mut packet = Vec::with_capacity(PLOT_PACKET_HEADER_LEN + payload.len());
packet.extend_from_slice(PLOT_PACKET_MAGIC);
packet.push(PLOT_PACKET_VERSION);
packet.push(plot_format_to_u8(format));
packet.push(sample_type_to_u8(sample_type));
packet.push(match endian {
Endian::Little => 0,
Endian::Big => 1,
});
packet.push(channels.min(u8::MAX as usize) as u8);
packet
.extend_from_slice(&(samples_per_channel.min(u16::MAX as usize) as u16).to_le_bytes());
packet.extend_from_slice(&(payload.len().min(u32::MAX as usize) as u32).to_le_bytes());
packet.extend_from_slice(payload);
packet
}
fn decode_plot(&self, frame: &[u8]) -> Option<DecodedData> {
if frame.len() < PLOT_PACKET_HEADER_LEN {
return None;
}
if &frame[..2] != PLOT_PACKET_MAGIC {
return None;
}
if frame[2] != PLOT_PACKET_VERSION {
return None;
}
let format = plot_format_from_u8(frame[3])?;
let sample_type = sample_type_from_u8(frame[4])?;
let endian = match frame[5] {
0 => Endian::Little,
1 => Endian::Big,
_ => return None,
};
let channels = frame[6] as usize;
let samples_per_channel = u16::from_le_bytes([frame[7], frame[8]]) as usize;
let payload_len = u32::from_le_bytes([frame[9], frame[10], frame[11], frame[12]]) as usize;
let payload = frame.get(PLOT_PACKET_HEADER_LEN..PLOT_PACKET_HEADER_LEN + payload_len)?;
let num_channels = match format {
PlotFormat::XY => 2,
_ => channels.max(1),
};
if matches!(format, PlotFormat::XY) && channels != 2 {
return None;
}
let expected_len = sample_type
.byte_size()
.checked_mul(num_channels)?
.checked_mul(samples_per_channel)?;
if payload.len() != expected_len {
return None;
}
let decoder = PlotDecoder::new(PlotConfig {
sample_type,
endian,
channels: num_channels,
format,
});
decoder.decode(payload)
}
}
impl ProtocolDecoder for MixedTextPlotDecoder {
fn name(&self) -> &str {
"MixedTextPlot"
}
fn decode(&self, frame: &[u8]) -> Option<DecodedData> {
let (&kind, payload) = frame.split_first()?;
match kind {
MIXED_FRAME_KIND_TEXT => TextDecoder::new(self.config.encoding).decode(payload),
MIXED_FRAME_KIND_PLOT => self.decode_plot(payload),
_ => None,
}
}
}
fn plot_format_to_u8(format: PlotFormat) -> u8 {
match format {
PlotFormat::Interleaved => 0,
PlotFormat::Block => 1,
PlotFormat::XY => 2,
}
}
fn plot_format_from_u8(value: u8) -> Option<PlotFormat> {
match value {
0 => Some(PlotFormat::Interleaved),
1 => Some(PlotFormat::Block),
2 => Some(PlotFormat::XY),
_ => None,
}
}
fn sample_type_to_u8(sample_type: SampleType) -> u8 {
match sample_type {
SampleType::I8 => 0,
SampleType::U8 => 1,
SampleType::I16 => 2,
SampleType::U16 => 3,
SampleType::I32 => 4,
SampleType::U32 => 5,
SampleType::I64 => 6,
SampleType::U64 => 7,
SampleType::F32 => 8,
SampleType::F64 => 9,
}
}
fn sample_type_from_u8(value: u8) -> Option<SampleType> {
match value {
0 => Some(SampleType::I8),
1 => Some(SampleType::U8),
2 => Some(SampleType::I16),
3 => Some(SampleType::U16),
4 => Some(SampleType::I32),
5 => Some(SampleType::U32),
6 => Some(SampleType::I64),
7 => Some(SampleType::U64),
8 => Some(SampleType::F32),
9 => Some(SampleType::F64),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mixed_decoder_decodes_text_frame() {
let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default());
let frame = b"\0hello";
assert!(matches!(decoder.decode(frame), Some(DecodedData::Text(ref s)) if s == "hello"));
}
#[test]
fn mixed_decoder_decodes_plot_frame() {
let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default());
let payload = [0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x40];
let packet = MixedTextPlotDecoder::build_plot_packet(
SampleType::F32,
Endian::Little,
1,
PlotFormat::Interleaved,
2,
&payload,
);
let frame = [vec![MIXED_FRAME_KIND_PLOT], packet].concat();
let decoded = decoder.decode(&frame).unwrap();
match decoded {
DecodedData::Plot(frame) => {
assert_eq!(frame.channels.len(), 1);
assert_eq!(frame.channels[0], vec![1.0, 2.0]);
}
other => panic!("expected Plot, got {other:?}"),
}
}
#[test]
fn mixed_decoder_rejects_bad_payload_length() {
let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default());
let packet = MixedTextPlotDecoder::build_plot_packet(
SampleType::F32,
Endian::Little,
1,
PlotFormat::Interleaved,
2,
&[0x00, 0x00, 0x80, 0x3f],
);
let frame = [vec![MIXED_FRAME_KIND_PLOT], packet].concat();
assert!(decoder.decode(&frame).is_none());
}
}

View File

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

View File

@@ -0,0 +1,525 @@
use super::{DecodedData, ProtocolDecoder};
use crate::protocol::Endian;
use serde::{Deserialize, Serialize};
/// Numeric type of samples in a plot frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
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, Serialize, Deserialize, Default)]
pub enum PlotFormat {
#[default]
Interleaved,
Block,
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,
pub format: PlotFormat,
}
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,
format: self.config.format,
}))
}
}
#[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 mut data = 1u32.to_le_bytes().to_vec();
data.extend_from_slice(&[0xff; 3]);
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,
format: PlotFormat::Interleaved,
};
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);
assert_eq!(frame.format, PlotFormat::Interleaved);
}
other => panic!("expected Plot, got {:?}", other),
}
}
#[test]
fn plot_decoder_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<PlotDecoder>();
}
}

View File

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

View File

@@ -0,0 +1,903 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncWrite};
use tracing::info;
use crate::error::Result;
use crate::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
};
use crate::transport::tcp::TcpTransport;
use crate::transport::udp::UdpTransport;
pub mod serial;
pub mod tcp;
pub mod udp;
fn default_serial_data_bits() -> SerialDataBits {
SerialDataBits::Eight
}
fn default_serial_parity() -> SerialParity {
SerialParity::None
}
fn default_serial_stop_bits() -> SerialStopBits {
SerialStopBits::One
}
fn default_serial_flow_control() -> SerialFlowControl {
SerialFlowControl::None
}
fn default_serial_dtr() -> bool {
false
}
fn default_serial_rts() -> bool {
false
}
#[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,
#[serde(default = "default_serial_data_bits")]
data_bits: SerialDataBits,
#[serde(default = "default_serial_parity")]
parity: SerialParity,
#[serde(default = "default_serial_stop_bits")]
stop_bits: SerialStopBits,
#[serde(default = "default_serial_flow_control")]
flow_control: SerialFlowControl,
#[serde(default = "default_serial_dtr")]
dtr: bool,
#[serde(default = "default_serial_rts")]
rts: bool,
},
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,
data_bits,
parity,
stop_bits,
flow_control,
dtr,
rts,
} => Connection::Serial(SerialTransport::new(
port,
baud_rate,
data_bits,
parity,
stop_bits,
flow_control,
dtr,
rts,
)),
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<()> {
info!(transport = ?self.transport_type(), name = self.name(), "Connection connecting");
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<()> {
info!(name = self.name(), "Connection disconnecting");
match self {
Connection::Serial(t) => t.disconnect().await,
Connection::Tcp(t) => t.disconnect().await,
Connection::Udp(t) => t.disconnect().await,
}
}
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
match self {
Connection::Serial(t) => t.set_dtr(state),
Connection::Tcp(_) | Connection::Udp(_) => {
Err(crate::error::Error::ConnectionFailed(
"DTR only supported on Serial connections".into(),
))
}
}
}
pub fn set_rts(&mut self, state: bool) -> Result<()> {
match self {
Connection::Serial(t) => t.set_rts(state),
Connection::Tcp(_) | Connection::Udp(_) => {
Err(crate::error::Error::ConnectionFailed(
"RTS only supported on Serial connections".into(),
))
}
}
}
}
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::*;
use crate::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
};
fn serial_config(port: &str, baud_rate: u32) -> TransportConfig {
TransportConfig::Serial {
port: port.into(),
baud_rate,
data_bits: SerialDataBits::Eight,
parity: SerialParity::None,
stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
}
}
#[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;
assert_eq!(original, cloned);
let original = TransportType::Udp;
let cloned = original;
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,
data_bits: SerialDataBits::Eight,
parity: SerialParity::None,
stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
};
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 = serial_config("COM1", 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 = serial_config("COM1", 115200);
let json = serde_json::to_string(&cfg).unwrap();
assert!(json.contains("COM1"));
assert!(json.contains("115200"));
assert!(json.contains("data_bits"));
assert!(json.contains("parity"));
assert!(json.contains("stop_bits"));
assert!(json.contains("flow_control"));
}
#[test]
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,
data_bits,
parity,
stop_bits,
flow_control,
dtr,
rts,
} => {
assert_eq!(port, "COM1");
assert_eq!(baud_rate, 9600);
assert_eq!(data_bits, SerialDataBits::Eight);
assert_eq!(parity, SerialParity::None);
assert_eq!(stop_bits, SerialStopBits::One);
assert_eq!(flow_control, SerialFlowControl::None);
assert!(!dtr);
assert!(!rts);
}
_ => panic!("expected Serial variant"),
}
}
#[test]
fn test_transport_config_deserialize_serial_with_explicit_options() {
let json = r#"{"Serial":{"port":"COM2","baud_rate":57600,"data_bits":"Seven","parity":"Even","stop_bits":"Two","flow_control":"Hardware"}}"#;
let cfg: TransportConfig = serde_json::from_str(json).unwrap();
match cfg {
TransportConfig::Serial {
port,
baud_rate,
data_bits,
parity,
stop_bits,
flow_control,
dtr,
rts,
} => {
assert_eq!(port, "COM2");
assert_eq!(baud_rate, 57600);
assert_eq!(data_bits, SerialDataBits::Seven);
assert_eq!(parity, SerialParity::Even);
assert_eq!(stop_bits, SerialStopBits::Two);
assert_eq!(flow_control, SerialFlowControl::Hardware);
assert!(!dtr);
assert!(!rts);
}
_ => 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![
serial_config("COM3", 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(serial_config("COM1", 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(serial_config("COM1", 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(serial_config("COM1", 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(serial_config("COM1", 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 std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::ReadBuf;
for mut conn in [serial_conn(), tcp_conn(), udp_conn()] {
let pinned = Pin::new(&mut conn);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
let mut buf_data = [0u8; 16];
let mut buf = ReadBuf::new(&mut buf_data);
match pinned.poll_read(&mut cx, &mut buf) {
Poll::Ready(Err(e)) => {
assert_eq!(e.kind(), std::io::ErrorKind::NotConnected);
}
other => panic!("expected Poll::Ready(Err(NotConnected)), got {:?}", other),
}
}
}
#[test]
fn test_connection_poll_write_not_connected() {
use std::pin::Pin;
use std::task::{Context, Poll};
for mut conn in [serial_conn(), tcp_conn(), udp_conn()] {
let pinned = Pin::new(&mut conn);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match pinned.poll_write(&mut cx, b"hello") {
Poll::Ready(Err(e)) => {
assert_eq!(e.kind(), std::io::ErrorKind::NotConnected);
}
other => panic!("expected Poll::Ready(Err(NotConnected)), got {:?}", other),
}
}
}
#[test]
fn test_connection_poll_flush_not_connected() {
use std::pin::Pin;
use std::task::{Context, Poll};
for mut conn in [serial_conn(), tcp_conn(), udp_conn()] {
let pinned = Pin::new(&mut conn);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match pinned.poll_flush(&mut cx) {
Poll::Ready(Ok(())) => {}
other => panic!("expected Poll::Ready(Ok(())), got {:?}", other),
}
}
}
#[test]
fn test_connection_poll_shutdown_not_connected() {
use std::pin::Pin;
use std::task::{Context, Poll};
for mut conn in [serial_conn(), tcp_conn(), udp_conn()] {
let pinned = Pin::new(&mut conn);
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match pinned.poll_shutdown(&mut cx) {
Poll::Ready(Ok(())) => {}
other => panic!("expected Poll::Ready(Ok(())), got {:?}", other),
}
}
}
#[tokio::test]
async fn test_connection_tcp_connect_and_disconnect() {
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let mut conn = Connection::new(TransportConfig::Tcp { addr });
assert!(!conn.is_connected());
conn.connect().await.unwrap();
assert!(conn.is_connected());
conn.disconnect().await.unwrap();
assert!(!conn.is_connected());
}
#[tokio::test]
async fn test_connection_udp_connect_and_disconnect_without_remote() {
let mut conn = Connection::new(TransportConfig::Udp {
bind_addr: "127.0.0.1:0".into(),
remote_addr: None,
});
assert!(!conn.is_connected());
conn.connect().await.unwrap();
assert!(conn.is_connected());
conn.disconnect().await.unwrap();
assert!(!conn.is_connected());
}
#[tokio::test]
async fn test_connection_udp_connect_and_disconnect_with_remote() {
use tokio::net::UdpSocket;
let remote = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let remote_addr = remote.local_addr().unwrap().to_string();
let mut conn = Connection::new(TransportConfig::Udp {
bind_addr: "127.0.0.1:0".into(),
remote_addr: Some(remote_addr),
});
assert!(!conn.is_connected());
conn.connect().await.unwrap();
assert!(conn.is_connected());
conn.disconnect().await.unwrap();
assert!(!conn.is_connected());
}
#[tokio::test]
async fn test_connection_tcp_double_connect_is_noop() {
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
assert!(conn.is_connected());
let result = conn.connect().await;
assert!(result.is_ok(), "double connect should be a no-op");
assert!(conn.is_connected());
}
#[tokio::test]
async fn test_connection_tcp_disconnect_without_connect() {
let mut conn = Connection::new(TransportConfig::Tcp {
addr: "127.0.0.1:8080".into(),
});
let result = conn.disconnect().await;
assert!(result.is_ok(), "disconnect without connect should be safe");
assert!(!conn.is_connected());
}
#[tokio::test]
async fn test_connection_tcp_connect_to_unreachable() {
let mut conn = Connection::new(TransportConfig::Tcp {
addr: "127.0.0.1:1".into(),
});
let result = conn.connect().await;
assert!(result.is_err());
assert!(!conn.is_connected());
}
#[tokio::test]
async fn test_connection_udp_connect_bind_failure() {
let mut conn = Connection::new(TransportConfig::Udp {
bind_addr: "invalid_addr".into(),
remote_addr: None,
});
let result = conn.connect().await;
assert!(result.is_err());
assert!(!conn.is_connected());
}
#[tokio::test]
async fn test_connection_udp_double_connect_is_noop() {
let mut conn = Connection::new(TransportConfig::Udp {
bind_addr: "127.0.0.1:0".into(),
remote_addr: None,
});
conn.connect().await.unwrap();
assert!(conn.is_connected());
let result = conn.connect().await;
assert!(result.is_ok());
assert!(conn.is_connected());
}
#[tokio::test]
async fn test_connection_tcp_async_read_write() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
AsyncWriteExt::write_all(&mut stream, b"hello")
.await
.unwrap();
let mut buf = [0u8; 5];
AsyncReadExt::read_exact(&mut stream, &mut buf)
.await
.unwrap();
assert_eq!(&buf, b"world");
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut buf = [0u8; 5];
AsyncReadExt::read_exact(&mut conn, &mut buf).await.unwrap();
assert_eq!(&buf, b"hello");
AsyncWriteExt::write_all(&mut conn, b"world").await.unwrap();
conn.disconnect().await.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn test_connection_udp_async_read_write_with_remote() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UdpSocket;
let remote = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let remote_addr = remote.local_addr().unwrap().to_string();
let mut conn = Connection::new(TransportConfig::Udp {
bind_addr: "127.0.0.1:0".into(),
remote_addr: Some(remote_addr),
});
conn.connect().await.unwrap();
AsyncWriteExt::write_all(&mut conn, b"hello").await.unwrap();
let mut buf = [0u8; 1024];
let (n, from) = remote.recv_from(&mut buf).await.unwrap();
assert_eq!(&buf[..n], b"hello");
remote.send_to(b"world", from).await.unwrap();
let mut read_buf = [0u8; 5];
AsyncReadExt::read_exact(&mut conn, &mut read_buf)
.await
.unwrap();
assert_eq!(&read_buf, b"world");
}
}

View File

@@ -0,0 +1,556 @@
use async_trait::async_trait;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits};
use serialport::SerialPort;
use tracing::{debug, info, warn};
use super::{Transport, TransportType};
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SerialDataBits {
Five,
Six,
Seven,
Eight,
}
impl From<SerialDataBits> for DataBits {
fn from(value: SerialDataBits) -> Self {
match value {
SerialDataBits::Five => DataBits::Five,
SerialDataBits::Six => DataBits::Six,
SerialDataBits::Seven => DataBits::Seven,
SerialDataBits::Eight => DataBits::Eight,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SerialParity {
None,
Odd,
Even,
}
impl From<SerialParity> for Parity {
fn from(value: SerialParity) -> Self {
match value {
SerialParity::None => Parity::None,
SerialParity::Odd => Parity::Odd,
SerialParity::Even => Parity::Even,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SerialStopBits {
One,
Two,
}
impl From<SerialStopBits> for StopBits {
fn from(value: SerialStopBits) -> Self {
match value {
SerialStopBits::One => StopBits::One,
SerialStopBits::Two => StopBits::Two,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SerialFlowControl {
None,
Software,
Hardware,
}
impl From<SerialFlowControl> for FlowControl {
fn from(value: SerialFlowControl) -> Self {
match value {
SerialFlowControl::None => FlowControl::None,
SerialFlowControl::Software => FlowControl::Software,
SerialFlowControl::Hardware => FlowControl::Hardware,
}
}
}
#[derive(Debug)]
pub struct SerialTransport {
port: Option<SerialStream>,
port_name: String,
baud_rate: u32,
data_bits: SerialDataBits,
parity: SerialParity,
stop_bits: SerialStopBits,
flow_control: SerialFlowControl,
dtr: bool,
rts: bool,
}
impl SerialTransport {
#[allow(clippy::too_many_arguments)]
pub fn new(
port_name: String,
baud_rate: u32,
data_bits: SerialDataBits,
parity: SerialParity,
stop_bits: SerialStopBits,
flow_control: SerialFlowControl,
dtr: bool,
rts: bool,
) -> Self {
Self {
port: None,
port_name,
baud_rate,
data_bits,
parity,
stop_bits,
flow_control,
dtr,
rts,
}
}
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
}
pub fn data_bits(&self) -> SerialDataBits {
self.data_bits
}
pub fn parity(&self) -> SerialParity {
self.parity
}
pub fn stop_bits(&self) -> SerialStopBits {
self.stop_bits
}
pub fn flow_control(&self) -> SerialFlowControl {
self.flow_control
}
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
match &mut self.port {
Some(port) => {
port.write_data_terminal_ready(state)?;
self.dtr = state;
debug!("DTR set to {} on {}", state, self.port_name);
Ok(())
}
None => Err(crate::error::Error::ConnectionFailed(format!(
"port {} not open",
self.port_name
))),
}
}
pub fn set_rts(&mut self, state: bool) -> Result<()> {
match &mut self.port {
Some(port) => {
port.write_request_to_send(state)?;
self.rts = state;
debug!("RTS set to {} on {}", state, self.port_name);
Ok(())
}
None => Err(crate::error::Error::ConnectionFailed(format!(
"port {} not open",
self.port_name
))),
}
}
}
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,
self.data_bits,
self.parity,
self.stop_bits,
self.flow_control
);
let mut port = tokio_serial::new(&self.port_name, self.baud_rate)
.data_bits(self.data_bits.into())
.parity(self.parity.into())
.stop_bits(self.stop_bits.into())
.flow_control(self.flow_control.into())
.open_native_async()
.map_err(|e| {
crate::error::Error::ConnectionFailed(format!(
"Failed to open {}: {}",
self.port_name, e
))
})?;
// Linux kernel toggles DTR/RTS on every open() — restore configured
// state immediately afterward. Many wireless serial modules (HC-12,
// HC-15, HC-05, Bluetooth/UART bridges) need DTR asserted to stay in
// transparent data mode and not fall into AT-command / reset state.
if let Err(e) = port.write_data_terminal_ready(self.dtr) {
warn!("Failed to set DTR({}) on {}: {}", self.dtr, self.port_name, e);
}
if let Err(e) = port.write_request_to_send(self.rts) {
warn!("Failed to set RTS({}) on {}: {}", self.rts, 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,
SerialDataBits::Eight,
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
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,
SerialDataBits::Seven,
SerialParity::Even,
SerialStopBits::Two,
SerialFlowControl::Hardware,
true,
true,
);
assert_eq!(transport.port_name(), "COM3");
assert_eq!(transport.baud_rate(), 9600);
assert_eq!(transport.data_bits(), SerialDataBits::Seven);
assert_eq!(transport.parity(), SerialParity::Even);
assert_eq!(transport.stop_bits(), SerialStopBits::Two);
assert_eq!(transport.flow_control(), SerialFlowControl::Hardware);
}
#[test]
fn test_name() {
let transport = SerialTransport::new(
"COM1".into(),
115200,
SerialDataBits::Eight,
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.name(), "COM1");
}
#[test]
fn test_transport_type() {
let transport = SerialTransport::new(
"COM1".into(),
115200,
SerialDataBits::Eight,
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.transport_type(), TransportType::Serial);
}
#[test]
fn test_is_connected_false_by_default() {
let transport = SerialTransport::new(
"COM1".into(),
115200,
SerialDataBits::Eight,
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
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,
SerialDataBits::Eight,
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
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,
SerialDataBits::Eight,
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
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,
SerialDataBits::Eight,
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
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,
SerialDataBits::Eight,
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
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,
SerialDataBits::Eight,
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.name(), "COM1");
}
#[test]
fn test_transport_transport_type() {
let transport = SerialTransport::new(
"COM1".into(),
115200,
SerialDataBits::Eight,
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.transport_type(), TransportType::Serial);
}
#[test]
fn test_transport_is_connected() {
let transport = SerialTransport::new(
"COM1".into(),
115200,
SerialDataBits::Eight,
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert!(!transport.is_connected());
}
}

View File

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

View File

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

View File

@@ -0,0 +1,831 @@
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, UdpSocket};
use tokio::time::timeout;
use pipeview_core::frame::{
Endian, Framer,
cobs::CobsFramer,
cobs::cobs_encode as raw_cobs_encode,
fixed::FixedLengthFramer,
length::{LengthConfig, LengthPrefixedFramer},
line::{LineConfig, LineFramer},
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
};
use pipeview_core::protocol::{
DecodedData, ProtocolDecoder,
hex::{HexConfig, HexDecoder},
mixed::{MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER, MixedTextPlotConfig, MixedTextPlotDecoder},
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
text::{TextDecoder, TextEncoding},
};
use pipeview_core::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
};
use pipeview_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, 0.0];
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] - 0.0).abs() < 1e-5);
}
other => panic!("expected Plot, got {:?}", other),
}
server.await.unwrap();
}
#[tokio::test]
async fn tcp_mixed_text_and_plot_single_stream() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let payload = [
0x00, 0x00, 0x80, 0x3f, // 1.0
0x00, 0x00, 0x00, 0x40, // 2.0
];
let packet = MixedTextPlotDecoder::build_plot_packet(
SampleType::F32,
Endian::Little,
1,
PlotFormat::Interleaved,
2,
&payload,
);
let mut mixed = b"status ok\n".to_vec();
mixed.push(MIXED_PLOT_ESCAPE);
mixed.push(MIXED_PLOT_MARKER);
mixed.extend_from_slice(&raw_cobs_encode(&packet));
mixed.push(0x00);
mixed.extend_from_slice(b"done\n");
stream.write_all(&mixed).await.unwrap();
});
let mut conn = Connection::new(TransportConfig::Tcp { addr });
conn.connect().await.unwrap();
let mut framer = MixedTextPlotFramer::new(MixedFramerConfig::default());
let frames = read_to_framer(&mut conn, &mut framer).await;
conn.disconnect().await.unwrap();
let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default());
let results: Vec<DecodedData> = frames.iter().filter_map(|f| decoder.decode(f)).collect();
assert_eq!(results.len(), 3);
assert!(matches!(&results[0], DecodedData::Text(s) if s == "status ok"));
match &results[1] {
DecodedData::Plot(frame) => assert_eq!(frame.channels[0], vec![1.0, 2.0]),
other => panic!("expected Plot, got {other:?}"),
}
assert!(matches!(&results[2], DecodedData::Text(s) if s == "done"));
server.await.unwrap();
}
#[tokio::test]
async fn tcp_fixed_plot_two_channel_u16() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
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,
data_bits: SerialDataBits::Eight,
parity: SerialParity::None,
stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
});
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
}