Compare commits
3 Commits
09bee86a08
...
dc8f2eb8a4
| Author | SHA1 | Date | |
|---|---|---|---|
| dc8f2eb8a4 | |||
| edec8113fb | |||
| 1fec5f8941 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1 +1,5 @@
|
|||||||
/target
|
/target
|
||||||
|
|
||||||
|
# Compiled binaries in tools/
|
||||||
|
tools/test_plot_c
|
||||||
|
tools/test_text_c
|
||||||
|
|||||||
144
AGENTS.md
144
AGENTS.md
@@ -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
3
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ pub enum SessionCmd {
|
|||||||
SetAutoReconnect(bool),
|
SetAutoReconnect(bool),
|
||||||
Close,
|
Close,
|
||||||
Reconfigure(SessionConfig),
|
Reconfigure(SessionConfig),
|
||||||
|
SetDtr(bool),
|
||||||
|
SetRts(bool),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
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,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
540
tools/test_plot_serial.c
Normal 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
270
tools/test_text_serial.c
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user