Compare commits
18 Commits
594637ec50
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e600418da8 | |||
| e939b16d2d | |||
| a2c7c0fa71 | |||
| ab1c5882d1 | |||
| 2efbc9c284 | |||
| b3acb02d52 | |||
| afb8ca509a | |||
| 9cdbd5a892 | |||
| 8207b5e883 | |||
| 202b8261c2 | |||
| dc8f2eb8a4 | |||
| edec8113fb | |||
| 1fec5f8941 | |||
| 09bee86a08 | |||
| 18bdd2652a | |||
| 9b826d79da | |||
| d489bc0831 | |||
| 882b7f391f |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1 +1,8 @@
|
|||||||
/target
|
/target
|
||||||
|
|
||||||
|
# Compiled binaries in tools/
|
||||||
|
tools/test_plot_c
|
||||||
|
tools/test_text_c
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
|||||||
133
Cargo.lock
generated
133
Cargo.lock
generated
@@ -3262,6 +3262,74 @@ dependencies = [
|
|||||||
"futures-io",
|
"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]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.33"
|
version = "0.3.33"
|
||||||
@@ -5750,71 +5818,6 @@ version = "0.8.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
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]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/xserial-core",
|
"crates/pipeview-core",
|
||||||
"crates/xserial-client",
|
"crates/pipeview-client",
|
||||||
"crates/xserial-tui",
|
"crates/pipeview-tui",
|
||||||
"crates/xserial-gui",
|
"crates/pipeview-gui",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|||||||
394
README.md
Normal file
394
README.md
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
# pipeview
|
||||||
|
|
||||||
|
**跨平台串口 / TCP / UDP 数据观测工具** — 可配置分帧、协议解码、多会话管理、实时波形绘图,单二进制零依赖。
|
||||||
|
|
||||||
|
基于 Rust + [egui](https://github.com/emilk/egui) 构建,支持 Lua 脚本扩展。
|
||||||
|
|
||||||
|
[English](README_en.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [功能特性](#功能特性)
|
||||||
|
- [快速开始](#快速开始)
|
||||||
|
- [完整示例:无人机遥测解析](#完整示例无人机遥测解析)
|
||||||
|
- [Lua 脚本开发指南](#lua-脚本开发指南)
|
||||||
|
- [架构](#架构)
|
||||||
|
- [配置与持久化](#配置与持久化)
|
||||||
|
- [开发工具](#开发工具)
|
||||||
|
- [技术栈](#技术栈)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 传输层
|
||||||
|
|
||||||
|
| 类型 | 说明 | 配置项 |
|
||||||
|
|------|------|--------|
|
||||||
|
| **Serial** | 串口通信 | 端口名、波特率 (300–12M)、数据位 (5/6/7/8)、校验位 (None/Odd/Even)、停止位 (1/2)、流控 (None/Software/Hardware)、DTR/RTS 控制 |
|
||||||
|
| **TCP** | TCP 客户端 | 目标地址 `host:port` |
|
||||||
|
| **UDP** | UDP 通信 | 绑定地址 `host:port`,可选远端地址 |
|
||||||
|
|
||||||
|
### 分帧器(Framer)
|
||||||
|
|
||||||
|
将原始字节流切分为独立帧。每个 Session 可配置**多条独立管线**,同一字节流并行送入多个 framer→decoder 链路。
|
||||||
|
|
||||||
|
| 分帧器 | 说明 | 配置参数 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| **Line** | 按 `\n` 分割文本行 | `strip_cr`(去除 `\r`)、`max_line_len`(最大行长度) |
|
||||||
|
| **Fixed** | 固定字节数为一帧 | `frame_len`(帧长度) |
|
||||||
|
| **Length** | 长度前缀协议 | `len_bytes` (1/2/4/8)、`endian` (大小端)、`length_includes_self`、`max_payload` |
|
||||||
|
| **COBS** | [Consistent Overhead Byte Stuffing](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) 编码 | `max_frame`(最大帧长) |
|
||||||
|
| **MixedTextPlot** | 单连接混合文本行 + COBS 编码的 plot 帧 | `strip_cr`、`max_line_len`、`max_plot_frame` |
|
||||||
|
| **Lua** | 用户自定义分帧脚本 | `script_path`(Lua 脚本路径) |
|
||||||
|
|
||||||
|
### 解码器(Decoder)
|
||||||
|
|
||||||
|
将帧数据解析为可展示的内容。
|
||||||
|
|
||||||
|
| 解码器 | 输出类型 | 配置参数 |
|
||||||
|
|--------|----------|----------|
|
||||||
|
| **Text** | 文本 | `encoding`(UTF-8 / Latin1 / ASCII) |
|
||||||
|
| **Hex** | 十六进制 | `uppercase`、`separator`、`bytes_per_group`、`endian` |
|
||||||
|
| **Plot** | 波形数据 | `sample_type` (i8–f64)、`endian`、`channels` (1–64)、`format` (Interleaved / Block / XY) |
|
||||||
|
| **MixedTextPlot** | 混合文本 + 波形 | `encoding` |
|
||||||
|
| **Lua** | 自定义 | `script_path`(Lua 脚本路径) |
|
||||||
|
|
||||||
|
**Plot 采样格式:**
|
||||||
|
|
||||||
|
| 格式 | 说明 | 字节排列 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **Interleaved** | 多通道交叉排列 | `[ch0_s0, ch1_s0, ch2_s0, ch0_s1, ch1_s1, …]` |
|
||||||
|
| **Block** | 按通道分块排列 | `[ch0_s0, ch0_s1, …, ch1_s0, ch1_s1, …]` |
|
||||||
|
| **XY** | 2 通道交替 x/y | `[x0, y0, x1, y1, …]` |
|
||||||
|
|
||||||
|
支持的采样类型:`i8`、`u8`、`i16`、`u16`、`i32`、`u32`、`i64`、`u64`、`f32`、`f64`
|
||||||
|
|
||||||
|
### 视图
|
||||||
|
|
||||||
|
- **文本视图** — 带时间戳、方向标记 (`[IN]`/`[OUT]`)、管线标签的格式化文本流。支持搜索(`Ctrl+F`)、大小写匹配、匹配计数和高亮。
|
||||||
|
- **十六进制视图** — hex dump 与 ASCII 侧栏并排展示,可按分组和大小端解析多字节数值。
|
||||||
|
- **波形视图** — 基于 [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot) 的实时波形。支持自动缩放、框选缩放、坐标轴锁定、跟随最新数据、浮动窗口、通道图例。
|
||||||
|
|
||||||
|
### 会话管理
|
||||||
|
|
||||||
|
- **多会话并发** — 标签页切换,每个 Session 独立配置
|
||||||
|
- **连接控制** — Connect / Disconnect / Reconnect,支持断线自动重连
|
||||||
|
- **运行时重配置** — 修改分帧器/解码器参数无需断开连接
|
||||||
|
- **数据发送** — Text 模式(UTF-8,可选换行符)和 Hex 模式(十六进制字节)。支持 `None` / `LF` / `CR` / `CRLF` 四种行尾
|
||||||
|
- **环形缓冲** — 默认 10,000 条历史记录,可配置 100–1,000,000
|
||||||
|
- **日志到文件** — 每行数据实时写入,1KB 缓冲 + 后台线程,不阻塞 UI
|
||||||
|
- **搜索** — `Ctrl+F` 呼出搜索栏,大小写敏感切换,F3/Shift+F3 前后跳转
|
||||||
|
|
||||||
|
### Lua 脚本扩展
|
||||||
|
|
||||||
|
内置 LuaJIT 运行时:
|
||||||
|
|
||||||
|
- **自定义分帧器** — 实现 `feed(bytes)`、`flush()`、`reset()`、`pending_len()` 四个函数
|
||||||
|
- **自定义解码器** — 实现 `decode(frame)` 函数,返回 text/hex/plot/binary 四种类型
|
||||||
|
- **会话 API** — `pipeview.open()` 创建 Session、`session:on_data()` 事件回调、`session:send()` 发送数据
|
||||||
|
- **工具函数** — `pipeview.list_ports()`、`pipeview.sleep(ms)`、`pipeview.poll(limit)`、`pipeview.log(msg)`
|
||||||
|
|
||||||
|
### 快捷键
|
||||||
|
|
||||||
|
| 快捷键 | 操作 |
|
||||||
|
|--------|------|
|
||||||
|
| `Ctrl+N` | 新建会话 |
|
||||||
|
| `Ctrl+E` | 编辑当前会话 |
|
||||||
|
| `Ctrl+W` | 删除当前会话 |
|
||||||
|
| `Ctrl+F5` | 切换连接 |
|
||||||
|
| `Ctrl+T` / `Ctrl+H` / `Ctrl+P` | 切换到文本 / 十六进制 / 波形视图 |
|
||||||
|
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | 下一个 / 上一个标签页 |
|
||||||
|
| `Ctrl+L` | 清空输出 |
|
||||||
|
| `Ctrl+F` | 搜索 |
|
||||||
|
| `F3` / `Shift+F3` | 上一个 / 下一个匹配 |
|
||||||
|
| `Ctrl+,` | UI 设置 |
|
||||||
|
| `Esc` | 关闭浮层 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 前置条件
|
||||||
|
|
||||||
|
| 平台 | 依赖 |
|
||||||
|
|------|------|
|
||||||
|
| **Linux** | `libudev-dev`(`apt install libudev-dev`) |
|
||||||
|
| **macOS** | 无需额外依赖 |
|
||||||
|
| **Windows** | 无需额外依赖 |
|
||||||
|
| **所有平台** | Rust ≥ 1.85、C 编译器(GCC / Clang / MSVC) |
|
||||||
|
|
||||||
|
### 构建与运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆项目
|
||||||
|
git clone https://github.com/your-org/pipeview.git
|
||||||
|
cd pipeview
|
||||||
|
|
||||||
|
# 编译运行
|
||||||
|
cargo run -p pipeview-gui
|
||||||
|
|
||||||
|
# 运行测试
|
||||||
|
cargo test --workspace # ~308 个测试
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发布构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build -p pipeview-gui --release
|
||||||
|
# 二进制位于 target/release/pipeview-gui (Linux/macOS)
|
||||||
|
# 或 target/release/pipeview-gui.exe (Windows)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整示例:无人机遥测解析
|
||||||
|
|
||||||
|
以 Betaflight/INAV 飞控遥测为例,数据格式为:
|
||||||
|
|
||||||
|
```
|
||||||
|
AHRS q:1.0000,0.0998,0.0499,0.0200|YPR:8.87,4.44,2.96|Gyro:14.78,8.87,5.92|RC:1559,1544,1500,1519|M:1612,1588,1603,1597|L:0 F:1 C:0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 1:启动测试数据源
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/test_drone.py --rate 10 --port 8091
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
Listening on 127.0.0.1:8091, waiting for connections...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:配置 Session
|
||||||
|
|
||||||
|
在 pipeview-gui 中创建 Session:
|
||||||
|
|
||||||
|
| 配置项 | 值 |
|
||||||
|
|--------|-----|
|
||||||
|
| Transport | `TCP 127.0.0.1:8091` |
|
||||||
|
| Pipeline 1 | Framer: `Line` → Decoder: `Lua` → 选择 `examples/drone_text.lua` |
|
||||||
|
| Pipeline 2 | Framer: `Line` → Decoder: `Lua` → 选择 `examples/drone_plot.lua` |
|
||||||
|
|
||||||
|
### 步骤 3:查看结果
|
||||||
|
|
||||||
|
- **文本视图** — 显示格式化的传感器数据
|
||||||
|
- **波形视图** — 显示 Gyro 三轴实时曲线 (gz/gy/gx)
|
||||||
|
|
||||||
|
`examples/drone_plot.lua` 的核心逻辑:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
local gz, gy, gx = frame:match("Gyro:([%d%.%-]+),([%d%.%-]+),([%d%.%-]+)")
|
||||||
|
return {
|
||||||
|
kind = "plot",
|
||||||
|
channels = { { tonumber(gz) }, { tonumber(gy) }, { tonumber(gx) } },
|
||||||
|
sample_type = "F64",
|
||||||
|
format = "Block",
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lua 脚本开发指南
|
||||||
|
|
||||||
|
### 分帧器 API
|
||||||
|
|
||||||
|
分帧器将原始字节流切分为帧,Lua 脚本必须返回包含以下函数的 table:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
-- 输入新到达的字节,返回帧数组(Lua strings)
|
||||||
|
feed = function(bytes)
|
||||||
|
-- bytes: Lua string(原始字节)
|
||||||
|
-- 返回: { frame1, frame2, ... } 或 nil
|
||||||
|
end,
|
||||||
|
|
||||||
|
-- 刷新缓冲区中残留的数据
|
||||||
|
flush = function()
|
||||||
|
-- 返回: 最后一帧(Lua string)或 nil
|
||||||
|
end,
|
||||||
|
|
||||||
|
-- 重置内部状态
|
||||||
|
reset = function()
|
||||||
|
end,
|
||||||
|
|
||||||
|
-- 返回缓冲区中待处理的字节数
|
||||||
|
pending_len = function()
|
||||||
|
-- 返回: number
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
参考实现:`tests/lua_line_framer.lua`(按 `\n` 分割的行分帧器)
|
||||||
|
|
||||||
|
### 解码器 API
|
||||||
|
|
||||||
|
解码器将帧解析为结构化数据,Lua 脚本必须返回包含 `decode` 函数的 table:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
-- frame: Lua string(来自分帧器的一帧)
|
||||||
|
-- 返回 nil → 跳过此帧
|
||||||
|
-- 返回 string → 自动视为 Text
|
||||||
|
-- 返回 table → 必须包含 kind 字段
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回值格式:**
|
||||||
|
|
||||||
|
| `kind` | 必需字段 | 可选字段 | 用途 |
|
||||||
|
|--------|----------|----------|------|
|
||||||
|
| `"text"` | `data: string` | — | 文本视图 |
|
||||||
|
| `"hex"` | `data: string` | — | 十六进制视图 |
|
||||||
|
| `"binary"` | `data: string` | — | 原始二进制 |
|
||||||
|
| `"plot"` | `channels: {{number,…},…}` | `sample_type`、`format` | 波形视图 |
|
||||||
|
|
||||||
|
**Plot 返回值示例:**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
kind = "plot",
|
||||||
|
channels = {
|
||||||
|
{ 1.0, 2.0, 3.0 }, -- channel 0
|
||||||
|
{ 4.0, 5.0, 6.0 }, -- channel 1
|
||||||
|
},
|
||||||
|
sample_type = "F64", -- 默认 F64,可选 I8/U8/I16/U16/I32/U32/I64/U64/F32
|
||||||
|
format = "Block", -- 默认 Interleaved,可选 Block/XY
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
更多示例见 `examples/` 目录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ pipeview-gui (egui) pipeview-tui (ratatui) │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ pipeview-client │
|
||||||
|
│ SessionManager · Session · Config · History │
|
||||||
|
│ Lua Runtime (mlua / LuaJIT) │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ pipeview-core │
|
||||||
|
│ Transport ──▶ Frame ──▶ Protocol ──▶ Pipeline │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Crate | 类型 | 职责 |
|
||||||
|
|-------|------|------|
|
||||||
|
| `pipeview-core` | library | 传输层(Serial/TCP/UDP)、分帧器(Line/Fixed/Length/COBS/Mixed/Lua)、协议解码器(Text/Hex/Plot)、MultiPipeline |
|
||||||
|
| `pipeview-client` | library | Session 生命周期管理、SessionManager、事件广播(tokio broadcast)、RingBuffer 历史、Lua 运行时及会话 API |
|
||||||
|
| `pipeview-gui` | binary | egui 桌面应用,包含 sidebar/config/console/text/hex/plot 面板、键盘快捷键、字体管理、性能分析 |
|
||||||
|
| `pipeview-tui` | binary | ratatui 终端应用(功能未对齐 GUI,仍在开发中) |
|
||||||
|
|
||||||
|
**依赖方向:** `core ← client ← {gui, tui}`
|
||||||
|
|
||||||
|
**数据流:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Transport] → read bytes → [MultiPipeline]
|
||||||
|
→ Pipeline 1: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
|
||||||
|
→ Pipeline 2: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
|
||||||
|
→ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
每条管线独立分帧、解码,互不干扰。只有成功解码的管线产生输出。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置与持久化
|
||||||
|
|
||||||
|
GUI 状态自动保存,路径遵循各平台规范:
|
||||||
|
|
||||||
|
| 平台 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| Linux | `$XDG_CONFIG_HOME/pipeview/gui-state.json` 或 `~/.config/pipeview/gui-state.json` |
|
||||||
|
| macOS | `~/Library/Application Support/pipeview/gui-state.json` |
|
||||||
|
| Windows | `%APPDATA%\pipeview\gui-state.json` |
|
||||||
|
|
||||||
|
持久化的内容包括:Session 配置(传输参数、管线设置)、日志开关及路径、活动标签页、显示选项。
|
||||||
|
|
||||||
|
日志文件默认保存在配置目录的 `logs/` 子目录下,文件命名格式为 `session_{id}_{timestamp}.log`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发工具
|
||||||
|
|
||||||
|
### 测试数据生成器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Plot 波形测试数据
|
||||||
|
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
|
||||||
|
python tools/test_plot.py --wire-format mixed --format xy --channels 2
|
||||||
|
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
|
||||||
|
|
||||||
|
# 无人机遥测测试数据
|
||||||
|
python tools/test_drone.py --rate 10 --port 8092
|
||||||
|
```
|
||||||
|
|
||||||
|
### 性能分析
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=pipeview_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p pipeview-gui
|
||||||
|
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p pipeview-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
输出每帧的耗时、事件 drain 耗时、text/hex/plot 渲染耗时、plot 点数统计。
|
||||||
|
|
||||||
|
### 日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=info cargo run -p pipeview-gui # 应用日志
|
||||||
|
RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui # 详细日志
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 `tracing-subscriber` + `RUST_LOG` 环境变量控制。
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/
|
||||||
|
pipeview-core/ # 传输、分帧、协议
|
||||||
|
pipeview-client/ # 会话管理、Lua 运行时
|
||||||
|
pipeview-gui/ # egui 桌面应用
|
||||||
|
pipeview-tui/ # ratatui 终端应用
|
||||||
|
examples/ # Lua 脚本示例
|
||||||
|
drone_plot.lua # 飞控遥测波形解码器
|
||||||
|
drone_text.lua # 飞控遥测文本解码器
|
||||||
|
tests/ # Lua 测试 fixture
|
||||||
|
tools/ # 开发辅助工具
|
||||||
|
test_plot.py # 波形测试数据生成器
|
||||||
|
test_drone.py # 飞控测试数据生成器
|
||||||
|
test_plot_serial.c # C 串口波形测试客户端
|
||||||
|
test_text_serial.c # C 串口文本测试客户端
|
||||||
|
xs_mixed_plot.h # MixedTextPlot 协议参考头文件
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **运行时**:tokio (multi-thread)
|
||||||
|
- **串口**:`tokio-serial` + `serialport`
|
||||||
|
- **GUI**:egui + egui_plot
|
||||||
|
- **Lua**:mlua 0.11, LuaJIT (vendored 编译), async/serde/send
|
||||||
|
- **事件分发**:`tokio::sync::broadcast`(多订阅者)
|
||||||
|
- **零 feature flags**、零 build script、零条件编译
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
378
README_en.md
Normal file
378
README_en.md
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
# pipeview
|
||||||
|
|
||||||
|
**Cross-platform serial / TCP / UDP data inspection tool** with configurable framing, protocol decoding, multi-session management, and real-time waveform plotting — all in a single binary.
|
||||||
|
|
||||||
|
Built with Rust + [egui](https://github.com/emilk/egui), scriptable with Lua.
|
||||||
|
|
||||||
|
[中文文档](README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Features](#features)
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [Walkthrough: Drone Telemetry](#walkthrough-drone-telemetry)
|
||||||
|
- [Lua Scripting Guide](#lua-scripting-guide)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Configuration & Persistence](#configuration--persistence)
|
||||||
|
- [Development Tools](#development-tools)
|
||||||
|
- [Tech Stack](#tech-stack)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
| Type | Description | Options |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| **Serial** | Serial port communication | Port name, baud rate (300–12M), data bits (5/6/7/8), parity (None/Odd/Even), stop bits (1/2), flow control (None/Software/Hardware), DTR/RTS control |
|
||||||
|
| **TCP** | TCP client | Target address `host:port` |
|
||||||
|
| **UDP** | UDP communication | Bind address `host:port`, optional remote address |
|
||||||
|
|
||||||
|
### Framers
|
||||||
|
|
||||||
|
Framers split raw byte streams into discrete frames. Each session supports **multiple independent pipelines** — the same byte stream feeds multiple framer→decoder chains in parallel.
|
||||||
|
|
||||||
|
| Framer | Description | Parameters |
|
||||||
|
|--------|-------------|------------|
|
||||||
|
| **Line** | Split by `\n` | `strip_cr`, `max_line_len` |
|
||||||
|
| **Fixed** | Fixed-length frames | `frame_len` |
|
||||||
|
| **Length** | Length-prefixed protocol | `len_bytes` (1/2/4/8), `endian`, `length_includes_self`, `max_payload` |
|
||||||
|
| **COBS** | [Consistent Overhead Byte Stuffing](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) | `max_frame` |
|
||||||
|
| **MixedTextPlot** | Hybrid text lines + COBS-encoded plot frames on one stream | `strip_cr`, `max_line_len`, `max_plot_frame` |
|
||||||
|
| **Lua** | User-defined framer script | `script_path` |
|
||||||
|
|
||||||
|
### Decoders
|
||||||
|
|
||||||
|
Decoders parse framed data into displayable content.
|
||||||
|
|
||||||
|
| Decoder | Output | Parameters |
|
||||||
|
|---------|--------|------------|
|
||||||
|
| **Text** | Text | `encoding` (UTF-8 / Latin1 / ASCII) |
|
||||||
|
| **Hex** | Hexadecimal | `uppercase`, `separator`, `bytes_per_group`, `endian` |
|
||||||
|
| **Plot** | Waveform | `sample_type` (i8–f64), `endian`, `channels` (1–64), `format` (Interleaved / Block / XY) |
|
||||||
|
| **MixedTextPlot** | Hybrid text + waveform | `encoding` |
|
||||||
|
| **Lua** | Custom | `script_path` |
|
||||||
|
|
||||||
|
**Plot sample formats:**
|
||||||
|
|
||||||
|
| Format | Description | Byte layout |
|
||||||
|
|--------|-------------|-------------|
|
||||||
|
| **Interleaved** | Channels interleaved | `[ch0_s0, ch1_s0, ch2_s0, ch0_s1, ch1_s1, …]` |
|
||||||
|
| **Block** | Channel-contiguous blocks | `[ch0_s0, ch0_s1, …, ch1_s0, ch1_s1, …]` |
|
||||||
|
| **XY** | 2-channel x/y pairs | `[x0, y0, x1, y1, …]` |
|
||||||
|
|
||||||
|
Supported sample types: `i8`, `u8`, `i16`, `u16`, `i32`, `u32`, `i64`, `u64`, `f32`, `f64`
|
||||||
|
|
||||||
|
### Views
|
||||||
|
|
||||||
|
- **Text View** — formatted text with timestamps, direction markers (`[IN]`/`[OUT]`), and pipeline labels. Built-in search (`Ctrl+F`) with case-sensitive toggle, match counter, and highlighting.
|
||||||
|
- **Hex View** — hex dump alongside ASCII sidebar. Configurable byte grouping and endianness.
|
||||||
|
- **Plot View** — real-time waveforms via [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot). Auto-fit, box zoom, axis locking, follow-latest, detached window, channel legend.
|
||||||
|
|
||||||
|
### Sessions
|
||||||
|
|
||||||
|
- **Multi-session** — tabbed interface, each session independently configured
|
||||||
|
- **Connection control** — Connect / Disconnect / Reconnect with auto-reconnect on drop
|
||||||
|
- **Live reconfiguration** — change framer/decoder settings without restarting
|
||||||
|
- **Data sending** — Text mode (UTF-8, configurable line endings) and Hex mode (raw bytes). Four line ending options: None / LF / CR / CRLF
|
||||||
|
- **Ring buffer** — default 10,000 entries, configurable 100–1,000,000
|
||||||
|
- **Log to file** — background writer thread with 1 KB buffer, non-blocking
|
||||||
|
- **Search** — `Ctrl+F` search bar, case-sensitive toggle, F3/Shift+F3 navigation
|
||||||
|
|
||||||
|
### Lua Scripting
|
||||||
|
|
||||||
|
Built-in LuaJIT runtime:
|
||||||
|
|
||||||
|
- **Custom framers** — implement `feed(bytes)`, `flush()`, `reset()`, `pending_len()`
|
||||||
|
- **Custom decoders** — implement `decode(frame)`, return text/hex/plot/binary
|
||||||
|
- **Session API** — `pipeview.open()` to create sessions, `session:on_data()` for event callbacks, `session:send()` to transmit data
|
||||||
|
- **Utilities** — `pipeview.list_ports()`, `pipeview.sleep(ms)`, `pipeview.poll(limit)`, `pipeview.log(msg)`
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| `Ctrl+N` | New session |
|
||||||
|
| `Ctrl+E` | Edit current session |
|
||||||
|
| `Ctrl+W` | Delete current session |
|
||||||
|
| `Ctrl+F5` | Toggle connection |
|
||||||
|
| `Ctrl+T` / `Ctrl+H` / `Ctrl+P` | Switch to Text / Hex / Plot view |
|
||||||
|
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Next / Previous tab |
|
||||||
|
| `Ctrl+L` | Clear output |
|
||||||
|
| `Ctrl+F` | Search |
|
||||||
|
| `F3` / `Shift+F3` | Next / Previous match |
|
||||||
|
| `Ctrl+,` | UI settings |
|
||||||
|
| `Esc` | Close overlay |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
| Platform | Dependencies |
|
||||||
|
|----------|-------------|
|
||||||
|
| **Linux** | `libudev-dev` (`apt install libudev-dev`) |
|
||||||
|
| **macOS** | None |
|
||||||
|
| **Windows** | None |
|
||||||
|
| **All** | Rust ≥ 1.85, C compiler (GCC / Clang / MSVC) |
|
||||||
|
|
||||||
|
### Build & Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your-org/pipeview.git
|
||||||
|
cd pipeview
|
||||||
|
|
||||||
|
cargo run -p pipeview-gui
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cargo test --workspace # ~308 tests
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build -p pipeview-gui --release
|
||||||
|
# Binary: target/release/pipeview-gui (Linux/macOS)
|
||||||
|
# target/release/pipeview-gui.exe (Windows)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Walkthrough: Drone Telemetry
|
||||||
|
|
||||||
|
Parse Betaflight/INAV flight controller telemetry in the format:
|
||||||
|
|
||||||
|
```
|
||||||
|
AHRS q:1.0000,0.0998,0.0499,0.0200|YPR:8.87,4.44,2.96|Gyro:14.78,8.87,5.92|RC:1559,1544,1500,1519|M:1612,1588,1603,1597|L:0 F:1 C:0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1: Start the data source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/test_drone.py --rate 10 --port 8091
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Configure the session
|
||||||
|
|
||||||
|
In pipeview-gui, create a session with two pipelines:
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Transport | `TCP 127.0.0.1:8091` |
|
||||||
|
| Pipeline 1 | Framer: `Line` → Decoder: `Lua` → `examples/drone_text.lua` |
|
||||||
|
| Pipeline 2 | Framer: `Line` → Decoder: `Lua` → `examples/drone_plot.lua` |
|
||||||
|
|
||||||
|
### Step 3: View the results
|
||||||
|
|
||||||
|
- **Text View** — formatted sensor readouts
|
||||||
|
- **Plot View** — real-time gyroscope curves (gz/gy/gx)
|
||||||
|
|
||||||
|
The decoder in `examples/drone_plot.lua`:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
local gz, gy, gx = frame:match("Gyro:([%d%.%-]+),([%d%.%-]+),([%d%.%-]+)")
|
||||||
|
return {
|
||||||
|
kind = "plot",
|
||||||
|
channels = { { tonumber(gz) }, { tonumber(gy) }, { tonumber(gx) } },
|
||||||
|
sample_type = "F64",
|
||||||
|
format = "Block",
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lua Scripting Guide
|
||||||
|
|
||||||
|
### Framer API
|
||||||
|
|
||||||
|
A Lua framer splits raw bytes into frames. The script must return a table with these functions:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
feed = function(bytes)
|
||||||
|
-- bytes: Lua string (raw bytes)
|
||||||
|
-- Returns: { frame1, frame2, ... } or nil
|
||||||
|
end,
|
||||||
|
|
||||||
|
flush = function()
|
||||||
|
-- Returns: last buffered frame (string) or nil
|
||||||
|
end,
|
||||||
|
|
||||||
|
reset = function()
|
||||||
|
end,
|
||||||
|
|
||||||
|
pending_len = function()
|
||||||
|
-- Returns: number of bytes pending in buffer
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: `tests/lua_line_framer.lua` (line-based framer splitting on `\n`).
|
||||||
|
|
||||||
|
### Decoder API
|
||||||
|
|
||||||
|
A Lua decoder parses a frame into structured data. The script must return a table with a `decode` function:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
-- frame: Lua string (one frame from the framer)
|
||||||
|
-- Returns nil → skip this frame
|
||||||
|
-- Returns string → treated as Text
|
||||||
|
-- Returns table → must contain "kind" field
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Return value formats:**
|
||||||
|
|
||||||
|
| `kind` | Required | Optional | Purpose |
|
||||||
|
|--------|----------|----------|---------|
|
||||||
|
| `"text"` | `data: string` | — | Text view |
|
||||||
|
| `"hex"` | `data: string` | — | Hex view |
|
||||||
|
| `"binary"` | `data: string` | — | Raw bytes |
|
||||||
|
| `"plot"` | `channels: {{number,…},…}` | `sample_type`, `format` | Plot view |
|
||||||
|
|
||||||
|
**Plot return value example:**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
kind = "plot",
|
||||||
|
channels = {
|
||||||
|
{ 1.0, 2.0, 3.0 }, -- channel 0
|
||||||
|
{ 4.0, 5.0, 6.0 }, -- channel 1
|
||||||
|
},
|
||||||
|
sample_type = "F64", -- default F64; also I8/U8/I16/U32/U64/F32
|
||||||
|
format = "Block", -- default Interleaved; also Block/XY
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
More examples in `examples/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ pipeview-gui (egui) pipeview-tui (ratatui) │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ pipeview-client │
|
||||||
|
│ SessionManager · Session · Config · History │
|
||||||
|
│ Lua Runtime (mlua / LuaJIT) │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ pipeview-core │
|
||||||
|
│ Transport ──▶ Frame ──▶ Protocol ──▶ Pipeline │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Crate | Type | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `pipeview-core` | library | Transport (Serial/TCP/UDP), framers (Line/Fixed/Length/COBS/Mixed/Lua), decoders (Text/Hex/Plot), MultiPipeline |
|
||||||
|
| `pipeview-client` | library | Session lifecycle, SessionManager, event broadcast (tokio broadcast), RingBuffer history, Lua runtime & session API |
|
||||||
|
| `pipeview-gui` | binary | egui desktop app: sidebar, config, console, text/hex/plot panels, keyboard shortcuts, font management, profiling |
|
||||||
|
| `pipeview-tui` | binary | ratatui terminal app (feature-incomplete, under development) |
|
||||||
|
|
||||||
|
**Dependency flow:** `core ← client ← {gui, tui}`
|
||||||
|
|
||||||
|
**Data flow:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Transport] → read bytes → [MultiPipeline]
|
||||||
|
→ Pipeline 1: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
|
||||||
|
→ Pipeline 2: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
|
||||||
|
→ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Each pipeline frames and decodes independently. Only successful decodes produce output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration & Persistence
|
||||||
|
|
||||||
|
GUI state is saved automatically to platform-standard locations:
|
||||||
|
|
||||||
|
| Platform | Path |
|
||||||
|
|----------|------|
|
||||||
|
| Linux | `$XDG_CONFIG_HOME/pipeview/gui-state.json` or `~/.config/pipeview/gui-state.json` |
|
||||||
|
| macOS | `~/Library/Application Support/pipeview/gui-state.json` |
|
||||||
|
| Windows | `%APPDATA%\pipeview\gui-state.json` |
|
||||||
|
|
||||||
|
Persisted data includes: session configurations (transport, pipelines), log settings, active tab, and display options.
|
||||||
|
|
||||||
|
Log files are written to `logs/` under the config directory, named `session_{id}_{timestamp}.log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Tools
|
||||||
|
|
||||||
|
### Test Data Generators
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Plot waveform test data
|
||||||
|
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
|
||||||
|
python tools/test_plot.py --wire-format mixed --format xy --channels 2
|
||||||
|
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
|
||||||
|
|
||||||
|
# Drone telemetry test data
|
||||||
|
python tools/test_drone.py --rate 10 --port 8092
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profiling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=pipeview_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p pipeview-gui
|
||||||
|
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p pipeview-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tracing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=info cargo run -p pipeview-gui
|
||||||
|
RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/
|
||||||
|
pipeview-core/ # Transport, framing, protocol
|
||||||
|
pipeview-client/ # Session management, Lua runtime
|
||||||
|
pipeview-gui/ # egui desktop application
|
||||||
|
pipeview-tui/ # ratatui terminal application
|
||||||
|
examples/ # Lua script examples
|
||||||
|
drone_plot.lua # Drone telemetry plot decoder
|
||||||
|
drone_text.lua # Drone telemetry text decoder
|
||||||
|
tests/ # Lua test fixtures
|
||||||
|
tools/ # Development utilities
|
||||||
|
test_plot.py # Waveform test data generator
|
||||||
|
test_drone.py # Drone test data generator
|
||||||
|
test_plot_serial.c # C serial plot test client
|
||||||
|
test_text_serial.c # C serial text test client
|
||||||
|
xs_mixed_plot.h # MixedTextPlot protocol reference
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: tokio (multi-threaded)
|
||||||
|
- **Serial**: `tokio-serial` + `serialport`
|
||||||
|
- **GUI**: egui + egui_plot
|
||||||
|
- **Lua**: mlua 0.11, LuaJIT (vendored), async/serde/send
|
||||||
|
- **Events**: `tokio::sync::broadcast` (multi-subscriber)
|
||||||
|
- **Zero feature flags**, zero build scripts, zero conditional compilation
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "xserial-client"
|
name = "pipeview-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
xserial-core = { path = "../xserial-core" }
|
pipeview-core = { path = "../pipeview-core" }
|
||||||
tokio = { workspace = true, features = ["sync", "time"] }
|
tokio = { workspace = true, features = ["sync", "time"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
@@ -8,4 +8,6 @@ pub enum SessionCmd {
|
|||||||
SetAutoReconnect(bool),
|
SetAutoReconnect(bool),
|
||||||
Close,
|
Close,
|
||||||
Reconfigure(SessionConfig),
|
Reconfigure(SessionConfig),
|
||||||
|
SetDtr(bool),
|
||||||
|
SetRts(bool),
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use xserial_core::frame::{
|
use pipeview_core::frame::{
|
||||||
Endian, Framer,
|
Endian, Framer,
|
||||||
cobs::CobsFramer,
|
cobs::CobsFramer,
|
||||||
fixed::FixedLengthFramer,
|
fixed::FixedLengthFramer,
|
||||||
@@ -7,14 +7,17 @@ use xserial_core::frame::{
|
|||||||
line::{LineConfig, LineFramer},
|
line::{LineConfig, LineFramer},
|
||||||
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
||||||
};
|
};
|
||||||
use xserial_core::protocol::{
|
use pipeview_core::protocol::{
|
||||||
ProtocolDecoder,
|
ProtocolDecoder,
|
||||||
hex::{HexConfig, HexDecoder},
|
hex::{HexConfig, HexDecoder},
|
||||||
mixed::{MixedTextPlotConfig as MixedDecoderConfig, MixedTextPlotDecoder},
|
mixed::{MixedTextPlotConfig as MixedDecoderConfig, MixedTextPlotDecoder},
|
||||||
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
|
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
|
||||||
text::{TextDecoder, TextEncoding},
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum FramerConfig {
|
pub enum FramerConfig {
|
||||||
@@ -49,6 +52,9 @@ pub enum FramerConfig {
|
|||||||
#[serde(default = "default_max_frame")]
|
#[serde(default = "default_max_frame")]
|
||||||
max_plot_frame: usize,
|
max_plot_frame: usize,
|
||||||
},
|
},
|
||||||
|
Lua {
|
||||||
|
script_path: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
@@ -68,15 +74,15 @@ fn default_max_frame() -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FramerConfig {
|
impl FramerConfig {
|
||||||
pub fn build(self) -> Box<dyn Framer> {
|
pub fn build(self) -> Result<Box<dyn Framer>> {
|
||||||
match self {
|
Ok(match self {
|
||||||
FramerConfig::Line {
|
FramerConfig::Line {
|
||||||
strip_cr,
|
strip_cr,
|
||||||
max_line_len,
|
max_line_len,
|
||||||
} => Box::new(LineFramer::new(LineConfig {
|
} => Box::new(LineFramer::new(LineConfig {
|
||||||
strip_cr,
|
strip_cr,
|
||||||
max_line_len,
|
max_line_len,
|
||||||
})),
|
})) as Box<dyn Framer>,
|
||||||
FramerConfig::Fixed { frame_len } => Box::new(FixedLengthFramer::new(frame_len)),
|
FramerConfig::Fixed { frame_len } => Box::new(FixedLengthFramer::new(frame_len)),
|
||||||
FramerConfig::Length {
|
FramerConfig::Length {
|
||||||
len_bytes,
|
len_bytes,
|
||||||
@@ -99,7 +105,10 @@ impl FramerConfig {
|
|||||||
max_line_len,
|
max_line_len,
|
||||||
max_plot_frame,
|
max_plot_frame,
|
||||||
})),
|
})),
|
||||||
|
FramerConfig::Lua { script_path } => {
|
||||||
|
Box::new(LuaFramer::from_script_path(script_path)?)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +141,9 @@ pub enum DecoderConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
encoding: TextEncoding,
|
encoding: TextEncoding,
|
||||||
},
|
},
|
||||||
|
Lua {
|
||||||
|
script_path: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_sep() -> String {
|
fn default_sep() -> String {
|
||||||
@@ -142,9 +154,11 @@ fn default_one() -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DecoderConfig {
|
impl DecoderConfig {
|
||||||
pub fn build(self) -> Box<dyn ProtocolDecoder> {
|
pub fn build(self) -> Result<Box<dyn ProtocolDecoder>> {
|
||||||
match self {
|
Ok(match self {
|
||||||
DecoderConfig::Text { encoding } => Box::new(TextDecoder::new(encoding)),
|
DecoderConfig::Text { encoding } => {
|
||||||
|
Box::new(TextDecoder::new(encoding)) as Box<dyn ProtocolDecoder>
|
||||||
|
}
|
||||||
DecoderConfig::Hex {
|
DecoderConfig::Hex {
|
||||||
uppercase,
|
uppercase,
|
||||||
separator,
|
separator,
|
||||||
@@ -170,7 +184,10 @@ impl DecoderConfig {
|
|||||||
DecoderConfig::MixedTextPlot { encoding } => {
|
DecoderConfig::MixedTextPlot { encoding } => {
|
||||||
Box::new(MixedTextPlotDecoder::new(MixedDecoderConfig { encoding }))
|
Box::new(MixedTextPlotDecoder::new(MixedDecoderConfig { encoding }))
|
||||||
}
|
}
|
||||||
|
DecoderConfig::Lua { script_path } => {
|
||||||
|
Box::new(LuaDecoder::from_script_path(script_path)?)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,10 +229,10 @@ impl Default for SessionConfig {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use xserial_core::protocol::Endian;
|
use pipeview_core::protocol::Endian;
|
||||||
use xserial_core::protocol::plot::{PlotFormat, SampleType};
|
use pipeview_core::protocol::plot::{PlotFormat, SampleType};
|
||||||
use xserial_core::protocol::text::TextEncoding;
|
use pipeview_core::protocol::text::TextEncoding;
|
||||||
use xserial_core::transport::TransportConfig;
|
use pipeview_core::transport::TransportConfig;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn framer_line_serde_roundtrip() {
|
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]
|
#[test]
|
||||||
fn decoder_text_serde() {
|
fn decoder_text_serde() {
|
||||||
let cfg: DecoderConfig = serde_json::from_str(r#"{"Text":{"encoding":"Latin1"}}"#).unwrap();
|
let cfg: DecoderConfig = serde_json::from_str(r#"{"Text":{"encoding":"Latin1"}}"#).unwrap();
|
||||||
@@ -345,6 +372,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_lua_serde() {
|
||||||
|
let cfg: DecoderConfig =
|
||||||
|
serde_json::from_str(r#"{"Lua":{"script_path":"/tmp/decoder.lua"}}"#).unwrap();
|
||||||
|
match cfg {
|
||||||
|
DecoderConfig::Lua { script_path } => assert_eq!(script_path, "/tmp/decoder.lua"),
|
||||||
|
_ => panic!("expected Lua"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_config_full_roundtrip() {
|
fn session_config_full_roundtrip() {
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
@@ -388,7 +425,7 @@ mod tests {
|
|||||||
max_plot_frame: 1024,
|
max_plot_frame: 1024,
|
||||||
},
|
},
|
||||||
] {
|
] {
|
||||||
let f = cfg.build();
|
let f = cfg.build().unwrap();
|
||||||
assert_eq!(f.pending_len(), 0);
|
assert_eq!(f.pending_len(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,6 +437,7 @@ mod tests {
|
|||||||
encoding: TextEncoding::Utf8
|
encoding: TextEncoding::Utf8
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
.unwrap()
|
||||||
.name(),
|
.name(),
|
||||||
"Text"
|
"Text"
|
||||||
);
|
);
|
||||||
@@ -411,6 +449,7 @@ mod tests {
|
|||||||
endian: Endian::Big
|
endian: Endian::Big
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
.unwrap()
|
||||||
.name(),
|
.name(),
|
||||||
"Hex"
|
"Hex"
|
||||||
);
|
);
|
||||||
@@ -422,6 +461,7 @@ mod tests {
|
|||||||
format: PlotFormat::XY
|
format: PlotFormat::XY
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
.unwrap()
|
||||||
.name(),
|
.name(),
|
||||||
"Plot"
|
"Plot"
|
||||||
);
|
);
|
||||||
@@ -430,6 +470,7 @@ mod tests {
|
|||||||
encoding: TextEncoding::Utf8
|
encoding: TextEncoding::Utf8
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
.unwrap()
|
||||||
.name(),
|
.name(),
|
||||||
"MixedTextPlot"
|
"MixedTextPlot"
|
||||||
);
|
);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use xserial_core::protocol::DecodedData;
|
use pipeview_core::protocol::DecodedData;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DecodedEntry {
|
pub struct DecodedEntry {
|
||||||
@@ -28,6 +28,9 @@ impl<T> RingBuffer<T> {
|
|||||||
pub fn iter(&self) -> impl Iterator<Item = &T> {
|
pub fn iter(&self) -> impl Iterator<Item = &T> {
|
||||||
self.buf.iter()
|
self.buf.iter()
|
||||||
}
|
}
|
||||||
|
pub fn get(&self, index: usize) -> Option<&T> {
|
||||||
|
self.buf.get(index)
|
||||||
|
}
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
self.buf.clear();
|
self.buf.clear();
|
||||||
}
|
}
|
||||||
404
crates/pipeview-client/src/lua/codec.rs
Normal file
404
crates/pipeview-client/src/lua/codec.rs
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use mlua::{Function, Lua, RegistryKey, Table, Value};
|
||||||
|
use tracing::warn;
|
||||||
|
use pipeview_core::frame::Framer;
|
||||||
|
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame, SampleType};
|
||||||
|
use pipeview_core::protocol::{DecodedData, ProtocolDecoder};
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
pub struct LuaFramer {
|
||||||
|
lua: Lua,
|
||||||
|
feed: RegistryKey,
|
||||||
|
flush: RegistryKey,
|
||||||
|
reset: RegistryKey,
|
||||||
|
pending_len: RegistryKey,
|
||||||
|
script_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LuaFramer {
|
||||||
|
pub fn from_script_path(script_path: impl Into<String>) -> Result<Self> {
|
||||||
|
let script_path = script_path.into();
|
||||||
|
let lua = Lua::new();
|
||||||
|
let table = load_script_table(&lua, &script_path)?;
|
||||||
|
let feed = registry_function(&lua, &table, "feed", &script_path)?;
|
||||||
|
let flush = registry_function(&lua, &table, "flush", &script_path)?;
|
||||||
|
let reset = registry_function(&lua, &table, "reset", &script_path)?;
|
||||||
|
let pending_len = registry_function(&lua, &table, "pending_len", &script_path)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
lua,
|
||||||
|
feed,
|
||||||
|
flush,
|
||||||
|
reset,
|
||||||
|
pending_len,
|
||||||
|
script_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Framer for LuaFramer {
|
||||||
|
fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
|
||||||
|
let result = (|| -> mlua::Result<Vec<Vec<u8>>> {
|
||||||
|
let function: Function = self.lua.registry_value(&self.feed)?;
|
||||||
|
let bytes = self.lua.create_string(data)?;
|
||||||
|
frames_from_value(function.call::<Value>(bytes)?)
|
||||||
|
})();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(frames) => frames,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(script = %self.script_path, error = %err, "lua framer feed failed");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> Option<Vec<u8>> {
|
||||||
|
let result = (|| -> mlua::Result<Option<Vec<u8>>> {
|
||||||
|
let function: Function = self.lua.registry_value(&self.flush)?;
|
||||||
|
optional_bytes_from_value(function.call::<Value>(())?)
|
||||||
|
})();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(frame) => frame,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(script = %self.script_path, error = %err, "lua framer flush failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
let result = (|| -> mlua::Result<()> {
|
||||||
|
let function: Function = self.lua.registry_value(&self.reset)?;
|
||||||
|
function.call::<()>(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if let Err(err) = result {
|
||||||
|
warn!(script = %self.script_path, error = %err, "lua framer reset failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_len(&self) -> usize {
|
||||||
|
let result = (|| -> mlua::Result<usize> {
|
||||||
|
let function: Function = self.lua.registry_value(&self.pending_len)?;
|
||||||
|
function.call::<usize>(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(len) => len,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(script = %self.script_path, error = %err, "lua framer pending_len failed");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LuaDecoder {
|
||||||
|
lua: Lua,
|
||||||
|
decode: RegistryKey,
|
||||||
|
script_path: String,
|
||||||
|
call_lock: Mutex<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LuaDecoder {
|
||||||
|
pub fn from_script_path(script_path: impl Into<String>) -> Result<Self> {
|
||||||
|
let script_path = script_path.into();
|
||||||
|
let lua = Lua::new();
|
||||||
|
let table = load_script_table(&lua, &script_path)?;
|
||||||
|
let decode = registry_function(&lua, &table, "decode", &script_path)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
lua,
|
||||||
|
decode,
|
||||||
|
script_path,
|
||||||
|
call_lock: Mutex::new(()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolDecoder for LuaDecoder {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"Lua"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(&self, frame: &[u8]) -> Option<DecodedData> {
|
||||||
|
let _guard = self
|
||||||
|
.call_lock
|
||||||
|
.lock()
|
||||||
|
.expect("lua decoder call mutex poisoned");
|
||||||
|
let result = (|| -> mlua::Result<Option<DecodedData>> {
|
||||||
|
let function: Function = self.lua.registry_value(&self.decode)?;
|
||||||
|
let bytes = self.lua.create_string(frame)?;
|
||||||
|
decoded_from_value(function.call::<Value>(bytes)?, frame)
|
||||||
|
})();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(decoded) => decoded,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(script = %self.script_path, error = %err, "lua decoder decode failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_script_table(lua: &Lua, script_path: &str) -> Result<Table> {
|
||||||
|
let script = fs::read_to_string(script_path)?;
|
||||||
|
match lua.load(&script).eval::<Value>()? {
|
||||||
|
Value::Table(table) => Ok(table),
|
||||||
|
other => Err(Error::Other(format!(
|
||||||
|
"lua script '{script_path}' must return a table, got {}",
|
||||||
|
other.type_name()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_function(
|
||||||
|
lua: &Lua,
|
||||||
|
table: &Table,
|
||||||
|
name: &str,
|
||||||
|
script_path: &str,
|
||||||
|
) -> Result<RegistryKey> {
|
||||||
|
let value: Value = table.get(name)?;
|
||||||
|
match value {
|
||||||
|
Value::Function(function) => Ok(lua.create_registry_value(function)?),
|
||||||
|
other => Err(Error::Other(format!(
|
||||||
|
"lua script '{script_path}' field '{name}' must be a function, got {}",
|
||||||
|
other.type_name()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frames_from_value(value: Value) -> mlua::Result<Vec<Vec<u8>>> {
|
||||||
|
match value {
|
||||||
|
Value::Nil => Ok(Vec::new()),
|
||||||
|
Value::String(bytes) => Ok(vec![bytes.as_bytes().to_vec()]),
|
||||||
|
Value::Table(table) => {
|
||||||
|
let mut frames = Vec::new();
|
||||||
|
for value in table.sequence_values::<mlua::String>() {
|
||||||
|
frames.push(value?.as_bytes().to_vec());
|
||||||
|
}
|
||||||
|
Ok(frames)
|
||||||
|
}
|
||||||
|
other => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"framer feed must return nil, string, or table of strings, got {}",
|
||||||
|
other.type_name()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_bytes_from_value(value: Value) -> mlua::Result<Option<Vec<u8>>> {
|
||||||
|
match value {
|
||||||
|
Value::Nil => Ok(None),
|
||||||
|
Value::String(bytes) => Ok(Some(bytes.as_bytes().to_vec())),
|
||||||
|
other => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"framer flush must return nil or string, got {}",
|
||||||
|
other.type_name()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decoded_from_value(value: Value, raw: &[u8]) -> mlua::Result<Option<DecodedData>> {
|
||||||
|
match value {
|
||||||
|
Value::Nil => Ok(None),
|
||||||
|
Value::String(text) => Ok(Some(DecodedData::Text(text.to_str()?.to_string()))),
|
||||||
|
Value::Table(table) => decoded_from_table(table, raw).map(Some),
|
||||||
|
other => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"decoder decode must return nil, string, or table, got {}",
|
||||||
|
other.type_name()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decoded_from_table(table: Table, raw: &[u8]) -> mlua::Result<DecodedData> {
|
||||||
|
let kind: String = table.get("kind")?;
|
||||||
|
match kind.as_str() {
|
||||||
|
"text" => Ok(DecodedData::Text(table.get("data")?)),
|
||||||
|
"hex" => Ok(DecodedData::Hex(table.get("data")?)),
|
||||||
|
"binary" => {
|
||||||
|
let bytes: mlua::String = table.get("data")?;
|
||||||
|
Ok(DecodedData::Binary(bytes.as_bytes().to_vec()))
|
||||||
|
}
|
||||||
|
"plot" => Ok(DecodedData::Plot(plot_from_table(table, raw)?)),
|
||||||
|
other => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"unknown lua decoder kind '{other}'"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plot_from_table(table: Table, raw: &[u8]) -> mlua::Result<PlotFrame> {
|
||||||
|
let channels_table: Table = table.get("channels")?;
|
||||||
|
let mut channels = Vec::new();
|
||||||
|
for channel in channels_table.sequence_values::<Table>() {
|
||||||
|
let channel = channel?;
|
||||||
|
let mut samples = Vec::new();
|
||||||
|
for sample in channel.sequence_values::<f64>() {
|
||||||
|
samples.push(sample?);
|
||||||
|
}
|
||||||
|
channels.push(samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(PlotFrame {
|
||||||
|
channels,
|
||||||
|
raw: raw.to_vec(),
|
||||||
|
sample_type: match table.get::<Option<String>>("sample_type")?.as_deref() {
|
||||||
|
Some(value) => parse_sample_type(value)?,
|
||||||
|
None => SampleType::F64,
|
||||||
|
},
|
||||||
|
format: match table.get::<Option<String>>("format")?.as_deref() {
|
||||||
|
Some(value) => parse_plot_format(value)?,
|
||||||
|
None => PlotFormat::Interleaved,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sample_type(value: &str) -> mlua::Result<SampleType> {
|
||||||
|
match value {
|
||||||
|
"i8" | "I8" => Ok(SampleType::I8),
|
||||||
|
"u8" | "U8" => Ok(SampleType::U8),
|
||||||
|
"i16" | "I16" => Ok(SampleType::I16),
|
||||||
|
"u16" | "U16" => Ok(SampleType::U16),
|
||||||
|
"i32" | "I32" => Ok(SampleType::I32),
|
||||||
|
"u32" | "U32" => Ok(SampleType::U32),
|
||||||
|
"i64" | "I64" => Ok(SampleType::I64),
|
||||||
|
"u64" | "U64" => Ok(SampleType::U64),
|
||||||
|
"f32" | "F32" => Ok(SampleType::F32),
|
||||||
|
"f64" | "F64" => Ok(SampleType::F64),
|
||||||
|
other => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"unknown plot sample_type '{other}'"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_plot_format(value: &str) -> mlua::Result<PlotFormat> {
|
||||||
|
match value {
|
||||||
|
"Interleaved" | "interleaved" => Ok(PlotFormat::Interleaved),
|
||||||
|
"Block" | "block" => Ok(PlotFormat::Block),
|
||||||
|
"XY" | "xy" => Ok(PlotFormat::XY),
|
||||||
|
other => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"unknown plot format '{other}'"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn write_script(name: &str, script: &str) -> PathBuf {
|
||||||
|
let nonce = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let path = std::env::temp_dir().join(format!("pipeview_{name}_{nonce}.lua"));
|
||||||
|
fs::write(&path, script).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_framer_splits_lines_across_chunks() {
|
||||||
|
let path = write_script(
|
||||||
|
"framer",
|
||||||
|
r#"
|
||||||
|
local buffer = ""
|
||||||
|
return {
|
||||||
|
feed = function(bytes)
|
||||||
|
buffer = buffer .. bytes
|
||||||
|
local frames = {}
|
||||||
|
while true do
|
||||||
|
local i = buffer:find("\n", 1, true)
|
||||||
|
if not i then break end
|
||||||
|
frames[#frames + 1] = buffer:sub(1, i - 1)
|
||||||
|
buffer = buffer:sub(i + 1)
|
||||||
|
end
|
||||||
|
return frames
|
||||||
|
end,
|
||||||
|
flush = function()
|
||||||
|
if #buffer == 0 then return nil end
|
||||||
|
local frame = buffer
|
||||||
|
buffer = ""
|
||||||
|
return frame
|
||||||
|
end,
|
||||||
|
reset = function()
|
||||||
|
buffer = ""
|
||||||
|
end,
|
||||||
|
pending_len = function()
|
||||||
|
return #buffer
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut framer = LuaFramer::from_script_path(path.to_string_lossy()).unwrap();
|
||||||
|
assert_eq!(framer.feed(b"one\nt"), vec![b"one".to_vec()]);
|
||||||
|
assert_eq!(framer.pending_len(), 1);
|
||||||
|
assert_eq!(framer.feed(b"wo\nthree"), vec![b"two".to_vec()]);
|
||||||
|
assert_eq!(framer.flush(), Some(b"three".to_vec()));
|
||||||
|
assert_eq!(framer.pending_len(), 0);
|
||||||
|
|
||||||
|
fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_decoder_maps_text_binary_and_plot() {
|
||||||
|
let path = write_script(
|
||||||
|
"decoder",
|
||||||
|
r#"
|
||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
if frame == "skip" then
|
||||||
|
return nil
|
||||||
|
elseif frame == "bin" then
|
||||||
|
return { kind = "binary", data = "\1\2" }
|
||||||
|
elseif frame == "plot" then
|
||||||
|
return {
|
||||||
|
kind = "plot",
|
||||||
|
channels = { { 1.0, 2.0 }, { 3.5, 4.5 } },
|
||||||
|
sample_type = "F64",
|
||||||
|
format = "Block",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return { kind = "text", data = "decoded:" .. frame }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let decoder = LuaDecoder::from_script_path(path.to_string_lossy()).unwrap();
|
||||||
|
assert!(decoder.decode(b"skip").is_none());
|
||||||
|
assert!(matches!(
|
||||||
|
decoder.decode(b"abc"),
|
||||||
|
Some(DecodedData::Text(text)) if text == "decoded:abc"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decoder.decode(b"bin"),
|
||||||
|
Some(DecodedData::Binary(bytes)) if bytes == vec![1, 2]
|
||||||
|
));
|
||||||
|
match decoder.decode(b"plot").unwrap() {
|
||||||
|
DecodedData::Plot(frame) => {
|
||||||
|
assert_eq!(frame.channels, vec![vec![1.0, 2.0], vec![3.5, 4.5]]);
|
||||||
|
assert_eq!(frame.sample_type, SampleType::F64);
|
||||||
|
assert_eq!(frame.format, PlotFormat::Block);
|
||||||
|
assert_eq!(frame.raw, b"plot");
|
||||||
|
}
|
||||||
|
other => panic!("expected plot, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_framer_requires_expected_functions() {
|
||||||
|
let path = write_script("bad_framer", "return { feed = function() return {} end }");
|
||||||
|
let result = LuaFramer::from_script_path(path.to_string_lossy());
|
||||||
|
assert!(result.is_err());
|
||||||
|
fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use mlua::{Lua, LuaSerdeExt, Result as LuaResult, Table, Value};
|
|||||||
use crate::config::SessionConfig;
|
use crate::config::SessionConfig;
|
||||||
use crate::manager::SessionManager;
|
use crate::manager::SessionManager;
|
||||||
|
|
||||||
|
pub(crate) mod codec;
|
||||||
mod session_api;
|
mod session_api;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -80,9 +81,9 @@ pub fn register(lua: &Lua) -> LuaResult<()> {
|
|||||||
|
|
||||||
pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()> {
|
pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()> {
|
||||||
let runtime = LuaRuntime::new(manager);
|
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();
|
let runtime = runtime.clone();
|
||||||
lua.create_async_function(move |lua, config: Table| {
|
lua.create_async_function(move |lua, config: Table| {
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
@@ -95,15 +96,15 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
|||||||
})?
|
})?
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
xserial.set(
|
pipeview.set(
|
||||||
"list_ports",
|
"list_ports",
|
||||||
lua.create_function(|_, ()| {
|
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<_>>())
|
Ok(ports.into_iter().map(|p| p.port_name).collect::<Vec<_>>())
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
xserial.set("sleep", {
|
pipeview.set("sleep", {
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
lua.create_async_function(move |lua, ms: u64| {
|
lua.create_async_function(move |lua, ms: u64| {
|
||||||
let runtime = runtime.clone();
|
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();
|
let runtime = runtime.clone();
|
||||||
lua.create_async_function(move |lua, limit_per_session: Option<usize>| {
|
lua.create_async_function(move |lua, limit_per_session: Option<usize>| {
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
@@ -123,7 +124,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
|||||||
})?
|
})?
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
xserial.set(
|
pipeview.set(
|
||||||
"log",
|
"log",
|
||||||
lua.create_function(|_, msg: String| {
|
lua.create_function(|_, msg: String| {
|
||||||
tracing::info!("[lua] {}", msg);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,9 +146,9 @@ mod tests {
|
|||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
register(&lua).unwrap();
|
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"] {
|
for name in ["open", "list_ports", "sleep", "poll", "log"] {
|
||||||
let value: Value = xserial.get(name).unwrap();
|
let value: Value = pipeview.get(name).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(value, Value::Function(_)),
|
matches!(value, Value::Function(_)),
|
||||||
"{name} should be a function"
|
"{name} should be a function"
|
||||||
@@ -161,8 +162,8 @@ mod tests {
|
|||||||
let manager = SessionManager::new();
|
let manager = SessionManager::new();
|
||||||
register_with_manager(&lua, manager.clone()).unwrap();
|
register_with_manager(&lua, manager.clone()).unwrap();
|
||||||
|
|
||||||
let xserial: Table = lua.globals().get("xserial").unwrap();
|
let pipeview: Table = lua.globals().get("pipeview").unwrap();
|
||||||
let open: Value = xserial.get("open").unwrap();
|
let open: Value = pipeview.get("open").unwrap();
|
||||||
assert!(matches!(open, Value::Function(_)));
|
assert!(matches!(open, Value::Function(_)));
|
||||||
assert_eq!(manager.count(), 0);
|
assert_eq!(manager.count(), 0);
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ use std::time::Duration;
|
|||||||
use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, UserData, UserDataMethods, Value, Variadic};
|
use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, UserData, UserDataMethods, Value, Variadic};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use xserial_core::protocol::DecodedData;
|
use pipeview_core::protocol::DecodedData;
|
||||||
|
|
||||||
use crate::config::SessionConfig;
|
use crate::config::SessionConfig;
|
||||||
use crate::lua::LuaRuntime;
|
use crate::lua::LuaRuntime;
|
||||||
@@ -224,13 +224,13 @@ impl CallbackState {
|
|||||||
|
|
||||||
impl Drop for CallbackState {
|
impl Drop for CallbackState {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Ok(mut relay_task) = self.relay_task.lock() {
|
if let Ok(mut relay_task) = self.relay_task.lock()
|
||||||
if let Some(task) = relay_task.take() {
|
&& let Some(task) = relay_task.take()
|
||||||
|
{
|
||||||
task.abort();
|
task.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl UserData for LuaSessionHandle {
|
impl UserData for LuaSessionHandle {
|
||||||
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
|
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
|
||||||
@@ -11,8 +11,8 @@ use tokio::sync::{Mutex, broadcast, mpsc};
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use xserial_core::pipeline::{MultiPipeline, Pipeline};
|
use pipeview_core::pipeline::{MultiPipeline, Pipeline};
|
||||||
use xserial_core::transport::Connection;
|
use pipeview_core::transport::Connection;
|
||||||
|
|
||||||
use crate::cmd::SessionCmd;
|
use crate::cmd::SessionCmd;
|
||||||
use crate::config::SessionConfig;
|
use crate::config::SessionConfig;
|
||||||
@@ -138,6 +138,22 @@ impl SessionHandle {
|
|||||||
.map_err(|_| String::from("session closed"))
|
.map_err(|_| String::from("session closed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_dtr(&self, state: bool) -> Result<(), String> {
|
||||||
|
self.inner
|
||||||
|
.cmd_tx
|
||||||
|
.send(SessionCmd::SetDtr(state))
|
||||||
|
.await
|
||||||
|
.map_err(|_| String::from("session closed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_rts(&self, state: bool) -> Result<(), String> {
|
||||||
|
self.inner
|
||||||
|
.cmd_tx
|
||||||
|
.send(SessionCmd::SetRts(state))
|
||||||
|
.await
|
||||||
|
.map_err(|_| String::from("session closed"))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn read(&self, timeout_ms: u64) -> Option<DecodedEntry> {
|
pub async fn read(&self, timeout_ms: u64) -> Option<DecodedEntry> {
|
||||||
let mut rx = self.inner.event_tx.subscribe();
|
let mut rx = self.inner.event_tx.subscribe();
|
||||||
let deadline = Duration::from_millis(timeout_ms);
|
let deadline = Duration::from_millis(timeout_ms);
|
||||||
@@ -176,20 +192,21 @@ impl Drop for SessionHandle {
|
|||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if Arc::strong_count(&self.inner) == 1 {
|
if Arc::strong_count(&self.inner) == 1 {
|
||||||
self.inner.request_close_nonblocking();
|
self.inner.request_close_nonblocking();
|
||||||
if let Ok(mut task) = self.inner.task.try_lock() {
|
if let Ok(mut task) = self.inner.task.try_lock()
|
||||||
if let Some(task) = task.take() {
|
&& let Some(task) = task.take()
|
||||||
|
{
|
||||||
task.abort();
|
task.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
id: SessionId,
|
id: SessionId,
|
||||||
config: SessionConfig,
|
config: SessionConfig,
|
||||||
conn: Option<Connection>,
|
conn: Option<Connection>,
|
||||||
pipeline: MultiPipeline,
|
pipeline: MultiPipeline,
|
||||||
|
pipeline_build_error: Option<String>,
|
||||||
history: RingBuffer<DecodedEntry>,
|
history: RingBuffer<DecodedEntry>,
|
||||||
cmd_rx: mpsc::Receiver<SessionCmd>,
|
cmd_rx: mpsc::Receiver<SessionCmd>,
|
||||||
event_tx: broadcast::Sender<SessionEvent>,
|
event_tx: broadcast::Sender<SessionEvent>,
|
||||||
@@ -201,9 +218,17 @@ impl Session {
|
|||||||
pub fn spawn(id: SessionId, config: SessionConfig) -> SessionHandle {
|
pub fn spawn(id: SessionId, config: SessionConfig) -> SessionHandle {
|
||||||
let (cmd_tx, cmd_rx) = mpsc::channel(32);
|
let (cmd_tx, cmd_rx) = mpsc::channel(32);
|
||||||
let (event_tx, _) = broadcast::channel(256);
|
let (event_tx, _) = broadcast::channel(256);
|
||||||
|
let (pipeline, pipeline_build_error) = match Self::build_pipeline(&config) {
|
||||||
|
Ok(pipeline) => (pipeline, None),
|
||||||
|
Err(err) => (
|
||||||
|
MultiPipeline::new(),
|
||||||
|
Some(format!("pipeline build failed: {err}")),
|
||||||
|
),
|
||||||
|
};
|
||||||
let mut session = Self {
|
let mut session = Self {
|
||||||
id,
|
id,
|
||||||
pipeline: Self::build_pipeline(&config),
|
pipeline,
|
||||||
|
pipeline_build_error,
|
||||||
history: RingBuffer::new(config.history_limit),
|
history: RingBuffer::new(config.history_limit),
|
||||||
config,
|
config,
|
||||||
conn: None,
|
conn: None,
|
||||||
@@ -217,22 +242,29 @@ impl Session {
|
|||||||
SessionHandle::new(inner)
|
SessionHandle::new(inner)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_pipeline(config: &SessionConfig) -> MultiPipeline {
|
fn build_pipeline(config: &SessionConfig) -> crate::Result<MultiPipeline> {
|
||||||
let mut pipelines = MultiPipeline::new();
|
let mut pipelines = MultiPipeline::new();
|
||||||
for pipeline in &config.pipelines {
|
for pipeline in &config.pipelines {
|
||||||
pipelines.add(Pipeline::new(
|
pipelines.add(Pipeline::new(
|
||||||
pipeline.name.clone(),
|
pipeline.name.clone(),
|
||||||
pipeline.framer.clone().build(),
|
pipeline.framer.clone().build()?,
|
||||||
pipeline.decoder.clone().build(),
|
pipeline.decoder.clone().build()?,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
pipelines
|
Ok(pipelines)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run(&mut self) {
|
async fn run(&mut self) {
|
||||||
info!(session_id = self.id, "session started");
|
info!(session_id = self.id, "session started");
|
||||||
|
|
||||||
if let Err(err) = self.connect().await {
|
if let Some(message) = self.pipeline_build_error.take() {
|
||||||
|
self.emit_error(message);
|
||||||
|
self.desired_connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.desired_connected
|
||||||
|
&& let Err(err) = self.connect().await
|
||||||
|
{
|
||||||
warn!(session_id = self.id, error = %err, "initial connect failed");
|
warn!(session_id = self.id, error = %err, "initial connect failed");
|
||||||
self.emit_error(format!("connect failed: {err}"));
|
self.emit_error(format!("connect failed: {err}"));
|
||||||
}
|
}
|
||||||
@@ -272,11 +304,11 @@ impl Session {
|
|||||||
}
|
}
|
||||||
Some(SessionCmd::Connect) => {
|
Some(SessionCmd::Connect) => {
|
||||||
self.desired_connected = true;
|
self.desired_connected = true;
|
||||||
if self.conn.is_none() {
|
if self.conn.is_none()
|
||||||
if let Err(err) = self.connect().await {
|
&& let Err(err) = self.connect().await
|
||||||
|
{
|
||||||
self.emit_error(format!("connect failed: {err}"));
|
self.emit_error(format!("connect failed: {err}"));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Some(SessionCmd::Disconnect) => {
|
Some(SessionCmd::Disconnect) => {
|
||||||
@@ -304,6 +336,14 @@ impl Session {
|
|||||||
self.handle_reconfigure(config).await;
|
self.handle_reconfigure(config).await;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
Some(SessionCmd::SetDtr(state)) => {
|
||||||
|
self.handle_set_dtr(state);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Some(SessionCmd::SetRts(state)) => {
|
||||||
|
self.handle_set_rts(state);
|
||||||
|
true
|
||||||
|
}
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,9 +362,16 @@ impl Session {
|
|||||||
|
|
||||||
async fn handle_reconfigure(&mut self, config: SessionConfig) {
|
async fn handle_reconfigure(&mut self, config: SessionConfig) {
|
||||||
info!(session_id = self.id, "reconfiguring session");
|
info!(session_id = self.id, "reconfiguring session");
|
||||||
|
let pipeline = match Self::build_pipeline(&config) {
|
||||||
|
Ok(pipeline) => pipeline,
|
||||||
|
Err(err) => {
|
||||||
|
self.emit_error(format!("pipeline build failed: {err}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
self.disconnect(true).await;
|
self.disconnect(true).await;
|
||||||
self.config = config;
|
self.config = config;
|
||||||
self.pipeline = Self::build_pipeline(&self.config);
|
self.pipeline = pipeline;
|
||||||
self.history = RingBuffer::new(self.config.history_limit);
|
self.history = RingBuffer::new(self.config.history_limit);
|
||||||
|
|
||||||
if let Err(err) = self.connect().await {
|
if let Err(err) = self.connect().await {
|
||||||
@@ -332,6 +379,28 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_set_dtr(&mut self, state: bool) {
|
||||||
|
match self.conn.as_mut() {
|
||||||
|
Some(conn) => {
|
||||||
|
if let Err(err) = conn.set_dtr(state) {
|
||||||
|
self.emit_error(format!("set_dtr: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => self.emit_error(String::from("set_dtr: session not connected")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_set_rts(&mut self, state: bool) {
|
||||||
|
match self.conn.as_mut() {
|
||||||
|
Some(conn) => {
|
||||||
|
if let Err(err) = conn.set_rts(state) {
|
||||||
|
self.emit_error(format!("set_rts: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => self.emit_error(String::from("set_rts: session not connected")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_read_result(
|
async fn handle_read_result(
|
||||||
&mut self,
|
&mut self,
|
||||||
result: Result<Vec<DecodedEntry>, std::io::Error>,
|
result: Result<Vec<DecodedEntry>, std::io::Error>,
|
||||||
@@ -430,8 +499,8 @@ async fn try_read(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_io_error(err: xserial_core::error::Error) -> std::io::Error {
|
fn to_io_error(err: pipeview_core::error::Error) -> std::io::Error {
|
||||||
io::Error::new(io::ErrorKind::Other, err.to_string())
|
io::Error::other(err.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -441,7 +510,7 @@ mod tests {
|
|||||||
|
|
||||||
fn tcp_config() -> SessionConfig {
|
fn tcp_config() -> SessionConfig {
|
||||||
SessionConfig {
|
SessionConfig {
|
||||||
transport: xserial_core::transport::TransportConfig::Tcp {
|
transport: pipeview_core::transport::TransportConfig::Tcp {
|
||||||
addr: "127.0.0.1:1".into(),
|
addr: "127.0.0.1:1".into(),
|
||||||
},
|
},
|
||||||
pipelines: vec![PipelineConfig {
|
pipelines: vec![PipelineConfig {
|
||||||
@@ -451,7 +520,7 @@ mod tests {
|
|||||||
max_line_len: 65536,
|
max_line_len: 65536,
|
||||||
},
|
},
|
||||||
decoder: DecoderConfig::Text {
|
decoder: DecoderConfig::Text {
|
||||||
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
|
encoding: pipeview_core::protocol::text::TextEncoding::Utf8,
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -507,6 +576,7 @@ mod tests {
|
|||||||
let handle = Session::spawn(2, tcp_config());
|
let handle = Session::spawn(2, tcp_config());
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
let _ = handle.send(b"data".to_vec()).await;
|
let _ = handle.send(b"data".to_vec()).await;
|
||||||
|
let _ = handle.close().await;
|
||||||
handle.join().await;
|
handle.join().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::sync::Once;
|
use std::sync::Once;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use std::{fs, path::PathBuf};
|
||||||
|
|
||||||
use mlua::Lua;
|
use mlua::Lua;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
@@ -10,12 +11,22 @@ static INIT: Once = Once::new();
|
|||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
INIT.call_once(|| {
|
INIT.call_once(|| {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter("xserial=trace")
|
.with_env_filter("pipeview=trace")
|
||||||
.try_init()
|
.try_init()
|
||||||
.ok();
|
.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 {
|
fn text_pipeline_lua() -> &'static str {
|
||||||
r#"
|
r#"
|
||||||
{
|
{
|
||||||
@@ -36,18 +47,18 @@ fn hex_pipeline_lua() -> &'static str {
|
|||||||
"#
|
"#
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── xserial.sleep / xserial.log ──────────────────────────────────
|
// ── pipeview.sleep / pipeview.log ──────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_sleep_and_log() {
|
async fn lua_sleep_and_log() {
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
lua.load(
|
lua.load(
|
||||||
r#"
|
r#"
|
||||||
xserial.log("test start")
|
pipeview.log("test start")
|
||||||
xserial.sleep(50)
|
pipeview.sleep(50)
|
||||||
xserial.log("test end")
|
pipeview.log("test end")
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.exec_async()
|
.exec_async()
|
||||||
@@ -55,15 +66,15 @@ async fn lua_sleep_and_log() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── xserial.list_ports ───────────────────────────────────────────
|
// ── pipeview.list_ports ───────────────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_list_ports() {
|
async fn lua_list_ports() {
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let result: Vec<String> = lua
|
let result: Vec<String> = lua
|
||||||
.load(r#"return xserial.list_ports()"#)
|
.load(r#"return pipeview.list_ports()"#)
|
||||||
.eval_async()
|
.eval_async()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -73,7 +84,7 @@ async fn lua_list_ports() {
|
|||||||
let _ = result;
|
let _ = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── xserial.open + session:send / session:read / session:close ────
|
// ── pipeview.open + session:send / session:read / session:close ────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_session_open_send_read_close() {
|
async fn lua_session_open_send_read_close() {
|
||||||
@@ -91,11 +102,11 @@ async fn lua_session_open_send_read_close() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -130,11 +141,11 @@ async fn lua_session_read_timeout() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -151,7 +162,7 @@ async fn lua_session_read_timeout() {
|
|||||||
lua.load(&script).exec_async().await.unwrap();
|
lua.load(&script).exec_async().await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── xserial.open with hex decoder ────────────────────────────────
|
// ── pipeview.open with hex decoder ────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_session_hex_decoder() {
|
async fn lua_session_hex_decoder() {
|
||||||
@@ -165,11 +176,11 @@ async fn lua_session_hex_decoder() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -190,6 +201,102 @@ async fn lua_session_hex_decoder() {
|
|||||||
server.await.unwrap();
|
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 ─────────────────────────────────────
|
// ── session:on_data callback ─────────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -204,12 +311,12 @@ async fn lua_session_on_data_callback() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local received = {{}}
|
local received = {{}}
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -220,8 +327,8 @@ async fn lua_session_on_data_callback() {
|
|||||||
|
|
||||||
for _ = 1, 50 do
|
for _ = 1, 50 do
|
||||||
if #received < 2 then
|
if #received < 2 then
|
||||||
xserial.poll(1)
|
pipeview.poll(1)
|
||||||
xserial.sleep(10)
|
pipeview.sleep(10)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -264,11 +371,11 @@ async fn lua_session_reconfigure() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -313,11 +420,11 @@ async fn lua_session_next_event_stream() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -343,7 +450,7 @@ async fn lua_session_next_event_stream() {
|
|||||||
server.await.unwrap();
|
server.await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── session:off + xserial.poll ───────────────────────────────────
|
// ── session:off + pipeview.poll ───────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_session_off_stops_future_callbacks() {
|
async fn lua_session_off_stops_future_callbacks() {
|
||||||
@@ -359,12 +466,12 @@ async fn lua_session_off_stops_future_callbacks() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local received = {{}}
|
local received = {{}}
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -375,8 +482,8 @@ async fn lua_session_off_stops_future_callbacks() {
|
|||||||
|
|
||||||
for _ = 1, 50 do
|
for _ = 1, 50 do
|
||||||
if #received == 0 then
|
if #received == 0 then
|
||||||
xserial.poll(1)
|
pipeview.poll(1)
|
||||||
xserial.sleep(10)
|
pipeview.sleep(10)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -384,8 +491,8 @@ async fn lua_session_off_stops_future_callbacks() {
|
|||||||
assert(received[1] == "text:first")
|
assert(received[1] == "text:first")
|
||||||
assert(sess:off(token) == true)
|
assert(sess:off(token) == true)
|
||||||
|
|
||||||
xserial.sleep(250)
|
pipeview.sleep(250)
|
||||||
xserial.poll(10)
|
pipeview.poll(10)
|
||||||
|
|
||||||
assert(#received == 1, "callback should not fire after off")
|
assert(#received == 1, "callback should not fire after off")
|
||||||
assert(sess:off(token) == false)
|
assert(sess:off(token) == false)
|
||||||
@@ -3,11 +3,11 @@ use std::time::Duration;
|
|||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use xserial_client::SessionManager;
|
use pipeview_client::SessionManager;
|
||||||
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
use pipeview_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
||||||
use xserial_client::session::{Session, SessionEvent};
|
use pipeview_client::session::{Session, SessionEvent};
|
||||||
use xserial_core::protocol::DecodedData;
|
use pipeview_core::protocol::DecodedData;
|
||||||
use xserial_core::transport::TransportConfig;
|
use pipeview_core::transport::TransportConfig;
|
||||||
|
|
||||||
fn tcp_config(addr: String) -> SessionConfig {
|
fn tcp_config(addr: String) -> SessionConfig {
|
||||||
SessionConfig {
|
SessionConfig {
|
||||||
@@ -19,7 +19,7 @@ fn tcp_config(addr: String) -> SessionConfig {
|
|||||||
max_line_len: 1024 * 1024,
|
max_line_len: 1024 * 1024,
|
||||||
},
|
},
|
||||||
decoder: DecoderConfig::Text {
|
decoder: DecoderConfig::Text {
|
||||||
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
|
encoding: pipeview_core::protocol::text::TextEncoding::Utf8,
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
history_limit: 100,
|
history_limit: 100,
|
||||||
@@ -72,18 +72,15 @@ async fn session_multiple_reads() {
|
|||||||
let mut texts = Vec::new();
|
let mut texts = Vec::new();
|
||||||
tokio::time::timeout(Duration::from_secs(5), async {
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
loop {
|
loop {
|
||||||
match rx.recv().await.unwrap() {
|
if let SessionEvent::Data(_, entry) = rx.recv().await.unwrap()
|
||||||
SessionEvent::Data(_, entry) => {
|
&& let DecodedData::Text(s) = entry.data
|
||||||
if let DecodedData::Text(s) = entry.data {
|
{
|
||||||
texts.push(s);
|
texts.push(s);
|
||||||
if texts.len() == 3 {
|
if texts.len() == 3 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -116,7 +113,7 @@ async fn session_multi_pipeline_text_and_hex() {
|
|||||||
uppercase: false,
|
uppercase: false,
|
||||||
separator: " ".into(),
|
separator: " ".into(),
|
||||||
bytes_per_group: 1,
|
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();
|
let mut results = Vec::new();
|
||||||
tokio::time::timeout(Duration::from_secs(5), async {
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
loop {
|
loop {
|
||||||
match rx.recv().await.unwrap() {
|
if let SessionEvent::Data(_, entry) = rx.recv().await.unwrap() {
|
||||||
SessionEvent::Data(_, entry) => {
|
|
||||||
results.push((entry.pipeline_name.clone(), entry.data.clone()));
|
results.push((entry.pipeline_name.clone(), entry.data.clone()));
|
||||||
if results.len() == 2 {
|
if results.len() == 2 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -291,7 +285,7 @@ async fn session_reconfigure_changes_pipeline() {
|
|||||||
uppercase: true,
|
uppercase: true,
|
||||||
separator: " ".into(),
|
separator: " ".into(),
|
||||||
bytes_per_group: 1,
|
bytes_per_group: 1,
|
||||||
endian: xserial_core::protocol::Endian::Big,
|
endian: pipeview_core::protocol::Endian::Big,
|
||||||
};
|
};
|
||||||
|
|
||||||
handle.reconfigure(new_cfg).await.unwrap();
|
handle.reconfigure(new_cfg).await.unwrap();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "xserial-core"
|
name = "pipeview-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@ mod tests {
|
|||||||
let mut f = LengthPrefixedFramer::new(cfg);
|
let mut f = LengthPrefixedFramer::new(cfg);
|
||||||
// Corrupt frame: claims 200 bytes, actual payload is 0xFF bytes
|
// Corrupt frame: claims 200 bytes, actual payload is 0xFF bytes
|
||||||
let mut data = be2(200);
|
let mut data = be2(200);
|
||||||
data.extend_from_slice(&vec![0xFFu8; 200]);
|
data.extend_from_slice(&[0xFFu8; 200]);
|
||||||
// Followed by valid frame
|
// Followed by valid frame
|
||||||
data.extend(&be2(3));
|
data.extend(&be2(3));
|
||||||
data.extend_from_slice(b"foo");
|
data.extend_from_slice(b"foo");
|
||||||
@@ -72,11 +72,11 @@ impl MixedTextPlotFramer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(decoded) = cobs_decode(&self.plot_buf) {
|
if let Some(decoded) = cobs_decode(&self.plot_buf)
|
||||||
if decoded.len() <= self.config.max_plot_frame {
|
&& decoded.len() <= self.config.max_plot_frame
|
||||||
|
{
|
||||||
frames.push(tag_plot_frame(decoded));
|
frames.push(tag_plot_frame(decoded));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
self.plot_buf.clear();
|
self.plot_buf.clear();
|
||||||
self.plot_overflow = false;
|
self.plot_overflow = false;
|
||||||
}
|
}
|
||||||
@@ -61,11 +61,11 @@ pub struct PipelineResult {
|
|||||||
/// Manages multiple [`Pipeline`]s, feeding the same byte stream to all of them.
|
/// Manages multiple [`Pipeline`]s, feeding the same byte stream to all of them.
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use xserial_core::pipeline::{MultiPipeline, Pipeline};
|
/// use pipeview_core::pipeline::{MultiPipeline, Pipeline};
|
||||||
/// use xserial_core::frame::line::{LineFramer, LineConfig};
|
/// use pipeview_core::frame::line::{LineFramer, LineConfig};
|
||||||
/// use xserial_core::frame::fixed::FixedLengthFramer;
|
/// use pipeview_core::frame::fixed::FixedLengthFramer;
|
||||||
/// use xserial_core::protocol::text::{TextDecoder, TextEncoding};
|
/// use pipeview_core::protocol::text::{TextDecoder, TextEncoding};
|
||||||
/// use xserial_core::protocol::hex::{HexDecoder, HexConfig};
|
/// use pipeview_core::protocol::hex::{HexDecoder, HexConfig};
|
||||||
///
|
///
|
||||||
/// let mut mp = MultiPipeline::new();
|
/// let mut mp = MultiPipeline::new();
|
||||||
/// mp.add(
|
/// mp.add(
|
||||||
@@ -52,7 +52,8 @@ impl MixedTextPlotDecoder {
|
|||||||
Endian::Big => 1,
|
Endian::Big => 1,
|
||||||
});
|
});
|
||||||
packet.push(channels.min(u8::MAX as usize) as u8);
|
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.len().min(u32::MAX as usize) as u32).to_le_bytes());
|
||||||
packet.extend_from_slice(payload);
|
packet.extend_from_slice(payload);
|
||||||
packet
|
packet
|
||||||
@@ -469,7 +469,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let d = PlotDecoder::new(cfg);
|
let d = PlotDecoder::new(cfg);
|
||||||
// 7 bytes: 1 full u32 (4 bytes) + 3 trailing bytes
|
// 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();
|
let result = d.decode(&data).unwrap();
|
||||||
match result {
|
match result {
|
||||||
DecodedData::Plot(frame) => {
|
DecodedData::Plot(frame) => {
|
||||||
@@ -30,6 +30,14 @@ fn default_serial_flow_control() -> SerialFlowControl {
|
|||||||
SerialFlowControl::None
|
SerialFlowControl::None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_serial_dtr() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_serial_rts() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum TransportType {
|
pub enum TransportType {
|
||||||
Serial,
|
Serial,
|
||||||
@@ -60,6 +68,10 @@ pub enum TransportConfig {
|
|||||||
stop_bits: SerialStopBits,
|
stop_bits: SerialStopBits,
|
||||||
#[serde(default = "default_serial_flow_control")]
|
#[serde(default = "default_serial_flow_control")]
|
||||||
flow_control: SerialFlowControl,
|
flow_control: SerialFlowControl,
|
||||||
|
#[serde(default = "default_serial_dtr")]
|
||||||
|
dtr: bool,
|
||||||
|
#[serde(default = "default_serial_rts")]
|
||||||
|
rts: bool,
|
||||||
},
|
},
|
||||||
Tcp {
|
Tcp {
|
||||||
addr: String,
|
addr: String,
|
||||||
@@ -96,6 +108,8 @@ impl Connection {
|
|||||||
parity,
|
parity,
|
||||||
stop_bits,
|
stop_bits,
|
||||||
flow_control,
|
flow_control,
|
||||||
|
dtr,
|
||||||
|
rts,
|
||||||
} => Connection::Serial(SerialTransport::new(
|
} => Connection::Serial(SerialTransport::new(
|
||||||
port,
|
port,
|
||||||
baud_rate,
|
baud_rate,
|
||||||
@@ -103,6 +117,8 @@ impl Connection {
|
|||||||
parity,
|
parity,
|
||||||
stop_bits,
|
stop_bits,
|
||||||
flow_control,
|
flow_control,
|
||||||
|
dtr,
|
||||||
|
rts,
|
||||||
)),
|
)),
|
||||||
TransportConfig::Tcp { addr } => Connection::Tcp(TcpTransport::new(addr)),
|
TransportConfig::Tcp { addr } => Connection::Tcp(TcpTransport::new(addr)),
|
||||||
TransportConfig::Udp {
|
TransportConfig::Udp {
|
||||||
@@ -153,6 +169,28 @@ impl Connection {
|
|||||||
Connection::Udp(t) => t.disconnect().await,
|
Connection::Udp(t) => t.disconnect().await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Connection::Serial(t) => t.set_dtr(state),
|
||||||
|
Connection::Tcp(_) | Connection::Udp(_) => {
|
||||||
|
Err(crate::error::Error::ConnectionFailed(
|
||||||
|
"DTR only supported on Serial connections".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_rts(&mut self, state: bool) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Connection::Serial(t) => t.set_rts(state),
|
||||||
|
Connection::Tcp(_) | Connection::Udp(_) => {
|
||||||
|
Err(crate::error::Error::ConnectionFailed(
|
||||||
|
"RTS only supported on Serial connections".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncRead for Connection {
|
impl AsyncRead for Connection {
|
||||||
@@ -220,6 +258,8 @@ mod tests {
|
|||||||
parity: SerialParity::None,
|
parity: SerialParity::None,
|
||||||
stop_bits: SerialStopBits::One,
|
stop_bits: SerialStopBits::One,
|
||||||
flow_control: SerialFlowControl::None,
|
flow_control: SerialFlowControl::None,
|
||||||
|
dtr: false,
|
||||||
|
rts: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,11 +294,11 @@ mod tests {
|
|||||||
assert_eq!(original, cloned);
|
assert_eq!(original, cloned);
|
||||||
|
|
||||||
let original = TransportType::Tcp;
|
let original = TransportType::Tcp;
|
||||||
let cloned = original.clone();
|
let cloned = original;
|
||||||
assert_eq!(original, cloned);
|
assert_eq!(original, cloned);
|
||||||
|
|
||||||
let original = TransportType::Udp;
|
let original = TransportType::Udp;
|
||||||
let cloned = original.clone();
|
let cloned = original;
|
||||||
assert_eq!(original, cloned);
|
assert_eq!(original, cloned);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +352,8 @@ mod tests {
|
|||||||
parity: SerialParity::None,
|
parity: SerialParity::None,
|
||||||
stop_bits: SerialStopBits::One,
|
stop_bits: SerialStopBits::One,
|
||||||
flow_control: SerialFlowControl::None,
|
flow_control: SerialFlowControl::None,
|
||||||
|
dtr: false,
|
||||||
|
rts: false,
|
||||||
};
|
};
|
||||||
let _ = format!("{:?}", cfg);
|
let _ = format!("{:?}", cfg);
|
||||||
|
|
||||||
@@ -371,6 +413,8 @@ mod tests {
|
|||||||
parity,
|
parity,
|
||||||
stop_bits,
|
stop_bits,
|
||||||
flow_control,
|
flow_control,
|
||||||
|
dtr,
|
||||||
|
rts,
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(port, "COM1");
|
assert_eq!(port, "COM1");
|
||||||
assert_eq!(baud_rate, 9600);
|
assert_eq!(baud_rate, 9600);
|
||||||
@@ -378,6 +422,8 @@ mod tests {
|
|||||||
assert_eq!(parity, SerialParity::None);
|
assert_eq!(parity, SerialParity::None);
|
||||||
assert_eq!(stop_bits, SerialStopBits::One);
|
assert_eq!(stop_bits, SerialStopBits::One);
|
||||||
assert_eq!(flow_control, SerialFlowControl::None);
|
assert_eq!(flow_control, SerialFlowControl::None);
|
||||||
|
assert!(!dtr);
|
||||||
|
assert!(!rts);
|
||||||
}
|
}
|
||||||
_ => panic!("expected Serial variant"),
|
_ => panic!("expected Serial variant"),
|
||||||
}
|
}
|
||||||
@@ -395,6 +441,8 @@ mod tests {
|
|||||||
parity,
|
parity,
|
||||||
stop_bits,
|
stop_bits,
|
||||||
flow_control,
|
flow_control,
|
||||||
|
dtr,
|
||||||
|
rts,
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(port, "COM2");
|
assert_eq!(port, "COM2");
|
||||||
assert_eq!(baud_rate, 57600);
|
assert_eq!(baud_rate, 57600);
|
||||||
@@ -402,6 +450,8 @@ mod tests {
|
|||||||
assert_eq!(parity, SerialParity::Even);
|
assert_eq!(parity, SerialParity::Even);
|
||||||
assert_eq!(stop_bits, SerialStopBits::Two);
|
assert_eq!(stop_bits, SerialStopBits::Two);
|
||||||
assert_eq!(flow_control, SerialFlowControl::Hardware);
|
assert_eq!(flow_control, SerialFlowControl::Hardware);
|
||||||
|
assert!(!dtr);
|
||||||
|
assert!(!rts);
|
||||||
}
|
}
|
||||||
_ => panic!("expected Serial variant"),
|
_ => panic!("expected Serial variant"),
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||||
use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits};
|
use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits};
|
||||||
|
use serialport::SerialPort;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use super::{Transport, TransportType};
|
use super::{Transport, TransportType};
|
||||||
@@ -83,9 +84,12 @@ pub struct SerialTransport {
|
|||||||
parity: SerialParity,
|
parity: SerialParity,
|
||||||
stop_bits: SerialStopBits,
|
stop_bits: SerialStopBits,
|
||||||
flow_control: SerialFlowControl,
|
flow_control: SerialFlowControl,
|
||||||
|
dtr: bool,
|
||||||
|
rts: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SerialTransport {
|
impl SerialTransport {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
port_name: String,
|
port_name: String,
|
||||||
baud_rate: u32,
|
baud_rate: u32,
|
||||||
@@ -93,6 +97,8 @@ impl SerialTransport {
|
|||||||
parity: SerialParity,
|
parity: SerialParity,
|
||||||
stop_bits: SerialStopBits,
|
stop_bits: SerialStopBits,
|
||||||
flow_control: SerialFlowControl,
|
flow_control: SerialFlowControl,
|
||||||
|
dtr: bool,
|
||||||
|
rts: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
port: None,
|
port: None,
|
||||||
@@ -102,6 +108,8 @@ impl SerialTransport {
|
|||||||
parity,
|
parity,
|
||||||
stop_bits,
|
stop_bits,
|
||||||
flow_control,
|
flow_control,
|
||||||
|
dtr,
|
||||||
|
rts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +146,36 @@ impl SerialTransport {
|
|||||||
pub fn flow_control(&self) -> SerialFlowControl {
|
pub fn flow_control(&self) -> SerialFlowControl {
|
||||||
self.flow_control
|
self.flow_control
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
|
||||||
|
match &mut self.port {
|
||||||
|
Some(port) => {
|
||||||
|
port.write_data_terminal_ready(state)?;
|
||||||
|
self.dtr = state;
|
||||||
|
debug!("DTR set to {} on {}", state, self.port_name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(crate::error::Error::ConnectionFailed(format!(
|
||||||
|
"port {} not open",
|
||||||
|
self.port_name
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_rts(&mut self, state: bool) -> Result<()> {
|
||||||
|
match &mut self.port {
|
||||||
|
Some(port) => {
|
||||||
|
port.write_request_to_send(state)?;
|
||||||
|
self.rts = state;
|
||||||
|
debug!("RTS set to {} on {}", state, self.port_name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(crate::error::Error::ConnectionFailed(format!(
|
||||||
|
"port {} not open",
|
||||||
|
self.port_name
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncRead for SerialTransport {
|
impl AsyncRead for SerialTransport {
|
||||||
@@ -221,7 +259,7 @@ impl Transport for SerialTransport {
|
|||||||
self.flow_control
|
self.flow_control
|
||||||
);
|
);
|
||||||
|
|
||||||
let port = tokio_serial::new(&self.port_name, self.baud_rate)
|
let mut port = tokio_serial::new(&self.port_name, self.baud_rate)
|
||||||
.data_bits(self.data_bits.into())
|
.data_bits(self.data_bits.into())
|
||||||
.parity(self.parity.into())
|
.parity(self.parity.into())
|
||||||
.stop_bits(self.stop_bits.into())
|
.stop_bits(self.stop_bits.into())
|
||||||
@@ -234,6 +272,17 @@ impl Transport for SerialTransport {
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Linux kernel toggles DTR/RTS on every open() — restore configured
|
||||||
|
// state immediately afterward. Many wireless serial modules (HC-12,
|
||||||
|
// HC-15, HC-05, Bluetooth/UART bridges) need DTR asserted to stay in
|
||||||
|
// transparent data mode and not fall into AT-command / reset state.
|
||||||
|
if let Err(e) = port.write_data_terminal_ready(self.dtr) {
|
||||||
|
warn!("Failed to set DTR({}) on {}: {}", self.dtr, self.port_name, e);
|
||||||
|
}
|
||||||
|
if let Err(e) = port.write_request_to_send(self.rts) {
|
||||||
|
warn!("Failed to set RTS({}) on {}: {}", self.rts, self.port_name, e);
|
||||||
|
}
|
||||||
|
|
||||||
debug!("Serial port {} opened successfully", self.port_name);
|
debug!("Serial port {} opened successfully", self.port_name);
|
||||||
self.port = Some(port);
|
self.port = Some(port);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -282,6 +331,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.port_name(), "COM1");
|
assert_eq!(transport.port_name(), "COM1");
|
||||||
assert_eq!(transport.baud_rate(), 115200);
|
assert_eq!(transport.baud_rate(), 115200);
|
||||||
@@ -296,6 +347,8 @@ mod tests {
|
|||||||
SerialParity::Even,
|
SerialParity::Even,
|
||||||
SerialStopBits::Two,
|
SerialStopBits::Two,
|
||||||
SerialFlowControl::Hardware,
|
SerialFlowControl::Hardware,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.port_name(), "COM3");
|
assert_eq!(transport.port_name(), "COM3");
|
||||||
assert_eq!(transport.baud_rate(), 9600);
|
assert_eq!(transport.baud_rate(), 9600);
|
||||||
@@ -314,6 +367,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.name(), "COM1");
|
assert_eq!(transport.name(), "COM1");
|
||||||
}
|
}
|
||||||
@@ -327,6 +382,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.transport_type(), TransportType::Serial);
|
assert_eq!(transport.transport_type(), TransportType::Serial);
|
||||||
}
|
}
|
||||||
@@ -340,6 +397,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert!(!transport.is_connected());
|
assert!(!transport.is_connected());
|
||||||
}
|
}
|
||||||
@@ -368,6 +427,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
let pinned = Pin::new(&mut transport);
|
let pinned = Pin::new(&mut transport);
|
||||||
let mut buf_data = [0u8; 16];
|
let mut buf_data = [0u8; 16];
|
||||||
@@ -391,6 +452,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
let pinned = Pin::new(&mut transport);
|
let pinned = Pin::new(&mut transport);
|
||||||
let data = b"hello";
|
let data = b"hello";
|
||||||
@@ -411,6 +474,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
let pinned = Pin::new(&mut transport);
|
let pinned = Pin::new(&mut transport);
|
||||||
let waker = noop_waker();
|
let waker = noop_waker();
|
||||||
@@ -430,6 +495,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
let pinned = Pin::new(&mut transport);
|
let pinned = Pin::new(&mut transport);
|
||||||
let waker = noop_waker();
|
let waker = noop_waker();
|
||||||
@@ -451,6 +518,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.name(), "COM1");
|
assert_eq!(transport.name(), "COM1");
|
||||||
}
|
}
|
||||||
@@ -464,6 +533,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.transport_type(), TransportType::Serial);
|
assert_eq!(transport.transport_type(), TransportType::Serial);
|
||||||
}
|
}
|
||||||
@@ -477,6 +548,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert!(!transport.is_connected());
|
assert!(!transport.is_connected());
|
||||||
}
|
}
|
||||||
@@ -4,26 +4,26 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|||||||
use tokio::net::{TcpListener, UdpSocket};
|
use tokio::net::{TcpListener, UdpSocket};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
use xserial_core::frame::{
|
use pipeview_core::frame::{
|
||||||
Endian, Framer,
|
Endian, Framer,
|
||||||
cobs::cobs_encode as raw_cobs_encode,
|
|
||||||
cobs::CobsFramer,
|
cobs::CobsFramer,
|
||||||
|
cobs::cobs_encode as raw_cobs_encode,
|
||||||
fixed::FixedLengthFramer,
|
fixed::FixedLengthFramer,
|
||||||
length::{LengthConfig, LengthPrefixedFramer},
|
length::{LengthConfig, LengthPrefixedFramer},
|
||||||
line::{LineConfig, LineFramer},
|
line::{LineConfig, LineFramer},
|
||||||
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
||||||
};
|
};
|
||||||
use xserial_core::protocol::{
|
use pipeview_core::protocol::{
|
||||||
DecodedData, ProtocolDecoder,
|
DecodedData, ProtocolDecoder,
|
||||||
hex::{HexConfig, HexDecoder},
|
hex::{HexConfig, HexDecoder},
|
||||||
mixed::{MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER, MixedTextPlotConfig, MixedTextPlotDecoder},
|
mixed::{MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER, MixedTextPlotConfig, MixedTextPlotDecoder},
|
||||||
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
|
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
|
||||||
text::{TextDecoder, TextEncoding},
|
text::{TextDecoder, TextEncoding},
|
||||||
};
|
};
|
||||||
use xserial_core::transport::serial::{
|
use pipeview_core::transport::serial::{
|
||||||
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
|
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);
|
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 server = tokio::spawn(async move {
|
||||||
let (mut stream, _) = listener.accept().await.unwrap();
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
// 3 samples × 4 bytes each = 12 bytes per frame
|
// 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();
|
let mut data = Vec::new();
|
||||||
for s in &samples {
|
for s in &samples {
|
||||||
data.extend_from_slice(&s.to_le_bytes());
|
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_eq!(frame.channels[0].len(), 3);
|
||||||
assert!((frame.channels[0][0] - 1.0).abs() < 1e-5);
|
assert!((frame.channels[0][0] - 1.0).abs() < 1e-5);
|
||||||
assert!((frame.channels[0][1] - (-2.5)).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),
|
other => panic!("expected Plot, got {:?}", other),
|
||||||
}
|
}
|
||||||
@@ -298,10 +298,7 @@ async fn tcp_mixed_text_and_plot_single_stream() {
|
|||||||
conn.disconnect().await.unwrap();
|
conn.disconnect().await.unwrap();
|
||||||
|
|
||||||
let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default());
|
let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default());
|
||||||
let results: Vec<DecodedData> = frames
|
let results: Vec<DecodedData> = frames.iter().filter_map(|f| decoder.decode(f)).collect();
|
||||||
.iter()
|
|
||||||
.filter_map(|f| decoder.decode(f))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert_eq!(results.len(), 3);
|
assert_eq!(results.len(), 3);
|
||||||
assert!(matches!(&results[0], DecodedData::Text(s) if s == "status ok"));
|
assert!(matches!(&results[0], DecodedData::Text(s) if s == "status ok"));
|
||||||
@@ -691,6 +688,8 @@ async fn connection_transport_type_dispatch() {
|
|||||||
parity: SerialParity::None,
|
parity: SerialParity::None,
|
||||||
stop_bits: SerialStopBits::One,
|
stop_bits: SerialStopBits::One,
|
||||||
flow_control: SerialFlowControl::None,
|
flow_control: SerialFlowControl::None,
|
||||||
|
dtr: false,
|
||||||
|
rts: false,
|
||||||
});
|
});
|
||||||
let tcp = Connection::new(TransportConfig::Tcp {
|
let tcp = Connection::new(TransportConfig::Tcp {
|
||||||
addr: "127.0.0.1:8080".into(),
|
addr: "127.0.0.1:8080".into(),
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "xserial-gui"
|
name = "pipeview-gui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "xserial-gui"
|
name = "pipeview-gui"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
xserial-core = { path = "../xserial-core" }
|
pipeview-core = { path = "../pipeview-core" }
|
||||||
xserial-client = { path = "../xserial-client" }
|
pipeview-client = { path = "../pipeview-client" }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
egui = { workspace = true }
|
egui = { workspace = true }
|
||||||
eframe = { workspace = true }
|
eframe = { workspace = true }
|
||||||
1484
crates/pipeview-gui/src/app.rs
Normal file
1484
crates/pipeview-gui/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
175
crates/pipeview-gui/src/app_state.rs
Normal file
175
crates/pipeview-gui/src/app_state.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
use egui_plot::PlotBounds;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use xserial_client::RingBuffer;
|
use pipeview_client::RingBuffer;
|
||||||
use xserial_client::event::DecodedEntry;
|
use pipeview_client::event::DecodedEntry;
|
||||||
use xserial_core::protocol::DecodedData;
|
use pipeview_core::protocol::DecodedData;
|
||||||
use xserial_core::protocol::plot::{PlotFormat, PlotFrame};
|
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum LineDirection {
|
pub enum LineDirection {
|
||||||
@@ -51,8 +52,12 @@ impl TextBuffer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = &ConsoleLine> {
|
pub fn get(&self, index: usize) -> Option<&ConsoleLine> {
|
||||||
self.lines.iter()
|
self.lines.get(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.lines.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
@@ -62,6 +67,32 @@ impl TextBuffer {
|
|||||||
pub fn set_limit(&mut self, limit: usize) {
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
self.lines.set_limit(limit);
|
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)]
|
#[derive(Clone)]
|
||||||
@@ -135,8 +166,12 @@ impl HexBuffer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = &HexLine> {
|
pub fn get(&self, index: usize) -> Option<&HexLine> {
|
||||||
self.lines.iter()
|
self.lines.get(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.lines.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
@@ -146,6 +181,33 @@ impl HexBuffer {
|
|||||||
pub fn set_limit(&mut self, limit: usize) {
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
self.lines.set_limit(limit);
|
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 {
|
pub struct PlotSeries {
|
||||||
@@ -168,11 +230,17 @@ impl PlotSeries {
|
|||||||
if !sample.is_finite() {
|
if !sample.is_finite() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
self.points.push_back([self.next_x, *sample]);
|
self.push_point([self.next_x, *sample], limit);
|
||||||
self.next_x += 1.0;
|
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()
|
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 {
|
if self.points.is_empty() || max_points == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let visible: Vec<[f64; 2]> = self
|
let mut exact_visible = Vec::with_capacity(max_points.min(self.points.len()));
|
||||||
.points
|
for point in self.points.iter().copied() {
|
||||||
.iter()
|
if point[0] < x_min {
|
||||||
.copied()
|
continue;
|
||||||
.filter(|point| point[0] >= x_min && point[0] <= x_max)
|
}
|
||||||
.collect();
|
if point[0] > x_max {
|
||||||
if visible.len() <= max_points {
|
break;
|
||||||
return visible;
|
}
|
||||||
|
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 bucket_count = (max_points / 2).max(1);
|
||||||
let width = (x_max - x_min).max(1.0);
|
let width = (x_max - x_min).max(1.0);
|
||||||
let bucket_width = width / bucket_count as f64;
|
let bucket_width = width / bucket_count as f64;
|
||||||
let mut rendered = Vec::with_capacity(bucket_count * 2);
|
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 {
|
for bucket_index in 0..bucket_count {
|
||||||
let bucket_start = x_min + bucket_width * bucket_index as f64;
|
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 min_point: Option<[f64; 2]> = None;
|
||||||
let mut max_point: Option<[f64; 2]> = None;
|
let mut max_point: Option<[f64; 2]> = None;
|
||||||
|
|
||||||
while start < visible.len() {
|
while let Some(point) = points.peek().copied() {
|
||||||
let point = visible[start];
|
|
||||||
if point[0] > bucket_end {
|
if point[0] > bucket_end {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -227,7 +324,7 @@ impl PlotSeries {
|
|||||||
_ => max_point = Some(point),
|
_ => max_point = Some(point),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
start += 1;
|
points.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
match (min_point, max_point) {
|
match (min_point, max_point) {
|
||||||
@@ -274,10 +371,69 @@ pub enum PlotSeriesKind {
|
|||||||
XY,
|
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 {
|
pub struct PlotBuffer {
|
||||||
limit: usize,
|
limit: usize,
|
||||||
kind: PlotSeriesKind,
|
kind: PlotSeriesKind,
|
||||||
series: Vec<PlotSeries>,
|
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 {
|
impl PlotBuffer {
|
||||||
@@ -286,6 +442,10 @@ impl PlotBuffer {
|
|||||||
limit,
|
limit,
|
||||||
kind: PlotSeriesKind::TimeSeries,
|
kind: PlotSeriesKind::TimeSeries,
|
||||||
series: Vec::new(),
|
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) {
|
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::XY => PlotSeriesKind::XY,
|
||||||
PlotFormat::Interleaved | PlotFormat::Block => PlotSeriesKind::TimeSeries,
|
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) {
|
if matches!(frame.format, PlotFormat::XY) {
|
||||||
self.push_xy_frame(pipeline_name, frame);
|
self.push_xy_frame(pipeline_name, frame);
|
||||||
@@ -313,15 +481,33 @@ impl PlotBuffer {
|
|||||||
format!("{pipeline_name}:ch{}", index + 1)
|
format!("{pipeline_name}:ch{}", index + 1)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(series) = self
|
if let Some(series_index) = self
|
||||||
.series
|
.series
|
||||||
.iter_mut()
|
.iter()
|
||||||
.find(|series| series.name == series_name)
|
.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 {
|
} else {
|
||||||
let mut series = PlotSeries::new(series_name);
|
let mut series = PlotSeries::new(series_name);
|
||||||
series.push_samples(channel, self.limit);
|
series.push_samples(channel, self.limit);
|
||||||
|
for point in series.points() {
|
||||||
|
self.note_time_series_point(point);
|
||||||
|
}
|
||||||
self.series.push(series);
|
self.series.push(series);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,30 +523,36 @@ impl PlotBuffer {
|
|||||||
let len = x.len().min(y.len());
|
let len = x.len().min(y.len());
|
||||||
let series_name = format!("{pipeline_name}:xy");
|
let series_name = format!("{pipeline_name}:xy");
|
||||||
|
|
||||||
let series = if let Some(series) = self
|
let series_index = if let Some(index) = self
|
||||||
.series
|
.series
|
||||||
.iter_mut()
|
.iter()
|
||||||
.find(|series| series.name == series_name)
|
.position(|series| series.name == series_name)
|
||||||
{
|
{
|
||||||
series
|
index
|
||||||
} else {
|
} else {
|
||||||
self.series.push(PlotSeries::new(series_name));
|
self.series.push(PlotSeries::new(series_name));
|
||||||
self.series.last_mut().expect("just pushed")
|
self.series.len() - 1
|
||||||
};
|
};
|
||||||
|
|
||||||
for index in 0..len {
|
for index in 0..len {
|
||||||
if !x[index].is_finite() || !y[index].is_finite() {
|
if !x[index].is_finite() || !y[index].is_finite() {
|
||||||
continue;
|
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 {
|
self.note_xy_point(point);
|
||||||
series.points.pop_front();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
self.series.clear();
|
self.series.clear();
|
||||||
|
self.invalidate_bounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_limit(&mut self, limit: usize) {
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
@@ -370,6 +562,7 @@ impl PlotBuffer {
|
|||||||
series.points.pop_front();
|
series.points.pop_front();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.invalidate_bounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
@@ -383,14 +576,149 @@ impl PlotBuffer {
|
|||||||
pub fn iter(&self) -> impl Iterator<Item = &PlotSeries> {
|
pub fn iter(&self) -> impl Iterator<Item = &PlotSeries> {
|
||||||
self.series.iter()
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use xserial_client::event::DecodedEntry;
|
use pipeview_client::event::DecodedEntry;
|
||||||
use xserial_core::protocol::DecodedData;
|
use pipeview_core::protocol::DecodedData;
|
||||||
use xserial_core::protocol::plot::{PlotFrame, SampleType};
|
use pipeview_core::protocol::plot::{PlotFrame, SampleType};
|
||||||
|
|
||||||
fn plot_entry() -> DecodedEntry {
|
fn plot_entry() -> DecodedEntry {
|
||||||
DecodedEntry {
|
DecodedEntry {
|
||||||
@@ -459,7 +787,11 @@ mod tests {
|
|||||||
let rendered = series.render_points_time_series(0.0, 99.0, 16);
|
let rendered = series.render_points_time_series(0.0, 99.0, 16);
|
||||||
assert!(!rendered.is_empty());
|
assert!(!rendered.is_empty());
|
||||||
assert!(rendered.len() <= 16);
|
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]
|
#[test]
|
||||||
109
crates/pipeview-gui/src/logging.rs
Normal file
109
crates/pipeview-gui/src/logging.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::{BufWriter, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use pipeview_client::event::DecodedEntry;
|
||||||
|
use pipeview_core::protocol::DecodedData;
|
||||||
|
|
||||||
|
/// Manages background file logging via a dedicated writer thread.
|
||||||
|
///
|
||||||
|
/// Lines are sent through an mpsc channel to a background thread that
|
||||||
|
/// appends to the log file using buffered I/O, keeping the GUI thread
|
||||||
|
/// non-blocking.
|
||||||
|
pub struct LogWriter {
|
||||||
|
sender: mpsc::Sender<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogWriter {
|
||||||
|
/// Spawns a background thread that opens `path` in append mode and
|
||||||
|
/// writes every line it receives through the returned `LogWriter`.
|
||||||
|
pub fn open(path: impl Into<PathBuf>) -> std::io::Result<Self> {
|
||||||
|
let path: PathBuf = path.into();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let file = OpenOptions::new().create(true).append(true).open(&path)?;
|
||||||
|
let mut writer = BufWriter::with_capacity(1024, file);
|
||||||
|
let (sender, receiver) = mpsc::channel::<String>();
|
||||||
|
|
||||||
|
let thread_name = format!("pipeview-log-{}", path.display());
|
||||||
|
thread::Builder::new().name(thread_name).spawn(move || {
|
||||||
|
while let Ok(line) = receiver.recv() {
|
||||||
|
if writeln!(writer, "{line}").is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = writer.flush();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self { sender })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a line to the background writer. Non-blocking — drops the line
|
||||||
|
/// silently if the writer thread has stopped.
|
||||||
|
pub fn write_line(&self, line: &str) {
|
||||||
|
let _ = self.sender.send(line.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LogWriter {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Sender is dropped here, which causes the background thread's
|
||||||
|
// receiver.recv() to return Err, triggering flush + exit.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a [`DecodedEntry`] into a log line.
|
||||||
|
///
|
||||||
|
/// Format: `[HH:MM:SS.mmm] [IN] [pipeline] payload`
|
||||||
|
///
|
||||||
|
/// Returns `None` for Binary and Plot entries (not meaningful as text logs).
|
||||||
|
pub fn format_data_log(
|
||||||
|
entry: &DecodedEntry,
|
||||||
|
direction: &str,
|
||||||
|
show_timestamp: bool,
|
||||||
|
show_direction: bool,
|
||||||
|
show_pipeline: bool,
|
||||||
|
) -> Option<String> {
|
||||||
|
let data = match &entry.data {
|
||||||
|
DecodedData::Text(text) => text.clone(),
|
||||||
|
DecodedData::Hex(hex) => hex.clone(),
|
||||||
|
DecodedData::Binary(_) | DecodedData::Plot(_) => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parts = Vec::with_capacity(4);
|
||||||
|
if show_timestamp {
|
||||||
|
parts.push(utc_timestamp());
|
||||||
|
}
|
||||||
|
if show_direction {
|
||||||
|
parts.push(format!("[{direction}]"));
|
||||||
|
}
|
||||||
|
if show_pipeline {
|
||||||
|
parts.push(format!("[{}]", entry.pipeline_name));
|
||||||
|
}
|
||||||
|
parts.push(data);
|
||||||
|
|
||||||
|
Some(parts.join(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a sent text string into a log line (no `DecodedEntry` available
|
||||||
|
/// for outbound data).
|
||||||
|
pub fn format_sent_log(text: &str) -> String {
|
||||||
|
format!("{} [OUT] {text}", utc_timestamp())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utc_timestamp() -> String {
|
||||||
|
let since_epoch = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let secs = since_epoch.as_secs();
|
||||||
|
let ms = since_epoch.subsec_millis();
|
||||||
|
let h = (secs / 3600) % 24;
|
||||||
|
let m = (secs / 60) % 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
format!("{h:02}:{m:02}:{s:02}.{ms:03}")
|
||||||
|
}
|
||||||
64
crates/pipeview-gui/src/main.rs
Normal file
64
crates/pipeview-gui/src/main.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod app_state;
|
||||||
|
mod buffers;
|
||||||
|
mod logging;
|
||||||
|
mod panels;
|
||||||
|
mod perf;
|
||||||
|
mod shortcuts;
|
||||||
|
mod ui_fonts;
|
||||||
|
|
||||||
|
use pipeview_client::SessionManager;
|
||||||
|
|
||||||
|
/// On Linux, when launched from a file manager, a terminal emulator may be spawned
|
||||||
|
/// to run this binary. We re-spawn ourselves detached so the terminal can close
|
||||||
|
/// while the GUI window persists.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn ensure_detached() {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
if std::env::var("PIPEVIEW_DETACHED").is_ok() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exe = match std::env::current_exe() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
|
||||||
|
let _ = process::Command::new(&exe)
|
||||||
|
.args(&args)
|
||||||
|
.env("PIPEVIEW_DETACHED", "1")
|
||||||
|
.stdin(process::Stdio::null())
|
||||||
|
.stdout(process::Stdio::null())
|
||||||
|
.stderr(process::Stdio::null())
|
||||||
|
.process_group(0)
|
||||||
|
.spawn();
|
||||||
|
|
||||||
|
process::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
ensure_detached();
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||||
|
let _guard = rt.enter();
|
||||||
|
|
||||||
|
let mgr = SessionManager::new();
|
||||||
|
let rx = mgr.subscribe();
|
||||||
|
|
||||||
|
let _ = eframe::run_native(
|
||||||
|
"pipeview",
|
||||||
|
eframe::NativeOptions::default(),
|
||||||
|
Box::new(|cc| Ok(Box::new(app::XserialApp::new(mgr, rx, cc.egui_ctx.clone())))),
|
||||||
|
);
|
||||||
|
}
|
||||||
1046
crates/pipeview-gui/src/panels/config.rs
Normal file
1046
crates/pipeview-gui/src/panels/config.rs
Normal file
File diff suppressed because it is too large
Load Diff
129
crates/pipeview-gui/src/panels/console.rs
Normal file
129
crates/pipeview-gui/src/panels/console.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
use crate::app::{DisplayOptions, SearchState};
|
||||||
|
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
|
||||||
|
use egui::{
|
||||||
|
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
|
||||||
|
text::{LayoutJob, TextFormat},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn render(
|
||||||
|
ui: &mut Ui,
|
||||||
|
buf: &TextBuffer,
|
||||||
|
display: DisplayOptions,
|
||||||
|
search: Option<&SearchState>,
|
||||||
|
) -> usize {
|
||||||
|
let line_count = buf.len();
|
||||||
|
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
||||||
|
ScrollArea::both().stick_to_bottom(true).show_rows(
|
||||||
|
ui,
|
||||||
|
row_height,
|
||||||
|
line_count,
|
||||||
|
|ui, row_range| {
|
||||||
|
for row in row_range {
|
||||||
|
if let Some(line) = buf.get(row) {
|
||||||
|
ui.add(
|
||||||
|
Label::new(format_console_line(line, display, search, ui.style()))
|
||||||
|
.wrap_mode(TextWrapMode::Extend),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
line_count
|
||||||
|
}
|
||||||
|
|
||||||
|
fn highlight_matches(
|
||||||
|
text: &str,
|
||||||
|
query: &str,
|
||||||
|
case_sensitive: bool,
|
||||||
|
default_format: TextFormat,
|
||||||
|
highlight_format: TextFormat,
|
||||||
|
) -> LayoutJob {
|
||||||
|
let search_text = if case_sensitive {
|
||||||
|
text.to_string()
|
||||||
|
} else {
|
||||||
|
text.to_lowercase()
|
||||||
|
};
|
||||||
|
let query = if case_sensitive {
|
||||||
|
query.to_string()
|
||||||
|
} else {
|
||||||
|
query.to_lowercase()
|
||||||
|
};
|
||||||
|
let mut job = LayoutJob::default();
|
||||||
|
let mut last_end = 0;
|
||||||
|
let mut idx = 0;
|
||||||
|
while let Some(pos) = search_text[idx..].find(&query) {
|
||||||
|
let start = idx + pos;
|
||||||
|
let end = start + query.len();
|
||||||
|
if last_end < start {
|
||||||
|
job.append(&text[last_end..start], 0.0, default_format.clone());
|
||||||
|
}
|
||||||
|
job.append(&text[start..end], 0.0, highlight_format.clone());
|
||||||
|
last_end = end;
|
||||||
|
idx = end;
|
||||||
|
}
|
||||||
|
if last_end < text.len() {
|
||||||
|
job.append(&text[last_end..], 0.0, default_format);
|
||||||
|
}
|
||||||
|
job
|
||||||
|
}
|
||||||
|
|
||||||
|
fn monospace_format(style: &egui::Style) -> TextFormat {
|
||||||
|
TextFormat {
|
||||||
|
font_id: style
|
||||||
|
.text_styles
|
||||||
|
.get(&TextStyle::Monospace)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
color: Color32::WHITE,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_console_line(
|
||||||
|
line: &ConsoleLine,
|
||||||
|
display: DisplayOptions,
|
||||||
|
search: Option<&SearchState>,
|
||||||
|
style: &egui::Style,
|
||||||
|
) -> LayoutJob {
|
||||||
|
let mut prefix = Vec::new();
|
||||||
|
if display.show_timestamp {
|
||||||
|
let ts = line.elapsed.as_secs_f64();
|
||||||
|
let time = if ts < 60.0 {
|
||||||
|
format!("{:05.2}", ts)
|
||||||
|
} else {
|
||||||
|
format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64)
|
||||||
|
};
|
||||||
|
prefix.push(format!("[{time}]"));
|
||||||
|
}
|
||||||
|
if display.show_direction {
|
||||||
|
let dir = match line.direction {
|
||||||
|
LineDirection::In => "IN",
|
||||||
|
LineDirection::Out => "OUT",
|
||||||
|
};
|
||||||
|
prefix.push(format!("[{dir}]"));
|
||||||
|
}
|
||||||
|
if display.show_pipeline {
|
||||||
|
prefix.push(format!("[{}]", line.pipeline));
|
||||||
|
}
|
||||||
|
let prefix = if prefix.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("{} ", prefix.join(" "))
|
||||||
|
};
|
||||||
|
let full_text = format!("{prefix}{}", line.text);
|
||||||
|
|
||||||
|
let default = monospace_format(style);
|
||||||
|
let highlight = TextFormat {
|
||||||
|
font_id: default.font_id.clone(),
|
||||||
|
color: Color32::BLACK,
|
||||||
|
background: Color32::from_rgb(255, 255, 0),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match search {
|
||||||
|
Some(state) if state.active && !state.query.is_empty() => {
|
||||||
|
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
|
||||||
|
}
|
||||||
|
_ => LayoutJob::single_section(full_text, default),
|
||||||
|
}
|
||||||
|
}
|
||||||
134
crates/pipeview-gui/src/panels/hex_view.rs
Normal file
134
crates/pipeview-gui/src/panels/hex_view.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
use crate::app::{DisplayOptions, SearchState};
|
||||||
|
use crate::buffers::{HexBuffer, HexLine, LineDirection};
|
||||||
|
use egui::{
|
||||||
|
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
|
||||||
|
text::{LayoutJob, TextFormat},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn render(
|
||||||
|
ui: &mut Ui,
|
||||||
|
buf: &HexBuffer,
|
||||||
|
display: DisplayOptions,
|
||||||
|
search: Option<&SearchState>,
|
||||||
|
) -> usize {
|
||||||
|
let line_count = buf.len();
|
||||||
|
if line_count == 0 {
|
||||||
|
ui.label("no hex data");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
||||||
|
ScrollArea::both().stick_to_bottom(true).show_rows(
|
||||||
|
ui,
|
||||||
|
row_height,
|
||||||
|
line_count,
|
||||||
|
|ui, row_range| {
|
||||||
|
for row in row_range {
|
||||||
|
if let Some(line) = buf.get(row) {
|
||||||
|
ui.add(
|
||||||
|
Label::new(format_hex_line(line, display, search, ui.style()))
|
||||||
|
.wrap_mode(TextWrapMode::Extend),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
line_count
|
||||||
|
}
|
||||||
|
|
||||||
|
fn highlight_matches(
|
||||||
|
text: &str,
|
||||||
|
query: &str,
|
||||||
|
case_sensitive: bool,
|
||||||
|
default_format: TextFormat,
|
||||||
|
highlight_format: TextFormat,
|
||||||
|
) -> LayoutJob {
|
||||||
|
let search_text = if case_sensitive {
|
||||||
|
text.to_string()
|
||||||
|
} else {
|
||||||
|
text.to_lowercase()
|
||||||
|
};
|
||||||
|
let query = if case_sensitive {
|
||||||
|
query.to_string()
|
||||||
|
} else {
|
||||||
|
query.to_lowercase()
|
||||||
|
};
|
||||||
|
let mut job = LayoutJob::default();
|
||||||
|
let mut last_end = 0;
|
||||||
|
let mut idx = 0;
|
||||||
|
while let Some(pos) = search_text[idx..].find(&query) {
|
||||||
|
let start = idx + pos;
|
||||||
|
let end = start + query.len();
|
||||||
|
if last_end < start {
|
||||||
|
job.append(&text[last_end..start], 0.0, default_format.clone());
|
||||||
|
}
|
||||||
|
job.append(&text[start..end], 0.0, highlight_format.clone());
|
||||||
|
last_end = end;
|
||||||
|
idx = end;
|
||||||
|
}
|
||||||
|
if last_end < text.len() {
|
||||||
|
job.append(&text[last_end..], 0.0, default_format);
|
||||||
|
}
|
||||||
|
job
|
||||||
|
}
|
||||||
|
|
||||||
|
fn monospace_format(style: &egui::Style) -> TextFormat {
|
||||||
|
TextFormat {
|
||||||
|
font_id: style
|
||||||
|
.text_styles
|
||||||
|
.get(&TextStyle::Monospace)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
color: Color32::WHITE,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_hex_line(
|
||||||
|
line: &HexLine,
|
||||||
|
display: DisplayOptions,
|
||||||
|
search: Option<&SearchState>,
|
||||||
|
style: &egui::Style,
|
||||||
|
) -> LayoutJob {
|
||||||
|
let mut prefix = Vec::new();
|
||||||
|
if display.show_timestamp {
|
||||||
|
let ts = line.elapsed.as_secs_f64();
|
||||||
|
let time = if ts < 60.0 {
|
||||||
|
format!("{:05.2}", ts)
|
||||||
|
} else {
|
||||||
|
format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64)
|
||||||
|
};
|
||||||
|
prefix.push(format!("[{time}]"));
|
||||||
|
}
|
||||||
|
if display.show_direction {
|
||||||
|
let dir = match line.direction {
|
||||||
|
LineDirection::In => "IN",
|
||||||
|
LineDirection::Out => "OUT",
|
||||||
|
};
|
||||||
|
prefix.push(format!("[{dir}]"));
|
||||||
|
}
|
||||||
|
if display.show_pipeline {
|
||||||
|
prefix.push(format!("[{}]", line.pipeline));
|
||||||
|
}
|
||||||
|
let prefix = if prefix.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("{} ", prefix.join(" "))
|
||||||
|
};
|
||||||
|
let full_text = format!("{prefix}{} |{}|", line.hex, line.ascii);
|
||||||
|
|
||||||
|
let default = monospace_format(style);
|
||||||
|
let highlight = TextFormat {
|
||||||
|
font_id: default.font_id.clone(),
|
||||||
|
color: Color32::BLACK,
|
||||||
|
background: Color32::from_rgb(255, 255, 0),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match search {
|
||||||
|
Some(state) if state.active && !state.query.is_empty() => {
|
||||||
|
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
|
||||||
|
}
|
||||||
|
_ => LayoutJob::single_section(full_text, default),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,31 +1,61 @@
|
|||||||
use crate::buffers::{PlotBuffer, PlotSeriesKind};
|
use crate::buffers::{PlotBuffer, PlotSeriesKind};
|
||||||
|
use crate::perf::PlotRenderStats;
|
||||||
use egui::{Button, Ui, vec2};
|
use egui::{Button, Ui, vec2};
|
||||||
use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints};
|
use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PlotDisplayMode {
|
||||||
|
Embedded,
|
||||||
|
Detached,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
pub struct PlotViewState {
|
pub struct PlotViewState {
|
||||||
pub lock_x: bool,
|
pub lock_x: bool,
|
||||||
pub lock_y: bool,
|
pub lock_y: bool,
|
||||||
pub box_zoom: bool,
|
pub box_zoom: bool,
|
||||||
pub follow_latest: bool,
|
pub follow_latest: bool,
|
||||||
pub last_bounds: Option<PlotBounds>,
|
pub last_bounds: Option<PlotBounds>,
|
||||||
|
pub detached: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PlotViewState {
|
pub struct PlotRenderOutput {
|
||||||
fn default() -> Self {
|
pub stats: PlotRenderStats,
|
||||||
Self {
|
pub toggle_detached: bool,
|
||||||
lock_x: false,
|
|
||||||
lock_y: false,
|
|
||||||
box_zoom: false,
|
|
||||||
follow_latest: false,
|
|
||||||
last_bounds: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
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");
|
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;
|
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() {
|
if ui.button("Reset View").clicked() {
|
||||||
reset_view = true;
|
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.separator();
|
||||||
ui.checkbox(&mut state.lock_x, "Lock X");
|
ui.checkbox(&mut state.lock_x, "Lock X");
|
||||||
ui.checkbox(&mut state.lock_y, "Lock Y");
|
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}"))
|
let mut plot = Plot::new(format!("plot_view_{session_id}"))
|
||||||
.legend(Legend::default())
|
.legend(Legend::default())
|
||||||
.allow_boxed_zoom(state.box_zoom)
|
.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();
|
plot = plot.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut rendered_points = 0usize;
|
||||||
plot.show(ui, |plot_ui| {
|
plot.show(ui, |plot_ui| {
|
||||||
let current_bounds = plot_ui.plot_bounds();
|
let current_bounds = plot_ui.plot_bounds();
|
||||||
state.last_bounds = current_bounds.is_finite().then_some(current_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 {
|
if let Some(bounds) = data_bounds {
|
||||||
let current_width = current_bounds.width();
|
let current_width = current_bounds.width();
|
||||||
let data_width = 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]
|
(bounds.max()[0] - current_width)..=bounds.max()[0]
|
||||||
} else {
|
} else {
|
||||||
bounds.min()[0]..=bounds.max()[0]
|
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());
|
plot_ui.set_plot_bounds_x(x_range.clone());
|
||||||
|
|
||||||
let mut followed = current_bounds;
|
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);
|
state.last_bounds = Some(followed);
|
||||||
}
|
}
|
||||||
} else if zoom_x != 1.0 || zoom_y != 1.0 {
|
} 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() {
|
for series in buf.iter() {
|
||||||
let sampled_points = match buf.kind() {
|
let sampled_points = match buf.kind() {
|
||||||
PlotSeriesKind::TimeSeries => {
|
PlotSeriesKind::TimeSeries => series.render_points_time_series(
|
||||||
series.render_points_time_series(
|
|
||||||
current_bounds.min()[0],
|
current_bounds.min()[0],
|
||||||
current_bounds.max()[0],
|
current_bounds.max()[0],
|
||||||
max_render_points,
|
max_render_points,
|
||||||
)
|
),
|
||||||
}
|
|
||||||
PlotSeriesKind::XY => series.render_points_xy(max_render_points),
|
PlotSeriesKind::XY => series.render_points_xy(max_render_points),
|
||||||
};
|
};
|
||||||
|
rendered_points += sampled_points.len();
|
||||||
let points = PlotPoints::from_iter(sampled_points);
|
let points = PlotPoints::from_iter(sampled_points);
|
||||||
plot_ui.line(Line::new(series.name.clone(), points));
|
plot_ui.line(Line::new(series.name.clone(), points));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
fn plot_bounds(buf: &PlotBuffer) -> Option<PlotBounds> {
|
PlotRenderOutput {
|
||||||
let mut bounds = PlotBounds::NOTHING;
|
stats: PlotRenderStats {
|
||||||
|
series_count: buf.series_len(),
|
||||||
for series in buf.iter() {
|
stored_points: buf.total_points(),
|
||||||
for [x, y] in series.points() {
|
rendered_points,
|
||||||
if x.is_finite() && y.is_finite() {
|
},
|
||||||
bounds.extend_with(&[x, y].into());
|
toggle_detached,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
68
crates/pipeview-gui/src/panels/sidebar.rs
Normal file
68
crates/pipeview-gui/src/panels/sidebar.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
257
crates/pipeview-gui/src/perf.rs
Normal file
257
crates/pipeview-gui/src/perf.rs
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct RepaintCounter {
|
||||||
|
inner: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepaintCounter {
|
||||||
|
pub fn increment(&self) {
|
||||||
|
self.inner.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(&self) -> u64 {
|
||||||
|
self.inner.swap(0, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DurationStats {
|
||||||
|
count: u64,
|
||||||
|
total: Duration,
|
||||||
|
max: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DurationStats {
|
||||||
|
fn record(&mut self, elapsed: Duration) {
|
||||||
|
self.count += 1;
|
||||||
|
self.total += elapsed;
|
||||||
|
self.max = self.max.max(elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn avg_ms(&self) -> f64 {
|
||||||
|
if self.count == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(self.total.as_secs_f64() * 1000.0) / self.count as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max_ms(&self) -> f64 {
|
||||||
|
self.max.as_secs_f64() * 1000.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct IntervalStats {
|
||||||
|
frames: DurationStats,
|
||||||
|
drains: DurationStats,
|
||||||
|
text_renders: DurationStats,
|
||||||
|
hex_renders: DurationStats,
|
||||||
|
plot_renders: DurationStats,
|
||||||
|
drained_events: u64,
|
||||||
|
drained_data_events: u64,
|
||||||
|
drained_text_events: u64,
|
||||||
|
drained_hex_events: u64,
|
||||||
|
drained_plot_events: u64,
|
||||||
|
max_pending_events: usize,
|
||||||
|
max_text_lines: usize,
|
||||||
|
max_hex_lines: usize,
|
||||||
|
max_plot_series: usize,
|
||||||
|
max_plot_stored_points: usize,
|
||||||
|
max_plot_rendered_points: usize,
|
||||||
|
tabs: usize,
|
||||||
|
active_view: &'static str,
|
||||||
|
active_text_lines: usize,
|
||||||
|
active_hex_lines: usize,
|
||||||
|
active_plot_series: usize,
|
||||||
|
active_plot_points: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DrainStats {
|
||||||
|
pub drained_events: u64,
|
||||||
|
pub drained_data_events: u64,
|
||||||
|
pub drained_text_events: u64,
|
||||||
|
pub drained_hex_events: u64,
|
||||||
|
pub drained_plot_events: u64,
|
||||||
|
pub pending_events: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct GuiSnapshot {
|
||||||
|
pub tabs: usize,
|
||||||
|
pub active_view: &'static str,
|
||||||
|
pub active_text_lines: usize,
|
||||||
|
pub active_hex_lines: usize,
|
||||||
|
pub active_plot_series: usize,
|
||||||
|
pub active_plot_points: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PlotRenderStats {
|
||||||
|
pub series_count: usize,
|
||||||
|
pub stored_points: usize,
|
||||||
|
pub rendered_points: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GuiProfiler {
|
||||||
|
enabled: bool,
|
||||||
|
interval: Duration,
|
||||||
|
last_report_at: Instant,
|
||||||
|
repaint_counter: RepaintCounter,
|
||||||
|
interval_stats: IntervalStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GuiProfiler {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let enabled = env_flag("XSERIAL_GUI_PROFILE");
|
||||||
|
let interval_ms = env::var("XSERIAL_GUI_PROFILE_INTERVAL_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
|
.filter(|value| *value > 0)
|
||||||
|
.unwrap_or(1_000);
|
||||||
|
Self {
|
||||||
|
enabled,
|
||||||
|
interval: Duration::from_millis(interval_ms),
|
||||||
|
last_report_at: Instant::now(),
|
||||||
|
repaint_counter: RepaintCounter::default(),
|
||||||
|
interval_stats: IntervalStats {
|
||||||
|
active_view: "none",
|
||||||
|
..IntervalStats::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn repaint_counter(&self) -> RepaintCounter {
|
||||||
|
self.repaint_counter.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_drain(&mut self, elapsed: Duration, stats: DrainStats) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.interval_stats.drains.record(elapsed);
|
||||||
|
self.interval_stats.drained_events += stats.drained_events;
|
||||||
|
self.interval_stats.drained_data_events += stats.drained_data_events;
|
||||||
|
self.interval_stats.drained_text_events += stats.drained_text_events;
|
||||||
|
self.interval_stats.drained_hex_events += stats.drained_hex_events;
|
||||||
|
self.interval_stats.drained_plot_events += stats.drained_plot_events;
|
||||||
|
self.interval_stats.max_pending_events = self
|
||||||
|
.interval_stats
|
||||||
|
.max_pending_events
|
||||||
|
.max(stats.pending_events);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_text_render(&mut self, elapsed: Duration, line_count: usize) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.interval_stats.text_renders.record(elapsed);
|
||||||
|
self.interval_stats.max_text_lines = self.interval_stats.max_text_lines.max(line_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_hex_render(&mut self, elapsed: Duration, line_count: usize) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.interval_stats.hex_renders.record(elapsed);
|
||||||
|
self.interval_stats.max_hex_lines = self.interval_stats.max_hex_lines.max(line_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_plot_render(&mut self, elapsed: Duration, stats: PlotRenderStats) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.interval_stats.plot_renders.record(elapsed);
|
||||||
|
self.interval_stats.max_plot_series =
|
||||||
|
self.interval_stats.max_plot_series.max(stats.series_count);
|
||||||
|
self.interval_stats.max_plot_stored_points = self
|
||||||
|
.interval_stats
|
||||||
|
.max_plot_stored_points
|
||||||
|
.max(stats.stored_points);
|
||||||
|
self.interval_stats.max_plot_rendered_points = self
|
||||||
|
.interval_stats
|
||||||
|
.max_plot_rendered_points
|
||||||
|
.max(stats.rendered_points);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_frame(&mut self, elapsed: Duration, snapshot: GuiSnapshot) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.interval_stats.frames.record(elapsed);
|
||||||
|
self.interval_stats.tabs = snapshot.tabs;
|
||||||
|
self.interval_stats.active_view = snapshot.active_view;
|
||||||
|
self.interval_stats.active_text_lines = snapshot.active_text_lines;
|
||||||
|
self.interval_stats.active_hex_lines = snapshot.active_hex_lines;
|
||||||
|
self.interval_stats.active_plot_series = snapshot.active_plot_series;
|
||||||
|
self.interval_stats.active_plot_points = snapshot.active_plot_points;
|
||||||
|
|
||||||
|
if self.last_report_at.elapsed() < self.interval {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let repaints = self.repaint_counter.take();
|
||||||
|
info!(
|
||||||
|
target: "pipeview_gui::perf",
|
||||||
|
frames = self.interval_stats.frames.count,
|
||||||
|
frame_avg_ms = self.interval_stats.frames.avg_ms(),
|
||||||
|
frame_max_ms = self.interval_stats.frames.max_ms(),
|
||||||
|
drains = self.interval_stats.drains.count,
|
||||||
|
drain_avg_ms = self.interval_stats.drains.avg_ms(),
|
||||||
|
drain_max_ms = self.interval_stats.drains.max_ms(),
|
||||||
|
drained_events = self.interval_stats.drained_events,
|
||||||
|
drained_data_events = self.interval_stats.drained_data_events,
|
||||||
|
drained_text_events = self.interval_stats.drained_text_events,
|
||||||
|
drained_hex_events = self.interval_stats.drained_hex_events,
|
||||||
|
drained_plot_events = self.interval_stats.drained_plot_events,
|
||||||
|
repaint_requests = repaints,
|
||||||
|
max_pending_events = self.interval_stats.max_pending_events,
|
||||||
|
text_renders = self.interval_stats.text_renders.count,
|
||||||
|
text_avg_ms = self.interval_stats.text_renders.avg_ms(),
|
||||||
|
text_max_ms = self.interval_stats.text_renders.max_ms(),
|
||||||
|
max_text_lines = self.interval_stats.max_text_lines,
|
||||||
|
hex_renders = self.interval_stats.hex_renders.count,
|
||||||
|
hex_avg_ms = self.interval_stats.hex_renders.avg_ms(),
|
||||||
|
hex_max_ms = self.interval_stats.hex_renders.max_ms(),
|
||||||
|
max_hex_lines = self.interval_stats.max_hex_lines,
|
||||||
|
plot_renders = self.interval_stats.plot_renders.count,
|
||||||
|
plot_avg_ms = self.interval_stats.plot_renders.avg_ms(),
|
||||||
|
plot_max_ms = self.interval_stats.plot_renders.max_ms(),
|
||||||
|
max_plot_series = self.interval_stats.max_plot_series,
|
||||||
|
max_plot_stored_points = self.interval_stats.max_plot_stored_points,
|
||||||
|
max_plot_rendered_points = self.interval_stats.max_plot_rendered_points,
|
||||||
|
tabs = self.interval_stats.tabs,
|
||||||
|
active_view = self.interval_stats.active_view,
|
||||||
|
active_text_lines = self.interval_stats.active_text_lines,
|
||||||
|
active_hex_lines = self.interval_stats.active_hex_lines,
|
||||||
|
active_plot_series = self.interval_stats.active_plot_series,
|
||||||
|
active_plot_points = self.interval_stats.active_plot_points,
|
||||||
|
"gui perf"
|
||||||
|
);
|
||||||
|
|
||||||
|
self.last_report_at = Instant::now();
|
||||||
|
self.interval_stats = IntervalStats {
|
||||||
|
active_view: "none",
|
||||||
|
..IntervalStats::default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_flag(name: &str) -> bool {
|
||||||
|
env::var(name)
|
||||||
|
.map(|value| {
|
||||||
|
let value = value.trim().to_ascii_lowercase();
|
||||||
|
!value.is_empty() && value != "0" && value != "false" && value != "off"
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
142
crates/pipeview-gui/src/shortcuts.rs
Normal file
142
crates/pipeview-gui/src/shortcuts.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use egui::{Context, Key, KeyboardShortcut, Modifiers};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Action {
|
||||||
|
NextTab,
|
||||||
|
PrevTab,
|
||||||
|
|
||||||
|
ViewText,
|
||||||
|
ViewHex,
|
||||||
|
ViewPlot,
|
||||||
|
|
||||||
|
NewSession,
|
||||||
|
EditSession,
|
||||||
|
DeleteSession,
|
||||||
|
ToggleConnect,
|
||||||
|
Clear,
|
||||||
|
|
||||||
|
UiSettings,
|
||||||
|
CloseOverlay,
|
||||||
|
|
||||||
|
Search,
|
||||||
|
SearchNext,
|
||||||
|
SearchPrev,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
||||||
|
use Action::*;
|
||||||
|
|
||||||
|
vec![
|
||||||
|
(NextTab, KeyboardShortcut::new(Modifiers::CTRL, Key::Tab)),
|
||||||
|
(
|
||||||
|
PrevTab,
|
||||||
|
KeyboardShortcut::new(Modifiers::CTRL | Modifiers::SHIFT, Key::Tab),
|
||||||
|
),
|
||||||
|
(ViewText, KeyboardShortcut::new(Modifiers::CTRL, Key::T)),
|
||||||
|
(ViewHex, KeyboardShortcut::new(Modifiers::CTRL, Key::H)),
|
||||||
|
(ViewPlot, KeyboardShortcut::new(Modifiers::CTRL, Key::P)),
|
||||||
|
(NewSession, KeyboardShortcut::new(Modifiers::CTRL, Key::N)),
|
||||||
|
(EditSession, KeyboardShortcut::new(Modifiers::CTRL, Key::E)),
|
||||||
|
(
|
||||||
|
DeleteSession,
|
||||||
|
KeyboardShortcut::new(Modifiers::CTRL, Key::W),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ToggleConnect,
|
||||||
|
KeyboardShortcut::new(Modifiers::CTRL, Key::F5),
|
||||||
|
),
|
||||||
|
(Clear, KeyboardShortcut::new(Modifiers::CTRL, Key::L)),
|
||||||
|
(
|
||||||
|
UiSettings,
|
||||||
|
KeyboardShortcut::new(Modifiers::CTRL, Key::Comma),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CloseOverlay,
|
||||||
|
KeyboardShortcut::new(Modifiers::NONE, Key::Escape),
|
||||||
|
),
|
||||||
|
(Search, KeyboardShortcut::new(Modifiers::CTRL, Key::F)),
|
||||||
|
(SearchNext, KeyboardShortcut::new(Modifiers::NONE, Key::F3)),
|
||||||
|
(
|
||||||
|
SearchPrev,
|
||||||
|
KeyboardShortcut::new(Modifiers::SHIFT, Key::F3),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process(
|
||||||
|
ctx: &Context,
|
||||||
|
suppress_char_shortcuts: bool,
|
||||||
|
bindings: &[(Action, KeyboardShortcut)],
|
||||||
|
) -> Vec<Action> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
|
||||||
|
ctx.input_mut(|input| {
|
||||||
|
for (action, shortcut) in bindings {
|
||||||
|
if !input.consume_shortcut(shortcut) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if suppress_char_shortcuts && is_char_based(*action) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push(*action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_char_based(action: Action) -> bool {
|
||||||
|
use Action::*;
|
||||||
|
!matches!(action, CloseOverlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bindings_contain_no_duplicate_shortcuts() {
|
||||||
|
let bindings = default_bindings();
|
||||||
|
for (i, (_, a)) in bindings.iter().enumerate() {
|
||||||
|
for (j, (_, b)) in bindings.iter().enumerate() {
|
||||||
|
if i != j {
|
||||||
|
assert_ne!(a, b, "Duplicate shortcut at indices {i} and {j}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bindings_non_empty() {
|
||||||
|
assert!(!default_bindings().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_overlay_not_char_based() {
|
||||||
|
assert!(!is_char_based(Action::CloseOverlay));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_other_actions_are_char_based() {
|
||||||
|
for action in [
|
||||||
|
Action::NextTab,
|
||||||
|
Action::PrevTab,
|
||||||
|
Action::ViewText,
|
||||||
|
Action::ViewHex,
|
||||||
|
Action::ViewPlot,
|
||||||
|
Action::NewSession,
|
||||||
|
Action::EditSession,
|
||||||
|
Action::DeleteSession,
|
||||||
|
Action::ToggleConnect,
|
||||||
|
Action::Clear,
|
||||||
|
Action::UiSettings,
|
||||||
|
Action::Search,
|
||||||
|
Action::SearchNext,
|
||||||
|
Action::SearchPrev,
|
||||||
|
] {
|
||||||
|
assert!(is_char_based(action), "{action:?} should be char-based");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,19 +10,14 @@ use tracing::{info, warn};
|
|||||||
|
|
||||||
const FONT_SETTINGS_FILE_NAME: &str = "gui-fonts.json";
|
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 {
|
pub enum FontChoice {
|
||||||
Auto,
|
Auto,
|
||||||
|
#[default]
|
||||||
Default,
|
Default,
|
||||||
System(String),
|
System(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FontChoice {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct FontCandidate {
|
pub struct FontCandidate {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -60,7 +55,21 @@ impl Default for UiFontSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn discover_font_candidates() -> Vec<FontCandidate> {
|
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 {
|
pub fn load_font_settings() -> UiFontSettings {
|
||||||
@@ -143,9 +152,7 @@ fn load_selected_font(
|
|||||||
) -> Option<(String, Vec<u8>)> {
|
) -> Option<(String, Vec<u8>)> {
|
||||||
match choice {
|
match choice {
|
||||||
FontChoice::Default => None,
|
FontChoice::Default => None,
|
||||||
FontChoice::Auto => {
|
FontChoice::Auto => load_auto_font(candidates, already_loaded),
|
||||||
load_auto_font(candidates, already_loaded)
|
|
||||||
}
|
|
||||||
FontChoice::System(id) => {
|
FontChoice::System(id) => {
|
||||||
let candidate = candidates.iter().find(|candidate| &candidate.id == id)?;
|
let candidate = candidates.iter().find(|candidate| &candidate.id == id)?;
|
||||||
if already_loaded.iter().any(|loaded| loaded == &candidate.id) {
|
if already_loaded.iter().any(|loaded| loaded == &candidate.id) {
|
||||||
@@ -195,19 +202,22 @@ fn apply_font_size(style: &mut Style, settings: &UiFontSettings) {
|
|||||||
style
|
style
|
||||||
.text_styles
|
.text_styles
|
||||||
.insert(TextStyle::Body, FontId::proportional(settings.ui_font_size));
|
.insert(TextStyle::Body, FontId::proportional(settings.ui_font_size));
|
||||||
style
|
style.text_styles.insert(
|
||||||
.text_styles
|
TextStyle::Button,
|
||||||
.insert(TextStyle::Button, FontId::proportional(settings.ui_font_size));
|
FontId::proportional(settings.ui_font_size),
|
||||||
|
);
|
||||||
style.text_styles.insert(
|
style.text_styles.insert(
|
||||||
TextStyle::Monospace,
|
TextStyle::Monospace,
|
||||||
FontId::monospace(settings.monospace_font_size),
|
FontId::monospace(settings.monospace_font_size),
|
||||||
);
|
);
|
||||||
style
|
style.text_styles.insert(
|
||||||
.text_styles
|
TextStyle::Small,
|
||||||
.insert(TextStyle::Small, FontId::proportional((settings.ui_font_size - 2.0).max(8.0)));
|
FontId::proportional((settings.ui_font_size - 2.0).max(8.0)),
|
||||||
style
|
);
|
||||||
.text_styles
|
style.text_styles.insert(
|
||||||
.insert(TextStyle::Heading, FontId::proportional(settings.heading_font_size));
|
TextStyle::Heading,
|
||||||
|
FontId::proportional(settings.heading_font_size),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discover_via_fc_list() -> Option<Vec<FontCandidate>> {
|
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)
|
(!candidates.is_empty()).then_some(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discover_via_fallback_paths() -> Vec<FontCandidate> {
|
fn discover_via_font_directories() -> Vec<FontCandidate> {
|
||||||
let fallback_paths = [
|
let mut candidates = Vec::new();
|
||||||
"/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
|
let mut stack = platform_font_directories()
|
||||||
"/usr/share/fonts/google-noto-sans-cjk-vf-fonts/NotoSansCJK-VF.ttc",
|
|
||||||
"/usr/share/fonts/google-droid-sans-fonts/DroidSansFallbackFull.ttf",
|
|
||||||
];
|
|
||||||
|
|
||||||
fallback_paths
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|path| {
|
.filter(|path| path.is_dir())
|
||||||
fs::metadata(path).ok().map(|_| {
|
.map(|path| (path, 0usize))
|
||||||
let stem = Path::new(path)
|
.collect::<Vec<_>>();
|
||||||
.file_stem()
|
|
||||||
.and_then(|name| name.to_str())
|
while let Some((dir, depth)) = stack.pop() {
|
||||||
.unwrap_or("font")
|
let entries = match fs::read_dir(&dir) {
|
||||||
.to_owned();
|
Ok(entries) => entries,
|
||||||
FontCandidate {
|
Err(err) => {
|
||||||
id: sanitize_id(&stem),
|
warn!(path = %dir.display(), error = %err, "Failed to read font directory");
|
||||||
label: stem.clone(),
|
continue;
|
||||||
display_label: format!("{stem} [CJK]"),
|
|
||||||
path: path.to_owned(),
|
|
||||||
likely_cjk: true,
|
|
||||||
search_key: build_search_key(&stem, "Regular", path),
|
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
})
|
|
||||||
.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 {
|
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 {
|
fn font_settings_path() -> PathBuf {
|
||||||
if let Ok(path) = env::var("XDG_CONFIG_HOME") {
|
font_settings_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_font_settings_from_path(path: &Path) -> io::Result<UiFontSettings> {
|
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)
|
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 {
|
const fn default_ui_font_size() -> f32 {
|
||||||
14.0
|
14.0
|
||||||
}
|
}
|
||||||
@@ -397,6 +558,21 @@ fn is_likely_cjk(family: &str, style: &str, path: &str) -> bool {
|
|||||||
"hei",
|
"hei",
|
||||||
"kai",
|
"kai",
|
||||||
"song",
|
"song",
|
||||||
|
"fang",
|
||||||
|
"yahei",
|
||||||
|
"deng",
|
||||||
|
"ming",
|
||||||
|
"simsun",
|
||||||
|
"simhei",
|
||||||
|
"simkai",
|
||||||
|
"simfang",
|
||||||
|
"msyh",
|
||||||
|
"msjh",
|
||||||
|
"meiryo",
|
||||||
|
"msgothic",
|
||||||
|
"ms gothic",
|
||||||
|
"malgun",
|
||||||
|
"gulim",
|
||||||
"gothic",
|
"gothic",
|
||||||
"mincho",
|
"mincho",
|
||||||
]
|
]
|
||||||
@@ -415,7 +591,7 @@ mod tests {
|
|||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.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 path = dir.join("gui-fonts.json");
|
||||||
let settings = UiFontSettings {
|
let settings = UiFontSettings {
|
||||||
primary_choice: FontChoice::System(String::from("primary")),
|
primary_choice: FontChoice::System(String::from("primary")),
|
||||||
@@ -436,4 +612,62 @@ mod tests {
|
|||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
let _ = fs::remove_dir_all(&dir);
|
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"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "xserial-tui"
|
name = "pipeview-tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "xserial-tui"
|
name = "pipeview-tui"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
xserial-core = { path = "../xserial-core" }
|
pipeview-core = { path = "../pipeview-core" }
|
||||||
xserial-client = { path = "../xserial-client" }
|
pipeview-client = { path = "../pipeview-client" }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
ratatui = { workspace = true }
|
ratatui = { workspace = true }
|
||||||
crossterm = { workspace = true }
|
crossterm = { workspace = true }
|
||||||
@@ -19,3 +19,6 @@ tracing-appender = { workspace = true }
|
|||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
mlua = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
1085
crates/pipeview-tui/src/app.rs
Normal file
1085
crates/pipeview-tui/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
103
crates/pipeview-tui/src/app_state.rs
Normal file
103
crates/pipeview-tui/src/app_state.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::warn;
|
||||||
|
use pipeview_client::SessionConfig;
|
||||||
|
|
||||||
|
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PersistedTuiState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub sessions: Vec<SessionConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active: usize,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_timestamp: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_direction: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_pipeline: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_tui_state() -> PersistedTuiState {
|
||||||
|
match load_tui_state_from_path(&tui_state_path()) {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedTuiState::default(),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "Failed to load persisted TUI state");
|
||||||
|
PersistedTuiState::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_tui_state(state: &PersistedTuiState) {
|
||||||
|
if let Err(err) = save_tui_state_to_path(state, &tui_state_path()) {
|
||||||
|
warn!(error = %err, "Failed to persist TUI state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tui_state_path() -> PathBuf {
|
||||||
|
state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
|
||||||
|
match os {
|
||||||
|
"windows" => {
|
||||||
|
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("pipeview")
|
||||||
|
.join(TUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("pipeview")
|
||||||
|
.join(TUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"macos" => {
|
||||||
|
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(home)
|
||||||
|
.join("Library")
|
||||||
|
.join("Application Support")
|
||||||
|
.join("pipeview")
|
||||||
|
.join(TUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("pipeview")
|
||||||
|
.join(TUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(home)
|
||||||
|
.join(".config")
|
||||||
|
.join("pipeview")
|
||||||
|
.join(TUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PathBuf::from(TUI_STATE_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_tui_state_from_path(path: &Path) -> io::Result<PersistedTuiState> {
|
||||||
|
let text = fs::read_to_string(path)?;
|
||||||
|
serde_json::from_str(&text).map_err(io::Error::other)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_tui_state_to_path(state: &PersistedTuiState, path: &Path) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?;
|
||||||
|
fs::write(path, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
146
crates/pipeview-tui/src/buffers.rs
Normal file
146
crates/pipeview-tui/src/buffers.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use pipeview_client::{DecodedEntry, RingBuffer};
|
||||||
|
use pipeview_core::protocol::DecodedData;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LineDirection {
|
||||||
|
In,
|
||||||
|
Out,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ConsoleLine {
|
||||||
|
pub elapsed: Duration,
|
||||||
|
pub pipeline: String,
|
||||||
|
pub text: String,
|
||||||
|
pub direction: LineDirection,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TextBuffer {
|
||||||
|
started_at: Instant,
|
||||||
|
lines: RingBuffer<ConsoleLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextBuffer {
|
||||||
|
pub fn new(limit: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
started_at: Instant::now(),
|
||||||
|
lines: RingBuffer::new(limit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||||
|
if let DecodedData::Text(text) = &entry.data {
|
||||||
|
self.lines.push(ConsoleLine {
|
||||||
|
elapsed: self.started_at.elapsed(),
|
||||||
|
pipeline: entry.pipeline_name.clone(),
|
||||||
|
text: text.clone(),
|
||||||
|
direction: LineDirection::In,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_outbound(&mut self, text: String) {
|
||||||
|
self.lines.push(ConsoleLine {
|
||||||
|
elapsed: self.started_at.elapsed(),
|
||||||
|
pipeline: String::from("OUT"),
|
||||||
|
text,
|
||||||
|
direction: LineDirection::Out,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.lines.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.lines.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
|
self.lines.set_limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recent(&self, count: usize) -> Vec<ConsoleLine> {
|
||||||
|
self.lines.drain_recent(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HexLine {
|
||||||
|
pub elapsed: Duration,
|
||||||
|
pub pipeline: String,
|
||||||
|
pub hex: String,
|
||||||
|
pub ascii: String,
|
||||||
|
pub direction: LineDirection,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HexBuffer {
|
||||||
|
started_at: Instant,
|
||||||
|
lines: RingBuffer<HexLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HexBuffer {
|
||||||
|
pub fn new(limit: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
started_at: Instant::now(),
|
||||||
|
lines: RingBuffer::new(limit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||||
|
if let DecodedData::Hex(hex) = &entry.data {
|
||||||
|
self.lines.push(HexLine {
|
||||||
|
elapsed: self.started_at.elapsed(),
|
||||||
|
pipeline: entry.pipeline_name.clone(),
|
||||||
|
ascii: decode_ascii(hex),
|
||||||
|
hex: hex.clone(),
|
||||||
|
direction: LineDirection::In,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_outbound(&mut self, hex: String) {
|
||||||
|
self.lines.push(HexLine {
|
||||||
|
elapsed: self.started_at.elapsed(),
|
||||||
|
pipeline: String::from("OUT"),
|
||||||
|
ascii: decode_ascii(&hex),
|
||||||
|
hex,
|
||||||
|
direction: LineDirection::Out,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.lines.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.lines.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
|
self.lines.set_limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recent(&self, count: usize) -> Vec<HexLine> {
|
||||||
|
self.lines.drain_recent(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_ascii(hex: &str) -> String {
|
||||||
|
hex::decode(hex.replace(' ', ""))
|
||||||
|
.map(|bytes| {
|
||||||
|
bytes
|
||||||
|
.into_iter()
|
||||||
|
.map(|byte| {
|
||||||
|
if byte.is_ascii_graphic() || byte == b' ' {
|
||||||
|
byte as char
|
||||||
|
} else {
|
||||||
|
'.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| String::from("[invalid hex]"))
|
||||||
|
}
|
||||||
38
crates/pipeview-tui/src/main.rs
Normal file
38
crates/pipeview-tui/src/main.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
mod app;
|
||||||
|
mod app_state;
|
||||||
|
mod buffers;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
execute,
|
||||||
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
|
};
|
||||||
|
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||||
|
use pipeview_client::SessionManager;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> io::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
let rx = manager.subscribe();
|
||||||
|
|
||||||
|
let result = app::run(&mut terminal, app::App::new(manager, rx));
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
332
crates/pipeview-tui/src/ui.rs
Normal file
332
crates/pipeview-tui/src/ui.rs
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Modifier, Style},
|
||||||
|
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::{App, AppMode, ConnectionStatus, DisplayOptions, SendMode, SessionForm, View};
|
||||||
|
use crate::buffers::{ConsoleLine, HexLine, LineDirection};
|
||||||
|
|
||||||
|
const MAX_RENDER_LINES: usize = 400;
|
||||||
|
|
||||||
|
pub fn render(frame: &mut Frame, app: &App) {
|
||||||
|
let root = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Length(34), Constraint::Min(20)])
|
||||||
|
.split(frame.area());
|
||||||
|
|
||||||
|
render_sidebar(frame, app, root[0]);
|
||||||
|
render_main(frame, app, root[1]);
|
||||||
|
|
||||||
|
if let AppMode::SessionForm(form) = app.mode() {
|
||||||
|
render_session_form(frame, form);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let items: Vec<ListItem> = app
|
||||||
|
.tabs()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, tab)| {
|
||||||
|
let prefix = if index == app.active_index() {
|
||||||
|
"> "
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
let transport = App::transport_summary(&tab.session_config);
|
||||||
|
ListItem::new(format!(
|
||||||
|
"{prefix}[{}] {}\n {}",
|
||||||
|
tab.id,
|
||||||
|
tab.status.badge(),
|
||||||
|
transport
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let sidebar = if items.is_empty() {
|
||||||
|
Paragraph::new("No sessions.\nPress n to create one.")
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Sessions"))
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
} else {
|
||||||
|
Paragraph::new("").block(Block::default().borders(Borders::ALL).title("Sessions"))
|
||||||
|
};
|
||||||
|
|
||||||
|
frame.render_widget(sidebar, area);
|
||||||
|
if !items.is_empty() {
|
||||||
|
frame.render_widget(
|
||||||
|
List::new(items).block(Block::default().borders(Borders::ALL).title("Sessions")),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_main(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let sections = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(4),
|
||||||
|
Constraint::Min(8),
|
||||||
|
Constraint::Length(7),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
if let Some(tab) = app.active_tab() {
|
||||||
|
render_header(frame, app, sections[0]);
|
||||||
|
render_receive(frame, app, sections[1]);
|
||||||
|
render_send(frame, app, sections[2]);
|
||||||
|
let footer = format!(
|
||||||
|
"{}{}",
|
||||||
|
app.help_text(),
|
||||||
|
app.notice()
|
||||||
|
.map(|notice| format!(" | {notice}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(footer)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Help"))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
sections[3],
|
||||||
|
);
|
||||||
|
|
||||||
|
if let ConnectionStatus::Error(_) = &tab.status {
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!(
|
||||||
|
"No active session.\n{}\n{}",
|
||||||
|
app.help_text(),
|
||||||
|
app.notice().unwrap_or("")
|
||||||
|
))
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("pipeview-tui"))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_header(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let tab = app.active_tab().expect("active tab");
|
||||||
|
let display = app.display();
|
||||||
|
let status_line = match &tab.status {
|
||||||
|
ConnectionStatus::Error(message) => {
|
||||||
|
format!("Session {} [{}] {}", tab.id, tab.status.badge(), message)
|
||||||
|
}
|
||||||
|
_ => format!("Session {} [{}]", tab.id, tab.status.badge()),
|
||||||
|
};
|
||||||
|
let line2 = format!(
|
||||||
|
"{} | View: {} | Send: {} | AutoReconnect: {} | AppendNewline: {}",
|
||||||
|
App::transport_summary(&tab.session_config),
|
||||||
|
match tab.view {
|
||||||
|
View::Text => "Text",
|
||||||
|
View::Hex => "Hex",
|
||||||
|
},
|
||||||
|
match tab.send_mode {
|
||||||
|
SendMode::Text => "Text",
|
||||||
|
SendMode::Hex => "Hex",
|
||||||
|
},
|
||||||
|
tab.auto_reconnect,
|
||||||
|
tab.append_newline
|
||||||
|
);
|
||||||
|
let line3 = format!(
|
||||||
|
"Display: ts={} dir={} pipe={} | Text lines={} Hex lines={}",
|
||||||
|
display.show_timestamp,
|
||||||
|
display.show_direction,
|
||||||
|
display.show_pipeline,
|
||||||
|
tab.console.len(),
|
||||||
|
tab.hex.len()
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!("{status_line}\n{line2}\n{line3}"))
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Session"))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_receive(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let tab = app.active_tab().expect("active tab");
|
||||||
|
let display = app.display();
|
||||||
|
let title = match tab.view {
|
||||||
|
View::Text => "Receive Text",
|
||||||
|
View::Hex => "Receive Hex",
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines: Vec<String> = match tab.view {
|
||||||
|
View::Text => tab
|
||||||
|
.console
|
||||||
|
.recent(MAX_RENDER_LINES)
|
||||||
|
.into_iter()
|
||||||
|
.map(|line| format_console_line(&line, display))
|
||||||
|
.collect(),
|
||||||
|
View::Hex => tab
|
||||||
|
.hex
|
||||||
|
.recent(MAX_RENDER_LINES)
|
||||||
|
.into_iter()
|
||||||
|
.map(|line| format_hex_line(&line, display))
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = if lines.is_empty() {
|
||||||
|
String::from("no data")
|
||||||
|
} else {
|
||||||
|
lines.join("\n")
|
||||||
|
};
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(body)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_send(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let tab = app.active_tab().expect("active tab");
|
||||||
|
let editing = matches!(app.mode(), AppMode::SendInput);
|
||||||
|
let title = if editing { "Send [editing]" } else { "Send" };
|
||||||
|
let status = tab.send_status.as_deref().unwrap_or("idle");
|
||||||
|
let hint = match tab.send_mode {
|
||||||
|
SendMode::Text => "Press i to edit input, Enter to send while editing",
|
||||||
|
SendMode::Hex => "Hex bytes, e.g. 48 65 6C 6C 6F",
|
||||||
|
};
|
||||||
|
let body = format!(
|
||||||
|
"Mode: {} | Append newline: {}\nStatus: {}\nHint: {}\n\n{}",
|
||||||
|
match tab.send_mode {
|
||||||
|
SendMode::Text => "Text",
|
||||||
|
SendMode::Hex => "Hex",
|
||||||
|
},
|
||||||
|
tab.append_newline,
|
||||||
|
status,
|
||||||
|
hint,
|
||||||
|
if tab.send_input.is_empty() {
|
||||||
|
String::from("<empty>")
|
||||||
|
} else {
|
||||||
|
tab.send_input.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(body)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_session_form(frame: &mut Frame, form: &SessionForm) {
|
||||||
|
let rows = form.rows();
|
||||||
|
let footer_lines = form.footer_lines();
|
||||||
|
let height = (rows.len() + footer_lines.len() + 4) as u16;
|
||||||
|
let area = centered_rect(82, height, frame.area());
|
||||||
|
let inner = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(footer_lines.len() as u16),
|
||||||
|
])
|
||||||
|
.margin(1)
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = rows
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, row)| ListItem::new(row).style(focus_style(idx == form.focused_field)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(
|
||||||
|
Block::default().borders(Borders::ALL).title(form.title()),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
frame.render_widget(List::new(items), inner[0]);
|
||||||
|
frame.render_widget(Paragraph::new(footer_lines.join("\n")), inner[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_console_line(line: &ConsoleLine, display: DisplayOptions) -> String {
|
||||||
|
let mut prefix = Vec::new();
|
||||||
|
if display.show_timestamp {
|
||||||
|
prefix.push(format!("[{}]", format_elapsed(line.elapsed)));
|
||||||
|
}
|
||||||
|
if display.show_direction {
|
||||||
|
prefix.push(format!(
|
||||||
|
"[{}]",
|
||||||
|
match line.direction {
|
||||||
|
LineDirection::In => "IN",
|
||||||
|
LineDirection::Out => "OUT",
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if display.show_pipeline {
|
||||||
|
prefix.push(format!("[{}]", line.pipeline));
|
||||||
|
}
|
||||||
|
if prefix.is_empty() {
|
||||||
|
line.text.clone()
|
||||||
|
} else {
|
||||||
|
format!("{} {}", prefix.join(" "), line.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_hex_line(line: &HexLine, display: DisplayOptions) -> String {
|
||||||
|
let mut prefix = Vec::new();
|
||||||
|
if display.show_timestamp {
|
||||||
|
prefix.push(format!("[{}]", format_elapsed(line.elapsed)));
|
||||||
|
}
|
||||||
|
if display.show_direction {
|
||||||
|
prefix.push(format!(
|
||||||
|
"[{}]",
|
||||||
|
match line.direction {
|
||||||
|
LineDirection::In => "IN",
|
||||||
|
LineDirection::Out => "OUT",
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if display.show_pipeline {
|
||||||
|
prefix.push(format!("[{}]", line.pipeline));
|
||||||
|
}
|
||||||
|
let base = format!("{} |{}|", line.hex, line.ascii);
|
||||||
|
if prefix.is_empty() {
|
||||||
|
base
|
||||||
|
} else {
|
||||||
|
format!("{} {}", prefix.join(" "), base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_elapsed(elapsed: std::time::Duration) -> String {
|
||||||
|
let secs = elapsed.as_secs_f64();
|
||||||
|
if secs < 60.0 {
|
||||||
|
format!("{secs:05.2}")
|
||||||
|
} else {
|
||||||
|
format!("{:02}:{:02}", (secs / 60.0) as u64, (secs % 60.0) as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focus_style(focused: bool) -> Style {
|
||||||
|
if focused {
|
||||||
|
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||||
|
let vertical = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(height.min(area.height)),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(width.min(area.width)),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.split(vertical[1])[1]
|
||||||
|
}
|
||||||
@@ -1,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})"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
use crate::app::DisplayOptions;
|
|
||||||
use crate::buffers::{LineDirection, TextBuffer};
|
|
||||||
use egui::{ScrollArea, Ui};
|
|
||||||
|
|
||||||
pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) {
|
|
||||||
ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| {
|
|
||||||
for line in buf.iter() {
|
|
||||||
let mut prefix = Vec::new();
|
|
||||||
if display.show_timestamp {
|
|
||||||
let ts = line.elapsed.as_secs_f64();
|
|
||||||
let time = if ts < 60.0 {
|
|
||||||
format!("{:05.2}", ts)
|
|
||||||
} else {
|
|
||||||
format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64)
|
|
||||||
};
|
|
||||||
prefix.push(format!("[{time}]"));
|
|
||||||
}
|
|
||||||
if display.show_direction {
|
|
||||||
let dir = match line.direction {
|
|
||||||
LineDirection::In => "IN",
|
|
||||||
LineDirection::Out => "OUT",
|
|
||||||
};
|
|
||||||
prefix.push(format!("[{dir}]"));
|
|
||||||
}
|
|
||||||
if display.show_pipeline {
|
|
||||||
prefix.push(format!("[{}]", line.pipeline));
|
|
||||||
}
|
|
||||||
let prefix = if prefix.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("{} ", prefix.join(" "))
|
|
||||||
};
|
|
||||||
ui.monospace(format!("{prefix}{}", line.text));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
use crate::app::DisplayOptions;
|
|
||||||
use crate::buffers::{HexBuffer, LineDirection};
|
|
||||||
use egui::{ScrollArea, Ui};
|
|
||||||
|
|
||||||
pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) {
|
|
||||||
let mut lines = buf.iter().peekable();
|
|
||||||
if lines.peek().is_none() {
|
|
||||||
ui.label("no hex data");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| {
|
|
||||||
for line in lines {
|
|
||||||
let mut prefix = Vec::new();
|
|
||||||
if display.show_timestamp {
|
|
||||||
let ts = line.elapsed.as_secs_f64();
|
|
||||||
let time = if ts < 60.0 {
|
|
||||||
format!("{:05.2}", ts)
|
|
||||||
} else {
|
|
||||||
format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64)
|
|
||||||
};
|
|
||||||
prefix.push(format!("[{time}]"));
|
|
||||||
}
|
|
||||||
if display.show_direction {
|
|
||||||
let dir = match line.direction {
|
|
||||||
LineDirection::In => "IN",
|
|
||||||
LineDirection::Out => "OUT",
|
|
||||||
};
|
|
||||||
prefix.push(format!("[{dir}]"));
|
|
||||||
}
|
|
||||||
if display.show_pipeline {
|
|
||||||
prefix.push(format!("[{}]", line.pipeline));
|
|
||||||
}
|
|
||||||
let prefix = if prefix.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("{} ", prefix.join(" "))
|
|
||||||
};
|
|
||||||
ui.monospace(format!("{prefix}{} |{}|", line.hex, line.ascii));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
fn main() {}
|
|
||||||
16
examples/drone_plot.lua
Normal file
16
examples/drone_plot.lua
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
if #frame == 0 then return nil end
|
||||||
|
|
||||||
|
local gyro_str = frame:match("Gyro:([%d%.%-]+),([%d%.%-]+),([%d%.%-]+)")
|
||||||
|
if not gyro_str then return nil end
|
||||||
|
|
||||||
|
local gz, gy, gx = frame:match("Gyro:([%d%.%-]+),([%d%.%-]+),([%d%.%-]+)")
|
||||||
|
return {
|
||||||
|
kind = "plot",
|
||||||
|
channels = { { tonumber(gz) }, { tonumber(gy) }, { tonumber(gx) } },
|
||||||
|
sample_type = "F64",
|
||||||
|
format = "Block",
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
33
examples/drone_text.lua
Normal file
33
examples/drone_text.lua
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
local LABELS = {
|
||||||
|
AHRS = "Quaternion",
|
||||||
|
YPR = "Yaw/Pitch/Roll",
|
||||||
|
Gyro = "Gyro (deg/s)",
|
||||||
|
RC = "RC Channels",
|
||||||
|
M = "Motors",
|
||||||
|
L = "L",
|
||||||
|
F = "F",
|
||||||
|
C = "C",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
if #frame == 0 then return nil end
|
||||||
|
|
||||||
|
local lines = {}
|
||||||
|
for segment in frame:gmatch("[^|]+") do
|
||||||
|
local colon = segment:find(":")
|
||||||
|
if colon then
|
||||||
|
local key = segment:sub(1, colon - 1)
|
||||||
|
local val = segment:sub(colon + 1)
|
||||||
|
local label = LABELS[key] or key
|
||||||
|
lines[#lines + 1] = string.format("%-18s %s", label .. ":", val)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
lines[#lines + 1] = string.rep("-", 40)
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind = "text",
|
||||||
|
data = table.concat(lines, "\n"),
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
43
tests/lua_line_framer.lua
Normal file
43
tests/lua_line_framer.lua
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
local buffer = ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
feed = function(bytes)
|
||||||
|
buffer = buffer .. bytes
|
||||||
|
local frames = {}
|
||||||
|
|
||||||
|
while true do
|
||||||
|
local i = buffer:find("\n", 1, true)
|
||||||
|
if not i then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
local frame = buffer:sub(1, i - 1)
|
||||||
|
if frame:sub(-1) == "\r" then
|
||||||
|
frame = frame:sub(1, -2)
|
||||||
|
end
|
||||||
|
|
||||||
|
frames[#frames + 1] = frame
|
||||||
|
buffer = buffer:sub(i + 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
return frames
|
||||||
|
end,
|
||||||
|
|
||||||
|
flush = function()
|
||||||
|
if #buffer == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local frame = buffer
|
||||||
|
buffer = ""
|
||||||
|
return frame
|
||||||
|
end,
|
||||||
|
|
||||||
|
reset = function()
|
||||||
|
buffer = ""
|
||||||
|
end,
|
||||||
|
|
||||||
|
pending_len = function()
|
||||||
|
return #buffer
|
||||||
|
end,
|
||||||
|
}
|
||||||
12
tests/lua_text_decoder.lua
Normal file
12
tests/lua_text_decoder.lua
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
if #frame == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind = "text",
|
||||||
|
data = frame,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
102
tools/test_drone.py
Normal file
102
tools/test_drone.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate fake drone telemetry data for testing pipeview Lua decoder."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def generate_line(t: float) -> str:
|
||||||
|
"""Generate one telemetry line with simulated sensor values."""
|
||||||
|
# Quaternion (w, x, y, z) — gently rotating
|
||||||
|
angle = t * 0.5
|
||||||
|
qw = math.cos(angle)
|
||||||
|
qx = math.sin(angle) * 0.1
|
||||||
|
qy = math.sin(angle) * 0.05
|
||||||
|
qz = math.sin(angle) * 0.02
|
||||||
|
|
||||||
|
# Yaw/Pitch/Roll — sine wave simulation
|
||||||
|
yaw = math.degrees(math.sin(t * 0.3)) * 30
|
||||||
|
pitch = math.degrees(math.sin(t * 0.5)) * 15
|
||||||
|
roll = math.degrees(math.sin(t * 0.7)) * 10
|
||||||
|
|
||||||
|
# Gyro (deg/s)
|
||||||
|
gz = math.sin(t * 0.3) * 50 + random.uniform(-2, 2)
|
||||||
|
gy = math.sin(t * 0.5) * 30 + random.uniform(-2, 2)
|
||||||
|
gx = math.sin(t * 0.7) * 20 + random.uniform(-2, 2)
|
||||||
|
|
||||||
|
# RC channels (1000-2000 us)
|
||||||
|
rc_r = int(1500 + math.sin(t * 0.3) * 200)
|
||||||
|
rc_p = int(1500 + math.sin(t * 0.5) * 150)
|
||||||
|
rc_t = int(1000 + (math.sin(t * 0.1) + 1) * 500) # throttle 1000-2000
|
||||||
|
rc_y = int(1500 + math.sin(t * 0.3) * 100)
|
||||||
|
|
||||||
|
# Motors (1000-2000)
|
||||||
|
base = 1200 + int((math.sin(t * 0.1) + 1) * 400)
|
||||||
|
m1 = base + random.randint(-20, 20)
|
||||||
|
m2 = base + random.randint(-20, 20)
|
||||||
|
m3 = base + random.randint(-20, 20)
|
||||||
|
m4 = base + random.randint(-20, 20)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"AHRS q:{qw:.4f},{qx:.4f},{qy:.4f},{qz:.4f}|"
|
||||||
|
f"YPR:{yaw:.2f},{pitch:.2f},{roll:.2f}|"
|
||||||
|
f"Gyro:{gz:.2f},{gy:.2f},{gx:.2f}|"
|
||||||
|
f"RC:{rc_r},{rc_p},{rc_t},{rc_y}|"
|
||||||
|
f"M:{m1},{m2},{m3},{m4}|"
|
||||||
|
f"L:0 F:1 C:0\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Drone telemetry test data generator")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1", help="TCP host")
|
||||||
|
parser.add_argument("--port", type=int, default=8092, help="TCP port")
|
||||||
|
parser.add_argument("--rate", type=float, default=10, help="Lines per second")
|
||||||
|
parser.add_argument("--duration", type=float, default=0, help="Seconds to run (0 = forever)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
interval = 1.0 / args.rate
|
||||||
|
print(f"Sending drone telemetry to {args.host}:{args.port} at {args.rate} Hz")
|
||||||
|
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
|
||||||
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
server.bind((args.host, args.port))
|
||||||
|
server.listen(1)
|
||||||
|
print(f"Listening on {args.host}:{args.port}, waiting for connections...")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
sock, addr = server.accept()
|
||||||
|
print(f"Client connected from {addr}")
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
seq = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
t = time.time() - t0
|
||||||
|
line = generate_line(t)
|
||||||
|
try:
|
||||||
|
sock.sendall(line.encode())
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
break
|
||||||
|
seq += 1
|
||||||
|
|
||||||
|
if args.duration > 0 and t >= args.duration:
|
||||||
|
break
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
target = (seq + 1) * interval
|
||||||
|
sleep_time = target - elapsed
|
||||||
|
if sleep_time > 0:
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"Client disconnected. Sent {seq} lines. Waiting for new connection...")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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 argparse
|
||||||
import math
|
import math
|
||||||
@@ -10,6 +10,11 @@ import time
|
|||||||
|
|
||||||
PLOT_ESCAPE = 0x1E
|
PLOT_ESCAPE = 0x1E
|
||||||
PLOT_MARKER = ord("P")
|
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(
|
def build_frame(
|
||||||
@@ -98,44 +103,56 @@ def build_mixed_plot_frame(
|
|||||||
)
|
)
|
||||||
return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00"
|
return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00"
|
||||||
|
|
||||||
def main():
|
|
||||||
p = argparse.ArgumentParser(description="xserial plot test server")
|
def is_disconnect_error(err: OSError) -> bool:
|
||||||
p.add_argument("--host", default="127.0.0.1")
|
return isinstance(
|
||||||
p.add_argument("--port", type=int, default=8080)
|
err,
|
||||||
p.add_argument("--channels", type=int, default=1)
|
(
|
||||||
p.add_argument(
|
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",
|
"--format",
|
||||||
choices=("interleaved", "xy"),
|
choices=("interleaved", "xy"),
|
||||||
default="interleaved",
|
default="interleaved",
|
||||||
help="plot format (default: interleaved)",
|
help="plot format (default: interleaved)",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
parser.add_argument(
|
||||||
"--rate",
|
"--rate",
|
||||||
type=float,
|
type=float,
|
||||||
default=1024,
|
default=1024,
|
||||||
help="samples/sec per channel (default: 1024)",
|
help="samples/sec per channel (default: 1024)",
|
||||||
)
|
)
|
||||||
p.add_argument("--freq", type=float, default=1.0, help="sine Hz")
|
parser.add_argument("--freq", type=float, default=1.0, help="sine Hz")
|
||||||
p.add_argument("--amp", type=float, default=100)
|
parser.add_argument("--amp", type=float, default=100)
|
||||||
p.add_argument(
|
parser.add_argument(
|
||||||
"--text-interval",
|
"--text-interval",
|
||||||
type=float,
|
type=float,
|
||||||
default=1.0,
|
default=1.0,
|
||||||
help="seconds between status text messages (default: 1.0)",
|
help="seconds between status text messages (default: 1.0)",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
parser.add_argument(
|
||||||
"--framelen",
|
"--framelen",
|
||||||
type=int,
|
type=int,
|
||||||
default=0,
|
default=0,
|
||||||
help="fixed frame bytes (0=one sample per channel per frame)",
|
help="fixed frame bytes (0=one sample per channel per frame)",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
parser.add_argument(
|
||||||
"--wire-format",
|
"--wire-format",
|
||||||
choices=("mixed", "raw"),
|
choices=("mixed", "raw", "text"),
|
||||||
default="mixed",
|
default="mixed",
|
||||||
help="mixed sends text+plot on one connection; raw sends plot only",
|
help="mixed sends text+plot; raw sends plot only; text sends text only",
|
||||||
)
|
)
|
||||||
args = p.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
sample_size = 4 # f32
|
sample_size = 4 # f32
|
||||||
|
|
||||||
@@ -176,17 +193,36 @@ def main():
|
|||||||
values.append(args.amp * math.sin(2 * math.pi * args.freq * t + phase))
|
values.append(args.amp * math.sin(2 * math.pi * args.freq * t + phase))
|
||||||
return values
|
return values
|
||||||
|
|
||||||
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("GUI:")
|
||||||
print(f" transport = TCP {args.host}:{args.port}")
|
print(f" transport = TCP {args.host}:{args.port}")
|
||||||
if args.wire_format == "mixed":
|
if args.wire_format == "mixed":
|
||||||
print(" framer = MixedTextPlot")
|
print(" framer = MixedTextPlot")
|
||||||
print(" decoder = MixedTextPlot\n")
|
print(" decoder = MixedTextPlot\n")
|
||||||
else:
|
elif args.wire_format == "raw":
|
||||||
print(f" framer = Fixed({frame_bytes})")
|
print(f" framer = Fixed({frame_bytes})")
|
||||||
print(
|
print(
|
||||||
f" decoder = Plot(f32, little-endian, {args.channels} channel, {args.format})\n"
|
f" decoder = Plot(f32, little-endian, {args.channels} channel, {args.format})\n"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
print(" framer = Line")
|
||||||
|
print(" decoder = Text")
|
||||||
|
print(" lua test = Lua framer tests/lua_line_framer.lua")
|
||||||
|
print(" Lua decoder tests/lua_text_decoder.lua\n")
|
||||||
|
if args.wire_format == "text":
|
||||||
|
print(f"sending text only at {args.text_interval:.3f}s intervals")
|
||||||
|
print(f"sample clock: {args.rate:.1f} samples/sec\n")
|
||||||
|
else:
|
||||||
print(f"sending {samples_per_channel} samples/frame at {args.rate:.1f} samples/sec")
|
print(f"sending {samples_per_channel} samples/frame at {args.rate:.1f} samples/sec")
|
||||||
print(f"effective frame rate: {frame_rate:.2f} fps\n")
|
print(f"effective frame rate: {frame_rate:.2f} fps\n")
|
||||||
|
|
||||||
@@ -213,6 +249,25 @@ def main():
|
|||||||
next_text_at = started_at
|
next_text_at = started_at
|
||||||
try:
|
try:
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
|
if args.wire_format == "text":
|
||||||
|
now = time.perf_counter()
|
||||||
|
wait = next_text_at - now
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(min(wait, 0.1))
|
||||||
|
continue
|
||||||
|
|
||||||
|
sample_index = int((now - started_at) * args.rate)
|
||||||
|
conn.sendall(
|
||||||
|
build_text_line(
|
||||||
|
started_at,
|
||||||
|
now,
|
||||||
|
sample_index,
|
||||||
|
).encode("utf-8")
|
||||||
|
)
|
||||||
|
text_count += 1
|
||||||
|
next_text_at += args.text_interval
|
||||||
|
continue
|
||||||
|
|
||||||
plot_payload = build_frame(
|
plot_payload = build_frame(
|
||||||
sample_index=sample_index,
|
sample_index=sample_index,
|
||||||
channels=args.channels,
|
channels=args.channels,
|
||||||
@@ -235,16 +290,13 @@ def main():
|
|||||||
|
|
||||||
now = time.perf_counter()
|
now = time.perf_counter()
|
||||||
if args.wire_format == "mixed" and now >= next_text_at:
|
if args.wire_format == "mixed" and now >= next_text_at:
|
||||||
values = sample_values(sample_index)
|
conn.sendall(
|
||||||
joined = ", ".join(
|
build_text_line(
|
||||||
f"ch{index + 1}={value:8.3f}"
|
started_at,
|
||||||
for index, value in enumerate(values)
|
now,
|
||||||
|
sample_index,
|
||||||
|
).encode("utf-8")
|
||||||
)
|
)
|
||||||
line = (
|
|
||||||
f"时间={now - started_at:7.3f}s "
|
|
||||||
f"样本={sample_index:7d} {joined}\n"
|
|
||||||
)
|
|
||||||
conn.sendall(line.encode("utf-8"))
|
|
||||||
text_count += 1
|
text_count += 1
|
||||||
next_text_at += args.text_interval
|
next_text_at += args.text_interval
|
||||||
|
|
||||||
@@ -255,8 +307,16 @@ def main():
|
|||||||
wait = frame_count * frame_interval - elapsed
|
wait = frame_count * frame_interval - elapsed
|
||||||
if wait > 0:
|
if wait > 0:
|
||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
except OSError as err:
|
||||||
print(f"[conn -] {addr} ({frame_count} plot frames, {text_count} text lines)")
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
finally:
|
finally:
|
||||||
@@ -274,5 +334,6 @@ def main():
|
|||||||
stop_event.set()
|
stop_event.set()
|
||||||
stream_thread.join(timeout=1.0)
|
stream_thread.join(timeout=1.0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
540
tools/test_plot_serial.c
Normal file
540
tools/test_plot_serial.c
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
/*
|
||||||
|
* test_plot_serial.c — 持续向串口发送 MixedTextPlot 正弦波 + 文本流
|
||||||
|
*
|
||||||
|
* 编译 (Linux):
|
||||||
|
* gcc -std=c11 -Wall -Wextra -O2 -o test_plot_serial tools/test_plot_serial.c -lm
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* ./test_plot_serial /dev/ttyUSB0
|
||||||
|
* ./test_plot_serial /dev/ttyUSB0 --baud 115200 --channels 2 --format xy --rate 1024 --freq 2.0 --amp 150
|
||||||
|
* ./test_plot_serial --help
|
||||||
|
*
|
||||||
|
* 依赖: tools/xs_mixed_plot.h (同目录, 定义 COBS 和 MixedTextPlot 帧格式)
|
||||||
|
*
|
||||||
|
* 协议说明:
|
||||||
|
* Plot 帧: 0x1E 'P' + COBS(packet) + 0x00
|
||||||
|
* 文本帧: UTF-8 文本行 + '\n' (直接写入, 由 MixedTextPlot framer 按行分割)
|
||||||
|
*
|
||||||
|
* 串口参数: 8N1, 无流控, 默认 115200 baud
|
||||||
|
*/
|
||||||
|
|
||||||
|
#define _POSIX_C_SOURCE 200809L
|
||||||
|
#include "xs_mixed_plot.h"
|
||||||
|
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <getopt.h>
|
||||||
|
#include <math.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <termios.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#ifndef M_PI
|
||||||
|
#define M_PI 3.14159265358979323846
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ── 配置默认值 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#define DEFAULT_BAUDRATE 115200
|
||||||
|
#define DEFAULT_RATE 1024.0 /* samples/sec per channel */
|
||||||
|
#define DEFAULT_FREQ 1.0 /* sine Hz */
|
||||||
|
#define DEFAULT_AMP 100.0
|
||||||
|
#define DEFAULT_TEXT_INTERVAL 1.0 /* seconds between text lines */
|
||||||
|
|
||||||
|
/* ── 全局状态 (用于信号处理) ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
static volatile sig_atomic_t g_running = 1;
|
||||||
|
|
||||||
|
static void sigint_handler(int sig) {
|
||||||
|
(void)sig;
|
||||||
|
g_running = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 缓冲大小 ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#define MAX_CHANNELS 8
|
||||||
|
#define MAX_SPC 4096 /* samples_per_channel */
|
||||||
|
#define MAX_VALUES (MAX_CHANNELS * MAX_SPC) /* float values per frame */
|
||||||
|
#define MAX_PAYLOAD (MAX_VALUES * 4)
|
||||||
|
#define MAX_PACKET (13 + MAX_PAYLOAD)
|
||||||
|
#define MAX_FRAME (2 + (MAX_PACKET + MAX_PACKET / 254 + 1) + 1)
|
||||||
|
#define TEXT_BUF_SIZE 512
|
||||||
|
|
||||||
|
/* ── 运行时参数 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *port;
|
||||||
|
int baudrate;
|
||||||
|
uint8_t channels;
|
||||||
|
uint16_t samples_per_channel;
|
||||||
|
uint8_t plot_format; /* XS_PLOT_FORMAT_INTERLEAVED / XY */
|
||||||
|
double rate; /* samples/sec */
|
||||||
|
double freq; /* sine Hz */
|
||||||
|
double amp;
|
||||||
|
double text_interval; /* seconds */
|
||||||
|
} args_t;
|
||||||
|
|
||||||
|
/* ── 串口操作 ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static int serial_open(const char *path, int baudrate) {
|
||||||
|
int fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK);
|
||||||
|
if (fd < 0) {
|
||||||
|
perror("open");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 恢复阻塞模式, 但配合超时使用 select */
|
||||||
|
int flags = fcntl(fd, F_GETFL, 0);
|
||||||
|
if (flags < 0) { perror("fcntl"); close(fd); return -1; }
|
||||||
|
flags &= ~O_NONBLOCK;
|
||||||
|
if (fcntl(fd, F_SETFL, flags) < 0) { perror("fcntl"); close(fd); return -1; }
|
||||||
|
|
||||||
|
struct termios tty;
|
||||||
|
memset(&tty, 0, sizeof(tty));
|
||||||
|
if (tcgetattr(fd, &tty) < 0) { perror("tcgetattr"); close(fd); return -1; }
|
||||||
|
|
||||||
|
/* 输入: 忽略 BREAK, CR→NL 不转换, 奇偶校验不检查 */
|
||||||
|
tty.c_iflag = IGNBRK;
|
||||||
|
|
||||||
|
/* 输出: 原始模式, 不做任何转换 */
|
||||||
|
tty.c_oflag = 0;
|
||||||
|
|
||||||
|
/* 控制: 8 位, 无奇偶校验, 启用接收器 */
|
||||||
|
tty.c_cflag = CS8 | CREAD | CLOCAL;
|
||||||
|
|
||||||
|
/* 本地: 关闭标准行处理/回显/信号字符 */
|
||||||
|
tty.c_lflag = 0;
|
||||||
|
|
||||||
|
/* 特殊字符: 无超时 (VMIN=1, VTIME=0 → 阻塞读直到至少1字节) */
|
||||||
|
tty.c_cc[VMIN] = 1;
|
||||||
|
tty.c_cc[VTIME] = 0;
|
||||||
|
|
||||||
|
/* 设置波特率 */
|
||||||
|
speed_t speed = B115200;
|
||||||
|
switch (baudrate) {
|
||||||
|
case 9600: speed = B9600; break;
|
||||||
|
case 19200: speed = B19200; break;
|
||||||
|
case 38400: speed = B38400; break;
|
||||||
|
case 57600: speed = B57600; break;
|
||||||
|
case 115200: speed = B115200; break;
|
||||||
|
case 230400: speed = B230400; break;
|
||||||
|
case 460800: speed = B460800; break;
|
||||||
|
case 921600: speed = B921600; break;
|
||||||
|
default:
|
||||||
|
fprintf(stderr, "不支持的波特率: %d (支持: 9600-921600)\n", baudrate);
|
||||||
|
close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
cfsetispeed(&tty, speed);
|
||||||
|
cfsetospeed(&tty, speed);
|
||||||
|
|
||||||
|
#ifdef CRTSCTS
|
||||||
|
tty.c_cflag &= ~CRTSCTS;
|
||||||
|
#endif
|
||||||
|
/* 关闭软件流控 */
|
||||||
|
tty.c_iflag &= ~(IXON | IXOFF | IXANY);
|
||||||
|
|
||||||
|
if (tcsetattr(fd, TCSANOW, &tty) < 0) {
|
||||||
|
perror("tcsetattr");
|
||||||
|
close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 排空缓冲区 */
|
||||||
|
tcflush(fd, TCIOFLUSH);
|
||||||
|
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 时间工具 ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static double now_sec(void) {
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sleep_sec(double seconds) {
|
||||||
|
if (seconds <= 0.0) return;
|
||||||
|
struct timespec ts;
|
||||||
|
ts.tv_sec = (time_t)seconds;
|
||||||
|
ts.tv_nsec = (long)((seconds - (time_t)seconds) * 1e9);
|
||||||
|
clock_nanosleep(CLOCK_MONOTONIC, 0, &ts, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 信号生成 ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static void generate_samples(
|
||||||
|
float *samples,
|
||||||
|
uint32_t sample_index,
|
||||||
|
uint8_t channels,
|
||||||
|
uint16_t samples_per_channel,
|
||||||
|
uint8_t plot_format,
|
||||||
|
double rate,
|
||||||
|
double freq,
|
||||||
|
double amp)
|
||||||
|
{
|
||||||
|
if (plot_format == XS_PLOT_FORMAT_XY) {
|
||||||
|
/* XY 模式: 每对 (cos, sin) 画一个圆 */
|
||||||
|
for (uint16_t i = 0; i < samples_per_channel; ++i) {
|
||||||
|
double t = (double)(sample_index + i) / rate;
|
||||||
|
samples[2 * i] = (float)(amp * cos(2.0 * M_PI * freq * t));
|
||||||
|
samples[2 * i + 1] = (float)(amp * sin(2.0 * M_PI * freq * t));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/* interleaved 模式: 每个通道相位偏移 */
|
||||||
|
for (uint16_t i = 0; i < samples_per_channel; ++i) {
|
||||||
|
double t = (double)(sample_index + i) / rate;
|
||||||
|
for (uint8_t ch = 0; ch < channels; ++ch) {
|
||||||
|
double phase = 2.0 * M_PI * (double)ch / (double)channels;
|
||||||
|
samples[i * channels + ch] = (float)(amp * sin(2.0 * M_PI * freq * t + phase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 用法 ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static void print_usage(const char *prog) {
|
||||||
|
printf("用法: %s <串口> [选项]\n", prog);
|
||||||
|
printf("\n选项:\n");
|
||||||
|
printf(" --baud <n> 波特率 (默认 %d)\n", DEFAULT_BAUDRATE);
|
||||||
|
printf(" --channels <n> 通道数 (默认 1, --format xy 时强制 2)\n");
|
||||||
|
printf(" --format <fmt> 格式: interleaved | xy (默认 interleaved)\n");
|
||||||
|
printf(" --rate <f> 采样率, samples/sec (默认 %.0f)\n", DEFAULT_RATE);
|
||||||
|
printf(" --freq <f> 正弦频率 Hz (默认 %.1f)\n", DEFAULT_FREQ);
|
||||||
|
printf(" --amp <f> 振幅 (默认 %.0f)\n", DEFAULT_AMP);
|
||||||
|
printf(" --text-interval <f> 文本输出间隔秒 (默认 %.1f)\n", DEFAULT_TEXT_INTERVAL);
|
||||||
|
printf(" --spc <n> 每帧每通道采样数 (默认 1)\n");
|
||||||
|
printf(" -h, --help 显示帮助\n");
|
||||||
|
printf("\n支持的波特率: 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600\n");
|
||||||
|
printf("\n示例:\n");
|
||||||
|
printf(" %s /dev/ttyUSB0\n", prog);
|
||||||
|
printf(" %s /dev/ttyUSB0 --baud 921600 --channels 2 --rate 2048 --freq 5\n", prog);
|
||||||
|
printf(" %s /dev/ttyUSB0 --format xy --amp 200 --text-interval 2.0\n", prog);
|
||||||
|
printf(" %s /dev/ttyUSB0 --channels 4 --spc 128 --rate 1024\n", prog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 参数解析 ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static int parse_args(int argc, char **argv, args_t *args) {
|
||||||
|
memset(args, 0, sizeof(*args));
|
||||||
|
args->baudrate = DEFAULT_BAUDRATE;
|
||||||
|
args->channels = 1;
|
||||||
|
args->samples_per_channel = 1;
|
||||||
|
args->plot_format = XS_PLOT_FORMAT_INTERLEAVED;
|
||||||
|
args->rate = DEFAULT_RATE;
|
||||||
|
args->freq = DEFAULT_FREQ;
|
||||||
|
args->amp = DEFAULT_AMP;
|
||||||
|
args->text_interval = DEFAULT_TEXT_INTERVAL;
|
||||||
|
|
||||||
|
enum { OPT_BAUD = 256, OPT_CHANNELS, OPT_FORMAT, OPT_RATE, OPT_FREQ,
|
||||||
|
OPT_AMP, OPT_TEXT_INTERVAL, OPT_SPC };
|
||||||
|
|
||||||
|
static struct option long_opts[] = {
|
||||||
|
{"baud", required_argument, 0, OPT_BAUD},
|
||||||
|
{"channels", required_argument, 0, OPT_CHANNELS},
|
||||||
|
{"format", required_argument, 0, OPT_FORMAT},
|
||||||
|
{"rate", required_argument, 0, OPT_RATE},
|
||||||
|
{"freq", required_argument, 0, OPT_FREQ},
|
||||||
|
{"amp", required_argument, 0, OPT_AMP},
|
||||||
|
{"text-interval", required_argument, 0, OPT_TEXT_INTERVAL},
|
||||||
|
{"spc", required_argument, 0, OPT_SPC},
|
||||||
|
{"help", no_argument, 0, 'h'},
|
||||||
|
{0, 0, 0, 0}
|
||||||
|
};
|
||||||
|
|
||||||
|
int opt;
|
||||||
|
while ((opt = getopt_long(argc, argv, "h", long_opts, NULL)) != -1) {
|
||||||
|
switch (opt) {
|
||||||
|
case OPT_BAUD:
|
||||||
|
args->baudrate = atoi(optarg);
|
||||||
|
break;
|
||||||
|
case OPT_CHANNELS: {
|
||||||
|
int v = atoi(optarg);
|
||||||
|
if (v < 1 || v > MAX_CHANNELS) {
|
||||||
|
fprintf(stderr, "通道数必须在 1-%d 之间\n", MAX_CHANNELS);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
args->channels = (uint8_t)v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case OPT_FORMAT:
|
||||||
|
if (strcmp(optarg, "xy") == 0) {
|
||||||
|
args->plot_format = XS_PLOT_FORMAT_XY;
|
||||||
|
} else if (strcmp(optarg, "interleaved") == 0) {
|
||||||
|
args->plot_format = XS_PLOT_FORMAT_INTERLEAVED;
|
||||||
|
} else {
|
||||||
|
fprintf(stderr, "未知格式: %s (支持: interleaved, xy)\n", optarg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case OPT_RATE:
|
||||||
|
args->rate = atof(optarg);
|
||||||
|
break;
|
||||||
|
case OPT_FREQ:
|
||||||
|
args->freq = atof(optarg);
|
||||||
|
break;
|
||||||
|
case OPT_AMP:
|
||||||
|
args->amp = atof(optarg);
|
||||||
|
break;
|
||||||
|
case OPT_TEXT_INTERVAL:
|
||||||
|
args->text_interval = atof(optarg);
|
||||||
|
break;
|
||||||
|
case OPT_SPC: {
|
||||||
|
int v = atoi(optarg);
|
||||||
|
if (v < 1 || v > MAX_SPC) {
|
||||||
|
fprintf(stderr, "spc 必须在 1-%d 之间\n", MAX_SPC);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
args->samples_per_channel = (uint16_t)v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'h':
|
||||||
|
print_usage(argv[0]);
|
||||||
|
exit(0);
|
||||||
|
default:
|
||||||
|
print_usage(argv[0]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optind >= argc) {
|
||||||
|
fprintf(stderr, "错误: 缺少串口路径\n");
|
||||||
|
print_usage(argv[0]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
args->port = argv[optind];
|
||||||
|
|
||||||
|
/* 校验 */
|
||||||
|
if (args->plot_format == XS_PLOT_FORMAT_XY) {
|
||||||
|
if (args->channels != 2) {
|
||||||
|
fprintf(stderr, "错误: --format xy 要求 --channels 2\n");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args->channels == 0) {
|
||||||
|
fprintf(stderr, "错误: --channels 必须 >= 1\n");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (args->rate <= 0.0) {
|
||||||
|
fprintf(stderr, "错误: --rate 必须为正数\n");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (args->text_interval <= 0.0) {
|
||||||
|
fprintf(stderr, "错误: --text-interval 必须为正数\n");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 发送 plot 帧 ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static int send_plot_frame(
|
||||||
|
int fd,
|
||||||
|
const float *samples,
|
||||||
|
uint8_t channels,
|
||||||
|
uint16_t samples_per_channel,
|
||||||
|
uint8_t plot_format,
|
||||||
|
uint8_t *packet_buf,
|
||||||
|
size_t packet_cap,
|
||||||
|
uint8_t *frame_buf,
|
||||||
|
size_t frame_cap)
|
||||||
|
{
|
||||||
|
size_t frame_len = 0;
|
||||||
|
xs_status_t rc = xs_mixed_build_plot_frame_f32(
|
||||||
|
samples, channels, samples_per_channel, plot_format,
|
||||||
|
packet_buf, packet_cap,
|
||||||
|
frame_buf, frame_cap,
|
||||||
|
&frame_len);
|
||||||
|
if (rc != XS_OK) {
|
||||||
|
fprintf(stderr, "构建 plot 帧失败: %d\n", rc);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t written = 0;
|
||||||
|
while (written < frame_len) {
|
||||||
|
ssize_t n = write(fd, frame_buf + written, frame_len - written);
|
||||||
|
if (n < 0) {
|
||||||
|
perror("write");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
written += (size_t)n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 发送文本行 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static int send_text_line(
|
||||||
|
int fd,
|
||||||
|
uint32_t sample_index,
|
||||||
|
const float *current_samples,
|
||||||
|
uint8_t channels,
|
||||||
|
uint16_t samples_per_channel,
|
||||||
|
const char *format_name)
|
||||||
|
{
|
||||||
|
char buf[TEXT_BUF_SIZE];
|
||||||
|
int pos = 0;
|
||||||
|
|
||||||
|
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||||
|
"sample=%-7u", sample_index);
|
||||||
|
|
||||||
|
if (channels > 1) {
|
||||||
|
for (uint8_t ch = 0; ch < channels; ++ch) {
|
||||||
|
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||||
|
" ch%d=%8.3f", ch + 1, (double)current_samples[ch]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||||
|
" value=%8.3f", (double)current_samples[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||||
|
" [fmt=%s, ch=%u, spc=%u]\n",
|
||||||
|
format_name, channels, samples_per_channel);
|
||||||
|
|
||||||
|
/* 确保不超过缓冲 */
|
||||||
|
if (pos >= (int)sizeof(buf)) pos = (int)sizeof(buf) - 1;
|
||||||
|
|
||||||
|
size_t len = (size_t)pos;
|
||||||
|
size_t written = 0;
|
||||||
|
while (written < len) {
|
||||||
|
ssize_t n = write(fd, buf + written, len - written);
|
||||||
|
if (n < 0) {
|
||||||
|
perror("write");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
written += (size_t)n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 主函数 ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
args_t args;
|
||||||
|
if (parse_args(argc, argv, &args) < 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 打印配置 */
|
||||||
|
const char *format_name = (args.plot_format == XS_PLOT_FORMAT_XY) ? "xy" : "interleaved";
|
||||||
|
double frame_interval = (double)args.samples_per_channel / args.rate;
|
||||||
|
double frame_rate = args.rate / (double)args.samples_per_channel;
|
||||||
|
|
||||||
|
printf("═══ pipeview Serial Plot Generator ═══\n");
|
||||||
|
printf("串口: %s @ %d baud\n", args.port, args.baudrate);
|
||||||
|
printf("通道: %u 格式: %s\n", args.channels, format_name);
|
||||||
|
printf("频率: %.1f Hz 振幅: %.1f\n", args.freq, args.amp);
|
||||||
|
printf("采样率: %.0f samples/sec spc: %u\n", args.rate, args.samples_per_channel);
|
||||||
|
printf("帧间隔: %.3f ms (%.2f fps)\n", frame_interval * 1000.0, frame_rate);
|
||||||
|
printf("文本间隔: %.1f s\n", args.text_interval);
|
||||||
|
printf("═══════════════════════════════════════\n");
|
||||||
|
|
||||||
|
/* 分配缓冲 */
|
||||||
|
uint32_t value_count = (uint32_t)args.channels * (uint32_t)args.samples_per_channel;
|
||||||
|
uint32_t payload_len = value_count * 4;
|
||||||
|
size_t packet_cap = (size_t)XS_PLOT_HEADER_SIZE + (size_t)payload_len;
|
||||||
|
size_t frame_cap = 2u + xs_cobs_max_encoded_size(packet_cap) + 1u;
|
||||||
|
|
||||||
|
uint8_t *packet_buf = malloc(packet_cap);
|
||||||
|
uint8_t *frame_buf = malloc(frame_cap);
|
||||||
|
float *samples = malloc((size_t)value_count * sizeof(float));
|
||||||
|
|
||||||
|
if (!packet_buf || !frame_buf || !samples) {
|
||||||
|
fprintf(stderr, "内存分配失败\n");
|
||||||
|
free(packet_buf); free(frame_buf); free(samples);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 打开串口 */
|
||||||
|
int fd = serial_open(args.port, args.baudrate);
|
||||||
|
if (fd < 0) {
|
||||||
|
fprintf(stderr, "无法打开串口 %s\n", args.port);
|
||||||
|
free(packet_buf); free(frame_buf); free(samples);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printf("已打开 %s\n\n", args.port);
|
||||||
|
|
||||||
|
/* 设置信号处理 */
|
||||||
|
struct sigaction sa;
|
||||||
|
memset(&sa, 0, sizeof(sa));
|
||||||
|
sa.sa_handler = sigint_handler;
|
||||||
|
sigaction(SIGINT, &sa, NULL);
|
||||||
|
sigaction(SIGTERM, &sa, NULL);
|
||||||
|
|
||||||
|
/* 主循环 */
|
||||||
|
uint32_t sample_index = 0;
|
||||||
|
uint64_t frame_count = 0;
|
||||||
|
uint64_t text_count = 0;
|
||||||
|
double started_at = now_sec();
|
||||||
|
double next_text_at = started_at + args.text_interval;
|
||||||
|
|
||||||
|
printf("开始发送 (Ctrl+C 停止) ...\n");
|
||||||
|
|
||||||
|
while (g_running) {
|
||||||
|
/* 生成采样数据 */
|
||||||
|
generate_samples(samples, sample_index,
|
||||||
|
args.channels, args.samples_per_channel,
|
||||||
|
args.plot_format, args.rate, args.freq, args.amp);
|
||||||
|
|
||||||
|
/* 发送 plot 帧 */
|
||||||
|
if (send_plot_frame(fd, samples,
|
||||||
|
args.channels, args.samples_per_channel,
|
||||||
|
args.plot_format,
|
||||||
|
packet_buf, packet_cap,
|
||||||
|
frame_buf, frame_cap) < 0) {
|
||||||
|
fprintf(stderr, "发送失败, 退出\n");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
frame_count++;
|
||||||
|
|
||||||
|
/* 检查是否需要发送文本行 */
|
||||||
|
double now = now_sec();
|
||||||
|
if (now >= next_text_at) {
|
||||||
|
if (send_text_line(fd, sample_index, samples,
|
||||||
|
args.channels, args.samples_per_channel,
|
||||||
|
format_name) < 0) {
|
||||||
|
fprintf(stderr, "发送文本失败, 退出\n");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
text_count++;
|
||||||
|
next_text_at += args.text_interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
sample_index += args.samples_per_channel;
|
||||||
|
|
||||||
|
/* 帧间隔时序控制 */
|
||||||
|
double elapsed = now_sec() - started_at;
|
||||||
|
double target = (double)frame_count * frame_interval;
|
||||||
|
double wait = target - elapsed;
|
||||||
|
if (wait > 0.0) {
|
||||||
|
sleep_sec(wait);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double total_time = now_sec() - started_at;
|
||||||
|
printf("\n");
|
||||||
|
printf("═══ 已停止 ═══\n");
|
||||||
|
printf("运行时间: %.1f 秒\n", total_time);
|
||||||
|
printf("Plot 帧: %lu (%.1f fps)\n",
|
||||||
|
(unsigned long)frame_count,
|
||||||
|
total_time > 0.0 ? (double)frame_count / total_time : 0.0);
|
||||||
|
printf("文本行: %lu\n", (unsigned long)text_count);
|
||||||
|
|
||||||
|
/* 清理 */
|
||||||
|
tcflush(fd, TCIOFLUSH);
|
||||||
|
close(fd);
|
||||||
|
free(packet_buf);
|
||||||
|
free(frame_buf);
|
||||||
|
free(samples);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
270
tools/test_text_serial.c
Normal file
270
tools/test_text_serial.c
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/*
|
||||||
|
* test_text_serial.c — 持续向串口发送纯文本正弦波数据流
|
||||||
|
*
|
||||||
|
* 编译 (Linux):
|
||||||
|
* gcc -std=c11 -Wall -Wextra -O2 -o test_text_serial tools/test_text_serial.c -lm
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* ./test_text_serial /dev/ttyUSB0
|
||||||
|
* ./test_text_serial /dev/ttyUSB0 --baud 115200 --channels 2 --rate 100 --freq 2.0 --amp 150
|
||||||
|
* ./test_text_serial --help
|
||||||
|
*
|
||||||
|
* 输出格式 (每行):
|
||||||
|
* sample=123 ch1=100.000 ch2=0.000
|
||||||
|
*
|
||||||
|
* 串口参数: 8N1, 无流控, 默认 115200 baud
|
||||||
|
*/
|
||||||
|
|
||||||
|
#define _POSIX_C_SOURCE 200809L
|
||||||
|
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <getopt.h>
|
||||||
|
#include <math.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <termios.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#ifndef M_PI
|
||||||
|
#define M_PI 3.14159265358979323846
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define DEFAULT_BAUDRATE 115200
|
||||||
|
#define DEFAULT_RATE 100.0 /* lines/sec */
|
||||||
|
#define DEFAULT_FREQ 1.0
|
||||||
|
#define DEFAULT_AMP 100.0
|
||||||
|
#define MAX_CHANNELS 8
|
||||||
|
#define LINE_BUF_SIZE 512
|
||||||
|
|
||||||
|
static volatile sig_atomic_t g_running = 1;
|
||||||
|
|
||||||
|
static void sigint_handler(int sig) {
|
||||||
|
(void)sig;
|
||||||
|
g_running = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *port;
|
||||||
|
int baudrate;
|
||||||
|
uint8_t channels;
|
||||||
|
double rate;
|
||||||
|
double freq;
|
||||||
|
double amp;
|
||||||
|
} args_t;
|
||||||
|
|
||||||
|
static int serial_open(const char *path, int baudrate) {
|
||||||
|
int fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK);
|
||||||
|
if (fd < 0) { perror("open"); return -1; }
|
||||||
|
|
||||||
|
int flags = fcntl(fd, F_GETFL, 0);
|
||||||
|
if (flags < 0) { perror("fcntl"); close(fd); return -1; }
|
||||||
|
flags &= ~O_NONBLOCK;
|
||||||
|
if (fcntl(fd, F_SETFL, flags) < 0) { perror("fcntl"); close(fd); return -1; }
|
||||||
|
|
||||||
|
struct termios tty;
|
||||||
|
memset(&tty, 0, sizeof(tty));
|
||||||
|
if (tcgetattr(fd, &tty) < 0) { perror("tcgetattr"); close(fd); return -1; }
|
||||||
|
|
||||||
|
tty.c_iflag = IGNBRK;
|
||||||
|
tty.c_oflag = 0;
|
||||||
|
tty.c_cflag = CS8 | CREAD | CLOCAL;
|
||||||
|
tty.c_lflag = 0;
|
||||||
|
tty.c_cc[VMIN] = 1;
|
||||||
|
tty.c_cc[VTIME] = 0;
|
||||||
|
|
||||||
|
speed_t speed = B115200;
|
||||||
|
switch (baudrate) {
|
||||||
|
case 9600: speed = B9600; break;
|
||||||
|
case 19200: speed = B19200; break;
|
||||||
|
case 38400: speed = B38400; break;
|
||||||
|
case 57600: speed = B57600; break;
|
||||||
|
case 115200: speed = B115200; break;
|
||||||
|
case 230400: speed = B230400; break;
|
||||||
|
case 460800: speed = B460800; break;
|
||||||
|
case 921600: speed = B921600; break;
|
||||||
|
default:
|
||||||
|
fprintf(stderr, "不支持的波特率: %d\n", baudrate);
|
||||||
|
close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
cfsetispeed(&tty, speed);
|
||||||
|
cfsetospeed(&tty, speed);
|
||||||
|
|
||||||
|
#ifdef CRTSCTS
|
||||||
|
tty.c_cflag &= ~CRTSCTS;
|
||||||
|
#endif
|
||||||
|
tty.c_iflag &= ~(IXON | IXOFF | IXANY);
|
||||||
|
|
||||||
|
if (tcsetattr(fd, TCSANOW, &tty) < 0) {
|
||||||
|
perror("tcsetattr");
|
||||||
|
close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
tcflush(fd, TCIOFLUSH);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double now_sec(void) {
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sleep_sec(double seconds) {
|
||||||
|
if (seconds <= 0.0) return;
|
||||||
|
struct timespec ts;
|
||||||
|
ts.tv_sec = (time_t)seconds;
|
||||||
|
ts.tv_nsec = (long)((seconds - (time_t)seconds) * 1e9);
|
||||||
|
clock_nanosleep(CLOCK_MONOTONIC, 0, &ts, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int format_line(char *buf, size_t cap, uint32_t sample, const float *values, uint8_t channels) {
|
||||||
|
int pos = snprintf(buf, cap, "sample=%-7u", sample);
|
||||||
|
for (uint8_t ch = 0; ch < channels; ++ch) {
|
||||||
|
pos += snprintf(buf + pos, cap - (size_t)pos, " ch%d=%8.3f", ch + 1, (double)values[ch]);
|
||||||
|
}
|
||||||
|
pos += snprintf(buf + pos, cap - (size_t)pos, "\n");
|
||||||
|
return pos >= (int)cap ? (int)cap - 1 : pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void print_usage(const char *prog) {
|
||||||
|
printf("用法: %s <串口> [选项]\n", prog);
|
||||||
|
printf("\n选项:\n");
|
||||||
|
printf(" --baud <n> 波特率 (默认 %d)\n", DEFAULT_BAUDRATE);
|
||||||
|
printf(" --channels <n> 通道数 (默认 1)\n");
|
||||||
|
printf(" --rate <f> 行/秒 输出速率 (默认 %.0f)\n", DEFAULT_RATE);
|
||||||
|
printf(" --freq <f> 正弦频率 Hz (默认 %.1f)\n", DEFAULT_FREQ);
|
||||||
|
printf(" --amp <f> 振幅 (默认 %.0f)\n", DEFAULT_AMP);
|
||||||
|
printf(" -h, --help 显示帮助\n");
|
||||||
|
printf("\n示例:\n");
|
||||||
|
printf(" %s /dev/ttyUSB0\n", prog);
|
||||||
|
printf(" %s /dev/ttyUSB0 --channels 2 --rate 50 --freq 3\n", prog);
|
||||||
|
printf(" %s /dev/ttyUSB0 --baud 921600 --rate 1000\n", prog);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int parse_args(int argc, char **argv, args_t *args) {
|
||||||
|
memset(args, 0, sizeof(*args));
|
||||||
|
args->baudrate = DEFAULT_BAUDRATE;
|
||||||
|
args->channels = 1;
|
||||||
|
args->rate = DEFAULT_RATE;
|
||||||
|
args->freq = DEFAULT_FREQ;
|
||||||
|
args->amp = DEFAULT_AMP;
|
||||||
|
|
||||||
|
enum { OPT_BAUD = 256, OPT_CHANNELS, OPT_RATE, OPT_FREQ, OPT_AMP };
|
||||||
|
|
||||||
|
static struct option long_opts[] = {
|
||||||
|
{"baud", required_argument, 0, OPT_BAUD},
|
||||||
|
{"channels", required_argument, 0, OPT_CHANNELS},
|
||||||
|
{"rate", required_argument, 0, OPT_RATE},
|
||||||
|
{"freq", required_argument, 0, OPT_FREQ},
|
||||||
|
{"amp", required_argument, 0, OPT_AMP},
|
||||||
|
{"help", no_argument, 0, 'h'},
|
||||||
|
{0, 0, 0, 0}
|
||||||
|
};
|
||||||
|
|
||||||
|
int opt;
|
||||||
|
while ((opt = getopt_long(argc, argv, "h", long_opts, NULL)) != -1) {
|
||||||
|
switch (opt) {
|
||||||
|
case OPT_BAUD: args->baudrate = atoi(optarg); break;
|
||||||
|
case OPT_CHANNELS: {
|
||||||
|
int v = atoi(optarg);
|
||||||
|
if (v < 1 || v > MAX_CHANNELS) {
|
||||||
|
fprintf(stderr, "通道数必须在 1-%d 之间\n", MAX_CHANNELS);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
args->channels = (uint8_t)v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case OPT_RATE: args->rate = atof(optarg); break;
|
||||||
|
case OPT_FREQ: args->freq = atof(optarg); break;
|
||||||
|
case OPT_AMP: args->amp = atof(optarg); break;
|
||||||
|
case 'h': print_usage(argv[0]); exit(0);
|
||||||
|
default: print_usage(argv[0]); return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optind >= argc) {
|
||||||
|
fprintf(stderr, "错误: 缺少串口路径\n");
|
||||||
|
print_usage(argv[0]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
args->port = argv[optind];
|
||||||
|
|
||||||
|
if (args->rate <= 0.0) { fprintf(stderr, "错误: --rate 必须为正数\n"); return -1; }
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
args_t args;
|
||||||
|
if (parse_args(argc, argv, &args) < 0) return 1;
|
||||||
|
|
||||||
|
double interval = 1.0 / args.rate;
|
||||||
|
|
||||||
|
printf("═══ pipeview Plain Text Generator ═══\n");
|
||||||
|
printf("串口: %s @ %d baud\n", args.port, args.baudrate);
|
||||||
|
printf("通道: %u 振幅: %.1f 频率: %.1f Hz\n", args.channels, args.amp, args.freq);
|
||||||
|
printf("速率: %.0f lines/sec (间隔 %.3f ms)\n", args.rate, interval * 1000.0);
|
||||||
|
printf("═════════════════════════════════════\n");
|
||||||
|
|
||||||
|
float values[MAX_CHANNELS];
|
||||||
|
char line[LINE_BUF_SIZE];
|
||||||
|
|
||||||
|
int fd = serial_open(args.port, args.baudrate);
|
||||||
|
if (fd < 0) { fprintf(stderr, "无法打开串口 %s\n", args.port); return 1; }
|
||||||
|
printf("已打开 %s\n\n开始发送 (Ctrl+C 停止) ...\n", args.port);
|
||||||
|
|
||||||
|
struct sigaction sa;
|
||||||
|
memset(&sa, 0, sizeof(sa));
|
||||||
|
sa.sa_handler = sigint_handler;
|
||||||
|
sigaction(SIGINT, &sa, NULL);
|
||||||
|
sigaction(SIGTERM, &sa, NULL);
|
||||||
|
|
||||||
|
uint32_t sample_index = 0;
|
||||||
|
uint64_t line_count = 0;
|
||||||
|
double started_at = now_sec();
|
||||||
|
|
||||||
|
while (g_running) {
|
||||||
|
double t = (double)sample_index / args.rate;
|
||||||
|
for (uint8_t ch = 0; ch < args.channels; ++ch) {
|
||||||
|
double phase = 2.0 * M_PI * (double)ch / (double)args.channels;
|
||||||
|
values[ch] = (float)(args.amp * sin(2.0 * M_PI * args.freq * t + phase));
|
||||||
|
}
|
||||||
|
|
||||||
|
int len = format_line(line, sizeof(line), sample_index, values, args.channels);
|
||||||
|
size_t written = 0;
|
||||||
|
while (written < (size_t)len) {
|
||||||
|
ssize_t n = write(fd, line + written, (size_t)len - written);
|
||||||
|
if (n < 0) { perror("write"); goto cleanup; }
|
||||||
|
written += (size_t)n;
|
||||||
|
}
|
||||||
|
line_count++;
|
||||||
|
|
||||||
|
double elapsed = now_sec() - started_at;
|
||||||
|
double target = (double)line_count * interval;
|
||||||
|
double wait = target - elapsed;
|
||||||
|
if (wait > 0.0) sleep_sec(wait);
|
||||||
|
|
||||||
|
sample_index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup: {
|
||||||
|
double total_time = now_sec() - started_at;
|
||||||
|
printf("\n═══ 已停止 ═══\n");
|
||||||
|
printf("运行时间: %.1f 秒\n", total_time);
|
||||||
|
printf("行数: %lu (%.1f lines/sec)\n",
|
||||||
|
(unsigned long)line_count,
|
||||||
|
total_time > 0.0 ? (double)line_count / total_time : 0.0);
|
||||||
|
|
||||||
|
tcflush(fd, TCIOFLUSH);
|
||||||
|
close(fd);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
237
tools/xs_mixed_plot.h
Normal file
237
tools/xs_mixed_plot.h
Normal 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 */
|
||||||
Reference in New Issue
Block a user