Compare commits

...

3 Commits

Author SHA1 Message Date
dc8f2eb8a4 feat: restrict DTR/RTS to serial transport; allow manual port input; clean up tool binaries
- Only show DTR/RTS toggles when transport is Serial (not TCP/UDP)
- Add manual port text entry next to serial port combo box
- Reduce combo box width to 180px to make room for text input
- Remove compiled ELF binaries from tools/ and add to .gitignore
2026-06-11 18:01:32 +08:00
edec8113fb Add Lua pipeline support 2026-06-11 15:07:02 +08:00
1fec5f8941 feat: Add DTR/RTS control for serial ports with GUI toggles
- Assert DTR/RTS after serial port open to prevent wireless modules
  (HC-12/HC-15/Bluetooth bridges) from entering AT command mode after
  the unavoidable Linux kernel DTR pulse on every open() call.
- Add dtr/rts fields to TransportConfig::Serial (default: false).
- Add set_dtr()/set_rts() on SerialTransport, Connection, SessionHandle.
- Add SessionCmd::SetDtr/SetRts for live toggling via command channel.
- Add DTR/RTS checkboxes to GUI session controls (visible when connected)
  and to config form for persistence.
- Add test_plot_serial.c and test_text_serial.c — standalone C test
  tools for sending MixedTextPlot frames and plain text over serial.
- Update AGENTS.md with build prerequisites, architecture map, testing
  conventions, env vars, and known quirks.
2026-06-11 14:18:39 +08:00
19 changed files with 1935 additions and 59 deletions

4
.gitignore vendored
View File

@@ -1 +1,5 @@
/target /target
# Compiled binaries in tools/
tools/test_plot_c
tools/test_text_c

144
AGENTS.md
View File

