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:
438
crates/pipeview-core/src/pipeline.rs
Normal file
438
crates/pipeview-core/src/pipeline.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user