Add Lua pipeline support
This commit is contained in:
@@ -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"
|
||||||
);
|
);
|
||||||
|
|||||||
404
crates/xserial-client/src/lua/codec.rs
Normal file
404
crates/xserial-client/src/lua/codec.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
43
tests/lua_line_framer.lua
Normal 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,
|
||||||
|
}
|
||||||
12
tests/lua_text_decoder.lua
Normal file
12
tests/lua_text_decoder.lua
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
if #frame == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind = "text",
|
||||||
|
data = frame,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user