@@ -1,28 +1,134 @@
# Repository Guidelines # Repository Guidelines
## Project Structure & Module Organization ## Build Prerequisites
`xserial` is a Rust workspace. The root [Cargo.toml](/D:/Dev/xserial/Cargo.toml) defines four crates in `crates/`:
- `xserial-core`: transport, framing, protocol, and pipeline primitives in `src/`, with integration tests in `tests/pipeline.rs`.
- `xserial-client`: session management, history, commands, and Lua bindings, with integration tests under `tests/`.
- `xserial-tui`: terminal app entrypoint in `src/main.rs`.
- `xserial-gui`: egui/eframe app, with UI panels in `src/panels/` and font helpers in `src/ui_fonts.rs`.
Use `tools/test_plot.py` as a local waveform source for GUI plot testing. Treat `target/` as generated output. - **Linux**: `libudev-dev` (for `serialport`).
- **All platforms**: a C compiler (`cc`, `gcc`, `clang`, or MSVC) — `mlua` uses `features = ["vendored"]` which compiles LuaJIT from source at build time.
- **No pinned toolchain**: all crates use `edition = "2024"` (requires Rust ≥ 1.85). No `rust-toolchain.toml`; builds with whatever `rustc` is on `PATH`.
## Project Structure
`xserial` is a Rust workspace. The root `Cargo.toml` defines four crates in `crates/`, with dependencies flowing:
```
xserial-core (no internal deps — transport, framing, protocol, pipeline)
xserial-client (depends on xserial-core — sessions, history, commands, Lua)
↑ ↑
xserial-gui xserial-tui
(both depend on xserial-core AND xserial-client)
```
| Crate | Type | What goes here |
|-------|------|---------------|
| `xserial-core` | library (`src/lib.rs`) | `transport/`, `frame/`, `protocol/`, `pipeline.rs` — protocol and I/O. Integration tests: `tests/pipeline.rs`. |
| `xserial-client` | library (`src/lib.rs`) | `session.rs`, `manager.rs`, `config.rs`, `history.rs`, `lua/` — state management and Lua bindings. Integration tests: `tests/session_lifecycle.rs`, `tests/lua_tests.rs`. |
| `xserial-gui` | binary (`src/main.rs`) | egui/eframe app. Panels in `src/panels/`. Font helpers in `src/ui_fonts.rs`. Profiling in `src/perf.rs`. |
| `xserial-tui` | binary (`src/main.rs`) | ratatui/crossterm app. **Still a placeholder** — zero tests, not at feature parity with GUI. |
**Module boundaries**: transport/protocol → `xserial-core`. Session/Lua → `xserial-client`. UI state → `xserial-gui` or `xserial-tui`. Never put I/O logic in the UI crates, never put UI state in the client crate.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `cargo check --workspace`: fast compile check for all crates.
- `cargo test --workspace`: run the workspace test suite, including `crates/xserial-core/tests` and `crates/xserial-client/tests`.
- `cargo run -p xserial-gui`: launch the desktop GUI.
- `cargo run -p xserial-tui`: launch the terminal UI.
- `cargo fmt --all`: apply standard Rust formatting.
- `cargo clippy --workspace --all-targets -- -D warnings`: catch lint issues before review.
- `python tools/test_plot.py --help`: inspect options for the TCP plot-frame generator.
## Coding Style & Naming Conventions ```bash
Follow standard Rust formatting: 4-space indentation, trailing commas in multiline literals, and `snake_case` for modules, files, functions, and tests. Use `PascalCase` for structs/enums and `SCREAMING_SNAKE_CASE` for constants. Keep crate boundaries clean: protocol/transport code belongs in `xserial-core`; session and Lua integration belong in `xserial-client`; UI state stays in `xserial-gui` or `xserial-tui`. cargo check --workspace # fast compile check (all crates)
cargo test --workspace # full suite (~266 tests)
cargo fmt --all # standard Rust formatting
cargo clippy --workspace --all-targets -- -D warnings # lint gate
```
**Run specific tests:**
```bash
cargo test -p xserial-core -- tcp_line_text_utf8
cargo test -p xserial-client -- session_echo_send_and_read
cargo test -p xserial-core -- --nocapture # show test output
# Integration tests only
cargo test -p xserial-core --test pipeline
cargo test -p xserial-client --test session_lifecycle
cargo test -p xserial-client --test lua_tests
```
**Launch:**
```bash
cargo run -p xserial-gui
cargo run -p xserial-tui
```
## Environment & Configuration
### Tracing (all entrypoints)
```bash
RUST_LOG=info cargo run -p xserial-gui
RUST_LOG=xserial_gui::perf=debug cargo run -p xserial-gui
```
`tracing_subscriber::EnvFilter::from_default_env()` reads `RUST_LOG`. The workspace dep `tracing-subscriber` has `features = ["env-filter"]`.
### GUI Profiling
```bash
RUST_LOG=xserial_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p xserial-gui
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p xserial-gui # custom interval (default 1s)
```
Profiling logs go to target `xserial_gui::perf`.
### State & Config File Paths (runtime, not compile-time)
Uses `env::consts::OS` + env vars — no `cfg(target_os)` gating for path resolution:
| Platform | Root config path |
|----------|-----------------|
| Linux | `$XDG_CONFIG_HOME/xserial/` or `~/.config/xserial/` |
| macOS | `~/Library/Application Support/xserial/` |
| Windows | `%APPDATA%\xserial\` |
State files: `gui-state.json`, `tui-state.json`, `font-settings.json`.
### GUI Plot Testing
```bash
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
python tools/test_plot.py --wire-format mixed --format xy --channels 2
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
```
Configure in GUI: Transport `TCP 127.0.0.1:8091`, Framer `MixedTextPlot`, Decoder `MixedTextPlot`.
The wire format spec lives in `tools/xs_mixed_plot.h` (C header, reference only — not compiled into Rust).
## Coding Style
Standard Rust conventions (`cargo fmt`). No crate-level lint attrs, no `[lints]` table in any `Cargo.toml`. Names: `snake_case` for modules/files/functions/tests, `PascalCase` for types, `SCREAMING_SNAKE_CASE` for constants.
## Testing Guidelines ## Testing Guidelines
Prefer focused unit tests next to implementation and integration tests in `crates/<crate>/tests/*.rs`. Name tests after behavior, for example `session_lifecycle` or `pipeline`. Run `cargo test --workspace` before opening a PR; add targeted regression tests for transport, framing, parsing, and session-state fixes.
## Commit & Pull Request Guidelines - **Inline unit tests** (`#[cfg(test)] mod tests`) next to implementation — 24 source files with `#[test]` or `#[tokio::test]`.
Recent history uses short, imperative subjects such as `Persist GUI font settings` and `Improve session controls and GUI views`, with occasional `feat:` prefixes for major additions. Keep subjects concise and action-oriented. PRs should state which crate(s) changed, list validation commands, and include screenshots or short recordings for GUI/TUI changes. Call out protocol, transport, or Lua API compatibility impacts explicitly. - **Integration tests** in `crates/<crate>/tests/*.rs` — 3 files covering pipeline, session lifecycle, and Lua bindings.
- **~266 tests total** — all active (zero `#[ignore]`). Single `#[should_panic]` in `frame/fixed.rs`.
- **All tests are self-contained**: TCP/UDP bind to `127.0.0.1:0` (OS-assigned port). No hardcoded ports. No external services. Serial tests use dummy port names and never open real hardware.
- **Async tests** use `#[tokio::test]` (no `async-std`). **No benchmarks** exist.
- Run `cargo test --workspace` before opening PRs. Add targeted regression tests for transport, framing, parsing, and session-state fixes.
## Known Quirks & Constraints
- **No CI** — no `.github/workflows` or other CI config. Agents must self-verify with `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings`.
- **No feature flags** — zero `[features]` in any `Cargo.toml`. No `#[cfg(feature)]` in any source.
- **No build scripts** — zero `build.rs` files. No codegen. No `include!`/`include_str!`/`include_bytes!`.
- **All `unsafe` is test-only** — constructing noop wakers for poll-based unit tests in `transport/`. No non-test `unsafe` anywhere.
- **`xserial-tui` is a stub** — no tests, minimal implementation. GUI is the primary target.
- **Platform font directories** use `#[cfg(target_os)]` in `ui_fonts.rs` (three blocks: Windows, macOS, Linux/BSD).
- **`tokio` features differ by build**: `xserial-client` uses `["sync", "time"]` for production, `["full"]` for dev-dependencies (tests need net/IO).
## Commit & PR Guidelines
Short, imperative subjects (`Persist GUI font settings`), occasional `feat:` prefix for major additions. PRs: state which crate(s) changed, list validation commands, include screenshots/recordings for GUI/TUI changes. Explicitly call out protocol, transport, or Lua API compatibility impacts.
## Reference
- `tools/test_plot.py --help` — TCP plot-frame generator options.
- `tools/xs_mixed_plot.h` — wire format spec for MixedTextPlot (COBS-framed binary plot + text on one stream).

3
Cargo.lock generated
View File

@@ -5806,7 +5806,10 @@ dependencies = [
"crossterm 0.28.1", "crossterm 0.28.1",
"hex", "hex",
"image", "image",
"mlua",
"ratatui", "ratatui",
"serde",
"serde_json",
"tokio", "tokio",
"tracing", "tracing",
"tracing-appender", "tracing-appender",

View File

@@ -8,4 +8,6 @@ pub enum SessionCmd {
SetAutoReconnect(bool), SetAutoReconnect(bool),
Close, Close,
Reconfigure(SessionConfig), Reconfigure(SessionConfig),
SetDtr(bool),
SetRts(bool),
} }

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

@@ -138,6 +138,22 @@ impl SessionHandle {
.map_err(|_| String::from("session closed")) .map_err(|_| String::from("session closed"))
} }
pub async fn set_dtr(&self, state: bool) -> Result<(), String> {
self.inner
.cmd_tx
.send(SessionCmd::SetDtr(state))
.await
.map_err(|_| String::from("session closed"))
}
pub async fn set_rts(&self, state: bool) -> Result<(), String> {
self.inner
.cmd_tx
.send(SessionCmd::SetRts(state))
.await
.map_err(|_| String::from("session closed"))
}
pub async fn read(&self, timeout_ms: u64) -> Option<DecodedEntry> { pub async fn read(&self, timeout_ms: u64) -> Option<DecodedEntry> {
let mut rx = self.inner.event_tx.subscribe(); let mut rx = self.inner.event_tx.subscribe();
let deadline = Duration::from_millis(timeout_ms); let deadline = Duration::from_millis(timeout_ms);
@@ -190,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>,
@@ -201,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,
@@ -217,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}"));
} }
@@ -304,6 +336,14 @@ impl Session {
self.handle_reconfigure(config).await; self.handle_reconfigure(config).await;
true true
} }
Some(SessionCmd::SetDtr(state)) => {
self.handle_set_dtr(state);
true
}
Some(SessionCmd::SetRts(state)) => {
self.handle_set_rts(state);
true
}
None => false, None => false,
} }
} }
@@ -322,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 {
@@ -332,6 +379,28 @@ impl Session {
} }
} }
fn handle_set_dtr(&mut self, state: bool) {
match self.conn.as_mut() {
Some(conn) => {
if let Err(err) = conn.set_dtr(state) {
self.emit_error(format!("set_dtr: {err}"));
}
}
None => self.emit_error(String::from("set_dtr: session not connected")),
}
}
fn handle_set_rts(&mut self, state: bool) {
match self.conn.as_mut() {
Some(conn) => {
if let Err(err) = conn.set_rts(state) {
self.emit_error(format!("set_rts: {err}"));
}
}
None => self.emit_error(String::from("set_rts: session not connected")),
}
}
async fn handle_read_result( async fn handle_read_result(
&mut self, &mut self,
result: Result<Vec<DecodedEntry>, std::io::Error>, result: Result<Vec<DecodedEntry>, std::io::Error>,

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

@@ -30,6 +30,14 @@ fn default_serial_flow_control() -> SerialFlowControl {
SerialFlowControl::None SerialFlowControl::None
} }
fn default_serial_dtr() -> bool {
false
}
fn default_serial_rts() -> bool {
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransportType { pub enum TransportType {
Serial, Serial,
@@ -60,6 +68,10 @@ pub enum TransportConfig {
stop_bits: SerialStopBits, stop_bits: SerialStopBits,
#[serde(default = "default_serial_flow_control")] #[serde(default = "default_serial_flow_control")]
flow_control: SerialFlowControl, flow_control: SerialFlowControl,
#[serde(default = "default_serial_dtr")]
dtr: bool,
#[serde(default = "default_serial_rts")]
rts: bool,
}, },
Tcp { Tcp {
addr: String, addr: String,
@@ -96,6 +108,8 @@ impl Connection {
parity, parity,
stop_bits, stop_bits,
flow_control, flow_control,
dtr,
rts,
} => Connection::Serial(SerialTransport::new( } => Connection::Serial(SerialTransport::new(
port, port,
baud_rate, baud_rate,
@@ -103,6 +117,8 @@ impl Connection {
parity, parity,
stop_bits, stop_bits,
flow_control, flow_control,
dtr,
rts,
)), )),
TransportConfig::Tcp { addr } => Connection::Tcp(TcpTransport::new(addr)), TransportConfig::Tcp { addr } => Connection::Tcp(TcpTransport::new(addr)),
TransportConfig::Udp { TransportConfig::Udp {
@@ -153,6 +169,28 @@ impl Connection {
Connection::Udp(t) => t.disconnect().await, Connection::Udp(t) => t.disconnect().await,
} }
} }
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
match self {
Connection::Serial(t) => t.set_dtr(state),
Connection::Tcp(_) | Connection::Udp(_) => {
Err(crate::error::Error::ConnectionFailed(
"DTR only supported on Serial connections".into(),
))
}
}
}
pub fn set_rts(&mut self, state: bool) -> Result<()> {
match self {
Connection::Serial(t) => t.set_rts(state),
Connection::Tcp(_) | Connection::Udp(_) => {
Err(crate::error::Error::ConnectionFailed(
"RTS only supported on Serial connections".into(),
))
}
}
}
} }
impl AsyncRead for Connection { impl AsyncRead for Connection {
@@ -220,6 +258,8 @@ mod tests {
parity: SerialParity::None, parity: SerialParity::None,
stop_bits: SerialStopBits::One, stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None, flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
} }
} }
@@ -312,6 +352,8 @@ mod tests {
parity: SerialParity::None, parity: SerialParity::None,
stop_bits: SerialStopBits::One, stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None, flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
}; };
let _ = format!("{:?}", cfg); let _ = format!("{:?}", cfg);
@@ -371,6 +413,8 @@ mod tests {
parity, parity,
stop_bits, stop_bits,
flow_control, flow_control,
dtr,
rts,
} => { } => {
assert_eq!(port, "COM1"); assert_eq!(port, "COM1");
assert_eq!(baud_rate, 9600); assert_eq!(baud_rate, 9600);
@@ -378,6 +422,8 @@ mod tests {
assert_eq!(parity, SerialParity::None); assert_eq!(parity, SerialParity::None);
assert_eq!(stop_bits, SerialStopBits::One); assert_eq!(stop_bits, SerialStopBits::One);
assert_eq!(flow_control, SerialFlowControl::None); assert_eq!(flow_control, SerialFlowControl::None);
assert!(!dtr);
assert!(!rts);
} }
_ => panic!("expected Serial variant"), _ => panic!("expected Serial variant"),
} }
@@ -395,6 +441,8 @@ mod tests {
parity, parity,
stop_bits, stop_bits,
flow_control, flow_control,
dtr,
rts,
} => { } => {
assert_eq!(port, "COM2"); assert_eq!(port, "COM2");
assert_eq!(baud_rate, 57600); assert_eq!(baud_rate, 57600);
@@ -402,6 +450,8 @@ mod tests {
assert_eq!(parity, SerialParity::Even); assert_eq!(parity, SerialParity::Even);
assert_eq!(stop_bits, SerialStopBits::Two); assert_eq!(stop_bits, SerialStopBits::Two);
assert_eq!(flow_control, SerialFlowControl::Hardware); assert_eq!(flow_control, SerialFlowControl::Hardware);
assert!(!dtr);
assert!(!rts);
} }
_ => panic!("expected Serial variant"), _ => panic!("expected Serial variant"),
} }

View File

@@ -1,6 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits}; use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits};
use serialport::SerialPort;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use super::{Transport, TransportType}; use super::{Transport, TransportType};
@@ -83,9 +84,12 @@ pub struct SerialTransport {
parity: SerialParity, parity: SerialParity,
stop_bits: SerialStopBits, stop_bits: SerialStopBits,
flow_control: SerialFlowControl, flow_control: SerialFlowControl,
dtr: bool,
rts: bool,
} }
impl SerialTransport { impl SerialTransport {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
port_name: String, port_name: String,
baud_rate: u32, baud_rate: u32,
@@ -93,6 +97,8 @@ impl SerialTransport {
parity: SerialParity, parity: SerialParity,
stop_bits: SerialStopBits, stop_bits: SerialStopBits,
flow_control: SerialFlowControl, flow_control: SerialFlowControl,
dtr: bool,
rts: bool,
) -> Self { ) -> Self {
Self { Self {
port: None, port: None,
@@ -102,6 +108,8 @@ impl SerialTransport {
parity, parity,
stop_bits, stop_bits,
flow_control, flow_control,
dtr,
rts,
} }
} }
@@ -138,6 +146,36 @@ impl SerialTransport {
pub fn flow_control(&self) -> SerialFlowControl { pub fn flow_control(&self) -> SerialFlowControl {
self.flow_control self.flow_control
} }
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
match &mut self.port {
Some(port) => {
port.write_data_terminal_ready(state)?;
self.dtr = state;
debug!("DTR set to {} on {}", state, self.port_name);
Ok(())
}
None => Err(crate::error::Error::ConnectionFailed(format!(
"port {} not open",
self.port_name
))),
}
}
pub fn set_rts(&mut self, state: bool) -> Result<()> {
match &mut self.port {
Some(port) => {
port.write_request_to_send(state)?;
self.rts = state;
debug!("RTS set to {} on {}", state, self.port_name);
Ok(())
}
None => Err(crate::error::Error::ConnectionFailed(format!(
"port {} not open",
self.port_name
))),
}
}
} }
impl AsyncRead for SerialTransport { impl AsyncRead for SerialTransport {
@@ -221,7 +259,7 @@ impl Transport for SerialTransport {
self.flow_control self.flow_control
); );
let port = tokio_serial::new(&self.port_name, self.baud_rate) let mut port = tokio_serial::new(&self.port_name, self.baud_rate)
.data_bits(self.data_bits.into()) .data_bits(self.data_bits.into())
.parity(self.parity.into()) .parity(self.parity.into())
.stop_bits(self.stop_bits.into()) .stop_bits(self.stop_bits.into())
@@ -234,6 +272,17 @@ impl Transport for SerialTransport {
)) ))
})?; })?;
// Linux kernel toggles DTR/RTS on every open() — restore configured
// state immediately afterward. Many wireless serial modules (HC-12,
// HC-15, HC-05, Bluetooth/UART bridges) need DTR asserted to stay in
// transparent data mode and not fall into AT-command / reset state.
if let Err(e) = port.write_data_terminal_ready(self.dtr) {
warn!("Failed to set DTR({}) on {}: {}", self.dtr, self.port_name, e);
}
if let Err(e) = port.write_request_to_send(self.rts) {
warn!("Failed to set RTS({}) on {}: {}", self.rts, self.port_name, e);
}
debug!("Serial port {} opened successfully", self.port_name); debug!("Serial port {} opened successfully", self.port_name);
self.port = Some(port); self.port = Some(port);
Ok(()) Ok(())
@@ -282,6 +331,8 @@ mod tests {
SerialParity::None, SerialParity::None,
SerialStopBits::One, SerialStopBits::One,
SerialFlowControl::None, SerialFlowControl::None,
true,
true,
); );
assert_eq!(transport.port_name(), "COM1"); assert_eq!(transport.port_name(), "COM1");
assert_eq!(transport.baud_rate(), 115200); assert_eq!(transport.baud_rate(), 115200);
@@ -296,6 +347,8 @@ mod tests {
SerialParity::Even, SerialParity::Even,
SerialStopBits::Two, SerialStopBits::Two,
SerialFlowControl::Hardware, SerialFlowControl::Hardware,
true,
true,
); );
assert_eq!(transport.port_name(), "COM3"); assert_eq!(transport.port_name(), "COM3");
assert_eq!(transport.baud_rate(), 9600); assert_eq!(transport.baud_rate(), 9600);
@@ -314,6 +367,8 @@ mod tests {
SerialParity::None, SerialParity::None,
SerialStopBits::One, SerialStopBits::One,
SerialFlowControl::None, SerialFlowControl::None,
true,
true,
); );
assert_eq!(transport.name(), "COM1"); assert_eq!(transport.name(), "COM1");
} }
@@ -327,6 +382,8 @@ mod tests {
SerialParity::None, SerialParity::None,
SerialStopBits::One, SerialStopBits::One,
SerialFlowControl::None, SerialFlowControl::None,
true,
true,
); );
assert_eq!(transport.transport_type(), TransportType::Serial); assert_eq!(transport.transport_type(), TransportType::Serial);
} }
@@ -340,6 +397,8 @@ mod tests {
SerialParity::None, SerialParity::None,
SerialStopBits::One, SerialStopBits::One,
SerialFlowControl::None, SerialFlowControl::None,
true,
true,
); );
assert!(!transport.is_connected()); assert!(!transport.is_connected());
} }
@@ -368,6 +427,8 @@ mod tests {
SerialParity::None, SerialParity::None,
SerialStopBits::One, SerialStopBits::One,
SerialFlowControl::None, SerialFlowControl::None,
true,
true,
); );
let pinned = Pin::new(&mut transport); let pinned = Pin::new(&mut transport);
let mut buf_data = [0u8; 16]; let mut buf_data = [0u8; 16];
@@ -391,6 +452,8 @@ mod tests {
SerialParity::None, SerialParity::None,
SerialStopBits::One, SerialStopBits::One,
SerialFlowControl::None, SerialFlowControl::None,
true,
true,
); );
let pinned = Pin::new(&mut transport); let pinned = Pin::new(&mut transport);
let data = b"hello"; let data = b"hello";
@@ -411,6 +474,8 @@ mod tests {
SerialParity::None, SerialParity::None,
SerialStopBits::One, SerialStopBits::One,
SerialFlowControl::None, SerialFlowControl::None,
true,
true,
); );
let pinned = Pin::new(&mut transport); let pinned = Pin::new(&mut transport);
let waker = noop_waker(); let waker = noop_waker();
@@ -430,6 +495,8 @@ mod tests {
SerialParity::None, SerialParity::None,
SerialStopBits::One, SerialStopBits::One,
SerialFlowControl::None, SerialFlowControl::None,
true,
true,
); );
let pinned = Pin::new(&mut transport); let pinned = Pin::new(&mut transport);
let waker = noop_waker(); let waker = noop_waker();
@@ -451,6 +518,8 @@ mod tests {
SerialParity::None, SerialParity::None,
SerialStopBits::One, SerialStopBits::One,
SerialFlowControl::None, SerialFlowControl::None,
true,
true,
); );
assert_eq!(transport.name(), "COM1"); assert_eq!(transport.name(), "COM1");
} }
@@ -464,6 +533,8 @@ mod tests {
SerialParity::None, SerialParity::None,
SerialStopBits::One, SerialStopBits::One,
SerialFlowControl::None, SerialFlowControl::None,
true,
true,
); );
assert_eq!(transport.transport_type(), TransportType::Serial); assert_eq!(transport.transport_type(), TransportType::Serial);
} }
@@ -477,6 +548,8 @@ mod tests {
SerialParity::None, SerialParity::None,
SerialStopBits::One, SerialStopBits::One,
SerialFlowControl::None, SerialFlowControl::None,
true,
true,
); );
assert!(!transport.is_connected()); assert!(!transport.is_connected());
} }

View File

@@ -688,6 +688,8 @@ async fn connection_transport_type_dispatch() {
parity: SerialParity::None, parity: SerialParity::None,
stop_bits: SerialStopBits::One, stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None, flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
}); });
let tcp = Connection::new(TransportConfig::Tcp { let tcp = Connection::new(TransportConfig::Tcp {
addr: "127.0.0.1:8080".into(), addr: "127.0.0.1:8080".into(),

View File

@@ -64,6 +64,8 @@ pub struct SessionTab {
pub plot_view: plot_view::PlotViewState, pub plot_view: plot_view::PlotViewState,
pub view: View, pub view: View,
pub auto_reconnect: bool, pub auto_reconnect: bool,
pub dtr: bool,
pub rts: bool,
pub send_input: String, pub send_input: String,
pub send_mode: SendMode, pub send_mode: SendMode,
pub append_newline: bool, pub append_newline: bool,
@@ -183,6 +185,10 @@ impl XserialApp {
fn add_session_tab(&mut self, session_config: SessionConfig) { fn add_session_tab(&mut self, session_config: SessionConfig) {
let history_limit = session_config.history_limit; let history_limit = session_config.history_limit;
let auto_reconnect = session_config.auto_reconnect; let auto_reconnect = session_config.auto_reconnect;
let (dtr, rts) = match &session_config.transport {
TransportConfig::Serial { dtr, rts, .. } => (*dtr, *rts),
_ => (false, false),
};
let handle = self.manager.create(session_config.clone()); let handle = self.manager.create(session_config.clone());
self.tabs.push(SessionTab { self.tabs.push(SessionTab {
id: handle.id(), id: handle.id(),
@@ -194,6 +200,8 @@ impl XserialApp {
plot_view: plot_view::PlotViewState::default(), plot_view: plot_view::PlotViewState::default(),
view: View::Text, view: View::Text,
auto_reconnect, auto_reconnect,
dtr,
rts,
send_input: String::new(), send_input: String::new(),
send_mode: SendMode::Text, send_mode: SendMode::Text,
append_newline: true, append_newline: true,
@@ -920,6 +928,28 @@ fn render_session_controls(
tab.status = ConnectionStatus::Error(String::from("session not found")); tab.status = ConnectionStatus::Error(String::from("session not found"));
} }
} }
if matches!(tab.status, ConnectionStatus::Connected)
&& matches!(tab.session_config.transport, TransportConfig::Serial { .. })
{
let dtr_changed = ui.checkbox(&mut tab.dtr, "DTR").changed();
let rts_changed = ui.checkbox(&mut tab.rts, "RTS").changed();
if dtr_changed || rts_changed {
if let Some(handle) = manager.get(tab.id) {
let dtr = tab.dtr;
let rts = tab.rts;
tokio::spawn(async move {
if dtr_changed {
let _ = handle.set_dtr(dtr).await;
}
if rts_changed {
let _ = handle.set_rts(rts).await;
}
});
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
}
}); });
auto_reconnect_changed auto_reconnect_changed
} }
@@ -1062,6 +1092,8 @@ mod tests {
parity: SerialParity::None, parity: SerialParity::None,
stop_bits: SerialStopBits::One, stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None, flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
}), }),
"Serial COM7" "Serial COM7"
); );

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 {
@@ -17,6 +17,8 @@ pub struct ConfigForm {
pub serial_parity: SerialParity, pub serial_parity: SerialParity,
pub serial_stop_bits: SerialStopBits, pub serial_stop_bits: SerialStopBits,
pub serial_flow_control: SerialFlowControl, pub serial_flow_control: SerialFlowControl,
pub serial_dtr: bool,
pub serial_rts: bool,
pub addr: String, pub addr: String,
pub udp_bind: String, pub udp_bind: String,
pub udp_remote: String, pub udp_remote: String,
@@ -42,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,
@@ -50,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)]
@@ -66,6 +70,7 @@ pub enum FramerChoice {
Length, Length,
Cobs, Cobs,
MixedTextPlot, MixedTextPlot,
Lua,
} }
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@@ -74,6 +79,7 @@ pub enum DecoderChoice {
Hex, Hex,
Plot, Plot,
MixedTextPlot, MixedTextPlot,
Lua,
} }
impl PipelineForm { impl PipelineForm {
@@ -94,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,
@@ -102,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(),
} }
} }
} }
@@ -116,6 +124,8 @@ impl Default for ConfigForm {
serial_parity: SerialParity::None, serial_parity: SerialParity::None,
serial_stop_bits: SerialStopBits::One, serial_stop_bits: SerialStopBits::One,
serial_flow_control: SerialFlowControl::None, serial_flow_control: SerialFlowControl::None,
serial_dtr: false,
serial_rts: false,
addr: String::from("127.0.0.1:8080"), addr: String::from("127.0.0.1:8080"),
udp_bind: String::from("0.0.0.0:9000"), udp_bind: String::from("0.0.0.0:9000"),
udp_remote: String::new(), udp_remote: String::new(),
@@ -158,6 +168,14 @@ impl ConfigForm {
TransportConfig::Serial { flow_control, .. } => *flow_control, TransportConfig::Serial { flow_control, .. } => *flow_control,
_ => SerialFlowControl::None, _ => SerialFlowControl::None,
}, },
serial_dtr: match &config.transport {
TransportConfig::Serial { dtr, .. } => *dtr,
_ => false,
},
serial_rts: match &config.transport {
TransportConfig::Serial { rts, .. } => *rts,
_ => false,
},
addr: match &config.transport { addr: match &config.transport {
TransportConfig::Tcp { addr } => addr.clone(), TransportConfig::Tcp { addr } => addr.clone(),
_ => String::from("127.0.0.1:8080"), _ => String::from("127.0.0.1:8080"),
@@ -190,6 +208,8 @@ impl ConfigForm {
parity: self.serial_parity, parity: self.serial_parity,
stop_bits: self.serial_stop_bits, stop_bits: self.serial_stop_bits,
flow_control: self.serial_flow_control, flow_control: self.serial_flow_control,
dtr: self.serial_dtr,
rts: self.serial_rts,
}, },
TransportChoice::Tcp => TransportConfig::Tcp { TransportChoice::Tcp => TransportConfig::Tcp {
addr: self.addr.clone(), addr: self.addr.clone(),
@@ -257,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 {
@@ -292,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
@@ -322,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 {
@@ -342,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(),
},
}, },
} }
} }
@@ -484,6 +518,8 @@ fn render_transport_section(ui: &mut Ui, form: &mut ConfigForm) {
); );
}); });
}); });
ui.checkbox(&mut form.serial_dtr, "DTR");
ui.checkbox(&mut form.serial_rts, "RTS");
} }
TransportChoice::Tcp => { TransportChoice::Tcp => {
ui.horizontal(|ui| { ui.horizontal(|ui| {
@@ -511,7 +547,7 @@ fn render_serial_port_combo(ui: &mut Ui, form: &mut ConfigForm) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Port:"); ui.label("Port:");
ComboBox::from_id_salt("serial_port") ComboBox::from_id_salt("serial_port")
.width(220.0) .width(180.0)
.selected_text(selected_text) .selected_text(selected_text)
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
if available_ports.is_empty() { if available_ports.is_empty() {
@@ -534,6 +570,7 @@ fn render_serial_port_combo(ui: &mut Ui, form: &mut ConfigForm) {
); );
} }
}); });
ui.add(TextEdit::singleline(&mut form.port).desired_width(260.0));
}); });
} }
@@ -576,12 +613,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),
@@ -600,10 +639,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()
@@ -694,6 +735,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),
);
});
}
} }
} }
@@ -773,6 +822,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),
);
});
}
} }
} }
@@ -834,6 +892,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) {
@@ -933,6 +1003,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,17 +193,36 @@ 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"
) )
else:
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"sending {samples_per_channel} samples/frame at {args.rate:.1f} samples/sec")
print(f"effective frame rate: {frame_rate:.2f} fps\n") print(f"effective frame rate: {frame_rate:.2f} fps\n")
@@ -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,6 +310,9 @@ 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
if args.wire_format == "text":
print(f"[conn -] {addr} ({text_count} text lines)")
else:
print( print(
f"[conn -] {addr} " f"[conn -] {addr} "
f"({frame_count} plot frames, {text_count} text lines)" f"({frame_count} plot frames, {text_count} text lines)"

540
tools/test_plot_serial.c Normal file
View File

@@ -0,0 +1,540 @@
/*
* test_plot_serial.c — 持续向串口发送 MixedTextPlot 正弦波 + 文本流
*
* 编译 (Linux):
* gcc -std=c11 -Wall -Wextra -O2 -o test_plot_serial tools/test_plot_serial.c -lm
*
* 用法:
* ./test_plot_serial /dev/ttyUSB0
* ./test_plot_serial /dev/ttyUSB0 --baud 115200 --channels 2 --format xy --rate 1024 --freq 2.0 --amp 150
* ./test_plot_serial --help
*
* 依赖: tools/xs_mixed_plot.h (同目录, 定义 COBS 和 MixedTextPlot 帧格式)
*
* 协议说明:
* Plot 帧: 0x1E 'P' + COBS(packet) + 0x00
* 文本帧: UTF-8 文本行 + '\n' (直接写入, 由 MixedTextPlot framer 按行分割)
*
* 串口参数: 8N1, 无流控, 默认 115200 baud
*/
#define _POSIX_C_SOURCE 200809L
#include "xs_mixed_plot.h"
#include <errno.h>
#include <fcntl.h>
#include <getopt.h>
#include <math.h>
#include <signal.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <termios.h>
#include <time.h>
#include <unistd.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
/* ── 配置默认值 ───────────────────────────────────────────────────── */
#define DEFAULT_BAUDRATE 115200
#define DEFAULT_RATE 1024.0 /* samples/sec per channel */
#define DEFAULT_FREQ 1.0 /* sine Hz */
#define DEFAULT_AMP 100.0
#define DEFAULT_TEXT_INTERVAL 1.0 /* seconds between text lines */
/* ── 全局状态 (用于信号处理) ──────────────────────────────────────── */
static volatile sig_atomic_t g_running = 1;
static void sigint_handler(int sig) {
(void)sig;
g_running = 0;
}
/* ── 缓冲大小 ─────────────────────────────────────────────────────── */
#define MAX_CHANNELS 8
#define MAX_SPC 4096 /* samples_per_channel */
#define MAX_VALUES (MAX_CHANNELS * MAX_SPC) /* float values per frame */
#define MAX_PAYLOAD (MAX_VALUES * 4)
#define MAX_PACKET (13 + MAX_PAYLOAD)
#define MAX_FRAME (2 + (MAX_PACKET + MAX_PACKET / 254 + 1) + 1)
#define TEXT_BUF_SIZE 512
/* ── 运行时参数 ───────────────────────────────────────────────────── */
typedef struct {
const char *port;
int baudrate;
uint8_t channels;
uint16_t samples_per_channel;
uint8_t plot_format; /* XS_PLOT_FORMAT_INTERLEAVED / XY */
double rate; /* samples/sec */
double freq; /* sine Hz */
double amp;
double text_interval; /* seconds */
} args_t;
/* ── 串口操作 ─────────────────────────────────────────────────────── */
static int serial_open(const char *path, int baudrate) {
int fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK);
if (fd < 0) {
perror("open");
return -1;
}
/* 恢复阻塞模式, 但配合超时使用 select */
int flags = fcntl(fd, F_GETFL, 0);
if (flags < 0) { perror("fcntl"); close(fd); return -1; }
flags &= ~O_NONBLOCK;
if (fcntl(fd, F_SETFL, flags) < 0) { perror("fcntl"); close(fd); return -1; }
struct termios tty;
memset(&tty, 0, sizeof(tty));
if (tcgetattr(fd, &tty) < 0) { perror("tcgetattr"); close(fd); return -1; }
/* 输入: 忽略 BREAK, CR→NL 不转换, 奇偶校验不检查 */
tty.c_iflag = IGNBRK;
/* 输出: 原始模式, 不做任何转换 */
tty.c_oflag = 0;
/* 控制: 8 位, 无奇偶校验, 启用接收器 */
tty.c_cflag = CS8 | CREAD | CLOCAL;
/* 本地: 关闭标准行处理/回显/信号字符 */
tty.c_lflag = 0;
/* 特殊字符: 无超时 (VMIN=1, VTIME=0 → 阻塞读直到至少1字节) */
tty.c_cc[VMIN] = 1;
tty.c_cc[VTIME] = 0;
/* 设置波特率 */
speed_t speed = B115200;
switch (baudrate) {
case 9600: speed = B9600; break;
case 19200: speed = B19200; break;
case 38400: speed = B38400; break;
case 57600: speed = B57600; break;
case 115200: speed = B115200; break;
case 230400: speed = B230400; break;
case 460800: speed = B460800; break;
case 921600: speed = B921600; break;
default:
fprintf(stderr, "不支持的波特率: %d (支持: 9600-921600)\n", baudrate);
close(fd);
return -1;
}
cfsetispeed(&tty, speed);
cfsetospeed(&tty, speed);
#ifdef CRTSCTS
tty.c_cflag &= ~CRTSCTS;
#endif
/* 关闭软件流控 */
tty.c_iflag &= ~(IXON | IXOFF | IXANY);
if (tcsetattr(fd, TCSANOW, &tty) < 0) {
perror("tcsetattr");
close(fd);
return -1;
}
/* 排空缓冲区 */
tcflush(fd, TCIOFLUSH);
return fd;
}
/* ── 时间工具 ─────────────────────────────────────────────────────── */
static double now_sec(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
}
static void sleep_sec(double seconds) {
if (seconds <= 0.0) return;
struct timespec ts;
ts.tv_sec = (time_t)seconds;
ts.tv_nsec = (long)((seconds - (time_t)seconds) * 1e9);
clock_nanosleep(CLOCK_MONOTONIC, 0, &ts, NULL);
}
/* ── 信号生成 ─────────────────────────────────────────────────────── */
static void generate_samples(
float *samples,
uint32_t sample_index,
uint8_t channels,
uint16_t samples_per_channel,
uint8_t plot_format,
double rate,
double freq,
double amp)
{
if (plot_format == XS_PLOT_FORMAT_XY) {
/* XY 模式: 每对 (cos, sin) 画一个圆 */
for (uint16_t i = 0; i < samples_per_channel; ++i) {
double t = (double)(sample_index + i) / rate;
samples[2 * i] = (float)(amp * cos(2.0 * M_PI * freq * t));
samples[2 * i + 1] = (float)(amp * sin(2.0 * M_PI * freq * t));
}
} else {
/* interleaved 模式: 每个通道相位偏移 */
for (uint16_t i = 0; i < samples_per_channel; ++i) {
double t = (double)(sample_index + i) / rate;
for (uint8_t ch = 0; ch < channels; ++ch) {
double phase = 2.0 * M_PI * (double)ch / (double)channels;
samples[i * channels + ch] = (float)(amp * sin(2.0 * M_PI * freq * t + phase));
}
}
}
}
/* ── 用法 ─────────────────────────────────────────────────────────── */
static void print_usage(const char *prog) {
printf("用法: %s <串口> [选项]\n", prog);
printf("\n选项:\n");
printf(" --baud <n> 波特率 (默认 %d)\n", DEFAULT_BAUDRATE);
printf(" --channels <n> 通道数 (默认 1, --format xy 时强制 2)\n");
printf(" --format <fmt> 格式: interleaved | xy (默认 interleaved)\n");
printf(" --rate <f> 采样率, samples/sec (默认 %.0f)\n", DEFAULT_RATE);
printf(" --freq <f> 正弦频率 Hz (默认 %.1f)\n", DEFAULT_FREQ);
printf(" --amp <f> 振幅 (默认 %.0f)\n", DEFAULT_AMP);
printf(" --text-interval <f> 文本输出间隔秒 (默认 %.1f)\n", DEFAULT_TEXT_INTERVAL);
printf(" --spc <n> 每帧每通道采样数 (默认 1)\n");
printf(" -h, --help 显示帮助\n");
printf("\n支持的波特率: 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600\n");
printf("\n示例:\n");
printf(" %s /dev/ttyUSB0\n", prog);
printf(" %s /dev/ttyUSB0 --baud 921600 --channels 2 --rate 2048 --freq 5\n", prog);
printf(" %s /dev/ttyUSB0 --format xy --amp 200 --text-interval 2.0\n", prog);
printf(" %s /dev/ttyUSB0 --channels 4 --spc 128 --rate 1024\n", prog);
}
/* ── 参数解析 ─────────────────────────────────────────────────────── */
static int parse_args(int argc, char **argv, args_t *args) {
memset(args, 0, sizeof(*args));
args->baudrate = DEFAULT_BAUDRATE;
args->channels = 1;
args->samples_per_channel = 1;
args->plot_format = XS_PLOT_FORMAT_INTERLEAVED;
args->rate = DEFAULT_RATE;
args->freq = DEFAULT_FREQ;
args->amp = DEFAULT_AMP;
args->text_interval = DEFAULT_TEXT_INTERVAL;
enum { OPT_BAUD = 256, OPT_CHANNELS, OPT_FORMAT, OPT_RATE, OPT_FREQ,
OPT_AMP, OPT_TEXT_INTERVAL, OPT_SPC };
static struct option long_opts[] = {
{"baud", required_argument, 0, OPT_BAUD},
{"channels", required_argument, 0, OPT_CHANNELS},
{"format", required_argument, 0, OPT_FORMAT},
{"rate", required_argument, 0, OPT_RATE},
{"freq", required_argument, 0, OPT_FREQ},
{"amp", required_argument, 0, OPT_AMP},
{"text-interval", required_argument, 0, OPT_TEXT_INTERVAL},
{"spc", required_argument, 0, OPT_SPC},
{"help", no_argument, 0, 'h'},
{0, 0, 0, 0}
};
int opt;
while ((opt = getopt_long(argc, argv, "h", long_opts, NULL)) != -1) {
switch (opt) {
case OPT_BAUD:
args->baudrate = atoi(optarg);
break;
case OPT_CHANNELS: {
int v = atoi(optarg);
if (v < 1 || v > MAX_CHANNELS) {
fprintf(stderr, "通道数必须在 1-%d 之间\n", MAX_CHANNELS);
return -1;
}
args->channels = (uint8_t)v;
break;
}
case OPT_FORMAT:
if (strcmp(optarg, "xy") == 0) {
args->plot_format = XS_PLOT_FORMAT_XY;
} else if (strcmp(optarg, "interleaved") == 0) {
args->plot_format = XS_PLOT_FORMAT_INTERLEAVED;
} else {
fprintf(stderr, "未知格式: %s (支持: interleaved, xy)\n", optarg);
return -1;
}
break;
case OPT_RATE:
args->rate = atof(optarg);
break;
case OPT_FREQ:
args->freq = atof(optarg);
break;
case OPT_AMP:
args->amp = atof(optarg);
break;
case OPT_TEXT_INTERVAL:
args->text_interval = atof(optarg);
break;
case OPT_SPC: {
int v = atoi(optarg);
if (v < 1 || v > MAX_SPC) {
fprintf(stderr, "spc 必须在 1-%d 之间\n", MAX_SPC);
return -1;
}
args->samples_per_channel = (uint16_t)v;
break;
}
case 'h':
print_usage(argv[0]);
exit(0);
default:
print_usage(argv[0]);
return -1;
}
}
if (optind >= argc) {
fprintf(stderr, "错误: 缺少串口路径\n");
print_usage(argv[0]);
return -1;
}
args->port = argv[optind];
/* 校验 */
if (args->plot_format == XS_PLOT_FORMAT_XY) {
if (args->channels != 2) {
fprintf(stderr, "错误: --format xy 要求 --channels 2\n");
return -1;
}
}
if (args->channels == 0) {
fprintf(stderr, "错误: --channels 必须 >= 1\n");
return -1;
}
if (args->rate <= 0.0) {
fprintf(stderr, "错误: --rate 必须为正数\n");
return -1;
}
if (args->text_interval <= 0.0) {
fprintf(stderr, "错误: --text-interval 必须为正数\n");
return -1;
}
return 0;
}
/* ── 发送 plot 帧 ─────────────────────────────────────────────────── */
static int send_plot_frame(
int fd,
const float *samples,
uint8_t channels,
uint16_t samples_per_channel,
uint8_t plot_format,
uint8_t *packet_buf,
size_t packet_cap,
uint8_t *frame_buf,
size_t frame_cap)
{
size_t frame_len = 0;
xs_status_t rc = xs_mixed_build_plot_frame_f32(
samples, channels, samples_per_channel, plot_format,
packet_buf, packet_cap,
frame_buf, frame_cap,
&frame_len);
if (rc != XS_OK) {
fprintf(stderr, "构建 plot 帧失败: %d\n", rc);
return -1;
}
size_t written = 0;
while (written < frame_len) {
ssize_t n = write(fd, frame_buf + written, frame_len - written);
if (n < 0) {
perror("write");
return -1;
}
written += (size_t)n;
}
return 0;
}
/* ── 发送文本行 ───────────────────────────────────────────────────── */
static int send_text_line(
int fd,
uint32_t sample_index,
const float *current_samples,
uint8_t channels,
uint16_t samples_per_channel,
const char *format_name)
{
char buf[TEXT_BUF_SIZE];
int pos = 0;
pos += snprintf(buf + pos, sizeof(buf) - pos,
"sample=%-7u", sample_index);
if (channels > 1) {
for (uint8_t ch = 0; ch < channels; ++ch) {
pos += snprintf(buf + pos, sizeof(buf) - pos,
" ch%d=%8.3f", ch + 1, (double)current_samples[ch]);
}
} else {
pos += snprintf(buf + pos, sizeof(buf) - pos,
" value=%8.3f", (double)current_samples[0]);
}
pos += snprintf(buf + pos, sizeof(buf) - pos,
" [fmt=%s, ch=%u, spc=%u]\n",
format_name, channels, samples_per_channel);
/* 确保不超过缓冲 */
if (pos >= (int)sizeof(buf)) pos = (int)sizeof(buf) - 1;
size_t len = (size_t)pos;
size_t written = 0;
while (written < len) {
ssize_t n = write(fd, buf + written, len - written);
if (n < 0) {
perror("write");
return -1;
}
written += (size_t)n;
}
return 0;
}
/* ── 主函数 ───────────────────────────────────────────────────────── */
int main(int argc, char **argv) {
args_t args;
if (parse_args(argc, argv, &args) < 0) {
return 1;
}
/* 打印配置 */
const char *format_name = (args.plot_format == XS_PLOT_FORMAT_XY) ? "xy" : "interleaved";
double frame_interval = (double)args.samples_per_channel / args.rate;
double frame_rate = args.rate / (double)args.samples_per_channel;
printf("═══ xserial Serial Plot Generator ═══\n");
printf("串口: %s @ %d baud\n", args.port, args.baudrate);
printf("通道: %u 格式: %s\n", args.channels, format_name);
printf("频率: %.1f Hz 振幅: %.1f\n", args.freq, args.amp);
printf("采样率: %.0f samples/sec spc: %u\n", args.rate, args.samples_per_channel);
printf("帧间隔: %.3f ms (%.2f fps)\n", frame_interval * 1000.0, frame_rate);
printf("文本间隔: %.1f s\n", args.text_interval);
printf("═══════════════════════════════════════\n");
/* 分配缓冲 */
uint32_t value_count = (uint32_t)args.channels * (uint32_t)args.samples_per_channel;
uint32_t payload_len = value_count * 4;
size_t packet_cap = (size_t)XS_PLOT_HEADER_SIZE + (size_t)payload_len;
size_t frame_cap = 2u + xs_cobs_max_encoded_size(packet_cap) + 1u;
uint8_t *packet_buf = malloc(packet_cap);
uint8_t *frame_buf = malloc(frame_cap);
float *samples = malloc((size_t)value_count * sizeof(float));
if (!packet_buf || !frame_buf || !samples) {
fprintf(stderr, "内存分配失败\n");
free(packet_buf); free(frame_buf); free(samples);
return 1;
}
/* 打开串口 */
int fd = serial_open(args.port, args.baudrate);
if (fd < 0) {
fprintf(stderr, "无法打开串口 %s\n", args.port);
free(packet_buf); free(frame_buf); free(samples);
return 1;
}
printf("已打开 %s\n\n", args.port);
/* 设置信号处理 */
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = sigint_handler;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
/* 主循环 */
uint32_t sample_index = 0;
uint64_t frame_count = 0;
uint64_t text_count = 0;
double started_at = now_sec();
double next_text_at = started_at + args.text_interval;
printf("开始发送 (Ctrl+C 停止) ...\n");
while (g_running) {
/* 生成采样数据 */
generate_samples(samples, sample_index,
args.channels, args.samples_per_channel,
args.plot_format, args.rate, args.freq, args.amp);
/* 发送 plot 帧 */
if (send_plot_frame(fd, samples,
args.channels, args.samples_per_channel,
args.plot_format,
packet_buf, packet_cap,
frame_buf, frame_cap) < 0) {
fprintf(stderr, "发送失败, 退出\n");
break;
}
frame_count++;
/* 检查是否需要发送文本行 */
double now = now_sec();
if (now >= next_text_at) {
if (send_text_line(fd, sample_index, samples,
args.channels, args.samples_per_channel,
format_name) < 0) {
fprintf(stderr, "发送文本失败, 退出\n");
break;
}
text_count++;
next_text_at += args.text_interval;
}
sample_index += args.samples_per_channel;
/* 帧间隔时序控制 */
double elapsed = now_sec() - started_at;
double target = (double)frame_count * frame_interval;
double wait = target - elapsed;
if (wait > 0.0) {
sleep_sec(wait);
}
}
double total_time = now_sec() - started_at;
printf("\n");
printf("═══ 已停止 ═══\n");
printf("运行时间: %.1f 秒\n", total_time);
printf("Plot 帧: %lu (%.1f fps)\n",
(unsigned long)frame_count,
total_time > 0.0 ? (double)frame_count / total_time : 0.0);
printf("文本行: %lu\n", (unsigned long)text_count);
/* 清理 */
tcflush(fd, TCIOFLUSH);
close(fd);
free(packet_buf);
free(frame_buf);
free(samples);
return 0;
}

