Add Lua pipeline support

This commit is contained in:
2026-06-11 15:07:02 +08:00
parent 1fec5f8941
commit edec8113fb
9 changed files with 769 additions and 38 deletions

View File

@@ -16,6 +16,9 @@ use xserial_core::protocol::{
}; };
use xserial_core::transport::TransportConfig; use xserial_core::transport::TransportConfig;
use crate::error::Result;
use crate::lua::codec::{LuaDecoder, LuaFramer};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FramerConfig { pub enum FramerConfig {
Line { Line {
@@ -49,6 +52,9 @@ pub enum FramerConfig {
#[serde(default = "default_max_frame")] #[serde(default = "default_max_frame")]
max_plot_frame: usize, max_plot_frame: usize,
}, },
Lua {
script_path: String,
},
} }
fn default_true() -> bool { fn default_true() -> bool {
@@ -68,15 +74,15 @@ fn default_max_frame() -> usize {
} }
impl FramerConfig { impl FramerConfig {
pub fn build(self) -> Box<dyn Framer> { pub fn build(self) -> Result<Box<dyn Framer>> {
match self { Ok(match self {
FramerConfig::Line { FramerConfig::Line {
strip_cr, strip_cr,
max_line_len, max_line_len,
} => Box::new(LineFramer::new(LineConfig { } => Box::new(LineFramer::new(LineConfig {
strip_cr, strip_cr,
max_line_len, max_line_len,
})), })) as Box<dyn Framer>,
FramerConfig::Fixed { frame_len } => Box::new(FixedLengthFramer::new(frame_len)), FramerConfig::Fixed { frame_len } => Box::new(FixedLengthFramer::new(frame_len)),
FramerConfig::Length { FramerConfig::Length {
len_bytes, len_bytes,
@@ -99,7 +105,10 @@ impl FramerConfig {
max_line_len, max_line_len,
max_plot_frame, max_plot_frame,
})), })),
} FramerConfig::Lua { script_path } => {
Box::new(LuaFramer::from_script_path(script_path)?)
}
})
} }
} }
@@ -132,6 +141,9 @@ pub enum DecoderConfig {
#[serde(default)] #[serde(default)]
encoding: TextEncoding, encoding: TextEncoding,
}, },
Lua {
script_path: String,
},
} }
fn default_sep() -> String { fn default_sep() -> String {
@@ -142,9 +154,11 @@ fn default_one() -> usize {
} }
impl DecoderConfig { impl DecoderConfig {
pub fn build(self) -> Box<dyn ProtocolDecoder> { pub fn build(self) -> Result<Box<dyn ProtocolDecoder>> {
match self { Ok(match self {
DecoderConfig::Text { encoding } => Box::new(TextDecoder::new(encoding)), DecoderConfig::Text { encoding } => {
Box::new(TextDecoder::new(encoding)) as Box<dyn ProtocolDecoder>
}
DecoderConfig::Hex { DecoderConfig::Hex {
uppercase, uppercase,
separator, separator,
@@ -170,7 +184,10 @@ impl DecoderConfig {
DecoderConfig::MixedTextPlot { encoding } => { DecoderConfig::MixedTextPlot { encoding } => {
Box::new(MixedTextPlotDecoder::new(MixedDecoderConfig { encoding })) Box::new(MixedTextPlotDecoder::new(MixedDecoderConfig { encoding }))
} }
} DecoderConfig::Lua { script_path } => {
Box::new(LuaDecoder::from_script_path(script_path)?)
}
})
} }
} }
@@ -285,6 +302,16 @@ mod tests {
} }
} }
#[test]
fn framer_lua_serde() {
let cfg: FramerConfig =
serde_json::from_str(r#"{"Lua":{"script_path":"/tmp/framer.lua"}}"#).unwrap();
match cfg {
FramerConfig::Lua { script_path } => assert_eq!(script_path, "/tmp/framer.lua"),
_ => panic!("expected Lua"),
}
}
#[test] #[test]
fn decoder_text_serde() { fn decoder_text_serde() {
let cfg: DecoderConfig = serde_json::from_str(r#"{"Text":{"encoding":"Latin1"}}"#).unwrap(); let cfg: DecoderConfig = serde_json::from_str(r#"{"Text":{"encoding":"Latin1"}}"#).unwrap();
@@ -345,6 +372,16 @@ mod tests {
} }
} }
#[test]
fn decoder_lua_serde() {
let cfg: DecoderConfig =
serde_json::from_str(r#"{"Lua":{"script_path":"/tmp/decoder.lua"}}"#).unwrap();
match cfg {
DecoderConfig::Lua { script_path } => assert_eq!(script_path, "/tmp/decoder.lua"),
_ => panic!("expected Lua"),
}
}
#[test] #[test]
fn session_config_full_roundtrip() { fn session_config_full_roundtrip() {
let json = r#"{ let json = r#"{
@@ -388,7 +425,7 @@ mod tests {
max_plot_frame: 1024, max_plot_frame: 1024,
}, },
] { ] {
let f = cfg.build(); let f = cfg.build().unwrap();
assert_eq!(f.pending_len(), 0); assert_eq!(f.pending_len(), 0);
} }
} }
@@ -400,6 +437,7 @@ mod tests {
encoding: TextEncoding::Utf8 encoding: TextEncoding::Utf8
} }
.build() .build()
.unwrap()
.name(), .name(),
"Text" "Text"
); );
@@ -411,6 +449,7 @@ mod tests {
endian: Endian::Big endian: Endian::Big
} }
.build() .build()
.unwrap()
.name(), .name(),
"Hex" "Hex"
); );
@@ -422,6 +461,7 @@ mod tests {
format: PlotFormat::XY format: PlotFormat::XY
} }
.build() .build()
.unwrap()
.name(), .name(),
"Plot" "Plot"
); );
@@ -430,6 +470,7 @@ mod tests {
encoding: TextEncoding::Utf8 encoding: TextEncoding::Utf8
} }
.build() .build()
.unwrap()
.name(), .name(),
"MixedTextPlot" "MixedTextPlot"
); );

