Compare commits
16 Commits
d489bc0831
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e600418da8 | |||
| e939b16d2d | |||
| a2c7c0fa71 | |||
| ab1c5882d1 | |||
| 2efbc9c284 | |||
| b3acb02d52 | |||
| afb8ca509a | |||
| 9cdbd5a892 | |||
| 8207b5e883 | |||
| 202b8261c2 | |||
| dc8f2eb8a4 | |||
| edec8113fb | |||
| 1fec5f8941 | |||
| 09bee86a08 | |||
| 18bdd2652a | |||
| 9b826d79da |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1 +1,8 @@
|
||||
/target
|
||||
|
||||
# Compiled binaries in tools/
|
||||
tools/test_plot_c
|
||||
tools/test_text_c
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
28
AGENTS.md
28
AGENTS.md
@@ -1,28 +0,0 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
`xserial` is a Rust workspace. The root [Cargo.toml](/D:/Dev/xserial/Cargo.toml) defines four crates in `crates/`:
|
||||
- `xserial-core`: transport, framing, protocol, and pipeline primitives in `src/`, with integration tests in `tests/pipeline.rs`.
|
||||
- `xserial-client`: session management, history, commands, and Lua bindings, with integration tests under `tests/`.
|
||||
- `xserial-tui`: terminal app entrypoint in `src/main.rs`.
|
||||
- `xserial-gui`: egui/eframe app, with UI panels in `src/panels/` and font helpers in `src/ui_fonts.rs`.
|
||||
|
||||
Use `tools/test_plot.py` as a local waveform source for GUI plot testing. Treat `target/` as generated output.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `cargo check --workspace`: fast compile check for all crates.
|
||||
- `cargo test --workspace`: run the workspace test suite, including `crates/xserial-core/tests` and `crates/xserial-client/tests`.
|
||||
- `cargo run -p xserial-gui`: launch the desktop GUI.
|
||||
- `cargo run -p xserial-tui`: launch the terminal UI.
|
||||
- `cargo fmt --all`: apply standard Rust formatting.
|
||||
- `cargo clippy --workspace --all-targets -- -D warnings`: catch lint issues before review.
|
||||
- `python tools/test_plot.py --help`: inspect options for the TCP plot-frame generator.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Follow standard Rust formatting: 4-space indentation, trailing commas in multiline literals, and `snake_case` for modules, files, functions, and tests. Use `PascalCase` for structs/enums and `SCREAMING_SNAKE_CASE` for constants. Keep crate boundaries clean: protocol/transport code belongs in `xserial-core`; session and Lua integration belong in `xserial-client`; UI state stays in `xserial-gui` or `xserial-tui`.
|
||||
|
||||
## Testing Guidelines
|
||||
Prefer focused unit tests next to implementation and integration tests in `crates/<crate>/tests/*.rs`. Name tests after behavior, for example `session_lifecycle` or `pipeline`. Run `cargo test --workspace` before opening a PR; add targeted regression tests for transport, framing, parsing, and session-state fixes.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Recent history uses short, imperative subjects such as `Persist GUI font settings` and `Improve session controls and GUI views`, with occasional `feat:` prefixes for major additions. Keep subjects concise and action-oriented. PRs should state which crate(s) changed, list validation commands, and include screenshots or short recordings for GUI/TUI changes. Call out protocol, transport, or Lua API compatibility impacts explicitly.
|
||||
133
Cargo.lock
generated
133
Cargo.lock
generated
@@ -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,71 +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",
|
||||
"ratatui",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"xserial-client",
|
||||
"xserial-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
|
||||
@@ -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]
|
||||
|
||||
394
README.md
Normal file
394
README.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# pipeview
|
||||
|
||||
**跨平台串口 / TCP / UDP 数据观测工具** — 可配置分帧、协议解码、多会话管理、实时波形绘图,单二进制零依赖。
|
||||
|
||||
基于 Rust + [egui](https://github.com/emilk/egui) 构建,支持 Lua 脚本扩展。
|
||||
|
||||
[English](README_en.md)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [功能特性](#功能特性)
|
||||
- [快速开始](#快速开始)
|
||||
- [完整示例:无人机遥测解析](#完整示例无人机遥测解析)
|
||||
- [Lua 脚本开发指南](#lua-脚本开发指南)
|
||||
- [架构](#架构)
|
||||
- [配置与持久化](#配置与持久化)
|
||||
- [开发工具](#开发工具)
|
||||
- [技术栈](#技术栈)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 传输层
|
||||
|
||||
| 类型 | 说明 | 配置项 |
|
||||
|------|------|--------|
|
||||
| **Serial** | 串口通信 | 端口名、波特率 (300–12M)、数据位 (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` (i8–f64)、`endian`、`channels` (1–64)、`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 条历史记录,可配置 100–1,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
|
||||
# 克隆项目
|
||||
git clone https://github.com/your-org/pipeview.git
|
||||
cd pipeview
|
||||
|
||||
# 编译运行
|
||||
cargo run -p pipeview-gui
|
||||
|
||||
# 运行测试
|
||||
cargo test --workspace # ~308 个测试
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
### 发布构建
|
||||
|
||||
```bash
|
||||
cargo build -p pipeview-gui --release
|
||||
# 二进制位于 target/release/pipeview-gui (Linux/macOS)
|
||||
# 或 target/release/pipeview-gui.exe (Windows)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整示例:无人机遥测解析
|
||||
|
||||
以 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
|
||||
python tools/test_plot.py --wire-format mixed --format xy --channels 2
|
||||
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
|
||||
|
||||
# 无人机遥测测试数据
|
||||
python tools/test_drone.py --rate 10 --port 8092
|
||||
```
|
||||
|
||||
### 性能分析
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
输出每帧的耗时、事件 drain 耗时、text/hex/plot 渲染耗时、plot 点数统计。
|
||||
|
||||
### 日志
|
||||
|
||||
```bash
|
||||
RUST_LOG=info cargo run -p pipeview-gui # 应用日志
|
||||
RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui # 详细日志
|
||||
```
|
||||
|
||||
使用 `tracing-subscriber` + `RUST_LOG` 环境变量控制。
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
crates/
|
||||
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 协议参考头文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **运行时**: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、零条件编译
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
378
README_en.md
Normal file
378
README_en.md
Normal 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 (300–12M), 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` (i8–f64), `endian`, `channels` (1–64), `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 100–1,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
|
||||
@@ -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 }
|
||||
@@ -8,4 +8,6 @@ pub enum SessionCmd {
|
||||
SetAutoReconnect(bool),
|
||||
Close,
|
||||
Reconfigure(SessionConfig),
|
||||
SetDtr(bool),
|
||||
SetRts(bool),
|
||||
}
|
||||
@@ -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,17 @@ 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};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum FramerConfig {
|
||||
@@ -49,6 +52,9 @@ pub enum FramerConfig {
|
||||
#[serde(default = "default_max_frame")]
|
||||
max_plot_frame: usize,
|
||||
},
|
||||
Lua {
|
||||
script_path: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -68,15 +74,15 @@ fn default_max_frame() -> usize {
|
||||
}
|
||||
|
||||
impl FramerConfig {
|
||||
pub fn build(self) -> Box<dyn Framer> {
|
||||
match self {
|
||||
pub fn build(self) -> Result<Box<dyn Framer>> {
|
||||
Ok(match self {
|
||||
FramerConfig::Line {
|
||||
strip_cr,
|
||||
max_line_len,
|
||||
} => Box::new(LineFramer::new(LineConfig {
|
||||
strip_cr,
|
||||
max_line_len,
|
||||
})),
|
||||
})) as Box<dyn Framer>,
|
||||
FramerConfig::Fixed { frame_len } => Box::new(FixedLengthFramer::new(frame_len)),
|
||||
FramerConfig::Length {
|
||||
len_bytes,
|
||||
@@ -99,7 +105,10 @@ impl FramerConfig {
|
||||
max_line_len,
|
||||
max_plot_frame,
|
||||
})),
|
||||
FramerConfig::Lua { script_path } => {
|
||||
Box::new(LuaFramer::from_script_path(script_path)?)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +141,9 @@ pub enum DecoderConfig {
|
||||
#[serde(default)]
|
||||
encoding: TextEncoding,
|
||||
},
|
||||
Lua {
|
||||
script_path: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_sep() -> String {
|
||||
@@ -142,9 +154,11 @@ fn default_one() -> usize {
|
||||
}
|
||||
|
||||
impl DecoderConfig {
|
||||
pub fn build(self) -> Box<dyn ProtocolDecoder> {
|
||||
match self {
|
||||
DecoderConfig::Text { encoding } => Box::new(TextDecoder::new(encoding)),
|
||||
pub fn build(self) -> Result<Box<dyn ProtocolDecoder>> {
|
||||
Ok(match self {
|
||||
DecoderConfig::Text { encoding } => {
|
||||
Box::new(TextDecoder::new(encoding)) as Box<dyn ProtocolDecoder>
|
||||
}
|
||||
DecoderConfig::Hex {
|
||||
uppercase,
|
||||
separator,
|
||||
@@ -170,7 +184,10 @@ impl DecoderConfig {
|
||||
DecoderConfig::MixedTextPlot { encoding } => {
|
||||
Box::new(MixedTextPlotDecoder::new(MixedDecoderConfig { encoding }))
|
||||
}
|
||||
DecoderConfig::Lua { script_path } => {
|
||||
Box::new(LuaDecoder::from_script_path(script_path)?)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,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() {
|
||||
@@ -285,6 +302,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framer_lua_serde() {
|
||||
let cfg: FramerConfig =
|
||||
serde_json::from_str(r#"{"Lua":{"script_path":"/tmp/framer.lua"}}"#).unwrap();
|
||||
match cfg {
|
||||
FramerConfig::Lua { script_path } => assert_eq!(script_path, "/tmp/framer.lua"),
|
||||
_ => panic!("expected Lua"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_text_serde() {
|
||||
let cfg: DecoderConfig = serde_json::from_str(r#"{"Text":{"encoding":"Latin1"}}"#).unwrap();
|
||||
@@ -345,6 +372,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_lua_serde() {
|
||||
let cfg: DecoderConfig =
|
||||
serde_json::from_str(r#"{"Lua":{"script_path":"/tmp/decoder.lua"}}"#).unwrap();
|
||||
match cfg {
|
||||
DecoderConfig::Lua { script_path } => assert_eq!(script_path, "/tmp/decoder.lua"),
|
||||
_ => panic!("expected Lua"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_config_full_roundtrip() {
|
||||
let json = r#"{
|
||||
@@ -388,7 +425,7 @@ mod tests {
|
||||
max_plot_frame: 1024,
|
||||
},
|
||||
] {
|
||||
let f = cfg.build();
|
||||
let f = cfg.build().unwrap();
|
||||
assert_eq!(f.pending_len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -400,6 +437,7 @@ mod tests {
|
||||
encoding: TextEncoding::Utf8
|
||||
}
|
||||
.build()
|
||||
.unwrap()
|
||||
.name(),
|
||||
"Text"
|
||||
);
|
||||
@@ -411,6 +449,7 @@ mod tests {
|
||||
endian: Endian::Big
|
||||
}
|
||||
.build()
|
||||
.unwrap()
|
||||
.name(),
|
||||
"Hex"
|
||||
);
|
||||
@@ -422,6 +461,7 @@ mod tests {
|
||||
format: PlotFormat::XY
|
||||
}
|
||||
.build()
|
||||
.unwrap()
|
||||
.name(),
|
||||
"Plot"
|
||||
);
|
||||
@@ -430,6 +470,7 @@ mod tests {
|
||||
encoding: TextEncoding::Utf8
|
||||
}
|
||||
.build()
|
||||
.unwrap()
|
||||
.name(),
|
||||
"MixedTextPlot"
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
use xserial_core::protocol::DecodedData;
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecodedEntry {
|
||||
@@ -28,6 +28,9 @@ impl<T> RingBuffer<T> {
|
||||
pub fn iter(&self) -> impl Iterator<Item = &T> {
|
||||
self.buf.iter()
|
||||
}
|
||||
pub fn get(&self, index: usize) -> Option<&T> {
|
||||
self.buf.get(index)
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.buf.clear();
|
||||
}
|
||||
404
crates/pipeview-client/src/lua/codec.rs
Normal file
404
crates/pipeview-client/src/lua/codec.rs
Normal file
@@ -0,0 +1,404 @@
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use mlua::{Function, Lua, RegistryKey, Table, Value};
|
||||
use tracing::warn;
|
||||
use pipeview_core::frame::Framer;
|
||||
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame, SampleType};
|
||||
use pipeview_core::protocol::{DecodedData, ProtocolDecoder};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
pub struct LuaFramer {
|
||||
lua: Lua,
|
||||
feed: RegistryKey,
|
||||
flush: RegistryKey,
|
||||
reset: RegistryKey,
|
||||
pending_len: RegistryKey,
|
||||
script_path: String,
|
||||
}
|
||||
|
||||
impl LuaFramer {
|
||||
pub fn from_script_path(script_path: impl Into<String>) -> Result<Self> {
|
||||
let script_path = script_path.into();
|
||||
let lua = Lua::new();
|
||||
let table = load_script_table(&lua, &script_path)?;
|
||||
let feed = registry_function(&lua, &table, "feed", &script_path)?;
|
||||
let flush = registry_function(&lua, &table, "flush", &script_path)?;
|
||||
let reset = registry_function(&lua, &table, "reset", &script_path)?;
|
||||
let pending_len = registry_function(&lua, &table, "pending_len", &script_path)?;
|
||||
|
||||
Ok(Self {
|
||||
lua,
|
||||
feed,
|
||||
flush,
|
||||
reset,
|
||||
pending_len,
|
||||
script_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Framer for LuaFramer {
|
||||
fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
|
||||
let result = (|| -> mlua::Result<Vec<Vec<u8>>> {
|
||||
let function: Function = self.lua.registry_value(&self.feed)?;
|
||||
let bytes = self.lua.create_string(data)?;
|
||||
frames_from_value(function.call::<Value>(bytes)?)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(frames) => frames,
|
||||
Err(err) => {
|
||||
warn!(script = %self.script_path, error = %err, "lua framer feed failed");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Option<Vec<u8>> {
|
||||
let result = (|| -> mlua::Result<Option<Vec<u8>>> {
|
||||
let function: Function = self.lua.registry_value(&self.flush)?;
|
||||
optional_bytes_from_value(function.call::<Value>(())?)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(frame) => frame,
|
||||
Err(err) => {
|
||||
warn!(script = %self.script_path, error = %err, "lua framer flush failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
let result = (|| -> mlua::Result<()> {
|
||||
let function: Function = self.lua.registry_value(&self.reset)?;
|
||||
function.call::<()>(())
|
||||
})();
|
||||
|
||||
if let Err(err) = result {
|
||||
warn!(script = %self.script_path, error = %err, "lua framer reset failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_len(&self) -> usize {
|
||||
let result = (|| -> mlua::Result<usize> {
|
||||
let function: Function = self.lua.registry_value(&self.pending_len)?;
|
||||
function.call::<usize>(())
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(len) => len,
|
||||
Err(err) => {
|
||||
warn!(script = %self.script_path, error = %err, "lua framer pending_len failed");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LuaDecoder {
|
||||
lua: Lua,
|
||||
decode: RegistryKey,
|
||||
script_path: String,
|
||||
call_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl LuaDecoder {
|
||||
pub fn from_script_path(script_path: impl Into<String>) -> Result<Self> {
|
||||
let script_path = script_path.into();
|
||||
let lua = Lua::new();
|
||||
let table = load_script_table(&lua, &script_path)?;
|
||||
let decode = registry_function(&lua, &table, "decode", &script_path)?;
|
||||
|
||||
Ok(Self {
|
||||
lua,
|
||||
decode,
|
||||
script_path,
|
||||
call_lock: Mutex::new(()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolDecoder for LuaDecoder {
|
||||
fn name(&self) -> &str {
|
||||
"Lua"
|
||||
}
|
||||
|
||||
fn decode(&self, frame: &[u8]) -> Option<DecodedData> {
|
||||
let _guard = self
|
||||
.call_lock
|
||||
.lock()
|
||||
.expect("lua decoder call mutex poisoned");
|
||||
let result = (|| -> mlua::Result<Option<DecodedData>> {
|
||||
let function: Function = self.lua.registry_value(&self.decode)?;
|
||||
let bytes = self.lua.create_string(frame)?;
|
||||
decoded_from_value(function.call::<Value>(bytes)?, frame)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(decoded) => decoded,
|
||||
Err(err) => {
|
||||
warn!(script = %self.script_path, error = %err, "lua decoder decode failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_script_table(lua: &Lua, script_path: &str) -> Result<Table> {
|
||||
let script = fs::read_to_string(script_path)?;
|
||||
match lua.load(&script).eval::<Value>()? {
|
||||
Value::Table(table) => Ok(table),
|
||||
other => Err(Error::Other(format!(
|
||||
"lua script '{script_path}' must return a table, got {}",
|
||||
other.type_name()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn registry_function(
|
||||
lua: &Lua,
|
||||
table: &Table,
|
||||
name: &str,
|
||||
script_path: &str,
|
||||
) -> Result<RegistryKey> {
|
||||
let value: Value = table.get(name)?;
|
||||
match value {
|
||||
Value::Function(function) => Ok(lua.create_registry_value(function)?),
|
||||
other => Err(Error::Other(format!(
|
||||
"lua script '{script_path}' field '{name}' must be a function, got {}",
|
||||
other.type_name()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn frames_from_value(value: Value) -> mlua::Result<Vec<Vec<u8>>> {
|
||||
match value {
|
||||
Value::Nil => Ok(Vec::new()),
|
||||
Value::String(bytes) => Ok(vec![bytes.as_bytes().to_vec()]),
|
||||
Value::Table(table) => {
|
||||
let mut frames = Vec::new();
|
||||
for value in table.sequence_values::<mlua::String>() {
|
||||
frames.push(value?.as_bytes().to_vec());
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
other => Err(mlua::Error::RuntimeError(format!(
|
||||
"framer feed must return nil, string, or table of strings, got {}",
|
||||
other.type_name()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_bytes_from_value(value: Value) -> mlua::Result<Option<Vec<u8>>> {
|
||||
match value {
|
||||
Value::Nil => Ok(None),
|
||||
Value::String(bytes) => Ok(Some(bytes.as_bytes().to_vec())),
|
||||
other => Err(mlua::Error::RuntimeError(format!(
|
||||
"framer flush must return nil or string, got {}",
|
||||
other.type_name()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn decoded_from_value(value: Value, raw: &[u8]) -> mlua::Result<Option<DecodedData>> {
|
||||
match value {
|
||||
Value::Nil => Ok(None),
|
||||
Value::String(text) => Ok(Some(DecodedData::Text(text.to_str()?.to_string()))),
|
||||
Value::Table(table) => decoded_from_table(table, raw).map(Some),
|
||||
other => Err(mlua::Error::RuntimeError(format!(
|
||||
"decoder decode must return nil, string, or table, got {}",
|
||||
other.type_name()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn decoded_from_table(table: Table, raw: &[u8]) -> mlua::Result<DecodedData> {
|
||||
let kind: String = table.get("kind")?;
|
||||
match kind.as_str() {
|
||||
"text" => Ok(DecodedData::Text(table.get("data")?)),
|
||||
"hex" => Ok(DecodedData::Hex(table.get("data")?)),
|
||||
"binary" => {
|
||||
let bytes: mlua::String = table.get("data")?;
|
||||
Ok(DecodedData::Binary(bytes.as_bytes().to_vec()))
|
||||
}
|
||||
"plot" => Ok(DecodedData::Plot(plot_from_table(table, raw)?)),
|
||||
other => Err(mlua::Error::RuntimeError(format!(
|
||||
"unknown lua decoder kind '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn plot_from_table(table: Table, raw: &[u8]) -> mlua::Result<PlotFrame> {
|
||||
let channels_table: Table = table.get("channels")?;
|
||||
let mut channels = Vec::new();
|
||||
for channel in channels_table.sequence_values::<Table>() {
|
||||
let channel = channel?;
|
||||
let mut samples = Vec::new();
|
||||
for sample in channel.sequence_values::<f64>() {
|
||||
samples.push(sample?);
|
||||
}
|
||||
channels.push(samples);
|
||||
}
|
||||
|
||||
Ok(PlotFrame {
|
||||
channels,
|
||||
raw: raw.to_vec(),
|
||||
sample_type: match table.get::<Option<String>>("sample_type")?.as_deref() {
|
||||
Some(value) => parse_sample_type(value)?,
|
||||
None => SampleType::F64,
|
||||
},
|
||||
format: match table.get::<Option<String>>("format")?.as_deref() {
|
||||
Some(value) => parse_plot_format(value)?,
|
||||
None => PlotFormat::Interleaved,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_sample_type(value: &str) -> mlua::Result<SampleType> {
|
||||
match value {
|
||||
"i8" | "I8" => Ok(SampleType::I8),
|
||||
"u8" | "U8" => Ok(SampleType::U8),
|
||||
"i16" | "I16" => Ok(SampleType::I16),
|
||||
"u16" | "U16" => Ok(SampleType::U16),
|
||||
"i32" | "I32" => Ok(SampleType::I32),
|
||||
"u32" | "U32" => Ok(SampleType::U32),
|
||||
"i64" | "I64" => Ok(SampleType::I64),
|
||||
"u64" | "U64" => Ok(SampleType::U64),
|
||||
"f32" | "F32" => Ok(SampleType::F32),
|
||||
"f64" | "F64" => Ok(SampleType::F64),
|
||||
other => Err(mlua::Error::RuntimeError(format!(
|
||||
"unknown plot sample_type '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_plot_format(value: &str) -> mlua::Result<PlotFormat> {
|
||||
match value {
|
||||
"Interleaved" | "interleaved" => Ok(PlotFormat::Interleaved),
|
||||
"Block" | "block" => Ok(PlotFormat::Block),
|
||||
"XY" | "xy" => Ok(PlotFormat::XY),
|
||||
other => Err(mlua::Error::RuntimeError(format!(
|
||||
"unknown plot format '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn write_script(name: &str, script: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("pipeview_{name}_{nonce}.lua"));
|
||||
fs::write(&path, script).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_framer_splits_lines_across_chunks() {
|
||||
let path = write_script(
|
||||
"framer",
|
||||
r#"
|
||||
local buffer = ""
|
||||
return {
|
||||
feed = function(bytes)
|
||||
buffer = buffer .. bytes
|
||||
local frames = {}
|
||||
while true do
|
||||
local i = buffer:find("\n", 1, true)
|
||||
if not i then break end
|
||||
frames[#frames + 1] = buffer:sub(1, i - 1)
|
||||
buffer = buffer:sub(i + 1)
|
||||
end
|
||||
return frames
|
||||
end,
|
||||
flush = function()
|
||||
if #buffer == 0 then return nil end
|
||||
local frame = buffer
|
||||
buffer = ""
|
||||
return frame
|
||||
end,
|
||||
reset = function()
|
||||
buffer = ""
|
||||
end,
|
||||
pending_len = function()
|
||||
return #buffer
|
||||
end,
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut framer = LuaFramer::from_script_path(path.to_string_lossy()).unwrap();
|
||||
assert_eq!(framer.feed(b"one\nt"), vec![b"one".to_vec()]);
|
||||
assert_eq!(framer.pending_len(), 1);
|
||||
assert_eq!(framer.feed(b"wo\nthree"), vec![b"two".to_vec()]);
|
||||
assert_eq!(framer.flush(), Some(b"three".to_vec()));
|
||||
assert_eq!(framer.pending_len(), 0);
|
||||
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_decoder_maps_text_binary_and_plot() {
|
||||
let path = write_script(
|
||||
"decoder",
|
||||
r#"
|
||||
return {
|
||||
decode = function(frame)
|
||||
if frame == "skip" then
|
||||
return nil
|
||||
elseif frame == "bin" then
|
||||
return { kind = "binary", data = "\1\2" }
|
||||
elseif frame == "plot" then
|
||||
return {
|
||||
kind = "plot",
|
||||
channels = { { 1.0, 2.0 }, { 3.5, 4.5 } },
|
||||
sample_type = "F64",
|
||||
format = "Block",
|
||||
}
|
||||
end
|
||||
return { kind = "text", data = "decoded:" .. frame }
|
||||
end,
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
let decoder = LuaDecoder::from_script_path(path.to_string_lossy()).unwrap();
|
||||
assert!(decoder.decode(b"skip").is_none());
|
||||
assert!(matches!(
|
||||
decoder.decode(b"abc"),
|
||||
Some(DecodedData::Text(text)) if text == "decoded:abc"
|
||||
));
|
||||
assert!(matches!(
|
||||
decoder.decode(b"bin"),
|
||||
Some(DecodedData::Binary(bytes)) if bytes == vec![1, 2]
|
||||
));
|
||||
match decoder.decode(b"plot").unwrap() {
|
||||
DecodedData::Plot(frame) => {
|
||||
assert_eq!(frame.channels, vec![vec![1.0, 2.0], vec![3.5, 4.5]]);
|
||||
assert_eq!(frame.sample_type, SampleType::F64);
|
||||
assert_eq!(frame.format, PlotFormat::Block);
|
||||
assert_eq!(frame.raw, b"plot");
|
||||
}
|
||||
other => panic!("expected plot, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_framer_requires_expected_functions() {
|
||||
let path = write_script("bad_framer", "return { feed = function() return {} end }");
|
||||
let result = LuaFramer::from_script_path(path.to_string_lossy());
|
||||
assert!(result.is_err());
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use mlua::{Lua, LuaSerdeExt, Result as LuaResult, Table, Value};
|
||||
use crate::config::SessionConfig;
|
||||
use crate::manager::SessionManager;
|
||||
|
||||
pub(crate) mod codec;
|
||||
mod session_api;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -80,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();
|
||||
@@ -95,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();
|
||||
@@ -115,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();
|
||||
@@ -123,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);
|
||||
@@ -131,7 +132,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
||||
})?,
|
||||
)?;
|
||||
|
||||
lua.globals().set("xserial", xserial)?;
|
||||
lua.globals().set("pipeview", pipeview)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -145,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"
|
||||
@@ -161,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);
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
@@ -138,6 +138,22 @@ impl SessionHandle {
|
||||
.map_err(|_| String::from("session closed"))
|
||||
}
|
||||
|
||||
pub async fn set_dtr(&self, state: bool) -> Result<(), String> {
|
||||
self.inner
|
||||
.cmd_tx
|
||||
.send(SessionCmd::SetDtr(state))
|
||||
.await
|
||||
.map_err(|_| String::from("session closed"))
|
||||
}
|
||||
|
||||
pub async fn set_rts(&self, state: bool) -> Result<(), String> {
|
||||
self.inner
|
||||
.cmd_tx
|
||||
.send(SessionCmd::SetRts(state))
|
||||
.await
|
||||
.map_err(|_| String::from("session closed"))
|
||||
}
|
||||
|
||||
pub async fn read(&self, timeout_ms: u64) -> Option<DecodedEntry> {
|
||||
let mut rx = self.inner.event_tx.subscribe();
|
||||
let deadline = Duration::from_millis(timeout_ms);
|
||||
@@ -176,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 {
|
||||
@@ -190,6 +206,7 @@ pub struct Session {
|
||||
config: SessionConfig,
|
||||
conn: Option<Connection>,
|
||||
pipeline: MultiPipeline,
|
||||
pipeline_build_error: Option<String>,
|
||||
history: RingBuffer<DecodedEntry>,
|
||||
cmd_rx: mpsc::Receiver<SessionCmd>,
|
||||
event_tx: broadcast::Sender<SessionEvent>,
|
||||
@@ -201,9 +218,17 @@ impl Session {
|
||||
pub fn spawn(id: SessionId, config: SessionConfig) -> SessionHandle {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel(32);
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
let (pipeline, pipeline_build_error) = match Self::build_pipeline(&config) {
|
||||
Ok(pipeline) => (pipeline, None),
|
||||
Err(err) => (
|
||||
MultiPipeline::new(),
|
||||
Some(format!("pipeline build failed: {err}")),
|
||||
),
|
||||
};
|
||||
let mut session = Self {
|
||||
id,
|
||||
pipeline: Self::build_pipeline(&config),
|
||||
pipeline,
|
||||
pipeline_build_error,
|
||||
history: RingBuffer::new(config.history_limit),
|
||||
config,
|
||||
conn: None,
|
||||
@@ -217,22 +242,29 @@ impl Session {
|
||||
SessionHandle::new(inner)
|
||||
}
|
||||
|
||||
fn build_pipeline(config: &SessionConfig) -> MultiPipeline {
|
||||
fn build_pipeline(config: &SessionConfig) -> crate::Result<MultiPipeline> {
|
||||
let mut pipelines = MultiPipeline::new();
|
||||
for pipeline in &config.pipelines {
|
||||
pipelines.add(Pipeline::new(
|
||||
pipeline.name.clone(),
|
||||
pipeline.framer.clone().build(),
|
||||
pipeline.decoder.clone().build(),
|
||||
pipeline.framer.clone().build()?,
|
||||
pipeline.decoder.clone().build()?,
|
||||
));
|
||||
}
|
||||
pipelines
|
||||
Ok(pipelines)
|
||||
}
|
||||
|
||||
async fn run(&mut self) {
|
||||
info!(session_id = self.id, "session started");
|
||||
|
||||
if let Err(err) = self.connect().await {
|
||||
if let Some(message) = self.pipeline_build_error.take() {
|
||||
self.emit_error(message);
|
||||
self.desired_connected = false;
|
||||
}
|
||||
|
||||
if self.desired_connected
|
||||
&& let Err(err) = self.connect().await
|
||||
{
|
||||
warn!(session_id = self.id, error = %err, "initial connect failed");
|
||||
self.emit_error(format!("connect failed: {err}"));
|
||||
}
|
||||
@@ -272,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) => {
|
||||
@@ -304,6 +336,14 @@ impl Session {
|
||||
self.handle_reconfigure(config).await;
|
||||
true
|
||||
}
|
||||
Some(SessionCmd::SetDtr(state)) => {
|
||||
self.handle_set_dtr(state);
|
||||
true
|
||||
}
|
||||
Some(SessionCmd::SetRts(state)) => {
|
||||
self.handle_set_rts(state);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
@@ -322,9 +362,16 @@ impl Session {
|
||||
|
||||
async fn handle_reconfigure(&mut self, config: SessionConfig) {
|
||||
info!(session_id = self.id, "reconfiguring session");
|
||||
let pipeline = match Self::build_pipeline(&config) {
|
||||
Ok(pipeline) => pipeline,
|
||||
Err(err) => {
|
||||
self.emit_error(format!("pipeline build failed: {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.disconnect(true).await;
|
||||
self.config = config;
|
||||
self.pipeline = Self::build_pipeline(&self.config);
|
||||
self.pipeline = pipeline;
|
||||
self.history = RingBuffer::new(self.config.history_limit);
|
||||
|
||||
if let Err(err) = self.connect().await {
|
||||
@@ -332,6 +379,28 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_set_dtr(&mut self, state: bool) {
|
||||
match self.conn.as_mut() {
|
||||
Some(conn) => {
|
||||
if let Err(err) = conn.set_dtr(state) {
|
||||
self.emit_error(format!("set_dtr: {err}"));
|
||||
}
|
||||
}
|
||||
None => self.emit_error(String::from("set_dtr: session not connected")),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_set_rts(&mut self, state: bool) {
|
||||
match self.conn.as_mut() {
|
||||
Some(conn) => {
|
||||
if let Err(err) = conn.set_rts(state) {
|
||||
self.emit_error(format!("set_rts: {err}"));
|
||||
}
|
||||
}
|
||||
None => self.emit_error(String::from("set_rts: session not connected")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_read_result(
|
||||
&mut self,
|
||||
result: Result<Vec<DecodedEntry>, std::io::Error>,
|
||||
@@ -430,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)]
|
||||
@@ -441,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 {
|
||||
@@ -451,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()
|
||||
@@ -507,6 +576,7 @@ mod tests {
|
||||
let handle = Session::spawn(2, tcp_config());
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let _ = handle.send(b"data".to_vec()).await;
|
||||
let _ = handle.close().await;
|
||||
handle.join().await;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::sync::Once;
|
||||
use std::time::Duration;
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use mlua::Lua;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
@@ -10,12 +11,22 @@ 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();
|
||||
});
|
||||
}
|
||||
|
||||
fn write_temp_lua(name: &str, script: &str) -> PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("pipeview_{name}_{nonce}.lua"));
|
||||
fs::write(&path, script).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn text_pipeline_lua() -> &'static str {
|
||||
r#"
|
||||
{
|
||||
@@ -36,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()
|
||||
@@ -55,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();
|
||||
@@ -73,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() {
|
||||
@@ -91,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 = {{{}}}
|
||||
}})
|
||||
@@ -130,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 = {{{}}}
|
||||
}})
|
||||
@@ -151,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() {
|
||||
@@ -165,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 = {{{}}}
|
||||
}})
|
||||
@@ -190,6 +201,102 @@ async fn lua_session_hex_decoder() {
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
// ── pipeview.open with Lua framer + Lua decoder ───────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn lua_session_custom_lua_pipeline() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
|
||||
let framer_path = write_temp_lua(
|
||||
"custom_framer",
|
||||
r#"
|
||||
local buffer = ""
|
||||
return {
|
||||
feed = function(bytes)
|
||||
buffer = buffer .. bytes
|
||||
local frames = {}
|
||||
while true do
|
||||
local i = buffer:find("|", 1, true)
|
||||
if not i then break end
|
||||
frames[#frames + 1] = buffer:sub(1, i - 1)
|
||||
buffer = buffer:sub(i + 1)
|
||||
end
|
||||
return frames
|
||||
end,
|
||||
flush = function()
|
||||
if #buffer == 0 then return nil end
|
||||
local frame = buffer
|
||||
buffer = ""
|
||||
return frame
|
||||
end,
|
||||
reset = function()
|
||||
buffer = ""
|
||||
end,
|
||||
pending_len = function()
|
||||
return #buffer
|
||||
end,
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let decoder_path = write_temp_lua(
|
||||
"custom_decoder",
|
||||
r#"
|
||||
return {
|
||||
decode = function(frame)
|
||||
return { kind = "text", data = string.upper(frame) }
|
||||
end,
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
stream.write_all(b"alpha|").await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
stream.write_all(b"beta|").await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
});
|
||||
|
||||
let lua = Lua::new();
|
||||
pipeview_client::lua::register(&lua).unwrap();
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
local sess = pipeview.open({{
|
||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||
pipelines = {{
|
||||
{{
|
||||
name = "custom",
|
||||
framer = {{ Lua = {{ script_path = [[{}]] }} }},
|
||||
decoder = {{ Lua = {{ script_path = [[{}]] }} }}
|
||||
}}
|
||||
}}
|
||||
}})
|
||||
|
||||
local r1 = sess:read(5000)
|
||||
assert(r1 ~= nil, "expected first custom frame")
|
||||
assert(r1.pipeline == "custom", "expected custom pipeline, got " .. tostring(r1.pipeline))
|
||||
assert(r1.kind == "text", "expected text kind, got " .. tostring(r1.kind))
|
||||
assert(r1.data == "ALPHA", "expected ALPHA, got " .. tostring(r1.data))
|
||||
|
||||
local r2 = sess:read(5000)
|
||||
assert(r2 ~= nil, "expected second custom frame")
|
||||
assert(r2.data == "BETA", "expected BETA, got " .. tostring(r2.data))
|
||||
|
||||
sess:close()
|
||||
"#,
|
||||
addr,
|
||||
framer_path.display(),
|
||||
decoder_path.display()
|
||||
);
|
||||
|
||||
lua.load(&script).exec_async().await.unwrap();
|
||||
server.await.unwrap();
|
||||
fs::remove_file(framer_path).unwrap();
|
||||
fs::remove_file(decoder_path).unwrap();
|
||||
}
|
||||
|
||||
// ── session:on_data callback ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
@@ -204,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 = {{{}}}
|
||||
}})
|
||||
@@ -220,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
|
||||
|
||||
@@ -264,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 = {{{}}}
|
||||
}})
|
||||
@@ -313,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 = {{{}}}
|
||||
}})
|
||||
@@ -343,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() {
|
||||
@@ -359,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 = {{{}}}
|
||||
}})
|
||||
@@ -375,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
|
||||
|
||||
@@ -384,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)
|
||||
@@ -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();
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "xserial-core"
|
||||
name = "pipeview-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
@@ -52,7 +52,8 @@ impl MixedTextPlotDecoder {
|
||||
Endian::Big => 1,
|
||||
});
|
||||
packet.push(channels.min(u8::MAX as usize) as u8);
|
||||
packet.extend_from_slice(&(samples_per_channel.min(u16::MAX as usize) as u16).to_le_bytes());
|
||||
packet
|
||||
.extend_from_slice(&(samples_per_channel.min(u16::MAX as usize) as u16).to_le_bytes());
|
||||
packet.extend_from_slice(&(payload.len().min(u32::MAX as usize) as u32).to_le_bytes());
|
||||
packet.extend_from_slice(payload);
|
||||
packet
|
||||
@@ -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) => {
|
||||
@@ -30,6 +30,14 @@ fn default_serial_flow_control() -> SerialFlowControl {
|
||||
SerialFlowControl::None
|
||||
}
|
||||
|
||||
fn default_serial_dtr() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_serial_rts() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransportType {
|
||||
Serial,
|
||||
@@ -60,6 +68,10 @@ pub enum TransportConfig {
|
||||
stop_bits: SerialStopBits,
|
||||
#[serde(default = "default_serial_flow_control")]
|
||||
flow_control: SerialFlowControl,
|
||||
#[serde(default = "default_serial_dtr")]
|
||||
dtr: bool,
|
||||
#[serde(default = "default_serial_rts")]
|
||||
rts: bool,
|
||||
},
|
||||
Tcp {
|
||||
addr: String,
|
||||
@@ -96,6 +108,8 @@ impl Connection {
|
||||
parity,
|
||||
stop_bits,
|
||||
flow_control,
|
||||
dtr,
|
||||
rts,
|
||||
} => Connection::Serial(SerialTransport::new(
|
||||
port,
|
||||
baud_rate,
|
||||
@@ -103,6 +117,8 @@ impl Connection {
|
||||
parity,
|
||||
stop_bits,
|
||||
flow_control,
|
||||
dtr,
|
||||
rts,
|
||||
)),
|
||||
TransportConfig::Tcp { addr } => Connection::Tcp(TcpTransport::new(addr)),
|
||||
TransportConfig::Udp {
|
||||
@@ -153,6 +169,28 @@ impl Connection {
|
||||
Connection::Udp(t) => t.disconnect().await,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
|
||||
match self {
|
||||
Connection::Serial(t) => t.set_dtr(state),
|
||||
Connection::Tcp(_) | Connection::Udp(_) => {
|
||||
Err(crate::error::Error::ConnectionFailed(
|
||||
"DTR only supported on Serial connections".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_rts(&mut self, state: bool) -> Result<()> {
|
||||
match self {
|
||||
Connection::Serial(t) => t.set_rts(state),
|
||||
Connection::Tcp(_) | Connection::Udp(_) => {
|
||||
Err(crate::error::Error::ConnectionFailed(
|
||||
"RTS only supported on Serial connections".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for Connection {
|
||||
@@ -220,6 +258,8 @@ mod tests {
|
||||
parity: SerialParity::None,
|
||||
stop_bits: SerialStopBits::One,
|
||||
flow_control: SerialFlowControl::None,
|
||||
dtr: false,
|
||||
rts: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,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);
|
||||
}
|
||||
|
||||
@@ -312,6 +352,8 @@ mod tests {
|
||||
parity: SerialParity::None,
|
||||
stop_bits: SerialStopBits::One,
|
||||
flow_control: SerialFlowControl::None,
|
||||
dtr: false,
|
||||
rts: false,
|
||||
};
|
||||
let _ = format!("{:?}", cfg);
|
||||
|
||||
@@ -371,6 +413,8 @@ mod tests {
|
||||
parity,
|
||||
stop_bits,
|
||||
flow_control,
|
||||
dtr,
|
||||
rts,
|
||||
} => {
|
||||
assert_eq!(port, "COM1");
|
||||
assert_eq!(baud_rate, 9600);
|
||||
@@ -378,6 +422,8 @@ mod tests {
|
||||
assert_eq!(parity, SerialParity::None);
|
||||
assert_eq!(stop_bits, SerialStopBits::One);
|
||||
assert_eq!(flow_control, SerialFlowControl::None);
|
||||
assert!(!dtr);
|
||||
assert!(!rts);
|
||||
}
|
||||
_ => panic!("expected Serial variant"),
|
||||
}
|
||||
@@ -395,6 +441,8 @@ mod tests {
|
||||
parity,
|
||||
stop_bits,
|
||||
flow_control,
|
||||
dtr,
|
||||
rts,
|
||||
} => {
|
||||
assert_eq!(port, "COM2");
|
||||
assert_eq!(baud_rate, 57600);
|
||||
@@ -402,6 +450,8 @@ mod tests {
|
||||
assert_eq!(parity, SerialParity::Even);
|
||||
assert_eq!(stop_bits, SerialStopBits::Two);
|
||||
assert_eq!(flow_control, SerialFlowControl::Hardware);
|
||||
assert!(!dtr);
|
||||
assert!(!rts);
|
||||
}
|
||||
_ => panic!("expected Serial variant"),
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits};
|
||||
use serialport::SerialPort;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::{Transport, TransportType};
|
||||
@@ -83,9 +84,12 @@ pub struct SerialTransport {
|
||||
parity: SerialParity,
|
||||
stop_bits: SerialStopBits,
|
||||
flow_control: SerialFlowControl,
|
||||
dtr: bool,
|
||||
rts: bool,
|
||||
}
|
||||
|
||||
impl SerialTransport {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
port_name: String,
|
||||
baud_rate: u32,
|
||||
@@ -93,6 +97,8 @@ impl SerialTransport {
|
||||
parity: SerialParity,
|
||||
stop_bits: SerialStopBits,
|
||||
flow_control: SerialFlowControl,
|
||||
dtr: bool,
|
||||
rts: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
port: None,
|
||||
@@ -102,6 +108,8 @@ impl SerialTransport {
|
||||
parity,
|
||||
stop_bits,
|
||||
flow_control,
|
||||
dtr,
|
||||
rts,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +146,36 @@ impl SerialTransport {
|
||||
pub fn flow_control(&self) -> SerialFlowControl {
|
||||
self.flow_control
|
||||
}
|
||||
|
||||
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
|
||||
match &mut self.port {
|
||||
Some(port) => {
|
||||
port.write_data_terminal_ready(state)?;
|
||||
self.dtr = state;
|
||||
debug!("DTR set to {} on {}", state, self.port_name);
|
||||
Ok(())
|
||||
}
|
||||
None => Err(crate::error::Error::ConnectionFailed(format!(
|
||||
"port {} not open",
|
||||
self.port_name
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_rts(&mut self, state: bool) -> Result<()> {
|
||||
match &mut self.port {
|
||||
Some(port) => {
|
||||
port.write_request_to_send(state)?;
|
||||
self.rts = state;
|
||||
debug!("RTS set to {} on {}", state, self.port_name);
|
||||
Ok(())
|
||||
}
|
||||
None => Err(crate::error::Error::ConnectionFailed(format!(
|
||||
"port {} not open",
|
||||
self.port_name
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for SerialTransport {
|
||||
@@ -221,7 +259,7 @@ impl Transport for SerialTransport {
|
||||
self.flow_control
|
||||
);
|
||||
|
||||
let port = tokio_serial::new(&self.port_name, self.baud_rate)
|
||||
let mut port = tokio_serial::new(&self.port_name, self.baud_rate)
|
||||
.data_bits(self.data_bits.into())
|
||||
.parity(self.parity.into())
|
||||
.stop_bits(self.stop_bits.into())
|
||||
@@ -234,6 +272,17 @@ impl Transport for SerialTransport {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Linux kernel toggles DTR/RTS on every open() — restore configured
|
||||
// state immediately afterward. Many wireless serial modules (HC-12,
|
||||
// HC-15, HC-05, Bluetooth/UART bridges) need DTR asserted to stay in
|
||||
// transparent data mode and not fall into AT-command / reset state.
|
||||
if let Err(e) = port.write_data_terminal_ready(self.dtr) {
|
||||
warn!("Failed to set DTR({}) on {}: {}", self.dtr, self.port_name, e);
|
||||
}
|
||||
if let Err(e) = port.write_request_to_send(self.rts) {
|
||||
warn!("Failed to set RTS({}) on {}: {}", self.rts, self.port_name, e);
|
||||
}
|
||||
|
||||
debug!("Serial port {} opened successfully", self.port_name);
|
||||
self.port = Some(port);
|
||||
Ok(())
|
||||
@@ -282,6 +331,8 @@ mod tests {
|
||||
SerialParity::None,
|
||||
SerialStopBits::One,
|
||||
SerialFlowControl::None,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
assert_eq!(transport.port_name(), "COM1");
|
||||
assert_eq!(transport.baud_rate(), 115200);
|
||||
@@ -296,6 +347,8 @@ mod tests {
|
||||
SerialParity::Even,
|
||||
SerialStopBits::Two,
|
||||
SerialFlowControl::Hardware,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
assert_eq!(transport.port_name(), "COM3");
|
||||
assert_eq!(transport.baud_rate(), 9600);
|
||||
@@ -314,6 +367,8 @@ mod tests {
|
||||
SerialParity::None,
|
||||
SerialStopBits::One,
|
||||
SerialFlowControl::None,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
assert_eq!(transport.name(), "COM1");
|
||||
}
|
||||
@@ -327,6 +382,8 @@ mod tests {
|
||||
SerialParity::None,
|
||||
SerialStopBits::One,
|
||||
SerialFlowControl::None,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
assert_eq!(transport.transport_type(), TransportType::Serial);
|
||||
}
|
||||
@@ -340,6 +397,8 @@ mod tests {
|
||||
SerialParity::None,
|
||||
SerialStopBits::One,
|
||||
SerialFlowControl::None,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
assert!(!transport.is_connected());
|
||||
}
|
||||
@@ -368,6 +427,8 @@ mod tests {
|
||||
SerialParity::None,
|
||||
SerialStopBits::One,
|
||||
SerialFlowControl::None,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
let pinned = Pin::new(&mut transport);
|
||||
let mut buf_data = [0u8; 16];
|
||||
@@ -391,6 +452,8 @@ mod tests {
|
||||
SerialParity::None,
|
||||
SerialStopBits::One,
|
||||
SerialFlowControl::None,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
let pinned = Pin::new(&mut transport);
|
||||
let data = b"hello";
|
||||
@@ -411,6 +474,8 @@ mod tests {
|
||||
SerialParity::None,
|
||||
SerialStopBits::One,
|
||||
SerialFlowControl::None,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
let pinned = Pin::new(&mut transport);
|
||||
let waker = noop_waker();
|
||||
@@ -430,6 +495,8 @@ mod tests {
|
||||
SerialParity::None,
|
||||
SerialStopBits::One,
|
||||
SerialFlowControl::None,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
let pinned = Pin::new(&mut transport);
|
||||
let waker = noop_waker();
|
||||
@@ -451,6 +518,8 @@ mod tests {
|
||||
SerialParity::None,
|
||||
SerialStopBits::One,
|
||||
SerialFlowControl::None,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
assert_eq!(transport.name(), "COM1");
|
||||
}
|
||||
@@ -464,6 +533,8 @@ mod tests {
|
||||
SerialParity::None,
|
||||
SerialStopBits::One,
|
||||
SerialFlowControl::None,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
assert_eq!(transport.transport_type(), TransportType::Serial);
|
||||
}
|
||||
@@ -477,6 +548,8 @@ mod tests {
|
||||
SerialParity::None,
|
||||
SerialStopBits::One,
|
||||
SerialFlowControl::None,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
assert!(!transport.is_connected());
|
||||
}
|
||||
@@ -4,26 +4,26 @@ 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::cobs_encode as raw_cobs_encode,
|
||||
cobs::CobsFramer,
|
||||
cobs::cobs_encode as raw_cobs_encode,
|
||||
fixed::FixedLengthFramer,
|
||||
length::{LengthConfig, LengthPrefixedFramer},
|
||||
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),
|
||||
}
|
||||
@@ -298,10 +298,7 @@ async fn tcp_mixed_text_and_plot_single_stream() {
|
||||
conn.disconnect().await.unwrap();
|
||||
|
||||
let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default());
|
||||
let results: Vec<DecodedData> = frames
|
||||
.iter()
|
||||
.filter_map(|f| decoder.decode(f))
|
||||
.collect();
|
||||
let results: Vec<DecodedData> = frames.iter().filter_map(|f| decoder.decode(f)).collect();
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
assert!(matches!(&results[0], DecodedData::Text(s) if s == "status ok"));
|
||||
@@ -691,6 +688,8 @@ async fn connection_transport_type_dispatch() {
|
||||
parity: SerialParity::None,
|
||||
stop_bits: SerialStopBits::One,
|
||||
flow_control: SerialFlowControl::None,
|
||||
dtr: false,
|
||||
rts: false,
|
||||
});
|
||||
let tcp = Connection::new(TransportConfig::Tcp {
|
||||
addr: "127.0.0.1:8080".into(),
|
||||
@@ -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 }
|
||||
@@ -1,16 +1,22 @@
|
||||
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;
|
||||
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);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ConnectionStatus {
|
||||
@@ -44,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,
|
||||
@@ -51,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,
|
||||
@@ -61,10 +139,16 @@ pub struct SessionTab {
|
||||
pub plot_view: plot_view::PlotViewState,
|
||||
pub view: View,
|
||||
pub auto_reconnect: bool,
|
||||
pub dtr: bool,
|
||||
pub rts: bool,
|
||||
pub send_input: String,
|
||||
pub send_mode: SendMode,
|
||||
pub append_newline: bool,
|
||||
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 {
|
||||
@@ -86,6 +170,8 @@ pub struct XserialApp {
|
||||
config_form: config::ConfigForm,
|
||||
event_rx: mpsc::Receiver<SessionEvent>,
|
||||
pending: Vec<SessionEvent>,
|
||||
profiler: GuiProfiler,
|
||||
shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
|
||||
}
|
||||
|
||||
impl XserialApp {
|
||||
@@ -94,16 +180,32 @@ impl XserialApp {
|
||||
mut rx: tokio::sync::broadcast::Receiver<SessionEvent>,
|
||||
ctx: egui::Context,
|
||||
) -> Self {
|
||||
let profiler = GuiProfiler::from_env();
|
||||
let repaint_counter = profiler.repaint_counter();
|
||||
let (event_tx, event_rx) = mpsc::channel();
|
||||
let repaint_ctx = ctx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut last_repaint = Instant::now()
|
||||
.checked_sub(DATA_REPAINT_INTERVAL)
|
||||
.unwrap_or_else(Instant::now);
|
||||
let mut delayed_repaint_pending = false;
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
if event_tx.send(event).is_err() {
|
||||
break;
|
||||
}
|
||||
let elapsed = last_repaint.elapsed();
|
||||
if elapsed >= DATA_REPAINT_INTERVAL {
|
||||
repaint_counter.increment();
|
||||
repaint_ctx.request_repaint();
|
||||
last_repaint = Instant::now();
|
||||
delayed_repaint_pending = false;
|
||||
} else if !delayed_repaint_pending {
|
||||
repaint_counter.increment();
|
||||
repaint_ctx.request_repaint_after(DATA_REPAINT_INTERVAL - elapsed);
|
||||
delayed_repaint_pending = true;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
@@ -139,11 +241,126 @@ impl XserialApp {
|
||||
config_form: config::ConfigForm::default(),
|
||||
event_rx,
|
||||
pending: Vec::new(),
|
||||
profiler,
|
||||
shortcut_bindings: default_bindings(),
|
||||
};
|
||||
app.restore_saved_sessions(saved_state);
|
||||
app
|
||||
}
|
||||
|
||||
fn execute(&mut self, action: shortcuts::Action) {
|
||||
use shortcuts::Action::*;
|
||||
|
||||
match action {
|
||||
NextTab => {
|
||||
if self.tabs.len() > 1 {
|
||||
self.active = (self.active + 1) % self.tabs.len();
|
||||
self.persist_gui_state();
|
||||
}
|
||||
}
|
||||
PrevTab => {
|
||||
if self.tabs.len() > 1 {
|
||||
self.active = if self.active == 0 {
|
||||
self.tabs.len() - 1
|
||||
} else {
|
||||
self.active - 1
|
||||
};
|
||||
self.persist_gui_state();
|
||||
}
|
||||
}
|
||||
ViewText => {
|
||||
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||
tab.view = View::Text;
|
||||
}
|
||||
}
|
||||
ViewHex => {
|
||||
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||
tab.view = View::Hex;
|
||||
}
|
||||
}
|
||||
ViewPlot => {
|
||||
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||
tab.view = View::Plot;
|
||||
}
|
||||
}
|
||||
NewSession => self.open_create_config(),
|
||||
EditSession => {
|
||||
if let Some(tab) = self.tabs.get(self.active) {
|
||||
self.open_edit_config(tab.id);
|
||||
}
|
||||
}
|
||||
DeleteSession => {
|
||||
if !self.tabs.is_empty() {
|
||||
let id = self.tabs[self.active].id;
|
||||
self.manager.remove(id);
|
||||
self.tabs.remove(self.active);
|
||||
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
|
||||
self.active = self.tabs.len() - 1;
|
||||
}
|
||||
self.persist_gui_state();
|
||||
}
|
||||
}
|
||||
ToggleConnect => {
|
||||
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||
let connected = matches!(
|
||||
tab.status,
|
||||
ConnectionStatus::Connected | ConnectionStatus::Connecting
|
||||
);
|
||||
if let Some(handle) = self.manager.get(tab.id) {
|
||||
if connected {
|
||||
tab.status = ConnectionStatus::Disconnected;
|
||||
let handle = handle.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.disconnect().await;
|
||||
});
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Connecting;
|
||||
let handle = handle.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.connect().await;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Clear => {
|
||||
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||
tab.console.clear();
|
||||
tab.hex.clear();
|
||||
tab.plot.clear();
|
||||
tab.send_status = Some(String::from("Cleared"));
|
||||
}
|
||||
}
|
||||
UiSettings => self.font_settings_open = true,
|
||||
CloseOverlay => {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_create_config(&mut self) {
|
||||
self.config_target = None;
|
||||
self.config_form = config::ConfigForm::default();
|
||||
@@ -151,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);
|
||||
@@ -162,6 +388,10 @@ impl XserialApp {
|
||||
fn add_session_tab(&mut self, session_config: SessionConfig) {
|
||||
let history_limit = session_config.history_limit;
|
||||
let auto_reconnect = session_config.auto_reconnect;
|
||||
let (dtr, rts) = match &session_config.transport {
|
||||
TransportConfig::Serial { dtr, rts, .. } => (*dtr, *rts),
|
||||
_ => (false, false),
|
||||
};
|
||||
let handle = self.manager.create(session_config.clone());
|
||||
self.tabs.push(SessionTab {
|
||||
id: handle.id(),
|
||||
@@ -173,10 +403,16 @@ impl XserialApp {
|
||||
plot_view: plot_view::PlotViewState::default(),
|
||||
view: View::Text,
|
||||
auto_reconnect,
|
||||
dtr,
|
||||
rts,
|
||||
send_input: String::new(),
|
||||
send_mode: SendMode::Text,
|
||||
append_newline: true,
|
||||
line_ending: LineEnding::None,
|
||||
send_status: None,
|
||||
search: SearchState::default(),
|
||||
log_enabled: false,
|
||||
log_path: String::new(),
|
||||
log_writer: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -187,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 {
|
||||
@@ -230,11 +474,22 @@ impl XserialApp {
|
||||
}
|
||||
|
||||
fn drain_events(&mut self) {
|
||||
let started = Instant::now();
|
||||
let mut stats = DrainStats {
|
||||
drained_events: 0,
|
||||
drained_data_events: 0,
|
||||
drained_text_events: 0,
|
||||
drained_hex_events: 0,
|
||||
drained_plot_events: 0,
|
||||
pending_events: 0,
|
||||
};
|
||||
while let Ok(event) = self.event_rx.try_recv() {
|
||||
self.pending.push(event);
|
||||
}
|
||||
stats.pending_events = self.pending.len();
|
||||
|
||||
for event in self.pending.drain(..) {
|
||||
stats.drained_events += 1;
|
||||
match event {
|
||||
SessionEvent::Connected(id) => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
||||
@@ -252,26 +507,73 @@ 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(_) => tab.console.push(&entry),
|
||||
DecodedData::Hex(_) => tab.hex.push(&entry),
|
||||
DecodedData::Plot(_) => tab.plot.push(&entry),
|
||||
DecodedData::Text(_) => {
|
||||
stats.drained_text_events += 1;
|
||||
tab.console.push(&entry);
|
||||
}
|
||||
DecodedData::Hex(_) => {
|
||||
stats.drained_hex_events += 1;
|
||||
tab.hex.push(&entry);
|
||||
}
|
||||
DecodedData::Plot(_) => {
|
||||
stats.drained_plot_events += 1;
|
||||
tab.plot.push(&entry);
|
||||
}
|
||||
DecodedData::Binary(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.profiler.record_drain(started.elapsed(), stats);
|
||||
}
|
||||
|
||||
fn wants_live_plot_repaint(&self) -> bool {
|
||||
self.tabs
|
||||
.get(self.active)
|
||||
.map(|tab| {
|
||||
matches!(tab.view, View::Plot) && matches!(tab.status, ConnectionStatus::Connected)
|
||||
self.tabs.iter().enumerate().any(|(index, tab)| {
|
||||
matches!(tab.status, ConnectionStatus::Connected)
|
||||
&& (tab.plot_view.detached
|
||||
|| (index == self.active && matches!(tab.view, View::Plot)))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn profiler_snapshot(&self) -> GuiSnapshot {
|
||||
if let Some(tab) = self.tabs.get(self.active) {
|
||||
GuiSnapshot {
|
||||
tabs: self.tabs.len(),
|
||||
active_view: match tab.view {
|
||||
View::Text => "text",
|
||||
View::Hex => "hex",
|
||||
View::Plot => "plot",
|
||||
},
|
||||
active_text_lines: tab.console.len(),
|
||||
active_hex_lines: tab.hex.len(),
|
||||
active_plot_series: tab.plot.series_len(),
|
||||
active_plot_points: tab.plot.total_points(),
|
||||
}
|
||||
} else {
|
||||
GuiSnapshot {
|
||||
tabs: self.tabs.len(),
|
||||
active_view: "none",
|
||||
active_text_lines: 0,
|
||||
active_hex_lines: 0,
|
||||
active_plot_series: 0,
|
||||
active_plot_points: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_config_window(&mut self, ctx: &egui::Context) {
|
||||
@@ -313,6 +615,7 @@ impl XserialApp {
|
||||
|
||||
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
|
||||
let mut on_new = false;
|
||||
let mut on_edit = None;
|
||||
let mut on_delete = None;
|
||||
let previous_active = self.active;
|
||||
|
||||
@@ -328,13 +631,26 @@ impl XserialApp {
|
||||
transport_summary: transport_summary(&tab.session_config.transport),
|
||||
})
|
||||
.collect();
|
||||
sidebar::render(ui, &sessions, &mut self.active, &mut on_new, &mut on_delete);
|
||||
sidebar::render(
|
||||
ui,
|
||||
&sessions,
|
||||
&mut self.active,
|
||||
&mut on_new,
|
||||
&mut on_edit,
|
||||
&mut on_delete,
|
||||
);
|
||||
});
|
||||
|
||||
if on_new {
|
||||
self.open_create_config();
|
||||
}
|
||||
|
||||
if let Some(index) = on_edit
|
||||
&& let Some(tab) = self.tabs.get(index)
|
||||
{
|
||||
self.open_edit_config(tab.id);
|
||||
}
|
||||
|
||||
if let Some(index) = on_delete {
|
||||
if let Some(tab) = self.tabs.get(index) {
|
||||
self.manager.remove(tab.id);
|
||||
@@ -362,8 +678,10 @@ impl XserialApp {
|
||||
|
||||
let manager = self.manager.clone();
|
||||
let display = &mut self.display;
|
||||
let mut edit_session = None;
|
||||
let mut persist_state = false;
|
||||
let mut text_render = None;
|
||||
let mut hex_render = None;
|
||||
let mut plot_render = None;
|
||||
|
||||
{
|
||||
let tab = &mut self.tabs[self.active];
|
||||
@@ -392,11 +710,7 @@ impl XserialApp {
|
||||
}
|
||||
});
|
||||
|
||||
let (configure_clicked, auto_reconnect_changed) =
|
||||
render_session_controls(ui, &manager, tab);
|
||||
if configure_clicked {
|
||||
edit_session = Some(tab.id);
|
||||
}
|
||||
let auto_reconnect_changed = render_session_controls(ui, &manager, tab);
|
||||
persist_state |= auto_reconnect_changed;
|
||||
|
||||
let mut display_changed = false;
|
||||
@@ -412,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));
|
||||
}
|
||||
@@ -431,10 +747,41 @@ impl XserialApp {
|
||||
UiBuilder::new()
|
||||
.max_rect(receive_rect)
|
||||
.layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)),
|
||||
|ui| match tab.view {
|
||||
View::Text => console::render(ui, &tab.console, *display),
|
||||
View::Hex => hex_view::render(ui, &tab.hex, *display),
|
||||
View::Plot => plot_view::render(ui, &tab.plot, tab.id, &mut tab.plot_view),
|
||||
|ui| {
|
||||
let started = Instant::now();
|
||||
match tab.view {
|
||||
View::Text => {
|
||||
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, tab.search.active.then_some(&tab.search));
|
||||
hex_render = Some((started.elapsed(), line_count));
|
||||
}
|
||||
View::Plot => {
|
||||
if tab.plot_view.detached {
|
||||
ui.heading(format!(
|
||||
"Plot window detached for Session {}",
|
||||
tab.id
|
||||
));
|
||||
ui.label("The plot is currently shown in a floating window.");
|
||||
if ui.button("Dock Plot Back").clicked() {
|
||||
tab.plot_view.detached = false;
|
||||
}
|
||||
} else {
|
||||
let output = plot_view::render(
|
||||
ui,
|
||||
&mut tab.plot,
|
||||
tab.id,
|
||||
&mut tab.plot_view,
|
||||
);
|
||||
if output.toggle_detached {
|
||||
tab.plot_view.detached = true;
|
||||
}
|
||||
plot_render = Some((started.elapsed(), output.stats));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -446,8 +793,14 @@ impl XserialApp {
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(id) = edit_session {
|
||||
self.open_edit_config(id);
|
||||
if let Some((elapsed, line_count)) = text_render {
|
||||
self.profiler.record_text_render(elapsed, line_count);
|
||||
}
|
||||
if let Some((elapsed, line_count)) = hex_render {
|
||||
self.profiler.record_hex_render(elapsed, line_count);
|
||||
}
|
||||
if let Some((elapsed, stats)) = plot_render {
|
||||
self.profiler.record_plot_render(elapsed, stats);
|
||||
}
|
||||
if persist_state {
|
||||
self.persist_gui_state();
|
||||
@@ -455,10 +808,50 @@ impl XserialApp {
|
||||
});
|
||||
}
|
||||
|
||||
fn render_detached_plot_windows(&mut self, ctx: &egui::Context) {
|
||||
let mut plot_renders = Vec::new();
|
||||
|
||||
for tab in &mut self.tabs {
|
||||
if !tab.plot_view.detached {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut open = true;
|
||||
let mut output = None;
|
||||
egui::Window::new(format!("Plot - Session {}", tab.id))
|
||||
.open(&mut open)
|
||||
.default_width(720.0)
|
||||
.default_height(480.0)
|
||||
.resizable(true)
|
||||
.vscroll(false)
|
||||
.show(ctx, |ui| {
|
||||
let started = Instant::now();
|
||||
let render_output =
|
||||
plot_view::render(ui, &mut tab.plot, tab.id, &mut tab.plot_view);
|
||||
output = Some((started.elapsed(), render_output));
|
||||
});
|
||||
|
||||
if let Some((elapsed, render_output)) = output {
|
||||
if render_output.toggle_detached {
|
||||
tab.plot_view.detached = false;
|
||||
}
|
||||
plot_renders.push((elapsed, render_output.stats));
|
||||
}
|
||||
|
||||
if !open {
|
||||
tab.plot_view.detached = false;
|
||||
}
|
||||
}
|
||||
|
||||
for (elapsed, stats) in plot_renders {
|
||||
self.profiler.record_plot_render(elapsed, stats);
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -601,6 +994,7 @@ fn transport_summary(transport: &TransportConfig) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_font_selector(
|
||||
ui: &mut egui::Ui,
|
||||
title: &str,
|
||||
@@ -686,6 +1080,16 @@ impl eframe::App for XserialApp {
|
||||
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
{
|
||||
let supress = ui.ctx().egui_wants_keyboard_input();
|
||||
let actions = shortcuts::process(ui.ctx(), supress, &self.shortcut_bindings);
|
||||
|
||||
for action in actions {
|
||||
self.execute(action);
|
||||
}
|
||||
}
|
||||
|
||||
let frame_started = Instant::now();
|
||||
self.drain_events();
|
||||
if self.wants_live_plot_repaint() {
|
||||
ui.ctx().request_repaint_after(Duration::from_millis(16));
|
||||
@@ -695,53 +1099,108 @@ impl eframe::App for XserialApp {
|
||||
self.render_config_window(ui.ctx());
|
||||
self.render_sidebar(ui);
|
||||
self.render_main_panel(ui);
|
||||
self.render_detached_plot_windows(ui.ctx());
|
||||
self.profiler
|
||||
.record_frame(frame_started.elapsed(), self.profiler_snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
tab: &mut SessionTab,
|
||||
) -> (bool, bool) {
|
||||
let mut configure_clicked = false;
|
||||
) -> bool {
|
||||
let mut auto_reconnect_changed = false;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui.button("Connect").clicked() {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
tab.status = ConnectionStatus::Connecting;
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.connect().await;
|
||||
});
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("Disconnect").clicked() {
|
||||
let connected = matches!(
|
||||
tab.status,
|
||||
ConnectionStatus::Connected | ConnectionStatus::Connecting
|
||||
);
|
||||
let connect_label = if connected { "Disconnect" } else { "Connect" };
|
||||
if ui.button(connect_label).clicked() {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
if connected {
|
||||
tab.status = ConnectionStatus::Disconnected;
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.disconnect().await;
|
||||
});
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("Reconnect").clicked() {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
tab.status = ConnectionStatus::Connecting;
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.reconnect().await;
|
||||
let _ = handle.connect().await;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("Configure").clicked() {
|
||||
configure_clicked = true;
|
||||
}
|
||||
// if ui.button("Reconnect").clicked() {
|
||||
// if let Some(handle) = manager.get(tab.id) {
|
||||
// tab.status = ConnectionStatus::Connecting;
|
||||
// tokio::spawn(async move {
|
||||
// let _ = handle.reconnect().await;
|
||||
// });
|
||||
// } else {
|
||||
// tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
// }
|
||||
// }
|
||||
|
||||
if ui.button("Clear").clicked() {
|
||||
tab.console.clear();
|
||||
@@ -763,8 +1222,30 @@ fn render_session_controls(
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
if matches!(tab.status, ConnectionStatus::Connected)
|
||||
&& matches!(tab.session_config.transport, TransportConfig::Serial { .. })
|
||||
{
|
||||
let dtr_changed = ui.checkbox(&mut tab.dtr, "DTR").changed();
|
||||
let rts_changed = ui.checkbox(&mut tab.rts, "RTS").changed();
|
||||
if dtr_changed || rts_changed {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
let dtr = tab.dtr;
|
||||
let rts = tab.rts;
|
||||
tokio::spawn(async move {
|
||||
if dtr_changed {
|
||||
let _ = handle.set_dtr(dtr).await;
|
||||
}
|
||||
if rts_changed {
|
||||
let _ = handle.set_rts(rts).await;
|
||||
}
|
||||
});
|
||||
(configure_clicked, auto_reconnect_changed)
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
auto_reconnect_changed
|
||||
}
|
||||
|
||||
fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
|
||||
@@ -774,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",
|
||||
@@ -816,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 => {
|
||||
@@ -832,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -854,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() {
|
||||
@@ -862,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()))
|
||||
}
|
||||
@@ -883,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,
|
||||
};
|
||||
|
||||
@@ -905,6 +1456,8 @@ mod tests {
|
||||
parity: SerialParity::None,
|
||||
stop_bits: SerialStopBits::One,
|
||||
flow_control: SerialFlowControl::None,
|
||||
dtr: false,
|
||||
rts: false,
|
||||
}),
|
||||
"Serial COM7"
|
||||
);
|
||||
@@ -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,
|
||||
@@ -1,9 +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 {
|
||||
@@ -51,8 +52,12 @@ impl TextBuffer {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &ConsoleLine> {
|
||||
self.lines.iter()
|
||||
pub fn get(&self, index: usize) -> Option<&ConsoleLine> {
|
||||
self.lines.get(index)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
@@ -62,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)]
|
||||
@@ -135,8 +166,12 @@ impl HexBuffer {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &HexLine> {
|
||||
self.lines.iter()
|
||||
pub fn get(&self, index: usize) -> Option<&HexLine> {
|
||||
self.lines.get(index)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
@@ -146,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 {
|
||||
@@ -168,11 +230,17 @@ impl PlotSeries {
|
||||
if !sample.is_finite() {
|
||||
continue;
|
||||
}
|
||||
self.points.push_back([self.next_x, *sample]);
|
||||
self.push_point([self.next_x, *sample], limit);
|
||||
self.next_x += 1.0;
|
||||
}
|
||||
while self.points.len() > limit {
|
||||
self.points.pop_front();
|
||||
}
|
||||
|
||||
fn push_point(&mut self, point: [f64; 2], limit: usize) -> Option<[f64; 2]> {
|
||||
self.points.push_back(point);
|
||||
if self.points.len() > limit {
|
||||
self.points.pop_front()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,26 +248,56 @@ impl PlotSeries {
|
||||
self.points.iter().copied()
|
||||
}
|
||||
|
||||
pub fn render_points_time_series(&self, x_min: f64, x_max: f64, max_points: usize) -> Vec<[f64; 2]> {
|
||||
pub fn len(&self) -> usize {
|
||||
self.points.len()
|
||||
}
|
||||
|
||||
fn first_point(&self) -> Option<[f64; 2]> {
|
||||
self.points.front().copied()
|
||||
}
|
||||
|
||||
fn last_point(&self) -> Option<[f64; 2]> {
|
||||
self.points.back().copied()
|
||||
}
|
||||
|
||||
pub fn render_points_time_series(
|
||||
&self,
|
||||
x_min: f64,
|
||||
x_max: f64,
|
||||
max_points: usize,
|
||||
) -> Vec<[f64; 2]> {
|
||||
if self.points.is_empty() || max_points == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let visible: Vec<[f64; 2]> = self
|
||||
.points
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|point| point[0] >= x_min && point[0] <= x_max)
|
||||
.collect();
|
||||
if visible.len() <= max_points {
|
||||
return visible;
|
||||
let mut exact_visible = Vec::with_capacity(max_points.min(self.points.len()));
|
||||
for point in self.points.iter().copied() {
|
||||
if point[0] < x_min {
|
||||
continue;
|
||||
}
|
||||
if point[0] > x_max {
|
||||
break;
|
||||
}
|
||||
exact_visible.push(point);
|
||||
if exact_visible.len() > max_points {
|
||||
exact_visible.clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !exact_visible.is_empty() {
|
||||
return exact_visible;
|
||||
}
|
||||
|
||||
let bucket_count = (max_points / 2).max(1);
|
||||
let width = (x_max - x_min).max(1.0);
|
||||
let bucket_width = width / bucket_count as f64;
|
||||
let mut rendered = Vec::with_capacity(bucket_count * 2);
|
||||
let mut start = 0usize;
|
||||
let mut points = self
|
||||
.points
|
||||
.iter()
|
||||
.copied()
|
||||
.skip_while(|point| point[0] < x_min)
|
||||
.peekable();
|
||||
|
||||
for bucket_index in 0..bucket_count {
|
||||
let bucket_start = x_min + bucket_width * bucket_index as f64;
|
||||
@@ -212,8 +310,7 @@ impl PlotSeries {
|
||||
let mut min_point: Option<[f64; 2]> = None;
|
||||
let mut max_point: Option<[f64; 2]> = None;
|
||||
|
||||
while start < visible.len() {
|
||||
let point = visible[start];
|
||||
while let Some(point) = points.peek().copied() {
|
||||
if point[0] > bucket_end {
|
||||
break;
|
||||
}
|
||||
@@ -227,7 +324,7 @@ impl PlotSeries {
|
||||
_ => max_point = Some(point),
|
||||
}
|
||||
}
|
||||
start += 1;
|
||||
points.next();
|
||||
}
|
||||
|
||||
match (min_point, max_point) {
|
||||
@@ -274,10 +371,69 @@ pub enum PlotSeriesKind {
|
||||
XY,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct BoundsRect {
|
||||
min_x: f64,
|
||||
max_x: f64,
|
||||
min_y: f64,
|
||||
max_y: f64,
|
||||
}
|
||||
|
||||
impl BoundsRect {
|
||||
fn from_point([x, y]: [f64; 2]) -> Option<Self> {
|
||||
if !(x.is_finite() && y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
min_x: x,
|
||||
max_x: x,
|
||||
min_y: y,
|
||||
max_y: y,
|
||||
})
|
||||
}
|
||||
|
||||
fn extend_with_point(&mut self, [x, y]: [f64; 2]) {
|
||||
if !(x.is_finite() && y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
self.min_x = self.min_x.min(x);
|
||||
self.max_x = self.max_x.max(x);
|
||||
self.min_y = self.min_y.min(y);
|
||||
self.max_y = self.max_y.max(y);
|
||||
}
|
||||
|
||||
fn touches(&self, [x, y]: [f64; 2]) -> bool {
|
||||
x == self.min_x || x == self.max_x || y == self.min_y || y == self.max_y
|
||||
}
|
||||
|
||||
fn into_plot_bounds(self) -> PlotBounds {
|
||||
let mut bounds =
|
||||
PlotBounds::from_min_max([self.min_x, self.min_y], [self.max_x, self.max_y]);
|
||||
|
||||
if bounds.width() <= 0.0 {
|
||||
bounds.set_x_center_width(bounds.center().x, 1.0);
|
||||
} else {
|
||||
bounds.expand_x(bounds.width() * 0.05);
|
||||
}
|
||||
|
||||
if bounds.height() <= 0.0 {
|
||||
bounds.set_y_center_height(bounds.center().y, 1.0);
|
||||
} else {
|
||||
bounds.expand_y(bounds.height() * 0.1);
|
||||
}
|
||||
|
||||
bounds
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PlotBuffer {
|
||||
limit: usize,
|
||||
kind: PlotSeriesKind,
|
||||
series: Vec<PlotSeries>,
|
||||
time_series_y_bounds: Option<(f64, f64)>,
|
||||
time_series_y_dirty: bool,
|
||||
xy_bounds: Option<BoundsRect>,
|
||||
xy_bounds_dirty: bool,
|
||||
}
|
||||
|
||||
impl PlotBuffer {
|
||||
@@ -286,6 +442,10 @@ impl PlotBuffer {
|
||||
limit,
|
||||
kind: PlotSeriesKind::TimeSeries,
|
||||
series: Vec::new(),
|
||||
time_series_y_bounds: None,
|
||||
time_series_y_dirty: false,
|
||||
xy_bounds: None,
|
||||
xy_bounds_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,10 +456,18 @@ impl PlotBuffer {
|
||||
}
|
||||
|
||||
fn push_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
|
||||
self.kind = match frame.format {
|
||||
let next_kind = match frame.format {
|
||||
PlotFormat::XY => PlotSeriesKind::XY,
|
||||
PlotFormat::Interleaved | PlotFormat::Block => PlotSeriesKind::TimeSeries,
|
||||
};
|
||||
if !matches!(
|
||||
(&self.kind, &next_kind),
|
||||
(PlotSeriesKind::TimeSeries, PlotSeriesKind::TimeSeries)
|
||||
| (PlotSeriesKind::XY, PlotSeriesKind::XY)
|
||||
) {
|
||||
self.invalidate_bounds();
|
||||
}
|
||||
self.kind = next_kind;
|
||||
|
||||
if matches!(frame.format, PlotFormat::XY) {
|
||||
self.push_xy_frame(pipeline_name, frame);
|
||||
@@ -313,15 +481,33 @@ impl PlotBuffer {
|
||||
format!("{pipeline_name}:ch{}", index + 1)
|
||||
};
|
||||
|
||||
if let Some(series) = self
|
||||
if let Some(series_index) = self
|
||||
.series
|
||||
.iter_mut()
|
||||
.find(|series| series.name == series_name)
|
||||
.iter()
|
||||
.position(|series| series.name == series_name)
|
||||
{
|
||||
series.push_samples(channel, self.limit);
|
||||
for sample in channel {
|
||||
if !sample.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let (point, removed) = {
|
||||
let series = &mut self.series[series_index];
|
||||
let point = [series.next_x, *sample];
|
||||
let removed = series.push_point(point, self.limit);
|
||||
series.next_x += 1.0;
|
||||
(point, removed)
|
||||
};
|
||||
if let Some(removed) = removed {
|
||||
self.note_time_series_removed(removed);
|
||||
}
|
||||
self.note_time_series_point(point);
|
||||
}
|
||||
} else {
|
||||
let mut series = PlotSeries::new(series_name);
|
||||
series.push_samples(channel, self.limit);
|
||||
for point in series.points() {
|
||||
self.note_time_series_point(point);
|
||||
}
|
||||
self.series.push(series);
|
||||
}
|
||||
}
|
||||
@@ -337,30 +523,36 @@ impl PlotBuffer {
|
||||
let len = x.len().min(y.len());
|
||||
let series_name = format!("{pipeline_name}:xy");
|
||||
|
||||
let series = if let Some(series) = self
|
||||
let series_index = if let Some(index) = self
|
||||
.series
|
||||
.iter_mut()
|
||||
.find(|series| series.name == series_name)
|
||||
.iter()
|
||||
.position(|series| series.name == series_name)
|
||||
{
|
||||
series
|
||||
index
|
||||
} else {
|
||||
self.series.push(PlotSeries::new(series_name));
|
||||
self.series.last_mut().expect("just pushed")
|
||||
self.series.len() - 1
|
||||
};
|
||||
|
||||
for index in 0..len {
|
||||
if !x[index].is_finite() || !y[index].is_finite() {
|
||||
continue;
|
||||
}
|
||||
series.points.push_back([x[index], y[index]]);
|
||||
let point = [x[index], y[index]];
|
||||
let removed = {
|
||||
let series = &mut self.series[series_index];
|
||||
series.push_point(point, self.limit)
|
||||
};
|
||||
if let Some(removed) = removed {
|
||||
self.note_xy_removed(removed);
|
||||
}
|
||||
while series.points.len() > self.limit {
|
||||
series.points.pop_front();
|
||||
self.note_xy_point(point);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.series.clear();
|
||||
self.invalidate_bounds();
|
||||
}
|
||||
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
@@ -370,6 +562,7 @@ impl PlotBuffer {
|
||||
series.points.pop_front();
|
||||
}
|
||||
}
|
||||
self.invalidate_bounds();
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
@@ -383,14 +576,149 @@ impl PlotBuffer {
|
||||
pub fn iter(&self) -> impl Iterator<Item = &PlotSeries> {
|
||||
self.series.iter()
|
||||
}
|
||||
|
||||
pub fn series_len(&self) -> usize {
|
||||
self.series.len()
|
||||
}
|
||||
|
||||
pub fn total_points(&self) -> usize {
|
||||
self.series.iter().map(PlotSeries::len).sum()
|
||||
}
|
||||
|
||||
pub fn plot_bounds(&mut self) -> Option<PlotBounds> {
|
||||
match self.kind {
|
||||
PlotSeriesKind::TimeSeries => self.time_series_plot_bounds(),
|
||||
PlotSeriesKind::XY => self.xy_plot_bounds(),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate_bounds(&mut self) {
|
||||
self.time_series_y_bounds = None;
|
||||
self.time_series_y_dirty = false;
|
||||
self.xy_bounds = None;
|
||||
self.xy_bounds_dirty = false;
|
||||
}
|
||||
|
||||
fn note_time_series_point(&mut self, point: [f64; 2]) {
|
||||
if !point[1].is_finite() {
|
||||
return;
|
||||
}
|
||||
match &mut self.time_series_y_bounds {
|
||||
Some((min_y, max_y)) => {
|
||||
*min_y = min_y.min(point[1]);
|
||||
*max_y = max_y.max(point[1]);
|
||||
}
|
||||
None => self.time_series_y_bounds = Some((point[1], point[1])),
|
||||
}
|
||||
}
|
||||
|
||||
fn note_time_series_removed(&mut self, removed: [f64; 2]) {
|
||||
if let Some((min_y, max_y)) = self.time_series_y_bounds
|
||||
&& (removed[1] == min_y || removed[1] == max_y)
|
||||
{
|
||||
self.time_series_y_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn note_xy_point(&mut self, point: [f64; 2]) {
|
||||
match &mut self.xy_bounds {
|
||||
Some(bounds) => bounds.extend_with_point(point),
|
||||
None => self.xy_bounds = BoundsRect::from_point(point),
|
||||
}
|
||||
}
|
||||
|
||||
fn note_xy_removed(&mut self, removed: [f64; 2]) {
|
||||
if let Some(bounds) = self.xy_bounds
|
||||
&& bounds.touches(removed)
|
||||
{
|
||||
self.xy_bounds_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn time_series_plot_bounds(&mut self) -> Option<PlotBounds> {
|
||||
let mut min_x = f64::INFINITY;
|
||||
let mut max_x = f64::NEG_INFINITY;
|
||||
|
||||
for series in &self.series {
|
||||
if let Some([x, _]) = series.first_point() {
|
||||
min_x = min_x.min(x);
|
||||
}
|
||||
if let Some([x, _]) = series.last_point() {
|
||||
max_x = max_x.max(x);
|
||||
}
|
||||
}
|
||||
|
||||
if !(min_x.is_finite() && max_x.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.time_series_y_dirty {
|
||||
self.recompute_time_series_y_bounds();
|
||||
}
|
||||
let (min_y, max_y) = self.time_series_y_bounds?;
|
||||
|
||||
Some(
|
||||
BoundsRect {
|
||||
min_x,
|
||||
max_x,
|
||||
min_y,
|
||||
max_y,
|
||||
}
|
||||
.into_plot_bounds(),
|
||||
)
|
||||
}
|
||||
|
||||
fn xy_plot_bounds(&mut self) -> Option<PlotBounds> {
|
||||
if self.xy_bounds_dirty {
|
||||
self.recompute_xy_bounds();
|
||||
}
|
||||
self.xy_bounds.map(BoundsRect::into_plot_bounds)
|
||||
}
|
||||
|
||||
fn recompute_time_series_y_bounds(&mut self) {
|
||||
let mut min_y = f64::INFINITY;
|
||||
let mut max_y = f64::NEG_INFINITY;
|
||||
|
||||
for series in &self.series {
|
||||
for [_, y] in series.points() {
|
||||
if y.is_finite() {
|
||||
min_y = min_y.min(y);
|
||||
max_y = max_y.max(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.time_series_y_bounds = if min_y.is_finite() && max_y.is_finite() {
|
||||
Some((min_y, max_y))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.time_series_y_dirty = false;
|
||||
}
|
||||
|
||||
fn recompute_xy_bounds(&mut self) {
|
||||
let mut bounds: Option<BoundsRect> = None;
|
||||
|
||||
for series in &self.series {
|
||||
for point in series.points() {
|
||||
match &mut bounds {
|
||||
Some(existing) => existing.extend_with_point(point),
|
||||
None => bounds = BoundsRect::from_point(point),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.xy_bounds = bounds;
|
||||
self.xy_bounds_dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
@@ -459,7 +787,11 @@ mod tests {
|
||||
let rendered = series.render_points_time_series(0.0, 99.0, 16);
|
||||
assert!(!rendered.is_empty());
|
||||
assert!(rendered.len() <= 16);
|
||||
assert!(rendered.iter().all(|point| point[0] >= 0.0 && point[0] <= 99.0));
|
||||
assert!(
|
||||
rendered
|
||||
.iter()
|
||||
.all(|point| point[0] >= 0.0 && point[0] <= 99.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
109
crates/pipeview-gui/src/logging.rs
Normal file
109
crates/pipeview-gui/src/logging.rs
Normal 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}")
|
||||
}
|
||||
64
crates/pipeview-gui/src/main.rs
Normal file
64
crates/pipeview-gui/src/main.rs
Normal 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())))),
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
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::{
|
||||
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
|
||||
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,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -17,6 +17,8 @@ pub struct ConfigForm {
|
||||
pub serial_parity: SerialParity,
|
||||
pub serial_stop_bits: SerialStopBits,
|
||||
pub serial_flow_control: SerialFlowControl,
|
||||
pub serial_dtr: bool,
|
||||
pub serial_rts: bool,
|
||||
pub addr: String,
|
||||
pub udp_bind: String,
|
||||
pub udp_remote: String,
|
||||
@@ -42,6 +44,7 @@ pub struct PipelineForm {
|
||||
pub mixed_strip_cr: bool,
|
||||
pub mixed_max_line_len: usize,
|
||||
pub mixed_max_plot_frame: usize,
|
||||
pub lua_framer_script_path: String,
|
||||
pub hex_uppercase: bool,
|
||||
pub hex_separator: String,
|
||||
pub hex_bytes_per_group: usize,
|
||||
@@ -50,6 +53,7 @@ pub struct PipelineForm {
|
||||
pub plot_channels: usize,
|
||||
pub plot_endian: Endian,
|
||||
pub plot_format: PlotFormat,
|
||||
pub lua_decoder_script_path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -66,6 +70,7 @@ pub enum FramerChoice {
|
||||
Length,
|
||||
Cobs,
|
||||
MixedTextPlot,
|
||||
Lua,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -74,6 +79,7 @@ pub enum DecoderChoice {
|
||||
Hex,
|
||||
Plot,
|
||||
MixedTextPlot,
|
||||
Lua,
|
||||
}
|
||||
|
||||
impl PipelineForm {
|
||||
@@ -94,6 +100,7 @@ impl PipelineForm {
|
||||
mixed_strip_cr: true,
|
||||
mixed_max_line_len: 1_048_576,
|
||||
mixed_max_plot_frame: 1_048_576,
|
||||
lua_framer_script_path: String::new(),
|
||||
hex_uppercase: false,
|
||||
hex_separator: String::from(" "),
|
||||
hex_bytes_per_group: 1,
|
||||
@@ -102,6 +109,7 @@ impl PipelineForm {
|
||||
plot_channels: 1,
|
||||
plot_endian: Endian::Little,
|
||||
plot_format: PlotFormat::Interleaved,
|
||||
lua_decoder_script_path: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +124,8 @@ impl Default for ConfigForm {
|
||||
serial_parity: SerialParity::None,
|
||||
serial_stop_bits: SerialStopBits::One,
|
||||
serial_flow_control: SerialFlowControl::None,
|
||||
serial_dtr: false,
|
||||
serial_rts: false,
|
||||
addr: String::from("127.0.0.1:8080"),
|
||||
udp_bind: String::from("0.0.0.0:9000"),
|
||||
udp_remote: String::new(),
|
||||
@@ -158,6 +168,14 @@ impl ConfigForm {
|
||||
TransportConfig::Serial { flow_control, .. } => *flow_control,
|
||||
_ => SerialFlowControl::None,
|
||||
},
|
||||
serial_dtr: match &config.transport {
|
||||
TransportConfig::Serial { dtr, .. } => *dtr,
|
||||
_ => false,
|
||||
},
|
||||
serial_rts: match &config.transport {
|
||||
TransportConfig::Serial { rts, .. } => *rts,
|
||||
_ => false,
|
||||
},
|
||||
addr: match &config.transport {
|
||||
TransportConfig::Tcp { addr } => addr.clone(),
|
||||
_ => String::from("127.0.0.1:8080"),
|
||||
@@ -190,6 +208,8 @@ impl ConfigForm {
|
||||
parity: self.serial_parity,
|
||||
stop_bits: self.serial_stop_bits,
|
||||
flow_control: self.serial_flow_control,
|
||||
dtr: self.serial_dtr,
|
||||
rts: self.serial_rts,
|
||||
},
|
||||
TransportChoice::Tcp => TransportConfig::Tcp {
|
||||
addr: self.addr.clone(),
|
||||
@@ -257,6 +277,10 @@ impl PipelineForm {
|
||||
form.mixed_max_line_len = *max_line_len;
|
||||
form.mixed_max_plot_frame = *max_plot_frame;
|
||||
}
|
||||
FramerConfig::Lua { script_path } => {
|
||||
form.framer = FramerChoice::Lua;
|
||||
form.lua_framer_script_path = script_path.clone();
|
||||
}
|
||||
}
|
||||
|
||||
match &config.decoder {
|
||||
@@ -292,6 +316,10 @@ impl PipelineForm {
|
||||
form.decoder = DecoderChoice::MixedTextPlot;
|
||||
form.text_encoding = *encoding;
|
||||
}
|
||||
DecoderConfig::Lua { script_path } => {
|
||||
form.decoder = DecoderChoice::Lua;
|
||||
form.lua_decoder_script_path = script_path.clone();
|
||||
}
|
||||
}
|
||||
|
||||
form
|
||||
@@ -322,6 +350,9 @@ impl PipelineForm {
|
||||
max_line_len: self.mixed_max_line_len,
|
||||
max_plot_frame: self.mixed_max_plot_frame,
|
||||
},
|
||||
FramerChoice::Lua => FramerConfig::Lua {
|
||||
script_path: self.lua_framer_script_path.clone(),
|
||||
},
|
||||
},
|
||||
decoder: match self.decoder {
|
||||
DecoderChoice::Text => DecoderConfig::Text {
|
||||
@@ -342,6 +373,9 @@ impl PipelineForm {
|
||||
DecoderChoice::MixedTextPlot => DecoderConfig::MixedTextPlot {
|
||||
encoding: self.text_encoding,
|
||||
},
|
||||
DecoderChoice::Lua => DecoderConfig::Lua {
|
||||
script_path: self.lua_decoder_script_path.clone(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -411,10 +445,7 @@ fn render_transport_section(ui: &mut Ui, form: &mut ConfigForm) {
|
||||
|
||||
match form.transport {
|
||||
TransportChoice::Serial => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Port:");
|
||||
ui.text_edit_singleline(&mut form.port);
|
||||
});
|
||||
render_serial_port_combo(ui, form);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Baud:");
|
||||
ui.add(DragValue::new(&mut form.baud).range(300..=12_000_000));
|
||||
@@ -487,6 +518,8 @@ fn render_transport_section(ui: &mut Ui, form: &mut ConfigForm) {
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.checkbox(&mut form.serial_dtr, "DTR");
|
||||
ui.checkbox(&mut form.serial_rts, "RTS");
|
||||
}
|
||||
TransportChoice::Tcp => {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -507,6 +540,62 @@ fn render_transport_section(ui: &mut Ui, form: &mut ConfigForm) {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_serial_port_combo(ui: &mut Ui, form: &mut ConfigForm) {
|
||||
let available_ports = available_serial_ports();
|
||||
let selected_text = selected_serial_port_text(&form.port, &available_ports);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Port:");
|
||||
ComboBox::from_id_salt("serial_port")
|
||||
.width(180.0)
|
||||
.selected_text(selected_text)
|
||||
.show_ui(ui, |ui| {
|
||||
if available_ports.is_empty() {
|
||||
ui.add_enabled(false, egui::Label::new("No serial ports found"));
|
||||
} else {
|
||||
for port in &available_ports {
|
||||
ui.selectable_value(&mut form.port, port.clone(), port);
|
||||
}
|
||||
}
|
||||
|
||||
let current_port = form.port.clone();
|
||||
if !current_port.trim().is_empty()
|
||||
&& !available_ports.iter().any(|port| port == ¤t_port)
|
||||
{
|
||||
ui.separator();
|
||||
ui.selectable_value(
|
||||
&mut form.port,
|
||||
current_port.clone(),
|
||||
format!("{current_port} (current)"),
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.add(TextEdit::singleline(&mut form.port).desired_width(260.0));
|
||||
});
|
||||
}
|
||||
|
||||
fn available_serial_ports() -> Vec<String> {
|
||||
let mut ports: Vec<_> = SerialTransport::list_ports()
|
||||
.into_iter()
|
||||
.map(|port| port.port_name)
|
||||
.collect();
|
||||
ports.sort();
|
||||
ports.dedup();
|
||||
ports
|
||||
}
|
||||
|
||||
fn selected_serial_port_text(current_port: &str, available_ports: &[String]) -> String {
|
||||
if current_port.trim().is_empty() {
|
||||
if available_ports.is_empty() {
|
||||
String::from("No serial ports found")
|
||||
} else {
|
||||
String::from("Select port")
|
||||
}
|
||||
} else {
|
||||
current_port.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pipeline_header(
|
||||
ui: &mut Ui,
|
||||
pipeline: &mut PipelineForm,
|
||||
@@ -524,12 +613,14 @@ fn render_pipeline_header(
|
||||
FramerChoice::Length => "Length",
|
||||
FramerChoice::Cobs => "Cobs",
|
||||
FramerChoice::MixedTextPlot => "MixedTextPlot",
|
||||
FramerChoice::Lua => "Lua",
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut pipeline.framer, FramerChoice::Line, "Line");
|
||||
ui.selectable_value(&mut pipeline.framer, FramerChoice::Fixed, "Fixed");
|
||||
ui.selectable_value(&mut pipeline.framer, FramerChoice::Length, "Length");
|
||||
ui.selectable_value(&mut pipeline.framer, FramerChoice::Cobs, "Cobs");
|
||||
ui.selectable_value(&mut pipeline.framer, FramerChoice::Lua, "Lua");
|
||||
if ui
|
||||
.selectable_label(
|
||||
matches!(pipeline.framer, FramerChoice::MixedTextPlot),
|
||||
@@ -548,10 +639,12 @@ fn render_pipeline_header(
|
||||
DecoderChoice::Hex => "Hex",
|
||||
DecoderChoice::Plot => "Plot",
|
||||
DecoderChoice::MixedTextPlot => "MixedTextPlot",
|
||||
DecoderChoice::Lua => "Lua",
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Text, "Text");
|
||||
ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Hex, "Hex");
|
||||
ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Lua, "Lua");
|
||||
if ui
|
||||
.selectable_label(matches!(pipeline.decoder, DecoderChoice::Plot), "Plot")
|
||||
.clicked()
|
||||
@@ -642,6 +735,14 @@ fn render_framer_settings(ui: &mut Ui, pipeline: &mut PipelineForm, index: usize
|
||||
ui.add(DragValue::new(&mut pipeline.mixed_max_plot_frame).range(1..=16_777_216));
|
||||
});
|
||||
}
|
||||
FramerChoice::Lua => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Script:");
|
||||
ui.add(
|
||||
TextEdit::singleline(&mut pipeline.lua_framer_script_path).desired_width(260.0),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,6 +822,15 @@ fn render_decoder_settings(ui: &mut Ui, pipeline: &mut PipelineForm, index: usiz
|
||||
"Plot sample type, endian, format, and channels come from the mixed packet header.",
|
||||
);
|
||||
}
|
||||
DecoderChoice::Lua => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Script:");
|
||||
ui.add(
|
||||
TextEdit::singleline(&mut pipeline.lua_decoder_script_path)
|
||||
.desired_width(260.0),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,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
|
||||
@@ -782,6 +892,18 @@ fn validate_pipeline(ui: &mut Ui, pipeline: &PipelineForm, errors: &mut Vec<Stri
|
||||
{
|
||||
push_error(String::from("Length framer len_bytes must be 1, 2, or 4"));
|
||||
}
|
||||
|
||||
if matches!(pipeline.framer, FramerChoice::Lua)
|
||||
&& pipeline.lua_framer_script_path.trim().is_empty()
|
||||
{
|
||||
push_error(String::from("Lua framer script path is required"));
|
||||
}
|
||||
|
||||
if matches!(pipeline.decoder, DecoderChoice::Lua)
|
||||
&& pipeline.lua_decoder_script_path.trim().is_empty()
|
||||
{
|
||||
push_error(String::from("Lua decoder script path is required"));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_text_encoding_combo(ui: &mut Ui, encoding: &mut TextEncoding, id: impl std::hash::Hash) {
|
||||
@@ -881,6 +1003,15 @@ mod tests {
|
||||
format: PlotFormat::Block,
|
||||
},
|
||||
},
|
||||
PipelineConfig {
|
||||
name: String::from("lua"),
|
||||
framer: FramerConfig::Lua {
|
||||
script_path: String::from("/tmp/framer.lua"),
|
||||
},
|
||||
decoder: DecoderConfig::Lua {
|
||||
script_path: String::from("/tmp/decoder.lua"),
|
||||
},
|
||||
},
|
||||
],
|
||||
history_limit: 4096,
|
||||
auto_reconnect: true,
|
||||
@@ -894,4 +1025,22 @@ mod tests {
|
||||
serde_json::to_string(&session).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_serial_port_text_prefers_current_value() {
|
||||
let ports = vec![String::from("/dev/ttyUSB0")];
|
||||
assert_eq!(
|
||||
selected_serial_port_text("/dev/ttyUSB1", &ports),
|
||||
"/dev/ttyUSB1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_serial_port_text_uses_placeholder_when_empty() {
|
||||
assert_eq!(selected_serial_port_text("", &[]), "No serial ports found");
|
||||
assert_eq!(
|
||||
selected_serial_port_text("", &[String::from("/dev/ttyUSB0")]),
|
||||
"Select port"
|
||||
);
|
||||
}
|
||||
}
|
||||
129
crates/pipeview-gui/src/panels/console.rs
Normal file
129
crates/pipeview-gui/src/panels/console.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
134
crates/pipeview-gui/src/panels/hex_view.rs
Normal file
134
crates/pipeview-gui/src/panels/hex_view.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,61 @@
|
||||
use crate::buffers::{PlotBuffer, PlotSeriesKind};
|
||||
use crate::perf::PlotRenderStats;
|
||||
use egui::{Button, Ui, vec2};
|
||||
use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlotDisplayMode {
|
||||
Embedded,
|
||||
Detached,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PlotViewState {
|
||||
pub lock_x: bool,
|
||||
pub lock_y: bool,
|
||||
pub box_zoom: bool,
|
||||
pub follow_latest: bool,
|
||||
pub last_bounds: Option<PlotBounds>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
pub struct PlotRenderOutput {
|
||||
pub stats: PlotRenderStats,
|
||||
pub toggle_detached: bool,
|
||||
}
|
||||
|
||||
pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotViewState) {
|
||||
pub fn render(
|
||||
ui: &mut Ui,
|
||||
buf: &mut PlotBuffer,
|
||||
session_id: u64,
|
||||
state: &mut PlotViewState,
|
||||
) -> PlotRenderOutput {
|
||||
let mode = if state.detached {
|
||||
PlotDisplayMode::Detached
|
||||
} else {
|
||||
PlotDisplayMode::Embedded
|
||||
};
|
||||
let mut toggle_detached = false;
|
||||
|
||||
if buf.is_empty() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let toggle_label = match mode {
|
||||
PlotDisplayMode::Embedded => "Pop Out",
|
||||
PlotDisplayMode::Detached => "Dock Back",
|
||||
};
|
||||
if ui.button(toggle_label).clicked() {
|
||||
toggle_detached = true;
|
||||
}
|
||||
ui.label("no plot data");
|
||||
return;
|
||||
});
|
||||
return PlotRenderOutput {
|
||||
stats: PlotRenderStats {
|
||||
series_count: 0,
|
||||
stored_points: 0,
|
||||
rendered_points: 0,
|
||||
},
|
||||
toggle_detached,
|
||||
};
|
||||
}
|
||||
|
||||
let mut auto_fit = false;
|
||||
@@ -40,6 +70,13 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
|
||||
if ui.button("Reset View").clicked() {
|
||||
reset_view = true;
|
||||
}
|
||||
let toggle_label = match mode {
|
||||
PlotDisplayMode::Embedded => "Pop Out",
|
||||
PlotDisplayMode::Detached => "Dock Back",
|
||||
};
|
||||
if ui.button(toggle_label).clicked() {
|
||||
toggle_detached = true;
|
||||
}
|
||||
ui.separator();
|
||||
ui.checkbox(&mut state.lock_x, "Lock X");
|
||||
ui.checkbox(&mut state.lock_y, "Lock Y");
|
||||
@@ -92,7 +129,7 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
|
||||
});
|
||||
}
|
||||
|
||||
let data_bounds = plot_bounds(buf);
|
||||
let data_bounds = buf.plot_bounds();
|
||||
let mut plot = Plot::new(format!("plot_view_{session_id}"))
|
||||
.legend(Legend::default())
|
||||
.allow_boxed_zoom(state.box_zoom)
|
||||
@@ -107,6 +144,7 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
|
||||
plot = plot.reset();
|
||||
}
|
||||
|
||||
let mut rendered_points = 0usize;
|
||||
plot.show(ui, |plot_ui| {
|
||||
let current_bounds = plot_ui.plot_bounds();
|
||||
state.last_bounds = current_bounds.is_finite().then_some(current_bounds);
|
||||
@@ -123,7 +161,10 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
|
||||
if let Some(bounds) = data_bounds {
|
||||
let current_width = current_bounds.width();
|
||||
let data_width = bounds.width();
|
||||
let x_range = if current_width.is_finite() && current_width > 0.0 && data_width > current_width {
|
||||
let x_range = if current_width.is_finite()
|
||||
&& current_width > 0.0
|
||||
&& data_width > current_width
|
||||
{
|
||||
(bounds.max()[0] - current_width)..=bounds.max()[0]
|
||||
} else {
|
||||
bounds.min()[0]..=bounds.max()[0]
|
||||
@@ -132,7 +173,10 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
|
||||
plot_ui.set_plot_bounds_x(x_range.clone());
|
||||
|
||||
let mut followed = current_bounds;
|
||||
followed.set_x(&PlotBounds::from_min_max([*x_range.start(), current_bounds.min()[1]], [*x_range.end(), current_bounds.max()[1]]));
|
||||
followed.set_x(&PlotBounds::from_min_max(
|
||||
[*x_range.start(), current_bounds.min()[1]],
|
||||
[*x_range.end(), current_bounds.max()[1]],
|
||||
));
|
||||
state.last_bounds = Some(followed);
|
||||
}
|
||||
} else if zoom_x != 1.0 || zoom_y != 1.0 {
|
||||
@@ -151,49 +195,25 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
|
||||
|
||||
for series in buf.iter() {
|
||||
let sampled_points = match buf.kind() {
|
||||
PlotSeriesKind::TimeSeries => {
|
||||
series.render_points_time_series(
|
||||
PlotSeriesKind::TimeSeries => series.render_points_time_series(
|
||||
current_bounds.min()[0],
|
||||
current_bounds.max()[0],
|
||||
max_render_points,
|
||||
)
|
||||
}
|
||||
),
|
||||
PlotSeriesKind::XY => series.render_points_xy(max_render_points),
|
||||
};
|
||||
rendered_points += sampled_points.len();
|
||||
let points = PlotPoints::from_iter(sampled_points);
|
||||
plot_ui.line(Line::new(series.name.clone(), points));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn plot_bounds(buf: &PlotBuffer) -> Option<PlotBounds> {
|
||||
let mut bounds = PlotBounds::NOTHING;
|
||||
|
||||
for series in buf.iter() {
|
||||
for [x, y] in series.points() {
|
||||
if x.is_finite() && y.is_finite() {
|
||||
bounds.extend_with(&[x, y].into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !bounds.is_finite() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if bounds.width() <= 0.0 {
|
||||
let center_x = bounds.center().x;
|
||||
bounds.set_x_center_width(center_x, 1.0);
|
||||
} else {
|
||||
bounds.expand_x(bounds.width() * 0.05);
|
||||
}
|
||||
|
||||
if bounds.height() <= 0.0 {
|
||||
let center_y = bounds.center().y;
|
||||
bounds.set_y_center_height(center_y, 1.0);
|
||||
} else {
|
||||
bounds.expand_y(bounds.height() * 0.1);
|
||||
}
|
||||
|
||||
Some(bounds)
|
||||
|
||||
PlotRenderOutput {
|
||||
stats: PlotRenderStats {
|
||||
series_count: buf.series_len(),
|
||||
stored_points: buf.total_points(),
|
||||
rendered_points,
|
||||
},
|
||||
toggle_detached,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub fn render(
|
||||
sessions: &[SessionListItem],
|
||||
active: &mut usize,
|
||||
on_new: &mut bool,
|
||||
on_edit: &mut Option<usize>,
|
||||
on_delete: &mut Option<usize>,
|
||||
) {
|
||||
ui.heading("Sessions");
|
||||
@@ -37,12 +38,23 @@ pub fn render(
|
||||
"{} Session {}\n{}",
|
||||
status_tag, session.id, session.transport_summary
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.selectable_label(*active == i, RichText::new(label).color(color))
|
||||
.clicked()
|
||||
{
|
||||
*active = i;
|
||||
}
|
||||
|
||||
if ui
|
||||
.small_button("⚙")
|
||||
.on_hover_text("Edit session config")
|
||||
.clicked()
|
||||
{
|
||||
*active = i;
|
||||
*on_edit = Some(i);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
257
crates/pipeview-gui/src/perf.rs
Normal file
257
crates/pipeview-gui/src/perf.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RepaintCounter {
|
||||
inner: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl RepaintCounter {
|
||||
pub fn increment(&self) {
|
||||
self.inner.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn take(&self) -> u64 {
|
||||
self.inner.swap(0, Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DurationStats {
|
||||
count: u64,
|
||||
total: Duration,
|
||||
max: Duration,
|
||||
}
|
||||
|
||||
impl DurationStats {
|
||||
fn record(&mut self, elapsed: Duration) {
|
||||
self.count += 1;
|
||||
self.total += elapsed;
|
||||
self.max = self.max.max(elapsed);
|
||||
}
|
||||
|
||||
fn avg_ms(&self) -> f64 {
|
||||
if self.count == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.total.as_secs_f64() * 1000.0) / self.count as f64
|
||||
}
|
||||
}
|
||||
|
||||
fn max_ms(&self) -> f64 {
|
||||
self.max.as_secs_f64() * 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct IntervalStats {
|
||||
frames: DurationStats,
|
||||
drains: DurationStats,
|
||||
text_renders: DurationStats,
|
||||
hex_renders: DurationStats,
|
||||
plot_renders: DurationStats,
|
||||
drained_events: u64,
|
||||
drained_data_events: u64,
|
||||
drained_text_events: u64,
|
||||
drained_hex_events: u64,
|
||||
drained_plot_events: u64,
|
||||
max_pending_events: usize,
|
||||
max_text_lines: usize,
|
||||
max_hex_lines: usize,
|
||||
max_plot_series: usize,
|
||||
max_plot_stored_points: usize,
|
||||
max_plot_rendered_points: usize,
|
||||
tabs: usize,
|
||||
active_view: &'static str,
|
||||
active_text_lines: usize,
|
||||
active_hex_lines: usize,
|
||||
active_plot_series: usize,
|
||||
active_plot_points: usize,
|
||||
}
|
||||
|
||||
pub struct DrainStats {
|
||||
pub drained_events: u64,
|
||||
pub drained_data_events: u64,
|
||||
pub drained_text_events: u64,
|
||||
pub drained_hex_events: u64,
|
||||
pub drained_plot_events: u64,
|
||||
pub pending_events: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct GuiSnapshot {
|
||||
pub tabs: usize,
|
||||
pub active_view: &'static str,
|
||||
pub active_text_lines: usize,
|
||||
pub active_hex_lines: usize,
|
||||
pub active_plot_series: usize,
|
||||
pub active_plot_points: usize,
|
||||
}
|
||||
|
||||
pub struct PlotRenderStats {
|
||||
pub series_count: usize,
|
||||
pub stored_points: usize,
|
||||
pub rendered_points: usize,
|
||||
}
|
||||
|
||||
pub struct GuiProfiler {
|
||||
enabled: bool,
|
||||
interval: Duration,
|
||||
last_report_at: Instant,
|
||||
repaint_counter: RepaintCounter,
|
||||
interval_stats: IntervalStats,
|
||||
}
|
||||
|
||||
impl GuiProfiler {
|
||||
pub fn from_env() -> Self {
|
||||
let enabled = env_flag("XSERIAL_GUI_PROFILE");
|
||||
let interval_ms = env::var("XSERIAL_GUI_PROFILE_INTERVAL_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1_000);
|
||||
Self {
|
||||
enabled,
|
||||
interval: Duration::from_millis(interval_ms),
|
||||
last_report_at: Instant::now(),
|
||||
repaint_counter: RepaintCounter::default(),
|
||||
interval_stats: IntervalStats {
|
||||
active_view: "none",
|
||||
..IntervalStats::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn repaint_counter(&self) -> RepaintCounter {
|
||||
self.repaint_counter.clone()
|
||||
}
|
||||
|
||||
pub fn record_drain(&mut self, elapsed: Duration, stats: DrainStats) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
self.interval_stats.drains.record(elapsed);
|
||||
self.interval_stats.drained_events += stats.drained_events;
|
||||
self.interval_stats.drained_data_events += stats.drained_data_events;
|
||||
self.interval_stats.drained_text_events += stats.drained_text_events;
|
||||
self.interval_stats.drained_hex_events += stats.drained_hex_events;
|
||||
self.interval_stats.drained_plot_events += stats.drained_plot_events;
|
||||
self.interval_stats.max_pending_events = self
|
||||
.interval_stats
|
||||
.max_pending_events
|
||||
.max(stats.pending_events);
|
||||
}
|
||||
|
||||
pub fn record_text_render(&mut self, elapsed: Duration, line_count: usize) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
self.interval_stats.text_renders.record(elapsed);
|
||||
self.interval_stats.max_text_lines = self.interval_stats.max_text_lines.max(line_count);
|
||||
}
|
||||
|
||||
pub fn record_hex_render(&mut self, elapsed: Duration, line_count: usize) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
self.interval_stats.hex_renders.record(elapsed);
|
||||
self.interval_stats.max_hex_lines = self.interval_stats.max_hex_lines.max(line_count);
|
||||
}
|
||||
|
||||
pub fn record_plot_render(&mut self, elapsed: Duration, stats: PlotRenderStats) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
self.interval_stats.plot_renders.record(elapsed);
|
||||
self.interval_stats.max_plot_series =
|
||||
self.interval_stats.max_plot_series.max(stats.series_count);
|
||||
self.interval_stats.max_plot_stored_points = self
|
||||
.interval_stats
|
||||
.max_plot_stored_points
|
||||
.max(stats.stored_points);
|
||||
self.interval_stats.max_plot_rendered_points = self
|
||||
.interval_stats
|
||||
.max_plot_rendered_points
|
||||
.max(stats.rendered_points);
|
||||
}
|
||||
|
||||
pub fn record_frame(&mut self, elapsed: Duration, snapshot: GuiSnapshot) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
self.interval_stats.frames.record(elapsed);
|
||||
self.interval_stats.tabs = snapshot.tabs;
|
||||
self.interval_stats.active_view = snapshot.active_view;
|
||||
self.interval_stats.active_text_lines = snapshot.active_text_lines;
|
||||
self.interval_stats.active_hex_lines = snapshot.active_hex_lines;
|
||||
self.interval_stats.active_plot_series = snapshot.active_plot_series;
|
||||
self.interval_stats.active_plot_points = snapshot.active_plot_points;
|
||||
|
||||
if self.last_report_at.elapsed() < self.interval {
|
||||
return;
|
||||
}
|
||||
|
||||
let repaints = self.repaint_counter.take();
|
||||
info!(
|
||||
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(),
|
||||
drains = self.interval_stats.drains.count,
|
||||
drain_avg_ms = self.interval_stats.drains.avg_ms(),
|
||||
drain_max_ms = self.interval_stats.drains.max_ms(),
|
||||
drained_events = self.interval_stats.drained_events,
|
||||
drained_data_events = self.interval_stats.drained_data_events,
|
||||
drained_text_events = self.interval_stats.drained_text_events,
|
||||
drained_hex_events = self.interval_stats.drained_hex_events,
|
||||
drained_plot_events = self.interval_stats.drained_plot_events,
|
||||
repaint_requests = repaints,
|
||||
max_pending_events = self.interval_stats.max_pending_events,
|
||||
text_renders = self.interval_stats.text_renders.count,
|
||||
text_avg_ms = self.interval_stats.text_renders.avg_ms(),
|
||||
text_max_ms = self.interval_stats.text_renders.max_ms(),
|
||||
max_text_lines = self.interval_stats.max_text_lines,
|
||||
hex_renders = self.interval_stats.hex_renders.count,
|
||||
hex_avg_ms = self.interval_stats.hex_renders.avg_ms(),
|
||||
hex_max_ms = self.interval_stats.hex_renders.max_ms(),
|
||||
max_hex_lines = self.interval_stats.max_hex_lines,
|
||||
plot_renders = self.interval_stats.plot_renders.count,
|
||||
plot_avg_ms = self.interval_stats.plot_renders.avg_ms(),
|
||||
plot_max_ms = self.interval_stats.plot_renders.max_ms(),
|
||||
max_plot_series = self.interval_stats.max_plot_series,
|
||||
max_plot_stored_points = self.interval_stats.max_plot_stored_points,
|
||||
max_plot_rendered_points = self.interval_stats.max_plot_rendered_points,
|
||||
tabs = self.interval_stats.tabs,
|
||||
active_view = self.interval_stats.active_view,
|
||||
active_text_lines = self.interval_stats.active_text_lines,
|
||||
active_hex_lines = self.interval_stats.active_hex_lines,
|
||||
active_plot_series = self.interval_stats.active_plot_series,
|
||||
active_plot_points = self.interval_stats.active_plot_points,
|
||||
"gui perf"
|
||||
);
|
||||
|
||||
self.last_report_at = Instant::now();
|
||||
self.interval_stats = IntervalStats {
|
||||
active_view: "none",
|
||||
..IntervalStats::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn env_flag(name: &str) -> bool {
|
||||
env::var(name)
|
||||
.map(|value| {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
!value.is_empty() && value != "0" && value != "false" && value != "off"
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
142
crates/pipeview-gui/src/shortcuts.rs
Normal file
142
crates/pipeview-gui/src/shortcuts.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use egui::{Context, Key, KeyboardShortcut, Modifiers};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
NextTab,
|
||||
PrevTab,
|
||||
|
||||
ViewText,
|
||||
ViewHex,
|
||||
ViewPlot,
|
||||
|
||||
NewSession,
|
||||
EditSession,
|
||||
DeleteSession,
|
||||
ToggleConnect,
|
||||
Clear,
|
||||
|
||||
UiSettings,
|
||||
CloseOverlay,
|
||||
|
||||
Search,
|
||||
SearchNext,
|
||||
SearchPrev,
|
||||
}
|
||||
|
||||
pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
||||
use Action::*;
|
||||
|
||||
vec![
|
||||
(NextTab, KeyboardShortcut::new(Modifiers::CTRL, Key::Tab)),
|
||||
(
|
||||
PrevTab,
|
||||
KeyboardShortcut::new(Modifiers::CTRL | Modifiers::SHIFT, Key::Tab),
|
||||
),
|
||||
(ViewText, KeyboardShortcut::new(Modifiers::CTRL, Key::T)),
|
||||
(ViewHex, KeyboardShortcut::new(Modifiers::CTRL, Key::H)),
|
||||
(ViewPlot, KeyboardShortcut::new(Modifiers::CTRL, Key::P)),
|
||||
(NewSession, KeyboardShortcut::new(Modifiers::CTRL, Key::N)),
|
||||
(EditSession, KeyboardShortcut::new(Modifiers::CTRL, Key::E)),
|
||||
(
|
||||
DeleteSession,
|
||||
KeyboardShortcut::new(Modifiers::CTRL, Key::W),
|
||||
),
|
||||
(
|
||||
ToggleConnect,
|
||||
KeyboardShortcut::new(Modifiers::CTRL, Key::F5),
|
||||
),
|
||||
(Clear, KeyboardShortcut::new(Modifiers::CTRL, Key::L)),
|
||||
(
|
||||
UiSettings,
|
||||
KeyboardShortcut::new(Modifiers::CTRL, Key::Comma),
|
||||
),
|
||||
(
|
||||
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),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn process(
|
||||
ctx: &Context,
|
||||
suppress_char_shortcuts: bool,
|
||||
bindings: &[(Action, KeyboardShortcut)],
|
||||
) -> Vec<Action> {
|
||||
let mut actions = Vec::new();
|
||||
|
||||
ctx.input_mut(|input| {
|
||||
for (action, shortcut) in bindings {
|
||||
if !input.consume_shortcut(shortcut) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if suppress_char_shortcuts && is_char_based(*action) {
|
||||
continue;
|
||||
}
|
||||
|
||||
actions.push(*action);
|
||||
}
|
||||
});
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
fn is_char_based(action: Action) -> bool {
|
||||
use Action::*;
|
||||
!matches!(action, CloseOverlay)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bindings_contain_no_duplicate_shortcuts() {
|
||||
let bindings = default_bindings();
|
||||
for (i, (_, a)) in bindings.iter().enumerate() {
|
||||
for (j, (_, b)) in bindings.iter().enumerate() {
|
||||
if i != j {
|
||||
assert_ne!(a, b, "Duplicate shortcut at indices {i} and {j}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bindings_non_empty() {
|
||||
assert!(!default_bindings().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_overlay_not_char_based() {
|
||||
assert!(!is_char_based(Action::CloseOverlay));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_other_actions_are_char_based() {
|
||||
for action in [
|
||||
Action::NextTab,
|
||||
Action::PrevTab,
|
||||
Action::ViewText,
|
||||
Action::ViewHex,
|
||||
Action::ViewPlot,
|
||||
Action::NewSession,
|
||||
Action::EditSession,
|
||||
Action::DeleteSession,
|
||||
Action::ToggleConnect,
|
||||
Action::Clear,
|
||||
Action::UiSettings,
|
||||
Action::Search,
|
||||
Action::SearchNext,
|
||||
Action::SearchPrev,
|
||||
] {
|
||||
assert!(is_char_based(action), "{action:?} should be char-based");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -19,3 +19,6 @@ tracing-appender = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
image = { workspace = true }
|
||||
mlua = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
1085
crates/pipeview-tui/src/app.rs
Normal file
1085
crates/pipeview-tui/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
103
crates/pipeview-tui/src/app_state.rs
Normal file
103
crates/pipeview-tui/src/app_state.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
use pipeview_client::SessionConfig;
|
||||
|
||||
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PersistedTuiState {
|
||||
#[serde(default)]
|
||||
pub sessions: Vec<SessionConfig>,
|
||||
#[serde(default)]
|
||||
pub active: usize,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_timestamp: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_direction: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_pipeline: bool,
|
||||
}
|
||||
|
||||
pub fn load_tui_state() -> PersistedTuiState {
|
||||
match load_tui_state_from_path(&tui_state_path()) {
|
||||
Ok(state) => state,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedTuiState::default(),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to load persisted TUI state");
|
||||
PersistedTuiState::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_tui_state(state: &PersistedTuiState) {
|
||||
if let Err(err) = save_tui_state_to_path(state, &tui_state_path()) {
|
||||
warn!(error = %err, "Failed to persist TUI state");
|
||||
}
|
||||
}
|
||||
|
||||
fn tui_state_path() -> PathBuf {
|
||||
state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
||||
}
|
||||
|
||||
fn 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("pipeview")
|
||||
.join(TUI_STATE_FILE_NAME);
|
||||
}
|
||||
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(path)
|
||||
.join("pipeview")
|
||||
.join(TUI_STATE_FILE_NAME);
|
||||
}
|
||||
}
|
||||
"macos" => {
|
||||
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(home)
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.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("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("pipeview")
|
||||
.join(TUI_STATE_FILE_NAME);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PathBuf::from(TUI_STATE_FILE_NAME)
|
||||
}
|
||||
|
||||
fn load_tui_state_from_path(path: &Path) -> io::Result<PersistedTuiState> {
|
||||
let text = fs::read_to_string(path)?;
|
||||
serde_json::from_str(&text).map_err(io::Error::other)
|
||||
}
|
||||
|
||||
fn save_tui_state_to_path(state: &PersistedTuiState, path: &Path) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?;
|
||||
fs::write(path, json)
|
||||
}
|
||||
|
||||
const fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
146
crates/pipeview-tui/src/buffers.rs
Normal file
146
crates/pipeview-tui/src/buffers.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pipeview_client::{DecodedEntry, RingBuffer};
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LineDirection {
|
||||
In,
|
||||
Out,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConsoleLine {
|
||||
pub elapsed: Duration,
|
||||
pub pipeline: String,
|
||||
pub text: String,
|
||||
pub direction: LineDirection,
|
||||
}
|
||||
|
||||
pub struct TextBuffer {
|
||||
started_at: Instant,
|
||||
lines: RingBuffer<ConsoleLine>,
|
||||
}
|
||||
|
||||
impl TextBuffer {
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
lines: RingBuffer::new(limit),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||
if let DecodedData::Text(text) = &entry.data {
|
||||
self.lines.push(ConsoleLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: entry.pipeline_name.clone(),
|
||||
text: text.clone(),
|
||||
direction: LineDirection::In,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_outbound(&mut self, text: String) {
|
||||
self.lines.push(ConsoleLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: String::from("OUT"),
|
||||
text,
|
||||
direction: LineDirection::Out,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.lines.clear();
|
||||
}
|
||||
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
self.lines.set_limit(limit);
|
||||
}
|
||||
|
||||
pub fn recent(&self, count: usize) -> Vec<ConsoleLine> {
|
||||
self.lines.drain_recent(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HexLine {
|
||||
pub elapsed: Duration,
|
||||
pub pipeline: String,
|
||||
pub hex: String,
|
||||
pub ascii: String,
|
||||
pub direction: LineDirection,
|
||||
}
|
||||
|
||||
pub struct HexBuffer {
|
||||
started_at: Instant,
|
||||
lines: RingBuffer<HexLine>,
|
||||
}
|
||||
|
||||
impl HexBuffer {
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
lines: RingBuffer::new(limit),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||
if let DecodedData::Hex(hex) = &entry.data {
|
||||
self.lines.push(HexLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: entry.pipeline_name.clone(),
|
||||
ascii: decode_ascii(hex),
|
||||
hex: hex.clone(),
|
||||
direction: LineDirection::In,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_outbound(&mut self, hex: String) {
|
||||
self.lines.push(HexLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: String::from("OUT"),
|
||||
ascii: decode_ascii(&hex),
|
||||
hex,
|
||||
direction: LineDirection::Out,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.lines.clear();
|
||||
}
|
||||
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
self.lines.set_limit(limit);
|
||||
}
|
||||
|
||||
pub fn recent(&self, count: usize) -> Vec<HexLine> {
|
||||
self.lines.drain_recent(count)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_ascii(hex: &str) -> String {
|
||||
hex::decode(hex.replace(' ', ""))
|
||||
.map(|bytes| {
|
||||
bytes
|
||||
.into_iter()
|
||||
.map(|byte| {
|
||||
if byte.is_ascii_graphic() || byte == b' ' {
|
||||
byte as char
|
||||
} else {
|
||||
'.'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|_| String::from("[invalid hex]"))
|
||||
}
|
||||
38
crates/pipeview-tui/src/main.rs
Normal file
38
crates/pipeview-tui/src/main.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
mod app;
|
||||
mod app_state;
|
||||
mod buffers;
|
||||
mod ui;
|
||||
|
||||
use std::io;
|
||||
|
||||
use crossterm::{
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||
use pipeview_client::SessionManager;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let manager = SessionManager::new();
|
||||
let rx = manager.subscribe();
|
||||
|
||||
let result = app::run(&mut terminal, app::App::new(manager, rx));
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
}
|
||||
332
crates/pipeview-tui/src/ui.rs
Normal file
332
crates/pipeview-tui/src/ui.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
use crate::app::{App, AppMode, ConnectionStatus, DisplayOptions, SendMode, SessionForm, View};
|
||||
use crate::buffers::{ConsoleLine, HexLine, LineDirection};
|
||||
|
||||
const MAX_RENDER_LINES: usize = 400;
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App) {
|
||||
let root = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(34), Constraint::Min(20)])
|
||||
.split(frame.area());
|
||||
|
||||
render_sidebar(frame, app, root[0]);
|
||||
render_main(frame, app, root[1]);
|
||||
|
||||
if let AppMode::SessionForm(form) = app.mode() {
|
||||
render_session_form(frame, form);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let items: Vec<ListItem> = app
|
||||
.tabs()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, tab)| {
|
||||
let prefix = if index == app.active_index() {
|
||||
"> "
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
let transport = App::transport_summary(&tab.session_config);
|
||||
ListItem::new(format!(
|
||||
"{prefix}[{}] {}\n {}",
|
||||
tab.id,
|
||||
tab.status.badge(),
|
||||
transport
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sidebar = if items.is_empty() {
|
||||
Paragraph::new("No sessions.\nPress n to create one.")
|
||||
.block(Block::default().borders(Borders::ALL).title("Sessions"))
|
||||
.wrap(Wrap { trim: false })
|
||||
} else {
|
||||
Paragraph::new("").block(Block::default().borders(Borders::ALL).title("Sessions"))
|
||||
};
|
||||
|
||||
frame.render_widget(sidebar, area);
|
||||
if !items.is_empty() {
|
||||
frame.render_widget(
|
||||
List::new(items).block(Block::default().borders(Borders::ALL).title("Sessions")),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_main(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let sections = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(4),
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(7),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
if let Some(tab) = app.active_tab() {
|
||||
render_header(frame, app, sections[0]);
|
||||
render_receive(frame, app, sections[1]);
|
||||
render_send(frame, app, sections[2]);
|
||||
let footer = format!(
|
||||
"{}{}",
|
||||
app.help_text(),
|
||||
app.notice()
|
||||
.map(|notice| format!(" | {notice}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(footer)
|
||||
.block(Block::default().borders(Borders::ALL).title("Help"))
|
||||
.wrap(Wrap { trim: false }),
|
||||
sections[3],
|
||||
);
|
||||
|
||||
if let ConnectionStatus::Error(_) = &tab.status {
|
||||
}
|
||||
} else {
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"No active session.\n{}\n{}",
|
||||
app.help_text(),
|
||||
app.notice().unwrap_or("")
|
||||
))
|
||||
.block(Block::default().borders(Borders::ALL).title("pipeview-tui"))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let tab = app.active_tab().expect("active tab");
|
||||
let display = app.display();
|
||||
let status_line = match &tab.status {
|
||||
ConnectionStatus::Error(message) => {
|
||||
format!("Session {} [{}] {}", tab.id, tab.status.badge(), message)
|
||||
}
|
||||
_ => format!("Session {} [{}]", tab.id, tab.status.badge()),
|
||||
};
|
||||
let line2 = format!(
|
||||
"{} | View: {} | Send: {} | AutoReconnect: {} | AppendNewline: {}",
|
||||
App::transport_summary(&tab.session_config),
|
||||
match tab.view {
|
||||
View::Text => "Text",
|
||||
View::Hex => "Hex",
|
||||
},
|
||||
match tab.send_mode {
|
||||
SendMode::Text => "Text",
|
||||
SendMode::Hex => "Hex",
|
||||
},
|
||||
tab.auto_reconnect,
|
||||
tab.append_newline
|
||||
);
|
||||
let line3 = format!(
|
||||
"Display: ts={} dir={} pipe={} | Text lines={} Hex lines={}",
|
||||
display.show_timestamp,
|
||||
display.show_direction,
|
||||
display.show_pipeline,
|
||||
tab.console.len(),
|
||||
tab.hex.len()
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("{status_line}\n{line2}\n{line3}"))
|
||||
.block(Block::default().borders(Borders::ALL).title("Session"))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_receive(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let tab = app.active_tab().expect("active tab");
|
||||
let display = app.display();
|
||||
let title = match tab.view {
|
||||
View::Text => "Receive Text",
|
||||
View::Hex => "Receive Hex",
|
||||
};
|
||||
|
||||
let lines: Vec<String> = match tab.view {
|
||||
View::Text => tab
|
||||
.console
|
||||
.recent(MAX_RENDER_LINES)
|
||||
.into_iter()
|
||||
.map(|line| format_console_line(&line, display))
|
||||
.collect(),
|
||||
View::Hex => tab
|
||||
.hex
|
||||
.recent(MAX_RENDER_LINES)
|
||||
.into_iter()
|
||||
.map(|line| format_hex_line(&line, display))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let body = if lines.is_empty() {
|
||||
String::from("no data")
|
||||
} else {
|
||||
lines.join("\n")
|
||||
};
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(body)
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_send(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let tab = app.active_tab().expect("active tab");
|
||||
let editing = matches!(app.mode(), AppMode::SendInput);
|
||||
let title = if editing { "Send [editing]" } else { "Send" };
|
||||
let status = tab.send_status.as_deref().unwrap_or("idle");
|
||||
let hint = match tab.send_mode {
|
||||
SendMode::Text => "Press i to edit input, Enter to send while editing",
|
||||
SendMode::Hex => "Hex bytes, e.g. 48 65 6C 6C 6F",
|
||||
};
|
||||
let body = format!(
|
||||
"Mode: {} | Append newline: {}\nStatus: {}\nHint: {}\n\n{}",
|
||||
match tab.send_mode {
|
||||
SendMode::Text => "Text",
|
||||
SendMode::Hex => "Hex",
|
||||
},
|
||||
tab.append_newline,
|
||||
status,
|
||||
hint,
|
||||
if tab.send_input.is_empty() {
|
||||
String::from("<empty>")
|
||||
} else {
|
||||
tab.send_input.clone()
|
||||
}
|
||||
);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(body)
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_session_form(frame: &mut Frame, form: &SessionForm) {
|
||||
let rows = form.rows();
|
||||
let footer_lines = form.footer_lines();
|
||||
let height = (rows.len() + footer_lines.len() + 4) as u16;
|
||||
let area = centered_rect(82, height, frame.area());
|
||||
let inner = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(footer_lines.len() as u16),
|
||||
])
|
||||
.margin(1)
|
||||
.split(area);
|
||||
|
||||
let items: Vec<ListItem> = rows
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, row)| ListItem::new(row).style(focus_style(idx == form.focused_field)))
|
||||
.collect();
|
||||
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(
|
||||
Block::default().borders(Borders::ALL).title(form.title()),
|
||||
area,
|
||||
);
|
||||
frame.render_widget(List::new(items), inner[0]);
|
||||
frame.render_widget(Paragraph::new(footer_lines.join("\n")), inner[1]);
|
||||
}
|
||||
|
||||
fn format_console_line(line: &ConsoleLine, display: DisplayOptions) -> String {
|
||||
let mut prefix = Vec::new();
|
||||
if display.show_timestamp {
|
||||
prefix.push(format!("[{}]", format_elapsed(line.elapsed)));
|
||||
}
|
||||
if display.show_direction {
|
||||
prefix.push(format!(
|
||||
"[{}]",
|
||||
match line.direction {
|
||||
LineDirection::In => "IN",
|
||||
LineDirection::Out => "OUT",
|
||||
}
|
||||
));
|
||||
}
|
||||
if display.show_pipeline {
|
||||
prefix.push(format!("[{}]", line.pipeline));
|
||||
}
|
||||
if prefix.is_empty() {
|
||||
line.text.clone()
|
||||
} else {
|
||||
format!("{} {}", prefix.join(" "), line.text)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_hex_line(line: &HexLine, display: DisplayOptions) -> String {
|
||||
let mut prefix = Vec::new();
|
||||
if display.show_timestamp {
|
||||
prefix.push(format!("[{}]", format_elapsed(line.elapsed)));
|
||||
}
|
||||
if display.show_direction {
|
||||
prefix.push(format!(
|
||||
"[{}]",
|
||||
match line.direction {
|
||||
LineDirection::In => "IN",
|
||||
LineDirection::Out => "OUT",
|
||||
}
|
||||
));
|
||||
}
|
||||
if display.show_pipeline {
|
||||
prefix.push(format!("[{}]", line.pipeline));
|
||||
}
|
||||
let base = format!("{} |{}|", line.hex, line.ascii);
|
||||
if prefix.is_empty() {
|
||||
base
|
||||
} else {
|
||||
format!("{} {}", prefix.join(" "), base)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_elapsed(elapsed: std::time::Duration) -> String {
|
||||
let secs = elapsed.as_secs_f64();
|
||||
if secs < 60.0 {
|
||||
format!("{secs:05.2}")
|
||||
} else {
|
||||
format!("{:02}:{:02}", (secs / 60.0) as u64, (secs % 60.0) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_style(focused: bool) -> Style {
|
||||
if focused {
|
||||
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(height.min(area.height)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(width.min(area.width)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(vertical[1])[1]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
mod app;
|
||||
mod app_state;
|
||||
mod buffers;
|
||||
mod panels;
|
||||
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())))),
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
use crate::app::DisplayOptions;
|
||||
use crate::buffers::{LineDirection, TextBuffer};
|
||||
use egui::{ScrollArea, Ui};
|
||||
|
||||
pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) {
|
||||
ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| {
|
||||
for line in buf.iter() {
|
||||
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(" "))
|
||||
};
|
||||
ui.monospace(format!("{prefix}{}", line.text));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
use crate::app::DisplayOptions;
|
||||
use crate::buffers::{HexBuffer, LineDirection};
|
||||
use egui::{ScrollArea, Ui};
|
||||
|
||||
pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) {
|
||||
let mut lines = buf.iter().peekable();
|
||||
if lines.peek().is_none() {
|
||||
ui.label("no hex data");
|
||||
return;
|
||||
}
|
||||
ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| {
|
||||
for line in lines {
|
||||
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(" "))
|
||||
};
|
||||
ui.monospace(format!("{prefix}{} |{}|", line.hex, line.ascii));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
fn main() {}
|
||||
16
examples/drone_plot.lua
Normal file
16
examples/drone_plot.lua
Normal 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
33
examples/drone_text.lua
Normal 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,
|
||||
}
|
||||
43
tests/lua_line_framer.lua
Normal file
43
tests/lua_line_framer.lua
Normal file
@@ -0,0 +1,43 @@
|
||||
local buffer = ""
|
||||
|
||||
return {
|
||||
feed = function(bytes)
|
||||
buffer = buffer .. bytes
|
||||
local frames = {}
|
||||
|
||||
while true do
|
||||
local i = buffer:find("\n", 1, true)
|
||||
if not i then
|
||||
break
|
||||
end
|
||||
|
||||
local frame = buffer:sub(1, i - 1)
|
||||
if frame:sub(-1) == "\r" then
|
||||
frame = frame:sub(1, -2)
|
||||
end
|
||||
|
||||
frames[#frames + 1] = frame
|
||||
buffer = buffer:sub(i + 1)
|
||||
end
|
||||
|
||||
return frames
|
||||
end,
|
||||
|
||||
flush = function()
|
||||
if #buffer == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local frame = buffer
|
||||
buffer = ""
|
||||
return frame
|
||||
end,
|
||||
|
||||
reset = function()
|
||||
buffer = ""
|
||||
end,
|
||||
|
||||
pending_len = function()
|
||||
return #buffer
|
||||
end,
|
||||
}
|
||||
12
tests/lua_text_decoder.lua
Normal file
12
tests/lua_text_decoder.lua
Normal file
@@ -0,0 +1,12 @@
|
||||
return {
|
||||
decode = function(frame)
|
||||
if #frame == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
return {
|
||||
kind = "text",
|
||||
data = frame,
|
||||
}
|
||||
end,
|
||||
}
|
||||
102
tools/test_drone.py
Normal file
102
tools/test_drone.py
Normal 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()
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TCP server that sends plot waveform frames for the current xserial GUI."""
|
||||
"""TCP server that sends plot waveform frames or text lines for pipeview GUI."""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
@@ -116,9 +116,9 @@ 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=8091)
|
||||
parser.add_argument("--port", type=int, default=8080)
|
||||
parser.add_argument("--channels", type=int, default=1)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
@@ -148,9 +148,9 @@ def main() -> None:
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wire-format",
|
||||
choices=("mixed", "raw"),
|
||||
choices=("mixed", "raw", "text"),
|
||||
default="mixed",
|
||||
help="mixed sends text+plot on one connection; raw sends plot only",
|
||||
help="mixed sends text+plot; raw sends plot only; text sends text only",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -193,17 +193,36 @@ def main() -> None:
|
||||
values.append(args.amp * math.sin(2 * math.pi * args.freq * t + phase))
|
||||
return values
|
||||
|
||||
def build_text_line(started_at: float, now: float, sample_index: int) -> str:
|
||||
values = sample_values(sample_index)
|
||||
joined = ", ".join(
|
||||
f"ch{index + 1}={value:8.3f}" for index, value in enumerate(values)
|
||||
)
|
||||
return (
|
||||
f"测试 time={now - started_at:7.3f}s "
|
||||
f"sample={sample_index:7d} {joined}\n"
|
||||
)
|
||||
|
||||
print(f"Listening {args.host}:{args.port} - Ctrl+C to stop")
|
||||
print("GUI:")
|
||||
print(f" transport = TCP {args.host}:{args.port}")
|
||||
if args.wire_format == "mixed":
|
||||
print(" framer = MixedTextPlot")
|
||||
print(" decoder = MixedTextPlot\n")
|
||||
else:
|
||||
elif args.wire_format == "raw":
|
||||
print(f" framer = Fixed({frame_bytes})")
|
||||
print(
|
||||
f" decoder = Plot(f32, little-endian, {args.channels} channel, {args.format})\n"
|
||||
)
|
||||
else:
|
||||
print(" framer = Line")
|
||||
print(" decoder = Text")
|
||||
print(" lua test = Lua framer tests/lua_line_framer.lua")
|
||||
print(" Lua decoder tests/lua_text_decoder.lua\n")
|
||||
if args.wire_format == "text":
|
||||
print(f"sending text only at {args.text_interval:.3f}s intervals")
|
||||
print(f"sample clock: {args.rate:.1f} samples/sec\n")
|
||||
else:
|
||||
print(f"sending {samples_per_channel} samples/frame at {args.rate:.1f} samples/sec")
|
||||
print(f"effective frame rate: {frame_rate:.2f} fps\n")
|
||||
|
||||
@@ -230,6 +249,25 @@ def main() -> None:
|
||||
next_text_at = started_at
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
if args.wire_format == "text":
|
||||
now = time.perf_counter()
|
||||
wait = next_text_at - now
|
||||
if wait > 0:
|
||||
time.sleep(min(wait, 0.1))
|
||||
continue
|
||||
|
||||
sample_index = int((now - started_at) * args.rate)
|
||||
conn.sendall(
|
||||
build_text_line(
|
||||
started_at,
|
||||
now,
|
||||
sample_index,
|
||||
).encode("utf-8")
|
||||
)
|
||||
text_count += 1
|
||||
next_text_at += args.text_interval
|
||||
continue
|
||||
|
||||
plot_payload = build_frame(
|
||||
sample_index=sample_index,
|
||||
channels=args.channels,
|
||||
@@ -252,16 +290,13 @@ def main() -> None:
|
||||
|
||||
now = time.perf_counter()
|
||||
if args.wire_format == "mixed" and now >= next_text_at:
|
||||
values = sample_values(sample_index)
|
||||
joined = ", ".join(
|
||||
f"ch{index + 1}={value:8.3f}"
|
||||
for index, value in enumerate(values)
|
||||
conn.sendall(
|
||||
build_text_line(
|
||||
started_at,
|
||||
now,
|
||||
sample_index,
|
||||
).encode("utf-8")
|
||||
)
|
||||
line = (
|
||||
f"time={now - started_at:7.3f}s "
|
||||
f"sample={sample_index:7d} {joined}\n"
|
||||
)
|
||||
conn.sendall(line.encode("utf-8"))
|
||||
text_count += 1
|
||||
next_text_at += args.text_interval
|
||||
|
||||
@@ -275,6 +310,9 @@ def main() -> None:
|
||||
except OSError as err:
|
||||
if not is_disconnect_error(err):
|
||||
raise
|
||||
if args.wire_format == "text":
|
||||
print(f"[conn -] {addr} ({text_count} text lines)")
|
||||
else:
|
||||
print(
|
||||
f"[conn -] {addr} "
|
||||
f"({frame_count} plot frames, {text_count} text lines)"
|
||||
|
||||
540
tools/test_plot_serial.c
Normal file
540
tools/test_plot_serial.c
Normal file
@@ -0,0 +1,540 @@
|
||||
/*
|
||||
* test_plot_serial.c — 持续向串口发送 MixedTextPlot 正弦波 + 文本流
|
||||
*
|
||||
* 编译 (Linux):
|
||||
* gcc -std=c11 -Wall -Wextra -O2 -o test_plot_serial tools/test_plot_serial.c -lm
|
||||
*
|
||||
* 用法:
|
||||
* ./test_plot_serial /dev/ttyUSB0
|
||||
* ./test_plot_serial /dev/ttyUSB0 --baud 115200 --channels 2 --format xy --rate 1024 --freq 2.0 --amp 150
|
||||
* ./test_plot_serial --help
|
||||
*
|
||||
* 依赖: tools/xs_mixed_plot.h (同目录, 定义 COBS 和 MixedTextPlot 帧格式)
|
||||
*
|
||||
* 协议说明:
|
||||
* Plot 帧: 0x1E 'P' + COBS(packet) + 0x00
|
||||
* 文本帧: UTF-8 文本行 + '\n' (直接写入, 由 MixedTextPlot framer 按行分割)
|
||||
*
|
||||
* 串口参数: 8N1, 无流控, 默认 115200 baud
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "xs_mixed_plot.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <getopt.h>
|
||||
#include <math.h>
|
||||
#include <signal.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <termios.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
|
||||
/* ── 配置默认值 ───────────────────────────────────────────────────── */
|
||||
|
||||
#define DEFAULT_BAUDRATE 115200
|
||||
#define DEFAULT_RATE 1024.0 /* samples/sec per channel */
|
||||
#define DEFAULT_FREQ 1.0 /* sine Hz */
|
||||
#define DEFAULT_AMP 100.0
|
||||
#define DEFAULT_TEXT_INTERVAL 1.0 /* seconds between text lines */
|
||||
|
||||
/* ── 全局状态 (用于信号处理) ──────────────────────────────────────── */
|
||||
|
||||
static volatile sig_atomic_t g_running = 1;
|
||||
|
||||
static void sigint_handler(int sig) {
|
||||
(void)sig;
|
||||
g_running = 0;
|
||||
}
|
||||
|
||||
/* ── 缓冲大小 ─────────────────────────────────────────────────────── */
|
||||
|
||||
#define MAX_CHANNELS 8
|
||||
#define MAX_SPC 4096 /* samples_per_channel */
|
||||
#define MAX_VALUES (MAX_CHANNELS * MAX_SPC) /* float values per frame */
|
||||
#define MAX_PAYLOAD (MAX_VALUES * 4)
|
||||
#define MAX_PACKET (13 + MAX_PAYLOAD)
|
||||
#define MAX_FRAME (2 + (MAX_PACKET + MAX_PACKET / 254 + 1) + 1)
|
||||
#define TEXT_BUF_SIZE 512
|
||||
|
||||
/* ── 运行时参数 ───────────────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
const char *port;
|
||||
int baudrate;
|
||||
uint8_t channels;
|
||||
uint16_t samples_per_channel;
|
||||
uint8_t plot_format; /* XS_PLOT_FORMAT_INTERLEAVED / XY */
|
||||
double rate; /* samples/sec */
|
||||
double freq; /* sine Hz */
|
||||
double amp;
|
||||
double text_interval; /* seconds */
|
||||
} args_t;
|
||||
|
||||
/* ── 串口操作 ─────────────────────────────────────────────────────── */
|
||||
|
||||
static int serial_open(const char *path, int baudrate) {
|
||||
int fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK);
|
||||
if (fd < 0) {
|
||||
perror("open");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 恢复阻塞模式, 但配合超时使用 select */
|
||||
int flags = fcntl(fd, F_GETFL, 0);
|
||||
if (flags < 0) { perror("fcntl"); close(fd); return -1; }
|
||||
flags &= ~O_NONBLOCK;
|
||||
if (fcntl(fd, F_SETFL, flags) < 0) { perror("fcntl"); close(fd); return -1; }
|
||||
|
||||
struct termios tty;
|
||||
memset(&tty, 0, sizeof(tty));
|
||||
if (tcgetattr(fd, &tty) < 0) { perror("tcgetattr"); close(fd); return -1; }
|
||||
|
||||
/* 输入: 忽略 BREAK, CR→NL 不转换, 奇偶校验不检查 */
|
||||
tty.c_iflag = IGNBRK;
|
||||
|
||||
/* 输出: 原始模式, 不做任何转换 */
|
||||
tty.c_oflag = 0;
|
||||
|
||||
/* 控制: 8 位, 无奇偶校验, 启用接收器 */
|
||||
tty.c_cflag = CS8 | CREAD | CLOCAL;
|
||||
|
||||
/* 本地: 关闭标准行处理/回显/信号字符 */
|
||||
tty.c_lflag = 0;
|
||||
|
||||
/* 特殊字符: 无超时 (VMIN=1, VTIME=0 → 阻塞读直到至少1字节) */
|
||||
tty.c_cc[VMIN] = 1;
|
||||
tty.c_cc[VTIME] = 0;
|
||||
|
||||
/* 设置波特率 */
|
||||
speed_t speed = B115200;
|
||||
switch (baudrate) {
|
||||
case 9600: speed = B9600; break;
|
||||
case 19200: speed = B19200; break;
|
||||
case 38400: speed = B38400; break;
|
||||
case 57600: speed = B57600; break;
|
||||
case 115200: speed = B115200; break;
|
||||
case 230400: speed = B230400; break;
|
||||
case 460800: speed = B460800; break;
|
||||
case 921600: speed = B921600; break;
|
||||
default:
|
||||
fprintf(stderr, "不支持的波特率: %d (支持: 9600-921600)\n", baudrate);
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
cfsetispeed(&tty, speed);
|
||||
cfsetospeed(&tty, speed);
|
||||
|
||||
#ifdef CRTSCTS
|
||||
tty.c_cflag &= ~CRTSCTS;
|
||||
#endif
|
||||
/* 关闭软件流控 */
|
||||
tty.c_iflag &= ~(IXON | IXOFF | IXANY);
|
||||
|
||||
if (tcsetattr(fd, TCSANOW, &tty) < 0) {
|
||||
perror("tcsetattr");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 排空缓冲区 */
|
||||
tcflush(fd, TCIOFLUSH);
|
||||
|
||||
return fd;
|
||||
}
|
||||
|
||||
/* ── 时间工具 ─────────────────────────────────────────────────────── */
|
||||
|
||||
static double now_sec(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
|
||||
}
|
||||
|
||||
static void sleep_sec(double seconds) {
|
||||
if (seconds <= 0.0) return;
|
||||
struct timespec ts;
|
||||
ts.tv_sec = (time_t)seconds;
|
||||
ts.tv_nsec = (long)((seconds - (time_t)seconds) * 1e9);
|
||||
clock_nanosleep(CLOCK_MONOTONIC, 0, &ts, NULL);
|
||||
}
|
||||
|
||||
/* ── 信号生成 ─────────────────────────────────────────────────────── */
|
||||
|
||||
static void generate_samples(
|
||||
float *samples,
|
||||
uint32_t sample_index,
|
||||
uint8_t channels,
|
||||
uint16_t samples_per_channel,
|
||||
uint8_t plot_format,
|
||||
double rate,
|
||||
double freq,
|
||||
double amp)
|
||||
{
|
||||
if (plot_format == XS_PLOT_FORMAT_XY) {
|
||||
/* XY 模式: 每对 (cos, sin) 画一个圆 */
|
||||
for (uint16_t i = 0; i < samples_per_channel; ++i) {
|
||||
double t = (double)(sample_index + i) / rate;
|
||||
samples[2 * i] = (float)(amp * cos(2.0 * M_PI * freq * t));
|
||||
samples[2 * i + 1] = (float)(amp * sin(2.0 * M_PI * freq * t));
|
||||
}
|
||||
} else {
|
||||
/* interleaved 模式: 每个通道相位偏移 */
|
||||
for (uint16_t i = 0; i < samples_per_channel; ++i) {
|
||||
double t = (double)(sample_index + i) / rate;
|
||||
for (uint8_t ch = 0; ch < channels; ++ch) {
|
||||
double phase = 2.0 * M_PI * (double)ch / (double)channels;
|
||||
samples[i * channels + ch] = (float)(amp * sin(2.0 * M_PI * freq * t + phase));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 用法 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static void print_usage(const char *prog) {
|
||||
printf("用法: %s <串口> [选项]\n", prog);
|
||||
printf("\n选项:\n");
|
||||
printf(" --baud <n> 波特率 (默认 %d)\n", DEFAULT_BAUDRATE);
|
||||
printf(" --channels <n> 通道数 (默认 1, --format xy 时强制 2)\n");
|
||||
printf(" --format <fmt> 格式: interleaved | xy (默认 interleaved)\n");
|
||||
printf(" --rate <f> 采样率, samples/sec (默认 %.0f)\n", DEFAULT_RATE);
|
||||
printf(" --freq <f> 正弦频率 Hz (默认 %.1f)\n", DEFAULT_FREQ);
|
||||
printf(" --amp <f> 振幅 (默认 %.0f)\n", DEFAULT_AMP);
|
||||
printf(" --text-interval <f> 文本输出间隔秒 (默认 %.1f)\n", DEFAULT_TEXT_INTERVAL);
|
||||
printf(" --spc <n> 每帧每通道采样数 (默认 1)\n");
|
||||
printf(" -h, --help 显示帮助\n");
|
||||
printf("\n支持的波特率: 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600\n");
|
||||
printf("\n示例:\n");
|
||||
printf(" %s /dev/ttyUSB0\n", prog);
|
||||
printf(" %s /dev/ttyUSB0 --baud 921600 --channels 2 --rate 2048 --freq 5\n", prog);
|
||||
printf(" %s /dev/ttyUSB0 --format xy --amp 200 --text-interval 2.0\n", prog);
|
||||
printf(" %s /dev/ttyUSB0 --channels 4 --spc 128 --rate 1024\n", prog);
|
||||
}
|
||||
|
||||
/* ── 参数解析 ─────────────────────────────────────────────────────── */
|
||||
|
||||
static int parse_args(int argc, char **argv, args_t *args) {
|
||||
memset(args, 0, sizeof(*args));
|
||||
args->baudrate = DEFAULT_BAUDRATE;
|
||||
args->channels = 1;
|
||||
args->samples_per_channel = 1;
|
||||
args->plot_format = XS_PLOT_FORMAT_INTERLEAVED;
|
||||
args->rate = DEFAULT_RATE;
|
||||
args->freq = DEFAULT_FREQ;
|
||||
args->amp = DEFAULT_AMP;
|
||||
args->text_interval = DEFAULT_TEXT_INTERVAL;
|
||||
|
||||
enum { OPT_BAUD = 256, OPT_CHANNELS, OPT_FORMAT, OPT_RATE, OPT_FREQ,
|
||||
OPT_AMP, OPT_TEXT_INTERVAL, OPT_SPC };
|
||||
|
||||
static struct option long_opts[] = {
|
||||
{"baud", required_argument, 0, OPT_BAUD},
|
||||
{"channels", required_argument, 0, OPT_CHANNELS},
|
||||
{"format", required_argument, 0, OPT_FORMAT},
|
||||
{"rate", required_argument, 0, OPT_RATE},
|
||||
{"freq", required_argument, 0, OPT_FREQ},
|
||||
{"amp", required_argument, 0, OPT_AMP},
|
||||
{"text-interval", required_argument, 0, OPT_TEXT_INTERVAL},
|
||||
{"spc", required_argument, 0, OPT_SPC},
|
||||
{"help", no_argument, 0, 'h'},
|
||||
{0, 0, 0, 0}
|
||||
};
|
||||
|
||||
int opt;
|
||||
while ((opt = getopt_long(argc, argv, "h", long_opts, NULL)) != -1) {
|
||||
switch (opt) {
|
||||
case OPT_BAUD:
|
||||
args->baudrate = atoi(optarg);
|
||||
break;
|
||||
case OPT_CHANNELS: {
|
||||
int v = atoi(optarg);
|
||||
if (v < 1 || v > MAX_CHANNELS) {
|
||||
fprintf(stderr, "通道数必须在 1-%d 之间\n", MAX_CHANNELS);
|
||||
return -1;
|
||||
}
|
||||
args->channels = (uint8_t)v;
|
||||
break;
|
||||
}
|
||||
case OPT_FORMAT:
|
||||
if (strcmp(optarg, "xy") == 0) {
|
||||
args->plot_format = XS_PLOT_FORMAT_XY;
|
||||
} else if (strcmp(optarg, "interleaved") == 0) {
|
||||
args->plot_format = XS_PLOT_FORMAT_INTERLEAVED;
|
||||
} else {
|
||||
fprintf(stderr, "未知格式: %s (支持: interleaved, xy)\n", optarg);
|
||||
return -1;
|
||||
}
|
||||
break;
|
||||
case OPT_RATE:
|
||||
args->rate = atof(optarg);
|
||||
break;
|
||||
case OPT_FREQ:
|
||||
args->freq = atof(optarg);
|
||||
break;
|
||||
case OPT_AMP:
|
||||
args->amp = atof(optarg);
|
||||
break;
|
||||
case OPT_TEXT_INTERVAL:
|
||||
args->text_interval = atof(optarg);
|
||||
break;
|
||||
case OPT_SPC: {
|
||||
int v = atoi(optarg);
|
||||
if (v < 1 || v > MAX_SPC) {
|
||||
fprintf(stderr, "spc 必须在 1-%d 之间\n", MAX_SPC);
|
||||
return -1;
|
||||
}
|
||||
args->samples_per_channel = (uint16_t)v;
|
||||
break;
|
||||
}
|
||||
case 'h':
|
||||
print_usage(argv[0]);
|
||||
exit(0);
|
||||
default:
|
||||
print_usage(argv[0]);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (optind >= argc) {
|
||||
fprintf(stderr, "错误: 缺少串口路径\n");
|
||||
print_usage(argv[0]);
|
||||
return -1;
|
||||
}
|
||||
args->port = argv[optind];
|
||||
|
||||
/* 校验 */
|
||||
if (args->plot_format == XS_PLOT_FORMAT_XY) {
|
||||
if (args->channels != 2) {
|
||||
fprintf(stderr, "错误: --format xy 要求 --channels 2\n");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if (args->channels == 0) {
|
||||
fprintf(stderr, "错误: --channels 必须 >= 1\n");
|
||||
return -1;
|
||||
}
|
||||
if (args->rate <= 0.0) {
|
||||
fprintf(stderr, "错误: --rate 必须为正数\n");
|
||||
return -1;
|
||||
}
|
||||
if (args->text_interval <= 0.0) {
|
||||
fprintf(stderr, "错误: --text-interval 必须为正数\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── 发送 plot 帧 ─────────────────────────────────────────────────── */
|
||||
|
||||
static int send_plot_frame(
|
||||
int fd,
|
||||
const float *samples,
|
||||
uint8_t channels,
|
||||
uint16_t samples_per_channel,
|
||||
uint8_t plot_format,
|
||||
uint8_t *packet_buf,
|
||||
size_t packet_cap,
|
||||
uint8_t *frame_buf,
|
||||
size_t frame_cap)
|
||||
{
|
||||
size_t frame_len = 0;
|
||||
xs_status_t rc = xs_mixed_build_plot_frame_f32(
|
||||
samples, channels, samples_per_channel, plot_format,
|
||||
packet_buf, packet_cap,
|
||||
frame_buf, frame_cap,
|
||||
&frame_len);
|
||||
if (rc != XS_OK) {
|
||||
fprintf(stderr, "构建 plot 帧失败: %d\n", rc);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t written = 0;
|
||||
while (written < frame_len) {
|
||||
ssize_t n = write(fd, frame_buf + written, frame_len - written);
|
||||
if (n < 0) {
|
||||
perror("write");
|
||||
return -1;
|
||||
}
|
||||
written += (size_t)n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── 发送文本行 ───────────────────────────────────────────────────── */
|
||||
|
||||
static int send_text_line(
|
||||
int fd,
|
||||
uint32_t sample_index,
|
||||
const float *current_samples,
|
||||
uint8_t channels,
|
||||
uint16_t samples_per_channel,
|
||||
const char *format_name)
|
||||
{
|
||||
char buf[TEXT_BUF_SIZE];
|
||||
int pos = 0;
|
||||
|
||||
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||
"sample=%-7u", sample_index);
|
||||
|
||||
if (channels > 1) {
|
||||
for (uint8_t ch = 0; ch < channels; ++ch) {
|
||||
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||
" ch%d=%8.3f", ch + 1, (double)current_samples[ch]);
|
||||
}
|
||||
} else {
|
||||
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||
" value=%8.3f", (double)current_samples[0]);
|
||||
}
|
||||
|
||||
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||
" [fmt=%s, ch=%u, spc=%u]\n",
|
||||
format_name, channels, samples_per_channel);
|
||||
|
||||
/* 确保不超过缓冲 */
|
||||
if (pos >= (int)sizeof(buf)) pos = (int)sizeof(buf) - 1;
|
||||
|
||||
size_t len = (size_t)pos;
|
||||
size_t written = 0;
|
||||
while (written < len) {
|
||||
ssize_t n = write(fd, buf + written, len - written);
|
||||
if (n < 0) {
|
||||
perror("write");
|
||||
return -1;
|
||||
}
|
||||
written += (size_t)n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── 主函数 ───────────────────────────────────────────────────────── */
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
args_t args;
|
||||
if (parse_args(argc, argv, &args) < 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* 打印配置 */
|
||||
const char *format_name = (args.plot_format == XS_PLOT_FORMAT_XY) ? "xy" : "interleaved";
|
||||
double frame_interval = (double)args.samples_per_channel / args.rate;
|
||||
double frame_rate = args.rate / (double)args.samples_per_channel;
|
||||
|
||||
printf("═══ 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);
|
||||
printf("采样率: %.0f samples/sec spc: %u\n", args.rate, args.samples_per_channel);
|
||||
printf("帧间隔: %.3f ms (%.2f fps)\n", frame_interval * 1000.0, frame_rate);
|
||||
printf("文本间隔: %.1f s\n", args.text_interval);
|
||||
printf("═══════════════════════════════════════\n");
|
||||
|
||||
/* 分配缓冲 */
|
||||
uint32_t value_count = (uint32_t)args.channels * (uint32_t)args.samples_per_channel;
|
||||
uint32_t payload_len = value_count * 4;
|
||||
size_t packet_cap = (size_t)XS_PLOT_HEADER_SIZE + (size_t)payload_len;
|
||||
size_t frame_cap = 2u + xs_cobs_max_encoded_size(packet_cap) + 1u;
|
||||
|
||||
uint8_t *packet_buf = malloc(packet_cap);
|
||||
uint8_t *frame_buf = malloc(frame_cap);
|
||||
float *samples = malloc((size_t)value_count * sizeof(float));
|
||||
|
||||
if (!packet_buf || !frame_buf || !samples) {
|
||||
fprintf(stderr, "内存分配失败\n");
|
||||
free(packet_buf); free(frame_buf); free(samples);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* 打开串口 */
|
||||
int fd = serial_open(args.port, args.baudrate);
|
||||
if (fd < 0) {
|
||||
fprintf(stderr, "无法打开串口 %s\n", args.port);
|
||||
free(packet_buf); free(frame_buf); free(samples);
|
||||
return 1;
|
||||
}
|
||||
printf("已打开 %s\n\n", args.port);
|
||||
|
||||
/* 设置信号处理 */
|
||||
struct sigaction sa;
|
||||
memset(&sa, 0, sizeof(sa));
|
||||
sa.sa_handler = sigint_handler;
|
||||
sigaction(SIGINT, &sa, NULL);
|
||||
sigaction(SIGTERM, &sa, NULL);
|
||||
|
||||
/* 主循环 */
|
||||
uint32_t sample_index = 0;
|
||||
uint64_t frame_count = 0;
|
||||
uint64_t text_count = 0;
|
||||
double started_at = now_sec();
|
||||
double next_text_at = started_at + args.text_interval;
|
||||
|
||||
printf("开始发送 (Ctrl+C 停止) ...\n");
|
||||
|
||||
while (g_running) {
|
||||
/* 生成采样数据 */
|
||||
generate_samples(samples, sample_index,
|
||||
args.channels, args.samples_per_channel,
|
||||
args.plot_format, args.rate, args.freq, args.amp);
|
||||
|
||||
/* 发送 plot 帧 */
|
||||
if (send_plot_frame(fd, samples,
|
||||
args.channels, args.samples_per_channel,
|
||||
args.plot_format,
|
||||
packet_buf, packet_cap,
|
||||
frame_buf, frame_cap) < 0) {
|
||||
fprintf(stderr, "发送失败, 退出\n");
|
||||
break;
|
||||
}
|
||||
frame_count++;
|
||||
|
||||
/* 检查是否需要发送文本行 */
|
||||
double now = now_sec();
|
||||
if (now >= next_text_at) {
|
||||
if (send_text_line(fd, sample_index, samples,
|
||||
args.channels, args.samples_per_channel,
|
||||
format_name) < 0) {
|
||||
fprintf(stderr, "发送文本失败, 退出\n");
|
||||
break;
|
||||
}
|
||||
text_count++;
|
||||
next_text_at += args.text_interval;
|
||||
}
|
||||
|
||||
sample_index += args.samples_per_channel;
|
||||
|
||||
/* 帧间隔时序控制 */
|
||||
double elapsed = now_sec() - started_at;
|
||||
double target = (double)frame_count * frame_interval;
|
||||
double wait = target - elapsed;
|
||||
if (wait > 0.0) {
|
||||
sleep_sec(wait);
|
||||
}
|
||||
}
|
||||
|
||||
double total_time = now_sec() - started_at;
|
||||
printf("\n");
|
||||
printf("═══ 已停止 ═══\n");
|
||||
printf("运行时间: %.1f 秒\n", total_time);
|
||||
printf("Plot 帧: %lu (%.1f fps)\n",
|
||||
(unsigned long)frame_count,
|
||||
total_time > 0.0 ? (double)frame_count / total_time : 0.0);
|
||||
printf("文本行: %lu\n", (unsigned long)text_count);
|
||||
|
||||
/* 清理 */
|
||||
tcflush(fd, TCIOFLUSH);
|
||||
close(fd);
|
||||
free(packet_buf);
|
||||
free(frame_buf);
|
||||
free(samples);
|
||||
|
||||
return 0;
|
||||
}
|
||||
270
tools/test_text_serial.c
Normal file
270
tools/test_text_serial.c
Normal file
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
* test_text_serial.c — 持续向串口发送纯文本正弦波数据流
|
||||
*
|
||||
* 编译 (Linux):
|
||||
* gcc -std=c11 -Wall -Wextra -O2 -o test_text_serial tools/test_text_serial.c -lm
|
||||
*
|
||||
* 用法:
|
||||
* ./test_text_serial /dev/ttyUSB0
|
||||
* ./test_text_serial /dev/ttyUSB0 --baud 115200 --channels 2 --rate 100 --freq 2.0 --amp 150
|
||||
* ./test_text_serial --help
|
||||
*
|
||||
* 输出格式 (每行):
|
||||
* sample=123 ch1=100.000 ch2=0.000
|
||||
*
|
||||
* 串口参数: 8N1, 无流控, 默认 115200 baud
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <getopt.h>
|
||||
#include <math.h>
|
||||
#include <signal.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <termios.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
|
||||
#define DEFAULT_BAUDRATE 115200
|
||||
#define DEFAULT_RATE 100.0 /* lines/sec */
|
||||
#define DEFAULT_FREQ 1.0
|
||||
#define DEFAULT_AMP 100.0
|
||||
#define MAX_CHANNELS 8
|
||||
#define LINE_BUF_SIZE 512
|
||||
|
||||
static volatile sig_atomic_t g_running = 1;
|
||||
|
||||
static void sigint_handler(int sig) {
|
||||
(void)sig;
|
||||
g_running = 0;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const char *port;
|
||||
int baudrate;
|
||||
uint8_t channels;
|
||||
double rate;
|
||||
double freq;
|
||||
double amp;
|
||||
} args_t;
|
||||
|
||||
static int serial_open(const char *path, int baudrate) {
|
||||
int fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK);
|
||||
if (fd < 0) { perror("open"); return -1; }
|
||||
|
||||
int flags = fcntl(fd, F_GETFL, 0);
|
||||
if (flags < 0) { perror("fcntl"); close(fd); return -1; }
|
||||
flags &= ~O_NONBLOCK;
|
||||
if (fcntl(fd, F_SETFL, flags) < 0) { perror("fcntl"); close(fd); return -1; }
|
||||
|
||||
struct termios tty;
|
||||
memset(&tty, 0, sizeof(tty));
|
||||
if (tcgetattr(fd, &tty) < 0) { perror("tcgetattr"); close(fd); return -1; }
|
||||
|
||||
tty.c_iflag = IGNBRK;
|
||||
tty.c_oflag = 0;
|
||||
tty.c_cflag = CS8 | CREAD | CLOCAL;
|
||||
tty.c_lflag = 0;
|
||||
tty.c_cc[VMIN] = 1;
|
||||
tty.c_cc[VTIME] = 0;
|
||||
|
||||
speed_t speed = B115200;
|
||||
switch (baudrate) {
|
||||
case 9600: speed = B9600; break;
|
||||
case 19200: speed = B19200; break;
|
||||
case 38400: speed = B38400; break;
|
||||
case 57600: speed = B57600; break;
|
||||
case 115200: speed = B115200; break;
|
||||
case 230400: speed = B230400; break;
|
||||
case 460800: speed = B460800; break;
|
||||
case 921600: speed = B921600; break;
|
||||
default:
|
||||
fprintf(stderr, "不支持的波特率: %d\n", baudrate);
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
cfsetispeed(&tty, speed);
|
||||
cfsetospeed(&tty, speed);
|
||||
|
||||
#ifdef CRTSCTS
|
||||
tty.c_cflag &= ~CRTSCTS;
|
||||
#endif
|
||||
tty.c_iflag &= ~(IXON | IXOFF | IXANY);
|
||||
|
||||
if (tcsetattr(fd, TCSANOW, &tty) < 0) {
|
||||
perror("tcsetattr");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
tcflush(fd, TCIOFLUSH);
|
||||
return fd;
|
||||
}
|
||||
|
||||
static double now_sec(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
|
||||
}
|
||||
|
||||
static void sleep_sec(double seconds) {
|
||||
if (seconds <= 0.0) return;
|
||||
struct timespec ts;
|
||||
ts.tv_sec = (time_t)seconds;
|
||||
ts.tv_nsec = (long)((seconds - (time_t)seconds) * 1e9);
|
||||
clock_nanosleep(CLOCK_MONOTONIC, 0, &ts, NULL);
|
||||
}
|
||||
|
||||
static int format_line(char *buf, size_t cap, uint32_t sample, const float *values, uint8_t channels) {
|
||||
int pos = snprintf(buf, cap, "sample=%-7u", sample);
|
||||
for (uint8_t ch = 0; ch < channels; ++ch) {
|
||||
pos += snprintf(buf + pos, cap - (size_t)pos, " ch%d=%8.3f", ch + 1, (double)values[ch]);
|
||||
}
|
||||
pos += snprintf(buf + pos, cap - (size_t)pos, "\n");
|
||||
return pos >= (int)cap ? (int)cap - 1 : pos;
|
||||
}
|
||||
|
||||
static void print_usage(const char *prog) {
|
||||
printf("用法: %s <串口> [选项]\n", prog);
|
||||
printf("\n选项:\n");
|
||||
printf(" --baud <n> 波特率 (默认 %d)\n", DEFAULT_BAUDRATE);
|
||||
printf(" --channels <n> 通道数 (默认 1)\n");
|
||||
printf(" --rate <f> 行/秒 输出速率 (默认 %.0f)\n", DEFAULT_RATE);
|
||||
printf(" --freq <f> 正弦频率 Hz (默认 %.1f)\n", DEFAULT_FREQ);
|
||||
printf(" --amp <f> 振幅 (默认 %.0f)\n", DEFAULT_AMP);
|
||||
printf(" -h, --help 显示帮助\n");
|
||||
printf("\n示例:\n");
|
||||
printf(" %s /dev/ttyUSB0\n", prog);
|
||||
printf(" %s /dev/ttyUSB0 --channels 2 --rate 50 --freq 3\n", prog);
|
||||
printf(" %s /dev/ttyUSB0 --baud 921600 --rate 1000\n", prog);
|
||||
}
|
||||
|
||||
static int parse_args(int argc, char **argv, args_t *args) {
|
||||
memset(args, 0, sizeof(*args));
|
||||
args->baudrate = DEFAULT_BAUDRATE;
|
||||
args->channels = 1;
|
||||
args->rate = DEFAULT_RATE;
|
||||
args->freq = DEFAULT_FREQ;
|
||||
args->amp = DEFAULT_AMP;
|
||||
|
||||
enum { OPT_BAUD = 256, OPT_CHANNELS, OPT_RATE, OPT_FREQ, OPT_AMP };
|
||||
|
||||
static struct option long_opts[] = {
|
||||
{"baud", required_argument, 0, OPT_BAUD},
|
||||
{"channels", required_argument, 0, OPT_CHANNELS},
|
||||
{"rate", required_argument, 0, OPT_RATE},
|
||||
{"freq", required_argument, 0, OPT_FREQ},
|
||||
{"amp", required_argument, 0, OPT_AMP},
|
||||
{"help", no_argument, 0, 'h'},
|
||||
{0, 0, 0, 0}
|
||||
};
|
||||
|
||||
int opt;
|
||||
while ((opt = getopt_long(argc, argv, "h", long_opts, NULL)) != -1) {
|
||||
switch (opt) {
|
||||
case OPT_BAUD: args->baudrate = atoi(optarg); break;
|
||||
case OPT_CHANNELS: {
|
||||
int v = atoi(optarg);
|
||||
if (v < 1 || v > MAX_CHANNELS) {
|
||||
fprintf(stderr, "通道数必须在 1-%d 之间\n", MAX_CHANNELS);
|
||||
return -1;
|
||||
}
|
||||
args->channels = (uint8_t)v;
|
||||
break;
|
||||
}
|
||||
case OPT_RATE: args->rate = atof(optarg); break;
|
||||
case OPT_FREQ: args->freq = atof(optarg); break;
|
||||
case OPT_AMP: args->amp = atof(optarg); break;
|
||||
case 'h': print_usage(argv[0]); exit(0);
|
||||
default: print_usage(argv[0]); return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (optind >= argc) {
|
||||
fprintf(stderr, "错误: 缺少串口路径\n");
|
||||
print_usage(argv[0]);
|
||||
return -1;
|
||||
}
|
||||
args->port = argv[optind];
|
||||
|
||||
if (args->rate <= 0.0) { fprintf(stderr, "错误: --rate 必须为正数\n"); return -1; }
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
args_t args;
|
||||
if (parse_args(argc, argv, &args) < 0) return 1;
|
||||
|
||||
double interval = 1.0 / args.rate;
|
||||
|
||||
printf("═══ 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);
|
||||
printf("═════════════════════════════════════\n");
|
||||
|
||||
float values[MAX_CHANNELS];
|
||||
char line[LINE_BUF_SIZE];
|
||||
|
||||
int fd = serial_open(args.port, args.baudrate);
|
||||
if (fd < 0) { fprintf(stderr, "无法打开串口 %s\n", args.port); return 1; }
|
||||
printf("已打开 %s\n\n开始发送 (Ctrl+C 停止) ...\n", args.port);
|
||||
|
||||
struct sigaction sa;
|
||||
memset(&sa, 0, sizeof(sa));
|
||||
sa.sa_handler = sigint_handler;
|
||||
sigaction(SIGINT, &sa, NULL);
|
||||
sigaction(SIGTERM, &sa, NULL);
|
||||
|
||||
uint32_t sample_index = 0;
|
||||
uint64_t line_count = 0;
|
||||
double started_at = now_sec();
|
||||
|
||||
while (g_running) {
|
||||
double t = (double)sample_index / args.rate;
|
||||
for (uint8_t ch = 0; ch < args.channels; ++ch) {
|
||||
double phase = 2.0 * M_PI * (double)ch / (double)args.channels;
|
||||
values[ch] = (float)(args.amp * sin(2.0 * M_PI * args.freq * t + phase));
|
||||
}
|
||||
|
||||
int len = format_line(line, sizeof(line), sample_index, values, args.channels);
|
||||
size_t written = 0;
|
||||
while (written < (size_t)len) {
|
||||
ssize_t n = write(fd, line + written, (size_t)len - written);
|
||||
if (n < 0) { perror("write"); goto cleanup; }
|
||||
written += (size_t)n;
|
||||
}
|
||||
line_count++;
|
||||
|
||||
double elapsed = now_sec() - started_at;
|
||||
double target = (double)line_count * interval;
|
||||
double wait = target - elapsed;
|
||||
if (wait > 0.0) sleep_sec(wait);
|
||||
|
||||
sample_index++;
|
||||
}
|
||||
|
||||
cleanup: {
|
||||
double total_time = now_sec() - started_at;
|
||||
printf("\n═══ 已停止 ═══\n");
|
||||
printf("运行时间: %.1f 秒\n", total_time);
|
||||
printf("行数: %lu (%.1f lines/sec)\n",
|
||||
(unsigned long)line_count,
|
||||
total_time > 0.0 ? (double)line_count / total_time : 0.0);
|
||||
|
||||
tcflush(fd, TCIOFLUSH);
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user