270
tools/test_text_serial.c Normal file
View File

@@ -0,0 +1,270 @@
/*
* test_text_serial.c — 持续向串口发送纯文本正弦波数据流
*
* 编译 (Linux):
* gcc -std=c11 -Wall -Wextra -O2 -o test_text_serial tools/test_text_serial.c -lm
*
* 用法:
* ./test_text_serial /dev/ttyUSB0
* ./test_text_serial /dev/ttyUSB0 --baud 115200 --channels 2 --rate 100 --freq 2.0 --amp 150
* ./test_text_serial --help
*
* 输出格式 (每行):
* sample=123 ch1=100.000 ch2=0.000
*
* 串口参数: 8N1, 无流控, 默认 115200 baud
*/
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <getopt.h>
#include <math.h>
#include <signal.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <termios.h>
#include <time.h>
#include <unistd.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#define DEFAULT_BAUDRATE 115200
#define DEFAULT_RATE 100.0 /* lines/sec */
#define DEFAULT_FREQ 1.0
#define DEFAULT_AMP 100.0
#define MAX_CHANNELS 8
#define LINE_BUF_SIZE 512
static volatile sig_atomic_t g_running = 1;
static void sigint_handler(int sig) {
(void)sig;
g_running = 0;
}
typedef struct {
const char *port;
int baudrate;
uint8_t channels;
double rate;
double freq;
double amp;
} args_t;
static int serial_open(const char *path, int baudrate) {
int fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK);
if (fd < 0) { perror("open"); return -1; }
int flags = fcntl(fd, F_GETFL, 0);
if (flags < 0) { perror("fcntl"); close(fd); return -1; }
flags &= ~O_NONBLOCK;
if (fcntl(fd, F_SETFL, flags) < 0) { perror("fcntl"); close(fd); return -1; }
struct termios tty;
memset(&tty, 0, sizeof(tty));
if (tcgetattr(fd, &tty) < 0) { perror("tcgetattr"); close(fd); return -1; }
tty.c_iflag = IGNBRK;
tty.c_oflag = 0;
tty.c_cflag = CS8 | CREAD | CLOCAL;
tty.c_lflag = 0;
tty.c_cc[VMIN] = 1;
tty.c_cc[VTIME] = 0;
speed_t speed = B115200;
switch (baudrate) {
case 9600: speed = B9600; break;
case 19200: speed = B19200; break;
case 38400: speed = B38400; break;
case 57600: speed = B57600; break;
case 115200: speed = B115200; break;
case 230400: speed = B230400; break;
case 460800: speed = B460800; break;
case 921600: speed = B921600; break;
default:
fprintf(stderr, "不支持的波特率: %d\n", baudrate);
close(fd);
return -1;
}
cfsetispeed(&tty, speed);
cfsetospeed(&tty, speed);
#ifdef CRTSCTS
tty.c_cflag &= ~CRTSCTS;
#endif
tty.c_iflag &= ~(IXON | IXOFF | IXANY);
if (tcsetattr(fd, TCSANOW, &tty) < 0) {
perror("tcsetattr");
close(fd);
return -1;
}
tcflush(fd, TCIOFLUSH);
return fd;
}
static double now_sec(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
}
static void sleep_sec(double seconds) {
if (seconds <= 0.0) return;
struct timespec ts;
ts.tv_sec = (time_t)seconds;
ts.tv_nsec = (long)((seconds - (time_t)seconds) * 1e9);
clock_nanosleep(CLOCK_MONOTONIC, 0, &ts, NULL);
}
static int format_line(char *buf, size_t cap, uint32_t sample, const float *values, uint8_t channels) {
int pos = snprintf(buf, cap, "sample=%-7u", sample);
for (uint8_t ch = 0; ch < channels; ++ch) {
pos += snprintf(buf + pos, cap - (size_t)pos, " ch%d=%8.3f", ch + 1, (double)values[ch]);
}
pos += snprintf(buf + pos, cap - (size_t)pos, "\n");
return pos >= (int)cap ? (int)cap - 1 : pos;
}
static void print_usage(const char *prog) {
printf("用法: %s <串口> [选项]\n", prog);
printf("\n选项:\n");
printf(" --baud <n> 波特率 (默认 %d)\n", DEFAULT_BAUDRATE);
printf(" --channels <n> 通道数 (默认 1)\n");
printf(" --rate <f> 行/秒 输出速率 (默认 %.0f)\n", DEFAULT_RATE);
printf(" --freq <f> 正弦频率 Hz (默认 %.1f)\n", DEFAULT_FREQ);
printf(" --amp <f> 振幅 (默认 %.0f)\n", DEFAULT_AMP);
printf(" -h, --help 显示帮助\n");
printf("\n示例:\n");
printf(" %s /dev/ttyUSB0\n", prog);
printf(" %s /dev/ttyUSB0 --channels 2 --rate 50 --freq 3\n", prog);
printf(" %s /dev/ttyUSB0 --baud 921600 --rate 1000\n", prog);
}
static int parse_args(int argc, char **argv, args_t *args) {
memset(args, 0, sizeof(*args));
args->baudrate = DEFAULT_BAUDRATE;
args->channels = 1;
args->rate = DEFAULT_RATE;
args->freq = DEFAULT_FREQ;
args->amp = DEFAULT_AMP;
enum { OPT_BAUD = 256, OPT_CHANNELS, OPT_RATE, OPT_FREQ, OPT_AMP };
static struct option long_opts[] = {
{"baud", required_argument, 0, OPT_BAUD},
{"channels", required_argument, 0, OPT_CHANNELS},
{"rate", required_argument, 0, OPT_RATE},
{"freq", required_argument, 0, OPT_FREQ},
{"amp", required_argument, 0, OPT_AMP},
{"help", no_argument, 0, 'h'},
{0, 0, 0, 0}
};
int opt;
while ((opt = getopt_long(argc, argv, "h", long_opts, NULL)) != -1) {
switch (opt) {
case OPT_BAUD: args->baudrate = atoi(optarg); break;
case OPT_CHANNELS: {
int v = atoi(optarg);
if (v < 1 || v > MAX_CHANNELS) {
fprintf(stderr, "通道数必须在 1-%d 之间\n", MAX_CHANNELS);
return -1;
}
args->channels = (uint8_t)v;
break;
}
case OPT_RATE: args->rate = atof(optarg); break;
case OPT_FREQ: args->freq = atof(optarg); break;
case OPT_AMP: args->amp = atof(optarg); break;
case 'h': print_usage(argv[0]); exit(0);
default: print_usage(argv[0]); return -1;
}
}
if (optind >= argc) {
fprintf(stderr, "错误: 缺少串口路径\n");
print_usage(argv[0]);
return -1;
}
args->port = argv[optind];
if (args->rate <= 0.0) { fprintf(stderr, "错误: --rate 必须为正数\n"); return -1; }
return 0;
}
int main(int argc, char **argv) {
args_t args;
if (parse_args(argc, argv, &args) < 0) return 1;
double interval = 1.0 / args.rate;
printf("═══ xserial Plain Text Generator ═══\n");
printf("串口: %s @ %d baud\n", args.port, args.baudrate);
printf("通道: %u 振幅: %.1f 频率: %.1f Hz\n", args.channels, args.amp, args.freq);
printf("速率: %.0f lines/sec (间隔 %.3f ms)\n", args.rate, interval * 1000.0);
printf("═════════════════════════════════════\n");
float values[MAX_CHANNELS];
char line[LINE_BUF_SIZE];
int fd = serial_open(args.port, args.baudrate);
if (fd < 0) { fprintf(stderr, "无法打开串口 %s\n", args.port); return 1; }
printf("已打开 %s\n\n开始发送 (Ctrl+C 停止) ...\n", args.port);
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = sigint_handler;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
uint32_t sample_index = 0;
uint64_t line_count = 0;
double started_at = now_sec();
while (g_running) {
double t = (double)sample_index / args.rate;
for (uint8_t ch = 0; ch < args.channels; ++ch) {
double phase = 2.0 * M_PI * (double)ch / (double)args.channels;
values[ch] = (float)(args.amp * sin(2.0 * M_PI * args.freq * t + phase));
}
int len = format_line(line, sizeof(line), sample_index, values, args.channels);
size_t written = 0;
while (written < (size_t)len) {
ssize_t n = write(fd, line + written, (size_t)len - written);
if (n < 0) { perror("write"); goto cleanup; }
written += (size_t)n;
}
line_count++;
double elapsed = now_sec() - started_at;
double target = (double)line_count * interval;
double wait = target - elapsed;
if (wait > 0.0) sleep_sec(wait);
sample_index++;
}
cleanup: {
double total_time = now_sec() - started_at;
printf("\n═══ 已停止 ═══\n");
printf("运行时间: %.1f 秒\n", total_time);
printf("行数: %lu (%.1f lines/sec)\n",
(unsigned long)line_count,
total_time > 0.0 ? (double)line_count / total_time : 0.0);
tcflush(fd, TCIOFLUSH);
close(fd);
return 0;
}
}