Compare commits

..

18 Commits

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

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

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

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

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

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-12 20:31:52 +08:00
afb8ca509a feat: add search for received data in Text and Hex views
- Add search() method to TextBuffer and HexBuffer
- Add SearchState with match tracking, prev/next navigation
- Highlight matched text with yellow background via LayoutJob
- Search bar UI with case-sensitive toggle and match counter
- Keyboard shortcuts: Ctrl+F (open), F3 (next), Shift+F3 (prev)
- Auto-focus input on Ctrl+F
- Update README with comprehensive project documentation
2026-06-12 02:42:17 +08:00
9cdbd5a892 Add keyboard shortcut system to xserial-gui
Introduce a centralized shortcut system with ShortcutAction enum and bindings table in shortcuts.rs. Global shortcuts are processed at the top of XserialApp::ui() before any widget rendering. Text input focus is detected via wants_keyboard_input() to suppress shortcuts when typing. Also fix pre-existing import ordering in panels/config.rs from cargo fmt.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-11 22:41:21 +08:00
8207b5e883 Fix deadlock in handle_send_to_dead_session test
The test called handle.join() without first calling handle.close(), causing the spawned session task to park forever waiting for cmd_rx.recv() to return None. Since join() borrows &self, cmd_tx stays alive, and the task never exits. Add close() call before join() to send a Close command and unblock the task.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-11 22:41:12 +08:00
202b8261c2 init tui 2026-06-11 19:11:36 +08:00
dc8f2eb8a4 feat: restrict DTR/RTS to serial transport; allow manual port input; clean up tool binaries
- Only show DTR/RTS toggles when transport is Serial (not TCP/UDP)
- Add manual port text entry next to serial port combo box
- Reduce combo box width to 180px to make room for text input
- Remove compiled ELF binaries from tools/ and add to .gitignore
2026-06-11 18:01:32 +08:00
edec8113fb Add Lua pipeline support 2026-06-11 15:07:02 +08:00
1fec5f8941 feat: Add DTR/RTS control for serial ports with GUI toggles
- Assert DTR/RTS after serial port open to prevent wireless modules
  (HC-12/HC-15/Bluetooth bridges) from entering AT command mode after
  the unavoidable Linux kernel DTR pulse on every open() call.
- Add dtr/rts fields to TransportConfig::Serial (default: false).
- Add set_dtr()/set_rts() on SerialTransport, Connection, SessionHandle.
- Add SessionCmd::SetDtr/SetRts for live toggling via command channel.
- Add DTR/RTS checkboxes to GUI session controls (visible when connected)
  and to config form for persistence.
- Add test_plot_serial.c and test_text_serial.c — standalone C test
  tools for sending MixedTextPlot frames and plain text over serial.
- Update AGENTS.md with build prerequisites, architecture map, testing
  conventions, env vars, and known quirks.
2026-06-11 14:18:39 +08:00
09bee86a08 add README.md 2026-06-10 13:41:56 +08:00
18bdd2652a Improve GUI plot workflow and performance 2026-06-10 11:54:57 +08:00
9b826d79da Use serial port dropdown 2026-06-10 00:43:58 +08:00
d489bc0831 Add GUI session persistence and full pipeline config 2026-06-09 23:18:15 +08:00
882b7f391f Improve GUI configuration and plotting tooling 2026-06-09 21:57:45 +08:00
76 changed files with 9154 additions and 1982 deletions

7
.gitignore vendored
View File

@@ -1 +1,8 @@
/target
# Compiled binaries in tools/
tools/test_plot_c
tools/test_text_c
# Python
__pycache__/

133
Cargo.lock generated
View File

@@ -3262,6 +3262,74 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pipeview-client"
version = "0.1.0"
dependencies = [
"mlua",
"pipeview-core",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "pipeview-core"
version = "0.1.0"
dependencies = [
"async-trait",
"bytes",
"hex",
"nom 8.0.0",
"serde",
"serde_json",
"serialport",
"thiserror 2.0.18",
"tokio",
"tokio-serial",
"tracing",
]
[[package]]
name = "pipeview-gui"
version = "0.1.0"
dependencies = [
"eframe",
"egui",
"egui_plot",
"hex",
"pipeview-client",
"pipeview-core",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "pipeview-tui"
version = "0.1.0"
dependencies = [
"clap",
"crossterm 0.28.1",
"hex",
"image",
"mlua",
"pipeview-client",
"pipeview-core",
"ratatui",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
@@ -5750,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"

View File

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

394
README.md Normal file
View 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** | 串口通信 | 端口名、波特率 (30012M)、数据位 (5/6/7/8)、校验位 (None/Odd/Even)、停止位 (1/2)、流控 (None/Software/Hardware)、DTR/RTS 控制 |
| **TCP** | TCP 客户端 | 目标地址 `host:port` |
| **UDP** | UDP 通信 | 绑定地址 `host:port`,可选远端地址 |
### 分帧器Framer
将原始字节流切分为独立帧。每个 Session 可配置**多条独立管线**,同一字节流并行送入多个 framer→decoder 链路。
| 分帧器 | 说明 | 配置参数 |
|--------|------|----------|
| **Line** | 按 `\n` 分割文本行 | `strip_cr`(去除 `\r`)、`max_line_len`(最大行长度) |
| **Fixed** | 固定字节数为一帧 | `frame_len`(帧长度) |
| **Length** | 长度前缀协议 | `len_bytes` (1/2/4/8)、`endian` (大小端)、`length_includes_self``max_payload` |
| **COBS** | [Consistent Overhead Byte Stuffing](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) 编码 | `max_frame`(最大帧长) |
| **MixedTextPlot** | 单连接混合文本行 + COBS 编码的 plot 帧 | `strip_cr``max_line_len``max_plot_frame` |
| **Lua** | 用户自定义分帧脚本 | `script_path`Lua 脚本路径) |
### 解码器Decoder
将帧数据解析为可展示的内容。
| 解码器 | 输出类型 | 配置参数 |
|--------|----------|----------|
| **Text** | 文本 | `encoding`UTF-8 / Latin1 / ASCII |
| **Hex** | 十六进制 | `uppercase``separator``bytes_per_group``endian` |
| **Plot** | 波形数据 | `sample_type` (i8f64)、`endian``channels` (164)、`format` (Interleaved / Block / XY) |
| **MixedTextPlot** | 混合文本 + 波形 | `encoding` |
| **Lua** | 自定义 | `script_path`Lua 脚本路径) |
**Plot 采样格式:**
| 格式 | 说明 | 字节排列 |
|------|------|----------|
| **Interleaved** | 多通道交叉排列 | `[ch0_s0, ch1_s0, ch2_s0, ch0_s1, ch1_s1, …]` |
| **Block** | 按通道分块排列 | `[ch0_s0, ch0_s1, …, ch1_s0, ch1_s1, …]` |
| **XY** | 2 通道交替 x/y | `[x0, y0, x1, y1, …]` |
支持的采样类型:`i8``u8``i16``u16``i32``u32``i64``u64``f32``f64`
### 视图
- **文本视图** — 带时间戳、方向标记 (`[IN]`/`[OUT]`)、管线标签的格式化文本流。支持搜索(`Ctrl+F`)、大小写匹配、匹配计数和高亮。
- **十六进制视图** — hex dump 与 ASCII 侧栏并排展示,可按分组和大小端解析多字节数值。
- **波形视图** — 基于 [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot) 的实时波形。支持自动缩放、框选缩放、坐标轴锁定、跟随最新数据、浮动窗口、通道图例。
### 会话管理
- **多会话并发** — 标签页切换,每个 Session 独立配置
- **连接控制** — Connect / Disconnect / Reconnect支持断线自动重连
- **运行时重配置** — 修改分帧器/解码器参数无需断开连接
- **数据发送** — Text 模式UTF-8可选换行符和 Hex 模式(十六进制字节)。支持 `None` / `LF` / `CR` / `CRLF` 四种行尾
- **环形缓冲** — 默认 10,000 条历史记录,可配置 1001,000,000
- **日志到文件** — 每行数据实时写入1KB 缓冲 + 后台线程,不阻塞 UI
- **搜索** — `Ctrl+F` 呼出搜索栏大小写敏感切换F3/Shift+F3 前后跳转
### Lua 脚本扩展
内置 LuaJIT 运行时:
- **自定义分帧器** — 实现 `feed(bytes)``flush()``reset()``pending_len()` 四个函数
- **自定义解码器** — 实现 `decode(frame)` 函数,返回 text/hex/plot/binary 四种类型
- **会话 API** — `pipeview.open()` 创建 Session、`session:on_data()` 事件回调、`session:send()` 发送数据
- **工具函数** — `pipeview.list_ports()``pipeview.sleep(ms)``pipeview.poll(limit)``pipeview.log(msg)`
### 快捷键
| 快捷键 | 操作 |
|--------|------|
| `Ctrl+N` | 新建会话 |
| `Ctrl+E` | 编辑当前会话 |
| `Ctrl+W` | 删除当前会话 |
| `Ctrl+F5` | 切换连接 |
| `Ctrl+T` / `Ctrl+H` / `Ctrl+P` | 切换到文本 / 十六进制 / 波形视图 |
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | 下一个 / 上一个标签页 |
| `Ctrl+L` | 清空输出 |
| `Ctrl+F` | 搜索 |
| `F3` / `Shift+F3` | 上一个 / 下一个匹配 |
| `Ctrl+,` | UI 设置 |
| `Esc` | 关闭浮层 |
---
## 快速开始
### 前置条件
| 平台 | 依赖 |
|------|------|
| **Linux** | `libudev-dev``apt install libudev-dev` |
| **macOS** | 无需额外依赖 |
| **Windows** | 无需额外依赖 |
| **所有平台** | Rust ≥ 1.85、C 编译器GCC / Clang / MSVC |
### 构建与运行
```bash
# 克隆项目
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
View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use xserial_core::frame::{
use pipeview_core::frame::{
Endian, Framer,
cobs::CobsFramer,
fixed::FixedLengthFramer,
@@ -7,14 +7,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"
);

View File

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

View File

@@ -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();
}

View 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();
}
}

View File

@@ -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);
}

View File

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

View File

@@ -11,8 +11,8 @@ use tokio::sync::{Mutex, broadcast, mpsc};
use tokio::task::JoinHandle;
use tracing::{debug, error, info, warn};
use xserial_core::pipeline::{MultiPipeline, Pipeline};
use xserial_core::transport::Connection;
use pipeview_core::pipeline::{MultiPipeline, Pipeline};
use pipeview_core::transport::Connection;
use crate::cmd::SessionCmd;
use crate::config::SessionConfig;
@@ -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;
}

View File

@@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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"),
}

View File

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

View File

@@ -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(),

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::warn;
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,
#[serde(default = "default_true")]
pub show_direction: bool,
#[serde(default = "default_true")]
pub show_pipeline: bool,
}
pub fn load_gui_state() -> PersistedGuiState {
match load_gui_state_from_path(&gui_state_path()) {
Ok(state) => state,
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedGuiState::default(),
Err(err) => {
warn!(error = %err, "Failed to load persisted GUI state");
PersistedGuiState::default()
}
}
}
pub fn save_gui_state(state: &PersistedGuiState) {
if let Err(err) = save_gui_state_to_path(state, &gui_state_path()) {
warn!(error = %err, "Failed to persist GUI state");
}
}
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("pipeview")
.join(GUI_STATE_FILE_NAME);
}
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join(GUI_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(GUI_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(GUI_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(GUI_STATE_FILE_NAME);
}
}
}
PathBuf::from(GUI_STATE_FILE_NAME)
}
fn load_gui_state_from_path(path: &Path) -> io::Result<PersistedGuiState> {
let text = fs::read_to_string(path)?;
serde_json::from_str(&text).map_err(io::Error::other)
}
fn save_gui_state_to_path(state: &PersistedGuiState, 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
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
use pipeview_core::transport::TransportConfig;
#[test]
fn windows_gui_state_path_uses_appdata() {
let path = gui_state_path_for_os_and_env("windows", |key| match key {
"APPDATA" => Some(String::from(r"C:\Users\Test\AppData\Roaming")),
_ => None,
});
assert_eq!(
path,
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
.join("pipeview")
.join(GUI_STATE_FILE_NAME)
);
}
#[test]
fn gui_state_roundtrip() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
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 {
transport: TransportConfig::Tcp {
addr: String::from("127.0.0.1:9000"),
},
..SessionConfig::default()
}],
sessions_log: vec![SessionLogConfig::default()],
active: 0,
show_timestamp: false,
show_direction: true,
show_pipeline: false,
};
save_gui_state_to_path(&state, &path).unwrap();
let loaded = load_gui_state_from_path(&path).unwrap();
assert_eq!(loaded.sessions.len(), 1);
assert!(!loaded.show_timestamp);
assert!(loaded.show_direction);
assert!(!loaded.show_pipeline);
let _ = fs::remove_file(&path);
let _ = fs::remove_dir_all(&dir);
}
}

View File

@@ -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]

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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,
}
}

View File

@@ -0,0 +1,68 @@
use crate::app::ConnectionStatus;
use egui::{Color32, RichText, Ui};
pub struct SessionListItem {
pub id: u64,
pub status: ConnectionStatus,
pub transport_summary: String,
}
pub fn render(
ui: &mut Ui,
sessions: &[SessionListItem],
active: &mut usize,
on_new: &mut bool,
on_edit: &mut Option<usize>,
on_delete: &mut Option<usize>,
) {
ui.heading("Sessions");
if ui
.button(RichText::new("+ New Session").color(Color32::GREEN))
.clicked()
{
*on_new = true;
}
ui.separator();
for (i, session) in sessions.iter().enumerate() {
let (color, status_tag) = match &session.status {
ConnectionStatus::Connected => (Color32::GREEN, "[connected]"),
ConnectionStatus::Disconnected => (Color32::GRAY, "[disconnected]"),
ConnectionStatus::Connecting => (Color32::YELLOW, "[connecting]"),
ConnectionStatus::Error(_) => (Color32::RED, "[error]"),
};
let label = format!(
"{} 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();
if ui
.button(RichText::new("Delete").color(Color32::RED))
.clicked()
{
*on_delete = Some(*active);
}
}

View 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)
}

View 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");
}
}
}

View File

@@ -10,19 +10,14 @@ use tracing::{info, warn};
const FONT_SETTINGS_FILE_NAME: &str = "gui-fonts.json";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FontChoice {
Auto,
#[default]
Default,
System(String),
}
impl Default for FontChoice {
fn default() -> Self {
Self::Default
}
}
#[derive(Clone, Debug)]
pub struct FontCandidate {
pub id: String,
@@ -60,7 +55,21 @@ impl Default for UiFontSettings {
}
pub fn discover_font_candidates() -> Vec<FontCandidate> {
discover_via_fc_list().unwrap_or_else(discover_via_fallback_paths)
let mut candidates = discover_via_fc_list().unwrap_or_default();
let mut seen_paths = candidates
.iter()
.map(|candidate| candidate.path.clone())
.collect::<std::collections::BTreeSet<_>>();
for candidate in discover_via_font_directories() {
if seen_paths.insert(candidate.path.clone()) {
candidates.push(candidate);
}
}
candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.path.cmp(&b.path)));
candidates.dedup_by(|a, b| a.path == b.path);
candidates
}
pub fn load_font_settings() -> UiFontSettings {
@@ -143,9 +152,7 @@ fn load_selected_font(
) -> Option<(String, Vec<u8>)> {
match choice {
FontChoice::Default => None,
FontChoice::Auto => {
load_auto_font(candidates, already_loaded)
}
FontChoice::Auto => load_auto_font(candidates, already_loaded),
FontChoice::System(id) => {
let candidate = candidates.iter().find(|candidate| &candidate.id == id)?;
if already_loaded.iter().any(|loaded| loaded == &candidate.id) {
@@ -195,19 +202,22 @@ fn apply_font_size(style: &mut Style, settings: &UiFontSettings) {
style
.text_styles
.insert(TextStyle::Body, FontId::proportional(settings.ui_font_size));
style
.text_styles
.insert(TextStyle::Button, FontId::proportional(settings.ui_font_size));
style.text_styles.insert(
TextStyle::Button,
FontId::proportional(settings.ui_font_size),
);
style.text_styles.insert(
TextStyle::Monospace,
FontId::monospace(settings.monospace_font_size),
);
style
.text_styles
.insert(TextStyle::Small, FontId::proportional((settings.ui_font_size - 2.0).max(8.0)));
style
.text_styles
.insert(TextStyle::Heading, FontId::proportional(settings.heading_font_size));
style.text_styles.insert(
TextStyle::Small,
FontId::proportional((settings.ui_font_size - 2.0).max(8.0)),
);
style.text_styles.insert(
TextStyle::Heading,
FontId::proportional(settings.heading_font_size),
);
}
fn discover_via_fc_list() -> Option<Vec<FontCandidate>> {
@@ -262,38 +272,47 @@ fn discover_via_fc_list() -> Option<Vec<FontCandidate>> {
});
}
candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.path.cmp(&b.path)));
candidates.dedup_by(|a, b| a.path == b.path);
(!candidates.is_empty()).then_some(candidates)
}
fn discover_via_fallback_paths() -> Vec<FontCandidate> {
let fallback_paths = [
"/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-sans-cjk-vf-fonts/NotoSansCJK-VF.ttc",
"/usr/share/fonts/google-droid-sans-fonts/DroidSansFallbackFull.ttf",
];
fallback_paths
fn discover_via_font_directories() -> Vec<FontCandidate> {
let mut candidates = Vec::new();
let mut stack = platform_font_directories()
.into_iter()
.filter_map(|path| {
fs::metadata(path).ok().map(|_| {
let stem = Path::new(path)
.file_stem()
.and_then(|name| name.to_str())
.unwrap_or("font")
.to_owned();
FontCandidate {
id: sanitize_id(&stem),
label: stem.clone(),
display_label: format!("{stem} [CJK]"),
path: path.to_owned(),
likely_cjk: true,
search_key: build_search_key(&stem, "Regular", path),
.filter(|path| path.is_dir())
.map(|path| (path, 0usize))
.collect::<Vec<_>>();
while let Some((dir, depth)) = stack.pop() {
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(err) => {
warn!(path = %dir.display(), error = %err, "Failed to read font directory");
continue;
}
})
})
.collect()
};
for entry in entries.flatten() {
let path = entry.path();
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(_) => continue,
};
if file_type.is_dir() {
if depth < 4 {
stack.push((path, depth + 1));
}
continue;
}
if let Some(candidate) = font_candidate_from_path(&path) {
candidates.push(candidate);
}
}
}
candidates
}
fn font_display_label(family: &str, style: &str, path: &str) -> String {
@@ -315,24 +334,7 @@ fn build_search_key(family: &str, style: &str, path: &str) -> String {
}
fn font_settings_path() -> PathBuf {
if let Ok(path) = env::var("XDG_CONFIG_HOME") {
let trimmed = path.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed).join("xserial").join(FONT_SETTINGS_FILE_NAME);
}
}
if let Ok(home) = env::var("HOME") {
let trimmed = home.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed)
.join(".config")
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME);
}
}
PathBuf::from(FONT_SETTINGS_FILE_NAME)
font_settings_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
}
fn load_font_settings_from_path(path: &Path) -> io::Result<UiFontSettings> {
@@ -348,6 +350,165 @@ fn save_font_settings_to_path(settings: &UiFontSettings, path: &Path) -> io::Res
fs::write(path, json)
}
fn font_settings_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(FONT_SETTINGS_FILE_NAME);
}
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join(FONT_SETTINGS_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(FONT_SETTINGS_FILE_NAME);
}
}
_ => {
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.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("pipeview")
.join(FONT_SETTINGS_FILE_NAME);
}
}
}
PathBuf::from(FONT_SETTINGS_FILE_NAME)
}
fn platform_font_directories() -> Vec<PathBuf> {
let mut dirs = Vec::new();
#[cfg(target_os = "windows")]
{
if let Some(windir) = env::var("WINDIR")
.ok()
.filter(|path| !path.trim().is_empty())
{
dirs.push(PathBuf::from(windir).join("Fonts"));
} else {
dirs.push(PathBuf::from(r"C:\Windows\Fonts"));
}
if let Some(local_app_data) = env::var("LOCALAPPDATA")
.ok()
.filter(|path| !path.trim().is_empty())
{
dirs.push(
PathBuf::from(local_app_data)
.join("Microsoft")
.join("Windows")
.join("Fonts"),
);
}
}
#[cfg(target_os = "macos")]
{
dirs.push(PathBuf::from("/System/Library/Fonts"));
dirs.push(PathBuf::from("/Library/Fonts"));
if let Some(home) = env::var("HOME").ok().filter(|path| !path.trim().is_empty()) {
dirs.push(PathBuf::from(home).join("Library").join("Fonts"));
}
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
dirs.push(PathBuf::from("/usr/share/fonts"));
dirs.push(PathBuf::from("/usr/local/share/fonts"));
if let Some(home) = env::var("HOME").ok().filter(|path| !path.trim().is_empty()) {
dirs.push(
PathBuf::from(&home)
.join(".local")
.join("share")
.join("fonts"),
);
dirs.push(PathBuf::from(home).join(".fonts"));
}
}
dirs
}
fn font_candidate_from_path(path: &Path) -> Option<FontCandidate> {
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
if !matches!(extension.as_str(), "ttf" | "ttc" | "otf" | "otc") {
return None;
}
let stem = path.file_stem()?.to_str()?.trim();
if stem.is_empty() {
return None;
}
let (family, style) = split_font_name(stem);
let path_string = path.to_string_lossy().into_owned();
let id = sanitize_id(&format!("{family}_{style}_{stem}"));
let label = if style.is_empty() || style == "Regular" {
family.clone()
} else {
format!("{family} ({style})")
};
Some(FontCandidate {
id,
label,
display_label: font_display_label(&family, &style, &path_string),
path: path_string.clone(),
likely_cjk: is_likely_cjk(&family, &style, &path_string),
search_key: build_search_key(&family, &style, &path_string),
})
}
fn split_font_name(stem: &str) -> (String, String) {
let normalized = stem.replace(['_', '-'], " ");
let compact = normalized.split_whitespace().collect::<Vec<_>>().join(" ");
let lower = compact.to_lowercase();
for style in [
"ExtraBold Italic",
"Extra Light Italic",
"SemiBold Italic",
"Bold Italic",
"ExtraBold",
"SemiBold",
"Medium",
"Regular",
"Italic",
"Bold",
"Light",
"Thin",
] {
let suffix = style.to_lowercase();
if let Some(prefix) = lower.strip_suffix(&suffix) {
let family = compact[..prefix.trim_end().len()].trim().to_owned();
if !family.is_empty() {
return (family, style.to_owned());
}
}
}
(compact, String::from("Regular"))
}
const fn default_ui_font_size() -> f32 {
14.0
}
@@ -397,6 +558,21 @@ fn is_likely_cjk(family: &str, style: &str, path: &str) -> bool {
"hei",
"kai",
"song",
"fang",
"yahei",
"deng",
"ming",
"simsun",
"simhei",
"simkai",
"simfang",
"msyh",
"msjh",
"meiryo",
"msgothic",
"ms gothic",
"malgun",
"gulim",
"gothic",
"mincho",
]
@@ -415,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")),
@@ -436,4 +612,62 @@ mod tests {
let _ = fs::remove_file(&path);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn linux_config_path_uses_xdg() {
let path = font_settings_path_for_os_and_env("linux", |key| match key {
"XDG_CONFIG_HOME" => Some(String::from("/tmp/xdg-config")),
_ => None,
});
assert_eq!(
path,
PathBuf::from("/tmp/xdg-config")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME)
);
}
#[test]
fn windows_config_path_uses_appdata() {
let path = font_settings_path_for_os_and_env("windows", |key| match key {
"APPDATA" => Some(String::from(r"C:\Users\Test\AppData\Roaming")),
_ => None,
});
assert_eq!(
path,
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME)
);
}
#[test]
fn macos_config_path_uses_application_support() {
let path = font_settings_path_for_os_and_env("macos", |key| match key {
"HOME" => Some(String::from("/Users/tester")),
_ => None,
});
assert_eq!(
path,
PathBuf::from("/Users/tester")
.join("Library")
.join("Application Support")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME)
);
}
#[test]
fn detects_windows_cjk_fonts() {
assert!(is_likely_cjk(
"Microsoft YaHei UI",
"Regular",
r"C:\Windows\Fonts\msyh.ttc"
));
assert!(is_likely_cjk(
"SimSun",
"Regular",
r"C:\Windows\Fonts\simsun.ttc"
));
}
}

View File

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

File diff suppressed because it is too large Load Diff

View 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
}

View 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]"))
}

View 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
}

View 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]
}

View File

@@ -1,804 +0,0 @@
use std::sync::mpsc;
use std::time::Duration;
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
use crate::panels::{config, console, hex_view, plot_view, sidebar};
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;
#[derive(Clone)]
pub enum ConnectionStatus {
Connected,
Disconnected,
Connecting,
Error(String),
}
impl ConnectionStatus {
fn badge(&self) -> (&'static str, &'static str) {
match self {
Self::Connected => ("[connected]", "Connected"),
Self::Disconnected => ("[disconnected]", "Disconnected"),
Self::Connecting => ("[connecting]", "Connecting"),
Self::Error(_) => ("[error]", "Error"),
}
}
}
#[derive(Clone, Copy, PartialEq)]
pub enum View {
Text,
Hex,
Plot,
}
#[derive(Clone, Copy, PartialEq)]
pub enum SendMode {
Text,
Hex,
}
#[derive(Clone, Copy)]
pub struct DisplayOptions {
pub show_timestamp: bool,
pub show_direction: bool,
pub show_pipeline: bool,
}
pub struct SessionTab {
pub id: u64,
pub session_config: SessionConfig,
pub status: ConnectionStatus,
pub console: TextBuffer,
pub hex: HexBuffer,
pub plot: PlotBuffer,
pub plot_view: plot_view::PlotViewState,
pub view: View,
pub auto_reconnect: bool,
pub send_input: String,
pub send_mode: SendMode,
pub append_newline: bool,
pub send_status: Option<String>,
}
pub struct XserialApp {
manager: SessionManager,
tabs: Vec<SessionTab>,
active: usize,
display: DisplayOptions,
font_settings_open: bool,
font_candidates: Vec<FontCandidate>,
font_settings: UiFontSettings,
primary_font_search: String,
fallback_font_search: String,
primary_filtered_fonts: Vec<usize>,
fallback_filtered_fonts: Vec<usize>,
primary_filter_cache_key: String,
fallback_filter_cache_key: String,
config_open: bool,
config_target: Option<u64>,
config_form: config::ConfigForm,
event_rx: mpsc::Receiver<SessionEvent>,
pending: Vec<SessionEvent>,
}
impl XserialApp {
pub fn new(
manager: SessionManager,
mut rx: tokio::sync::broadcast::Receiver<SessionEvent>,
ctx: egui::Context,
) -> Self {
let (event_tx, event_rx) = mpsc::channel();
let repaint_ctx = ctx.clone();
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(event) => {
if event_tx.send(event).is_err() {
break;
}
repaint_ctx.request_repaint();
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
let font_candidates = ui_fonts::discover_font_candidates();
let font_settings = ui_fonts::load_font_settings();
ui_fonts::apply_font_settings(&ctx, &font_settings, &font_candidates);
Self {
manager,
tabs: Vec::new(),
active: 0,
display: DisplayOptions {
show_timestamp: true,
show_direction: true,
show_pipeline: true,
},
font_settings_open: false,
font_candidates,
font_settings,
primary_font_search: String::new(),
fallback_font_search: String::new(),
primary_filtered_fonts: Vec::new(),
fallback_filtered_fonts: Vec::new(),
primary_filter_cache_key: String::from("\0"),
fallback_filter_cache_key: String::from("\0"),
config_open: false,
config_target: None,
config_form: config::ConfigForm::default(),
event_rx,
pending: Vec::new(),
}
}
fn open_create_config(&mut self) {
self.config_target = None;
self.config_form = config::ConfigForm::default();
self.config_open = true;
}
fn open_edit_config(&mut self, id: u64) {
if let Some(tab) = self.tabs.iter().find(|tab| tab.id == id) {
self.config_target = Some(id);
self.config_form = config::ConfigForm::from_session_config(&tab.session_config);
self.config_open = true;
}
}
fn apply_session_config(&mut self, id: u64, session_config: SessionConfig) {
let handle = self.manager.get(id);
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
let history_limit = session_config.history_limit;
tab.session_config = session_config.clone();
tab.auto_reconnect = session_config.auto_reconnect;
tab.console.set_limit(history_limit);
tab.hex.set_limit(history_limit);
tab.plot.set_limit(history_limit);
tab.status = ConnectionStatus::Connecting;
tab.send_status = Some(String::from("Session reconfigured"));
}
if let Some(handle) = handle {
tokio::spawn(async move {
let _ = handle.reconfigure(session_config).await;
});
} else if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
fn drain_events(&mut self) {
while let Ok(event) = self.event_rx.try_recv() {
self.pending.push(event);
}
for event in self.pending.drain(..) {
match event {
SessionEvent::Connected(id) => {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
tab.status = ConnectionStatus::Connected;
}
}
SessionEvent::Disconnected(id) | SessionEvent::Closed(id) => {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
tab.status = ConnectionStatus::Disconnected;
}
}
SessionEvent::Error(id, message) => {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
tab.status = ConnectionStatus::Error(message);
}
}
SessionEvent::Data(id, entry) => {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
match &entry.data {
DecodedData::Text(_) => tab.console.push(&entry),
DecodedData::Hex(_) => tab.hex.push(&entry),
DecodedData::Plot(_) => tab.plot.push(&entry),
DecodedData::Binary(_) => {}
}
}
}
}
}
}
fn wants_live_plot_repaint(&self) -> bool {
self.tabs
.get(self.active)
.map(|tab| {
matches!(tab.view, View::Plot) && matches!(tab.status, ConnectionStatus::Connected)
})
.unwrap_or(false)
}
fn render_config_window(&mut self, ctx: &egui::Context) {
if !self.config_open {
return;
}
let mut open = self.config_open;
let title = if self.config_target.is_some() {
"Edit Session Config"
} else {
"Session Config"
};
let submit_label = if self.config_target.is_some() {
"Save Session Config"
} else {
"Create Session"
};
let mut submitted = None;
egui::Window::new(title).open(&mut open).show(ctx, |ui| {
submitted = config::render(ui, &mut self.config_form, submit_label);
});
if let Some(session_config) = submitted {
if let Some(id) = self.config_target {
self.apply_session_config(id, session_config);
} else {
let history_limit = session_config.history_limit;
let auto_reconnect = session_config.auto_reconnect;
let handle = self.manager.create(session_config.clone());
self.tabs.push(SessionTab {
id: handle.id(),
session_config,
status: ConnectionStatus::Connecting,
console: TextBuffer::new(history_limit),
hex: HexBuffer::new(history_limit),
plot: PlotBuffer::new(history_limit),
plot_view: plot_view::PlotViewState::default(),
view: View::Text,
auto_reconnect,
send_input: String::new(),
send_mode: SendMode::Text,
append_newline: true,
send_status: None,
});
self.active = self.tabs.len() - 1;
}
open = false;
}
if !open {
self.config_target = None;
}
self.config_open = open;
}
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
let mut on_new = false;
let mut on_delete = None;
Panel::left("sidebar")
.resizable(false)
.show_inside(ui, |ui| {
let sessions: Vec<_> = self
.tabs
.iter()
.map(|tab| (tab.id, tab.status.clone()))
.collect();
sidebar::render(ui, &sessions, &mut self.active, &mut on_new, &mut on_delete);
});
if on_new {
self.open_create_config();
}
if let Some(index) = on_delete {
if let Some(tab) = self.tabs.get(index) {
self.manager.remove(tab.id);
}
self.tabs.remove(index);
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
self.active = self.tabs.len() - 1;
}
}
}
fn render_main_panel(&mut self, ui: &mut egui::Ui) {
egui::CentralPanel::default().show_inside(ui, |ui| {
if self.tabs.is_empty() {
ui.heading("No sessions.");
return;
}
if self.active >= self.tabs.len() {
self.active = self.tabs.len() - 1;
}
let manager = self.manager.clone();
let display = &mut self.display;
let mut edit_session = None;
{
let tab = &mut self.tabs[self.active];
let (badge, status_text) = tab.status.badge();
ui.horizontal(|ui| {
ui.heading(format!("Session {}", tab.id));
ui.label(badge);
ui.separator();
ui.label(status_text);
ui.separator();
if ui
.selectable_label(tab.view == View::Text, "Text")
.clicked()
{
tab.view = View::Text;
}
if ui.selectable_label(tab.view == View::Hex, "Hex").clicked() {
tab.view = View::Hex;
}
if ui
.selectable_label(tab.view == View::Plot, "Plot")
.clicked()
{
tab.view = View::Plot;
}
});
if render_session_controls(ui, &manager, tab) {
edit_session = Some(tab.id);
}
ui.horizontal_wrapped(|ui| {
ui.label("Show:");
ui.checkbox(&mut display.show_timestamp, "Timestamp");
ui.checkbox(&mut display.show_direction, "Direction");
ui.checkbox(&mut display.show_pipeline, "Pipe");
});
if let ConnectionStatus::Error(message) = &tab.status {
ui.label(egui::RichText::new(message).color(Color32::RED));
}
let full = ui.available_rect_before_wrap();
let send_height = 240.0;
let separator = 8.0;
let receive_height = (full.height() - send_height - separator).max(0.0);
let receive_rect = Rect::from_min_max(
full.min,
Pos2::new(full.max.x, full.min.y + receive_height),
);
let send_rect =
Rect::from_min_max(Pos2::new(full.min.x, full.max.y - send_height), full.max);
ui.scope_builder(
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.scope_builder(
UiBuilder::new()
.max_rect(send_rect)
.layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)),
|ui| render_send_panel(ui, &manager, tab),
);
}
if let Some(id) = edit_session {
self.open_edit_config(id);
}
});
}
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.separator();
// ui.label(format!(
// "Fonts: {} + {} {:.1} pt",
// ui_fonts::font_choice_label(
// &self.font_settings.primary_choice,
// &self.font_candidates
// ),
// ui_fonts::font_choice_label(
// &self.font_settings.fallback_choice,
// &self.font_candidates
// ),
// self.font_settings.ui_font_size
// ));
if ui.button("UI Settings").clicked() {
self.font_settings_open = true;
}
});
});
}
fn render_font_settings_window(&mut self, ctx: &egui::Context) {
if !self.font_settings_open {
return;
}
let mut open = self.font_settings_open;
let mut changed = false;
egui::Window::new("UI Settings")
.open(&mut open)
.default_width(520.0)
.resizable(true)
.show(ctx, |ui| {
ui.heading("Fonts");
ui.horizontal(|ui| {
ui.label("Primary:");
ui.monospace(ui_fonts::font_choice_label(
&self.font_settings.primary_choice,
&self.font_candidates,
));
ui.label("Fallback:");
ui.monospace(ui_fonts::font_choice_label(
&self.font_settings.fallback_choice,
&self.font_candidates,
));
if ui.button("Refresh").clicked() {
self.font_candidates = ui_fonts::discover_font_candidates();
self.invalidate_font_filters();
}
});
ui.small("Primary font is tried first. Fallback font is used when the primary font lacks a glyph.");
ui.add_space(6.0);
render_font_selector(
ui,
"Primary font",
"primary_font_choice",
&mut self.font_settings.primary_choice,
&mut self.primary_font_search,
&self.font_candidates,
&mut self.primary_filtered_fonts,
&mut self.primary_filter_cache_key,
true,
true,
180.0,
&mut changed,
);
ui.separator();
render_font_selector(
ui,
"Fallback font",
"fallback_font_choice",
&mut self.font_settings.fallback_choice,
&mut self.fallback_font_search,
&self.font_candidates,
&mut self.fallback_filtered_fonts,
&mut self.fallback_filter_cache_key,
true,
true,
140.0,
&mut changed,
);
ui.separator();
ui.heading("Sizes");
ui.label("UI font size");
changed |= ui
.add(
egui::Slider::new(&mut self.font_settings.ui_font_size, 10.0..=28.0)
.suffix(" pt"),
)
.changed();
ui.label("Monospace font size");
changed |= ui
.add(
egui::Slider::new(
&mut self.font_settings.monospace_font_size,
10.0..=28.0,
)
.suffix(" pt"),
)
.changed();
ui.label("Heading size");
changed |= ui
.add(
egui::Slider::new(&mut self.font_settings.heading_font_size, 14.0..=40.0)
.suffix(" pt"),
)
.changed();
ui.separator();
ui.heading("Preview");
ui.label("The quick brown fox jumps over the lazy dog.");
ui.label("中文预览:串口、网络、绘图、十六进制、会话管理。");
ui.monospace("Monospace preview: 0123456789 ABCDEF deadbeef");
});
if changed {
ui_fonts::apply_font_settings(ctx, &self.font_settings, &self.font_candidates);
ui_fonts::save_font_settings(&self.font_settings);
}
self.font_settings_open = open;
}
fn invalidate_font_filters(&mut self) {
self.primary_filtered_fonts.clear();
self.fallback_filtered_fonts.clear();
self.primary_filter_cache_key = String::from("\0");
self.fallback_filter_cache_key = String::from("\0");
}
}
fn render_font_selector(
ui: &mut egui::Ui,
title: &str,
id_prefix: &str,
choice: &mut FontChoice,
search: &mut String,
candidates: &[FontCandidate],
filtered: &mut Vec<usize>,
cache_key: &mut String,
allow_auto: bool,
allow_default: bool,
max_height: f32,
changed: &mut bool,
) {
ui.label(title);
ui.horizontal(|ui| {
ui.label("Search:");
ui.text_edit_singleline(search);
});
ui.horizontal_wrapped(|ui| {
if allow_auto {
*changed |= ui
.selectable_value(choice, FontChoice::Auto, "Auto")
.changed();
}
if allow_default {
*changed |= ui
.selectable_value(choice, FontChoice::Default, "Default")
.changed();
}
});
ui.add_space(4.0);
refresh_font_filter(search, candidates, filtered, cache_key);
ui.small(format!("{} fonts", filtered.len()));
let row_height = ui.spacing().interact_size.y;
egui::ScrollArea::vertical()
.id_salt(format!("{id_prefix}_scroll"))
.max_height(max_height)
.auto_shrink([false, false])
.show_rows(ui, row_height, filtered.len(), |ui, row_range| {
for row in row_range {
if let Some(candidate) = filtered.get(row).and_then(|index| candidates.get(*index)) {
let response = ui.selectable_value(
choice,
FontChoice::System(candidate.id.clone()),
candidate.display_label.as_str(),
);
*changed |= response.changed();
response.on_hover_text(&candidate.path);
}
}
});
}
fn refresh_font_filter(
search: &str,
candidates: &[FontCandidate],
filtered: &mut Vec<usize>,
cache_key: &mut String,
) {
let needle = search.trim().to_lowercase();
if *cache_key == needle {
return;
}
filtered.clear();
if needle.is_empty() {
filtered.extend(0..candidates.len());
} else {
filtered.extend(
candidates
.iter()
.enumerate()
.filter(|(_, candidate)| candidate.search_key.contains(&needle))
.map(|(index, _)| index),
);
}
*cache_key = needle;
}
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) {
self.drain_events();
if self.wants_live_plot_repaint() {
ui.ctx().request_repaint_after(Duration::from_millis(16));
}
self.render_top_bar(ui);
self.render_font_settings_window(ui.ctx());
self.render_config_window(ui.ctx());
self.render_sidebar(ui);
self.render_main_panel(ui);
}
}
fn render_session_controls(
ui: &mut egui::Ui,
manager: &SessionManager,
tab: &mut SessionTab,
) -> bool {
let mut configure_clicked = 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() {
if let Some(handle) = manager.get(tab.id) {
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;
});
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
if ui.button("Configure").clicked() {
configure_clicked = true;
}
if ui.button("Clear").clicked() {
tab.console.clear();
tab.hex.clear();
tab.plot.clear();
tab.send_status = Some(String::from("Cleared"));
}
let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect");
if response.changed() {
if let Some(handle) = manager.get(tab.id) {
let enabled = tab.auto_reconnect;
tokio::spawn(async move {
let _ = handle.set_auto_reconnect(enabled).await;
});
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
});
configure_clicked
}
fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
ui.set_width(ui.available_width());
ui.heading("Send");
ui.horizontal(|ui| {
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);
let hint = match tab.send_mode {
SendMode::Text => "Enter text to send",
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
};
let response = ui.add(
TextEdit::multiline(&mut tab.send_input)
.desired_rows(6)
.desired_width(f32::INFINITY)
.hint_text(hint),
);
let wants_submit = response.has_focus()
&& ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.command_only());
let mut send_clicked = false;
ui.horizontal(|ui| {
send_clicked = ui.button("Send").clicked();
if let Some(status) = &tab.send_status {
ui.label(
egui::RichText::new(status).color(if status.starts_with("Send failed") {
Color32::RED
} else {
Color32::GRAY
}),
);
}
});
if !(send_clicked || wants_submit) {
return;
}
match build_payload(tab) {
Ok(Some(payload)) => {
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()
};
if !text.is_empty() {
tab.console.push_outbound(text);
}
}
SendMode::Hex => {
let hex = tab
.send_input
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if !hex.is_empty() {
tab.hex.push_outbound(hex);
}
}
}
tokio::spawn(async move {
let _ = handle.send(payload).await;
});
tab.send_input.clear();
tab.send_status = Some(String::from("Sent"));
} else {
tab.send_status = Some(String::from("Send failed: session not found"));
}
}
Ok(None) => {
tab.send_status = Some(String::from("Nothing to send"));
}
Err(message) => {
tab.send_status = Some(format!("Send failed: {message}"));
}
}
}
fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
let trimmed = tab.send_input.trim();
if trimmed.is_empty() {
return Ok(None);
}
match tab.send_mode {
SendMode::Text => {
let mut text = tab.send_input.clone();
if tab.append_newline && !text.ends_with('\n') {
text.push('\n');
}
Ok(Some(text.into_bytes()))
}
SendMode::Hex => {
let compact: String = trimmed
.chars()
.filter(|ch| !ch.is_ascii_whitespace())
.collect();
hex::decode(compact)
.map(Some)
.map_err(|err| format!("invalid hex input ({err})"))
}
}
}

View File

@@ -1,24 +0,0 @@
mod app;
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())))),
);
}

View File

@@ -1,614 +0,0 @@
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
use xserial_core::protocol::plot::PlotFormat;
use xserial_core::protocol::text::TextEncoding;
use xserial_core::transport::TransportConfig;
use xserial_core::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
};
// ── 表单数据结构(简化版,方便 UI 渲染)────────────────────────────
#[derive(Clone)]
pub struct ConfigForm {
pub transport: TransportChoice,
pub port: String,
pub baud: u32,
pub serial_data_bits: SerialDataBits,
pub serial_parity: SerialParity,
pub serial_stop_bits: SerialStopBits,
pub serial_flow_control: SerialFlowControl,
pub addr: String,
pub udp_bind: String,
pub udp_remote: String,
pub pipelines: Vec<PipelineForm>,
pub history: usize,
pub auto_reconnect: bool,
}
#[derive(Clone)]
pub struct PipelineForm {
pub name: String,
pub framer: FramerChoice,
pub decoder: DecoderChoice,
pub text_encoding: TextEncoding,
pub plot_channels: usize,
pub plot_format: PlotFormat,
}
#[derive(Clone, PartialEq)]
pub enum TransportChoice {
Serial,
Tcp,
Udp,
}
#[derive(Clone, PartialEq)]
pub enum FramerChoice {
Line,
Fixed(usize),
MixedTextPlot,
}
#[derive(Clone, PartialEq)]
pub enum DecoderChoice {
Text,
Hex,
Plot,
MixedTextPlot,
}
impl Default for ConfigForm {
fn default() -> Self {
Self {
transport: TransportChoice::Tcp,
port: "/dev/ttyUSB0".into(),
baud: 115200,
serial_data_bits: SerialDataBits::Eight,
serial_parity: SerialParity::None,
serial_stop_bits: SerialStopBits::One,
serial_flow_control: SerialFlowControl::None,
addr: "127.0.0.1:8080".into(),
udp_bind: "0.0.0.0:9000".into(),
udp_remote: String::new(),
pipelines: vec![PipelineForm {
name: "text".into(),
framer: FramerChoice::Line,
decoder: DecoderChoice::Text,
text_encoding: TextEncoding::Utf8,
plot_channels: 1,
plot_format: PlotFormat::Interleaved,
}],
history: 10000,
auto_reconnect: false,
}
}
}
impl ConfigForm {
pub fn from_session_config(config: &SessionConfig) -> Self {
Self {
transport: match &config.transport {
TransportConfig::Serial { .. } => TransportChoice::Serial,
TransportConfig::Tcp { .. } => TransportChoice::Tcp,
TransportConfig::Udp { .. } => TransportChoice::Udp,
},
port: match &config.transport {
TransportConfig::Serial { port, .. } => port.clone(),
_ => String::from("/dev/ttyUSB0"),
},
baud: match &config.transport {
TransportConfig::Serial { baud_rate, .. } => *baud_rate,
_ => 115200,
},
serial_data_bits: match &config.transport {
TransportConfig::Serial { data_bits, .. } => *data_bits,
_ => SerialDataBits::Eight,
},
serial_parity: match &config.transport {
TransportConfig::Serial { parity, .. } => *parity,
_ => SerialParity::None,
},
serial_stop_bits: match &config.transport {
TransportConfig::Serial { stop_bits, .. } => *stop_bits,
_ => SerialStopBits::One,
},
serial_flow_control: match &config.transport {
TransportConfig::Serial { flow_control, .. } => *flow_control,
_ => SerialFlowControl::None,
},
addr: match &config.transport {
TransportConfig::Tcp { addr } => addr.clone(),
_ => String::from("127.0.0.1:8080"),
},
udp_bind: match &config.transport {
TransportConfig::Udp { bind_addr, .. } => bind_addr.clone(),
_ => String::from("0.0.0.0:9000"),
},
udp_remote: match &config.transport {
TransportConfig::Udp { remote_addr, .. } => remote_addr.clone().unwrap_or_default(),
_ => String::new(),
},
pipelines: config
.pipelines
.iter()
.map(|pipeline| {
let framer = match &pipeline.framer {
FramerConfig::Line { .. } => FramerChoice::Line,
FramerConfig::Fixed { frame_len } => FramerChoice::Fixed(*frame_len),
FramerConfig::MixedTextPlot { .. } => FramerChoice::MixedTextPlot,
FramerConfig::Length { .. } | FramerConfig::Cobs { .. } => {
FramerChoice::Line
}
};
let decoder = match &pipeline.decoder {
DecoderConfig::Text { .. } => DecoderChoice::Text,
DecoderConfig::Hex { .. } => DecoderChoice::Hex,
DecoderConfig::Plot { .. } => DecoderChoice::Plot,
DecoderConfig::MixedTextPlot { .. } => DecoderChoice::MixedTextPlot,
};
let plot_channels = match &pipeline.decoder {
DecoderConfig::Plot { channels, .. } => *channels,
_ => 1,
};
let text_encoding = match &pipeline.decoder {
DecoderConfig::Text { encoding } => *encoding,
DecoderConfig::MixedTextPlot { encoding } => *encoding,
_ => TextEncoding::Utf8,
};
let plot_format = match &pipeline.decoder {
DecoderConfig::Plot { format, .. } => *format,
_ => PlotFormat::Interleaved,
};
PipelineForm {
name: pipeline.name.clone(),
framer,
decoder,
text_encoding,
plot_channels,
plot_format,
}
})
.collect(),
history: config.history_limit,
auto_reconnect: config.auto_reconnect,
}
}
// 把表单数据转换成 xserial-client 的 SessionConfig
pub fn to_session_config(&self) -> SessionConfig {
SessionConfig {
transport: match self.transport {
TransportChoice::Serial => TransportConfig::Serial {
port: self.port.clone(),
baud_rate: self.baud,
data_bits: self.serial_data_bits,
parity: self.serial_parity,
stop_bits: self.serial_stop_bits,
flow_control: self.serial_flow_control,
},
TransportChoice::Tcp => TransportConfig::Tcp {
addr: self.addr.clone(),
},
TransportChoice::Udp => TransportConfig::Udp {
bind_addr: self.udp_bind.clone(),
remote_addr: if self.udp_remote.is_empty() {
None
} else {
Some(self.udp_remote.clone())
},
},
},
pipelines: self
.pipelines
.iter()
.map(|pipeline| PipelineConfig {
name: pipeline.name.clone(),
framer: match &pipeline.framer {
FramerChoice::Line => FramerConfig::Line {
strip_cr: true,
max_line_len: 1_048_576,
},
FramerChoice::Fixed(n) => FramerConfig::Fixed { frame_len: *n },
FramerChoice::MixedTextPlot => FramerConfig::MixedTextPlot {
strip_cr: true,
max_line_len: 1_048_576,
max_plot_frame: 1_048_576,
},
},
decoder: match &pipeline.decoder {
DecoderChoice::Text => DecoderConfig::Text {
encoding: pipeline.text_encoding,
},
DecoderChoice::Hex => DecoderConfig::Hex {
uppercase: false,
separator: " ".into(),
bytes_per_group: 1,
endian: xserial_core::protocol::Endian::Big,
},
DecoderChoice::Plot => DecoderConfig::Plot {
sample_type: xserial_core::protocol::plot::SampleType::F32,
endian: xserial_core::protocol::Endian::Little,
channels: pipeline.plot_channels.max(1),
format: pipeline.plot_format,
},
DecoderChoice::MixedTextPlot => DecoderConfig::MixedTextPlot {
encoding: pipeline.text_encoding,
},
},
})
.collect(),
history_limit: self.history,
auto_reconnect: self.auto_reconnect,
}
}
}
// ── 渲染函数 ──────────────────────────────────────────────────────
pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option<SessionConfig> {
let mut result = None;
let mut validation_errors = Vec::new();
ScrollArea::vertical().show(ui, |ui| {
ui.heading("Transport");
// 传输类型下拉
ComboBox::from_label("Type")
.selected_text(match form.transport {
TransportChoice::Serial => "Serial",
TransportChoice::Tcp => "TCP",
TransportChoice::Udp => "UDP",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut form.transport, TransportChoice::Serial, "Serial");
ui.selectable_value(&mut form.transport, TransportChoice::Tcp, "TCP");
ui.selectable_value(&mut form.transport, TransportChoice::Udp, "UDP");
});
// 根据类型显示不同字段
match form.transport {
TransportChoice::Serial => {
ui.horizontal(|ui| {
ui.label("Port:");
ui.text_edit_singleline(&mut form.port);
});
ui.horizontal(|ui| {
ui.label("Baud:");
ui.add(DragValue::new(&mut form.baud).range(300..=12_000_000));
});
ui.horizontal(|ui| {
ui.label("Data bits:");
ComboBox::from_id_salt("serial_data_bits")
.selected_text(match form.serial_data_bits {
SerialDataBits::Five => "5",
SerialDataBits::Six => "6",
SerialDataBits::Seven => "7",
SerialDataBits::Eight => "8",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut form.serial_data_bits,
SerialDataBits::Five,
"5",
);
ui.selectable_value(
&mut form.serial_data_bits,
SerialDataBits::Six,
"6",
);
ui.selectable_value(
&mut form.serial_data_bits,
SerialDataBits::Seven,
"7",
);
ui.selectable_value(
&mut form.serial_data_bits,
SerialDataBits::Eight,
"8",
);
});
});
ui.horizontal(|ui| {
ui.label("Parity:");
ComboBox::from_id_salt("serial_parity")
.selected_text(match form.serial_parity {
SerialParity::None => "None",
SerialParity::Odd => "Odd",
SerialParity::Even => "Even",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut form.serial_parity,
SerialParity::None,
"None",
);
ui.selectable_value(&mut form.serial_parity, SerialParity::Odd, "Odd");
ui.selectable_value(
&mut form.serial_parity,
SerialParity::Even,
"Even",
);
});
});
ui.horizontal(|ui| {
ui.label("Stop bits:");
ComboBox::from_id_salt("serial_stop_bits")
.selected_text(match form.serial_stop_bits {
SerialStopBits::One => "1",
SerialStopBits::Two => "2",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut form.serial_stop_bits,
SerialStopBits::One,
"1",
);
ui.selectable_value(
&mut form.serial_stop_bits,
SerialStopBits::Two,
"2",
);
});
});
ui.horizontal(|ui| {
ui.label("Flow:");
ComboBox::from_id_salt("serial_flow_control")
.selected_text(match form.serial_flow_control {
SerialFlowControl::None => "None",
SerialFlowControl::Software => "Software",
SerialFlowControl::Hardware => "Hardware",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut form.serial_flow_control,
SerialFlowControl::None,
"None",
);
ui.selectable_value(
&mut form.serial_flow_control,
SerialFlowControl::Software,
"Software",
);
ui.selectable_value(
&mut form.serial_flow_control,
SerialFlowControl::Hardware,
"Hardware",
);
});
});
}
TransportChoice::Tcp => {
ui.horizontal(|ui| {
ui.label("Addr:");
ui.text_edit_singleline(&mut form.addr);
});
}
TransportChoice::Udp => {
ui.horizontal(|ui| {
ui.label("Bind:");
ui.text_edit_singleline(&mut form.udp_bind);
});
ui.horizontal(|ui| {
ui.label("Remote:");
ui.text_edit_singleline(&mut form.udp_remote);
});
}
}
ui.separator();
ui.heading("Pipelines");
let mut remove = None;
// 管道列表
for (i, pipeline) in form.pipelines.iter_mut().enumerate() {
ui.group(|ui| {
ui.horizontal(|ui| {
ui.label("Name:");
ui.add(TextEdit::singleline(&mut pipeline.name).desired_width(60.0));
// Framer 选择
let fsel = match &pipeline.framer {
FramerChoice::Line => "Line".to_string(),
FramerChoice::Fixed(n) => format!("Fixed({})", n),
FramerChoice::MixedTextPlot => "MixedTextPlot".to_string(),
};
ComboBox::from_id_salt(format!("f{}", i))
.selected_text(fsel)
.show_ui(ui, |ui| {
if ui.selectable_label(false, "Line").clicked() {
pipeline.framer = FramerChoice::Line;
}
if ui.selectable_label(false, "Fixed").clicked() {
pipeline.framer = FramerChoice::Fixed(8);
}
if ui.selectable_label(false, "MixedTextPlot").clicked() {
pipeline.framer = FramerChoice::MixedTextPlot;
pipeline.decoder = DecoderChoice::MixedTextPlot;
}
});
if let FramerChoice::Fixed(n) = &mut pipeline.framer {
ui.add(DragValue::new(n).range(1..=65536));
}
// Decoder 选择
ComboBox::from_id_salt(format!("d{}", i))
.selected_text(match pipeline.decoder {
DecoderChoice::Text => "Text",
DecoderChoice::Hex => "Hex",
DecoderChoice::Plot => "Plot",
DecoderChoice::MixedTextPlot => "MixedTextPlot",
})
.show_ui(ui, |ui| {
if ui.selectable_label(false, "Text").clicked() {
pipeline.decoder = DecoderChoice::Text;
}
if ui.selectable_label(false, "Hex").clicked() {
pipeline.decoder = DecoderChoice::Hex;
}
if ui.selectable_label(false, "Plot").clicked() {
if matches!(pipeline.framer, FramerChoice::Line) {
pipeline.framer = FramerChoice::Fixed(256);
}
pipeline.decoder = DecoderChoice::Plot;
}
if ui.selectable_label(false, "MixedTextPlot").clicked() {
pipeline.framer = FramerChoice::MixedTextPlot;
pipeline.decoder = DecoderChoice::MixedTextPlot;
}
});
if ui.button("").clicked() {
remove = Some(i);
}
});
if matches!(pipeline.decoder, DecoderChoice::Plot) {
ui.horizontal(|ui| {
ui.label("Plot channels:");
ui.add(DragValue::new(&mut pipeline.plot_channels).range(1..=32));
});
ui.horizontal(|ui| {
ui.label("Plot format:");
ComboBox::from_id_salt(format!("plot_format{}", i))
.selected_text(match pipeline.plot_format {
PlotFormat::Interleaved => "Interleaved",
PlotFormat::Block => "Block",
PlotFormat::XY => "XY",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut pipeline.plot_format,
PlotFormat::Interleaved,
"Interleaved",
);
ui.selectable_value(
&mut pipeline.plot_format,
PlotFormat::Block,
"Block",
);
ui.selectable_value(
&mut pipeline.plot_format,
PlotFormat::XY,
"XY",
);
});
});
if matches!(pipeline.plot_format, PlotFormat::XY) {
pipeline.plot_channels = 2;
}
match &pipeline.framer {
FramerChoice::Line => {
let message = "Plot decoder requires Fixed framer for raw binary samples";
ui.colored_label(egui::Color32::RED, message);
validation_errors
.push(format!("pipeline '{}': {message}", pipeline.name));
}
FramerChoice::Fixed(n)
if *n % (pipeline.plot_channels.max(1) * 4) != 0 =>
{
let message = format!(
"Plot decoder currently expects interleaved f32 data, so frame length must be a multiple of {} bytes",
pipeline.plot_channels.max(1) * 4
);
ui.colored_label(egui::Color32::RED, &message);
validation_errors
.push(format!("pipeline '{}': {message}", pipeline.name));
}
FramerChoice::MixedTextPlot => {
let message = "Raw Plot decoder cannot be used with MixedTextPlot framer";
ui.colored_label(egui::Color32::RED, message);
validation_errors
.push(format!("pipeline '{}': {message}", pipeline.name));
}
FramerChoice::Fixed(_) => {}
}
if matches!(pipeline.plot_format, PlotFormat::XY) && pipeline.plot_channels != 2
{
let message = "XY plot requires exactly 2 channels";
ui.colored_label(egui::Color32::RED, message);
validation_errors.push(format!(
"pipeline '{}': {message}",
pipeline.name
));
}
}
if matches!(pipeline.decoder, DecoderChoice::Text) {
ui.horizontal(|ui| {
ui.label("Text encoding:");
render_text_encoding_combo(ui, &mut pipeline.text_encoding, i);
});
}
if matches!(pipeline.decoder, DecoderChoice::MixedTextPlot) {
ui.horizontal(|ui| {
ui.label("Mixed text encoding:");
render_text_encoding_combo(ui, &mut pipeline.text_encoding, i);
});
ui.small("Plot sample type, endian, format, and channels come from the mixed packet header.");
}
if matches!(pipeline.decoder, DecoderChoice::MixedTextPlot)
&& !matches!(pipeline.framer, FramerChoice::MixedTextPlot)
{
let message = "MixedTextPlot decoder requires MixedTextPlot framer";
ui.colored_label(egui::Color32::RED, message);
validation_errors.push(format!("pipeline '{}': {message}", pipeline.name));
}
});
}
if let Some(i) = remove {
form.pipelines.remove(i);
}
if ui.button("+ Add Pipeline").clicked() {
form.pipelines.push(PipelineForm {
name: format!("p{}", form.pipelines.len()),
framer: FramerChoice::Line,
decoder: DecoderChoice::Text,
text_encoding: TextEncoding::Utf8,
plot_channels: 1,
plot_format: PlotFormat::Interleaved,
});
}
ui.separator();
ui.horizontal(|ui| {
ui.label("History:");
ui.add(DragValue::new(&mut form.history).range(100..=1_000_000));
});
ui.checkbox(&mut form.auto_reconnect, "Auto reconnect");
ui.separator();
for error in &validation_errors {
ui.colored_label(egui::Color32::RED, error);
}
if ui.button(submit_label).clicked() {
if validation_errors.is_empty() {
result = Some(form.to_session_config());
}
}
});
result
}
fn render_text_encoding_combo(ui: &mut Ui, encoding: &mut TextEncoding, index: usize) {
ComboBox::from_id_salt(format!("text_encoding{}", index))
.selected_text(match encoding {
TextEncoding::Utf8 => "UTF-8",
TextEncoding::Latin1 => "Latin1",
TextEncoding::Ascii => "ASCII",
})
.show_ui(ui, |ui| {
ui.selectable_value(encoding, TextEncoding::Utf8, "UTF-8");
ui.selectable_value(encoding, TextEncoding::Latin1, "Latin1");
ui.selectable_value(encoding, TextEncoding::Ascii, "ASCII");
});
}

View File

@@ -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));
}
});
}

View File

@@ -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));
}
});
}

View File

@@ -1,51 +0,0 @@
use crate::app::ConnectionStatus;
use egui::{Color32, RichText, Ui};
pub fn render(
ui: &mut Ui,
sessions: &[(u64, ConnectionStatus)], // (id, 连接状态)
active: &mut usize, // 当前选中的会话索引
on_new: &mut bool, // 点击了"新建"
on_delete: &mut Option<usize>, // 点击了"删除"
) {
ui.heading("Sessions");
// 新建按钮 — 绿色文字
if ui
.button(RichText::new("+ New Session").color(Color32::GREEN))
.clicked()
{
*on_new = true;
}
ui.separator();
// 会话列表
for (i, (id, status)) in sessions.iter().enumerate() {
let (color, icon) = match status {
ConnectionStatus::Connected => (Color32::GREEN, ""),
ConnectionStatus::Disconnected => (Color32::GRAY, ""),
ConnectionStatus::Connecting => (Color32::YELLOW, ""),
ConnectionStatus::Error(_) => (Color32::RED, ""),
};
// selectable_label: 点击高亮clicked() 判断是否被点击
let label = format!("{} Session {}", icon, id);
if ui
.selectable_label(*active == i, RichText::new(label).color(color))
.clicked()
{
*active = i;
}
}
ui.separator();
// 删除按钮 — 红色文字
if ui
.button(RichText::new("Delete").color(Color32::RED))
.clicked()
{
*on_delete = Some(*active);
}
}

View File

@@ -1 +0,0 @@
fn main() {}

16
examples/drone_plot.lua Normal file
View File

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

33
examples/drone_text.lua Normal file
View File

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

43
tests/lua_line_framer.lua Normal file
View File

@@ -0,0 +1,43 @@
local buffer = ""
return {
feed = function(bytes)
buffer = buffer .. bytes
local frames = {}
while true do
local i = buffer:find("\n", 1, true)
if not i then
break
end
local frame = buffer:sub(1, i - 1)
if frame:sub(-1) == "\r" then
frame = frame:sub(1, -2)
end
frames[#frames + 1] = frame
buffer = buffer:sub(i + 1)
end
return frames
end,
flush = function()
if #buffer == 0 then
return nil
end
local frame = buffer
buffer = ""
return frame
end,
reset = function()
buffer = ""
end,
pending_len = function()
return #buffer
end,
}

View File

@@ -0,0 +1,12 @@
return {
decode = function(frame)
if #frame == 0 then
return nil
end
return {
kind = "text",
data = frame,
}
end,
}

102
tools/test_drone.py Normal file
View File

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

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""TCP server that sends plot waveform frames for the current xserial GUI."""
"""TCP server that sends plot waveform frames or text lines for pipeview GUI."""
import argparse
import math
@@ -10,6 +10,11 @@ import time
PLOT_ESCAPE = 0x1E
PLOT_MARKER = ord("P")
DISCONNECT_WINERRORS = {
10053, # Software caused connection abort
10054, # Connection reset by peer
10058, # Socket shutdown race on Windows
}
def build_frame(
@@ -98,44 +103,56 @@ def build_mixed_plot_frame(
)
return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00"
def main():
p = argparse.ArgumentParser(description="xserial plot test server")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8080)
p.add_argument("--channels", type=int, default=1)
p.add_argument(
def is_disconnect_error(err: OSError) -> bool:
return isinstance(
err,
(
BrokenPipeError,
ConnectionAbortedError,
ConnectionResetError,
),
) or getattr(err, "winerror", None) in DISCONNECT_WINERRORS
def main() -> None:
parser = argparse.ArgumentParser(description="pipeview plot test server")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--channels", type=int, default=1)
parser.add_argument(
"--format",
choices=("interleaved", "xy"),
default="interleaved",
help="plot format (default: interleaved)",
)
p.add_argument(
parser.add_argument(
"--rate",
type=float,
default=1024,
help="samples/sec per channel (default: 1024)",
)
p.add_argument("--freq", type=float, default=1.0, help="sine Hz")
p.add_argument("--amp", type=float, default=100)
p.add_argument(
parser.add_argument("--freq", type=float, default=1.0, help="sine Hz")
parser.add_argument("--amp", type=float, default=100)
parser.add_argument(
"--text-interval",
type=float,
default=1.0,
help="seconds between status text messages (default: 1.0)",
)
p.add_argument(
parser.add_argument(
"--framelen",
type=int,
default=0,
help="fixed frame bytes (0=one sample per channel per frame)",
)
p.add_argument(
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 = p.parse_args()
args = parser.parse_args()
sample_size = 4 # f32
@@ -176,17 +193,36 @@ def main():
values.append(args.amp * math.sin(2 * math.pi * args.freq * t + phase))
return values
print(f"Listening {args.host}:{args.port} — Ctrl+C to stop")
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")
@@ -213,6 +249,25 @@ def main():
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,
@@ -235,16 +290,13 @@ def main():
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"时间={now - started_at:7.3f}s "
f"样本={sample_index:7d} {joined}\n"
)
conn.sendall(line.encode("utf-8"))
text_count += 1
next_text_at += args.text_interval
@@ -255,8 +307,16 @@ def main():
wait = frame_count * frame_interval - elapsed
if wait > 0:
time.sleep(wait)
except (BrokenPipeError, ConnectionResetError):
print(f"[conn -] {addr} ({frame_count} plot frames, {text_count} text lines)")
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)"
)
finally:
conn.close()
finally:
@@ -274,5 +334,6 @@ def main():
stop_event.set()
stream_thread.join(timeout=1.0)
if __name__ == "__main__":
main()

540
tools/test_plot_serial.c Normal file
View File

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

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

237
tools/xs_mixed_plot.h Normal file
View File

@@ -0,0 +1,237 @@
#ifndef XS_MIXED_PLOT_H
#define XS_MIXED_PLOT_H
#include <stddef.h>
#include <stdint.h>
/* MixedTextPlot plot frame:
* 0x1E 'P' + COBS(packet) + 0x00
*
* Plot packet:
* "XP" +
* version:u8 +
* format:u8 +
* sample_type:u8 +
* endian:u8 +
* channels:u8 +
* samples_per_channel:u16le +
* payload_len:u32le +
* payload
*/
#define XS_MIXED_PLOT_ESCAPE 0x1E
#define XS_MIXED_PLOT_MARKER 0x50 /* 'P' */
#define XS_PLOT_MAGIC_0 0x58 /* 'X' */
#define XS_PLOT_MAGIC_1 0x50 /* 'P' */
#define XS_PLOT_VERSION 1
#define XS_PLOT_FORMAT_INTERLEAVED 0
#define XS_PLOT_FORMAT_BLOCK 1
#define XS_PLOT_FORMAT_XY 2
#define XS_SAMPLE_TYPE_F32 8
#define XS_ENDIAN_LITTLE 0
#define XS_PLOT_HEADER_SIZE 13
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
XS_OK = 0,
XS_ERR_ARG = -1,
XS_ERR_BUF = -2
} xs_status_t;
static size_t xs_cobs_max_encoded_size(size_t input_len) {
return input_len + (input_len / 254u) + 1u;
}
static void xs_write_u16_le(uint8_t *dst, uint16_t v) {
dst[0] = (uint8_t)(v & 0xFFu);
dst[1] = (uint8_t)((v >> 8) & 0xFFu);
}
static void xs_write_u32_le(uint8_t *dst, uint32_t v) {
dst[0] = (uint8_t)(v & 0xFFu);
dst[1] = (uint8_t)((v >> 8) & 0xFFu);
dst[2] = (uint8_t)((v >> 16) & 0xFFu);
dst[3] = (uint8_t)((v >> 24) & 0xFFu);
}
static void xs_write_f32_le(uint8_t *dst, float v) {
union {
float f;
uint8_t b[4];
} u;
u.f = v;
#if defined(__BYTE_ORDER__) && (__BYTE_ORDER__ == __ORDER_BIG_ENDIAN__)
dst[0] = u.b[3];
dst[1] = u.b[2];
dst[2] = u.b[1];
dst[3] = u.b[0];
#else
dst[0] = u.b[0];
dst[1] = u.b[1];
dst[2] = u.b[2];
dst[3] = u.b[3];
#endif
}
/* output must have at least xs_cobs_max_encoded_size(input_len) bytes */
static size_t xs_cobs_encode(const uint8_t *input, size_t input_len, uint8_t *output) {
size_t read_index = 0;
size_t write_index = 1;
size_t code_index = 0;
uint8_t code = 1;
while (read_index < input_len) {
if (input[read_index] == 0) {
output[code_index] = code;
code = 1;
code_index = write_index++;
read_index++;
} else {
output[write_index++] = input[read_index++];
code++;
if (code == 0xFFu) {
output[code_index] = code;
code = 1;
code_index = write_index++;
}
}
}
output[code_index] = code;
return write_index;
}
static xs_status_t xs_plot_build_packet_f32(
const float *samples,
uint8_t channels,
uint16_t samples_per_channel,
uint8_t plot_format,
uint8_t *out,
size_t out_cap,
size_t *out_len
) {
size_t i;
uint32_t value_count;
uint32_t payload_len;
size_t packet_len;
uint8_t *p;
if (!samples || !out || !out_len) {
return XS_ERR_ARG;
}
if (channels == 0) {
return XS_ERR_ARG;
}
if (plot_format == XS_PLOT_FORMAT_XY && channels != 2) {
return XS_ERR_ARG;
}
if (plot_format > XS_PLOT_FORMAT_XY) {
return XS_ERR_ARG;
}
value_count = (uint32_t)channels * (uint32_t)samples_per_channel;
payload_len = value_count * 4u;
packet_len = XS_PLOT_HEADER_SIZE + (size_t)payload_len;
if (out_cap < packet_len) {
return XS_ERR_BUF;
}
out[0] = XS_PLOT_MAGIC_0;
out[1] = XS_PLOT_MAGIC_1;
out[2] = XS_PLOT_VERSION;
out[3] = plot_format;
out[4] = XS_SAMPLE_TYPE_F32;
out[5] = XS_ENDIAN_LITTLE;
out[6] = channels;
xs_write_u16_le(&out[7], samples_per_channel);
xs_write_u32_le(&out[9], payload_len);
p = &out[13];
for (i = 0; i < (size_t)value_count; ++i) {
xs_write_f32_le(p, samples[i]);
p += 4;
}
*out_len = packet_len;
return XS_OK;
}
static xs_status_t xs_mixed_build_plot_frame_from_packet(
const uint8_t *packet,
size_t packet_len,
uint8_t *out,
size_t out_cap,
size_t *out_len
) {
size_t encoded_len;
if (!packet || !out || !out_len) {
return XS_ERR_ARG;
}
if (out_cap < 2u + xs_cobs_max_encoded_size(packet_len) + 1u) {
return XS_ERR_BUF;
}
out[0] = XS_MIXED_PLOT_ESCAPE;
out[1] = XS_MIXED_PLOT_MARKER;
encoded_len = xs_cobs_encode(packet, packet_len, &out[2]);
out[2 + encoded_len] = 0x00;
*out_len = 2u + encoded_len + 1u;
return XS_OK;
}
static xs_status_t xs_mixed_build_plot_frame_f32(
const float *samples,
uint8_t channels,
uint16_t samples_per_channel,
uint8_t plot_format,
uint8_t *packet_buf,
size_t packet_buf_cap,
uint8_t *frame_buf,
size_t frame_buf_cap,
size_t *frame_len
) {
xs_status_t rc;
size_t packet_len = 0;
if (!packet_buf || !frame_buf || !frame_len) {
return XS_ERR_ARG;
}
rc = xs_plot_build_packet_f32(
samples,
channels,
samples_per_channel,
plot_format,
packet_buf,
packet_buf_cap,
&packet_len
);
if (rc != XS_OK) {
return rc;
}
return xs_mixed_build_plot_frame_from_packet(
packet_buf,
packet_len,
frame_buf,
frame_buf_cap,
frame_len
);
}
#ifdef __cplusplus
}
#endif
#endif /* XS_MIXED_PLOT_H */