feat: Add DTR/RTS control for serial ports with GUI toggles

- Assert DTR/RTS after serial port open to prevent wireless modules
  (HC-12/HC-15/Bluetooth bridges) from entering AT command mode after
  the unavoidable Linux kernel DTR pulse on every open() call.
- Add dtr/rts fields to TransportConfig::Serial (default: false).
- Add set_dtr()/set_rts() on SerialTransport, Connection, SessionHandle.
- Add SessionCmd::SetDtr/SetRts for live toggling via command channel.
- Add DTR/RTS checkboxes to GUI session controls (visible when connected)
  and to config form for persistence.
- Add test_plot_serial.c and test_text_serial.c — standalone C test
  tools for sending MixedTextPlot frames and plain text over serial.
- Update AGENTS.md with build prerequisites, architecture map, testing
  conventions, env vars, and known quirks.
This commit is contained in:
2026-06-11 14:18:39 +08:00
parent 09bee86a08
commit 1fec5f8941
11 changed files with 1154 additions and 20 deletions

144
AGENTS.md
View File

@@ -1,28 +1,134 @@
# Repository Guidelines
## Project Structure & Module Organization
`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`.
## Build Prerequisites
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
- `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
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`.
```bash
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
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
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.
- **Inline unit tests** (`#[cfg(test)] mod tests`) next to implementation — 24 source files with `#[test]` or `#[tokio::test]`.
- **Integration tests** in `crates/<crate>/tests/*.rs` — 3 files covering pipeline, session lifecycle, and Lua bindings.
- **~266 tests total** — all active (zero `#[ignore]`). Single `#[should_panic]` in `frame/fixed.rs`.
- **All tests are self-contained**: TCP/UDP bind to `127.0.0.1:0` (OS-assigned port). No hardcoded ports. No external services. Serial tests use dummy port names and never open real hardware.
- **Async tests** use `#[tokio::test]` (no `async-std`). **No benchmarks** exist.
- Run `cargo test --workspace` before opening PRs. Add targeted regression tests for transport, framing, parsing, and session-state fixes.
## Known Quirks & Constraints
- **No CI** — no `.github/workflows` or other CI config. Agents must self-verify with `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings`.
- **No feature flags** — zero `[features]` in any `Cargo.toml`. No `#[cfg(feature)]` in any source.
- **No build scripts** — zero `build.rs` files. No codegen. No `include!`/`include_str!`/`include_bytes!`.
- **All `unsafe` is test-only** — constructing noop wakers for poll-based unit tests in `transport/`. No non-test `unsafe` anywhere.
- **`xserial-tui` is a stub** — no tests, minimal implementation. GUI is the primary target.
- **Platform font directories** use `#[cfg(target_os)]` in `ui_fonts.rs` (three blocks: Windows, macOS, Linux/BSD).
- **`tokio` features differ by build**: `xserial-client` uses `["sync", "time"]` for production, `["full"]` for dev-dependencies (tests need net/IO).
## Commit & PR Guidelines
Short, imperative subjects (`Persist GUI font settings`), occasional `feat:` prefix for major additions. PRs: state which crate(s) changed, list validation commands, include screenshots/recordings for GUI/TUI changes. Explicitly call out protocol, transport, or Lua API compatibility impacts.
## Reference
- `tools/test_plot.py --help` — TCP plot-frame generator options.
- `tools/xs_mixed_plot.h` — wire format spec for MixedTextPlot (COBS-framed binary plot + text on one stream).

3
Cargo.lock generated
View File

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

View File

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

View File

@@ -138,6 +138,22 @@ impl SessionHandle {
.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> {
let mut rx = self.inner.event_tx.subscribe();
let deadline = Duration::from_millis(timeout_ms);
@@ -304,6 +320,14 @@ impl Session {
self.handle_reconfigure(config).await;
true
}
Some(SessionCmd::SetDtr(state)) => {
self.handle_set_dtr(state);
true
}
Some(SessionCmd::SetRts(state)) => {
self.handle_set_rts(state);
true
}
None => false,
}
}
@@ -332,6 +356,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(
&mut self,
result: Result<Vec<DecodedEntry>, std::io::Error>,

View File

@@ -30,6 +30,14 @@ fn default_serial_flow_control() -> SerialFlowControl {
SerialFlowControl::None
}
fn default_serial_dtr() -> bool {
false
}
fn default_serial_rts() -> bool {
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransportType {
Serial,
@@ -60,6 +68,10 @@ pub enum TransportConfig {
stop_bits: SerialStopBits,
#[serde(default = "default_serial_flow_control")]
flow_control: SerialFlowControl,
#[serde(default = "default_serial_dtr")]
dtr: bool,
#[serde(default = "default_serial_rts")]
rts: bool,
},
Tcp {
addr: String,
@@ -96,6 +108,8 @@ impl Connection {
parity,
stop_bits,
flow_control,
dtr,
rts,
} => Connection::Serial(SerialTransport::new(
port,
baud_rate,
@@ -103,6 +117,8 @@ impl Connection {
parity,
stop_bits,
flow_control,
dtr,
rts,
)),
TransportConfig::Tcp { addr } => Connection::Tcp(TcpTransport::new(addr)),
TransportConfig::Udp {
@@ -153,6 +169,28 @@ impl Connection {
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 {
@@ -220,6 +258,8 @@ mod tests {
parity: SerialParity::None,
stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
}
}
@@ -312,6 +352,8 @@ mod tests {
parity: SerialParity::None,
stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
};
let _ = format!("{:?}", cfg);
@@ -371,6 +413,8 @@ mod tests {
parity,
stop_bits,
flow_control,
dtr,
rts,
} => {
assert_eq!(port, "COM1");
assert_eq!(baud_rate, 9600);
@@ -378,6 +422,8 @@ mod tests {
assert_eq!(parity, SerialParity::None);
assert_eq!(stop_bits, SerialStopBits::One);
assert_eq!(flow_control, SerialFlowControl::None);
assert!(!dtr);
assert!(!rts);
}
_ => panic!("expected Serial variant"),
}
@@ -395,6 +441,8 @@ mod tests {
parity,
stop_bits,
flow_control,
dtr,
rts,
} => {
assert_eq!(port, "COM2");
assert_eq!(baud_rate, 57600);
@@ -402,6 +450,8 @@ mod tests {
assert_eq!(parity, SerialParity::Even);
assert_eq!(stop_bits, SerialStopBits::Two);
assert_eq!(flow_control, SerialFlowControl::Hardware);
assert!(!dtr);
assert!(!rts);
}
_ => panic!("expected Serial variant"),
}

View File

@@ -1,6 +1,7 @@
use async_trait::async_trait;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits};
use serialport::SerialPort;
use tracing::{debug, info, warn};
use super::{Transport, TransportType};
@@ -83,9 +84,12 @@ pub struct SerialTransport {
parity: SerialParity,
stop_bits: SerialStopBits,
flow_control: SerialFlowControl,
dtr: bool,
rts: bool,
}
impl SerialTransport {
#[allow(clippy::too_many_arguments)]
pub fn new(
port_name: String,
baud_rate: u32,
@@ -93,6 +97,8 @@ impl SerialTransport {
parity: SerialParity,
stop_bits: SerialStopBits,
flow_control: SerialFlowControl,
dtr: bool,
rts: bool,
) -> Self {
Self {
port: None,
@@ -102,6 +108,8 @@ impl SerialTransport {
parity,
stop_bits,
flow_control,
dtr,
rts,
}
}
@@ -138,6 +146,36 @@ impl SerialTransport {
pub fn flow_control(&self) -> SerialFlowControl {
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 {
@@ -221,7 +259,7 @@ impl Transport for SerialTransport {
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())
.parity(self.parity.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);
self.port = Some(port);
Ok(())
@@ -282,6 +331,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.port_name(), "COM1");
assert_eq!(transport.baud_rate(), 115200);
@@ -296,6 +347,8 @@ mod tests {
SerialParity::Even,
SerialStopBits::Two,
SerialFlowControl::Hardware,
true,
true,
);
assert_eq!(transport.port_name(), "COM3");
assert_eq!(transport.baud_rate(), 9600);
@@ -314,6 +367,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.name(), "COM1");
}
@@ -327,6 +382,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.transport_type(), TransportType::Serial);
}
@@ -340,6 +397,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert!(!transport.is_connected());
}
@@ -368,6 +427,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
let pinned = Pin::new(&mut transport);
let mut buf_data = [0u8; 16];
@@ -391,6 +452,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
let pinned = Pin::new(&mut transport);
let data = b"hello";
@@ -411,6 +474,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
let pinned = Pin::new(&mut transport);
let waker = noop_waker();
@@ -430,6 +495,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
let pinned = Pin::new(&mut transport);
let waker = noop_waker();
@@ -451,6 +518,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.name(), "COM1");
}
@@ -464,6 +533,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.transport_type(), TransportType::Serial);
}
@@ -477,6 +548,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert!(!transport.is_connected());
}

View File

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

View File

@@ -64,6 +64,8 @@ pub struct SessionTab {
pub plot_view: plot_view::PlotViewState,
pub view: View,
pub auto_reconnect: bool,
pub dtr: bool,
pub rts: bool,
pub send_input: String,
pub send_mode: SendMode,
pub append_newline: bool,
@@ -183,6 +185,10 @@ impl XserialApp {
fn add_session_tab(&mut self, session_config: SessionConfig) {
let history_limit = session_config.history_limit;
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());
self.tabs.push(SessionTab {
id: handle.id(),
@@ -194,6 +200,8 @@ impl XserialApp {
plot_view: plot_view::PlotViewState::default(),
view: View::Text,
auto_reconnect,
dtr,
rts,
send_input: String::new(),
send_mode: SendMode::Text,
append_newline: true,
@@ -920,6 +928,22 @@ fn render_session_controls(
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
if matches!(tab.status, ConnectionStatus::Connected) {
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
}
@@ -1062,6 +1086,8 @@ mod tests {
parity: SerialParity::None,
stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
}),
"Serial COM7"
);

View File

@@ -17,6 +17,8 @@ pub struct ConfigForm {
pub serial_parity: SerialParity,
pub serial_stop_bits: SerialStopBits,
pub serial_flow_control: SerialFlowControl,
pub serial_dtr: bool,
pub serial_rts: bool,
pub addr: String,
pub udp_bind: String,
pub udp_remote: String,
@@ -116,6 +118,8 @@ impl Default for ConfigForm {
serial_parity: SerialParity::None,
serial_stop_bits: SerialStopBits::One,
serial_flow_control: SerialFlowControl::None,
serial_dtr: false,
serial_rts: false,
addr: String::from("127.0.0.1:8080"),
udp_bind: String::from("0.0.0.0:9000"),
udp_remote: String::new(),
@@ -158,6 +162,14 @@ impl ConfigForm {
TransportConfig::Serial { flow_control, .. } => *flow_control,
_ => 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 {
TransportConfig::Tcp { addr } => addr.clone(),
_ => String::from("127.0.0.1:8080"),
@@ -190,6 +202,8 @@ impl ConfigForm {
parity: self.serial_parity,
stop_bits: self.serial_stop_bits,
flow_control: self.serial_flow_control,
dtr: self.serial_dtr,
rts: self.serial_rts,
},
TransportChoice::Tcp => TransportConfig::Tcp {
addr: self.addr.clone(),
@@ -484,6 +498,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 => {
ui.horizontal(|ui| {

540
tools/test_plot_serial.c Normal file
View File

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

270
tools/test_text_serial.c Normal file
View File

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