Compare commits

..

7 Commits

Author SHA1 Message Date
e600418da8 Detach GUI from terminal on linux, hide console on Windows 2026-06-13 14:35:25 +08:00
e939b16d2d Rewrite README in Chinese and English, add drone examples
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-12 22:32:58 +08:00
a2c7c0fa71 Fix all clippy warnings across workspace
collapsible_if, let-chains, derivable_impls, clone_on_copy, useless_vec, approx_constant, io_other_error, single_match, collapsible_match, unnecessary_map_or, manual_is_multiple_of, result_large_err, too_many_arguments, upper_case_acronyms

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-12 21:09:47 +08:00
ab1c5882d1 Add log-to-file feature with background writer
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-12 20:58:44 +08:00
2efbc9c284 Add horizontal scroll and enforce white text in console and hex views
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-12 20:34:32 +08:00
b3acb02d52 Add LineEnding enum with ComboBox UI for send panel
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-12 20:31:52 +08:00
afb8ca509a feat: add search for received data in Text and Hex views
- Add search() method to TextBuffer and HexBuffer
- Add SearchState with match tracking, prev/next navigation
- Highlight matched text with yellow background via LayoutJob
- Search bar UI with case-sensitive toggle and match counter
- Keyboard shortcuts: Ctrl+F (open), F3 (next), Shift+F3 (prev)
- Auto-focus input on Ctrl+F
- Update README with comprehensive project documentation
2026-06-12 02:42:17 +08:00
70 changed files with 1961 additions and 721 deletions

3
.gitignore vendored
View File

@@ -3,3 +3,6 @@
# Compiled binaries in tools/
tools/test_plot_c
tools/test_text_c
# Python
__pycache__/

134
AGENTS.md
View File

@@ -1,134 +0,0 @@
# Repository Guidelines
## Build Prerequisites
- **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
```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
- **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).

136
Cargo.lock generated
View File

@@ -3262,6 +3262,74 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pipeview-client"
version = "0.1.0"
dependencies = [
"mlua",
"pipeview-core",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "pipeview-core"
version = "0.1.0"
dependencies = [
"async-trait",
"bytes",
"hex",
"nom 8.0.0",
"serde",
"serde_json",
"serialport",
"thiserror 2.0.18",
"tokio",
"tokio-serial",
"tracing",
]
[[package]]
name = "pipeview-gui"
version = "0.1.0"
dependencies = [
"eframe",
"egui",
"egui_plot",
"hex",
"pipeview-client",
"pipeview-core",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "pipeview-tui"
version = "0.1.0"
dependencies = [
"clap",
"crossterm 0.28.1",
"hex",
"image",
"mlua",
"pipeview-client",
"pipeview-core",
"ratatui",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
@@ -5750,74 +5818,6 @@ version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "xserial-client"
version = "0.1.0"
dependencies = [
"mlua",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-subscriber",
"xserial-core",
]
[[package]]
name = "xserial-core"
version = "0.1.0"
dependencies = [
"async-trait",
"bytes",
"hex",
"nom 8.0.0",
"serde",
"serde_json",
"serialport",
"thiserror 2.0.18",
"tokio",
"tokio-serial",
"tracing",
]
[[package]]
name = "xserial-gui"
version = "0.1.0"
dependencies = [
"eframe",
"egui",
"egui_plot",
"hex",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
"xserial-client",
"xserial-core",
]
[[package]]
name = "xserial-tui"
version = "0.1.0"
dependencies = [
"clap",
"crossterm 0.28.1",
"hex",
"image",
"mlua",
"ratatui",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"xserial-client",
"xserial-core",
]
[[package]]
name = "yoke"
version = "0.8.2"

View File

@@ -1,10 +1,10 @@
[workspace]
resolver = "2"
members = [
"crates/xserial-core",
"crates/xserial-client",
"crates/xserial-tui",
"crates/xserial-gui",
"crates/pipeview-core",
"crates/pipeview-client",
"crates/pipeview-tui",
"crates/pipeview-gui",
]
[workspace.dependencies]

478
README.md
View File

@@ -1,166 +1,394 @@
# xserial
# pipeview
`xserial` 是一个基于 Rust workspace 的串口 / TCP / UDP 数据观测工具,支持可配置分帧、协议解码、会话管理,以及以文本、十六进制和绘图为核心的桌面 GUI
**跨平台串口 / TCP / UDP 数据观测工具**可配置分帧、协议解码、会话管理、实时波形绘图,单二进制零依赖
## 工作区结构
基于 Rust + [egui](https://github.com/emilk/egui) 构建,支持 Lua 脚本扩展。
仓库当前包含 4 个 crate
[English](README_en.md)
- `xserial-core`
负责传输层、分帧器、协议解码器和 pipeline 基础能力。
- `xserial-client`
负责会话生命周期、配置、事件分发、历史缓冲和 Lua 相关接口。
- `xserial-gui`
当前主要使用入口,基于 `egui` / `eframe`
- `xserial-tui`
终端 UI crate目前还只是占位实现。
---
## 当前功能
## 目录
- 在一个 GUI 中管理多个 session。
- 支持 `Serial``TCP``UDP` 三种传输方式。
- 支持以下 framer
`Line``Fixed``Length``Cobs``MixedTextPlot`
- 支持以下 decoder
`Text``Hex``Plot``MixedTextPlot`
- 每个 session 可在 `Text``Hex``Plot` 三种视图之间切换。
- 支持单连接 mixed text + plot 数据流。
- 支持 `Interleaved``Block``XY` 三种 plot 格式。
- 可在 sidebar 中直接编辑 session 配置。
- plot 视图支持嵌入主界面,也支持在 GUI 内部弹出为浮动窗口。
- [功能特性](#功能特性)
- [快速开始](#快速开始)
- [完整示例:无人机遥测解析](#完整示例无人机遥测解析)
- [Lua 脚本开发指南](#lua-脚本开发指南)
- [架构](#架构)
- [配置与持久化](#配置与持久化)
- [开发工具](#开发工具)
- [技术栈](#技术栈)
- [License](#license)
## 构建
---
## 功能特性
### 传输层
| 类型 | 说明 | 配置项 |
|------|------|--------|
| **Serial** | 串口通信 | 端口名、波特率 (30012M)、数据位 (5/6/7/8)、校验位 (None/Odd/Even)、停止位 (1/2)、流控 (None/Software/Hardware)、DTR/RTS 控制 |
| **TCP** | TCP 客户端 | 目标地址 `host:port` |
| **UDP** | UDP 通信 | 绑定地址 `host:port`,可选远端地址 |
### 分帧器Framer
将原始字节流切分为独立帧。每个 Session 可配置**多条独立管线**,同一字节流并行送入多个 framer→decoder 链路。
| 分帧器 | 说明 | 配置参数 |
|--------|------|----------|
| **Line** | 按 `\n` 分割文本行 | `strip_cr`(去除 `\r`)、`max_line_len`(最大行长度) |
| **Fixed** | 固定字节数为一帧 | `frame_len`(帧长度) |
| **Length** | 长度前缀协议 | `len_bytes` (1/2/4/8)、`endian` (大小端)、`length_includes_self``max_payload` |
| **COBS** | [Consistent Overhead Byte Stuffing](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) 编码 | `max_frame`(最大帧长) |
| **MixedTextPlot** | 单连接混合文本行 + COBS 编码的 plot 帧 | `strip_cr``max_line_len``max_plot_frame` |
| **Lua** | 用户自定义分帧脚本 | `script_path`Lua 脚本路径) |
### 解码器Decoder
将帧数据解析为可展示的内容。
| 解码器 | 输出类型 | 配置参数 |
|--------|----------|----------|
| **Text** | 文本 | `encoding`UTF-8 / Latin1 / ASCII |
| **Hex** | 十六进制 | `uppercase``separator``bytes_per_group``endian` |
| **Plot** | 波形数据 | `sample_type` (i8f64)、`endian``channels` (164)、`format` (Interleaved / Block / XY) |
| **MixedTextPlot** | 混合文本 + 波形 | `encoding` |
| **Lua** | 自定义 | `script_path`Lua 脚本路径) |
**Plot 采样格式:**
| 格式 | 说明 | 字节排列 |
|------|------|----------|
| **Interleaved** | 多通道交叉排列 | `[ch0_s0, ch1_s0, ch2_s0, ch0_s1, ch1_s1, …]` |
| **Block** | 按通道分块排列 | `[ch0_s0, ch0_s1, …, ch1_s0, ch1_s1, …]` |
| **XY** | 2 通道交替 x/y | `[x0, y0, x1, y1, …]` |
支持的采样类型:`i8``u8``i16``u16``i32``u32``i64``u64``f32``f64`
### 视图
- **文本视图** — 带时间戳、方向标记 (`[IN]`/`[OUT]`)、管线标签的格式化文本流。支持搜索(`Ctrl+F`)、大小写匹配、匹配计数和高亮。
- **十六进制视图** — hex dump 与 ASCII 侧栏并排展示,可按分组和大小端解析多字节数值。
- **波形视图** — 基于 [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot) 的实时波形。支持自动缩放、框选缩放、坐标轴锁定、跟随最新数据、浮动窗口、通道图例。
### 会话管理
- **多会话并发** — 标签页切换,每个 Session 独立配置
- **连接控制** — Connect / Disconnect / Reconnect支持断线自动重连
- **运行时重配置** — 修改分帧器/解码器参数无需断开连接
- **数据发送** — Text 模式UTF-8可选换行符和 Hex 模式(十六进制字节)。支持 `None` / `LF` / `CR` / `CRLF` 四种行尾
- **环形缓冲** — 默认 10,000 条历史记录,可配置 1001,000,000
- **日志到文件** — 每行数据实时写入1KB 缓冲 + 后台线程,不阻塞 UI
- **搜索** — `Ctrl+F` 呼出搜索栏大小写敏感切换F3/Shift+F3 前后跳转
### Lua 脚本扩展
内置 LuaJIT 运行时:
- **自定义分帧器** — 实现 `feed(bytes)``flush()``reset()``pending_len()` 四个函数
- **自定义解码器** — 实现 `decode(frame)` 函数,返回 text/hex/plot/binary 四种类型
- **会话 API** — `pipeview.open()` 创建 Session、`session:on_data()` 事件回调、`session:send()` 发送数据
- **工具函数** — `pipeview.list_ports()``pipeview.sleep(ms)``pipeview.poll(limit)``pipeview.log(msg)`
### 快捷键
| 快捷键 | 操作 |
|--------|------|
| `Ctrl+N` | 新建会话 |
| `Ctrl+E` | 编辑当前会话 |
| `Ctrl+W` | 删除当前会话 |
| `Ctrl+F5` | 切换连接 |
| `Ctrl+T` / `Ctrl+H` / `Ctrl+P` | 切换到文本 / 十六进制 / 波形视图 |
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | 下一个 / 上一个标签页 |
| `Ctrl+L` | 清空输出 |
| `Ctrl+F` | 搜索 |
| `F3` / `Shift+F3` | 上一个 / 下一个匹配 |
| `Ctrl+,` | UI 设置 |
| `Esc` | 关闭浮层 |
---
## 快速开始
### 前置条件
| 平台 | 依赖 |
|------|------|
| **Linux** | `libudev-dev``apt install libudev-dev` |
| **macOS** | 无需额外依赖 |
| **Windows** | 无需额外依赖 |
| **所有平台** | Rust ≥ 1.85、C 编译器GCC / Clang / MSVC |
### 构建与运行
```bash
cargo check --workspace
```
# 克隆项目
git clone https://github.com/your-org/pipeview.git
cd pipeview
## 运行
# 编译运行
cargo run -p pipeview-gui
启动 GUI
```bash
cargo run -p xserial-gui
```
当前 `xserial-tui` 还不是完整实现:
```bash
cargo run -p xserial-tui
```
## 测试
运行整个 workspace 的测试:
```bash
cargo test --workspace
```
常用本地检查命令:
```bash
cargo fmt --all
# 运行测试
cargo test --workspace # ~308 个测试
cargo clippy --workspace --all-targets -- -D warnings
```
## GUI 快速开始
1. 启动 `xserial-gui`
2. 在 sidebar 中创建一个 session。
3. 选择传输方式:`Serial``TCP``UDP`
4. 添加一个或多个 pipeline并选择匹配的 framer / decoder。
5. 连接 session。
6.`Text``Hex``Plot` 视图之间切换。
补充说明:
- session 配置入口在 sidebar 的齿轮按钮。
- 连接控制是单个切换按钮,不再区分独立的 `Connect` / `Disconnect`
- plot 视图支持内嵌和浮动窗口两种显示方式。
## Plot 测试服务器
仓库自带一个本地波形测试工具:
### 发布构建
```bash
python tools/test_plot.py --help
cargo build -p pipeview-gui --release
# 二进制位于 target/release/pipeview-gui (Linux/macOS)
# 或 target/release/pipeview-gui.exe (Windows)
```
示例:通过 TCP 发送 mixed text + plot 数据流:
---
## 完整示例:无人机遥测解析
以 Betaflight/INAV 飞控遥测为例,数据格式为:
```
AHRS q:1.0000,0.0998,0.0499,0.0200|YPR:8.87,4.44,2.96|Gyro:14.78,8.87,5.92|RC:1559,1544,1500,1519|M:1612,1588,1603,1597|L:0 F:1 C:0
```
### 步骤 1启动测试数据源
```bash
python tools/test_drone.py --rate 10 --port 8091
```
输出:
```
Listening on 127.0.0.1:8091, waiting for connections...
```
### 步骤 2配置 Session
在 pipeview-gui 中创建 Session
| 配置项 | 值 |
|--------|-----|
| Transport | `TCP 127.0.0.1:8091` |
| Pipeline 1 | Framer: `Line` → Decoder: `Lua` → 选择 `examples/drone_text.lua` |
| Pipeline 2 | Framer: `Line` → Decoder: `Lua` → 选择 `examples/drone_plot.lua` |
### 步骤 3查看结果
- **文本视图** — 显示格式化的传感器数据
- **波形视图** — 显示 Gyro 三轴实时曲线 (gz/gy/gx)
`examples/drone_plot.lua` 的核心逻辑:
```lua
return {
decode = function(frame)
local gz, gy, gx = frame:match("Gyro:([%d%.%-]+),([%d%.%-]+),([%d%.%-]+)")
return {
kind = "plot",
channels = { { tonumber(gz) }, { tonumber(gy) }, { tonumber(gx) } },
sample_type = "F64",
format = "Block",
}
end,
}
```
---
## Lua 脚本开发指南
### 分帧器 API
分帧器将原始字节流切分为帧Lua 脚本必须返回包含以下函数的 table
```lua
return {
-- 输入新到达的字节返回帧数组Lua strings
feed = function(bytes)
-- bytes: Lua string原始字节
-- 返回: { frame1, frame2, ... } 或 nil
end,
-- 刷新缓冲区中残留的数据
flush = function()
-- 返回: 最后一帧Lua string或 nil
end,
-- 重置内部状态
reset = function()
end,
-- 返回缓冲区中待处理的字节数
pending_len = function()
-- 返回: number
end,
}
```
参考实现:`tests/lua_line_framer.lua`(按 `\n` 分割的行分帧器)
### 解码器 API
解码器将帧解析为结构化数据Lua 脚本必须返回包含 `decode` 函数的 table
```lua
return {
decode = function(frame)
-- frame: Lua string来自分帧器的一帧
-- 返回 nil → 跳过此帧
-- 返回 string → 自动视为 Text
-- 返回 table → 必须包含 kind 字段
end,
}
```
**返回值格式:**
| `kind` | 必需字段 | 可选字段 | 用途 |
|--------|----------|----------|------|
| `"text"` | `data: string` | — | 文本视图 |
| `"hex"` | `data: string` | — | 十六进制视图 |
| `"binary"` | `data: string` | — | 原始二进制 |
| `"plot"` | `channels: {{number,…},…}` | `sample_type``format` | 波形视图 |
**Plot 返回值示例:**
```lua
return {
kind = "plot",
channels = {
{ 1.0, 2.0, 3.0 }, -- channel 0
{ 4.0, 5.0, 6.0 }, -- channel 1
},
sample_type = "F64", -- 默认 F64可选 I8/U8/I16/U16/I32/U32/I64/U64/F32
format = "Block", -- 默认 Interleaved可选 Block/XY
}
```
更多示例见 `examples/` 目录。
---
## 架构
```
┌──────────────────────────────────────────────────┐
│ pipeview-gui (egui) pipeview-tui (ratatui) │
├──────────────────────────────────────────────────┤
│ pipeview-client │
│ SessionManager · Session · Config · History │
│ Lua Runtime (mlua / LuaJIT) │
├──────────────────────────────────────────────────┤
│ pipeview-core │
│ Transport ──▶ Frame ──▶ Protocol ──▶ Pipeline │
└──────────────────────────────────────────────────┘
```
| Crate | 类型 | 职责 |
|-------|------|------|
| `pipeview-core` | library | 传输层Serial/TCP/UDP、分帧器Line/Fixed/Length/COBS/Mixed/Lua、协议解码器Text/Hex/Plot、MultiPipeline |
| `pipeview-client` | library | Session 生命周期管理、SessionManager、事件广播tokio broadcast、RingBuffer 历史、Lua 运行时及会话 API |
| `pipeview-gui` | binary | egui 桌面应用,包含 sidebar/config/console/text/hex/plot 面板、键盘快捷键、字体管理、性能分析 |
| `pipeview-tui` | binary | ratatui 终端应用(功能未对齐 GUI仍在开发中 |
**依赖方向:** `core ← client ← {gui, tui}`
**数据流:**
```
[Transport] → read bytes → [MultiPipeline]
→ Pipeline 1: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
→ Pipeline 2: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
→ ...
```
每条管线独立分帧、解码,互不干扰。只有成功解码的管线产生输出。
---
## 配置与持久化
GUI 状态自动保存,路径遵循各平台规范:
| 平台 | 路径 |
|------|------|
| Linux | `$XDG_CONFIG_HOME/pipeview/gui-state.json``~/.config/pipeview/gui-state.json` |
| macOS | `~/Library/Application Support/pipeview/gui-state.json` |
| Windows | `%APPDATA%\pipeview\gui-state.json` |
持久化的内容包括Session 配置(传输参数、管线设置)、日志开关及路径、活动标签页、显示选项。
日志文件默认保存在配置目录的 `logs/` 子目录下,文件命名格式为 `session_{id}_{timestamp}.log`
---
## 开发工具
### 测试数据生成器
```bash
# Plot 波形测试数据
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
```
GUI 中对应配置为:
- Transport`TCP 127.0.0.1:8091`
- Framer`MixedTextPlot`
- Decoder`MixedTextPlot`
示例:发送 `XY` plot 数据:
```bash
python tools/test_plot.py --wire-format mixed --format xy --channels 2
```
示例:发送固定长度 raw plot 帧:
```bash
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
# 无人机遥测测试数据
python tools/test_drone.py --rate 10 --port 8092
```
GUI 中对应配置为:
- Framer`Fixed`
- Decoder`Plot`
- Sample type`F32`
- Endian`Little`
- `Channels` / `Format` 与脚本参数保持一致
## Profiling
GUI 内置了一套轻量级性能日志,默认关闭。
开启方式:
### 性能分析
```bash
RUST_LOG=xserial_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p xserial-gui
RUST_LOG=pipeview_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p pipeview-gui
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p pipeview-gui
```
修改统计输出间隔:
输出每帧的耗时、事件 drain 耗时、text/hex/plot 渲染耗时、plot 点数统计。
### 日志
```bash
RUST_LOG=xserial_gui::perf=info XSERIAL_GUI_PROFILE=1 XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p xserial-gui
RUST_LOG=info cargo run -p pipeview-gui # 应用日志
RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui # 详细日志
```
日志会输出:
使用 `tracing-subscriber` + `RUST_LOG` 环境变量控制。
- frame 耗时
- 事件 drain 耗时
- text / hex / plot 渲染耗时
- plot 点数量统计
### 项目结构
## 仓库目录
```text
```
crates/
xserial-core/
xserial-client/
xserial-gui/
xserial-tui/
tools/
test_plot.py
pipeview-core/ # 传输、分帧、协议
pipeview-client/ # 会话管理、Lua 运行时
pipeview-gui/ # egui 桌面应用
pipeview-tui/ # ratatui 终端应用
examples/ # Lua 脚本示例
drone_plot.lua # 飞控遥测波形解码器
drone_text.lua # 飞控遥测文本解码器
tests/ # Lua 测试 fixture
tools/ # 开发辅助工具
test_plot.py # 波形测试数据生成器
test_drone.py # 飞控测试数据生成器
test_plot_serial.c # C 串口波形测试客户端
test_text_serial.c # C 串口文本测试客户端
xs_mixed_plot.h # MixedTextPlot 协议参考头文件
```
## 开发约定
---
- 传输层、协议和 pipeline 逻辑优先放在 `xserial-core`
- 会话管理和状态编排优先放在 `xserial-client`
- UI 相关状态放在 `xserial-gui``xserial-tui`
- 集成测试位于 `crates/*/tests`
## 技术栈
## 当前状态
- **运行时**tokio (multi-thread)
- **串口**`tokio-serial` + `serialport`
- **GUI**egui + egui_plot
- **Lua**mlua 0.11, LuaJIT (vendored 编译), async/serde/send
- **事件分发**`tokio::sync::broadcast`(多订阅者)
- **零 feature flags**、零 build script、零条件编译
目前项目中最完整的是 GUI 路径。`xserial-tui` 已经在 workspace 中,但还没有达到和 GUI 同等的可用程度。
## License
MIT