View File

@@ -0,0 +1,404 @@
use std::fs;
use std::sync::Mutex;
use mlua::{Function, Lua, RegistryKey, Table, Value};
use tracing::warn;
use xserial_core::frame::Framer;
use xserial_core::protocol::plot::{PlotFormat, PlotFrame, SampleType};
use xserial_core::protocol::{DecodedData, ProtocolDecoder};
use crate::error::{Error, Result};
pub struct LuaFramer {
lua: Lua,
feed: RegistryKey,
flush: RegistryKey,
reset: RegistryKey,
pending_len: RegistryKey,
script_path: String,
}
impl LuaFramer {
pub fn from_script_path(script_path: impl Into<String>) -> Result<Self> {
let script_path = script_path.into();
let lua = Lua::new();
let table = load_script_table(&lua, &script_path)?;
let feed = registry_function(&lua, &table, "feed", &script_path)?;
let flush = registry_function(&lua, &table, "flush", &script_path)?;
let reset = registry_function(&lua, &table, "reset", &script_path)?;
let pending_len = registry_function(&lua, &table, "pending_len", &script_path)?;
Ok(Self {
lua,
feed,
flush,
reset,
pending_len,
script_path,
})
}
}
impl Framer for LuaFramer {
fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
let result = (|| -> mlua::Result<Vec<Vec<u8>>> {
let function: Function = self.lua.registry_value(&self.feed)?;
let bytes = self.lua.create_string(data)?;
frames_from_value(function.call::<Value>(bytes)?)
})();
match result {
Ok(frames) => frames,
Err(err) => {
warn!(script = %self.script_path, error = %err, "lua framer feed failed");
Vec::new()
}
}
}
fn flush(&mut self) -> Option<Vec<u8>> {
let result = (|| -> mlua::Result<Option<Vec<u8>>> {
let function: Function = self.lua.registry_value(&self.flush)?;
optional_bytes_from_value(function.call::<Value>(())?)
})();
match result {
Ok(frame) => frame,
Err(err) => {
warn!(script = %self.script_path, error = %err, "lua framer flush failed");
None
}
}
}
fn reset(&mut self) {
let result = (|| -> mlua::Result<()> {
let function: Function = self.lua.registry_value(&self.reset)?;
function.call::<()>(())
})();
if let Err(err) = result {
warn!(script = %self.script_path, error = %err, "lua framer reset failed");
}
}
fn pending_len(&self) -> usize {
let result = (|| -> mlua::Result<usize> {
let function: Function = self.lua.registry_value(&self.pending_len)?;
function.call::<usize>(())
})();
match result {
Ok(len) => len,
Err(err) => {
warn!(script = %self.script_path, error = %err, "lua framer pending_len failed");
0
}
}
}
}
pub struct LuaDecoder {
lua: Lua,
decode: RegistryKey,
script_path: String,
call_lock: Mutex<()>,
}
impl LuaDecoder {
pub fn from_script_path(script_path: impl Into<String>) -> Result<Self> {
let script_path = script_path.into();
let lua = Lua::new();
let table = load_script_table(&lua, &script_path)?;
let decode = registry_function(&lua, &table, "decode", &script_path)?;
Ok(Self {
lua,
decode,
script_path,
call_lock: Mutex::new(()),
})
}
}
impl ProtocolDecoder for LuaDecoder {
fn name(&self) -> &str {
"Lua"
}
fn decode(&self, frame: &[u8]) -> Option<DecodedData> {
let _guard = self
.call_lock
.lock()
.expect("lua decoder call mutex poisoned");
let result = (|| -> mlua::Result<Option<DecodedData>> {
let function: Function = self.lua.registry_value(&self.decode)?;
let bytes = self.lua.create_string(frame)?;
decoded_from_value(function.call::<Value>(bytes)?, frame)
})();
match result {
Ok(decoded) => decoded,
Err(err) => {
warn!(script = %self.script_path, error = %err, "lua decoder decode failed");
None
}
}
}
}
fn load_script_table(lua: &Lua, script_path: &str) -> Result<Table> {
let script = fs::read_to_string(script_path)?;
match lua.load(&script).eval::<Value>()? {
Value::Table(table) => Ok(table),
other => Err(Error::Other(format!(
"lua script '{script_path}' must return a table, got {}",
other.type_name()
))),
}
}
fn registry_function(
lua: &Lua,
table: &Table,
name: &str,
script_path: &str,
) -> Result<RegistryKey> {
let value: Value = table.get(name)?;
match value {
Value::Function(function) => Ok(lua.create_registry_value(function)?),
other => Err(Error::Other(format!(
"lua script '{script_path}' field '{name}' must be a function, got {}",
other.type_name()
))),
}
}
fn frames_from_value(value: Value) -> mlua::Result<Vec<Vec<u8>>> {
match value {
Value::Nil => Ok(Vec::new()),
Value::String(bytes) => Ok(vec![bytes.as_bytes().to_vec()]),
Value::Table(table) => {
let mut frames = Vec::new();
for value in table.sequence_values::<mlua::String>() {
frames.push(value?.as_bytes().to_vec());
}
Ok(frames)
}
other => Err(mlua::Error::RuntimeError(format!(
"framer feed must return nil, string, or table of strings, got {}",
other.type_name()
))),
}
}
fn optional_bytes_from_value(value: Value) -> mlua::Result<Option<Vec<u8>>> {
match value {
Value::Nil => Ok(None),
Value::String(bytes) => Ok(Some(bytes.as_bytes().to_vec())),
other => Err(mlua::Error::RuntimeError(format!(
"framer flush must return nil or string, got {}",
other.type_name()
))),
}
}
fn decoded_from_value(value: Value, raw: &[u8]) -> mlua::Result<Option<DecodedData>> {
match value {
Value::Nil => Ok(None),
Value::String(text) => Ok(Some(DecodedData::Text(text.to_str()?.to_string()))),
Value::Table(table) => decoded_from_table(table, raw).map(Some),
other => Err(mlua::Error::RuntimeError(format!(
"decoder decode must return nil, string, or table, got {}",
other.type_name()
))),
}
}
fn decoded_from_table(table: Table, raw: &[u8]) -> mlua::Result<DecodedData> {
let kind: String = table.get("kind")?;
match kind.as_str() {
"text" => Ok(DecodedData::Text(table.get("data")?)),
"hex" => Ok(DecodedData::Hex(table.get("data")?)),
"binary" => {
let bytes: mlua::String = table.get("data")?;
Ok(DecodedData::Binary(bytes.as_bytes().to_vec()))
}
"plot" => Ok(DecodedData::Plot(plot_from_table(table, raw)?)),
other => Err(mlua::Error::RuntimeError(format!(
"unknown lua decoder kind '{other}'"
))),
}
}
fn plot_from_table(table: Table, raw: &[u8]) -> mlua::Result<PlotFrame> {
let channels_table: Table = table.get("channels")?;
let mut channels = Vec::new();
for channel in channels_table.sequence_values::<Table>() {
let channel = channel?;
let mut samples = Vec::new();
for sample in channel.sequence_values::<f64>() {
samples.push(sample?);
}
channels.push(samples);
}
Ok(PlotFrame {
channels,
raw: raw.to_vec(),
sample_type: match table.get::<Option<String>>("sample_type")?.as_deref() {
Some(value) => parse_sample_type(value)?,
None => SampleType::F64,
},
format: match table.get::<Option<String>>("format")?.as_deref() {
Some(value) => parse_plot_format(value)?,
None => PlotFormat::Interleaved,
},
})
}
fn parse_sample_type(value: &str) -> mlua::Result<SampleType> {
match value {
"i8" | "I8" => Ok(SampleType::I8),
"u8" | "U8" => Ok(SampleType::U8),
"i16" | "I16" => Ok(SampleType::I16),
"u16" | "U16" => Ok(SampleType::U16),
"i32" | "I32" => Ok(SampleType::I32),
"u32" | "U32" => Ok(SampleType::U32),
"i64" | "I64" => Ok(SampleType::I64),
"u64" | "U64" => Ok(SampleType::U64),
"f32" | "F32" => Ok(SampleType::F32),
"f64" | "F64" => Ok(SampleType::F64),
other => Err(mlua::Error::RuntimeError(format!(
"unknown plot sample_type '{other}'"
))),
}
}
fn parse_plot_format(value: &str) -> mlua::Result<PlotFormat> {
match value {
"Interleaved" | "interleaved" => Ok(PlotFormat::Interleaved),
"Block" | "block" => Ok(PlotFormat::Block),
"XY" | "xy" => Ok(PlotFormat::XY),
other => Err(mlua::Error::RuntimeError(format!(
"unknown plot format '{other}'"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn write_script(name: &str, script: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("xserial_{name}_{nonce}.lua"));
fs::write(&path, script).unwrap();
path
}
#[test]
fn lua_framer_splits_lines_across_chunks() {
let path = write_script(
"framer",
r#"
local buffer = ""
return {
feed = function(bytes)
buffer = buffer .. bytes
local frames = {}
while true do
local i = buffer:find("\n", 1, true)
if not i then break end
frames[#frames + 1] = buffer:sub(1, i - 1)
buffer = buffer:sub(i + 1)
end
return frames
end,
flush = function()
if #buffer == 0 then return nil end
local frame = buffer
buffer = ""
return frame
end,
reset = function()
buffer = ""
end,
pending_len = function()
return #buffer
end,
}
"#,
);
let mut framer = LuaFramer::from_script_path(path.to_string_lossy()).unwrap();
assert_eq!(framer.feed(b"one\nt"), vec![b"one".to_vec()]);
assert_eq!(framer.pending_len(), 1);
assert_eq!(framer.feed(b"wo\nthree"), vec![b"two".to_vec()]);
assert_eq!(framer.flush(), Some(b"three".to_vec()));
assert_eq!(framer.pending_len(), 0);
fs::remove_file(path).unwrap();
}
#[test]
fn lua_decoder_maps_text_binary_and_plot() {
let path = write_script(
"decoder",
r#"
return {
decode = function(frame)
if frame == "skip" then
return nil
elseif frame == "bin" then
return { kind = "binary", data = "\1\2" }
elseif frame == "plot" then
return {
kind = "plot",
channels = { { 1.0, 2.0 }, { 3.5, 4.5 } },
sample_type = "F64",
format = "Block",
}
end
return { kind = "text", data = "decoded:" .. frame }
end,
}
"#,
);
let decoder = LuaDecoder::from_script_path(path.to_string_lossy()).unwrap();
assert!(decoder.decode(b"skip").is_none());
assert!(matches!(
decoder.decode(b"abc"),
Some(DecodedData::Text(text)) if text == "decoded:abc"
));
assert!(matches!(
decoder.decode(b"bin"),
Some(DecodedData::Binary(bytes)) if bytes == vec![1, 2]
));
match decoder.decode(b"plot").unwrap() {
DecodedData::Plot(frame) => {
assert_eq!(frame.channels, vec![vec![1.0, 2.0], vec![3.5, 4.5]]);
assert_eq!(frame.sample_type, SampleType::F64);
assert_eq!(frame.format, PlotFormat::Block);
assert_eq!(frame.raw, b"plot");
}
other => panic!("expected plot, got {other:?}"),
}
fs::remove_file(path).unwrap();
}
#[test]
fn lua_framer_requires_expected_functions() {
let path = write_script("bad_framer", "return { feed = function() return {} end }");
let result = LuaFramer::from_script_path(path.to_string_lossy());
assert!(result.is_err());
fs::remove_file(path).unwrap();
}
}

View File

@@ -6,6 +6,7 @@ use mlua::{Lua, LuaSerdeExt, Result as LuaResult, Table, Value};
use crate::config::SessionConfig; use crate::config::SessionConfig;
use crate::manager::SessionManager; use crate::manager::SessionManager;
pub(crate) mod codec;
mod session_api; mod session_api;
#[derive(Clone)] #[derive(Clone)]

View File

@@ -206,6 +206,7 @@ pub struct Session {
config: SessionConfig, config: SessionConfig,
conn: Option<Connection>, conn: Option<Connection>,
pipeline: MultiPipeline, pipeline: MultiPipeline,
pipeline_build_error: Option<String>,
history: RingBuffer<DecodedEntry>, history: RingBuffer<DecodedEntry>,
cmd_rx: mpsc::Receiver<SessionCmd>, cmd_rx: mpsc::Receiver<SessionCmd>,
event_tx: broadcast::Sender<SessionEvent>, event_tx: broadcast::Sender<SessionEvent>,
@@ -217,9 +218,17 @@ impl Session {
pub fn spawn(id: SessionId, config: SessionConfig) -> SessionHandle { pub fn spawn(id: SessionId, config: SessionConfig) -> SessionHandle {
let (cmd_tx, cmd_rx) = mpsc::channel(32); let (cmd_tx, cmd_rx) = mpsc::channel(32);
let (event_tx, _) = broadcast::channel(256); let (event_tx, _) = broadcast::channel(256);
let (pipeline, pipeline_build_error) = match Self::build_pipeline(&config) {
Ok(pipeline) => (pipeline, None),
Err(err) => (
MultiPipeline::new(),
Some(format!("pipeline build failed: {err}")),
),
};
let mut session = Self { let mut session = Self {
id, id,
pipeline: Self::build_pipeline(&config), pipeline,
pipeline_build_error,
history: RingBuffer::new(config.history_limit), history: RingBuffer::new(config.history_limit),
config, config,
conn: None, conn: None,
@@ -233,22 +242,29 @@ impl Session {
SessionHandle::new(inner) SessionHandle::new(inner)
} }
fn build_pipeline(config: &SessionConfig) -> MultiPipeline { fn build_pipeline(config: &SessionConfig) -> crate::Result<MultiPipeline> {
let mut pipelines = MultiPipeline::new(); let mut pipelines = MultiPipeline::new();
for pipeline in &config.pipelines { for pipeline in &config.pipelines {
pipelines.add(Pipeline::new( pipelines.add(Pipeline::new(
pipeline.name.clone(), pipeline.name.clone(),
pipeline.framer.clone().build(), pipeline.framer.clone().build()?,
pipeline.decoder.clone().build(), pipeline.decoder.clone().build()?,
)); ));
} }
pipelines Ok(pipelines)
} }
async fn run(&mut self) { async fn run(&mut self) {
info!(session_id = self.id, "session started"); info!(session_id = self.id, "session started");
if let Err(err) = self.connect().await { if let Some(message) = self.pipeline_build_error.take() {
self.emit_error(message);
self.desired_connected = false;
}
if self.desired_connected
&& let Err(err) = self.connect().await
{
warn!(session_id = self.id, error = %err, "initial connect failed"); warn!(session_id = self.id, error = %err, "initial connect failed");
self.emit_error(format!("connect failed: {err}")); self.emit_error(format!("connect failed: {err}"));
} }
@@ -346,9 +362,16 @@ impl Session {
async fn handle_reconfigure(&mut self, config: SessionConfig) { async fn handle_reconfigure(&mut self, config: SessionConfig) {
info!(session_id = self.id, "reconfiguring session"); info!(session_id = self.id, "reconfiguring session");
let pipeline = match Self::build_pipeline(&config) {
Ok(pipeline) => pipeline,
Err(err) => {
self.emit_error(format!("pipeline build failed: {err}"));
return;
}
};
self.disconnect(true).await; self.disconnect(true).await;
self.config = config; self.config = config;
self.pipeline = Self::build_pipeline(&self.config); self.pipeline = pipeline;
self.history = RingBuffer::new(self.config.history_limit); self.history = RingBuffer::new(self.config.history_limit);
if let Err(err) = self.connect().await { if let Err(err) = self.connect().await {

View File

@@ -1,5 +1,6 @@
use std::sync::Once; use std::sync::Once;
use std::time::Duration; use std::time::Duration;
use std::{fs, path::PathBuf};
use mlua::Lua; use mlua::Lua;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -16,6 +17,16 @@ fn init_tracing() {
}); });
} }
fn write_temp_lua(name: &str, script: &str) -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("xserial_{name}_{nonce}.lua"));
fs::write(&path, script).unwrap();
path
}
fn text_pipeline_lua() -> &'static str { fn text_pipeline_lua() -> &'static str {
r#" r#"
{ {
@@ -190,6 +201,102 @@ async fn lua_session_hex_decoder() {
server.await.unwrap(); server.await.unwrap();
} }
// ── xserial.open with Lua framer + Lua decoder ───────────────────
#[tokio::test]
async fn lua_session_custom_lua_pipeline() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let framer_path = write_temp_lua(
"custom_framer",
r#"
local buffer = ""
return {
feed = function(bytes)
buffer = buffer .. bytes
local frames = {}
while true do
local i = buffer:find("|", 1, true)
if not i then break end
frames[#frames + 1] = buffer:sub(1, i - 1)
buffer = buffer:sub(i + 1)
end
return frames
end,
flush = function()
if #buffer == 0 then return nil end
local frame = buffer
buffer = ""
return frame
end,
reset = function()
buffer = ""
end,
pending_len = function()
return #buffer
end,
}
"#,
);
let decoder_path = write_temp_lua(
"custom_decoder",
r#"
return {
decode = function(frame)
return { kind = "text", data = string.upper(frame) }
end,
}
"#,
);
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
stream.write_all(b"alpha|").await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
stream.write_all(b"beta|").await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let lua = Lua::new();
xserial_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local sess = xserial.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{
{{
name = "custom",
framer = {{ Lua = {{ script_path = [[{}]] }} }},
decoder = {{ Lua = {{ script_path = [[{}]] }} }}
}}
}}
}})
local r1 = sess:read(5000)
assert(r1 ~= nil, "expected first custom frame")
assert(r1.pipeline == "custom", "expected custom pipeline, got " .. tostring(r1.pipeline))
assert(r1.kind == "text", "expected text kind, got " .. tostring(r1.kind))
assert(r1.data == "ALPHA", "expected ALPHA, got " .. tostring(r1.data))
local r2 = sess:read(5000)
assert(r2 ~= nil, "expected second custom frame")
assert(r2.data == "BETA", "expected BETA, got " .. tostring(r2.data))
sess:close()
"#,
addr,
framer_path.display(),
decoder_path.display()
);
lua.load(&script).exec_async().await.unwrap();
server.await.unwrap();
fs::remove_file(framer_path).unwrap();
fs::remove_file(decoder_path).unwrap();
}
// ── session:on_data callback ───────────────────────────────────── // ── session:on_data callback ─────────────────────────────────────
#[tokio::test] #[tokio::test]

View File

@@ -1,12 +1,12 @@
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui}; use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig}; use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
use xserial_core::protocol::Endian;
use xserial_core::protocol::plot::{PlotFormat, SampleType}; use xserial_core::protocol::plot::{PlotFormat, SampleType};
use xserial_core::protocol::text::TextEncoding; use xserial_core::protocol::text::TextEncoding;
use xserial_core::transport::TransportConfig; use xserial_core::protocol::Endian;
use xserial_core::transport::serial::{ use xserial_core::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport, SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
}; };
use xserial_core::transport::TransportConfig;
#[derive(Clone)] #[derive(Clone)]
pub struct ConfigForm { pub struct ConfigForm {
@@ -44,6 +44,7 @@ pub struct PipelineForm {
pub mixed_strip_cr: bool, pub mixed_strip_cr: bool,
pub mixed_max_line_len: usize, pub mixed_max_line_len: usize,
pub mixed_max_plot_frame: usize, pub mixed_max_plot_frame: usize,
pub lua_framer_script_path: String,
pub hex_uppercase: bool, pub hex_uppercase: bool,
pub hex_separator: String, pub hex_separator: String,
pub hex_bytes_per_group: usize, pub hex_bytes_per_group: usize,
@@ -52,6 +53,7 @@ pub struct PipelineForm {
pub plot_channels: usize, pub plot_channels: usize,
pub plot_endian: Endian, pub plot_endian: Endian,
pub plot_format: PlotFormat, pub plot_format: PlotFormat,
pub lua_decoder_script_path: String,
} }
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@@ -68,6 +70,7 @@ pub enum FramerChoice {
Length, Length,
Cobs, Cobs,
MixedTextPlot, MixedTextPlot,
Lua,
} }
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@@ -76,6 +79,7 @@ pub enum DecoderChoice {
Hex, Hex,
Plot, Plot,
MixedTextPlot, MixedTextPlot,
Lua,
} }
impl PipelineForm { impl PipelineForm {
@@ -96,6 +100,7 @@ impl PipelineForm {
mixed_strip_cr: true, mixed_strip_cr: true,
mixed_max_line_len: 1_048_576, mixed_max_line_len: 1_048_576,
mixed_max_plot_frame: 1_048_576, mixed_max_plot_frame: 1_048_576,
lua_framer_script_path: String::new(),
hex_uppercase: false, hex_uppercase: false,
hex_separator: String::from(" "), hex_separator: String::from(" "),
hex_bytes_per_group: 1, hex_bytes_per_group: 1,
@@ -104,6 +109,7 @@ impl PipelineForm {
plot_channels: 1, plot_channels: 1,
plot_endian: Endian::Little, plot_endian: Endian::Little,
plot_format: PlotFormat::Interleaved, plot_format: PlotFormat::Interleaved,
lua_decoder_script_path: String::new(),
} }
} }
} }
@@ -271,6 +277,10 @@ impl PipelineForm {
form.mixed_max_line_len = *max_line_len; form.mixed_max_line_len = *max_line_len;
form.mixed_max_plot_frame = *max_plot_frame; form.mixed_max_plot_frame = *max_plot_frame;
} }
FramerConfig::Lua { script_path } => {
form.framer = FramerChoice::Lua;
form.lua_framer_script_path = script_path.clone();
}
} }
match &config.decoder { match &config.decoder {
@@ -306,6 +316,10 @@ impl PipelineForm {
form.decoder = DecoderChoice::MixedTextPlot; form.decoder = DecoderChoice::MixedTextPlot;
form.text_encoding = *encoding; form.text_encoding = *encoding;
} }
DecoderConfig::Lua { script_path } => {
form.decoder = DecoderChoice::Lua;
form.lua_decoder_script_path = script_path.clone();
}
} }
form form
@@ -336,6 +350,9 @@ impl PipelineForm {
max_line_len: self.mixed_max_line_len, max_line_len: self.mixed_max_line_len,
max_plot_frame: self.mixed_max_plot_frame, max_plot_frame: self.mixed_max_plot_frame,
}, },
FramerChoice::Lua => FramerConfig::Lua {
script_path: self.lua_framer_script_path.clone(),
},
}, },
decoder: match self.decoder { decoder: match self.decoder {
DecoderChoice::Text => DecoderConfig::Text { DecoderChoice::Text => DecoderConfig::Text {
@@ -356,6 +373,9 @@ impl PipelineForm {
DecoderChoice::MixedTextPlot => DecoderConfig::MixedTextPlot { DecoderChoice::MixedTextPlot => DecoderConfig::MixedTextPlot {
encoding: self.text_encoding, encoding: self.text_encoding,
}, },
DecoderChoice::Lua => DecoderConfig::Lua {
script_path: self.lua_decoder_script_path.clone(),
},
}, },
} }
} }
@@ -592,12 +612,14 @@ fn render_pipeline_header(
FramerChoice::Length => "Length", FramerChoice::Length => "Length",
FramerChoice::Cobs => "Cobs", FramerChoice::Cobs => "Cobs",
FramerChoice::MixedTextPlot => "MixedTextPlot", FramerChoice::MixedTextPlot => "MixedTextPlot",
FramerChoice::Lua => "Lua",
}) })
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
ui.selectable_value(&mut pipeline.framer, FramerChoice::Line, "Line"); ui.selectable_value(&mut pipeline.framer, FramerChoice::Line, "Line");
ui.selectable_value(&mut pipeline.framer, FramerChoice::Fixed, "Fixed"); ui.selectable_value(&mut pipeline.framer, FramerChoice::Fixed, "Fixed");
ui.selectable_value(&mut pipeline.framer, FramerChoice::Length, "Length"); ui.selectable_value(&mut pipeline.framer, FramerChoice::Length, "Length");
ui.selectable_value(&mut pipeline.framer, FramerChoice::Cobs, "Cobs"); ui.selectable_value(&mut pipeline.framer, FramerChoice::Cobs, "Cobs");
ui.selectable_value(&mut pipeline.framer, FramerChoice::Lua, "Lua");
if ui if ui
.selectable_label( .selectable_label(
matches!(pipeline.framer, FramerChoice::MixedTextPlot), matches!(pipeline.framer, FramerChoice::MixedTextPlot),
@@ -616,10 +638,12 @@ fn render_pipeline_header(
DecoderChoice::Hex => "Hex", DecoderChoice::Hex => "Hex",
DecoderChoice::Plot => "Plot", DecoderChoice::Plot => "Plot",
DecoderChoice::MixedTextPlot => "MixedTextPlot", DecoderChoice::MixedTextPlot => "MixedTextPlot",
DecoderChoice::Lua => "Lua",
}) })
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Text, "Text"); ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Text, "Text");
ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Hex, "Hex"); ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Hex, "Hex");
ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Lua, "Lua");
if ui if ui
.selectable_label(matches!(pipeline.decoder, DecoderChoice::Plot), "Plot") .selectable_label(matches!(pipeline.decoder, DecoderChoice::Plot), "Plot")
.clicked() .clicked()
@@ -710,6 +734,14 @@ fn render_framer_settings(ui: &mut Ui, pipeline: &mut PipelineForm, index: usize
ui.add(DragValue::new(&mut pipeline.mixed_max_plot_frame).range(1..=16_777_216)); ui.add(DragValue::new(&mut pipeline.mixed_max_plot_frame).range(1..=16_777_216));
}); });
} }
FramerChoice::Lua => {
ui.horizontal(|ui| {
ui.label("Script:");
ui.add(
TextEdit::singleline(&mut pipeline.lua_framer_script_path).desired_width(260.0),
);
});
}
} }
} }
@@ -789,6 +821,15 @@ fn render_decoder_settings(ui: &mut Ui, pipeline: &mut PipelineForm, index: usiz
"Plot sample type, endian, format, and channels come from the mixed packet header.", "Plot sample type, endian, format, and channels come from the mixed packet header.",
); );
} }
DecoderChoice::Lua => {
ui.horizontal(|ui| {
ui.label("Script:");
ui.add(
TextEdit::singleline(&mut pipeline.lua_decoder_script_path)
.desired_width(260.0),
);
});
}
} }
} }
@@ -850,6 +891,18 @@ fn validate_pipeline(ui: &mut Ui, pipeline: &PipelineForm, errors: &mut Vec<Stri
{ {
push_error(String::from("Length framer len_bytes must be 1, 2, or 4")); push_error(String::from("Length framer len_bytes must be 1, 2, or 4"));
} }
if matches!(pipeline.framer, FramerChoice::Lua)
&& pipeline.lua_framer_script_path.trim().is_empty()
{
push_error(String::from("Lua framer script path is required"));
}
if matches!(pipeline.decoder, DecoderChoice::Lua)
&& pipeline.lua_decoder_script_path.trim().is_empty()
{
push_error(String::from("Lua decoder script path is required"));
}
} }
fn render_text_encoding_combo(ui: &mut Ui, encoding: &mut TextEncoding, id: impl std::hash::Hash) { fn render_text_encoding_combo(ui: &mut Ui, encoding: &mut TextEncoding, id: impl std::hash::Hash) {
@@ -949,6 +1002,15 @@ mod tests {
format: PlotFormat::Block, format: PlotFormat::Block,
}, },
}, },
PipelineConfig {
name: String::from("lua"),
framer: FramerConfig::Lua {
script_path: String::from("/tmp/framer.lua"),
},
decoder: DecoderConfig::Lua {
script_path: String::from("/tmp/decoder.lua"),
},
},
], ],
history_limit: 4096, history_limit: 4096,
auto_reconnect: true, auto_reconnect: true,