378
README_en.md Normal file
View File

@@ -0,0 +1,378 @@
# pipeview
**Cross-platform serial / TCP / UDP data inspection tool** with configurable framing, protocol decoding, multi-session management, and real-time waveform plotting — all in a single binary.
Built with Rust + [egui](https://github.com/emilk/egui), scriptable with Lua.
[中文文档](README.md)
---
## Table of Contents
- [Features](#features)
- [Quick Start](#quick-start)
- [Walkthrough: Drone Telemetry](#walkthrough-drone-telemetry)
- [Lua Scripting Guide](#lua-scripting-guide)
- [Architecture](#architecture)
- [Configuration & Persistence](#configuration--persistence)
- [Development Tools](#development-tools)
- [Tech Stack](#tech-stack)
- [License](#license)
---
## Features
### Transport
| Type | Description | Options |
|------|-------------|---------|
| **Serial** | Serial port communication | Port name, baud rate (30012M), data bits (5/6/7/8), parity (None/Odd/Even), stop bits (1/2), flow control (None/Software/Hardware), DTR/RTS control |
| **TCP** | TCP client | Target address `host:port` |
| **UDP** | UDP communication | Bind address `host:port`, optional remote address |
### Framers
Framers split raw byte streams into discrete frames. Each session supports **multiple independent pipelines** — the same byte stream feeds multiple framer→decoder chains in parallel.
| Framer | Description | Parameters |
|--------|-------------|------------|
| **Line** | Split by `\n` | `strip_cr`, `max_line_len` |
| **Fixed** | Fixed-length frames | `frame_len` |
| **Length** | Length-prefixed protocol | `len_bytes` (1/2/4/8), `endian`, `length_includes_self`, `max_payload` |
| **COBS** | [Consistent Overhead Byte Stuffing](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) | `max_frame` |
| **MixedTextPlot** | Hybrid text lines + COBS-encoded plot frames on one stream | `strip_cr`, `max_line_len`, `max_plot_frame` |
| **Lua** | User-defined framer script | `script_path` |
### Decoders
Decoders parse framed data into displayable content.
| Decoder | Output | Parameters |
|---------|--------|------------|
| **Text** | Text | `encoding` (UTF-8 / Latin1 / ASCII) |
| **Hex** | Hexadecimal | `uppercase`, `separator`, `bytes_per_group`, `endian` |
| **Plot** | Waveform | `sample_type` (i8f64), `endian`, `channels` (164), `format` (Interleaved / Block / XY) |
| **MixedTextPlot** | Hybrid text + waveform | `encoding` |
| **Lua** | Custom | `script_path` |
**Plot sample formats:**
| Format | Description | Byte layout |
|--------|-------------|-------------|
| **Interleaved** | Channels interleaved | `[ch0_s0, ch1_s0, ch2_s0, ch0_s1, ch1_s1, …]` |
| **Block** | Channel-contiguous blocks | `[ch0_s0, ch0_s1, …, ch1_s0, ch1_s1, …]` |
| **XY** | 2-channel x/y pairs | `[x0, y0, x1, y1, …]` |
Supported sample types: `i8`, `u8`, `i16`, `u16`, `i32`, `u32`, `i64`, `u64`, `f32`, `f64`
### Views
- **Text View** — formatted text with timestamps, direction markers (`[IN]`/`[OUT]`), and pipeline labels. Built-in search (`Ctrl+F`) with case-sensitive toggle, match counter, and highlighting.
- **Hex View** — hex dump alongside ASCII sidebar. Configurable byte grouping and endianness.
- **Plot View** — real-time waveforms via [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot). Auto-fit, box zoom, axis locking, follow-latest, detached window, channel legend.
### Sessions
- **Multi-session** — tabbed interface, each session independently configured
- **Connection control** — Connect / Disconnect / Reconnect with auto-reconnect on drop
- **Live reconfiguration** — change framer/decoder settings without restarting
- **Data sending** — Text mode (UTF-8, configurable line endings) and Hex mode (raw bytes). Four line ending options: None / LF / CR / CRLF
- **Ring buffer** — default 10,000 entries, configurable 1001,000,000
- **Log to file** — background writer thread with 1 KB buffer, non-blocking
- **Search** — `Ctrl+F` search bar, case-sensitive toggle, F3/Shift+F3 navigation
### Lua Scripting
Built-in LuaJIT runtime:
- **Custom framers** — implement `feed(bytes)`, `flush()`, `reset()`, `pending_len()`
- **Custom decoders** — implement `decode(frame)`, return text/hex/plot/binary
- **Session API** — `pipeview.open()` to create sessions, `session:on_data()` for event callbacks, `session:send()` to transmit data
- **Utilities** — `pipeview.list_ports()`, `pipeview.sleep(ms)`, `pipeview.poll(limit)`, `pipeview.log(msg)`
### Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl+N` | New session |
| `Ctrl+E` | Edit current session |
| `Ctrl+W` | Delete current session |
| `Ctrl+F5` | Toggle connection |
| `Ctrl+T` / `Ctrl+H` / `Ctrl+P` | Switch to Text / Hex / Plot view |
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Next / Previous tab |
| `Ctrl+L` | Clear output |
| `Ctrl+F` | Search |
| `F3` / `Shift+F3` | Next / Previous match |
| `Ctrl+,` | UI settings |
| `Esc` | Close overlay |
---
## Quick Start
### Prerequisites
| Platform | Dependencies |
|----------|-------------|
| **Linux** | `libudev-dev` (`apt install libudev-dev`) |
| **macOS** | None |
| **Windows** | None |
| **All** | Rust ≥ 1.85, C compiler (GCC / Clang / MSVC) |
### Build & Run
```bash
git clone https://github.com/your-org/pipeview.git
cd pipeview
cargo run -p pipeview-gui
# Run tests
cargo test --workspace # ~308 tests
cargo clippy --workspace --all-targets -- -D warnings
```
### Release Build
```bash
cargo build -p pipeview-gui --release
# Binary: target/release/pipeview-gui (Linux/macOS)
# target/release/pipeview-gui.exe (Windows)
```
---
## Walkthrough: Drone Telemetry
Parse Betaflight/INAV flight controller telemetry in the format:
```
AHRS q:1.0000,0.0998,0.0499,0.0200|YPR:8.87,4.44,2.96|Gyro:14.78,8.87,5.92|RC:1559,1544,1500,1519|M:1612,1588,1603,1597|L:0 F:1 C:0
```
### Step 1: Start the data source
```bash
python tools/test_drone.py --rate 10 --port 8091
```
### Step 2: Configure the session
In pipeview-gui, create a session with two pipelines:
| Setting | Value |
|---------|-------|
| Transport | `TCP 127.0.0.1:8091` |
| Pipeline 1 | Framer: `Line` → Decoder: `Lua``examples/drone_text.lua` |
| Pipeline 2 | Framer: `Line` → Decoder: `Lua``examples/drone_plot.lua` |
### Step 3: View the results
- **Text View** — formatted sensor readouts
- **Plot View** — real-time gyroscope curves (gz/gy/gx)
The decoder in `examples/drone_plot.lua`:
```lua
return {
decode = function(frame)
local gz, gy, gx = frame:match("Gyro:([%d%.%-]+),([%d%.%-]+),([%d%.%-]+)")
return {
kind = "plot",
channels = { { tonumber(gz) }, { tonumber(gy) }, { tonumber(gx) } },
sample_type = "F64",
format = "Block",
}
end,
}
```
---
## Lua Scripting Guide
### Framer API
A Lua framer splits raw bytes into frames. The script must return a table with these functions:
```lua
return {
feed = function(bytes)
-- bytes: Lua string (raw bytes)
-- Returns: { frame1, frame2, ... } or nil
end,
flush = function()
-- Returns: last buffered frame (string) or nil
end,
reset = function()
end,
pending_len = function()
-- Returns: number of bytes pending in buffer
end,
}
```
Reference: `tests/lua_line_framer.lua` (line-based framer splitting on `\n`).
### Decoder API
A Lua decoder parses a frame into structured data. The script must return a table with a `decode` function:
```lua
return {
decode = function(frame)
-- frame: Lua string (one frame from the framer)
-- Returns nil → skip this frame
-- Returns string → treated as Text
-- Returns table → must contain "kind" field
end,
}
```
**Return value formats:**
| `kind` | Required | Optional | Purpose |
|--------|----------|----------|---------|
| `"text"` | `data: string` | — | Text view |
| `"hex"` | `data: string` | — | Hex view |
| `"binary"` | `data: string` | — | Raw bytes |
| `"plot"` | `channels: {{number,…},…}` | `sample_type`, `format` | Plot view |
**Plot return value example:**
```lua
return {
kind = "plot",
channels = {
{ 1.0, 2.0, 3.0 }, -- channel 0
{ 4.0, 5.0, 6.0 }, -- channel 1
},
sample_type = "F64", -- default F64; also I8/U8/I16/U32/U64/F32
format = "Block", -- default Interleaved; also Block/XY
}
```
More examples in `examples/`.
---
## Architecture
```
┌──────────────────────────────────────────────────┐
│ pipeview-gui (egui) pipeview-tui (ratatui) │
├──────────────────────────────────────────────────┤
│ pipeview-client │
│ SessionManager · Session · Config · History │
│ Lua Runtime (mlua / LuaJIT) │
├──────────────────────────────────────────────────┤
│ pipeview-core │
│ Transport ──▶ Frame ──▶ Protocol ──▶ Pipeline │
└──────────────────────────────────────────────────┘
```
| Crate | Type | Purpose |
|-------|------|---------|
| `pipeview-core` | library | Transport (Serial/TCP/UDP), framers (Line/Fixed/Length/COBS/Mixed/Lua), decoders (Text/Hex/Plot), MultiPipeline |
| `pipeview-client` | library | Session lifecycle, SessionManager, event broadcast (tokio broadcast), RingBuffer history, Lua runtime & session API |
| `pipeview-gui` | binary | egui desktop app: sidebar, config, console, text/hex/plot panels, keyboard shortcuts, font management, profiling |
| `pipeview-tui` | binary | ratatui terminal app (feature-incomplete, under development) |
**Dependency flow:** `core ← client ← {gui, tui}`
**Data flow:**
```
[Transport] → read bytes → [MultiPipeline]
→ Pipeline 1: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
→ Pipeline 2: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
→ ...
```
Each pipeline frames and decodes independently. Only successful decodes produce output.
---
## Configuration & Persistence
GUI state is saved automatically to platform-standard locations:
| Platform | Path |
|----------|------|
| Linux | `$XDG_CONFIG_HOME/pipeview/gui-state.json` or `~/.config/pipeview/gui-state.json` |
| macOS | `~/Library/Application Support/pipeview/gui-state.json` |
| Windows | `%APPDATA%\pipeview\gui-state.json` |
Persisted data includes: session configurations (transport, pipelines), log settings, active tab, and display options.
Log files are written to `logs/` under the config directory, named `session_{id}_{timestamp}.log`.
---
## Development Tools
### Test Data Generators
```bash
# Plot waveform test data
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
# Drone telemetry test data
python tools/test_drone.py --rate 10 --port 8092
```
### Profiling
```bash
RUST_LOG=pipeview_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p pipeview-gui
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p pipeview-gui
```
### Tracing
```bash
RUST_LOG=info cargo run -p pipeview-gui
RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui
```
### Project Structure
```
crates/
pipeview-core/ # Transport, framing, protocol
pipeview-client/ # Session management, Lua runtime
pipeview-gui/ # egui desktop application
pipeview-tui/ # ratatui terminal application
examples/ # Lua script examples
drone_plot.lua # Drone telemetry plot decoder
drone_text.lua # Drone telemetry text decoder
tests/ # Lua test fixtures
tools/ # Development utilities
test_plot.py # Waveform test data generator
test_drone.py # Drone test data generator
test_plot_serial.c # C serial plot test client
test_text_serial.c # C serial text test client
xs_mixed_plot.h # MixedTextPlot protocol reference
```
---
## Tech Stack
- **Runtime**: tokio (multi-threaded)
- **Serial**: `tokio-serial` + `serialport`
- **GUI**: egui + egui_plot
- **Lua**: mlua 0.11, LuaJIT (vendored), async/serde/send
- **Events**: `tokio::sync::broadcast` (multi-subscriber)
- **Zero feature flags**, zero build scripts, zero conditional compilation
## License
MIT

View File

@@ -1,10 +1,10 @@
[package]
name = "xserial-client"
name = "pipeview-client"
version = "0.1.0"
edition = "2024"
[dependencies]
xserial-core = { path = "../xserial-core" }
pipeview-core = { path = "../pipeview-core" }
tokio = { workspace = true, features = ["sync", "time"] }
tracing = { workspace = true }
serde = { workspace = true }

View File

@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use xserial_core::frame::{
use pipeview_core::frame::{
Endian, Framer,
cobs::CobsFramer,
fixed::FixedLengthFramer,
@@ -7,14 +7,14 @@ use xserial_core::frame::{
line::{LineConfig, LineFramer},
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
};
use xserial_core::protocol::{
use pipeview_core::protocol::{
ProtocolDecoder,
hex::{HexConfig, HexDecoder},
mixed::{MixedTextPlotConfig as MixedDecoderConfig, MixedTextPlotDecoder},
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
text::{TextDecoder, TextEncoding},
};
use xserial_core::transport::TransportConfig;
use pipeview_core::transport::TransportConfig;
use crate::error::Result;
use crate::lua::codec::{LuaDecoder, LuaFramer};
@@ -229,10 +229,10 @@ impl Default for SessionConfig {
#[cfg(test)]
mod tests {
use super::*;
use xserial_core::protocol::Endian;
use xserial_core::protocol::plot::{PlotFormat, SampleType};
use xserial_core::protocol::text::TextEncoding;
use xserial_core::transport::TransportConfig;
use pipeview_core::protocol::Endian;
use pipeview_core::protocol::plot::{PlotFormat, SampleType};
use pipeview_core::protocol::text::TextEncoding;
use pipeview_core::transport::TransportConfig;
#[test]
fn framer_line_serde_roundtrip() {

View File

@@ -1,4 +1,4 @@
use xserial_core::protocol::DecodedData;
use pipeview_core::protocol::DecodedData;
#[derive(Debug, Clone)]
pub struct DecodedEntry {

View File

@@ -3,9 +3,9 @@ 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 pipeview_core::frame::Framer;
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame, SampleType};
use pipeview_core::protocol::{DecodedData, ProtocolDecoder};
use crate::error::{Error, Result};
@@ -297,7 +297,7 @@ mod tests {
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("xserial_{name}_{nonce}.lua"));
let path = std::env::temp_dir().join(format!("pipeview_{name}_{nonce}.lua"));
fs::write(&path, script).unwrap();
path
}

View File

@@ -81,9 +81,9 @@ pub fn register(lua: &Lua) -> LuaResult<()> {
pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()> {
let runtime = LuaRuntime::new(manager);
let xserial = lua.create_table()?;
let pipeview = lua.create_table()?;
xserial.set("open", {
pipeview.set("open", {
let runtime = runtime.clone();
lua.create_async_function(move |lua, config: Table| {
let runtime = runtime.clone();
@@ -96,15 +96,15 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
})?
})?;
xserial.set(
pipeview.set(
"list_ports",
lua.create_function(|_, ()| {
let ports = xserial_core::transport::serial::SerialTransport::list_ports();
let ports = pipeview_core::transport::serial::SerialTransport::list_ports();
Ok(ports.into_iter().map(|p| p.port_name).collect::<Vec<_>>())
})?,
)?;
xserial.set("sleep", {
pipeview.set("sleep", {
let runtime = runtime.clone();
lua.create_async_function(move |lua, ms: u64| {
let runtime = runtime.clone();
@@ -116,7 +116,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
})?
})?;
xserial.set("poll", {
pipeview.set("poll", {
let runtime = runtime.clone();
lua.create_async_function(move |lua, limit_per_session: Option<usize>| {
let runtime = runtime.clone();
@@ -124,7 +124,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
})?
})?;
xserial.set(
pipeview.set(
"log",
lua.create_function(|_, msg: String| {
tracing::info!("[lua] {}", msg);
@@ -132,7 +132,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
})?,
)?;
lua.globals().set("xserial", xserial)?;
lua.globals().set("pipeview", pipeview)?;
Ok(())
}
@@ -146,9 +146,9 @@ mod tests {
let lua = Lua::new();
register(&lua).unwrap();
let xserial: Table = lua.globals().get("xserial").unwrap();
let pipeview: Table = lua.globals().get("pipeview").unwrap();
for name in ["open", "list_ports", "sleep", "poll", "log"] {
let value: Value = xserial.get(name).unwrap();
let value: Value = pipeview.get(name).unwrap();
assert!(
matches!(value, Value::Function(_)),
"{name} should be a function"
@@ -162,8 +162,8 @@ mod tests {
let manager = SessionManager::new();
register_with_manager(&lua, manager.clone()).unwrap();
let xserial: Table = lua.globals().get("xserial").unwrap();
let open: Value = xserial.get("open").unwrap();
let pipeview: Table = lua.globals().get("pipeview").unwrap();
let open: Value = pipeview.get("open").unwrap();
assert!(matches!(open, Value::Function(_)));
assert_eq!(manager.count(), 0);
}

View File

@@ -8,7 +8,7 @@ use std::time::Duration;
use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, UserData, UserDataMethods, Value, Variadic};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use xserial_core::protocol::DecodedData;
use pipeview_core::protocol::DecodedData;
use crate::config::SessionConfig;
use crate::lua::LuaRuntime;
@@ -224,12 +224,12 @@ impl CallbackState {
impl Drop for CallbackState {
fn drop(&mut self) {
if let Ok(mut relay_task) = self.relay_task.lock() {
if let Some(task) = relay_task.take() {
if let Ok(mut relay_task) = self.relay_task.lock()
&& let Some(task) = relay_task.take()
{
task.abort();
}
}
}
}
impl UserData for LuaSessionHandle {

View File

@@ -11,8 +11,8 @@ use tokio::sync::{Mutex, broadcast, mpsc};
use tokio::task::JoinHandle;
use tracing::{debug, error, info, warn};
use xserial_core::pipeline::{MultiPipeline, Pipeline};
use xserial_core::transport::Connection;
use pipeview_core::pipeline::{MultiPipeline, Pipeline};
use pipeview_core::transport::Connection;
use crate::cmd::SessionCmd;
use crate::config::SessionConfig;
@@ -192,13 +192,13 @@ impl Drop for SessionHandle {
fn drop(&mut self) {
if Arc::strong_count(&self.inner) == 1 {
self.inner.request_close_nonblocking();
if let Ok(mut task) = self.inner.task.try_lock() {
if let Some(task) = task.take() {
if let Ok(mut task) = self.inner.task.try_lock()
&& let Some(task) = task.take()
{
task.abort();
}
}
}
}
}
pub struct Session {
@@ -304,11 +304,11 @@ impl Session {
}
Some(SessionCmd::Connect) => {
self.desired_connected = true;
if self.conn.is_none() {
if let Err(err) = self.connect().await {
if self.conn.is_none()
&& let Err(err) = self.connect().await
{
self.emit_error(format!("connect failed: {err}"));
}
}
true
}
Some(SessionCmd::Disconnect) => {
@@ -499,8 +499,8 @@ async fn try_read(
}
}
fn to_io_error(err: xserial_core::error::Error) -> std::io::Error {
io::Error::new(io::ErrorKind::Other, err.to_string())
fn to_io_error(err: pipeview_core::error::Error) -> std::io::Error {
io::Error::other(err.to_string())
}
#[cfg(test)]
@@ -510,7 +510,7 @@ mod tests {
fn tcp_config() -> SessionConfig {
SessionConfig {
transport: xserial_core::transport::TransportConfig::Tcp {
transport: pipeview_core::transport::TransportConfig::Tcp {
addr: "127.0.0.1:1".into(),
},
pipelines: vec![PipelineConfig {
@@ -520,7 +520,7 @@ mod tests {
max_line_len: 65536,
},
decoder: DecoderConfig::Text {
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
encoding: pipeview_core::protocol::text::TextEncoding::Utf8,
},
}],
..Default::default()

View File

@@ -11,7 +11,7 @@ static INIT: Once = Once::new();
fn init_tracing() {
INIT.call_once(|| {
tracing_subscriber::fmt()
.with_env_filter("xserial=trace")
.with_env_filter("pipeview=trace")
.try_init()
.ok();
});
@@ -22,7 +22,7 @@ fn write_temp_lua(name: &str, script: &str) -> PathBuf {
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("xserial_{name}_{nonce}.lua"));
let path = std::env::temp_dir().join(format!("pipeview_{name}_{nonce}.lua"));
fs::write(&path, script).unwrap();
path
}
@@ -47,18 +47,18 @@ fn hex_pipeline_lua() -> &'static str {
"#
}
// ── xserial.sleep / xserial.log ──────────────────────────────────
// ── pipeview.sleep / pipeview.log ──────────────────────────────────
#[tokio::test]
async fn lua_sleep_and_log() {
let lua = Lua::new();
xserial_client::lua::register(&lua).unwrap();
pipeview_client::lua::register(&lua).unwrap();
lua.load(
r#"
xserial.log("test start")
xserial.sleep(50)
xserial.log("test end")
pipeview.log("test start")
pipeview.sleep(50)
pipeview.log("test end")
"#,
)
.exec_async()
@@ -66,15 +66,15 @@ async fn lua_sleep_and_log() {
.unwrap();
}
// ── xserial.list_ports ───────────────────────────────────────────
// ── pipeview.list_ports ───────────────────────────────────────────
#[tokio::test]
async fn lua_list_ports() {
let lua = Lua::new();
xserial_client::lua::register(&lua).unwrap();
pipeview_client::lua::register(&lua).unwrap();
let result: Vec<String> = lua
.load(r#"return xserial.list_ports()"#)
.load(r#"return pipeview.list_ports()"#)
.eval_async()
.await
.unwrap();
@@ -84,7 +84,7 @@ async fn lua_list_ports() {
let _ = result;
}
// ── xserial.open + session:send / session:read / session:close ────
// ── pipeview.open + session:send / session:read / session:close ────
#[tokio::test]
async fn lua_session_open_send_read_close() {
@@ -102,11 +102,11 @@ async fn lua_session_open_send_read_close() {
});
let lua = Lua::new();
xserial_client::lua::register(&lua).unwrap();
pipeview_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local sess = xserial.open({{
local sess = pipeview.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{{}}}
}})
@@ -141,11 +141,11 @@ async fn lua_session_read_timeout() {
});
let lua = Lua::new();
xserial_client::lua::register(&lua).unwrap();
pipeview_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local sess = xserial.open({{
local sess = pipeview.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{{}}}
}})
@@ -162,7 +162,7 @@ async fn lua_session_read_timeout() {
lua.load(&script).exec_async().await.unwrap();
}
// ── xserial.open with hex decoder ────────────────────────────────
// ── pipeview.open with hex decoder ────────────────────────────────
#[tokio::test]
async fn lua_session_hex_decoder() {
@@ -176,11 +176,11 @@ async fn lua_session_hex_decoder() {
});
let lua = Lua::new();
xserial_client::lua::register(&lua).unwrap();
pipeview_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local sess = xserial.open({{
local sess = pipeview.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{{}}}
}})
@@ -201,7 +201,7 @@ async fn lua_session_hex_decoder() {
server.await.unwrap();
}
// ── xserial.open with Lua framer + Lua decoder ───────────────────
// ── pipeview.open with Lua framer + Lua decoder ───────────────────
#[tokio::test]
async fn lua_session_custom_lua_pipeline() {
@@ -259,11 +259,11 @@ async fn lua_session_custom_lua_pipeline() {
});
let lua = Lua::new();
xserial_client::lua::register(&lua).unwrap();
pipeview_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local sess = xserial.open({{
local sess = pipeview.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{
{{
@@ -311,12 +311,12 @@ async fn lua_session_on_data_callback() {
});
let lua = Lua::new();
xserial_client::lua::register(&lua).unwrap();
pipeview_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local received = {{}}
local sess = xserial.open({{
local sess = pipeview.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{{}}}
}})
@@ -327,8 +327,8 @@ async fn lua_session_on_data_callback() {
for _ = 1, 50 do
if #received < 2 then
xserial.poll(1)
xserial.sleep(10)
pipeview.poll(1)
pipeview.sleep(10)
end
end
@@ -371,11 +371,11 @@ async fn lua_session_reconfigure() {
});
let lua = Lua::new();
xserial_client::lua::register(&lua).unwrap();
pipeview_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local sess = xserial.open({{
local sess = pipeview.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{{}}}
}})
@@ -420,11 +420,11 @@ async fn lua_session_next_event_stream() {
});
let lua = Lua::new();
xserial_client::lua::register(&lua).unwrap();
pipeview_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local sess = xserial.open({{
local sess = pipeview.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{{}}}
}})
@@ -450,7 +450,7 @@ async fn lua_session_next_event_stream() {
server.await.unwrap();
}
// ── session:off + xserial.poll ───────────────────────────────────
// ── session:off + pipeview.poll ───────────────────────────────────
#[tokio::test]
async fn lua_session_off_stops_future_callbacks() {
@@ -466,12 +466,12 @@ async fn lua_session_off_stops_future_callbacks() {
});
let lua = Lua::new();
xserial_client::lua::register(&lua).unwrap();
pipeview_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local received = {{}}
local sess = xserial.open({{
local sess = pipeview.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{{}}}
}})
@@ -482,8 +482,8 @@ async fn lua_session_off_stops_future_callbacks() {
for _ = 1, 50 do
if #received == 0 then
xserial.poll(1)
xserial.sleep(10)
pipeview.poll(1)
pipeview.sleep(10)
end
end
@@ -491,8 +491,8 @@ async fn lua_session_off_stops_future_callbacks() {
assert(received[1] == "text:first")
assert(sess:off(token) == true)
xserial.sleep(250)
xserial.poll(10)
pipeview.sleep(250)
pipeview.poll(10)
assert(#received == 1, "callback should not fire after off")
assert(sess:off(token) == false)

View File

@@ -3,11 +3,11 @@ use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use xserial_client::SessionManager;
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
use xserial_client::session::{Session, SessionEvent};
use xserial_core::protocol::DecodedData;
use xserial_core::transport::TransportConfig;
use pipeview_client::SessionManager;
use pipeview_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
use pipeview_client::session::{Session, SessionEvent};
use pipeview_core::protocol::DecodedData;
use pipeview_core::transport::TransportConfig;
fn tcp_config(addr: String) -> SessionConfig {
SessionConfig {
@@ -19,7 +19,7 @@ fn tcp_config(addr: String) -> SessionConfig {
max_line_len: 1024 * 1024,
},
decoder: DecoderConfig::Text {
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
encoding: pipeview_core::protocol::text::TextEncoding::Utf8,
},
}],
history_limit: 100,
@@ -72,18 +72,15 @@ async fn session_multiple_reads() {
let mut texts = Vec::new();
tokio::time::timeout(Duration::from_secs(5), async {
loop {
match rx.recv().await.unwrap() {
SessionEvent::Data(_, entry) => {
if let DecodedData::Text(s) = entry.data {
if let SessionEvent::Data(_, entry) = rx.recv().await.unwrap()
&& let DecodedData::Text(s) = entry.data
{
texts.push(s);
if texts.len() == 3 {
break;
}
}
}
_ => {}
}
}
})
.await
.unwrap();
@@ -116,7 +113,7 @@ async fn session_multi_pipeline_text_and_hex() {
uppercase: false,
separator: " ".into(),
bytes_per_group: 1,
endian: xserial_core::protocol::Endian::Big,
endian: pipeview_core::protocol::Endian::Big,
},
});
@@ -127,15 +124,12 @@ async fn session_multi_pipeline_text_and_hex() {
let mut results = Vec::new();
tokio::time::timeout(Duration::from_secs(5), async {
loop {
match rx.recv().await.unwrap() {
SessionEvent::Data(_, entry) => {
if let SessionEvent::Data(_, entry) = rx.recv().await.unwrap() {
results.push((entry.pipeline_name.clone(), entry.data.clone()));
if results.len() == 2 {
break;
}
}
_ => {}
}
}
})
.await
@@ -291,7 +285,7 @@ async fn session_reconfigure_changes_pipeline() {
uppercase: true,
separator: " ".into(),
bytes_per_group: 1,
endian: xserial_core::protocol::Endian::Big,
endian: pipeview_core::protocol::Endian::Big,
};
handle.reconfigure(new_cfg).await.unwrap();

View File

@@ -1,5 +1,5 @@
[package]
name = "xserial-core"
name = "pipeview-core"
version = "0.1.0"
edition = "2024"

View File

@@ -324,7 +324,7 @@ mod tests {
let mut f = LengthPrefixedFramer::new(cfg);
// Corrupt frame: claims 200 bytes, actual payload is 0xFF bytes
let mut data = be2(200);
data.extend_from_slice(&vec![0xFFu8; 200]);
data.extend_from_slice(&[0xFFu8; 200]);
// Followed by valid frame
data.extend(&be2(3));
data.extend_from_slice(b"foo");

View File

@@ -72,11 +72,11 @@ impl MixedTextPlotFramer {
return;
}
if let Some(decoded) = cobs_decode(&self.plot_buf) {
if decoded.len() <= self.config.max_plot_frame {
if let Some(decoded) = cobs_decode(&self.plot_buf)
&& decoded.len() <= self.config.max_plot_frame
{
frames.push(tag_plot_frame(decoded));
}
}
self.plot_buf.clear();
self.plot_overflow = false;
}

View File

@@ -61,11 +61,11 @@ pub struct PipelineResult {
/// Manages multiple [`Pipeline`]s, feeding the same byte stream to all of them.
///
/// ```
/// use xserial_core::pipeline::{MultiPipeline, Pipeline};
/// use xserial_core::frame::line::{LineFramer, LineConfig};
/// use xserial_core::frame::fixed::FixedLengthFramer;
/// use xserial_core::protocol::text::{TextDecoder, TextEncoding};
/// use xserial_core::protocol::hex::{HexDecoder, HexConfig};
/// use pipeview_core::pipeline::{MultiPipeline, Pipeline};
/// use pipeview_core::frame::line::{LineFramer, LineConfig};
/// use pipeview_core::frame::fixed::FixedLengthFramer;
/// use pipeview_core::protocol::text::{TextDecoder, TextEncoding};
/// use pipeview_core::protocol::hex::{HexDecoder, HexConfig};
///
/// let mut mp = MultiPipeline::new();
/// mp.add(

View File

@@ -469,7 +469,8 @@ mod tests {
};
let d = PlotDecoder::new(cfg);
// 7 bytes: 1 full u32 (4 bytes) + 3 trailing bytes
let data = vec![1u32.to_le_bytes().to_vec(), vec![0xff; 3]].concat();
let mut data = 1u32.to_le_bytes().to_vec();
data.extend_from_slice(&[0xff; 3]);
let result = d.decode(&data).unwrap();
match result {
DecodedData::Plot(frame) => {

View File

@@ -294,11 +294,11 @@ mod tests {
assert_eq!(original, cloned);
let original = TransportType::Tcp;
let cloned = original.clone();
let cloned = original;
assert_eq!(original, cloned);
let original = TransportType::Udp;
let cloned = original.clone();
let cloned = original;
assert_eq!(original, cloned);
}

View File

@@ -4,7 +4,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, UdpSocket};
use tokio::time::timeout;
use xserial_core::frame::{
use pipeview_core::frame::{
Endian, Framer,
cobs::CobsFramer,
cobs::cobs_encode as raw_cobs_encode,
@@ -13,17 +13,17 @@ use xserial_core::frame::{
line::{LineConfig, LineFramer},
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
};
use xserial_core::protocol::{
use pipeview_core::protocol::{
DecodedData, ProtocolDecoder,
hex::{HexConfig, HexDecoder},
mixed::{MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER, MixedTextPlotConfig, MixedTextPlotDecoder},
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
text::{TextDecoder, TextEncoding},
};
use xserial_core::transport::serial::{
use pipeview_core::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
};
use xserial_core::transport::{Connection, TransportConfig, TransportType};
use pipeview_core::transport::{Connection, TransportConfig, TransportType};
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
@@ -225,7 +225,7 @@ async fn tcp_fixed_plot_f32() {
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
// 3 samples × 4 bytes each = 12 bytes per frame
let samples: [f32; 3] = [1.0, -2.5, 3.14];
let samples: [f32; 3] = [1.0, -2.5, 0.0];
let mut data = Vec::new();
for s in &samples {
data.extend_from_slice(&s.to_le_bytes());
@@ -255,7 +255,7 @@ async fn tcp_fixed_plot_f32() {
assert_eq!(frame.channels[0].len(), 3);
assert!((frame.channels[0][0] - 1.0).abs() < 1e-5);
assert!((frame.channels[0][1] - (-2.5)).abs() < 1e-5);
assert!((frame.channels[0][2] - 3.14).abs() < 1e-5);
assert!((frame.channels[0][2] - 0.0).abs() < 1e-5);
}
other => panic!("expected Plot, got {:?}", other),
}

View File

@@ -1,15 +1,15 @@
[package]
name = "xserial-gui"
name = "pipeview-gui"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "xserial-gui"
name = "pipeview-gui"
path = "src/main.rs"
[dependencies]
xserial-core = { path = "../xserial-core" }
xserial-client = { path = "../xserial-client" }
pipeview-core = { path = "../pipeview-core" }
pipeview-client = { path = "../pipeview-client" }
tokio = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true }

View File

@@ -1,18 +1,20 @@
use crate::app_state::{self, PersistedGuiState};
use crate::app_state::{self, PersistedGuiState, SessionLogConfig};
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
use crate::logging::{self, LogWriter};
use crate::panels::{config, console, hex_view, plot_view, sidebar};
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
use crate::shortcuts::{self, default_bindings};
use crate::ui_fonts::{self, FontCandidate, FontChoice, UiFontSettings};
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
use xserial_client::SessionManager;
use xserial_client::config::SessionConfig;
use xserial_client::session::SessionEvent;
use xserial_core::protocol::DecodedData;
use xserial_core::transport::TransportConfig;
use pipeview_client::SessionManager;
use pipeview_client::config::SessionConfig;
use pipeview_client::session::SessionEvent;
use pipeview_core::protocol::DecodedData;
use pipeview_core::transport::TransportConfig;
const DATA_REPAINT_INTERVAL: Duration = Duration::from_millis(33);
@@ -48,6 +50,26 @@ pub enum SendMode {
Hex,
}
#[derive(Clone, Copy, PartialEq)]
#[allow(clippy::upper_case_acronyms)]
pub enum LineEnding {
None,
LF,
CR,
CRLF,
}
impl std::fmt::Display for LineEnding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => write!(f, "None"),
Self::LF => write!(f, "LF (\\n)"),
Self::CR => write!(f, "CR (\\r)"),
Self::CRLF => write!(f, "CRLF (\\r\\n)"),
}
}
}
#[derive(Clone, Copy)]
pub struct DisplayOptions {
pub show_timestamp: bool,
@@ -55,6 +77,58 @@ pub struct DisplayOptions {
pub show_pipeline: bool,
}
#[derive(Clone, Default)]
pub struct SearchState {
pub query: String,
pub matches: Vec<usize>,
pub current_match: usize,
pub case_sensitive: bool,
pub active: bool,
pub just_opened: bool,
}
impl SearchState {
pub fn clear(&mut self) {
self.query.clear();
self.matches.clear();
self.current_match = 0;
self.active = false;
self.just_opened = false;
}
pub fn next(&mut self) {
if !self.matches.is_empty() {
self.current_match = (self.current_match + 1) % self.matches.len();
}
}
pub fn prev(&mut self) {
if !self.matches.is_empty() {
self.current_match = if self.current_match == 0 {
self.matches.len() - 1
} else {
self.current_match - 1
};
}
}
// pub fn current_line_index(&self) -> Option<usize> {
// self.matches.get(self.current_match).copied()
// }
pub fn match_count(&self) -> usize {
self.matches.len()
}
pub fn current_display(&self) -> usize {
if self.matches.is_empty() {
0
} else {
self.current_match + 1
}
}
}
pub struct SessionTab {
pub id: u64,
pub session_config: SessionConfig,
@@ -69,8 +143,12 @@ pub struct SessionTab {
pub rts: bool,
pub send_input: String,
pub send_mode: SendMode,
pub append_newline: bool,
pub line_ending: LineEnding,
pub send_status: Option<String>,
pub search: SearchState,
pub log_enabled: bool,
pub log_path: String,
pub log_writer: Option<LogWriter>,
}
pub struct XserialApp {
@@ -260,6 +338,26 @@ impl XserialApp {
self.config_open = false;
self.font_settings_open = false;
}
Search => {
if let Some(tab) = self.tabs.get_mut(self.active) {
tab.search.active = true;
tab.search.just_opened = true;
}
}
SearchNext => {
if let Some(tab) = self.tabs.get_mut(self.active)
&& tab.search.active
{
tab.search.next();
}
}
SearchPrev => {
if let Some(tab) = self.tabs.get_mut(self.active)
&& tab.search.active
{
tab.search.prev();
}
}
}
}
@@ -270,8 +368,17 @@ impl XserialApp {
}
fn restore_saved_sessions(&mut self, saved_state: PersistedGuiState) {
for session_config in saved_state.sessions {
for (i, session_config) in saved_state.sessions.into_iter().enumerate() {
self.add_session_tab(session_config);
if let Some(tab) = self.tabs.last_mut()
&& let Some(log_cfg) = saved_state.sessions_log.get(i)
{
tab.log_enabled = log_cfg.enabled;
tab.log_path = log_cfg.file_path.clone();
if log_cfg.enabled && !log_cfg.file_path.is_empty() {
tab.log_writer = LogWriter::open(&log_cfg.file_path).ok();
}
}
}
if !self.tabs.is_empty() {
self.active = saved_state.active.min(self.tabs.len() - 1);
@@ -300,8 +407,12 @@ impl XserialApp {
rts,
send_input: String::new(),
send_mode: SendMode::Text,
append_newline: true,
line_ending: LineEnding::None,
send_status: None,
search: SearchState::default(),
log_enabled: false,
log_path: String::new(),
log_writer: None,
});
}
@@ -312,6 +423,14 @@ impl XserialApp {
.iter()
.map(|tab| tab.session_config.clone())
.collect(),
sessions_log: self
.tabs
.iter()
.map(|tab| SessionLogConfig {
enabled: tab.log_enabled,
file_path: tab.log_path.clone(),
})
.collect(),
active: if self.tabs.is_empty() {
0
} else {
@@ -390,6 +509,17 @@ impl XserialApp {
SessionEvent::Data(id, entry) => {
stats.drained_data_events += 1;
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
if let Some(ref writer) = tab.log_writer
&& let Some(line) = logging::format_data_log(
&entry,
"IN",
self.display.show_timestamp,
self.display.show_direction,
self.display.show_pipeline,
)
{
writer.write_line(&line);
}
match &entry.data {
DecodedData::Text(_) => {
stats.drained_text_events += 1;
@@ -596,6 +726,8 @@ impl XserialApp {
});
persist_state |= display_changed;
render_search_bar(ui, tab);
if let ConnectionStatus::Error(message) = &tab.status {
ui.label(egui::RichText::new(message).color(Color32::RED));
}
@@ -619,11 +751,11 @@ impl XserialApp {
let started = Instant::now();
match tab.view {
View::Text => {
let line_count = console::render(ui, &tab.console, *display);
let line_count = console::render(ui, &tab.console, *display, tab.search.active.then_some(&tab.search));
text_render = Some((started.elapsed(), line_count));
}
View::Hex => {
let line_count = hex_view::render(ui, &tab.hex, *display);
let line_count = hex_view::render(ui, &tab.hex, *display, tab.search.active.then_some(&tab.search));
hex_render = Some((started.elapsed(), line_count));
}
View::Plot => {
@@ -719,7 +851,7 @@ impl XserialApp {
fn render_top_bar(&mut self, ui: &mut egui::Ui) {
Panel::top("top_bar").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.heading("xserial");
ui.heading("pipeview");
ui.separator();
// ui.label(format!(
// "Fonts: {} + {} {:.1} pt",
@@ -862,6 +994,7 @@ fn transport_summary(transport: &TransportConfig) -> String {
}
}
#[allow(clippy::too_many_arguments)]
fn render_font_selector(
ui: &mut egui::Ui,
title: &str,
@@ -972,6 +1105,62 @@ impl eframe::App for XserialApp {
}
}
fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
if !tab.search.active {
return;
}
ui.horizontal(|ui| {
let response = ui.add(
TextEdit::singleline(&mut tab.search.query)
.hint_text("Search...")
.desired_width(200.0),
);
if tab.search.just_opened {
response.request_focus();
tab.search.just_opened = false;
}
if response.changed() {
let matches = match tab.view {
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
View::Plot => Vec::new(),
};
tab.search.matches = matches;
tab.search.current_match = 0;
}
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
tab.search.next();
}
ui.label(format!(
"{}/{}",
tab.search.current_display(),
tab.search.match_count()
));
if ui.button("").clicked() {
tab.search.prev();
}
if ui.button("").clicked() {
tab.search.next();
}
if ui.checkbox(&mut tab.search.case_sensitive, "Aa").changed() {
let matches = match tab.view {
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
View::Plot => Vec::new(),
};
tab.search.matches = matches;
tab.search.current_match = 0;
}
if ui.button("").clicked() {
tab.search.clear();
}
});
}
fn render_session_controls(
ui: &mut egui::Ui,
manager: &SessionManager,
@@ -1066,11 +1255,60 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
ui.selectable_value(&mut tab.send_mode, SendMode::Text, "Text");
ui.selectable_value(&mut tab.send_mode, SendMode::Hex, "Hex");
if tab.send_mode == SendMode::Text {
ui.checkbox(&mut tab.append_newline, "Append newline");
ui.add_space(6.0);
egui::ComboBox::from_label("Line ending")
.selected_text(tab.line_ending.to_string())
.show_ui(ui, |ui| {
ui.selectable_value(
&mut tab.line_ending,
LineEnding::None,
LineEnding::None.to_string(),
);
ui.selectable_value(
&mut tab.line_ending,
LineEnding::LF,
LineEnding::LF.to_string(),
);
ui.selectable_value(
&mut tab.line_ending,
LineEnding::CR,
LineEnding::CR.to_string(),
);
ui.selectable_value(
&mut tab.line_ending,
LineEnding::CRLF,
LineEnding::CRLF.to_string(),
);
});
}
});
ui.add_space(6.0);
let log_toggled = ui
.checkbox(&mut tab.log_enabled, "Log to file")
.changed();
if log_toggled {
if tab.log_enabled {
tab.log_path = default_log_path(tab.id)
.to_string_lossy()
.to_string();
tab.log_writer = LogWriter::open(&tab.log_path).ok();
} else {
tab.log_writer = None;
}
}
if tab.log_enabled {
ui.horizontal(|ui| {
let changed = ui
.text_edit_singleline(&mut tab.log_path)
.lost_focus();
if changed && !tab.log_path.is_empty() {
tab.log_writer = LogWriter::open(&tab.log_path).ok();
}
});
}
ui.add_space(3.0);
let hint = match tab.send_mode {
SendMode::Text => "Enter text to send",
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
@@ -1108,13 +1346,18 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
if let Some(handle) = manager.get(tab.id) {
match tab.send_mode {
SendMode::Text => {
let text = if tab.append_newline {
tab.send_input.trim_end_matches('\n').to_string()
} else {
tab.send_input.clone()
};
let mut text = tab.send_input.trim_end_matches('\n').to_string();
if !text.is_empty() {
tab.console.push_outbound(text);
match tab.line_ending {
LineEnding::None => {}
LineEnding::LF => text.push('\n'),
LineEnding::CR => text.push('\r'),
LineEnding::CRLF => text.push_str("\r\n"),
}
tab.console.push_outbound(text.clone());
if let Some(ref writer) = tab.log_writer {
writer.write_line(&logging::format_sent_log(&text));
}
}
}
SendMode::Hex => {
@@ -1124,7 +1367,10 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
.collect::<Vec<_>>()
.join(" ");
if !hex.is_empty() {
tab.hex.push_outbound(hex);
tab.hex.push_outbound(hex.clone());
if let Some(ref writer) = tab.log_writer {
writer.write_line(&logging::format_sent_log(&hex));
}
}
}
}
@@ -1146,6 +1392,16 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
}
}
fn default_log_path(session_id: u64) -> PathBuf {
use std::time::{SystemTime, UNIX_EPOCH};
let dir = app_state::config_dir().join("logs");
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
dir.join(format!("session_{session_id}_{ts}.log"))
}
fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
let trimmed = tab.send_input.trim();
if trimmed.is_empty() {
@@ -1154,9 +1410,12 @@ fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
match tab.send_mode {
SendMode::Text => {
let mut text = tab.send_input.clone();
if tab.append_newline && !text.ends_with('\n') {
text.push('\n');
let mut text = tab.send_input.trim_end_matches('\n').to_string();
match tab.line_ending {
LineEnding::None => {}
LineEnding::LF => text.push('\n'),
LineEnding::CR => text.push('\r'),
LineEnding::CRLF => text.push_str("\r\n"),
}
Ok(Some(text.into_bytes()))
}
@@ -1175,8 +1434,8 @@ fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
#[cfg(test)]
mod tests {
use super::transport_summary;
use xserial_core::transport::TransportConfig;
use xserial_core::transport::serial::{
use pipeview_core::transport::TransportConfig;
use pipeview_core::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
};

View File

@@ -5,15 +5,25 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::warn;
use xserial_client::config::SessionConfig;
use pipeview_client::config::SessionConfig;
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionLogConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub file_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PersistedGuiState {
#[serde(default)]
pub sessions: Vec<SessionConfig>,
#[serde(default)]
pub sessions_log: Vec<SessionLogConfig>,
#[serde(default)]
pub active: usize,
#[serde(default = "default_true")]
pub show_timestamp: bool,
@@ -44,17 +54,25 @@ fn gui_state_path() -> PathBuf {
gui_state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
}
/// Returns the pipeview configuration directory (the parent of gui-state.json).
pub fn config_dir() -> PathBuf {
gui_state_path()
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
}
fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
match os {
"windows" => {
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("xserial")
.join("pipeview")
.join(GUI_STATE_FILE_NAME);
}
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("xserial")
.join("pipeview")
.join(GUI_STATE_FILE_NAME);
}
}
@@ -63,20 +81,20 @@ fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<Stri
return PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("xserial")
.join("pipeview")
.join(GUI_STATE_FILE_NAME);
}
}
_ => {
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("xserial")
.join("pipeview")
.join(GUI_STATE_FILE_NAME);
}
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(home)
.join(".config")
.join("xserial")
.join("pipeview")
.join(GUI_STATE_FILE_NAME);
}
}
@@ -106,7 +124,7 @@ const fn default_true() -> bool {
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
use xserial_core::transport::TransportConfig;
use pipeview_core::transport::TransportConfig;
#[test]
fn windows_gui_state_path_uses_appdata() {
@@ -117,7 +135,7 @@ mod tests {
assert_eq!(
path,
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
.join("xserial")
.join("pipeview")
.join(GUI_STATE_FILE_NAME)
);
}
@@ -128,7 +146,7 @@ mod tests {
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = env::temp_dir().join(format!("xserial-gui-state-{unique}"));
let dir = env::temp_dir().join(format!("pipeview-gui-state-{unique}"));
let path = dir.join(GUI_STATE_FILE_NAME);
let state = PersistedGuiState {
sessions: vec![SessionConfig {
@@ -137,6 +155,7 @@ mod tests {
},
..SessionConfig::default()
}],
sessions_log: vec![SessionLogConfig::default()],
active: 0,
show_timestamp: false,
show_direction: true,

View File

@@ -1,10 +1,10 @@
use egui_plot::PlotBounds;
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use xserial_client::RingBuffer;
use xserial_client::event::DecodedEntry;
use xserial_core::protocol::DecodedData;
use xserial_core::protocol::plot::{PlotFormat, PlotFrame};
use pipeview_client::RingBuffer;
use pipeview_client::event::DecodedEntry;
use pipeview_core::protocol::DecodedData;
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
#[derive(Clone)]
pub enum LineDirection {
@@ -67,6 +67,32 @@ impl TextBuffer {
pub fn set_limit(&mut self, limit: usize) {
self.lines.set_limit(limit);
}
// pub fn iter(&self) -> impl Iterator<Item = &ConsoleLine> {
// (0..self.lines.len()).filter_map(|i| self.lines.get(i))
// }
pub fn search(&self, query: &str, case_sensitive: bool) -> Vec<usize> {
if query.is_empty() {
return Vec::new();
}
let query_lower = if case_sensitive {
String::new()
} else {
query.to_lowercase()
};
(0..self.lines.len())
.filter(|&i| {
self.lines.get(i).is_some_and(|line| {
if case_sensitive {
line.text.contains(query)
} else {
line.text.to_lowercase().contains(&query_lower)
}
})
})
.collect()
}
}
#[derive(Clone)]
@@ -155,6 +181,33 @@ impl HexBuffer {
pub fn set_limit(&mut self, limit: usize) {
self.lines.set_limit(limit);
}
// pub fn iter(&self) -> impl Iterator<Item = &HexLine> {
// (0..self.lines.len()).filter_map(|i| self.lines.get(i))
// }
pub fn search(&self, query: &str, case_sensitive: bool) -> Vec<usize> {
if query.is_empty() {
return Vec::new();
}
let query_lower = if case_sensitive {
String::new()
} else {
query.to_lowercase()
};
(0..self.lines.len())
.filter(|&i| {
self.lines.get(i).is_some_and(|line| {
if case_sensitive {
line.hex.contains(query) || line.ascii.contains(query)
} else {
line.hex.to_lowercase().contains(&query_lower)
|| line.ascii.to_lowercase().contains(&query_lower)
}
})
})
.collect()
}
}
pub struct PlotSeries {
@@ -663,9 +716,9 @@ impl PlotBuffer {
#[cfg(test)]
mod tests {
use super::*;
use xserial_client::event::DecodedEntry;
use xserial_core::protocol::DecodedData;
use xserial_core::protocol::plot::{PlotFrame, SampleType};
use pipeview_client::event::DecodedEntry;
use pipeview_core::protocol::DecodedData;
use pipeview_core::protocol::plot::{PlotFrame, SampleType};
fn plot_entry() -> DecodedEntry {
DecodedEntry {

View File

@@ -0,0 +1,109 @@
use std::fs::{self, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::sync::mpsc;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use pipeview_client::event::DecodedEntry;
use pipeview_core::protocol::DecodedData;
/// Manages background file logging via a dedicated writer thread.
///
/// Lines are sent through an mpsc channel to a background thread that
/// appends to the log file using buffered I/O, keeping the GUI thread
/// non-blocking.
pub struct LogWriter {
sender: mpsc::Sender<String>,
}
impl LogWriter {
/// Spawns a background thread that opens `path` in append mode and
/// writes every line it receives through the returned `LogWriter`.
pub fn open(path: impl Into<PathBuf>) -> std::io::Result<Self> {
let path: PathBuf = path.into();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).append(true).open(&path)?;
let mut writer = BufWriter::with_capacity(1024, file);
let (sender, receiver) = mpsc::channel::<String>();
let thread_name = format!("pipeview-log-{}", path.display());
thread::Builder::new().name(thread_name).spawn(move || {
while let Ok(line) = receiver.recv() {
if writeln!(writer, "{line}").is_err() {
break;
}
}
let _ = writer.flush();
})?;
Ok(Self { sender })
}
/// Send a line to the background writer. Non-blocking — drops the line
/// silently if the writer thread has stopped.
pub fn write_line(&self, line: &str) {
let _ = self.sender.send(line.to_owned());
}
}
impl Drop for LogWriter {
fn drop(&mut self) {
// Sender is dropped here, which causes the background thread's
// receiver.recv() to return Err, triggering flush + exit.
}
}
/// Format a [`DecodedEntry`] into a log line.
///
/// Format: `[HH:MM:SS.mmm] [IN] [pipeline] payload`
///
/// Returns `None` for Binary and Plot entries (not meaningful as text logs).
pub fn format_data_log(
entry: &DecodedEntry,
direction: &str,
show_timestamp: bool,
show_direction: bool,
show_pipeline: bool,
) -> Option<String> {
let data = match &entry.data {
DecodedData::Text(text) => text.clone(),
DecodedData::Hex(hex) => hex.clone(),
DecodedData::Binary(_) | DecodedData::Plot(_) => return None,
};
let mut parts = Vec::with_capacity(4);
if show_timestamp {
parts.push(utc_timestamp());
}
if show_direction {
parts.push(format!("[{direction}]"));
}
if show_pipeline {
parts.push(format!("[{}]", entry.pipeline_name));
}
parts.push(data);
Some(parts.join(" "))
}
/// Format a sent text string into a log line (no `DecodedEntry` available
/// for outbound data).
pub fn format_sent_log(text: &str) -> String {
format!("{} [OUT] {text}", utc_timestamp())
}
fn utc_timestamp() -> String {
let since_epoch = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = since_epoch.as_secs();
let ms = since_epoch.subsec_millis();
let h = (secs / 3600) % 24;
let m = (secs / 60) % 60;
let s = secs % 60;
format!("{h:02}:{m:02}:{s:02}.{ms:03}")
}

View File

@@ -0,0 +1,64 @@
#![cfg_attr(windows, windows_subsystem = "windows")]
mod app;
mod app_state;
mod buffers;
mod logging;
mod panels;
mod perf;
mod shortcuts;
mod ui_fonts;
use pipeview_client::SessionManager;
/// On Linux, when launched from a file manager, a terminal emulator may be spawned
/// to run this binary. We re-spawn ourselves detached so the terminal can close
/// while the GUI window persists.
#[cfg(target_os = "linux")]
fn ensure_detached() {
use std::os::unix::process::CommandExt;
use std::process;
if std::env::var("PIPEVIEW_DETACHED").is_ok() {
return;
}
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(_) => return,
};
let args: Vec<String> = std::env::args().skip(1).collect();
let _ = process::Command::new(&exe)
.args(&args)
.env("PIPEVIEW_DETACHED", "1")
.stdin(process::Stdio::null())
.stdout(process::Stdio::null())
.stderr(process::Stdio::null())
.process_group(0)
.spawn();
process::exit(0);
}
fn main() {
#[cfg(target_os = "linux")]
ensure_detached();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
let _guard = rt.enter();
let mgr = SessionManager::new();
let rx = mgr.subscribe();
let _ = eframe::run_native(
"pipeview",
eframe::NativeOptions::default(),
Box::new(|cc| Ok(Box::new(app::XserialApp::new(mgr, rx, cc.egui_ctx.clone())))),
);
}

View File

@@ -1,10 +1,10 @@
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
use xserial_core::protocol::Endian;
use xserial_core::protocol::plot::{PlotFormat, SampleType};
use xserial_core::protocol::text::TextEncoding;
use xserial_core::transport::TransportConfig;
use xserial_core::transport::serial::{
use pipeview_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
use pipeview_core::protocol::Endian;
use pipeview_core::protocol::plot::{PlotFormat, SampleType};
use pipeview_core::protocol::text::TextEncoding;
use pipeview_core::transport::TransportConfig;
use pipeview_core::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
};
@@ -878,7 +878,7 @@ fn validate_pipeline(ui: &mut Ui, pipeline: &PipelineForm, errors: &mut Vec<Stri
};
let sample_bytes = pipeline.plot_sample_type.byte_size();
let frame_unit = channel_count.saturating_mul(sample_bytes);
if frame_unit == 0 || pipeline.fixed_frame_len % frame_unit != 0 {
if frame_unit == 0 || !pipeline.fixed_frame_len.is_multiple_of(frame_unit) {
push_error(format!(
"Fixed frame length must be a multiple of {} bytes for the current plot layout",
frame_unit

View File

@@ -0,0 +1,129 @@
use crate::app::{DisplayOptions, SearchState};
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
use egui::{
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
text::{LayoutJob, TextFormat},
};
pub fn render(
ui: &mut Ui,
buf: &TextBuffer,
display: DisplayOptions,
search: Option<&SearchState>,
) -> usize {
let line_count = buf.len();
let row_height = ui.text_style_height(&TextStyle::Monospace);
ScrollArea::both().stick_to_bottom(true).show_rows(
ui,
row_height,
line_count,
|ui, row_range| {
for row in row_range {
if let Some(line) = buf.get(row) {
ui.add(
Label::new(format_console_line(line, display, search, ui.style()))
.wrap_mode(TextWrapMode::Extend),
);
}
}
},
);
line_count
}
fn highlight_matches(
text: &str,
query: &str,
case_sensitive: bool,
default_format: TextFormat,
highlight_format: TextFormat,
) -> LayoutJob {
let search_text = if case_sensitive {
text.to_string()
} else {
text.to_lowercase()
};
let query = if case_sensitive {
query.to_string()
} else {
query.to_lowercase()
};
let mut job = LayoutJob::default();
let mut last_end = 0;
let mut idx = 0;
while let Some(pos) = search_text[idx..].find(&query) {
let start = idx + pos;
let end = start + query.len();
if last_end < start {
job.append(&text[last_end..start], 0.0, default_format.clone());
}
job.append(&text[start..end], 0.0, highlight_format.clone());
last_end = end;
idx = end;
}
if last_end < text.len() {
job.append(&text[last_end..], 0.0, default_format);
}
job
}
fn monospace_format(style: &egui::Style) -> TextFormat {
TextFormat {
font_id: style
.text_styles
.get(&TextStyle::Monospace)
.cloned()
.unwrap_or_default(),
color: Color32::WHITE,
..Default::default()
}
}
pub fn format_console_line(
line: &ConsoleLine,
display: DisplayOptions,
search: Option<&SearchState>,
style: &egui::Style,
) -> LayoutJob {
let mut prefix = Vec::new();
if display.show_timestamp {
let ts = line.elapsed.as_secs_f64();
let time = if ts < 60.0 {
format!("{:05.2}", ts)
} else {
format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64)
};
prefix.push(format!("[{time}]"));
}
if display.show_direction {
let dir = match line.direction {
LineDirection::In => "IN",
LineDirection::Out => "OUT",
};
prefix.push(format!("[{dir}]"));
}
if display.show_pipeline {
prefix.push(format!("[{}]", line.pipeline));
}
let prefix = if prefix.is_empty() {
String::new()
} else {
format!("{} ", prefix.join(" "))
};
let full_text = format!("{prefix}{}", line.text);
let default = monospace_format(style);
let highlight = TextFormat {
font_id: default.font_id.clone(),
color: Color32::BLACK,
background: Color32::from_rgb(255, 255, 0),
..Default::default()
};
match search {
Some(state) if state.active && !state.query.is_empty() => {
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
}
_ => LayoutJob::single_section(full_text, default),
}
}

View File

@@ -0,0 +1,134 @@
use crate::app::{DisplayOptions, SearchState};
use crate::buffers::{HexBuffer, HexLine, LineDirection};
use egui::{
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
text::{LayoutJob, TextFormat},
};
pub fn render(
ui: &mut Ui,
buf: &HexBuffer,
display: DisplayOptions,
search: Option<&SearchState>,
) -> usize {
let line_count = buf.len();
if line_count == 0 {
ui.label("no hex data");
return 0;
}
let row_height = ui.text_style_height(&TextStyle::Monospace);
ScrollArea::both().stick_to_bottom(true).show_rows(
ui,
row_height,
line_count,
|ui, row_range| {
for row in row_range {
if let Some(line) = buf.get(row) {
ui.add(
Label::new(format_hex_line(line, display, search, ui.style()))
.wrap_mode(TextWrapMode::Extend),
);
}
}
},
);
line_count
}
fn highlight_matches(
text: &str,
query: &str,
case_sensitive: bool,
default_format: TextFormat,
highlight_format: TextFormat,
) -> LayoutJob {
let search_text = if case_sensitive {
text.to_string()
} else {
text.to_lowercase()
};
let query = if case_sensitive {
query.to_string()
} else {
query.to_lowercase()
};
let mut job = LayoutJob::default();
let mut last_end = 0;
let mut idx = 0;
while let Some(pos) = search_text[idx..].find(&query) {
let start = idx + pos;
let end = start + query.len();
if last_end < start {
job.append(&text[last_end..start], 0.0, default_format.clone());
}
job.append(&text[start..end], 0.0, highlight_format.clone());
last_end = end;
idx = end;
}
if last_end < text.len() {
job.append(&text[last_end..], 0.0, default_format);
}
job
}
fn monospace_format(style: &egui::Style) -> TextFormat {
TextFormat {
font_id: style
.text_styles
.get(&TextStyle::Monospace)
.cloned()
.unwrap_or_default(),
color: Color32::WHITE,
..Default::default()
}
}
fn format_hex_line(
line: &HexLine,
display: DisplayOptions,
search: Option<&SearchState>,
style: &egui::Style,
) -> LayoutJob {
let mut prefix = Vec::new();
if display.show_timestamp {
let ts = line.elapsed.as_secs_f64();
let time = if ts < 60.0 {
format!("{:05.2}", ts)
} else {
format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64)
};
prefix.push(format!("[{time}]"));
}
if display.show_direction {
let dir = match line.direction {
LineDirection::In => "IN",
LineDirection::Out => "OUT",
};
prefix.push(format!("[{dir}]"));
}
if display.show_pipeline {
prefix.push(format!("[{}]", line.pipeline));
}
let prefix = if prefix.is_empty() {
String::new()
} else {
format!("{} ", prefix.join(" "))
};
let full_text = format!("{prefix}{} |{}|", line.hex, line.ascii);
let default = monospace_format(style);
let highlight = TextFormat {
font_id: default.font_id.clone(),
color: Color32::BLACK,
background: Color32::from_rgb(255, 255, 0),
..Default::default()
};
match search {
Some(state) if state.active && !state.query.is_empty() => {
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
}
_ => LayoutJob::single_section(full_text, default),
}
}

View File

@@ -9,6 +9,7 @@ pub enum PlotDisplayMode {
Detached,
}
#[derive(Default)]
pub struct PlotViewState {
pub lock_x: bool,
pub lock_y: bool,
@@ -18,19 +19,6 @@ pub struct PlotViewState {
pub detached: bool,
}
impl Default for PlotViewState {
fn default() -> Self {
Self {
lock_x: false,
lock_y: false,
box_zoom: false,
follow_latest: false,
last_bounds: None,
detached: false,
}
}
}
pub struct PlotRenderOutput {
pub stats: PlotRenderStats,
pub toggle_detached: bool,

View File

@@ -202,7 +202,7 @@ impl GuiProfiler {
let repaints = self.repaint_counter.take();
info!(
target: "xserial_gui::perf",
target: "pipeview_gui::perf",
frames = self.interval_stats.frames.count,
frame_avg_ms = self.interval_stats.frames.avg_ms(),
frame_max_ms = self.interval_stats.frames.max_ms(),

View File

@@ -17,6 +17,10 @@ pub enum Action {
UiSettings,
CloseOverlay,
Search,
SearchNext,
SearchPrev,
}
pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
@@ -50,6 +54,12 @@ pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
CloseOverlay,
KeyboardShortcut::new(Modifiers::NONE, Key::Escape),
),
(Search, KeyboardShortcut::new(Modifiers::CTRL, Key::F)),
(SearchNext, KeyboardShortcut::new(Modifiers::NONE, Key::F3)),
(
SearchPrev,
KeyboardShortcut::new(Modifiers::SHIFT, Key::F3),
),
]
}
@@ -122,6 +132,9 @@ mod tests {
Action::ToggleConnect,
Action::Clear,
Action::UiSettings,
Action::Search,
Action::SearchNext,
Action::SearchPrev,
] {
assert!(is_char_based(action), "{action:?} should be char-based");
}

View File

@@ -10,19 +10,14 @@ use tracing::{info, warn};
const FONT_SETTINGS_FILE_NAME: &str = "gui-fonts.json";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FontChoice {
Auto,
#[default]
Default,
System(String),
}
impl Default for FontChoice {
fn default() -> Self {
Self::Default
}
}
#[derive(Clone, Debug)]
pub struct FontCandidate {
pub id: String,
@@ -363,12 +358,12 @@ fn font_settings_path_for_os_and_env(
"windows" => {
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("xserial")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME);
}
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("xserial")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME);
}
}
@@ -377,20 +372,20 @@ fn font_settings_path_for_os_and_env(
return PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("xserial")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME);
}
}
_ => {
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("xserial")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME);
}
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(home)
.join(".config")
.join("xserial")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME);
}
}
@@ -596,7 +591,7 @@ mod tests {
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = env::temp_dir().join(format!("xserial-gui-font-settings-{unique}"));
let dir = env::temp_dir().join(format!("pipeview-gui-font-settings-{unique}"));
let path = dir.join("gui-fonts.json");
let settings = UiFontSettings {
primary_choice: FontChoice::System(String::from("primary")),
@@ -627,7 +622,7 @@ mod tests {
assert_eq!(
path,
PathBuf::from("/tmp/xdg-config")
.join("xserial")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME)
);
}
@@ -641,7 +636,7 @@ mod tests {
assert_eq!(
path,
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
.join("xserial")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME)
);
}
@@ -657,7 +652,7 @@ mod tests {
PathBuf::from("/Users/tester")
.join("Library")
.join("Application Support")
.join("xserial")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME)
);
}

View File

@@ -1,15 +1,15 @@
[package]
name = "xserial-tui"
name = "pipeview-tui"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "xserial-tui"
name = "pipeview-tui"
path = "src/main.rs"
[dependencies]
xserial-core = { path = "../xserial-core" }
xserial-client = { path = "../xserial-client" }
pipeview-core = { path = "../pipeview-core" }
pipeview-client = { path = "../pipeview-client" }
tokio = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }

View File

@@ -3,12 +3,12 @@ use std::{io, time::Duration};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::DefaultTerminal;
use tokio::sync::broadcast;
use xserial_client::{
use pipeview_client::{
DecoderConfig, FramerConfig, PipelineConfig, SessionConfig, SessionEvent, SessionId,
SessionManager,
};
use xserial_core::protocol::DecodedData;
use xserial_core::transport::{
use pipeview_core::protocol::DecodedData;
use pipeview_core::transport::{
TransportConfig, TransportType,
serial::{SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport},
};
@@ -716,6 +716,7 @@ impl App {
}
}
#[allow(clippy::result_large_err)]
fn submit_session_form(&mut self, form: SessionForm) -> Result<(), (SessionForm, String)> {
let config = match build_session_config(&form) {
Ok(config) => config,
@@ -741,18 +742,14 @@ impl App {
fn on_normal_key(&mut self, code: KeyCode, modifiers: KeyModifiers) {
match code {
KeyCode::Char('q') => self.should_quit = true,
KeyCode::Char('j') => {
if self.active + 1 < self.tabs.len() {
KeyCode::Char('j') if self.active + 1 < self.tabs.len() => {
self.active += 1;
self.persist_state();
}
}
KeyCode::Char('k') => {
if self.active > 0 {
KeyCode::Char('k') if self.active > 0 => {
self.active -= 1;
self.persist_state();
}
}
KeyCode::Char('n') => self.open_create_form(),
KeyCode::Char('e') => self.open_edit_form(),
KeyCode::Char('x') => self.delete_active_session(),
@@ -854,13 +851,13 @@ impl App {
}
KeyCode::Enter => {
let mode = std::mem::replace(&mut self.mode, AppMode::Normal);
if let AppMode::SessionForm(form) = mode {
if let Err((form, err)) = self.submit_session_form(form) {
if let AppMode::SessionForm(form) = mode
&& let Err((form, err)) = self.submit_session_form(form)
{
self.mode = AppMode::SessionForm(form);
self.set_notice(err);
}
}
}
_ => {}
}
}
@@ -879,13 +876,12 @@ pub fn run(terminal: &mut DefaultTerminal, mut app: App) -> io::Result<()> {
app.drain_events();
terminal.draw(|frame| crate::ui::render(frame, &app))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
app.on_key(key.code, key.modifiers);
}
}
}
if app.should_quit() {
break;

View File

@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::warn;
use xserial_client::SessionConfig;
use pipeview_client::SessionConfig;
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
@@ -49,12 +49,12 @@ fn state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>)
"windows" => {
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("xserial")
.join("pipeview")
.join(TUI_STATE_FILE_NAME);
}
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("xserial")
.join("pipeview")
.join(TUI_STATE_FILE_NAME);
}
}
@@ -63,20 +63,20 @@ fn state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>)
return PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("xserial")
.join("pipeview")
.join(TUI_STATE_FILE_NAME);
}
}
_ => {
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("xserial")
.join("pipeview")
.join(TUI_STATE_FILE_NAME);
}
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(home)
.join(".config")
.join("xserial")
.join("pipeview")
.join(TUI_STATE_FILE_NAME);
}
}

View File

@@ -1,7 +1,7 @@
use std::time::{Duration, Instant};
use xserial_client::{DecodedEntry, RingBuffer};
use xserial_core::protocol::DecodedData;
use pipeview_client::{DecodedEntry, RingBuffer};
use pipeview_core::protocol::DecodedData;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum LineDirection {

View File

@@ -10,7 +10,7 @@ use crossterm::{
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use xserial_client::SessionManager;
use pipeview_client::SessionManager;
#[tokio::main]
async fn main() -> io::Result<()> {

View File

@@ -91,10 +91,7 @@ fn render_main(frame: &mut Frame, app: &App, area: Rect) {
sections[3],
);
if matches!(tab.status, ConnectionStatus::Error(_)) {
if let ConnectionStatus::Error(message) = &tab.status {
let _ = message;
}
if let ConnectionStatus::Error(_) = &tab.status {
}
} else {
frame.render_widget(
@@ -103,7 +100,7 @@ fn render_main(frame: &mut Frame, app: &App, area: Rect) {
app.help_text(),
app.notice().unwrap_or("")
))
.block(Block::default().borders(Borders::ALL).title("xserial-tui"))
.block(Block::default().borders(Borders::ALL).title("pipeview-tui"))
.wrap(Wrap { trim: false }),
area,
);

View File

@@ -1,27 +0,0 @@
mod app;
mod app_state;
mod buffers;
mod panels;
mod perf;
mod shortcuts;
mod ui_fonts;
use xserial_client::SessionManager;
fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
let _guard = rt.enter();
let mgr = SessionManager::new();
let rx = mgr.subscribe();
let _ = eframe::run_native(
"xserial",
eframe::NativeOptions::default(),
Box::new(|cc| Ok(Box::new(app::XserialApp::new(mgr, rx, cc.egui_ctx.clone())))),
);
}

View File

@@ -1,53 +0,0 @@
use crate::app::DisplayOptions;
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
use egui::{Label, RichText, ScrollArea, TextStyle, TextWrapMode, Ui};
pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) -> usize {
let line_count = buf.len();
let row_height = ui.text_style_height(&TextStyle::Monospace);
ScrollArea::vertical().stick_to_bottom(true).show_rows(
ui,
row_height,
line_count,
|ui, row_range| {
for row in row_range {
if let Some(line) = buf.get(row) {
ui.add(
Label::new(RichText::new(format_console_line(line, display)).monospace())
.wrap_mode(TextWrapMode::Extend),
);
}
}
},
);
line_count
}
fn format_console_line(line: &ConsoleLine, display: DisplayOptions) -> String {
let mut prefix = Vec::new();
if display.show_timestamp {
let ts = line.elapsed.as_secs_f64();
let time = if ts < 60.0 {
format!("{:05.2}", ts)
} else {
format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64)
};
prefix.push(format!("[{time}]"));
}
if display.show_direction {
let dir = match line.direction {
LineDirection::In => "IN",
LineDirection::Out => "OUT",
};
prefix.push(format!("[{dir}]"));
}
if display.show_pipeline {
prefix.push(format!("[{}]", line.pipeline));
}
let prefix = if prefix.is_empty() {
String::new()
} else {
format!("{} ", prefix.join(" "))
};
format!("{prefix}{}", line.text)
}

View File

@@ -1,57 +0,0 @@
use crate::app::DisplayOptions;
use crate::buffers::{HexBuffer, HexLine, LineDirection};
use egui::{Label, RichText, ScrollArea, TextStyle, TextWrapMode, Ui};
pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) -> usize {
let line_count = buf.len();
if line_count == 0 {
ui.label("no hex data");
return 0;
}
let row_height = ui.text_style_height(&TextStyle::Monospace);
ScrollArea::vertical().stick_to_bottom(true).show_rows(
ui,
row_height,
line_count,
|ui, row_range| {
for row in row_range {
if let Some(line) = buf.get(row) {
ui.add(
Label::new(RichText::new(format_hex_line(line, display)).monospace())
.wrap_mode(TextWrapMode::Extend),
);
}
}
},
);
line_count
}
fn format_hex_line(line: &HexLine, display: DisplayOptions) -> String {
let mut prefix = Vec::new();
if display.show_timestamp {
let ts = line.elapsed.as_secs_f64();
let time = if ts < 60.0 {
format!("{:05.2}", ts)
} else {
format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64)
};
prefix.push(format!("[{time}]"));
}
if display.show_direction {
let dir = match line.direction {
LineDirection::In => "IN",
LineDirection::Out => "OUT",
};
prefix.push(format!("[{dir}]"));
}
if display.show_pipeline {
prefix.push(format!("[{}]", line.pipeline));
}
let prefix = if prefix.is_empty() {
String::new()
} else {
format!("{} ", prefix.join(" "))
};
format!("{prefix}{} |{}|", line.hex, line.ascii)
}

16
examples/drone_plot.lua Normal file
View File

@@ -0,0 +1,16 @@
return {
decode = function(frame)
if #frame == 0 then return nil end
local gyro_str = frame:match("Gyro:([%d%.%-]+),([%d%.%-]+),([%d%.%-]+)")
if not gyro_str then return nil end
local gz, gy, gx = frame:match("Gyro:([%d%.%-]+),([%d%.%-]+),([%d%.%-]+)")
return {
kind = "plot",
channels = { { tonumber(gz) }, { tonumber(gy) }, { tonumber(gx) } },
sample_type = "F64",
format = "Block",
}
end,
}

33
examples/drone_text.lua Normal file
View File

@@ -0,0 +1,33 @@
local LABELS = {
AHRS = "Quaternion",
YPR = "Yaw/Pitch/Roll",
Gyro = "Gyro (deg/s)",
RC = "RC Channels",
M = "Motors",
L = "L",
F = "F",
C = "C",
}
return {
decode = function(frame)
if #frame == 0 then return nil end
local lines = {}
for segment in frame:gmatch("[^|]+") do
local colon = segment:find(":")
if colon then
local key = segment:sub(1, colon - 1)
local val = segment:sub(colon + 1)
local label = LABELS[key] or key
lines[#lines + 1] = string.format("%-18s %s", label .. ":", val)
end
end
lines[#lines + 1] = string.rep("-", 40)
return {
kind = "text",
data = table.concat(lines, "\n"),
}
end,
}

102
tools/test_drone.py Normal file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Generate fake drone telemetry data for testing pipeview Lua decoder."""
import argparse
import math
import random
import socket
import time
def generate_line(t: float) -> str:
"""Generate one telemetry line with simulated sensor values."""
# Quaternion (w, x, y, z) — gently rotating
angle = t * 0.5
qw = math.cos(angle)
qx = math.sin(angle) * 0.1
qy = math.sin(angle) * 0.05
qz = math.sin(angle) * 0.02
# Yaw/Pitch/Roll — sine wave simulation
yaw = math.degrees(math.sin(t * 0.3)) * 30
pitch = math.degrees(math.sin(t * 0.5)) * 15
roll = math.degrees(math.sin(t * 0.7)) * 10
# Gyro (deg/s)
gz = math.sin(t * 0.3) * 50 + random.uniform(-2, 2)
gy = math.sin(t * 0.5) * 30 + random.uniform(-2, 2)
gx = math.sin(t * 0.7) * 20 + random.uniform(-2, 2)
# RC channels (1000-2000 us)
rc_r = int(1500 + math.sin(t * 0.3) * 200)
rc_p = int(1500 + math.sin(t * 0.5) * 150)
rc_t = int(1000 + (math.sin(t * 0.1) + 1) * 500) # throttle 1000-2000
rc_y = int(1500 + math.sin(t * 0.3) * 100)
# Motors (1000-2000)
base = 1200 + int((math.sin(t * 0.1) + 1) * 400)
m1 = base + random.randint(-20, 20)
m2 = base + random.randint(-20, 20)
m3 = base + random.randint(-20, 20)
m4 = base + random.randint(-20, 20)
return (
f"AHRS q:{qw:.4f},{qx:.4f},{qy:.4f},{qz:.4f}|"
f"YPR:{yaw:.2f},{pitch:.2f},{roll:.2f}|"
f"Gyro:{gz:.2f},{gy:.2f},{gx:.2f}|"
f"RC:{rc_r},{rc_p},{rc_t},{rc_y}|"
f"M:{m1},{m2},{m3},{m4}|"
f"L:0 F:1 C:0\n"
)
def main():
parser = argparse.ArgumentParser(description="Drone telemetry test data generator")
parser.add_argument("--host", default="127.0.0.1", help="TCP host")
parser.add_argument("--port", type=int, default=8092, help="TCP port")
parser.add_argument("--rate", type=float, default=10, help="Lines per second")
parser.add_argument("--duration", type=float, default=0, help="Seconds to run (0 = forever)")
args = parser.parse_args()
interval = 1.0 / args.rate
print(f"Sending drone telemetry to {args.host}:{args.port} at {args.rate} Hz")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((args.host, args.port))
server.listen(1)
print(f"Listening on {args.host}:{args.port}, waiting for connections...")
while True:
sock, addr = server.accept()
print(f"Client connected from {addr}")
t0 = time.time()
seq = 0
try:
while True:
t = time.time() - t0
line = generate_line(t)
try:
sock.sendall(line.encode())
except (BrokenPipeError, ConnectionResetError):
break
seq += 1
if args.duration > 0 and t >= args.duration:
break
elapsed = time.time() - t0
target = (seq + 1) * interval
sleep_time = target - elapsed
if sleep_time > 0:
time.sleep(sleep_time)
except (BrokenPipeError, ConnectionResetError):
pass
print(f"Client disconnected. Sent {seq} lines. Waiting for new connection...")
if __name__ == "__main__":
main()

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""TCP server that sends plot waveform frames or text lines for xserial GUI."""
"""TCP server that sends plot waveform frames or text lines for pipeview GUI."""
import argparse
import math
@@ -116,7 +116,7 @@ def is_disconnect_error(err: OSError) -> bool:
def main() -> None:
parser = argparse.ArgumentParser(description="xserial plot test server")
parser = argparse.ArgumentParser(description="pipeview plot test server")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--channels", type=int, default=1)

View File

@@ -429,7 +429,7 @@ int main(int argc, char **argv) {
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("═══ pipeview 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);

View File

@@ -208,7 +208,7 @@ int main(int argc, char **argv) {
double interval = 1.0 / args.rate;
printf("═══ xserial Plain Text Generator ═══\n");
printf("═══ pipeview 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);