43
tests/lua_line_framer.lua Normal file
View File

@@ -0,0 +1,43 @@
local buffer = ""
return {
feed = function(bytes)
buffer = buffer .. bytes
local frames = {}
while true do
local i = buffer:find("\n", 1, true)
if not i then
break
end
local frame = buffer:sub(1, i - 1)
if frame:sub(-1) == "\r" then
frame = frame:sub(1, -2)
end
frames[#frames + 1] = frame
buffer = buffer:sub(i + 1)
end
return frames
end,
flush = function()
if #buffer == 0 then
return nil
end
local frame = buffer
buffer = ""
return frame
end,
reset = function()
buffer = ""
end,
pending_len = function()
return #buffer
end,
}

View File

@@ -0,0 +1,12 @@
return {
decode = function(frame)
if #frame == 0 then
return nil
end
return {
kind = "text",
data = frame,
}
end,
}

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""TCP server that sends plot waveform frames for the current xserial GUI.""" """TCP server that sends plot waveform frames or text lines for xserial GUI."""
import argparse import argparse
import math import math
@@ -118,7 +118,7 @@ def is_disconnect_error(err: OSError) -> bool:
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="xserial plot test server") parser = argparse.ArgumentParser(description="xserial plot test server")
parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8091) parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--channels", type=int, default=1) parser.add_argument("--channels", type=int, default=1)
parser.add_argument( parser.add_argument(
"--format", "--format",
@@ -148,9 +148,9 @@ def main() -> None:
) )
parser.add_argument( parser.add_argument(
"--wire-format", "--wire-format",
choices=("mixed", "raw"), choices=("mixed", "raw", "text"),
default="mixed", default="mixed",
help="mixed sends text+plot on one connection; raw sends plot only", help="mixed sends text+plot; raw sends plot only; text sends text only",
) )
args = parser.parse_args() args = parser.parse_args()
@@ -193,19 +193,38 @@ def main() -> None:
values.append(args.amp * math.sin(2 * math.pi * args.freq * t + phase)) values.append(args.amp * math.sin(2 * math.pi * args.freq * t + phase))
return values return values
def build_text_line(started_at: float, now: float, sample_index: int) -> str:
values = sample_values(sample_index)
joined = ", ".join(
f"ch{index + 1}={value:8.3f}" for index, value in enumerate(values)
)
return (
f"测试 time={now - started_at:7.3f}s "
f"sample={sample_index:7d} {joined}\n"
)
print(f"Listening {args.host}:{args.port} - Ctrl+C to stop") print(f"Listening {args.host}:{args.port} - Ctrl+C to stop")
print("GUI:") print("GUI:")
print(f" transport = TCP {args.host}:{args.port}") print(f" transport = TCP {args.host}:{args.port}")
if args.wire_format == "mixed": if args.wire_format == "mixed":
print(" framer = MixedTextPlot") print(" framer = MixedTextPlot")
print(" decoder = MixedTextPlot\n") print(" decoder = MixedTextPlot\n")
else: elif args.wire_format == "raw":
print(f" framer = Fixed({frame_bytes})") print(f" framer = Fixed({frame_bytes})")
print( print(
f" decoder = Plot(f32, little-endian, {args.channels} channel, {args.format})\n" f" decoder = Plot(f32, little-endian, {args.channels} channel, {args.format})\n"
) )
print(f"sending {samples_per_channel} samples/frame at {args.rate:.1f} samples/sec") else:
print(f"effective frame rate: {frame_rate:.2f} fps\n") print(" framer = Line")
print(" decoder = Text")
print(" lua test = Lua framer tests/lua_line_framer.lua")
print(" Lua decoder tests/lua_text_decoder.lua\n")
if args.wire_format == "text":
print(f"sending text only at {args.text_interval:.3f}s intervals")
print(f"sample clock: {args.rate:.1f} samples/sec\n")
else:
print(f"sending {samples_per_channel} samples/frame at {args.rate:.1f} samples/sec")
print(f"effective frame rate: {frame_rate:.2f} fps\n")
stop_event = threading.Event() stop_event = threading.Event()
@@ -230,6 +249,25 @@ def main() -> None:
next_text_at = started_at next_text_at = started_at
try: try:
while not stop_event.is_set(): while not stop_event.is_set():
if args.wire_format == "text":
now = time.perf_counter()
wait = next_text_at - now
if wait > 0:
time.sleep(min(wait, 0.1))
continue
sample_index = int((now - started_at) * args.rate)
conn.sendall(
build_text_line(
started_at,
now,
sample_index,
).encode("utf-8")
)
text_count += 1
next_text_at += args.text_interval
continue
plot_payload = build_frame( plot_payload = build_frame(
sample_index=sample_index, sample_index=sample_index,
channels=args.channels, channels=args.channels,
@@ -252,16 +290,13 @@ def main() -> None:
now = time.perf_counter() now = time.perf_counter()
if args.wire_format == "mixed" and now >= next_text_at: if args.wire_format == "mixed" and now >= next_text_at:
values = sample_values(sample_index) conn.sendall(
joined = ", ".join( build_text_line(
f"ch{index + 1}={value:8.3f}" started_at,
for index, value in enumerate(values) now,
sample_index,
).encode("utf-8")
) )
line = (
f"测试 time={now - started_at:7.3f}s "
f"sample={sample_index:7d} {joined}\n"
)
conn.sendall(line.encode("utf-8"))
text_count += 1 text_count += 1
next_text_at += args.text_interval next_text_at += args.text_interval
@@ -275,10 +310,13 @@ def main() -> None:
except OSError as err: except OSError as err:
if not is_disconnect_error(err): if not is_disconnect_error(err):
raise raise
print( if args.wire_format == "text":
f"[conn -] {addr} " print(f"[conn -] {addr} ({text_count} text lines)")
f"({frame_count} plot frames, {text_count} text lines)" else:
) print(
f"[conn -] {addr} "
f"({frame_count} plot frames, {text_count} text lines)"
)
finally: finally:
conn.close() conn.close()
finally: finally: