Compare commits
4 Commits
main
...
3de2c055d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 3de2c055d5 | |||
| b378f14f0a | |||
| 227a459712 | |||
| 367800a426 |
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
@@ -1,41 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Linux system dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y libudev-dev libxkbcommon-dev libxcb1-dev libxcb-icccm4-dev libxcb-shape0-dev libxcb-xfixes0-dev libgl1-mesa-dev libgtk-3-dev
|
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
components: rustfmt, clippy
|
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: cargo fmt --check
|
|
||||||
|
|
||||||
- name: Clippy
|
|
||||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
|
|
||||||
- name: Smoke-check Python tools
|
|
||||||
run: |
|
|
||||||
python3 -m py_compile tools/*.py
|
|
||||||
for f in tools/test_plot.py tools/test_drone.py tools/test_ansi.py tools/stress_test.py; do
|
|
||||||
python3 "$f" --help >/dev/null
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: cargo test --workspace
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -6,10 +6,3 @@ tools/test_text_c
|
|||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
||||||
/AGENTS.md
|
|
||||||
/.agents
|
|
||||||
/.codex
|
|
||||||
/.omo
|
|
||||||
/.opencode
|
|
||||||
/.trellis
|
|
||||||
134
AGENTS.md
Normal file
134
AGENTS.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Build Prerequisites
|
||||||
|
|
||||||
|
- **Linux**: `libudev-dev` (for `serialport`).
|
||||||
|
- **All platforms**: a C compiler (`cc`, `gcc`, `clang`, or MSVC) — `mlua` uses `features = ["vendored"]` which compiles LuaJIT from source at build time.
|
||||||
|
- **No pinned toolchain**: all crates use `edition = "2024"` (requires Rust ≥ 1.85). No `rust-toolchain.toml`; builds with whatever `rustc` is on `PATH`.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
`xserial` is a Rust workspace. The root `Cargo.toml` defines four crates in `crates/`, with dependencies flowing:
|
||||||
|
|
||||||
|
```
|
||||||
|
xserial-core (no internal deps — transport, framing, protocol, pipeline)
|
||||||
|
↑
|
||||||
|
xserial-client (depends on xserial-core — sessions, history, commands, Lua)
|
||||||
|
↑ ↑
|
||||||
|
xserial-gui xserial-tui
|
||||||
|
(both depend on xserial-core AND xserial-client)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Crate | Type | What goes here |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `xserial-core` | library (`src/lib.rs`) | `transport/`, `frame/`, `protocol/`, `pipeline.rs` — protocol and I/O. Integration tests: `tests/pipeline.rs`. |
|
||||||
|
| `xserial-client` | library (`src/lib.rs`) | `session.rs`, `manager.rs`, `config.rs`, `history.rs`, `lua/` — state management and Lua bindings. Integration tests: `tests/session_lifecycle.rs`, `tests/lua_tests.rs`. |
|
||||||
|
| `xserial-gui` | binary (`src/main.rs`) | egui/eframe app. Panels in `src/panels/`. Font helpers in `src/ui_fonts.rs`. Profiling in `src/perf.rs`. |
|
||||||
|
| `xserial-tui` | binary (`src/main.rs`) | ratatui/crossterm app. **Still a placeholder** — zero tests, not at feature parity with GUI. |
|
||||||
|
|
||||||
|
**Module boundaries**: transport/protocol → `xserial-core`. Session/Lua → `xserial-client`. UI state → `xserial-gui` or `xserial-tui`. Never put I/O logic in the UI crates, never put UI state in the client crate.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check --workspace # fast compile check (all crates)
|
||||||
|
cargo test --workspace # full suite (~266 tests)
|
||||||
|
cargo fmt --all # standard Rust formatting
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings # lint gate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run specific tests:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p xserial-core -- tcp_line_text_utf8
|
||||||
|
cargo test -p xserial-client -- session_echo_send_and_read
|
||||||
|
cargo test -p xserial-core -- --nocapture # show test output
|
||||||
|
|
||||||
|
# Integration tests only
|
||||||
|
cargo test -p xserial-core --test pipeline
|
||||||
|
cargo test -p xserial-client --test session_lifecycle
|
||||||
|
cargo test -p xserial-client --test lua_tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Launch:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p xserial-gui
|
||||||
|
cargo run -p xserial-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment & Configuration
|
||||||
|
|
||||||
|
### Tracing (all entrypoints)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=info cargo run -p xserial-gui
|
||||||
|
RUST_LOG=xserial_gui::perf=debug cargo run -p xserial-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
`tracing_subscriber::EnvFilter::from_default_env()` reads `RUST_LOG`. The workspace dep `tracing-subscriber` has `features = ["env-filter"]`.
|
||||||
|
|
||||||
|
### GUI Profiling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=xserial_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p xserial-gui
|
||||||
|
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p xserial-gui # custom interval (default 1s)
|
||||||
|
```
|
||||||
|
|
||||||
|
Profiling logs go to target `xserial_gui::perf`.
|
||||||
|
|
||||||
|
### State & Config File Paths (runtime, not compile-time)
|
||||||
|
|
||||||
|
Uses `env::consts::OS` + env vars — no `cfg(target_os)` gating for path resolution:
|
||||||
|
|
||||||
|
| Platform | Root config path |
|
||||||
|
|----------|-----------------|
|
||||||
|
| Linux | `$XDG_CONFIG_HOME/xserial/` or `~/.config/xserial/` |
|
||||||
|
| macOS | `~/Library/Application Support/xserial/` |
|
||||||
|
| Windows | `%APPDATA%\xserial\` |
|
||||||
|
|
||||||
|
State files: `gui-state.json`, `tui-state.json`, `font-settings.json`.
|
||||||
|
|
||||||
|
### GUI Plot Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
|
||||||
|
python tools/test_plot.py --wire-format mixed --format xy --channels 2
|
||||||
|
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure in GUI: Transport `TCP 127.0.0.1:8091`, Framer `MixedTextPlot`, Decoder `MixedTextPlot`.
|
||||||
|
|
||||||
|
The wire format spec lives in `tools/xs_mixed_plot.h` (C header, reference only — not compiled into Rust).
|
||||||
|
|
||||||
|
## Coding Style
|
||||||
|
|
||||||
|
Standard Rust conventions (`cargo fmt`). No crate-level lint attrs, no `[lints]` table in any `Cargo.toml`. Names: `snake_case` for modules/files/functions/tests, `PascalCase` for types, `SCREAMING_SNAKE_CASE` for constants.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
- **Inline unit tests** (`#[cfg(test)] mod tests`) next to implementation — 24 source files with `#[test]` or `#[tokio::test]`.
|
||||||
|
- **Integration tests** in `crates/<crate>/tests/*.rs` — 3 files covering pipeline, session lifecycle, and Lua bindings.
|
||||||
|
- **~266 tests total** — all active (zero `#[ignore]`). Single `#[should_panic]` in `frame/fixed.rs`.
|
||||||
|
- **All tests are self-contained**: TCP/UDP bind to `127.0.0.1:0` (OS-assigned port). No hardcoded ports. No external services. Serial tests use dummy port names and never open real hardware.
|
||||||
|
- **Async tests** use `#[tokio::test]` (no `async-std`). **No benchmarks** exist.
|
||||||
|
- Run `cargo test --workspace` before opening PRs. Add targeted regression tests for transport, framing, parsing, and session-state fixes.
|
||||||
|
|
||||||
|
## Known Quirks & Constraints
|
||||||
|
|
||||||
|
- **No CI** — no `.github/workflows` or other CI config. Agents must self-verify with `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings`.
|
||||||
|
- **No feature flags** — zero `[features]` in any `Cargo.toml`. No `#[cfg(feature)]` in any source.
|
||||||
|
- **No build scripts** — zero `build.rs` files. No codegen. No `include!`/`include_str!`/`include_bytes!`.
|
||||||
|
- **All `unsafe` is test-only** — constructing noop wakers for poll-based unit tests in `transport/`. No non-test `unsafe` anywhere.
|
||||||
|
- **`xserial-tui` is a stub** — no tests, minimal implementation. GUI is the primary target.
|
||||||
|
- **Platform font directories** use `#[cfg(target_os)]` in `ui_fonts.rs` (three blocks: Windows, macOS, Linux/BSD).
|
||||||
|
- **`tokio` features differ by build**: `xserial-client` uses `["sync", "time"]` for production, `["full"]` for dev-dependencies (tests need net/IO).
|
||||||
|
|
||||||
|
## Commit & PR Guidelines
|
||||||
|
|
||||||
|
Short, imperative subjects (`Persist GUI font settings`), occasional `feat:` prefix for major additions. PRs: state which crate(s) changed, list validation commands, include screenshots/recordings for GUI/TUI changes. Explicitly call out protocol, transport, or Lua API compatibility impacts.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- `tools/test_plot.py --help` — TCP plot-frame generator options.
|
||||||
|
- `tools/xs_mixed_plot.h` — wire format spec for MixedTextPlot (COBS-framed binary plot + text on one stream).
|
||||||
1268
Cargo.lock
generated
1268
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
18
Cargo.toml
@@ -1,9 +1,10 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/pipeview-core",
|
"crates/xserial-core",
|
||||||
"crates/pipeview-client",
|
"crates/xserial-client",
|
||||||
"crates/pipeview-gui",
|
"crates/xserial-tui",
|
||||||
|
"crates/xserial-gui",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -29,10 +30,21 @@ thiserror = "2"
|
|||||||
# ── 日志 ──
|
# ── 日志 ──
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
tracing-appender = "0.2"
|
||||||
|
|
||||||
# ── 工具 ──
|
# ── 工具 ──
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
|
||||||
|
# ── 图像 ──
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|
||||||
|
# ── CLI ──
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|
||||||
|
# ── TUI ──
|
||||||
|
ratatui = "0.30"
|
||||||
|
crossterm = "0.28"
|
||||||
|
|
||||||
# ── GUI ──
|
# ── GUI ──
|
||||||
egui = "0.34"
|
egui = "0.34"
|
||||||
eframe = "0.34"
|
eframe = "0.34"
|
||||||
|
|||||||
390
README.md
390
README.md
@@ -1,187 +1,179 @@
|
|||||||
# pipeview
|
# xserial
|
||||||
|
|
||||||
**跨平台串口 / TCP / UDP 数据观测工具** — 可配置分帧、协议解码、多会话管理、实时波形绘图,单二进制零依赖。
|
**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.
|
||||||
|
|
||||||
基于 Rust + [egui](https://github.com/emilk/egui) 构建,支持 Lua 脚本扩展。
|
Built with Rust + [egui](https://github.com/emilk/egui), scriptable with Lua.
|
||||||
|
|
||||||
[English](README_en.md)
|
[中文文档](README_zh.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 目录
|
## Table of Contents
|
||||||
|
|
||||||
- [功能特性](#功能特性)
|
- [Features](#features)
|
||||||
- [快速开始](#快速开始)
|
- [Quick Start](#quick-start)
|
||||||
- [完整示例:无人机遥测解析](#完整示例无人机遥测解析)
|
- [Walkthrough: Drone Telemetry](#walkthrough-drone-telemetry)
|
||||||
- [Lua 脚本开发指南](#lua-脚本开发指南)
|
- [Lua Scripting Guide](#lua-scripting-guide)
|
||||||
- [架构](#架构)
|
- [Architecture](#architecture)
|
||||||
- [配置与持久化](#配置与持久化)
|
- [Configuration & Persistence](#configuration--persistence)
|
||||||
- [开发工具](#开发工具)
|
- [Development Tools](#development-tools)
|
||||||
- [技术栈](#技术栈)
|
- [Tech Stack](#tech-stack)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 功能特性
|
## Features
|
||||||
|
|
||||||
### 传输层
|
### Transport
|
||||||
|
|
||||||
| 类型 | 说明 | 配置项 |
|
| Type | Description | Options |
|
||||||
|------|------|--------|
|
|------|-------------|---------|
|
||||||
| **Serial** | 串口通信 | 端口名、波特率 (300–12M)、数据位 (5/6/7/8)、校验位 (None/Odd/Even)、停止位 (1/2)、流控 (None/Software/Hardware)、DTR/RTS 控制 |
|
| **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 客户端 | 目标地址 `host:port` |
|
| **TCP** | TCP client | Target address `host:port` |
|
||||||
| **UDP** | UDP 通信 | 绑定地址 `host:port`,可选远端地址 |
|
| **UDP** | UDP communication | Bind address `host:port`, optional remote address |
|
||||||
|
|
||||||
### 分帧器(Framer)
|
### Framers
|
||||||
|
|
||||||
将原始字节流切分为独立帧。每个 Session 可配置**多条独立管线**,同一字节流并行送入多个 framer→decoder 链路。
|
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** | 按 `\n` 分割文本行 | `strip_cr`(去除 `\r`)、`max_line_len`(最大行长度) |
|
| **Line** | Split by `\n` | `strip_cr`, `max_line_len` |
|
||||||
| **Fixed** | 固定字节数为一帧 | `frame_len`(帧长度) |
|
| **Fixed** | Fixed-length frames | `frame_len` |
|
||||||
| **Length** | 长度前缀协议 | `len_bytes` (1/2/4/8)、`endian` (大小端)、`length_includes_self`、`max_payload` |
|
| **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`(最大帧长) |
|
| **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` |
|
| **MixedTextPlot** | Hybrid text lines + COBS-encoded plot frames on one stream | `strip_cr`, `max_line_len`, `max_plot_frame` |
|
||||||
| **Lua** | 用户自定义分帧脚本 | `script_path`(Lua 脚本路径) |
|
| **Lua** | User-defined framer script | `script_path` |
|
||||||
|
|
||||||
### 解码器(Decoder)
|
### Decoders
|
||||||
|
|
||||||
将帧数据解析为可展示的内容。
|
Decoders parse framed data into displayable content.
|
||||||
|
|
||||||
| 解码器 | 输出类型 | 配置参数 |
|
| Decoder | Output | Parameters |
|
||||||
|--------|----------|----------|
|
|---------|--------|------------|
|
||||||
| **Text** | 文本 | `encoding`(UTF-8 / Latin1 / ASCII) |
|
| **Text** | Text | `encoding` (UTF-8 / Latin1 / ASCII) |
|
||||||
| **Hex** | 十六进制 | `uppercase`、`separator`、`bytes_per_group`、`endian` |
|
| **Hex** | Hexadecimal | `uppercase`, `separator`, `bytes_per_group`, `endian` |
|
||||||
| **Plot** | 波形数据 | `sample_type` (i8–f64)、`endian`、`channels` (1–64)、`format` (Interleaved / Block / XY) |
|
| **Plot** | Waveform | `sample_type` (i8–f64), `endian`, `channels` (1–64), `format` (Interleaved / Block / XY) |
|
||||||
| **MixedTextPlot** | 混合文本 + 波形 | `encoding` |
|
| **MixedTextPlot** | Hybrid text + waveform | `encoding` |
|
||||||
| **Lua** | 自定义 | `script_path`(Lua 脚本路径) |
|
| **Lua** | Custom | `script_path` |
|
||||||
|
|
||||||
**Plot 采样格式:**
|
**Plot sample formats:**
|
||||||
|
|
||||||
| 格式 | 说明 | 字节排列 |
|
| Format | Description | Byte layout |
|
||||||
|------|------|----------|
|
|--------|-------------|-------------|
|
||||||
| **Interleaved** | 多通道交叉排列 | `[ch0_s0, ch1_s0, ch2_s0, ch0_s1, ch1_s1, …]` |
|
| **Interleaved** | Channels interleaved | `[ch0_s0, ch1_s0, ch2_s0, ch0_s1, ch1_s1, …]` |
|
||||||
| **Block** | 按通道分块排列 | `[ch0_s0, ch0_s1, …, ch1_s0, ch1_s1, …]` |
|
| **Block** | Channel-contiguous blocks | `[ch0_s0, ch0_s1, …, ch1_s0, ch1_s1, …]` |
|
||||||
| **XY** | 2 通道交替 x/y | `[x0, y0, x1, y1, …]` |
|
| **XY** | 2-channel x/y pairs | `[x0, y0, x1, y1, …]` |
|
||||||
|
|
||||||
支持的采样类型:`i8`、`u8`、`i16`、`u16`、`i32`、`u32`、`i64`、`u64`、`f32`、`f64`
|
Supported sample types: `i8`, `u8`, `i16`, `u16`, `i32`, `u32`, `i64`, `u64`, `f32`, `f64`
|
||||||
|
|
||||||
### 视图
|
### Views
|
||||||
|
|
||||||
- **文本视图** — 带时间戳、方向标记 (`[IN]`/`[OUT]`)、管线标签的格式化文本流。支持搜索(`Ctrl+F`)、大小写匹配、匹配计数和高亮。
|
- **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 dump 与 ASCII 侧栏并排展示,可按分组和大小端解析多字节数值。
|
- **Hex View** — hex dump alongside ASCII sidebar. Configurable byte grouping and endianness.
|
||||||
- **波形视图** — 基于 [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot) 的实时波形。支持自动缩放、框选缩放、坐标轴锁定、跟随最新数据、浮动窗口、通道图例。
|
- **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
|
||||||
|
|
||||||
- **多会话并发** — 标签页切换,每个 Session 独立配置
|
- **Multi-session** — tabbed interface, each session independently configured
|
||||||
- **连接控制** — Connect / Disconnect / Reconnect,支持断线自动重连
|
- **Connection control** — Connect / Disconnect / Reconnect with auto-reconnect on drop
|
||||||
- **运行时重配置** — 修改分帧器/解码器参数无需断开连接
|
- **Live reconfiguration** — change framer/decoder settings without restarting
|
||||||
- **数据发送** — Text 模式(UTF-8,可选换行符)和 Hex 模式(十六进制字节)。支持 `None` / `LF` / `CR` / `CRLF` 四种行尾
|
- **Data sending** — Text mode (UTF-8, configurable line endings) and Hex mode (raw bytes). Four line ending options: None / LF / CR / CRLF
|
||||||
- **环形缓冲** — 默认 10,000 条历史记录,可配置 100–1,000,000
|
- **Ring buffer** — default 10,000 entries, configurable 100–1,000,000
|
||||||
- **日志到文件** — 每行数据实时写入,1KB 缓冲 + 后台线程,不阻塞 UI
|
- **Log to file** — background writer thread with 1 KB buffer, non-blocking
|
||||||
- **搜索** — `Ctrl+F` 呼出搜索栏,大小写敏感切换,F3/Shift+F3 前后跳转
|
- **Search** — `Ctrl+F` search bar, case-sensitive toggle, F3/Shift+F3 navigation
|
||||||
|
|
||||||
### Lua 脚本扩展
|
### Lua Scripting
|
||||||
|
|
||||||
内置 LuaJIT 运行时:
|
Built-in LuaJIT runtime:
|
||||||
|
|
||||||
- **自定义分帧器** — 实现 `feed(bytes)`、`flush()`、`reset()`、`pending_len()` 四个函数
|
- **Custom framers** — implement `feed(bytes)`, `flush()`, `reset()`, `pending_len()`
|
||||||
- **自定义解码器** — 实现 `decode(frame)` 函数,返回 text/hex/plot/binary 四种类型
|
- **Custom decoders** — implement `decode(frame)`, return text/hex/plot/binary
|
||||||
- **会话 API** — `pipeview.open()` 创建 Session、`session:on_data()` 事件回调、`session:send()` 发送数据
|
- **Session API** — `xserial.open()` to create sessions, `session:on_data()` for event callbacks, `session:send()` to transmit data
|
||||||
- **工具函数** — `pipeview.list_ports()`、`pipeview.sleep(ms)`、`pipeview.poll(limit)`、`pipeview.log(msg)`
|
- **Utilities** — `xserial.list_ports()`, `xserial.sleep(ms)`, `xserial.poll(limit)`, `xserial.log(msg)`
|
||||||
|
|
||||||
### 快捷键
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
| 快捷键 | 操作 |
|
| Shortcut | Action |
|
||||||
|--------|------|
|
|----------|--------|
|
||||||
| `Ctrl+N` | 新建会话 |
|
| `Ctrl+N` | New session |
|
||||||
| `Ctrl+E` | 编辑当前会话 |
|
| `Ctrl+E` | Edit current session |
|
||||||
| `Ctrl+W` | 删除当前会话 |
|
| `Ctrl+W` | Delete current session |
|
||||||
| `Ctrl+F5` | 切换连接 |
|
| `Ctrl+F5` | Toggle connection |
|
||||||
| `Ctrl+T` / `Ctrl+H` / `Ctrl+P` | 切换到文本 / 十六进制 / 波形视图 |
|
| `Ctrl+T` / `Ctrl+H` / `Ctrl+P` | Switch to Text / Hex / Plot view |
|
||||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | 下一个 / 上一个标签页 |
|
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Next / Previous tab |
|
||||||
| `Ctrl+L` | 清空输出 |
|
| `Ctrl+L` | Clear output |
|
||||||
| `Ctrl+F` | 搜索 |
|
| `Ctrl+F` | Search |
|
||||||
| `F3` / `Shift+F3` | 上一个 / 下一个匹配 |
|
| `F3` / `Shift+F3` | Next / Previous match |
|
||||||
| `Ctrl+,` | UI 设置 |
|
| `Ctrl+,` | UI settings |
|
||||||
| `Esc` | 关闭浮层 |
|
| `Esc` | Close overlay |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速开始
|
## Quick Start
|
||||||
|
|
||||||
### 前置条件
|
### Prerequisites
|
||||||
|
|
||||||
| 平台 | 依赖 |
|
| Platform | Dependencies |
|
||||||
|------|------|
|
|----------|-------------|
|
||||||
| **Linux** | `libudev-dev`(`apt install libudev-dev`) |
|
| **Linux** | `libudev-dev` (`apt install libudev-dev`) |
|
||||||
| **macOS** | 无需额外依赖 |
|
| **macOS** | None |
|
||||||
| **Windows** | 无需额外依赖 |
|
| **Windows** | None |
|
||||||
| **所有平台** | Rust ≥ 1.85、C 编译器(GCC / Clang / MSVC) |
|
| **All** | Rust ≥ 1.85, C compiler (GCC / Clang / MSVC) |
|
||||||
|
|
||||||
### 构建与运行
|
### Build & Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆项目
|
git clone https://github.com/your-org/xserial.git
|
||||||
git clone https://github.com/your-org/pipeview.git
|
cd xserial
|
||||||
cd pipeview
|
|
||||||
|
|
||||||
# 编译运行
|
cargo run -p xserial-gui
|
||||||
cargo run -p pipeview-gui
|
|
||||||
|
|
||||||
# 运行测试
|
# Run tests
|
||||||
cargo test --workspace # ~308 个测试
|
cargo test --workspace # ~308 tests
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
### 发布构建
|
### Release Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build -p pipeview-gui --release
|
cargo build -p xserial-gui --release
|
||||||
# 二进制位于 target/release/pipeview-gui (Linux/macOS)
|
# Binary: target/release/xserial-gui (Linux/macOS)
|
||||||
# 或 target/release/pipeview-gui.exe (Windows)
|
# target/release/xserial-gui.exe (Windows)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 完整示例:无人机遥测解析
|
## Walkthrough: Drone Telemetry
|
||||||
|
|
||||||
以 Betaflight/INAV 飞控遥测为例,数据格式为:
|
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
|
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:启动测试数据源
|
### Step 1: Start the data source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python tools/test_drone.py --rate 10 --port 8091
|
python tools/test_drone.py --rate 10 --port 8091
|
||||||
```
|
```
|
||||||
|
|
||||||
输出:
|
### Step 2: Configure the session
|
||||||
|
|
||||||
```
|
In xserial-gui, create a session with two pipelines:
|
||||||
Listening on 127.0.0.1:8091, waiting for connections...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤 2:配置 Session
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
在 pipeview-gui 中创建 Session:
|
|
||||||
|
|
||||||
| 配置项 | 值 |
|
|
||||||
|--------|-----|
|
|
||||||
| Transport | `TCP 127.0.0.1:8091` |
|
| Transport | `TCP 127.0.0.1:8091` |
|
||||||
| Pipeline 1 | Framer: `Line` → Decoder: `Lua` → 选择 `examples/drone_text.lua` |
|
| Pipeline 1 | Framer: `Line` → Decoder: `Lua` → `examples/drone_text.lua` |
|
||||||
| Pipeline 2 | Framer: `Line` → Decoder: `Lua` → 选择 `examples/drone_plot.lua` |
|
| Pipeline 2 | Framer: `Line` → Decoder: `Lua` → `examples/drone_plot.lua` |
|
||||||
|
|
||||||
### 步骤 3:查看结果
|
### Step 3: View the results
|
||||||
|
|
||||||
- **文本视图** — 显示格式化的传感器数据
|
- **Text View** — formatted sensor readouts
|
||||||
- **波形视图** — 显示 Gyro 三轴实时曲线 (gz/gy/gx)
|
- **Plot View** — real-time gyroscope curves (gz/gy/gx)
|
||||||
|
|
||||||
`examples/drone_plot.lua` 的核心逻辑:
|
The decoder in `examples/drone_plot.lua`:
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
return {
|
return {
|
||||||
@@ -199,63 +191,59 @@ return {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Lua 脚本开发指南
|
## Lua Scripting Guide
|
||||||
|
|
||||||
### 分帧器 API
|
### Framer API
|
||||||
|
|
||||||
分帧器将原始字节流切分为帧,Lua 脚本必须返回包含以下函数的 table:
|
A Lua framer splits raw bytes into frames. The script must return a table with these functions:
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
return {
|
return {
|
||||||
-- 输入新到达的字节,返回帧数组(Lua strings)
|
|
||||||
feed = function(bytes)
|
feed = function(bytes)
|
||||||
-- bytes: Lua string(原始字节)
|
-- bytes: Lua string (raw bytes)
|
||||||
-- 返回: { frame1, frame2, ... } 或 nil
|
-- Returns: { frame1, frame2, ... } or nil
|
||||||
end,
|
end,
|
||||||
|
|
||||||
-- 刷新缓冲区中残留的数据
|
|
||||||
flush = function()
|
flush = function()
|
||||||
-- 返回: 最后一帧(Lua string)或 nil
|
-- Returns: last buffered frame (string) or nil
|
||||||
end,
|
end,
|
||||||
|
|
||||||
-- 重置内部状态
|
|
||||||
reset = function()
|
reset = function()
|
||||||
end,
|
end,
|
||||||
|
|
||||||
-- 返回缓冲区中待处理的字节数
|
|
||||||
pending_len = function()
|
pending_len = function()
|
||||||
-- 返回: number
|
-- Returns: number of bytes pending in buffer
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
参考实现:`crates/pipeview-client/tests/fixtures/lua_line_framer.lua`(按 `\n` 分割的行分帧器)
|
Reference: `tests/lua_line_framer.lua` (line-based framer splitting on `\n`).
|
||||||
|
|
||||||
### 解码器 API
|
### Decoder API
|
||||||
|
|
||||||
解码器将帧解析为结构化数据,Lua 脚本必须返回包含 `decode` 函数的 table:
|
A Lua decoder parses a frame into structured data. The script must return a table with a `decode` function:
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
return {
|
return {
|
||||||
decode = function(frame)
|
decode = function(frame)
|
||||||
-- frame: Lua string(来自分帧器的一帧)
|
-- frame: Lua string (one frame from the framer)
|
||||||
-- 返回 nil → 跳过此帧
|
-- Returns nil → skip this frame
|
||||||
-- 返回 string → 自动视为 Text
|
-- Returns string → treated as Text
|
||||||
-- 返回 table → 必须包含 kind 字段
|
-- Returns table → must contain "kind" field
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**返回值格式:**
|
**Return value formats:**
|
||||||
|
|
||||||
| `kind` | 必需字段 | 可选字段 | 用途 |
|
| `kind` | Required | Optional | Purpose |
|
||||||
|--------|----------|----------|------|
|
|--------|----------|----------|---------|
|
||||||
| `"text"` | `data: string` | — | 文本视图 |
|
| `"text"` | `data: string` | — | Text view |
|
||||||
| `"hex"` | `data: string` | — | 十六进制视图 |
|
| `"hex"` | `data: string` | — | Hex view |
|
||||||
| `"binary"` | `data: string` | — | 原始二进制 |
|
| `"binary"` | `data: string` | — | Raw bytes |
|
||||||
| `"plot"` | `channels: {{number,…},…}` | `sample_type`、`format` | 波形视图 |
|
| `"plot"` | `channels: {{number,…},…}` | `sample_type`, `format` | Plot view |
|
||||||
|
|
||||||
**Plot 返回值示例:**
|
**Plot return value example:**
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
return {
|
return {
|
||||||
@@ -264,39 +252,40 @@ return {
|
|||||||
{ 1.0, 2.0, 3.0 }, -- channel 0
|
{ 1.0, 2.0, 3.0 }, -- channel 0
|
||||||
{ 4.0, 5.0, 6.0 }, -- channel 1
|
{ 4.0, 5.0, 6.0 }, -- channel 1
|
||||||
},
|
},
|
||||||
sample_type = "F64", -- 默认 F64,可选 I8/U8/I16/U16/I32/U32/I64/U64/F32
|
sample_type = "F64", -- default F64; also I8/U8/I16/U32/U64/F32
|
||||||
format = "Block", -- 默认 Interleaved,可选 Block/XY
|
format = "Block", -- default Interleaved; also Block/XY
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
更多示例见 `examples/` 目录。
|
More examples in `examples/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 架构
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────┐
|
||||||
│ pipeview-gui (egui) │
|
│ xserial-gui (egui) xserial-tui (ratatui) │
|
||||||
├──────────────────────────────────────────────────┤
|
├──────────────────────────────────────────────────┤
|
||||||
│ pipeview-client │
|
│ xserial-client │
|
||||||
│ SessionManager · Session · Config · History │
|
│ SessionManager · Session · Config · History │
|
||||||
│ Lua Runtime (mlua / LuaJIT) │
|
│ Lua Runtime (mlua / LuaJIT) │
|
||||||
├──────────────────────────────────────────────────┤
|
├──────────────────────────────────────────────────┤
|
||||||
│ pipeview-core │
|
│ xserial-core │
|
||||||
│ Transport ──▶ Frame ──▶ Protocol ──▶ Pipeline │
|
│ Transport ──▶ Frame ──▶ Protocol ──▶ Pipeline │
|
||||||
└──────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
| Crate | 类型 | 职责 |
|
| Crate | Type | Purpose |
|
||||||
|-------|------|------|
|
|-------|------|---------|
|
||||||
| `pipeview-core` | library | 传输层(Serial/TCP/UDP)、分帧器(Line/Fixed/Length/COBS/Mixed/Lua)、协议解码器(Text/Hex/Plot)、MultiPipeline |
|
| `xserial-core` | library | Transport (Serial/TCP/UDP), framers (Line/Fixed/Length/COBS/Mixed/Lua), decoders (Text/Hex/Plot), MultiPipeline |
|
||||||
| `pipeview-client` | library | Session 生命周期管理、SessionManager、事件广播(tokio broadcast)、RingBuffer 历史、Lua 运行时及会话 API |
|
| `xserial-client` | library | Session lifecycle, SessionManager, event broadcast (tokio broadcast), RingBuffer history, Lua runtime & session API |
|
||||||
| `pipeview-gui` | binary | egui 桌面应用,包含 sidebar/config/console/text/hex/plot 面板、键盘快捷键、字体管理、性能分析 |
|
| `xserial-gui` | binary | egui desktop app: sidebar, config, console, text/hex/plot panels, keyboard shortcuts, font management, profiling |
|
||||||
|
| `xserial-tui` | binary | ratatui terminal app (feature-incomplete, under development) |
|
||||||
|
|
||||||
**依赖方向:** `core ← client ← gui`
|
**Dependency flow:** `core ← client ← {gui, tui}`
|
||||||
|
|
||||||
**数据流:**
|
**Data flow:**
|
||||||
|
|
||||||
```
|
```
|
||||||
[Transport] → read bytes → [MultiPipeline]
|
[Transport] → read bytes → [MultiPipeline]
|
||||||
@@ -305,87 +294,84 @@ return {
|
|||||||
→ ...
|
→ ...
|
||||||
```
|
```
|
||||||
|
|
||||||
每条管线独立分帧、解码,互不干扰。只有成功解码的管线产生输出。
|
Each pipeline frames and decodes independently. Only successful decodes produce output.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 配置与持久化
|
## Configuration & Persistence
|
||||||
|
|
||||||
GUI 状态自动保存,路径遵循各平台规范:
|
GUI state is saved automatically to platform-standard locations:
|
||||||
|
|
||||||
| 平台 | 路径 |
|
| Platform | Path |
|
||||||
|------|------|
|
|----------|------|
|
||||||
| Linux | `$XDG_CONFIG_HOME/pipeview/gui-state.json` 或 `~/.config/pipeview/gui-state.json` |
|
| Linux | `$XDG_CONFIG_HOME/xserial/gui-state.json` or `~/.config/xserial/gui-state.json` |
|
||||||
| macOS | `~/Library/Application Support/pipeview/gui-state.json` |
|
| macOS | `~/Library/Application Support/xserial/gui-state.json` |
|
||||||
| Windows | `%APPDATA%\pipeview\gui-state.json` |
|
| Windows | `%APPDATA%\xserial\gui-state.json` |
|
||||||
|
|
||||||
持久化的内容包括:Session 配置(传输参数、管线设置)、日志开关及路径、活动标签页、显示选项。
|
Persisted data includes: session configurations (transport, pipelines), log settings, active tab, and display options.
|
||||||
|
|
||||||
日志文件默认保存在配置目录的 `logs/` 子目录下,文件命名格式为 `session_{id}_{timestamp}.log`。
|
Log files are written to `logs/` under the config directory, named `session_{id}_{timestamp}.log`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 开发工具
|
## Development Tools
|
||||||
|
|
||||||
### 测试数据生成器
|
### Test Data Generators
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Plot 波形测试数据
|
# 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 --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 mixed --format xy --channels 2
|
||||||
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
|
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
|
python tools/test_drone.py --rate 10 --port 8092
|
||||||
```
|
```
|
||||||
|
|
||||||
### 性能分析
|
### Profiling
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RUST_LOG=pipeview_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p pipeview-gui
|
RUST_LOG=xserial_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p xserial-gui
|
||||||
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p pipeview-gui
|
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p xserial-gui
|
||||||
```
|
```
|
||||||
|
|
||||||
输出每帧的耗时、事件 drain 耗时、text/hex/plot 渲染耗时、plot 点数统计。
|
### Tracing
|
||||||
|
|
||||||
### 日志
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RUST_LOG=info cargo run -p pipeview-gui # 应用日志
|
RUST_LOG=info cargo run -p xserial-gui
|
||||||
RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui # 详细日志
|
RUST_LOG=xserial_gui=debug cargo run -p xserial-gui
|
||||||
```
|
```
|
||||||
|
|
||||||
使用 `tracing-subscriber` + `RUST_LOG` 环境变量控制。
|
### Project Structure
|
||||||
|
|
||||||
### 项目结构
|
|
||||||
|
|
||||||
```
|
```
|
||||||
crates/
|
crates/
|
||||||
pipeview-core/ # 传输、分帧、协议
|
xserial-core/ # Transport, framing, protocol
|
||||||
pipeview-client/ # 会话管理、Lua 运行时
|
xserial-client/ # Session management, Lua runtime
|
||||||
tests/fixtures/ # Lua 测试夹具
|
xserial-gui/ # egui desktop application
|
||||||
pipeview-gui/ # egui 桌面应用
|
xserial-tui/ # ratatui terminal application
|
||||||
examples/ # Lua 脚本示例
|
examples/ # Lua script examples
|
||||||
drone_plot.lua # 飞控遥测波形解码器
|
drone_plot.lua # Drone telemetry plot decoder
|
||||||
drone_text.lua # 飞控遥测文本解码器
|
drone_text.lua # Drone telemetry text decoder
|
||||||
tools/ # 开发辅助工具
|
tests/ # Lua test fixtures
|
||||||
test_plot.py # 波形测试数据生成器
|
tools/ # Development utilities
|
||||||
test_drone.py # 飞控测试数据生成器
|
test_plot.py # Waveform test data generator
|
||||||
test_plot_serial.c # C 串口波形测试客户端
|
test_drone.py # Drone test data generator
|
||||||
test_text_serial.c # C 串口文本测试客户端
|
test_plot_serial.c # C serial plot test client
|
||||||
xs_mixed_plot.h # MixedTextPlot 协议参考头文件
|
test_text_serial.c # C serial text test client
|
||||||
|
xs_mixed_plot.h # MixedTextPlot protocol reference
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 技术栈
|
## Tech Stack
|
||||||
|
|
||||||
- **运行时**:tokio (multi-thread)
|
- **Runtime**: tokio (multi-threaded)
|
||||||
- **串口**:`tokio-serial` + `serialport`
|
- **Serial**: `tokio-serial` + `serialport`
|
||||||
- **GUI**:egui + egui_plot
|
- **GUI**: egui + egui_plot
|
||||||
- **Lua**:mlua 0.11, LuaJIT (vendored 编译), async/serde/send
|
- **Lua**: mlua 0.11, LuaJIT (vendored), async/serde/send
|
||||||
- **事件分发**:`tokio::sync::broadcast`(多订阅者)
|
- **Events**: `tokio::sync::broadcast` (multi-subscriber)
|
||||||
- **零 feature flags**、零 build script、零条件编译
|
- **Zero feature flags**, zero build scripts, zero conditional compilation
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
376
README_en.md
376
README_en.md
@@ -1,376 +0,0 @@
|
|||||||
# 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: `crates/pipeview-client/tests/fixtures/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-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 |
|
|
||||||
|
|
||||||
**Dependency flow:** `core ← client ← gui`
|
|
||||||
|
|
||||||
**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
|
|
||||||
tests/fixtures/ # Lua test fixtures
|
|
||||||
pipeview-gui/ # egui desktop application
|
|
||||||
examples/ # Lua script examples
|
|
||||||
drone_plot.lua # Drone telemetry plot decoder
|
|
||||||
drone_text.lua # Drone telemetry text decoder
|
|
||||||
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
|
|
||||||
394
README_zh.md
Normal file
394
README_zh.md
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
# xserial
|
||||||
|
|
||||||
|
**跨平台串口 / TCP / UDP 数据观测工具** — 可配置分帧、协议解码、多会话管理、实时波形绘图,单二进制零依赖。
|
||||||
|
|
||||||
|
基于 Rust + [egui](https://github.com/emilk/egui) 构建,支持 Lua 脚本扩展。
|
||||||
|
|
||||||
|
[English](README.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** — `xserial.open()` 创建 Session、`session:on_data()` 事件回调、`session:send()` 发送数据
|
||||||
|
- **工具函数** — `xserial.list_ports()`、`xserial.sleep(ms)`、`xserial.poll(limit)`、`xserial.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/xserial.git
|
||||||
|
cd xserial
|
||||||
|
|
||||||
|
# 编译运行
|
||||||
|
cargo run -p xserial-gui
|
||||||
|
|
||||||
|
# 运行测试
|
||||||
|
cargo test --workspace # ~308 个测试
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发布构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build -p xserial-gui --release
|
||||||
|
# 二进制位于 target/release/xserial-gui (Linux/macOS)
|
||||||
|
# 或 target/release/xserial-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
|
||||||
|
|
||||||
|
在 xserial-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/` 目录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ xserial-gui (egui) xserial-tui (ratatui) │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ xserial-client │
|
||||||
|
│ SessionManager · Session · Config · History │
|
||||||
|
│ Lua Runtime (mlua / LuaJIT) │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ xserial-core │
|
||||||
|
│ Transport ──▶ Frame ──▶ Protocol ──▶ Pipeline │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Crate | 类型 | 职责 |
|
||||||
|
|-------|------|------|
|
||||||
|
| `xserial-core` | library | 传输层(Serial/TCP/UDP)、分帧器(Line/Fixed/Length/COBS/Mixed/Lua)、协议解码器(Text/Hex/Plot)、MultiPipeline |
|
||||||
|
| `xserial-client` | library | Session 生命周期管理、SessionManager、事件广播(tokio broadcast)、RingBuffer 历史、Lua 运行时及会话 API |
|
||||||
|
| `xserial-gui` | binary | egui 桌面应用,包含 sidebar/config/console/text/hex/plot 面板、键盘快捷键、字体管理、性能分析 |
|
||||||
|
| `xserial-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/xserial/gui-state.json` 或 `~/.config/xserial/gui-state.json` |
|
||||||
|
| macOS | `~/Library/Application Support/xserial/gui-state.json` |
|
||||||
|
| Windows | `%APPDATA%\xserial\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=xserial_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p xserial-gui
|
||||||
|
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p xserial-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
输出每帧的耗时、事件 drain 耗时、text/hex/plot 渲染耗时、plot 点数统计。
|
||||||
|
|
||||||
|
### 日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=info cargo run -p xserial-gui # 应用日志
|
||||||
|
RUST_LOG=xserial_gui=debug cargo run -p xserial-gui # 详细日志
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 `tracing-subscriber` + `RUST_LOG` 环境变量控制。
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/
|
||||||
|
xserial-core/ # 传输、分帧、协议
|
||||||
|
xserial-client/ # 会话管理、Lua 运行时
|
||||||
|
xserial-gui/ # egui 桌面应用
|
||||||
|
xserial-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
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
use crate::event::DecodedEntry;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
|
|
||||||
pub struct RingBuffer<T> {
|
|
||||||
buf: VecDeque<T>,
|
|
||||||
limit: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> RingBuffer<T> {
|
|
||||||
pub fn new(limit: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
buf: VecDeque::with_capacity(limit.min(1024)),
|
|
||||||
limit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn push(&mut self, item: T) {
|
|
||||||
if self.buf.len() >= self.limit {
|
|
||||||
self.buf.pop_front();
|
|
||||||
}
|
|
||||||
self.buf.push_back(item);
|
|
||||||
}
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.buf.len()
|
|
||||||
}
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.buf.is_empty()
|
|
||||||
}
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = &T> {
|
|
||||||
self.buf.iter()
|
|
||||||
}
|
|
||||||
pub fn get(&self, index: usize) -> Option<&T> {
|
|
||||||
self.buf.get(index)
|
|
||||||
}
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.buf.clear();
|
|
||||||
}
|
|
||||||
pub fn set_limit(&mut self, limit: usize) {
|
|
||||||
self.limit = limit;
|
|
||||||
while self.buf.len() > self.limit {
|
|
||||||
self.buf.pop_front();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn drain_recent(&self, count: usize) -> Vec<T>
|
|
||||||
where
|
|
||||||
T: Clone,
|
|
||||||
{
|
|
||||||
let start = self.buf.len().saturating_sub(count);
|
|
||||||
self.buf.iter().skip(start).cloned().collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RingBuffer<DecodedEntry> {
|
|
||||||
pub fn entries_for_pipeline(&self, name: &str) -> Vec<&DecodedEntry> {
|
|
||||||
self.buf
|
|
||||||
.iter()
|
|
||||||
.filter(|e| e.pipeline_name == name)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::event::DecodedEntry;
|
|
||||||
use pipeview_core::protocol::DecodedData;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn push_evicts_oldest_when_limit_reached() {
|
|
||||||
let mut rb = RingBuffer::new(3);
|
|
||||||
rb.push(1);
|
|
||||||
rb.push(2);
|
|
||||||
rb.push(3);
|
|
||||||
rb.push(4);
|
|
||||||
rb.push(5);
|
|
||||||
|
|
||||||
assert_eq!(rb.len(), 3);
|
|
||||||
assert_eq!(rb.get(0), Some(&3));
|
|
||||||
assert_eq!(rb.get(1), Some(&4));
|
|
||||||
assert_eq!(rb.get(2), Some(&5));
|
|
||||||
assert_eq!(rb.get(3), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn iter_yields_oldest_to_newest() {
|
|
||||||
let mut rb = RingBuffer::new(3);
|
|
||||||
rb.push("a");
|
|
||||||
rb.push("b");
|
|
||||||
rb.push("c");
|
|
||||||
|
|
||||||
let items: Vec<_> = rb.iter().collect();
|
|
||||||
assert_eq!(items, vec![&"a", &"b", &"c"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clear_empties_buffer() {
|
|
||||||
let mut rb = RingBuffer::new(3);
|
|
||||||
rb.push(1);
|
|
||||||
rb.push(2);
|
|
||||||
rb.clear();
|
|
||||||
|
|
||||||
assert!(rb.is_empty());
|
|
||||||
assert_eq!(rb.len(), 0);
|
|
||||||
assert_eq!(rb.get(0), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_limit_trims_excess() {
|
|
||||||
let mut rb = RingBuffer::new(5);
|
|
||||||
for i in 0..5 {
|
|
||||||
rb.push(i);
|
|
||||||
}
|
|
||||||
rb.set_limit(2);
|
|
||||||
|
|
||||||
assert_eq!(rb.len(), 2);
|
|
||||||
assert_eq!(rb.get(0), Some(&3));
|
|
||||||
assert_eq!(rb.get(1), Some(&4));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_limit_can_grow_without_data_loss() {
|
|
||||||
let mut rb = RingBuffer::new(2);
|
|
||||||
rb.push(1);
|
|
||||||
rb.push(2);
|
|
||||||
rb.set_limit(5);
|
|
||||||
|
|
||||||
assert_eq!(rb.len(), 2);
|
|
||||||
assert_eq!(rb.get(0), Some(&1));
|
|
||||||
assert_eq!(rb.get(1), Some(&2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn drain_recent_respects_count_and_order() {
|
|
||||||
let mut rb = RingBuffer::new(5);
|
|
||||||
for i in 0..5 {
|
|
||||||
rb.push(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
let empty: Vec<i32> = rb.drain_recent(0);
|
|
||||||
assert!(empty.is_empty());
|
|
||||||
|
|
||||||
let last_two = rb.drain_recent(2);
|
|
||||||
assert_eq!(last_two, vec![3, 4]);
|
|
||||||
|
|
||||||
let all = rb.drain_recent(10);
|
|
||||||
assert_eq!(all, vec![0, 1, 2, 3, 4]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn entries_for_pipeline_filters_by_name() {
|
|
||||||
let mut rb = RingBuffer::new(10);
|
|
||||||
rb.push(DecodedEntry {
|
|
||||||
pipeline_name: "a".into(),
|
|
||||||
data: DecodedData::Text("one".into()),
|
|
||||||
});
|
|
||||||
rb.push(DecodedEntry {
|
|
||||||
pipeline_name: "b".into(),
|
|
||||||
data: DecodedData::Text("two".into()),
|
|
||||||
});
|
|
||||||
rb.push(DecodedEntry {
|
|
||||||
pipeline_name: "a".into(),
|
|
||||||
data: DecodedData::Text("three".into()),
|
|
||||||
});
|
|
||||||
|
|
||||||
let a = rb.entries_for_pipeline("a");
|
|
||||||
assert_eq!(a.len(), 2);
|
|
||||||
assert_eq!(a[0].pipeline_name, "a");
|
|
||||||
assert_eq!(a[1].pipeline_name, "a");
|
|
||||||
|
|
||||||
let none = rb.entries_for_pipeline("missing");
|
|
||||||
assert!(none.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use mlua::{Function, Lua, Table};
|
|
||||||
|
|
||||||
fn fixture_path(name: &str) -> PathBuf {
|
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.join("tests")
|
|
||||||
.join("fixtures")
|
|
||||||
.join(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_fixture(name: &str) -> (Lua, Table) {
|
|
||||||
let lua = Lua::new();
|
|
||||||
let src = fs::read_to_string(fixture_path(name)).unwrap();
|
|
||||||
let table: Table = lua.load(&src).set_name(name).eval().unwrap();
|
|
||||||
(lua, table)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lua_line_framer_fixture_splits_crlf_and_lf() {
|
|
||||||
let (lua, table) = load_fixture("lua_line_framer.lua");
|
|
||||||
let feed: Function = table.get("feed").unwrap();
|
|
||||||
|
|
||||||
let input = lua.create_string(b"hello\r\nworld\n").unwrap();
|
|
||||||
let frames: Vec<String> = feed.call(input).unwrap();
|
|
||||||
assert_eq!(frames, vec!["hello", "world"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lua_line_framer_fixture_handles_split_chunks_and_flush() {
|
|
||||||
let (lua, table) = load_fixture("lua_line_framer.lua");
|
|
||||||
let feed: Function = table.get("feed").unwrap();
|
|
||||||
let flush: Function = table.get("flush").unwrap();
|
|
||||||
let pending_len: Function = table.get("pending_len").unwrap();
|
|
||||||
let reset: Function = table.get("reset").unwrap();
|
|
||||||
|
|
||||||
let frames: Vec<String> = feed.call(lua.create_string(b"par").unwrap()).unwrap();
|
|
||||||
assert!(frames.is_empty());
|
|
||||||
assert_eq!(pending_len.call::<usize>(()).unwrap(), 3);
|
|
||||||
|
|
||||||
let frames: Vec<String> = feed.call(lua.create_string(b"tial\n").unwrap()).unwrap();
|
|
||||||
assert_eq!(frames, vec!["partial"]);
|
|
||||||
|
|
||||||
let flushed: Option<String> = flush.call(()).unwrap();
|
|
||||||
assert_eq!(flushed.as_deref(), None);
|
|
||||||
|
|
||||||
reset.call::<()>(()).unwrap();
|
|
||||||
assert_eq!(pending_len.call::<usize>(()).unwrap(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lua_text_decoder_fixture_decodes_text_and_rejects_empty() {
|
|
||||||
let (lua, table) = load_fixture("lua_text_decoder.lua");
|
|
||||||
let decode: Function = table.get("decode").unwrap();
|
|
||||||
|
|
||||||
let decoded: Option<Table> = decode.call(lua.create_string(b"abc").unwrap()).unwrap();
|
|
||||||
let decoded = decoded.expect("non-empty frame should decode");
|
|
||||||
let kind: String = decoded.get("kind").unwrap();
|
|
||||||
let data: String = decoded.get("data").unwrap();
|
|
||||||
assert_eq!(kind, "text");
|
|
||||||
assert_eq!(data, "abc");
|
|
||||||
|
|
||||||
let empty: Option<Table> = decode.call(lua.create_string(b"").unwrap()).unwrap();
|
|
||||||
assert!(empty.is_none(), "empty frame should be rejected");
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
use proptest::prelude::*;
|
|
||||||
|
|
||||||
use pipeview_core::frame::Framer;
|
|
||||||
use pipeview_core::frame::cobs::{CobsFramer, cobs_decode, cobs_encode};
|
|
||||||
use pipeview_core::frame::length::{LengthConfig, LengthPrefixedFramer};
|
|
||||||
use pipeview_core::protocol::Endian;
|
|
||||||
use pipeview_core::protocol::ProtocolDecoder;
|
|
||||||
use pipeview_core::protocol::hex::{HexConfig, HexDecoder};
|
|
||||||
use pipeview_core::protocol::plot::{PlotConfig, PlotDecoder, SampleType};
|
|
||||||
use pipeview_core::protocol::text::{TextDecoder, TextEncoding};
|
|
||||||
|
|
||||||
proptest! {
|
|
||||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
|
||||||
|
|
||||||
// ── COBS ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cobs_roundtrip_any_payload(payload in prop::collection::vec(any::<u8>(), 0..512)) {
|
|
||||||
let encoded = cobs_encode(&payload);
|
|
||||||
let decoded = cobs_decode(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(&decoded, &payload);
|
|
||||||
|
|
||||||
let mut framer = CobsFramer::new(1024 * 1024);
|
|
||||||
let mut wire = encoded;
|
|
||||||
wire.push(0x00);
|
|
||||||
let frames = framer.feed(&wire);
|
|
||||||
prop_assert_eq!(frames.len(), 1);
|
|
||||||
prop_assert_eq!(&frames[0], &payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Length-prefixed ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn length_roundtrip_any_payload(
|
|
||||||
len_bytes in prop_oneof![Just(1usize), Just(2usize), Just(4usize)],
|
|
||||||
little_endian in any::<bool>(),
|
|
||||||
includes_self in any::<bool>(),
|
|
||||||
payload in prop::collection::vec(any::<u8>(), 0..200)
|
|
||||||
) {
|
|
||||||
let endian = if little_endian { Endian::Little } else { Endian::Big };
|
|
||||||
let raw_len = if includes_self {
|
|
||||||
payload.len() + len_bytes
|
|
||||||
} else {
|
|
||||||
payload.len()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut wire = Vec::with_capacity(len_bytes + payload.len());
|
|
||||||
match (len_bytes, endian) {
|
|
||||||
(1, _) => wire.push(raw_len as u8),
|
|
||||||
(2, Endian::Big) => wire.extend_from_slice(&(raw_len as u16).to_be_bytes()),
|
|
||||||
(2, Endian::Little) => wire.extend_from_slice(&(raw_len as u16).to_le_bytes()),
|
|
||||||
(4, Endian::Big) => wire.extend_from_slice(&(raw_len as u32).to_be_bytes()),
|
|
||||||
(4, Endian::Little) => wire.extend_from_slice(&(raw_len as u32).to_le_bytes()),
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
wire.extend_from_slice(&payload);
|
|
||||||
|
|
||||||
let config = LengthConfig {
|
|
||||||
len_bytes,
|
|
||||||
endian,
|
|
||||||
length_includes_self: includes_self,
|
|
||||||
max_payload: 1024 * 1024,
|
|
||||||
};
|
|
||||||
let mut framer = LengthPrefixedFramer::new(config);
|
|
||||||
let frames = framer.feed(&wire);
|
|
||||||
prop_assert_eq!(frames.len(), 1);
|
|
||||||
prop_assert_eq!(&frames[0], &payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Protocol decoders never panic on arbitrary bytes ─────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn plot_decode_arbitrary_bytes_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..256)) {
|
|
||||||
let decoder = PlotDecoder::new(PlotConfig {
|
|
||||||
sample_type: SampleType::F32,
|
|
||||||
endian: Endian::Little,
|
|
||||||
channels: 2,
|
|
||||||
format: pipeview_core::protocol::plot::PlotFormat::Interleaved,
|
|
||||||
});
|
|
||||||
let _ = decoder.decode(&bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hex_decode_arbitrary_bytes_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..256)) {
|
|
||||||
let decoder = HexDecoder::new(HexConfig::default());
|
|
||||||
let decoded = decoder.decode(&bytes);
|
|
||||||
prop_assert!(decoded.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn latin1_decode_arbitrary_bytes_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..256)) {
|
|
||||||
let decoder = TextDecoder::new(TextEncoding::Latin1);
|
|
||||||
let decoded = decoder.decode(&bytes);
|
|
||||||
prop_assert!(decoded.is_some());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,451 +0,0 @@
|
|||||||
use ansitok::{AnsiColor, ElementKind, VisualAttribute, parse_ansi, parse_ansi_sgr};
|
|
||||||
use egui::Color32;
|
|
||||||
use egui::text::{LayoutJob, TextFormat};
|
|
||||||
use std::ops::Range;
|
|
||||||
|
|
||||||
pub fn ansi_to_layout_job(text: &str, base_format: TextFormat) -> LayoutJob {
|
|
||||||
let mut job = LayoutJob::default();
|
|
||||||
for segment in ansi_segments(text, &base_format) {
|
|
||||||
job.append(segment.text, 0.0, segment.format);
|
|
||||||
}
|
|
||||||
|
|
||||||
job
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ansi_to_layout_job_highlighted(
|
|
||||||
text: &str,
|
|
||||||
base_format: TextFormat,
|
|
||||||
query: &str,
|
|
||||||
case_sensitive: bool,
|
|
||||||
highlight_format: TextFormat,
|
|
||||||
) -> LayoutJob {
|
|
||||||
if query.is_empty() {
|
|
||||||
return ansi_to_layout_job(text, base_format);
|
|
||||||
}
|
|
||||||
|
|
||||||
let segments = ansi_segments(text, &base_format);
|
|
||||||
let mut visible_text = String::with_capacity(segments.iter().map(|s| s.text.len()).sum());
|
|
||||||
for segment in &segments {
|
|
||||||
visible_text.push_str(segment.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ranges = search_ranges(&visible_text, query, case_sensitive);
|
|
||||||
if ranges.is_empty() {
|
|
||||||
let mut job = LayoutJob::default();
|
|
||||||
for segment in segments {
|
|
||||||
job.append(segment.text, 0.0, segment.format);
|
|
||||||
}
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut job = LayoutJob::default();
|
|
||||||
let mut offset = 0;
|
|
||||||
let mut range_idx = 0;
|
|
||||||
for segment in segments {
|
|
||||||
let len = segment.text.len();
|
|
||||||
append_segment_with_highlights(
|
|
||||||
&mut job,
|
|
||||||
segment.text,
|
|
||||||
segment.format,
|
|
||||||
&highlight_format,
|
|
||||||
offset,
|
|
||||||
&ranges,
|
|
||||||
&mut range_idx,
|
|
||||||
);
|
|
||||||
offset += len;
|
|
||||||
}
|
|
||||||
job
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StyledSegment<'a> {
|
|
||||||
text: &'a str,
|
|
||||||
format: TextFormat,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct AnsiStyle {
|
|
||||||
fg: Option<Color32>,
|
|
||||||
bg: Option<Color32>,
|
|
||||||
bold: bool,
|
|
||||||
italic: bool,
|
|
||||||
underline: bool,
|
|
||||||
strikethrough: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AnsiStyle {
|
|
||||||
fn apply_sgr(&mut self, attr: VisualAttribute) {
|
|
||||||
match attr {
|
|
||||||
VisualAttribute::Reset(0) => self.reset_all(),
|
|
||||||
VisualAttribute::Reset(22) => self.bold = false,
|
|
||||||
VisualAttribute::Reset(23) => self.italic = false,
|
|
||||||
VisualAttribute::Reset(24) => self.underline = false,
|
|
||||||
VisualAttribute::Reset(29) => self.strikethrough = false,
|
|
||||||
VisualAttribute::Reset(39) => self.fg = None,
|
|
||||||
VisualAttribute::Reset(49) => self.bg = None,
|
|
||||||
VisualAttribute::Reset(_) => {}
|
|
||||||
VisualAttribute::Bold => self.bold = true,
|
|
||||||
VisualAttribute::Faint => self.bold = false,
|
|
||||||
VisualAttribute::Italic => self.italic = true,
|
|
||||||
VisualAttribute::Underline => self.underline = true,
|
|
||||||
VisualAttribute::Crossedout => self.strikethrough = true,
|
|
||||||
VisualAttribute::FgColor(c) => self.fg = Some(ansi_color_to_egui(c)),
|
|
||||||
VisualAttribute::BgColor(c) => self.bg = Some(ansi_color_to_egui(c)),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reset_all(&mut self) {
|
|
||||||
*self = Self::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format(&self, base_format: &TextFormat) -> TextFormat {
|
|
||||||
let mut fmt = base_format.clone();
|
|
||||||
|
|
||||||
if let Some(c) = self.fg {
|
|
||||||
if self.bold {
|
|
||||||
fmt.color = brighten(c);
|
|
||||||
} else {
|
|
||||||
fmt.color = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(c) = self.bg {
|
|
||||||
fmt.background = c;
|
|
||||||
}
|
|
||||||
if self.italic {
|
|
||||||
fmt.italics = true;
|
|
||||||
}
|
|
||||||
if self.underline {
|
|
||||||
fmt.underline = egui::Stroke::new(1.0, fmt.color);
|
|
||||||
}
|
|
||||||
if self.strikethrough {
|
|
||||||
fmt.strikethrough = egui::Stroke::new(1.0, fmt.color);
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ansi_segments<'a>(text: &'a str, base_format: &TextFormat) -> Vec<StyledSegment<'a>> {
|
|
||||||
let mut segments = Vec::new();
|
|
||||||
let mut style = AnsiStyle::default();
|
|
||||||
|
|
||||||
for element in parse_ansi(text) {
|
|
||||||
match element.kind() {
|
|
||||||
ElementKind::Text => {
|
|
||||||
let slice = &text[element.start()..element.end()];
|
|
||||||
if !slice.is_empty() {
|
|
||||||
segments.push(StyledSegment {
|
|
||||||
text: slice,
|
|
||||||
format: style.format(base_format),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ElementKind::Sgr => {
|
|
||||||
let sgr = &text[element.start()..element.end()];
|
|
||||||
apply_sgr_sequence(&mut style, sgr);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
segments
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_sgr_sequence(style: &mut AnsiStyle, sgr: &str) {
|
|
||||||
let params = sgr
|
|
||||||
.strip_prefix("\x1b[")
|
|
||||||
.and_then(|s| s.strip_suffix('m'))
|
|
||||||
.unwrap_or(sgr);
|
|
||||||
|
|
||||||
if params.is_empty() {
|
|
||||||
style.reset_all();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for output in parse_ansi_sgr(sgr) {
|
|
||||||
if let Some(attr) = output.as_escape() {
|
|
||||||
style.apply_sgr(attr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn search_ranges(text: &str, query: &str, case_sensitive: bool) -> Vec<Range<usize>> {
|
|
||||||
if query.is_empty() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
if case_sensitive {
|
|
||||||
return text
|
|
||||||
.match_indices(query)
|
|
||||||
.map(|(start, matched)| start..start + matched.len())
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
let (search_text, byte_to_original) = lowercase_with_byte_map(text);
|
|
||||||
let query = query.to_lowercase();
|
|
||||||
let mut ranges = Vec::new();
|
|
||||||
let mut idx = 0;
|
|
||||||
while let Some(pos) = search_text[idx..].find(&query) {
|
|
||||||
let start = idx + pos;
|
|
||||||
let end = start + query.len();
|
|
||||||
let original_start = byte_to_original.get(start).copied().unwrap_or(text.len());
|
|
||||||
let original_end = byte_to_original.get(end).copied().unwrap_or(text.len());
|
|
||||||
if original_start < original_end {
|
|
||||||
ranges.push(original_start..original_end);
|
|
||||||
}
|
|
||||||
idx = end;
|
|
||||||
}
|
|
||||||
ranges
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lowercase_with_byte_map(text: &str) -> (String, Vec<usize>) {
|
|
||||||
let mut lowered = String::new();
|
|
||||||
let mut byte_to_original = Vec::new();
|
|
||||||
|
|
||||||
for (original_idx, ch) in text.char_indices() {
|
|
||||||
for lower_ch in ch.to_lowercase() {
|
|
||||||
lowered.push(lower_ch);
|
|
||||||
for _ in 0..lower_ch.len_utf8() {
|
|
||||||
byte_to_original.push(original_idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
byte_to_original.push(text.len());
|
|
||||||
(lowered, byte_to_original)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_segment_with_highlights(
|
|
||||||
job: &mut LayoutJob,
|
|
||||||
text: &str,
|
|
||||||
format: TextFormat,
|
|
||||||
highlight_format: &TextFormat,
|
|
||||||
segment_start: usize,
|
|
||||||
ranges: &[Range<usize>],
|
|
||||||
range_idx: &mut usize,
|
|
||||||
) {
|
|
||||||
let segment_end = segment_start + text.len();
|
|
||||||
while *range_idx < ranges.len() && ranges[*range_idx].end <= segment_start {
|
|
||||||
*range_idx += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut idx = *range_idx;
|
|
||||||
let mut last = 0;
|
|
||||||
while idx < ranges.len() && ranges[idx].start < segment_end {
|
|
||||||
let range = &ranges[idx];
|
|
||||||
let start = range.start.max(segment_start) - segment_start;
|
|
||||||
let end = range.end.min(segment_end) - segment_start;
|
|
||||||
|
|
||||||
if last < start {
|
|
||||||
job.append(&text[last..start], 0.0, format.clone());
|
|
||||||
}
|
|
||||||
if start < end {
|
|
||||||
job.append(&text[start..end], 0.0, highlight_format.clone());
|
|
||||||
}
|
|
||||||
last = end;
|
|
||||||
|
|
||||||
if range.end <= segment_end {
|
|
||||||
idx += 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*range_idx = idx;
|
|
||||||
if last < text.len() {
|
|
||||||
job.append(&text[last..], 0.0, format);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ansi_color_to_egui(color: AnsiColor) -> Color32 {
|
|
||||||
match color {
|
|
||||||
AnsiColor::Bit4(c) => ansi_4bit(c),
|
|
||||||
AnsiColor::Bit8(c) => ansi_256(c),
|
|
||||||
AnsiColor::Bit24 { r, g, b } => Color32::from_rgb(r, g, b),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ansi_4bit(code: u8) -> Color32 {
|
|
||||||
match code {
|
|
||||||
30 => Color32::BLACK,
|
|
||||||
31 => Color32::from_rgb(194, 54, 33),
|
|
||||||
32 => Color32::from_rgb(37, 188, 36),
|
|
||||||
33 => Color32::from_rgb(173, 173, 39),
|
|
||||||
34 => Color32::from_rgb(73, 46, 225),
|
|
||||||
35 => Color32::from_rgb(211, 56, 211),
|
|
||||||
36 => Color32::from_rgb(51, 187, 200),
|
|
||||||
37 => Color32::from_rgb(203, 204, 205),
|
|
||||||
|
|
||||||
90 => Color32::from_rgb(129, 131, 131),
|
|
||||||
91 => Color32::from_rgb(252, 57, 31),
|
|
||||||
92 => Color32::from_rgb(49, 231, 34),
|
|
||||||
93 => Color32::from_rgb(234, 236, 35),
|
|
||||||
94 => Color32::from_rgb(88, 51, 255),
|
|
||||||
95 => Color32::from_rgb(249, 53, 248),
|
|
||||||
96 => Color32::from_rgb(20, 240, 240),
|
|
||||||
97 => Color32::from_rgb(233, 235, 235),
|
|
||||||
|
|
||||||
40 => Color32::BLACK,
|
|
||||||
41 => Color32::from_rgb(194, 54, 33),
|
|
||||||
42 => Color32::from_rgb(37, 188, 36),
|
|
||||||
43 => Color32::from_rgb(173, 173, 39),
|
|
||||||
44 => Color32::from_rgb(73, 46, 225),
|
|
||||||
45 => Color32::from_rgb(211, 56, 211),
|
|
||||||
46 => Color32::from_rgb(51, 187, 200),
|
|
||||||
47 => Color32::from_rgb(203, 204, 205),
|
|
||||||
|
|
||||||
100 => Color32::from_rgb(129, 131, 131),
|
|
||||||
101 => Color32::from_rgb(252, 57, 31),
|
|
||||||
102 => Color32::from_rgb(49, 231, 34),
|
|
||||||
103 => Color32::from_rgb(234, 236, 35),
|
|
||||||
104 => Color32::from_rgb(88, 51, 255),
|
|
||||||
105 => Color32::from_rgb(249, 53, 248),
|
|
||||||
106 => Color32::from_rgb(20, 240, 240),
|
|
||||||
107 => Color32::from_rgb(233, 235, 235),
|
|
||||||
|
|
||||||
_ => Color32::WHITE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ansi_256(code: u8) -> Color32 {
|
|
||||||
match code {
|
|
||||||
0..=15 => ansi_4bit(if code < 8 { code + 30 } else { code + 82 }),
|
|
||||||
16..=231 => {
|
|
||||||
let idx = code - 16;
|
|
||||||
let cube = [0, 95, 135, 175, 215, 255];
|
|
||||||
let r = cube[(idx / 36) as usize];
|
|
||||||
let g = cube[((idx / 6) % 6) as usize];
|
|
||||||
let b = cube[(idx % 6) as usize];
|
|
||||||
Color32::from_rgb(r, g, b)
|
|
||||||
}
|
|
||||||
232..=255 => {
|
|
||||||
let v = (code - 232) * 10 + 8;
|
|
||||||
Color32::from_rgb(v, v, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn brighten(color: Color32) -> Color32 {
|
|
||||||
Color32::from_rgb(
|
|
||||||
(color.r() as u32 * 13 / 10).min(255) as u8,
|
|
||||||
(color.g() as u32 * 13 / 10).min(255) as u8,
|
|
||||||
(color.b() as u32 * 13 / 10).min(255) as u8,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn visible_text(job: &LayoutJob) -> String {
|
|
||||||
job.sections
|
|
||||||
.iter()
|
|
||||||
.map(|s| &job.text[s.byte_range.clone()])
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn plain_text_no_ansi() {
|
|
||||||
let fmt = TextFormat::default();
|
|
||||||
let job = ansi_to_layout_job("hello", fmt);
|
|
||||||
assert_eq!(job.sections.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fg_red_reset() {
|
|
||||||
let fmt = TextFormat::default();
|
|
||||||
let job = ansi_to_layout_job("\x1b[31mred\x1b[0m plain", fmt);
|
|
||||||
assert!(job.sections.len() >= 2);
|
|
||||||
assert!(job.sections[0].format.color != job.sections[1].format.color);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_sgr_resets_all_styles() {
|
|
||||||
let fmt = TextFormat {
|
|
||||||
color: Color32::from_rgb(1, 2, 3),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let job = ansi_to_layout_job("\x1b[31mred\x1b[m plain", fmt.clone());
|
|
||||||
|
|
||||||
assert_eq!(visible_text(&job), "red plain");
|
|
||||||
assert!(job.sections.len() >= 2);
|
|
||||||
assert_ne!(job.sections[0].format.color, job.sections[1].format.color);
|
|
||||||
assert_eq!(job.sections[1].format.color, fmt.color);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn selective_foreground_reset_keeps_background() {
|
|
||||||
let fmt = TextFormat {
|
|
||||||
color: Color32::from_rgb(1, 2, 3),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let job = ansi_to_layout_job("\x1b[41;37mtext\x1b[39mmore", fmt.clone());
|
|
||||||
|
|
||||||
assert_eq!(visible_text(&job), "textmore");
|
|
||||||
assert!(job.sections.len() >= 2);
|
|
||||||
assert_ne!(job.sections[0].format.color, job.sections[1].format.color);
|
|
||||||
assert_eq!(job.sections[1].format.color, fmt.color);
|
|
||||||
assert_ne!(job.sections[0].format.background, fmt.background);
|
|
||||||
assert_eq!(
|
|
||||||
job.sections[0].format.background,
|
|
||||||
job.sections[1].format.background
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bold_brightens_color() {
|
|
||||||
let fmt = TextFormat::default();
|
|
||||||
let job = ansi_to_layout_job("\x1b[1;31mbold red\x1b[0m", fmt);
|
|
||||||
assert_eq!(job.sections.len(), 1);
|
|
||||||
let c = job.sections[0].format.color;
|
|
||||||
assert!(c.r() > 200 || c.g() > 50 || c.b() > 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strip_ansi_codes_from_output() {
|
|
||||||
let fmt = TextFormat::default();
|
|
||||||
let job = ansi_to_layout_job("\x1b[32mgreen\x1b[0m", fmt);
|
|
||||||
let text: String = job
|
|
||||||
.sections
|
|
||||||
.iter()
|
|
||||||
.map(|s| &job.text[s.byte_range.clone()])
|
|
||||||
.collect();
|
|
||||||
assert_eq!(text, "green");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn highlighted_search_uses_visible_ansi_text() {
|
|
||||||
let default = TextFormat {
|
|
||||||
color: Color32::WHITE,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let highlight = TextFormat {
|
|
||||||
color: Color32::BLACK,
|
|
||||||
background: Color32::from_rgb(255, 255, 0),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let job = ansi_to_layout_job_highlighted(
|
|
||||||
"\x1b[31mred\x1b[0m plain",
|
|
||||||
default,
|
|
||||||
"red plain",
|
|
||||||
true,
|
|
||||||
highlight.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(visible_text(&job), "red plain");
|
|
||||||
assert!(job.sections.len() >= 2);
|
|
||||||
assert!(
|
|
||||||
job.sections
|
|
||||||
.iter()
|
|
||||||
.all(|section| section.format.background == highlight.background)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn xterm_256_color_cube_values() {
|
|
||||||
assert_eq!(ansi_256(16), Color32::from_rgb(0, 0, 0));
|
|
||||||
assert_eq!(ansi_256(21), Color32::from_rgb(0, 0, 255));
|
|
||||||
assert_eq!(ansi_256(52), Color32::from_rgb(95, 0, 0));
|
|
||||||
assert_eq!(ansi_256(67), Color32::from_rgb(95, 135, 175));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
|
||||||
|
|
||||||
mod ansi_render;
|
|
||||||
mod app;
|
|
||||||
mod app_state;
|
|
||||||
mod buffers;
|
|
||||||
mod logging;
|
|
||||||
mod models;
|
|
||||||
mod panels;
|
|
||||||
mod perf;
|
|
||||||
mod shortcuts;
|
|
||||||
mod ui_fonts;
|
|
||||||
|
|
||||||
use pipeview_client::SessionManager;
|
|
||||||
|
|
||||||
/// On Linux, when launched from a file manager, a terminal emulator may be spawned
|
|
||||||
/// to run this binary. We re-spawn ourselves detached so the terminal can close
|
|
||||||
/// while the GUI window persists.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn ensure_detached() {
|
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
use std::process;
|
|
||||||
|
|
||||||
if std::env::var("PIPEVIEW_DETACHED").is_ok() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let exe = match std::env::current_exe() {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
||||||
|
|
||||||
let _ = process::Command::new(&exe)
|
|
||||||
.args(&args)
|
|
||||||
.env("PIPEVIEW_DETACHED", "1")
|
|
||||||
.stdin(process::Stdio::null())
|
|
||||||
.stdout(process::Stdio::null())
|
|
||||||
.stderr(process::Stdio::null())
|
|
||||||
.process_group(0)
|
|
||||||
.spawn();
|
|
||||||
|
|
||||||
process::exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
ensure_detached();
|
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
|
||||||
.init();
|
|
||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
|
||||||
let _guard = rt.enter();
|
|
||||||
|
|
||||||
let mgr = SessionManager::new();
|
|
||||||
let rx = mgr.subscribe();
|
|
||||||
|
|
||||||
let _ = eframe::run_native(
|
|
||||||
"pipeview",
|
|
||||||
eframe::NativeOptions::default(),
|
|
||||||
Box::new(|cc| Ok(Box::new(app::XserialApp::new(mgr, rx, cc.egui_ctx.clone())))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
|
||||||
use crate::logging::LogWriter;
|
|
||||||
use crate::panels::plot_view;
|
|
||||||
use pipeview_client::config::SessionConfig;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub enum ConnectionStatus {
|
|
||||||
Connected,
|
|
||||||
Disconnected,
|
|
||||||
Connecting,
|
|
||||||
Error(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConnectionStatus {
|
|
||||||
pub 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, PartialEq)]
|
|
||||||
#[allow(clippy::upper_case_acronyms)]
|
|
||||||
pub enum LineEnding {
|
|
||||||
None,
|
|
||||||
LF,
|
|
||||||
CR,
|
|
||||||
CRLF,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for LineEnding {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::None => write!(f, "None"),
|
|
||||||
Self::LF => write!(f, "LF (\\n)"),
|
|
||||||
Self::CR => write!(f, "CR (\\r)"),
|
|
||||||
Self::CRLF => write!(f, "CRLF (\\r\\n)"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub struct DisplayOptions {
|
|
||||||
pub show_timestamp: bool,
|
|
||||||
pub show_direction: bool,
|
|
||||||
pub show_pipeline: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
pub struct SearchState {
|
|
||||||
pub query: String,
|
|
||||||
pub matches: Vec<usize>,
|
|
||||||
pub current_match: usize,
|
|
||||||
pub case_sensitive: bool,
|
|
||||||
pub active: bool,
|
|
||||||
pub just_opened: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SearchState {
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.query.clear();
|
|
||||||
self.matches.clear();
|
|
||||||
self.current_match = 0;
|
|
||||||
self.active = false;
|
|
||||||
self.just_opened = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next(&mut self) {
|
|
||||||
if !self.matches.is_empty() {
|
|
||||||
self.current_match = (self.current_match + 1) % self.matches.len();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prev(&mut self) {
|
|
||||||
if !self.matches.is_empty() {
|
|
||||||
self.current_match = if self.current_match == 0 {
|
|
||||||
self.matches.len() - 1
|
|
||||||
} else {
|
|
||||||
self.current_match - 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn match_count(&self) -> usize {
|
|
||||||
self.matches.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_display(&self) -> usize {
|
|
||||||
if self.matches.is_empty() {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
self.current_match + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SessionTab {
|
|
||||||
pub id: u64,
|
|
||||||
pub session_config: SessionConfig,
|
|
||||||
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 dtr: bool,
|
|
||||||
pub rts: bool,
|
|
||||||
pub send_input: String,
|
|
||||||
pub send_mode: SendMode,
|
|
||||||
pub line_ending: LineEnding,
|
|
||||||
pub send_status: Option<String>,
|
|
||||||
pub search: SearchState,
|
|
||||||
pub log_enabled: bool,
|
|
||||||
pub log_path: String,
|
|
||||||
pub show_sent: bool,
|
|
||||||
pub log_writer: Option<LogWriter>,
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
use crate::ansi_render::{ansi_to_layout_job, ansi_to_layout_job_highlighted};
|
|
||||||
use crate::app::{DisplayOptions, SearchState};
|
|
||||||
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
|
|
||||||
use egui::{
|
|
||||||
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
|
|
||||||
text::{LayoutJob, TextFormat},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Nudge amount (pixels/frame) for auto-scroll during text-selection drag
|
|
||||||
const EDGE_SCROLL_NUDGE: f32 = 4.0;
|
|
||||||
/// Distance from the bottom edge (pixels) that triggers auto-scroll
|
|
||||||
const EDGE_SCROLL_ZONE: f32 = 30.0;
|
|
||||||
|
|
||||||
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);
|
|
||||||
let mut near_bottom_edge = false;
|
|
||||||
|
|
||||||
let scroll_area = ScrollArea::both()
|
|
||||||
.id_salt("console_text_area")
|
|
||||||
.stick_to_bottom(true);
|
|
||||||
|
|
||||||
let output = scroll_area.show_viewport(ui, |ui, viewport| {
|
|
||||||
let start_row = (viewport.min.y / row_height).floor().max(0.0) as usize;
|
|
||||||
let end_row = ((viewport.max.y / row_height).ceil() as usize).min(line_count);
|
|
||||||
|
|
||||||
// Check if user is drag-selecting near the bottom edge of the scroll area.
|
|
||||||
// We compare pointer position against the screen-space bottom of the
|
|
||||||
// allocated scroll area (ui.max_rect) rather than the content viewport,
|
|
||||||
// so that any overflow content area counts.
|
|
||||||
near_bottom_edge = ui.ctx().input(|input| {
|
|
||||||
input.pointer.button_down(egui::PointerButton::Primary)
|
|
||||||
&& input.pointer.hover_pos().is_some_and(|pos| {
|
|
||||||
let area = ui.max_rect();
|
|
||||||
pos.y > area.bottom() - EDGE_SCROLL_ZONE && pos.y < area.bottom() + 20.0
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
for row in start_row..end_row {
|
|
||||||
if let Some(line) = buf.get(row) {
|
|
||||||
ui.add(
|
|
||||||
Label::new(format_console_line(line, display, search, ui.style()))
|
|
||||||
.wrap_mode(TextWrapMode::Extend),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If the user is dragging a selection near the bottom edge, nudge the scroll
|
|
||||||
// offset for the next frame and request a repaint for smooth continuous scrolling.
|
|
||||||
if near_bottom_edge && line_count > 0 {
|
|
||||||
let mut state = output.state;
|
|
||||||
let max_offset =
|
|
||||||
(line_count as f32 * row_height) - output.inner_rect.height() + EDGE_SCROLL_ZONE;
|
|
||||||
state.offset.y = (state.offset.y + EDGE_SCROLL_NUDGE).min(max_offset.max(0.0));
|
|
||||||
state.store(ui.ctx(), output.id);
|
|
||||||
ui.ctx().request_repaint();
|
|
||||||
}
|
|
||||||
|
|
||||||
line_count
|
|
||||||
}
|
|
||||||
|
|
||||||
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() => ansi_to_layout_job_highlighted(
|
|
||||||
&full_text,
|
|
||||||
default,
|
|
||||||
&state.query,
|
|
||||||
state.case_sensitive,
|
|
||||||
highlight,
|
|
||||||
),
|
|
||||||
_ => ansi_to_layout_job(&full_text, default),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
use crate::app::XserialApp;
|
|
||||||
use crate::ui_fonts::{self, FontCandidate, FontChoice};
|
|
||||||
|
|
||||||
pub fn render_font_settings_window(app: &mut XserialApp, ctx: &egui::Context) {
|
|
||||||
if !app.font_settings_open {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut open = app.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(
|
|
||||||
&app.font_settings.primary_choice,
|
|
||||||
&app.font_candidates,
|
|
||||||
));
|
|
||||||
ui.label("Fallback:");
|
|
||||||
ui.monospace(ui_fonts::font_choice_label(
|
|
||||||
&app.font_settings.fallback_choice,
|
|
||||||
&app.font_candidates,
|
|
||||||
));
|
|
||||||
if ui.button("Refresh").clicked() {
|
|
||||||
app.font_candidates = ui_fonts::discover_font_candidates();
|
|
||||||
app.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 app.font_settings.primary_choice,
|
|
||||||
&mut app.primary_font_search,
|
|
||||||
&app.font_candidates,
|
|
||||||
&mut app.primary_filtered_fonts,
|
|
||||||
&mut app.primary_filter_cache_key,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
180.0,
|
|
||||||
&mut changed,
|
|
||||||
);
|
|
||||||
ui.separator();
|
|
||||||
render_font_selector(
|
|
||||||
ui,
|
|
||||||
"Fallback font",
|
|
||||||
"fallback_font_choice",
|
|
||||||
&mut app.font_settings.fallback_choice,
|
|
||||||
&mut app.fallback_font_search,
|
|
||||||
&app.font_candidates,
|
|
||||||
&mut app.fallback_filtered_fonts,
|
|
||||||
&mut app.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 app.font_settings.ui_font_size, 10.0..=28.0)
|
|
||||||
.suffix(" pt"),
|
|
||||||
)
|
|
||||||
.changed();
|
|
||||||
ui.label("Monospace font size");
|
|
||||||
changed |= ui
|
|
||||||
.add(
|
|
||||||
egui::Slider::new(
|
|
||||||
&mut app.font_settings.monospace_font_size,
|
|
||||||
10.0..=28.0,
|
|
||||||
)
|
|
||||||
.suffix(" pt"),
|
|
||||||
)
|
|
||||||
.changed();
|
|
||||||
ui.label("Heading size");
|
|
||||||
changed |= ui
|
|
||||||
.add(
|
|
||||||
egui::Slider::new(&mut app.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, &app.font_settings, &app.font_candidates);
|
|
||||||
ui_fonts::save_font_settings(&app.font_settings);
|
|
||||||
}
|
|
||||||
app.font_settings_open = open;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub 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;
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use crate::app_state;
|
|
||||||
use crate::logging::{self, LogWriter};
|
|
||||||
use crate::models::{LineEnding, SendMode, SessionTab};
|
|
||||||
use egui::{Color32, Label, TextEdit};
|
|
||||||
use pipeview_client::SessionManager;
|
|
||||||
|
|
||||||
pub fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
|
|
||||||
ui.set_width(ui.available_width());
|
|
||||||
ui.add(Label::new(egui::RichText::new("Send").heading()).selectable(false));
|
|
||||||
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.add_space(6.0);
|
|
||||||
egui::ComboBox::from_label("Line ending")
|
|
||||||
.selected_text(tab.line_ending.to_string())
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
ui.selectable_value(
|
|
||||||
&mut tab.line_ending,
|
|
||||||
LineEnding::None,
|
|
||||||
LineEnding::None.to_string(),
|
|
||||||
);
|
|
||||||
ui.selectable_value(
|
|
||||||
&mut tab.line_ending,
|
|
||||||
LineEnding::LF,
|
|
||||||
LineEnding::LF.to_string(),
|
|
||||||
);
|
|
||||||
ui.selectable_value(
|
|
||||||
&mut tab.line_ending,
|
|
||||||
LineEnding::CR,
|
|
||||||
LineEnding::CR.to_string(),
|
|
||||||
);
|
|
||||||
ui.selectable_value(
|
|
||||||
&mut tab.line_ending,
|
|
||||||
LineEnding::CRLF,
|
|
||||||
LineEnding::CRLF.to_string(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.add_space(6.0);
|
|
||||||
|
|
||||||
let log_toggled = ui
|
|
||||||
.horizontal(|ui| {
|
|
||||||
let toggled = ui.checkbox(&mut tab.log_enabled, "Log to file").changed();
|
|
||||||
ui.checkbox(&mut tab.show_sent, "Show sent data");
|
|
||||||
toggled
|
|
||||||
})
|
|
||||||
.inner;
|
|
||||||
if log_toggled {
|
|
||||||
if tab.log_enabled {
|
|
||||||
tab.log_path = default_log_path(tab.id).to_string_lossy().to_string();
|
|
||||||
tab.log_writer = LogWriter::open(&tab.log_path).ok();
|
|
||||||
} else {
|
|
||||||
tab.log_writer = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tab.log_enabled {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
let changed = ui.text_edit_singleline(&mut tab.log_path).lost_focus();
|
|
||||||
if changed && !tab.log_path.is_empty() {
|
|
||||||
tab.log_writer = LogWriter::open(&tab.log_path).ok();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ui.add_space(3.0);
|
|
||||||
|
|
||||||
let hint = match tab.send_mode {
|
|
||||||
SendMode::Text => "Enter to send, Ctrl+Enter for newline",
|
|
||||||
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)
|
|
||||||
.return_key(egui::KeyboardShortcut::new(
|
|
||||||
egui::Modifiers::COMMAND,
|
|
||||||
egui::Key::Enter,
|
|
||||||
))
|
|
||||||
.hint_text(hint),
|
|
||||||
);
|
|
||||||
|
|
||||||
let wants_submit = response.has_focus()
|
|
||||||
&& ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.is_none());
|
|
||||||
|
|
||||||
let mut send_clicked = false;
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
send_clicked = ui.button("Send").clicked();
|
|
||||||
if let Some(status) = &tab.send_status {
|
|
||||||
ui.add(
|
|
||||||
Label::new(egui::RichText::new(status).color(
|
|
||||||
if status.starts_with("Send failed") {
|
|
||||||
Color32::RED
|
|
||||||
} else {
|
|
||||||
Color32::GRAY
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.selectable(false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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 mut text = tab.send_input.trim_end_matches('\n').to_string();
|
|
||||||
if !text.is_empty() {
|
|
||||||
match tab.line_ending {
|
|
||||||
LineEnding::None => {}
|
|
||||||
LineEnding::LF => text.push('\n'),
|
|
||||||
LineEnding::CR => text.push('\r'),
|
|
||||||
LineEnding::CRLF => text.push_str("\r\n"),
|
|
||||||
}
|
|
||||||
if tab.show_sent {
|
|
||||||
tab.console.push_outbound(text.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref writer) = tab.log_writer {
|
|
||||||
writer.write_line(&logging::format_sent_log(&text));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SendMode::Hex => {
|
|
||||||
let hex = tab
|
|
||||||
.send_input
|
|
||||||
.split_whitespace()
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ");
|
|
||||||
if !hex.is_empty() {
|
|
||||||
if tab.show_sent {
|
|
||||||
tab.hex.push_outbound(hex.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref writer) = tab.log_writer {
|
|
||||||
writer.write_line(&logging::format_sent_log(&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}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn default_log_path(session_id: u64) -> PathBuf {
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
let dir = app_state::config_dir().join("logs");
|
|
||||||
let ts = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
dir.join(format!("session_{session_id}_{ts}.log"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub 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.trim_end_matches('\n').to_string();
|
|
||||||
match tab.line_ending {
|
|
||||||
LineEnding::None => {}
|
|
||||||
LineEnding::LF => text.push('\n'),
|
|
||||||
LineEnding::CR => text.push('\r'),
|
|
||||||
LineEnding::CRLF => text.push_str("\r\n"),
|
|
||||||
}
|
|
||||||
Ok(Some(text.into_bytes()))
|
|
||||||
}
|
|
||||||
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,154 +0,0 @@
|
|||||||
use crate::models::{ConnectionStatus, SessionTab, View};
|
|
||||||
use egui::{Label, TextEdit};
|
|
||||||
use pipeview_client::SessionManager;
|
|
||||||
use pipeview_core::transport::TransportConfig;
|
|
||||||
|
|
||||||
pub fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
|
|
||||||
if !tab.search.active {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
let response = ui.add(
|
|
||||||
TextEdit::singleline(&mut tab.search.query)
|
|
||||||
.hint_text("Search...")
|
|
||||||
.desired_width(200.0),
|
|
||||||
);
|
|
||||||
if tab.search.just_opened {
|
|
||||||
response.request_focus();
|
|
||||||
tab.search.just_opened = false;
|
|
||||||
}
|
|
||||||
if response.changed() {
|
|
||||||
let matches = match tab.view {
|
|
||||||
View::Text => tab
|
|
||||||
.console
|
|
||||||
.search(&tab.search.query, tab.search.case_sensitive),
|
|
||||||
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
|
|
||||||
View::Plot => Vec::new(),
|
|
||||||
};
|
|
||||||
tab.search.matches = matches;
|
|
||||||
tab.search.current_match = 0;
|
|
||||||
}
|
|
||||||
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
|
||||||
tab.search.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add(
|
|
||||||
Label::new(format!(
|
|
||||||
"{}/{}",
|
|
||||||
tab.search.current_display(),
|
|
||||||
tab.search.match_count()
|
|
||||||
))
|
|
||||||
.selectable(false),
|
|
||||||
);
|
|
||||||
|
|
||||||
if ui.button("▲").clicked() {
|
|
||||||
tab.search.prev();
|
|
||||||
}
|
|
||||||
if ui.button("▼").clicked() {
|
|
||||||
tab.search.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui.checkbox(&mut tab.search.case_sensitive, "Aa").changed() {
|
|
||||||
let matches = match tab.view {
|
|
||||||
View::Text => tab
|
|
||||||
.console
|
|
||||||
.search(&tab.search.query, tab.search.case_sensitive),
|
|
||||||
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
|
|
||||||
View::Plot => Vec::new(),
|
|
||||||
};
|
|
||||||
tab.search.matches = matches;
|
|
||||||
tab.search.current_match = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui.button("✕").clicked() {
|
|
||||||
tab.search.clear();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_session_controls(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
manager: &SessionManager,
|
|
||||||
tab: &mut SessionTab,
|
|
||||||
) -> bool {
|
|
||||||
let mut auto_reconnect_changed = false;
|
|
||||||
ui.horizontal_wrapped(|ui| {
|
|
||||||
let connected = matches!(
|
|
||||||
tab.status,
|
|
||||||
ConnectionStatus::Connected | ConnectionStatus::Connecting
|
|
||||||
);
|
|
||||||
let connect_label = if connected { "Disconnect" } else { "Connect" };
|
|
||||||
if ui.button(connect_label).clicked() {
|
|
||||||
if let Some(handle) = manager.get(tab.id) {
|
|
||||||
if connected {
|
|
||||||
tab.status = ConnectionStatus::Disconnected;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = handle.disconnect().await;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
tab.status = ConnectionStatus::Connecting;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = handle.connect().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("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() {
|
|
||||||
tab.session_config.auto_reconnect = tab.auto_reconnect;
|
|
||||||
auto_reconnect_changed = true;
|
|
||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if matches!(tab.status, ConnectionStatus::Connected)
|
|
||||||
&& matches!(tab.session_config.transport, TransportConfig::Serial { .. })
|
|
||||||
{
|
|
||||||
let dtr_changed = ui.checkbox(&mut tab.dtr, "DTR").changed();
|
|
||||||
let rts_changed = ui.checkbox(&mut tab.rts, "RTS").changed();
|
|
||||||
if dtr_changed || rts_changed {
|
|
||||||
if let Some(handle) = manager.get(tab.id) {
|
|
||||||
let dtr = tab.dtr;
|
|
||||||
let rts = tab.rts;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if dtr_changed {
|
|
||||||
let _ = handle.set_dtr(dtr).await;
|
|
||||||
}
|
|
||||||
if rts_changed {
|
|
||||||
let _ = handle.set_rts(rts).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
auto_reconnect_changed
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pipeview-client"
|
name = "xserial-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pipeview-core = { path = "../pipeview-core" }
|
xserial-core = { path = "../xserial-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 }
|
||||||
@@ -15,4 +15,3 @@ thiserror = { workspace = true }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
tempfile = "3"
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use pipeview_core::frame::{
|
use serde::{Deserialize, Serialize};
|
||||||
|
use xserial_core::frame::{
|
||||||
Endian, Framer,
|
Endian, Framer,
|
||||||
cobs::CobsFramer,
|
cobs::CobsFramer,
|
||||||
fixed::FixedLengthFramer,
|
fixed::FixedLengthFramer,
|
||||||
@@ -6,15 +7,14 @@ use pipeview_core::frame::{
|
|||||||
line::{LineConfig, LineFramer},
|
line::{LineConfig, LineFramer},
|
||||||
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
||||||
};
|
};
|
||||||
use pipeview_core::protocol::{
|
use xserial_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 pipeview_core::transport::TransportConfig;
|
use xserial_core::transport::TransportConfig;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::lua::codec::{LuaDecoder, LuaFramer};
|
use crate::lua::codec::{LuaDecoder, LuaFramer};
|
||||||
@@ -229,10 +229,10 @@ impl Default for SessionConfig {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use pipeview_core::protocol::Endian;
|
use xserial_core::protocol::Endian;
|
||||||
use pipeview_core::protocol::plot::{PlotFormat, SampleType};
|
use xserial_core::protocol::plot::{PlotFormat, SampleType};
|
||||||
use pipeview_core::protocol::text::TextEncoding;
|
use xserial_core::protocol::text::TextEncoding;
|
||||||
use pipeview_core::transport::TransportConfig;
|
use xserial_core::transport::TransportConfig;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn framer_line_serde_roundtrip() {
|
fn framer_line_serde_roundtrip() {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use pipeview_core::protocol::DecodedData;
|
use xserial_core::protocol::DecodedData;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DecodedEntry {
|
pub struct DecodedEntry {
|
||||||
59
crates/xserial-client/src/history.rs
Normal file
59
crates/xserial-client/src/history.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use crate::event::DecodedEntry;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
pub struct RingBuffer<T> {
|
||||||
|
buf: VecDeque<T>,
|
||||||
|
limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> RingBuffer<T> {
|
||||||
|
pub fn new(limit: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
buf: VecDeque::with_capacity(limit.min(1024)),
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn push(&mut self, item: T) {
|
||||||
|
if self.buf.len() >= self.limit {
|
||||||
|
self.buf.pop_front();
|
||||||
|
}
|
||||||
|
self.buf.push_back(item);
|
||||||
|
}
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.buf.len()
|
||||||
|
}
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.buf.is_empty()
|
||||||
|
}
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &T> {
|
||||||
|
self.buf.iter()
|
||||||
|
}
|
||||||
|
pub fn get(&self, index: usize) -> Option<&T> {
|
||||||
|
self.buf.get(index)
|
||||||
|
}
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.buf.clear();
|
||||||
|
}
|
||||||
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
|
self.limit = limit;
|
||||||
|
while self.buf.len() > self.limit {
|
||||||
|
self.buf.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn drain_recent(&self, count: usize) -> Vec<T>
|
||||||
|
where
|
||||||
|
T: Clone,
|
||||||
|
{
|
||||||
|
let start = self.buf.len().saturating_sub(count);
|
||||||
|
self.buf.iter().skip(start).cloned().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RingBuffer<DecodedEntry> {
|
||||||
|
pub fn entries_for_pipeline(&self, name: &str) -> Vec<&DecodedEntry> {
|
||||||
|
self.buf
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.pipeline_name == name)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@ use std::fs;
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use mlua::{Function, Lua, RegistryKey, Table, Value};
|
use mlua::{Function, Lua, RegistryKey, Table, Value};
|
||||||
use pipeview_core::frame::Framer;
|
|
||||||
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame, SampleType};
|
|
||||||
use pipeview_core::protocol::{DecodedData, ProtocolDecoder};
|
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
use xserial_core::frame::Framer;
|
||||||
|
use xserial_core::protocol::plot::{PlotFormat, PlotFrame, SampleType};
|
||||||
|
use xserial_core::protocol::{DecodedData, ProtocolDecoder};
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ mod tests {
|
|||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let path = std::env::temp_dir().join(format!("pipeview_{name}_{nonce}.lua"));
|
let path = std::env::temp_dir().join(format!("xserial_{name}_{nonce}.lua"));
|
||||||
fs::write(&path, script).unwrap();
|
fs::write(&path, script).unwrap();
|
||||||
path
|
path
|
||||||
}
|
}
|
||||||
@@ -81,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 pipeview = lua.create_table()?;
|
let xserial = lua.create_table()?;
|
||||||
|
|
||||||
pipeview.set("open", {
|
xserial.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();
|
||||||
@@ -96,15 +96,15 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
|||||||
})?
|
})?
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
pipeview.set(
|
xserial.set(
|
||||||
"list_ports",
|
"list_ports",
|
||||||
lua.create_function(|_, ()| {
|
lua.create_function(|_, ()| {
|
||||||
let ports = pipeview_core::transport::serial::SerialTransport::list_ports();
|
let ports = xserial_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<_>>())
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
pipeview.set("sleep", {
|
xserial.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();
|
||||||
@@ -116,7 +116,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
|||||||
})?
|
})?
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
pipeview.set("poll", {
|
xserial.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();
|
||||||
@@ -124,7 +124,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
|||||||
})?
|
})?
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
pipeview.set(
|
xserial.set(
|
||||||
"log",
|
"log",
|
||||||
lua.create_function(|_, msg: String| {
|
lua.create_function(|_, msg: String| {
|
||||||
tracing::info!("[lua] {}", msg);
|
tracing::info!("[lua] {}", msg);
|
||||||
@@ -132,7 +132,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
|||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
lua.globals().set("pipeview", pipeview)?;
|
lua.globals().set("xserial", xserial)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,9 +146,9 @@ mod tests {
|
|||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
register(&lua).unwrap();
|
register(&lua).unwrap();
|
||||||
|
|
||||||
let pipeview: Table = lua.globals().get("pipeview").unwrap();
|
let xserial: Table = lua.globals().get("xserial").unwrap();
|
||||||
for name in ["open", "list_ports", "sleep", "poll", "log"] {
|
for name in ["open", "list_ports", "sleep", "poll", "log"] {
|
||||||
let value: Value = pipeview.get(name).unwrap();
|
let value: Value = xserial.get(name).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(value, Value::Function(_)),
|
matches!(value, Value::Function(_)),
|
||||||
"{name} should be a function"
|
"{name} should be a function"
|
||||||
@@ -162,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 pipeview: Table = lua.globals().get("pipeview").unwrap();
|
let xserial: Table = lua.globals().get("xserial").unwrap();
|
||||||
let open: Value = pipeview.get("open").unwrap();
|
let open: Value = xserial.get("open").unwrap();
|
||||||
assert!(matches!(open, Value::Function(_)));
|
assert!(matches!(open, Value::Function(_)));
|
||||||
assert_eq!(manager.count(), 0);
|
assert_eq!(manager.count(), 0);
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,9 @@ use std::sync::{
|
|||||||
use std::time::Duration;
|
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 pipeview_core::protocol::DecodedData;
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
use xserial_core::protocol::DecodedData;
|
||||||
|
|
||||||
use crate::config::SessionConfig;
|
use crate::config::SessionConfig;
|
||||||
use crate::lua::LuaRuntime;
|
use crate::lua::LuaRuntime;
|
||||||
@@ -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 pipeview_core::pipeline::{MultiPipeline, Pipeline};
|
use xserial_core::pipeline::{MultiPipeline, Pipeline};
|
||||||
use pipeview_core::transport::Connection;
|
use xserial_core::transport::Connection;
|
||||||
|
|
||||||
use crate::cmd::SessionCmd;
|
use crate::cmd::SessionCmd;
|
||||||
use crate::config::SessionConfig;
|
use crate::config::SessionConfig;
|
||||||
@@ -499,7 +499,7 @@ async fn try_read(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_io_error(err: pipeview_core::error::Error) -> std::io::Error {
|
fn to_io_error(err: xserial_core::error::Error) -> std::io::Error {
|
||||||
io::Error::other(err.to_string())
|
io::Error::other(err.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,7 +510,7 @@ mod tests {
|
|||||||
|
|
||||||
fn tcp_config() -> SessionConfig {
|
fn tcp_config() -> SessionConfig {
|
||||||
SessionConfig {
|
SessionConfig {
|
||||||
transport: pipeview_core::transport::TransportConfig::Tcp {
|
transport: xserial_core::transport::TransportConfig::Tcp {
|
||||||
addr: "127.0.0.1:1".into(),
|
addr: "127.0.0.1:1".into(),
|
||||||
},
|
},
|
||||||
pipelines: vec![PipelineConfig {
|
pipelines: vec![PipelineConfig {
|
||||||
@@ -520,7 +520,7 @@ mod tests {
|
|||||||
max_line_len: 65536,
|
max_line_len: 65536,
|
||||||
},
|
},
|
||||||
decoder: DecoderConfig::Text {
|
decoder: DecoderConfig::Text {
|
||||||
encoding: pipeview_core::protocol::text::TextEncoding::Utf8,
|
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
use std::io::Write;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
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};
|
||||||
@@ -12,36 +11,20 @@ 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("pipeview=trace")
|
.with_env_filter("xserial=trace")
|
||||||
.try_init()
|
.try_init()
|
||||||
.ok();
|
.ok();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TempScript {
|
fn write_temp_lua(name: &str, script: &str) -> PathBuf {
|
||||||
path: PathBuf,
|
let nonce = std::time::SystemTime::now()
|
||||||
_guard: tempfile::TempPath,
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
}
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
impl TempScript {
|
let path = std::env::temp_dir().join(format!("xserial_{name}_{nonce}.lua"));
|
||||||
fn new(name: &str, script: &str) -> Self {
|
fs::write(&path, script).unwrap();
|
||||||
let mut file = tempfile::Builder::new()
|
path
|
||||||
.prefix(&format!("pipeview_{name}_"))
|
|
||||||
.suffix(".lua")
|
|
||||||
.tempfile()
|
|
||||||
.unwrap();
|
|
||||||
file.write_all(script.as_bytes()).unwrap();
|
|
||||||
let path = file.path().to_path_buf();
|
|
||||||
let guard = file.into_temp_path();
|
|
||||||
Self {
|
|
||||||
path,
|
|
||||||
_guard: guard,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path(&self) -> &Path {
|
|
||||||
&self.path
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn text_pipeline_lua() -> &'static str {
|
fn text_pipeline_lua() -> &'static str {
|
||||||
@@ -64,18 +47,18 @@ fn hex_pipeline_lua() -> &'static str {
|
|||||||
"#
|
"#
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── pipeview.sleep / pipeview.log ──────────────────────────────────
|
// ── xserial.sleep / xserial.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();
|
||||||
pipeview_client::lua::register(&lua).unwrap();
|
xserial_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
lua.load(
|
lua.load(
|
||||||
r#"
|
r#"
|
||||||
pipeview.log("test start")
|
xserial.log("test start")
|
||||||
pipeview.sleep(50)
|
xserial.sleep(50)
|
||||||
pipeview.log("test end")
|
xserial.log("test end")
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.exec_async()
|
.exec_async()
|
||||||
@@ -83,15 +66,15 @@ async fn lua_sleep_and_log() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── pipeview.list_ports ───────────────────────────────────────────
|
// ── xserial.list_ports ───────────────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_list_ports() {
|
async fn lua_list_ports() {
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
pipeview_client::lua::register(&lua).unwrap();
|
xserial_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let result: Vec<String> = lua
|
let result: Vec<String> = lua
|
||||||
.load(r#"return pipeview.list_ports()"#)
|
.load(r#"return xserial.list_ports()"#)
|
||||||
.eval_async()
|
.eval_async()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -101,7 +84,7 @@ async fn lua_list_ports() {
|
|||||||
let _ = result;
|
let _ = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── pipeview.open + session:send / session:read / session:close ────
|
// ── xserial.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() {
|
||||||
@@ -119,11 +102,11 @@ async fn lua_session_open_send_read_close() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
pipeview_client::lua::register(&lua).unwrap();
|
xserial_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = pipeview.open({{
|
local sess = xserial.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -158,11 +141,11 @@ async fn lua_session_read_timeout() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
pipeview_client::lua::register(&lua).unwrap();
|
xserial_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = pipeview.open({{
|
local sess = xserial.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -179,7 +162,7 @@ async fn lua_session_read_timeout() {
|
|||||||
lua.load(&script).exec_async().await.unwrap();
|
lua.load(&script).exec_async().await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── pipeview.open with hex decoder ────────────────────────────────
|
// ── xserial.open with hex decoder ────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_session_hex_decoder() {
|
async fn lua_session_hex_decoder() {
|
||||||
@@ -193,11 +176,11 @@ async fn lua_session_hex_decoder() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
pipeview_client::lua::register(&lua).unwrap();
|
xserial_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = pipeview.open({{
|
local sess = xserial.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -218,14 +201,14 @@ async fn lua_session_hex_decoder() {
|
|||||||
server.await.unwrap();
|
server.await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── pipeview.open with Lua framer + Lua decoder ───────────────────
|
// ── xserial.open with Lua framer + Lua decoder ───────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_session_custom_lua_pipeline() {
|
async fn lua_session_custom_lua_pipeline() {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = listener.local_addr().unwrap().to_string();
|
let addr = listener.local_addr().unwrap().to_string();
|
||||||
|
|
||||||
let framer_script = TempScript::new(
|
let framer_path = write_temp_lua(
|
||||||
"custom_framer",
|
"custom_framer",
|
||||||
r#"
|
r#"
|
||||||
local buffer = ""
|
local buffer = ""
|
||||||
@@ -256,7 +239,7 @@ async fn lua_session_custom_lua_pipeline() {
|
|||||||
}
|
}
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let decoder_script = TempScript::new(
|
let decoder_path = write_temp_lua(
|
||||||
"custom_decoder",
|
"custom_decoder",
|
||||||
r#"
|
r#"
|
||||||
return {
|
return {
|
||||||
@@ -276,11 +259,11 @@ async fn lua_session_custom_lua_pipeline() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
pipeview_client::lua::register(&lua).unwrap();
|
xserial_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = pipeview.open({{
|
local sess = xserial.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{
|
pipelines = {{
|
||||||
{{
|
{{
|
||||||
@@ -304,12 +287,14 @@ async fn lua_session_custom_lua_pipeline() {
|
|||||||
sess:close()
|
sess:close()
|
||||||
"#,
|
"#,
|
||||||
addr,
|
addr,
|
||||||
framer_script.path().display(),
|
framer_path.display(),
|
||||||
decoder_script.path().display()
|
decoder_path.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
lua.load(&script).exec_async().await.unwrap();
|
lua.load(&script).exec_async().await.unwrap();
|
||||||
server.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 ─────────────────────────────────────
|
||||||
@@ -326,12 +311,12 @@ async fn lua_session_on_data_callback() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
pipeview_client::lua::register(&lua).unwrap();
|
xserial_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local received = {{}}
|
local received = {{}}
|
||||||
local sess = pipeview.open({{
|
local sess = xserial.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -342,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
|
||||||
pipeview.poll(1)
|
xserial.poll(1)
|
||||||
pipeview.sleep(10)
|
xserial.sleep(10)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -386,11 +371,11 @@ async fn lua_session_reconfigure() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
pipeview_client::lua::register(&lua).unwrap();
|
xserial_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = pipeview.open({{
|
local sess = xserial.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -435,11 +420,11 @@ async fn lua_session_next_event_stream() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
pipeview_client::lua::register(&lua).unwrap();
|
xserial_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = pipeview.open({{
|
local sess = xserial.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -465,7 +450,7 @@ async fn lua_session_next_event_stream() {
|
|||||||
server.await.unwrap();
|
server.await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── session:off + pipeview.poll ───────────────────────────────────
|
// ── session:off + xserial.poll ───────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_session_off_stops_future_callbacks() {
|
async fn lua_session_off_stops_future_callbacks() {
|
||||||
@@ -481,12 +466,12 @@ async fn lua_session_off_stops_future_callbacks() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
pipeview_client::lua::register(&lua).unwrap();
|
xserial_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local received = {{}}
|
local received = {{}}
|
||||||
local sess = pipeview.open({{
|
local sess = xserial.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -497,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
|
||||||
pipeview.poll(1)
|
xserial.poll(1)
|
||||||
pipeview.sleep(10)
|
xserial.sleep(10)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -506,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)
|
||||||
|
|
||||||
pipeview.sleep(250)
|
xserial.sleep(250)
|
||||||
pipeview.poll(10)
|
xserial.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 pipeview_client::SessionManager;
|
use xserial_client::SessionManager;
|
||||||
use pipeview_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
||||||
use pipeview_client::session::{Session, SessionEvent};
|
use xserial_client::session::{Session, SessionEvent};
|
||||||
use pipeview_core::protocol::DecodedData;
|
use xserial_core::protocol::DecodedData;
|
||||||
use pipeview_core::transport::TransportConfig;
|
use xserial_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: pipeview_core::protocol::text::TextEncoding::Utf8,
|
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
history_limit: 100,
|
history_limit: 100,
|
||||||
@@ -113,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: pipeview_core::protocol::Endian::Big,
|
endian: xserial_core::protocol::Endian::Big,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -285,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: pipeview_core::protocol::Endian::Big,
|
endian: xserial_core::protocol::Endian::Big,
|
||||||
};
|
};
|
||||||
|
|
||||||
handle.reconfigure(new_cfg).await.unwrap();
|
handle.reconfigure(new_cfg).await.unwrap();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pipeview-core"
|
name = "xserial-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
@@ -15,6 +15,3 @@ serde_json = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
proptest = "1"
|
|
||||||
@@ -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 pipeview_core::pipeline::{MultiPipeline, Pipeline};
|
/// use xserial_core::pipeline::{MultiPipeline, Pipeline};
|
||||||
/// use pipeview_core::frame::line::{LineFramer, LineConfig};
|
/// use xserial_core::frame::line::{LineFramer, LineConfig};
|
||||||
/// use pipeview_core::frame::fixed::FixedLengthFramer;
|
/// use xserial_core::frame::fixed::FixedLengthFramer;
|
||||||
/// use pipeview_core::protocol::text::{TextDecoder, TextEncoding};
|
/// use xserial_core::protocol::text::{TextDecoder, TextEncoding};
|
||||||
/// use pipeview_core::protocol::hex::{HexDecoder, HexConfig};
|
/// use xserial_core::protocol::hex::{HexDecoder, HexConfig};
|
||||||
///
|
///
|
||||||
/// let mut mp = MultiPipeline::new();
|
/// let mut mp = MultiPipeline::new();
|
||||||
/// mp.add(
|
/// mp.add(
|
||||||
@@ -173,18 +173,22 @@ impl Connection {
|
|||||||
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
|
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
Connection::Serial(t) => t.set_dtr(state),
|
Connection::Serial(t) => t.set_dtr(state),
|
||||||
Connection::Tcp(_) | Connection::Udp(_) => Err(crate::error::Error::ConnectionFailed(
|
Connection::Tcp(_) | Connection::Udp(_) => {
|
||||||
"DTR only supported on Serial connections".into(),
|
Err(crate::error::Error::ConnectionFailed(
|
||||||
)),
|
"DTR only supported on Serial connections".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_rts(&mut self, state: bool) -> Result<()> {
|
pub fn set_rts(&mut self, state: bool) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
Connection::Serial(t) => t.set_rts(state),
|
Connection::Serial(t) => t.set_rts(state),
|
||||||
Connection::Tcp(_) | Connection::Udp(_) => Err(crate::error::Error::ConnectionFailed(
|
Connection::Tcp(_) | Connection::Udp(_) => {
|
||||||
"RTS only supported on Serial connections".into(),
|
Err(crate::error::Error::ConnectionFailed(
|
||||||
)),
|
"RTS only supported on Serial connections".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serialport::SerialPort;
|
|
||||||
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};
|
||||||
@@ -277,16 +277,10 @@ impl Transport for SerialTransport {
|
|||||||
// HC-15, HC-05, Bluetooth/UART bridges) need DTR asserted to stay in
|
// HC-15, HC-05, Bluetooth/UART bridges) need DTR asserted to stay in
|
||||||
// transparent data mode and not fall into AT-command / reset state.
|
// transparent data mode and not fall into AT-command / reset state.
|
||||||
if let Err(e) = port.write_data_terminal_ready(self.dtr) {
|
if let Err(e) = port.write_data_terminal_ready(self.dtr) {
|
||||||
warn!(
|
warn!("Failed to set DTR({}) on {}: {}", self.dtr, self.port_name, e);
|
||||||
"Failed to set DTR({}) on {}: {}",
|
|
||||||
self.dtr, self.port_name, e
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if let Err(e) = port.write_request_to_send(self.rts) {
|
if let Err(e) = port.write_request_to_send(self.rts) {
|
||||||
warn!(
|
warn!("Failed to set RTS({}) on {}: {}", self.rts, self.port_name, e);
|
||||||
"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);
|
||||||
@@ -337,8 +331,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
true,
|
true,
|
||||||
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);
|
||||||
@@ -353,8 +347,8 @@ mod tests {
|
|||||||
SerialParity::Even,
|
SerialParity::Even,
|
||||||
SerialStopBits::Two,
|
SerialStopBits::Two,
|
||||||
SerialFlowControl::Hardware,
|
SerialFlowControl::Hardware,
|
||||||
true,
|
true,
|
||||||
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);
|
||||||
@@ -373,8 +367,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.name(), "COM1");
|
assert_eq!(transport.name(), "COM1");
|
||||||
}
|
}
|
||||||
@@ -388,8 +382,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.transport_type(), TransportType::Serial);
|
assert_eq!(transport.transport_type(), TransportType::Serial);
|
||||||
}
|
}
|
||||||
@@ -403,8 +397,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert!(!transport.is_connected());
|
assert!(!transport.is_connected());
|
||||||
}
|
}
|
||||||
@@ -433,8 +427,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
true,
|
true,
|
||||||
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];
|
||||||
@@ -458,8 +452,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
let pinned = Pin::new(&mut transport);
|
let pinned = Pin::new(&mut transport);
|
||||||
let data = b"hello";
|
let data = b"hello";
|
||||||
@@ -480,8 +474,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
let pinned = Pin::new(&mut transport);
|
let pinned = Pin::new(&mut transport);
|
||||||
let waker = noop_waker();
|
let waker = noop_waker();
|
||||||
@@ -501,8 +495,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
let pinned = Pin::new(&mut transport);
|
let pinned = Pin::new(&mut transport);
|
||||||
let waker = noop_waker();
|
let waker = noop_waker();
|
||||||
@@ -524,8 +518,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.name(), "COM1");
|
assert_eq!(transport.name(), "COM1");
|
||||||
}
|
}
|
||||||
@@ -539,8 +533,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.transport_type(), TransportType::Serial);
|
assert_eq!(transport.transport_type(), TransportType::Serial);
|
||||||
}
|
}
|
||||||
@@ -554,8 +548,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert!(!transport.is_connected());
|
assert!(!transport.is_connected());
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|||||||
use tokio::net::{TcpListener, UdpSocket};
|
use tokio::net::{TcpListener, UdpSocket};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
use pipeview_core::frame::{
|
use xserial_core::frame::{
|
||||||
Endian, Framer,
|
Endian, Framer,
|
||||||
cobs::CobsFramer,
|
cobs::CobsFramer,
|
||||||
cobs::cobs_encode as raw_cobs_encode,
|
cobs::cobs_encode as raw_cobs_encode,
|
||||||
@@ -13,17 +13,17 @@ use pipeview_core::frame::{
|
|||||||
line::{LineConfig, LineFramer},
|
line::{LineConfig, LineFramer},
|
||||||
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
||||||
};
|
};
|
||||||
use pipeview_core::protocol::{
|
use xserial_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 pipeview_core::transport::serial::{
|
use xserial_core::transport::serial::{
|
||||||
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
|
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
|
||||||
};
|
};
|
||||||
use pipeview_core::transport::{Connection, TransportConfig, TransportType};
|
use xserial_core::transport::{Connection, TransportConfig, TransportType};
|
||||||
|
|
||||||
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
|
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
@@ -688,8 +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,
|
dtr: false,
|
||||||
rts: 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 = "pipeview-gui"
|
name = "xserial-gui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "pipeview-gui"
|
name = "xserial-gui"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pipeview-core = { path = "../pipeview-core" }
|
xserial-core = { path = "../xserial-core" }
|
||||||
pipeview-client = { path = "../pipeview-client" }
|
xserial-client = { path = "../xserial-client" }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
egui = { workspace = true }
|
egui = { workspace = true }
|
||||||
eframe = { workspace = true }
|
eframe = { workspace = true }
|
||||||
@@ -19,4 +19,3 @@ tracing-subscriber = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
ansitok = "0.3"
|
|
||||||
@@ -1,47 +1,177 @@
|
|||||||
use crate::app_state::{self, PersistedGuiState, SessionLogConfig};
|
use crate::app_state::{self, PersistedGuiState, SessionLogConfig};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
||||||
use crate::logging::{self, LogWriter};
|
use crate::logging::{self, LogWriter};
|
||||||
use crate::panels::{
|
use crate::panels::{config, console, hex_view, plot_view, sidebar};
|
||||||
config, console, font_settings, hex_view, plot_view, send_panel, session_controls, sidebar,
|
|
||||||
};
|
|
||||||
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
|
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
|
||||||
use crate::shortcuts::{self, default_bindings};
|
use crate::shortcuts::{self, default_bindings};
|
||||||
use crate::ui_fonts::{self, FontCandidate, UiFontSettings};
|
use crate::ui_fonts::{self, FontCandidate, FontChoice, UiFontSettings};
|
||||||
use egui::{Color32, Label, Layout, Panel, Pos2, Rect, UiBuilder};
|
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
|
||||||
use pipeview_client::SessionManager;
|
use xserial_client::SessionManager;
|
||||||
use pipeview_client::config::SessionConfig;
|
use xserial_client::config::SessionConfig;
|
||||||
use pipeview_client::session::SessionEvent;
|
use xserial_client::session::SessionEvent;
|
||||||
use pipeview_core::protocol::DecodedData;
|
use xserial_core::protocol::DecodedData;
|
||||||
use pipeview_core::transport::TransportConfig;
|
use xserial_core::transport::TransportConfig;
|
||||||
|
|
||||||
const DATA_REPAINT_INTERVAL: Duration = Duration::from_millis(33);
|
const DATA_REPAINT_INTERVAL: Duration = Duration::from_millis(33);
|
||||||
|
|
||||||
pub use crate::models::*;
|
#[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, PartialEq)]
|
||||||
|
#[allow(clippy::upper_case_acronyms)]
|
||||||
|
pub enum LineEnding {
|
||||||
|
None,
|
||||||
|
LF,
|
||||||
|
CR,
|
||||||
|
CRLF,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for LineEnding {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::None => write!(f, "None"),
|
||||||
|
Self::LF => write!(f, "LF (\\n)"),
|
||||||
|
Self::CR => write!(f, "CR (\\r)"),
|
||||||
|
Self::CRLF => write!(f, "CRLF (\\r\\n)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct DisplayOptions {
|
||||||
|
pub show_timestamp: bool,
|
||||||
|
pub show_direction: bool,
|
||||||
|
pub show_pipeline: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct SearchState {
|
||||||
|
pub query: String,
|
||||||
|
pub matches: Vec<usize>,
|
||||||
|
pub current_match: usize,
|
||||||
|
pub case_sensitive: bool,
|
||||||
|
pub active: bool,
|
||||||
|
pub just_opened: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchState {
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.query.clear();
|
||||||
|
self.matches.clear();
|
||||||
|
self.current_match = 0;
|
||||||
|
self.active = false;
|
||||||
|
self.just_opened = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&mut self) {
|
||||||
|
if !self.matches.is_empty() {
|
||||||
|
self.current_match = (self.current_match + 1) % self.matches.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev(&mut self) {
|
||||||
|
if !self.matches.is_empty() {
|
||||||
|
self.current_match = if self.current_match == 0 {
|
||||||
|
self.matches.len() - 1
|
||||||
|
} else {
|
||||||
|
self.current_match - 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pub fn current_line_index(&self) -> Option<usize> {
|
||||||
|
// self.matches.get(self.current_match).copied()
|
||||||
|
// }
|
||||||
|
|
||||||
|
pub fn match_count(&self) -> usize {
|
||||||
|
self.matches.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_display(&self) -> usize {
|
||||||
|
if self.matches.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.current_match + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SessionTab {
|
||||||
|
pub id: u64,
|
||||||
|
pub session_config: SessionConfig,
|
||||||
|
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 dtr: bool,
|
||||||
|
pub rts: bool,
|
||||||
|
pub send_input: String,
|
||||||
|
pub send_mode: SendMode,
|
||||||
|
pub line_ending: LineEnding,
|
||||||
|
pub send_status: Option<String>,
|
||||||
|
pub search: SearchState,
|
||||||
|
pub log_enabled: bool,
|
||||||
|
pub log_path: String,
|
||||||
|
pub log_writer: Option<LogWriter>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct XserialApp {
|
pub struct XserialApp {
|
||||||
pub(crate) manager: SessionManager,
|
manager: SessionManager,
|
||||||
pub(crate) tabs: Vec<SessionTab>,
|
tabs: Vec<SessionTab>,
|
||||||
pub(crate) active: usize,
|
active: usize,
|
||||||
pub(crate) display: DisplayOptions,
|
display: DisplayOptions,
|
||||||
pub(crate) font_settings_open: bool,
|
font_settings_open: bool,
|
||||||
pub(crate) font_candidates: Vec<FontCandidate>,
|
font_candidates: Vec<FontCandidate>,
|
||||||
pub(crate) font_settings: UiFontSettings,
|
font_settings: UiFontSettings,
|
||||||
pub(crate) primary_font_search: String,
|
primary_font_search: String,
|
||||||
pub(crate) fallback_font_search: String,
|
fallback_font_search: String,
|
||||||
pub(crate) primary_filtered_fonts: Vec<usize>,
|
primary_filtered_fonts: Vec<usize>,
|
||||||
pub(crate) fallback_filtered_fonts: Vec<usize>,
|
fallback_filtered_fonts: Vec<usize>,
|
||||||
pub(crate) primary_filter_cache_key: String,
|
primary_filter_cache_key: String,
|
||||||
pub(crate) fallback_filter_cache_key: String,
|
fallback_filter_cache_key: String,
|
||||||
pub(crate) config_open: bool,
|
config_open: bool,
|
||||||
pub(crate) config_target: Option<u64>,
|
config_target: Option<u64>,
|
||||||
pub(crate) config_form: config::ConfigForm,
|
config_form: config::ConfigForm,
|
||||||
pub(crate) event_rx: mpsc::Receiver<SessionEvent>,
|
event_rx: mpsc::Receiver<SessionEvent>,
|
||||||
pub(crate) pending: Vec<SessionEvent>,
|
pending: Vec<SessionEvent>,
|
||||||
pub(crate) profiler: GuiProfiler,
|
profiler: GuiProfiler,
|
||||||
pub(crate) shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
|
shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl XserialApp {
|
impl XserialApp {
|
||||||
@@ -282,7 +412,6 @@ impl XserialApp {
|
|||||||
search: SearchState::default(),
|
search: SearchState::default(),
|
||||||
log_enabled: false,
|
log_enabled: false,
|
||||||
log_path: String::new(),
|
log_path: String::new(),
|
||||||
show_sent: true,
|
|
||||||
log_writer: None,
|
log_writer: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -539,7 +668,7 @@ impl XserialApp {
|
|||||||
fn render_main_panel(&mut self, ui: &mut egui::Ui) {
|
fn render_main_panel(&mut self, ui: &mut egui::Ui) {
|
||||||
egui::CentralPanel::default().show_inside(ui, |ui| {
|
egui::CentralPanel::default().show_inside(ui, |ui| {
|
||||||
if self.tabs.is_empty() {
|
if self.tabs.is_empty() {
|
||||||
ui.add(Label::new(egui::RichText::new("No sessions.").heading()).selectable(false));
|
ui.heading("No sessions.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,13 +688,10 @@ impl XserialApp {
|
|||||||
let (badge, status_text) = tab.status.badge();
|
let (badge, status_text) = tab.status.badge();
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.add(
|
ui.heading(format!("Session {}", tab.id));
|
||||||
Label::new(egui::RichText::new(format!("Session {}", tab.id)).heading())
|
ui.label(badge);
|
||||||
.selectable(false),
|
|
||||||
);
|
|
||||||
ui.add(Label::new(badge).selectable(false));
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.add(Label::new(status_text).selectable(false));
|
ui.label(status_text);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if ui
|
if ui
|
||||||
.selectable_label(tab.view == View::Text, "Text")
|
.selectable_label(tab.view == View::Text, "Text")
|
||||||
@@ -584,13 +710,12 @@ impl XserialApp {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let auto_reconnect_changed =
|
let auto_reconnect_changed = render_session_controls(ui, &manager, tab);
|
||||||
session_controls::render_session_controls(ui, &manager, tab);
|
|
||||||
persist_state |= auto_reconnect_changed;
|
persist_state |= auto_reconnect_changed;
|
||||||
|
|
||||||
let mut display_changed = false;
|
let mut display_changed = false;
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.add(Label::new("Show:").selectable(false));
|
ui.label("Show:");
|
||||||
display_changed |= ui
|
display_changed |= ui
|
||||||
.checkbox(&mut display.show_timestamp, "Timestamp")
|
.checkbox(&mut display.show_timestamp, "Timestamp")
|
||||||
.changed();
|
.changed();
|
||||||
@@ -601,13 +726,10 @@ impl XserialApp {
|
|||||||
});
|
});
|
||||||
persist_state |= display_changed;
|
persist_state |= display_changed;
|
||||||
|
|
||||||
session_controls::render_search_bar(ui, tab);
|
render_search_bar(ui, tab);
|
||||||
|
|
||||||
if let ConnectionStatus::Error(message) = &tab.status {
|
if let ConnectionStatus::Error(message) = &tab.status {
|
||||||
ui.add(
|
ui.label(egui::RichText::new(message).color(Color32::RED));
|
||||||
Label::new(egui::RichText::new(message).color(Color32::RED))
|
|
||||||
.selectable(false),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let full = ui.available_rect_before_wrap();
|
let full = ui.available_rect_before_wrap();
|
||||||
@@ -629,41 +751,20 @@ impl XserialApp {
|
|||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
match tab.view {
|
match tab.view {
|
||||||
View::Text => {
|
View::Text => {
|
||||||
let line_count = console::render(
|
let line_count = console::render(ui, &tab.console, *display, tab.search.active.then_some(&tab.search));
|
||||||
ui,
|
|
||||||
&tab.console,
|
|
||||||
*display,
|
|
||||||
tab.search.active.then_some(&tab.search),
|
|
||||||
);
|
|
||||||
text_render = Some((started.elapsed(), line_count));
|
text_render = Some((started.elapsed(), line_count));
|
||||||
}
|
}
|
||||||
View::Hex => {
|
View::Hex => {
|
||||||
let line_count = hex_view::render(
|
let line_count = hex_view::render(ui, &tab.hex, *display, tab.search.active.then_some(&tab.search));
|
||||||
ui,
|
|
||||||
&tab.hex,
|
|
||||||
*display,
|
|
||||||
tab.search.active.then_some(&tab.search),
|
|
||||||
);
|
|
||||||
hex_render = Some((started.elapsed(), line_count));
|
hex_render = Some((started.elapsed(), line_count));
|
||||||
}
|
}
|
||||||
View::Plot => {
|
View::Plot => {
|
||||||
if tab.plot_view.detached {
|
if tab.plot_view.detached {
|
||||||
ui.add(
|
ui.heading(format!(
|
||||||
Label::new(
|
"Plot window detached for Session {}",
|
||||||
egui::RichText::new(format!(
|
tab.id
|
||||||
"Plot window detached for Session {}",
|
));
|
||||||
tab.id,
|
ui.label("The plot is currently shown in a floating window.");
|
||||||
))
|
|
||||||
.heading(),
|
|
||||||
)
|
|
||||||
.selectable(false),
|
|
||||||
);
|
|
||||||
ui.add(
|
|
||||||
Label::new(
|
|
||||||
"The plot is currently shown in a floating window.",
|
|
||||||
)
|
|
||||||
.selectable(false),
|
|
||||||
);
|
|
||||||
if ui.button("Dock Plot Back").clicked() {
|
if ui.button("Dock Plot Back").clicked() {
|
||||||
tab.plot_view.detached = false;
|
tab.plot_view.detached = false;
|
||||||
}
|
}
|
||||||
@@ -688,7 +789,7 @@ impl XserialApp {
|
|||||||
UiBuilder::new()
|
UiBuilder::new()
|
||||||
.max_rect(send_rect)
|
.max_rect(send_rect)
|
||||||
.layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)),
|
.layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)),
|
||||||
|ui| send_panel::render_send_panel(ui, &manager, tab),
|
|ui| render_send_panel(ui, &manager, tab),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,7 +851,7 @@ impl XserialApp {
|
|||||||
fn render_top_bar(&mut self, ui: &mut egui::Ui) {
|
fn render_top_bar(&mut self, ui: &mut egui::Ui) {
|
||||||
Panel::top("top_bar").show_inside(ui, |ui| {
|
Panel::top("top_bar").show_inside(ui, |ui| {
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.add(Label::new(egui::RichText::new("pipeview").heading()).selectable(false));
|
ui.heading("xserial");
|
||||||
ui.separator();
|
ui.separator();
|
||||||
// ui.label(format!(
|
// ui.label(format!(
|
||||||
// "Fonts: {} + {} {:.1} pt",
|
// "Fonts: {} + {} {:.1} pt",
|
||||||
@@ -772,10 +873,106 @@ impl XserialApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_font_settings_window(&mut self, ctx: &egui::Context) {
|
fn render_font_settings_window(&mut self, ctx: &egui::Context) {
|
||||||
font_settings::render_font_settings_window(self, ctx);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn invalidate_font_filters(&mut self) {
|
fn invalidate_font_filters(&mut self) {
|
||||||
self.primary_filtered_fonts.clear();
|
self.primary_filtered_fonts.clear();
|
||||||
self.fallback_filtered_fonts.clear();
|
self.fallback_filtered_fonts.clear();
|
||||||
self.primary_filter_cache_key = String::from("\0");
|
self.primary_filter_cache_key = String::from("\0");
|
||||||
@@ -797,6 +994,88 @@ fn transport_summary(transport: &TransportConfig) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
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 {
|
impl eframe::App for XserialApp {
|
||||||
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
|
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
|
||||||
|
|
||||||
@@ -826,11 +1105,337 @@ impl eframe::App for XserialApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
|
||||||
|
if !tab.search.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let response = ui.add(
|
||||||
|
TextEdit::singleline(&mut tab.search.query)
|
||||||
|
.hint_text("Search...")
|
||||||
|
.desired_width(200.0),
|
||||||
|
);
|
||||||
|
if tab.search.just_opened {
|
||||||
|
response.request_focus();
|
||||||
|
tab.search.just_opened = false;
|
||||||
|
}
|
||||||
|
if response.changed() {
|
||||||
|
let matches = match tab.view {
|
||||||
|
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
|
||||||
|
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
|
||||||
|
View::Plot => Vec::new(),
|
||||||
|
};
|
||||||
|
tab.search.matches = matches;
|
||||||
|
tab.search.current_match = 0;
|
||||||
|
}
|
||||||
|
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||||
|
tab.search.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.label(format!(
|
||||||
|
"{}/{}",
|
||||||
|
tab.search.current_display(),
|
||||||
|
tab.search.match_count()
|
||||||
|
));
|
||||||
|
|
||||||
|
if ui.button("▲").clicked() {
|
||||||
|
tab.search.prev();
|
||||||
|
}
|
||||||
|
if ui.button("▼").clicked() {
|
||||||
|
tab.search.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.checkbox(&mut tab.search.case_sensitive, "Aa").changed() {
|
||||||
|
let matches = match tab.view {
|
||||||
|
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
|
||||||
|
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
|
||||||
|
View::Plot => Vec::new(),
|
||||||
|
};
|
||||||
|
tab.search.matches = matches;
|
||||||
|
tab.search.current_match = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.button("✕").clicked() {
|
||||||
|
tab.search.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_session_controls(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
manager: &SessionManager,
|
||||||
|
tab: &mut SessionTab,
|
||||||
|
) -> bool {
|
||||||
|
let mut auto_reconnect_changed = false;
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
let connected = matches!(
|
||||||
|
tab.status,
|
||||||
|
ConnectionStatus::Connected | ConnectionStatus::Connecting
|
||||||
|
);
|
||||||
|
let connect_label = if connected { "Disconnect" } else { "Connect" };
|
||||||
|
if ui.button(connect_label).clicked() {
|
||||||
|
if let Some(handle) = manager.get(tab.id) {
|
||||||
|
if connected {
|
||||||
|
tab.status = ConnectionStatus::Disconnected;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = handle.disconnect().await;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tab.status = ConnectionStatus::Connecting;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = handle.connect().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("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() {
|
||||||
|
tab.session_config.auto_reconnect = tab.auto_reconnect;
|
||||||
|
auto_reconnect_changed = true;
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches!(tab.status, ConnectionStatus::Connected)
|
||||||
|
&& matches!(tab.session_config.transport, TransportConfig::Serial { .. })
|
||||||
|
{
|
||||||
|
let dtr_changed = ui.checkbox(&mut tab.dtr, "DTR").changed();
|
||||||
|
let rts_changed = ui.checkbox(&mut tab.rts, "RTS").changed();
|
||||||
|
if dtr_changed || rts_changed {
|
||||||
|
if let Some(handle) = manager.get(tab.id) {
|
||||||
|
let dtr = tab.dtr;
|
||||||
|
let rts = tab.rts;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if dtr_changed {
|
||||||
|
let _ = handle.set_dtr(dtr).await;
|
||||||
|
}
|
||||||
|
if rts_changed {
|
||||||
|
let _ = handle.set_rts(rts).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
auto_reconnect_changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
|
||||||
|
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.add_space(6.0);
|
||||||
|
egui::ComboBox::from_label("Line ending")
|
||||||
|
.selected_text(tab.line_ending.to_string())
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut tab.line_ending,
|
||||||
|
LineEnding::None,
|
||||||
|
LineEnding::None.to_string(),
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut tab.line_ending,
|
||||||
|
LineEnding::LF,
|
||||||
|
LineEnding::LF.to_string(),
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut tab.line_ending,
|
||||||
|
LineEnding::CR,
|
||||||
|
LineEnding::CR.to_string(),
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut tab.line_ending,
|
||||||
|
LineEnding::CRLF,
|
||||||
|
LineEnding::CRLF.to_string(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(6.0);
|
||||||
|
|
||||||
|
let log_toggled = ui
|
||||||
|
.checkbox(&mut tab.log_enabled, "Log to file")
|
||||||
|
.changed();
|
||||||
|
if log_toggled {
|
||||||
|
if tab.log_enabled {
|
||||||
|
tab.log_path = default_log_path(tab.id)
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
tab.log_writer = LogWriter::open(&tab.log_path).ok();
|
||||||
|
} else {
|
||||||
|
tab.log_writer = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tab.log_enabled {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let changed = ui
|
||||||
|
.text_edit_singleline(&mut tab.log_path)
|
||||||
|
.lost_focus();
|
||||||
|
if changed && !tab.log_path.is_empty() {
|
||||||
|
tab.log_writer = LogWriter::open(&tab.log_path).ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ui.add_space(3.0);
|
||||||
|
|
||||||
|
let hint = match tab.send_mode {
|
||||||
|
SendMode::Text => "Enter text to send",
|
||||||
|
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
|
||||||
|
};
|
||||||
|
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 mut text = tab.send_input.trim_end_matches('\n').to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
match tab.line_ending {
|
||||||
|
LineEnding::None => {}
|
||||||
|
LineEnding::LF => text.push('\n'),
|
||||||
|
LineEnding::CR => text.push('\r'),
|
||||||
|
LineEnding::CRLF => text.push_str("\r\n"),
|
||||||
|
}
|
||||||
|
tab.console.push_outbound(text.clone());
|
||||||
|
if let Some(ref writer) = tab.log_writer {
|
||||||
|
writer.write_line(&logging::format_sent_log(&text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SendMode::Hex => {
|
||||||
|
let hex = tab
|
||||||
|
.send_input
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
if !hex.is_empty() {
|
||||||
|
tab.hex.push_outbound(hex.clone());
|
||||||
|
if let Some(ref writer) = tab.log_writer {
|
||||||
|
writer.write_line(&logging::format_sent_log(&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 default_log_path(session_id: u64) -> PathBuf {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let dir = app_state::config_dir().join("logs");
|
||||||
|
let ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
dir.join(format!("session_{session_id}_{ts}.log"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
|
||||||
|
let trimmed = tab.send_input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
match tab.send_mode {
|
||||||
|
SendMode::Text => {
|
||||||
|
let mut text = tab.send_input.trim_end_matches('\n').to_string();
|
||||||
|
match tab.line_ending {
|
||||||
|
LineEnding::None => {}
|
||||||
|
LineEnding::LF => text.push('\n'),
|
||||||
|
LineEnding::CR => text.push('\r'),
|
||||||
|
LineEnding::CRLF => text.push_str("\r\n"),
|
||||||
|
}
|
||||||
|
Ok(Some(text.into_bytes()))
|
||||||
|
}
|
||||||
|
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})"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::transport_summary;
|
use super::transport_summary;
|
||||||
use pipeview_core::transport::TransportConfig;
|
use xserial_core::transport::TransportConfig;
|
||||||
use pipeview_core::transport::serial::{
|
use xserial_core::transport::serial::{
|
||||||
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
|
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3,9 +3,9 @@ use std::fs;
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use pipeview_client::config::SessionConfig;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
use xserial_client::config::SessionConfig;
|
||||||
|
|
||||||
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
|
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ fn gui_state_path() -> PathBuf {
|
|||||||
gui_state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
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).
|
/// Returns the xserial configuration directory (the parent of gui-state.json).
|
||||||
pub fn config_dir() -> PathBuf {
|
pub fn config_dir() -> PathBuf {
|
||||||
gui_state_path()
|
gui_state_path()
|
||||||
.parent()
|
.parent()
|
||||||
@@ -67,12 +67,12 @@ fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<Stri
|
|||||||
"windows" => {
|
"windows" => {
|
||||||
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(path)
|
return PathBuf::from(path)
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(GUI_STATE_FILE_NAME);
|
.join(GUI_STATE_FILE_NAME);
|
||||||
}
|
}
|
||||||
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(path)
|
return PathBuf::from(path)
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(GUI_STATE_FILE_NAME);
|
.join(GUI_STATE_FILE_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,20 +81,20 @@ fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<Stri
|
|||||||
return PathBuf::from(home)
|
return PathBuf::from(home)
|
||||||
.join("Library")
|
.join("Library")
|
||||||
.join("Application Support")
|
.join("Application Support")
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(GUI_STATE_FILE_NAME);
|
.join(GUI_STATE_FILE_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(path)
|
return PathBuf::from(path)
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(GUI_STATE_FILE_NAME);
|
.join(GUI_STATE_FILE_NAME);
|
||||||
}
|
}
|
||||||
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(home)
|
return PathBuf::from(home)
|
||||||
.join(".config")
|
.join(".config")
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(GUI_STATE_FILE_NAME);
|
.join(GUI_STATE_FILE_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,8 +123,8 @@ const fn default_true() -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use pipeview_core::transport::TransportConfig;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use xserial_core::transport::TransportConfig;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn windows_gui_state_path_uses_appdata() {
|
fn windows_gui_state_path_uses_appdata() {
|
||||||
@@ -135,7 +135,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
path,
|
path,
|
||||||
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
|
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(GUI_STATE_FILE_NAME)
|
.join(GUI_STATE_FILE_NAME)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,7 @@ mod tests {
|
|||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let dir = env::temp_dir().join(format!("pipeview-gui-state-{unique}"));
|
let dir = env::temp_dir().join(format!("xserial-gui-state-{unique}"));
|
||||||
let path = dir.join(GUI_STATE_FILE_NAME);
|
let path = dir.join(GUI_STATE_FILE_NAME);
|
||||||
let state = PersistedGuiState {
|
let state = PersistedGuiState {
|
||||||
sessions: vec![SessionConfig {
|
sessions: vec![SessionConfig {
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
use egui_plot::PlotBounds;
|
use egui_plot::PlotBounds;
|
||||||
use pipeview_client::RingBuffer;
|
|
||||||
use pipeview_client::event::DecodedEntry;
|
|
||||||
use pipeview_core::protocol::DecodedData;
|
|
||||||
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
use xserial_client::RingBuffer;
|
||||||
|
use xserial_client::event::DecodedEntry;
|
||||||
|
use xserial_core::protocol::DecodedData;
|
||||||
|
use xserial_core::protocol::plot::{PlotFormat, PlotFrame};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum LineDirection {
|
pub enum LineDirection {
|
||||||
@@ -716,9 +716,9 @@ impl PlotBuffer {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use pipeview_client::event::DecodedEntry;
|
use xserial_client::event::DecodedEntry;
|
||||||
use pipeview_core::protocol::DecodedData;
|
use xserial_core::protocol::DecodedData;
|
||||||
use pipeview_core::protocol::plot::{PlotFrame, SampleType};
|
use xserial_core::protocol::plot::{PlotFrame, SampleType};
|
||||||
|
|
||||||
fn plot_entry() -> DecodedEntry {
|
fn plot_entry() -> DecodedEntry {
|
||||||
DecodedEntry {
|
DecodedEntry {
|
||||||
@@ -6,8 +6,8 @@ use std::thread;
|
|||||||
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use pipeview_client::event::DecodedEntry;
|
use xserial_client::event::DecodedEntry;
|
||||||
use pipeview_core::protocol::DecodedData;
|
use xserial_core::protocol::DecodedData;
|
||||||
|
|
||||||
/// Manages background file logging via a dedicated writer thread.
|
/// Manages background file logging via a dedicated writer thread.
|
||||||
///
|
///
|
||||||
@@ -30,7 +30,7 @@ impl LogWriter {
|
|||||||
let mut writer = BufWriter::with_capacity(1024, file);
|
let mut writer = BufWriter::with_capacity(1024, file);
|
||||||
let (sender, receiver) = mpsc::channel::<String>();
|
let (sender, receiver) = mpsc::channel::<String>();
|
||||||
|
|
||||||
let thread_name = format!("pipeview-log-{}", path.display());
|
let thread_name = format!("xserial-log-{}", path.display());
|
||||||
thread::Builder::new().name(thread_name).spawn(move || {
|
thread::Builder::new().name(thread_name).spawn(move || {
|
||||||
while let Ok(line) = receiver.recv() {
|
while let Ok(line) = receiver.recv() {
|
||||||
if writeln!(writer, "{line}").is_err() {
|
if writeln!(writer, "{line}").is_err() {
|
||||||
28
crates/xserial-gui/src/main.rs
Normal file
28
crates/xserial-gui/src/main.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
mod app;
|
||||||
|
mod app_state;
|
||||||
|
mod buffers;
|
||||||
|
mod logging;
|
||||||
|
mod panels;
|
||||||
|
mod perf;
|
||||||
|
mod shortcuts;
|
||||||
|
mod ui_fonts;
|
||||||
|
|
||||||
|
use xserial_client::SessionManager;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||||
|
let _guard = rt.enter();
|
||||||
|
|
||||||
|
let mgr = SessionManager::new();
|
||||||
|
let rx = mgr.subscribe();
|
||||||
|
|
||||||
|
let _ = eframe::run_native(
|
||||||
|
"xserial",
|
||||||
|
eframe::NativeOptions::default(),
|
||||||
|
Box::new(|cc| Ok(Box::new(app::XserialApp::new(mgr, rx, cc.egui_ctx.clone())))),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
|
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
|
||||||
use pipeview_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
||||||
use pipeview_core::protocol::Endian;
|
use xserial_core::protocol::Endian;
|
||||||
use pipeview_core::protocol::plot::{PlotFormat, SampleType};
|
use xserial_core::protocol::plot::{PlotFormat, SampleType};
|
||||||
use pipeview_core::protocol::text::TextEncoding;
|
use xserial_core::protocol::text::TextEncoding;
|
||||||
use pipeview_core::transport::TransportConfig;
|
use xserial_core::transport::TransportConfig;
|
||||||
use pipeview_core::transport::serial::{
|
use xserial_core::transport::serial::{
|
||||||
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
|
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
|
||||||
};
|
};
|
||||||
|
|
||||||
129
crates/xserial-gui/src/panels/console.rs
Normal file
129
crates/xserial-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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,6 @@ use egui::{
|
|||||||
text::{LayoutJob, TextFormat},
|
text::{LayoutJob, TextFormat},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Nudge amount (pixels/frame) for auto-scroll during text-selection drag
|
|
||||||
const EDGE_SCROLL_NUDGE: f32 = 4.0;
|
|
||||||
/// Distance from the bottom edge (pixels) that triggers auto-scroll
|
|
||||||
const EDGE_SCROLL_ZONE: f32 = 30.0;
|
|
||||||
|
|
||||||
pub fn render(
|
pub fn render(
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
buf: &HexBuffer,
|
buf: &HexBuffer,
|
||||||
@@ -18,51 +13,25 @@ pub fn render(
|
|||||||
) -> usize {
|
) -> usize {
|
||||||
let line_count = buf.len();
|
let line_count = buf.len();
|
||||||
if line_count == 0 {
|
if line_count == 0 {
|
||||||
ui.add(Label::new("no hex data").selectable(false));
|
ui.label("no hex data");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
||||||
let mut near_bottom_edge = false;
|
ScrollArea::both().stick_to_bottom(true).show_rows(
|
||||||
|
ui,
|
||||||
let scroll_area = ScrollArea::both()
|
row_height,
|
||||||
.id_salt("hex_text_area")
|
line_count,
|
||||||
.stick_to_bottom(true);
|
|ui, row_range| {
|
||||||
|
for row in row_range {
|
||||||
let output = scroll_area.show_viewport(ui, |ui, viewport| {
|
if let Some(line) = buf.get(row) {
|
||||||
let start_row = (viewport.min.y / row_height).floor().max(0.0) as usize;
|
ui.add(
|
||||||
let end_row = ((viewport.max.y / row_height).ceil() as usize).min(line_count);
|
Label::new(format_hex_line(line, display, search, ui.style()))
|
||||||
|
.wrap_mode(TextWrapMode::Extend),
|
||||||
// Check if user is drag-selecting near the bottom edge of the scroll area
|
);
|
||||||
near_bottom_edge = ui.ctx().input(|input| {
|
}
|
||||||
input.pointer.button_down(egui::PointerButton::Primary)
|
|
||||||
&& input.pointer.hover_pos().is_some_and(|pos| {
|
|
||||||
let area = ui.max_rect();
|
|
||||||
pos.y > area.bottom() - EDGE_SCROLL_ZONE && pos.y < area.bottom() + 20.0
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
for row in start_row..end_row {
|
|
||||||
if let Some(line) = buf.get(row) {
|
|
||||||
ui.add(
|
|
||||||
Label::new(format_hex_line(line, display, search, ui.style()))
|
|
||||||
.wrap_mode(TextWrapMode::Extend),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
// If the user is dragging a selection near the bottom edge, nudge the scroll
|
|
||||||
// offset for the next frame and request a repaint for smooth continuous scrolling.
|
|
||||||
if near_bottom_edge {
|
|
||||||
let mut state = output.state;
|
|
||||||
let max_offset =
|
|
||||||
(line_count as f32 * row_height) - output.inner_rect.height() + EDGE_SCROLL_ZONE;
|
|
||||||
state.offset.y = (state.offset.y + EDGE_SCROLL_NUDGE).min(max_offset.max(0.0));
|
|
||||||
state.store(ui.ctx(), output.id);
|
|
||||||
ui.ctx().request_repaint();
|
|
||||||
}
|
|
||||||
|
|
||||||
line_count
|
line_count
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +78,7 @@ fn monospace_format(style: &egui::Style) -> TextFormat {
|
|||||||
.get(&TextStyle::Monospace)
|
.get(&TextStyle::Monospace)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
color: Color32::WHITE,
|
color: Color32::WHITE,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,13 +125,10 @@ fn format_hex_line(
|
|||||||
};
|
};
|
||||||
|
|
||||||
match search {
|
match search {
|
||||||
Some(state) if state.active && !state.query.is_empty() => highlight_matches(
|
Some(state) if state.active && !state.query.is_empty() => {
|
||||||
&full_text,
|
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
|
||||||
&state.query,
|
}
|
||||||
state.case_sensitive,
|
|
||||||
default,
|
|
||||||
highlight,
|
|
||||||
),
|
|
||||||
_ => LayoutJob::single_section(full_text, default),
|
_ => LayoutJob::single_section(full_text, default),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod console;
|
pub mod console;
|
||||||
pub mod font_settings;
|
|
||||||
pub mod hex_view;
|
pub mod hex_view;
|
||||||
pub mod plot_view;
|
pub mod plot_view;
|
||||||
pub mod send_panel;
|
|
||||||
pub mod session_controls;
|
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::buffers::{PlotBuffer, PlotSeriesKind};
|
use crate::buffers::{PlotBuffer, PlotSeriesKind};
|
||||||
use crate::perf::PlotRenderStats;
|
use crate::perf::PlotRenderStats;
|
||||||
use egui::{Button, Label, 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)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -46,7 +46,7 @@ pub fn render(
|
|||||||
if ui.button(toggle_label).clicked() {
|
if ui.button(toggle_label).clicked() {
|
||||||
toggle_detached = true;
|
toggle_detached = true;
|
||||||
}
|
}
|
||||||
ui.add(Label::new("no plot data").selectable(false));
|
ui.label("no plot data");
|
||||||
});
|
});
|
||||||
return PlotRenderOutput {
|
return PlotRenderOutput {
|
||||||
stats: PlotRenderStats {
|
stats: PlotRenderStats {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::app::ConnectionStatus;
|
use crate::app::ConnectionStatus;
|
||||||
use egui::{Color32, Label, RichText, Ui};
|
use egui::{Color32, RichText, Ui};
|
||||||
|
|
||||||
pub struct SessionListItem {
|
pub struct SessionListItem {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
@@ -15,7 +15,7 @@ pub fn render(
|
|||||||
on_edit: &mut Option<usize>,
|
on_edit: &mut Option<usize>,
|
||||||
on_delete: &mut Option<usize>,
|
on_delete: &mut Option<usize>,
|
||||||
) {
|
) {
|
||||||
ui.add(Label::new(egui::RichText::new("Sessions").heading()).selectable(false));
|
ui.heading("Sessions");
|
||||||
|
|
||||||
if ui
|
if ui
|
||||||
.button(RichText::new("+ New Session").color(Color32::GREEN))
|
.button(RichText::new("+ New Session").color(Color32::GREEN))
|
||||||
@@ -202,7 +202,7 @@ impl GuiProfiler {
|
|||||||
|
|
||||||
let repaints = self.repaint_counter.take();
|
let repaints = self.repaint_counter.take();
|
||||||
info!(
|
info!(
|
||||||
target: "pipeview_gui::perf",
|
target: "xserial_gui::perf",
|
||||||
frames = self.interval_stats.frames.count,
|
frames = self.interval_stats.frames.count,
|
||||||
frame_avg_ms = self.interval_stats.frames.avg_ms(),
|
frame_avg_ms = self.interval_stats.frames.avg_ms(),
|
||||||
frame_max_ms = self.interval_stats.frames.max_ms(),
|
frame_max_ms = self.interval_stats.frames.max_ms(),
|
||||||
@@ -56,7 +56,10 @@ pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
|||||||
),
|
),
|
||||||
(Search, KeyboardShortcut::new(Modifiers::CTRL, Key::F)),
|
(Search, KeyboardShortcut::new(Modifiers::CTRL, Key::F)),
|
||||||
(SearchNext, KeyboardShortcut::new(Modifiers::NONE, Key::F3)),
|
(SearchNext, KeyboardShortcut::new(Modifiers::NONE, Key::F3)),
|
||||||
(SearchPrev, KeyboardShortcut::new(Modifiers::SHIFT, Key::F3)),
|
(
|
||||||
|
SearchPrev,
|
||||||
|
KeyboardShortcut::new(Modifiers::SHIFT, Key::F3),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,12 +358,12 @@ fn font_settings_path_for_os_and_env(
|
|||||||
"windows" => {
|
"windows" => {
|
||||||
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(path)
|
return PathBuf::from(path)
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(FONT_SETTINGS_FILE_NAME);
|
.join(FONT_SETTINGS_FILE_NAME);
|
||||||
}
|
}
|
||||||
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(path)
|
return PathBuf::from(path)
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(FONT_SETTINGS_FILE_NAME);
|
.join(FONT_SETTINGS_FILE_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,20 +372,20 @@ fn font_settings_path_for_os_and_env(
|
|||||||
return PathBuf::from(home)
|
return PathBuf::from(home)
|
||||||
.join("Library")
|
.join("Library")
|
||||||
.join("Application Support")
|
.join("Application Support")
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(FONT_SETTINGS_FILE_NAME);
|
.join(FONT_SETTINGS_FILE_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(path)
|
return PathBuf::from(path)
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(FONT_SETTINGS_FILE_NAME);
|
.join(FONT_SETTINGS_FILE_NAME);
|
||||||
}
|
}
|
||||||
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(home)
|
return PathBuf::from(home)
|
||||||
.join(".config")
|
.join(".config")
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(FONT_SETTINGS_FILE_NAME);
|
.join(FONT_SETTINGS_FILE_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -591,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!("pipeview-gui-font-settings-{unique}"));
|
let dir = env::temp_dir().join(format!("xserial-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")),
|
||||||
@@ -622,7 +622,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
path,
|
path,
|
||||||
PathBuf::from("/tmp/xdg-config")
|
PathBuf::from("/tmp/xdg-config")
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(FONT_SETTINGS_FILE_NAME)
|
.join(FONT_SETTINGS_FILE_NAME)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -636,7 +636,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
path,
|
path,
|
||||||
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
|
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(FONT_SETTINGS_FILE_NAME)
|
.join(FONT_SETTINGS_FILE_NAME)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -652,7 +652,7 @@ mod tests {
|
|||||||
PathBuf::from("/Users/tester")
|
PathBuf::from("/Users/tester")
|
||||||
.join("Library")
|
.join("Library")
|
||||||
.join("Application Support")
|
.join("Application Support")
|
||||||
.join("pipeview")
|
.join("xserial")
|
||||||
.join(FONT_SETTINGS_FILE_NAME)
|
.join(FONT_SETTINGS_FILE_NAME)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
24
crates/xserial-tui/Cargo.toml
Normal file
24
crates/xserial-tui/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "xserial-tui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "xserial-tui"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
xserial-core = { path = "../xserial-core" }
|
||||||
|
xserial-client = { path = "../xserial-client" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
ratatui = { workspace = true }
|
||||||
|
crossterm = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
tracing-appender = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
hex = { workspace = true }
|
||||||
|
image = { workspace = true }
|
||||||
|
mlua = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
1085
crates/xserial-tui/src/app.rs
Normal file
1085
crates/xserial-tui/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
103
crates/xserial-tui/src/app_state.rs
Normal file
103
crates/xserial-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 xserial_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("xserial")
|
||||||
|
.join(TUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("xserial")
|
||||||
|
.join(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("xserial")
|
||||||
|
.join(TUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("xserial")
|
||||||
|
.join(TUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(home)
|
||||||
|
.join(".config")
|
||||||
|
.join("xserial")
|
||||||
|
.join(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/xserial-tui/src/buffers.rs
Normal file
146
crates/xserial-tui/src/buffers.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use xserial_client::{DecodedEntry, RingBuffer};
|
||||||
|
use xserial_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/xserial-tui/src/main.rs
Normal file
38
crates/xserial-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 xserial_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/xserial-tui/src/ui.rs
Normal file
332
crates/xserial-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("xserial-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,158 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Shared wire-format helpers for pipeview test tools.
|
|
||||||
|
|
||||||
Keep these functions in sync with:
|
|
||||||
- crates/pipeview-core/src/frame/cobs.rs (cobs_encode)
|
|
||||||
- crates/pipeview-core/src/protocol/mixed.rs (XP plot packet header)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import math
|
|
||||||
import struct
|
|
||||||
|
|
||||||
PLOT_ESCAPE = 0x1E
|
|
||||||
PLOT_MARKER = ord("P")
|
|
||||||
|
|
||||||
DISCONNECT_WINERRORS = {
|
|
||||||
10053, # Software caused connection abort
|
|
||||||
10054, # Connection reset by peer
|
|
||||||
10058, # Socket shutdown race on Windows
|
|
||||||
}
|
|
||||||
|
|
||||||
# XP plot packet header field values (see protocol/mixed.rs)
|
|
||||||
PLOT_PACKET_MAGIC = b"XP"
|
|
||||||
PLOT_PACKET_VERSION = 1
|
|
||||||
PLOT_FORMAT_IDS = {
|
|
||||||
"interleaved": 0,
|
|
||||||
"block": 1,
|
|
||||||
"xy": 2,
|
|
||||||
}
|
|
||||||
SAMPLE_TYPE_F32 = 8
|
|
||||||
ENDIAN_LITTLE = 0
|
|
||||||
|
|
||||||
|
|
||||||
def cobs_encode(payload: bytes) -> bytes:
|
|
||||||
"""Consistent Overhead Byte Stuffing encoder (matches Rust cobs_encode)."""
|
|
||||||
if not payload:
|
|
||||||
return b"\x01"
|
|
||||||
|
|
||||||
out = bytearray([0])
|
|
||||||
code_index = 0
|
|
||||||
code = 1
|
|
||||||
for byte in payload:
|
|
||||||
if byte == 0:
|
|
||||||
out[code_index] = code
|
|
||||||
code_index = len(out)
|
|
||||||
out.append(0)
|
|
||||||
code = 1
|
|
||||||
else:
|
|
||||||
out.append(byte)
|
|
||||||
code += 1
|
|
||||||
if code == 0xFF:
|
|
||||||
out[code_index] = code
|
|
||||||
code_index = len(out)
|
|
||||||
out.append(0)
|
|
||||||
code = 1
|
|
||||||
out[code_index] = code
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
|
|
||||||
def build_plot_payload(
|
|
||||||
sample_index: int,
|
|
||||||
channels: int,
|
|
||||||
plot_format: str,
|
|
||||||
samples_per_channel: int,
|
|
||||||
amplitude: float,
|
|
||||||
frequency_hz: float,
|
|
||||||
sample_rate_hz: float,
|
|
||||||
) -> bytes:
|
|
||||||
"""Build a raw little-endian f32 plot payload without any framing."""
|
|
||||||
if channels <= 0:
|
|
||||||
raise ValueError("channels must be >= 1")
|
|
||||||
if plot_format not in PLOT_FORMAT_IDS:
|
|
||||||
raise ValueError(f"unsupported plot format: {plot_format}")
|
|
||||||
if plot_format == "xy" and channels != 2:
|
|
||||||
raise ValueError("xy format requires exactly 2 channels")
|
|
||||||
|
|
||||||
values = []
|
|
||||||
if plot_format == "xy":
|
|
||||||
for offset in range(samples_per_channel):
|
|
||||||
t = (sample_index + offset) / sample_rate_hz
|
|
||||||
values.extend(
|
|
||||||
[
|
|
||||||
amplitude * math.cos(2 * math.pi * frequency_hz * t),
|
|
||||||
amplitude * math.sin(2 * math.pi * frequency_hz * t),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
elif plot_format == "block":
|
|
||||||
for ch in range(channels):
|
|
||||||
phase = 2 * math.pi * ch / channels
|
|
||||||
for offset in range(samples_per_channel):
|
|
||||||
t = (sample_index + offset) / sample_rate_hz
|
|
||||||
values.append(
|
|
||||||
amplitude * math.sin(2 * math.pi * frequency_hz * t + phase)
|
|
||||||
)
|
|
||||||
else: # interleaved
|
|
||||||
for offset in range(samples_per_channel):
|
|
||||||
t = (sample_index + offset) / sample_rate_hz
|
|
||||||
for ch in range(channels):
|
|
||||||
phase = 2 * math.pi * ch / channels
|
|
||||||
values.append(
|
|
||||||
amplitude * math.sin(2 * math.pi * frequency_hz * t + phase)
|
|
||||||
)
|
|
||||||
|
|
||||||
return struct.pack(f"<{len(values)}f", *values)
|
|
||||||
|
|
||||||
|
|
||||||
def build_plot_packet(
|
|
||||||
payload: bytes,
|
|
||||||
channels: int,
|
|
||||||
samples_per_channel: int,
|
|
||||||
plot_format: str,
|
|
||||||
) -> bytes:
|
|
||||||
"""Build an XP plot packet (the payload inside a MixedTextPlot COBS frame)."""
|
|
||||||
if not 1 <= channels <= 255:
|
|
||||||
raise ValueError("channels must be in 1..255")
|
|
||||||
if not 0 <= samples_per_channel <= 0xFFFF:
|
|
||||||
raise ValueError("samples_per_channel must fit in u16")
|
|
||||||
if plot_format not in PLOT_FORMAT_IDS:
|
|
||||||
raise ValueError(f"unsupported plot format: {plot_format}")
|
|
||||||
|
|
||||||
header = bytearray()
|
|
||||||
header.extend(PLOT_PACKET_MAGIC)
|
|
||||||
header.append(PLOT_PACKET_VERSION)
|
|
||||||
header.append(PLOT_FORMAT_IDS[plot_format])
|
|
||||||
header.append(SAMPLE_TYPE_F32)
|
|
||||||
header.append(ENDIAN_LITTLE)
|
|
||||||
header.append(channels)
|
|
||||||
header.extend(struct.pack("<H", samples_per_channel))
|
|
||||||
header.extend(struct.pack("<I", len(payload)))
|
|
||||||
header.extend(payload)
|
|
||||||
return bytes(header)
|
|
||||||
|
|
||||||
|
|
||||||
def build_mixed_plot_frame(
|
|
||||||
payload: bytes,
|
|
||||||
channels: int,
|
|
||||||
samples_per_channel: int,
|
|
||||||
plot_format: str,
|
|
||||||
) -> bytes:
|
|
||||||
"""Build a complete MixedTextPlot plot frame (escape + marker + COBS packet + 0x00)."""
|
|
||||||
packet = build_plot_packet(
|
|
||||||
payload=payload,
|
|
||||||
channels=channels,
|
|
||||||
samples_per_channel=samples_per_channel,
|
|
||||||
plot_format=plot_format,
|
|
||||||
)
|
|
||||||
return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00"
|
|
||||||
|
|
||||||
|
|
||||||
def is_disconnect_error(err: OSError) -> bool:
|
|
||||||
"""Return True when the socket error is an expected client-disconnect error."""
|
|
||||||
return isinstance(
|
|
||||||
err,
|
|
||||||
(
|
|
||||||
BrokenPipeError,
|
|
||||||
ConnectionAbortedError,
|
|
||||||
ConnectionResetError,
|
|
||||||
),
|
|
||||||
) or getattr(err, "winerror", None) in DISCONNECT_WINERRORS
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
pipeview GUI(egui)极限压力测试脚本
|
|
||||||
|
|
||||||
模拟各种高数据量场景,测试 pipeview GUI 的渲染性能和稳定性:
|
|
||||||
- 文本洪水:大文本行 + ANSI 颜色
|
|
||||||
- 十六进制洪水:大批量 hex dump
|
|
||||||
- 波形洪水:高频 plot 采样点
|
|
||||||
- 混合模式:同时发送文本 + 波形(MixedTextPlot)
|
|
||||||
- 突发模式:间歇性峰值流量
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python tools/stress_test.py --mode text --rate 5000 --port 8091
|
|
||||||
python tools/stress_test.py --mode plot --rate 200 --channels 8 --port 8092
|
|
||||||
python tools/stress_test.py --mode mixed --rate 1000 --port 8093
|
|
||||||
python tools/stress_test.py --mode burst --rate 10000 --burst-size 500 --port 8094
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import math
|
|
||||||
import random
|
|
||||||
import socket
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
import pv_protocol
|
|
||||||
|
|
||||||
# ── ANSI Color Palette ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
ANSI_COLORS = [
|
|
||||||
"\033[31m", "\033[32m", "\033[33m", "\033[34m", "\033[35m", "\033[36m",
|
|
||||||
"\033[91m", "\033[92m", "\033[93m", "\033[94m", "\033[95m", "\033[96m",
|
|
||||||
"\033[1;31m", "\033[1;32m", "\033[1;33m", "\033[1;34m",
|
|
||||||
]
|
|
||||||
ANSI_RESET = "\033[0m"
|
|
||||||
|
|
||||||
# ── Lorem ipsum lines for text stress testing ──────────────────────
|
|
||||||
|
|
||||||
LOREM_LINES = [
|
|
||||||
"[INFO] Sensor data received: temperature={temp:.2f}°C humidity={hum:.1f}% pressure={pres:.1f}hPa",
|
|
||||||
"[DEBUG] Frame #{n:06d} decoded pipeline={pipe} latency={lat:.3f}ms",
|
|
||||||
"[WARN] Buffer utilization {pct:.1f}% — threshold approaching",
|
|
||||||
"[ERROR] Checksum mismatch at offset {off:#x} expected={exp:#x} got={got:#x}",
|
|
||||||
"[TRACE] I2C transaction: addr={addr:#x} reg={reg:#x} val={val:#x}",
|
|
||||||
"[METRIC] throughput={tput:.1f} lines/s memory={mem:.1f}MB active_sessions={sess}",
|
|
||||||
"[EVENT] Session {sid} state changed: {old} -> {new}",
|
|
||||||
"[DATA] {ts} | CH{ch:02d} | {v0:+08.4f} | {v1:+08.4f} | {v2:+08.4f}",
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── Text stress generator ───────────────────────────────────────────
|
|
||||||
|
|
||||||
def generate_text_line(seq: int, ansi: bool = False, payload_size: int = 80) -> bytes:
|
|
||||||
"""Generate a single text line with optional ANSI colors."""
|
|
||||||
template = random.choice(LOREM_LINES)
|
|
||||||
line = template.format(
|
|
||||||
temp=20.0 + 10 * math.sin(seq * 0.1),
|
|
||||||
hum=45.0 + 20 * math.cos(seq * 0.07),
|
|
||||||
pres=1013.0 + random.uniform(-5, 5),
|
|
||||||
n=seq, pipe=f"pipe_{seq % 4}", lat=random.uniform(0.1, 5.0),
|
|
||||||
pct=random.uniform(10, 95), off=seq * 16,
|
|
||||||
exp=random.randint(0, 255), got=random.randint(0, 255),
|
|
||||||
addr=0x40 + (seq % 8), reg=0x00 + (seq % 16), val=random.randint(0, 65535),
|
|
||||||
tput=random.uniform(100, 10000), mem=random.uniform(50, 500), sess=random.randint(1, 8),
|
|
||||||
sid=seq % 8 + 1, old="disconnected", new="connected",
|
|
||||||
ts=time.strftime("%H:%M:%S", time.localtime()),
|
|
||||||
ch=seq % 8, v0=random.uniform(-10, 10), v1=random.uniform(-10, 10), v2=random.uniform(-10, 10),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Pad or trim to target payload size
|
|
||||||
if len(line) < payload_size:
|
|
||||||
line += " " + "x" * (payload_size - len(line) - 1)
|
|
||||||
elif len(line) > payload_size:
|
|
||||||
line = line[:payload_size]
|
|
||||||
|
|
||||||
if ansi:
|
|
||||||
color = random.choice(ANSI_COLORS)
|
|
||||||
line = f"{color}{line}{ANSI_RESET}"
|
|
||||||
|
|
||||||
return (line + "\n").encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def generate_hex_line(seq: int, bytes_per_line: int = 32) -> bytes:
|
|
||||||
"""Generate a hex dump line (as text, mimicking hex view data)."""
|
|
||||||
data = bytes([(seq * bytes_per_line + i) % 256 for i in range(bytes_per_line)])
|
|
||||||
hex_part = " ".join(f"{b:02X}" for b in data)
|
|
||||||
ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in data)
|
|
||||||
return f"[{seq:06d}] {hex_part} |{ascii_part}|\n".encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Plot stress generator ───────────────────────────────────────────
|
|
||||||
|
|
||||||
def build_plot_frame(
|
|
||||||
sample_index: int,
|
|
||||||
channels: int,
|
|
||||||
plot_format: str,
|
|
||||||
samples_per_channel: int,
|
|
||||||
amplitude: float,
|
|
||||||
frequency_hz: float,
|
|
||||||
sample_rate_hz: float,
|
|
||||||
) -> bytes:
|
|
||||||
"""Build a raw little-endian f32 plot payload (no framing)."""
|
|
||||||
return pv_protocol.build_plot_payload(
|
|
||||||
sample_index=sample_index,
|
|
||||||
channels=channels,
|
|
||||||
plot_format=plot_format,
|
|
||||||
samples_per_channel=samples_per_channel,
|
|
||||||
amplitude=amplitude,
|
|
||||||
frequency_hz=frequency_hz,
|
|
||||||
sample_rate_hz=sample_rate_hz,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_mixed_frame(seq: int, text_rate_per_plot: int, channels: int) -> bytes:
|
|
||||||
"""Build a MixedTextPlot frame: text lines + one XP plot frame."""
|
|
||||||
frames = []
|
|
||||||
for i in range(text_rate_per_plot):
|
|
||||||
frames.append(generate_text_line(seq * text_rate_per_plot + i, ansi=True))
|
|
||||||
plot_payload = build_plot_frame(seq, channels, "interleaved", 32, 100.0, 10.0, 1000.0)
|
|
||||||
frames.append(
|
|
||||||
pv_protocol.build_mixed_plot_frame(
|
|
||||||
payload=plot_payload,
|
|
||||||
channels=channels,
|
|
||||||
samples_per_channel=32,
|
|
||||||
plot_format="interleaved",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return b"".join(frames)
|
|
||||||
|
|
||||||
|
|
||||||
# ── TCP Server ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class StressServer:
|
|
||||||
def __init__(self, host: str, port: int, mode: str, rate: float,
|
|
||||||
payload_size: int, channels: int, burst_size: int,
|
|
||||||
duration: float, ansi: bool):
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self.mode = mode
|
|
||||||
self.rate = rate # lines or frames per second
|
|
||||||
self.payload_size = payload_size
|
|
||||||
self.channels = channels
|
|
||||||
self.burst_size = burst_size
|
|
||||||
self.duration = duration
|
|
||||||
self.ansi = ansi
|
|
||||||
self.stats = defaultdict(int)
|
|
||||||
self.running = False
|
|
||||||
self.clients = []
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
self.running = True
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
sock.bind((self.host, self.port))
|
|
||||||
sock.listen(5)
|
|
||||||
sock.settimeout(1.0)
|
|
||||||
|
|
||||||
print(f"[STRESS] {self.mode.upper()} mode | {self.rate} lines/s")
|
|
||||||
print(f"[STRESS] Listening on {self.host}:{self.port}")
|
|
||||||
if self.duration > 0:
|
|
||||||
print(f"[STRESS] Duration: {self.duration}s")
|
|
||||||
print(f"[STRESS] Payload: {self.payload_size}B | Channels: {self.channels}")
|
|
||||||
if self.mode == "burst":
|
|
||||||
print(f"[STRESS] Burst size: {self.burst_size} lines")
|
|
||||||
print()
|
|
||||||
|
|
||||||
stats_thread = threading.Thread(target=self._print_stats, daemon=True)
|
|
||||||
stats_thread.start()
|
|
||||||
|
|
||||||
accept_thread = threading.Thread(target=self._accept_loop, args=(sock,), daemon=True)
|
|
||||||
accept_thread.start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
while self.running:
|
|
||||||
time.sleep(0.1)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n[STRESS] Shutting down...")
|
|
||||||
finally:
|
|
||||||
self.running = False
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
def _accept_loop(self, sock):
|
|
||||||
while self.running:
|
|
||||||
try:
|
|
||||||
conn, addr = sock.accept()
|
|
||||||
print(f"[STRESS] Client connected: {addr[0]}:{addr[1]}")
|
|
||||||
t = threading.Thread(target=self._client_loop, args=(conn, addr), daemon=True)
|
|
||||||
t.start()
|
|
||||||
self.clients.append(t)
|
|
||||||
except socket.timeout:
|
|
||||||
continue
|
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
|
|
||||||
def _client_loop(self, conn: socket.socket, addr):
|
|
||||||
seq = 0
|
|
||||||
start_time = time.time()
|
|
||||||
last_count_time = start_time
|
|
||||||
local_count = 0
|
|
||||||
|
|
||||||
# Pre-compute interval
|
|
||||||
if self.mode == "burst":
|
|
||||||
interval = 0
|
|
||||||
burst_interval = max(0.016, self.burst_size / max(self.rate, 1))
|
|
||||||
else:
|
|
||||||
interval = 1.0 / max(self.rate, 1) if self.rate > 0 else 0.016
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
||||||
while self.running:
|
|
||||||
elapsed = time.time() - start_time
|
|
||||||
if self.duration > 0 and elapsed >= self.duration:
|
|
||||||
break
|
|
||||||
|
|
||||||
if self.mode == "burst":
|
|
||||||
self._send_burst(conn, seq)
|
|
||||||
seq += self.burst_size
|
|
||||||
local_count += self.burst_size
|
|
||||||
time.sleep(burst_interval)
|
|
||||||
else:
|
|
||||||
data = self._generate(seq)
|
|
||||||
conn.sendall(data)
|
|
||||||
seq += 1
|
|
||||||
local_count += 1
|
|
||||||
|
|
||||||
if interval > 0:
|
|
||||||
# Spin-wait for precise timing at high rates
|
|
||||||
target = start_time + (seq / self.rate)
|
|
||||||
sleep_time = target - time.time()
|
|
||||||
if sleep_time > 0:
|
|
||||||
time.sleep(min(sleep_time, interval))
|
|
||||||
|
|
||||||
# Periodic stats update
|
|
||||||
if time.time() - last_count_time >= 1.0:
|
|
||||||
with threading.Lock():
|
|
||||||
self.stats["lines"] += local_count
|
|
||||||
self.stats["bytes"] += local_count * self.payload_size # approximate
|
|
||||||
local_count = 0
|
|
||||||
last_count_time = time.time()
|
|
||||||
|
|
||||||
except OSError as e:
|
|
||||||
if pv_protocol.is_disconnect_error(e):
|
|
||||||
pass
|
|
||||||
elif not self.running:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
self.stats["errors"] += 1
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
conn.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
print(f"[STRESS] Client disconnected: {addr[0]}:{addr[1]}")
|
|
||||||
|
|
||||||
def _send_burst(self, conn, start_seq):
|
|
||||||
"""Send burst_size lines as fast as possible."""
|
|
||||||
data = b"".join(self._generate(start_seq + i) for i in range(self.burst_size))
|
|
||||||
conn.sendall(data)
|
|
||||||
|
|
||||||
def _generate(self, seq: int) -> bytes:
|
|
||||||
if self.mode == "text":
|
|
||||||
return generate_text_line(seq, ansi=self.ansi, payload_size=self.payload_size)
|
|
||||||
elif self.mode == "hex":
|
|
||||||
return generate_hex_line(seq, bytes_per_line=max(4, self.payload_size // 3))
|
|
||||||
elif self.mode == "plot":
|
|
||||||
return build_plot_frame(
|
|
||||||
seq, self.channels, "interleaved",
|
|
||||||
samples_per_channel=32, amplitude=100.0,
|
|
||||||
frequency_hz=10.0, sample_rate_hz=1000.0,
|
|
||||||
)
|
|
||||||
elif self.mode == "mixed":
|
|
||||||
return build_mixed_frame(seq, text_rate_per_plot=5, channels=self.channels)
|
|
||||||
else:
|
|
||||||
return generate_text_line(seq, ansi=True, payload_size=self.payload_size)
|
|
||||||
|
|
||||||
def _print_stats(self):
|
|
||||||
while self.running:
|
|
||||||
time.sleep(2.0)
|
|
||||||
with threading.Lock():
|
|
||||||
lines = self.stats["lines"]
|
|
||||||
errors = self.stats["errors"]
|
|
||||||
b = self.stats["bytes"]
|
|
||||||
if lines > 0:
|
|
||||||
kb_s = b / 2048 # KB/s over 2 seconds
|
|
||||||
print(f"[STATS] {lines:>8d} lines | {kb_s:>8.1f} KB/s | {errors:>4d} errors")
|
|
||||||
self.stats.clear()
|
|
||||||
|
|
||||||
|
|
||||||
# ── CLI ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="pipeview GUI(egui)极限压力测试",
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog="""
|
|
||||||
示例:
|
|
||||||
# 文本洪水:5000 行/秒,1KB 行宽,ANSI 彩色
|
|
||||||
python tools/stress_test.py --mode text --rate 5000 --payload 1024 --ansi
|
|
||||||
|
|
||||||
# 波形洪水:200 帧/秒,8 通道
|
|
||||||
python tools/stress_test.py --mode plot --rate 200 --channels 8 --port 8092
|
|
||||||
|
|
||||||
# 突发模式:每秒爆发一次 2000 行峰值
|
|
||||||
python tools/stress_test.py --mode burst --rate 10000 --burst-size 2000
|
|
||||||
|
|
||||||
# 混合模式:文本 + 波形(MixedTextPlot 帧)
|
|
||||||
python tools/stress_test.py --mode mixed --rate 1000 --channels 4
|
|
||||||
|
|
||||||
# 压测 60 秒后自动停止
|
|
||||||
python tools/stress_test.py --mode text --rate 10000 --duration 60
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
parser.add_argument("--host", default="127.0.0.1", help="绑定地址 (default: 127.0.0.1)")
|
|
||||||
parser.add_argument("--port", type=int, default=8091, help="端口 (default: 8091)")
|
|
||||||
parser.add_argument(
|
|
||||||
"--mode", choices=["text", "hex", "plot", "mixed", "burst"],
|
|
||||||
default="text", help="测试模式 (default: text)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--rate", type=float, default=1000,
|
|
||||||
help="目标速率 (lines/s 或 frames/s, default: 1000)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--payload", type=int, default=256,
|
|
||||||
help="文本行/hex 行目标字节数 (default: 256)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--channels", type=int, default=4,
|
|
||||||
help="Plot 通道数 (default: 4)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--burst-size", type=int, default=500,
|
|
||||||
help="突发模式每次发送的行数 (default: 500)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--duration", type=float, default=0,
|
|
||||||
help="运行时长(秒),0 表示无限制 (default: 0)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--ansi", action="store_true",
|
|
||||||
help="文本模式启用 ANSI 颜色"
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.mode == "plot" and args.rate > 500:
|
|
||||||
print("[WARN] Plot rate > 500 may overwhelm the renderer. Consider --rate 100-200.")
|
|
||||||
print()
|
|
||||||
|
|
||||||
server = StressServer(
|
|
||||||
host=args.host,
|
|
||||||
port=args.port,
|
|
||||||
mode=args.mode,
|
|
||||||
rate=args.rate,
|
|
||||||
payload_size=args.payload,
|
|
||||||
channels=args.channels,
|
|
||||||
burst_size=args.burst_size,
|
|
||||||
duration=args.duration,
|
|
||||||
ansi=args.ansi,
|
|
||||||
)
|
|
||||||
server.start()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate ANSI-colored terminal output for testing pipeview ANSI rendering."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import math
|
|
||||||
import random
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import itertools
|
|
||||||
|
|
||||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
RESET = "\x1b[0m"
|
|
||||||
|
|
||||||
# Standard 3/4-bit foreground
|
|
||||||
FG = {
|
|
||||||
"black": "\x1b[30m", "red": "\x1b[31m", "green": "\x1b[32m",
|
|
||||||
"yellow": "\x1b[33m", "blue": "\x1b[34m", "magenta":"\x1b[35m",
|
|
||||||
"cyan": "\x1b[36m", "white": "\x1b[37m",
|
|
||||||
}
|
|
||||||
FG_BRIGHT = {
|
|
||||||
"black": "\x1b[90m", "red": "\x1b[91m", "green": "\x1b[92m",
|
|
||||||
"yellow": "\x1b[93m", "blue": "\x1b[94m", "magenta":"\x1b[95m",
|
|
||||||
"cyan": "\x1b[96m", "white": "\x1b[97m",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Standard 3/4-bit background
|
|
||||||
BG = {
|
|
||||||
"black": "\x1b[40m", "red": "\x1b[41m", "green": "\x1b[42m",
|
|
||||||
"yellow": "\x1b[43m", "blue": "\x1b[44m", "magenta":"\x1b[45m",
|
|
||||||
"cyan": "\x1b[46m", "white": "\x1b[47m",
|
|
||||||
}
|
|
||||||
BG_BRIGHT = {
|
|
||||||
"black": "\x1b[100m", "red": "\x1b[101m", "green": "\x1b[102m",
|
|
||||||
"yellow": "\x1b[103m", "blue": "\x1b[104m", "magenta":"\x1b[105m",
|
|
||||||
"cyan": "\x1b[106m", "white": "\x1b[107m",
|
|
||||||
}
|
|
||||||
|
|
||||||
BOLD = "\x1b[1m"
|
|
||||||
FAINT = "\x1b[2m"
|
|
||||||
ITALIC = "\x1b[3m"
|
|
||||||
UNDERLINE = "\x1b[4m"
|
|
||||||
STRIKE = "\x1b[9m"
|
|
||||||
|
|
||||||
|
|
||||||
def fg256(n: int) -> str:
|
|
||||||
return f"\x1b[38;5;{n}m"
|
|
||||||
|
|
||||||
def bg256(n: int) -> str:
|
|
||||||
return f"\x1b[48;5;{n}m"
|
|
||||||
|
|
||||||
def fg_rgb(r: int, g: int, b: int) -> str:
|
|
||||||
return f"\x1b[38;2;{r};{g};{b}m"
|
|
||||||
|
|
||||||
def bg_rgb(r: int, g: int, b: int) -> str:
|
|
||||||
return f"\x1b[48;2;{r};{g};{b}m"
|
|
||||||
|
|
||||||
|
|
||||||
# ── test scenes ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def scene_16color(delay: float):
|
|
||||||
"""Show all 8 standard + 8 bright foreground colors."""
|
|
||||||
lines = []
|
|
||||||
lines.append(f"{BOLD}─── Standard 16 Foreground Colors ───{RESET}")
|
|
||||||
for name in FG:
|
|
||||||
lines.append(f" {FG[name]}■ {name:>8}{RESET} "
|
|
||||||
f"{FG_BRIGHT[name]}■ bright {name}{RESET}")
|
|
||||||
for line in lines:
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_bg_colors(delay: float):
|
|
||||||
"""Show foreground colors on colored backgrounds."""
|
|
||||||
lines = []
|
|
||||||
lines.append(f"{BOLD}─── Background Colors ───{RESET}")
|
|
||||||
for bg_name in BG:
|
|
||||||
line = ""
|
|
||||||
for fg_name in ["white", "black"]:
|
|
||||||
line += f"{BG[bg_name]}{FG[fg_name]} {fg_name} on {bg_name} {RESET} "
|
|
||||||
lines.append(line)
|
|
||||||
for line in lines:
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_styles(delay: float):
|
|
||||||
"""Show bold, italic, underline, strikethrough."""
|
|
||||||
lines = []
|
|
||||||
lines.append(f"{BOLD}─── Text Styles ───{RESET}")
|
|
||||||
lines.append(f" {BOLD}Bold text{RESET}")
|
|
||||||
lines.append(f" {ITALIC}Italic text{RESET}")
|
|
||||||
lines.append(f" {UNDERLINE}Underlined text{RESET}")
|
|
||||||
lines.append(f" {STRIKE}Strikethrough text{RESET}")
|
|
||||||
lines.append(f" {BOLD}{ITALIC}Bold + Italic{RESET}")
|
|
||||||
lines.append(f" {BOLD}{FG['red']}Bold Red{RESET} vs {FG['red']}Normal Red{RESET}")
|
|
||||||
for line in lines:
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_256_ramp(delay: float):
|
|
||||||
"""Show a 256-color ramp."""
|
|
||||||
lines = []
|
|
||||||
lines.append(f"{BOLD}─── 256-Color Ramp ───{RESET}")
|
|
||||||
# 16 basic colors
|
|
||||||
line = "Basic: "
|
|
||||||
for n in range(16):
|
|
||||||
line += f"{fg256(n)}██{RESET}"
|
|
||||||
lines.append(line)
|
|
||||||
for row in range(3):
|
|
||||||
line = f"Cube {row+1}: "
|
|
||||||
for col in range(6):
|
|
||||||
n = 16 + row * 36 + col * 6
|
|
||||||
line += f"{fg256(n)}██{RESET}"
|
|
||||||
lines.append(line)
|
|
||||||
line = "Gray: "
|
|
||||||
for n in range(232, 256):
|
|
||||||
line += f"{fg256(n)}█{RESET}"
|
|
||||||
lines.append(line)
|
|
||||||
for line in lines:
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_truecolor(delay: float):
|
|
||||||
"""Show 24-bit truecolor gradient."""
|
|
||||||
lines = []
|
|
||||||
lines.append(f"{BOLD}─── Truecolor (24-bit) ───{RESET}")
|
|
||||||
line = "R→G: "
|
|
||||||
for i in range(32):
|
|
||||||
r = 255 - i * 8
|
|
||||||
g = i * 8
|
|
||||||
line += f"{fg_rgb(r, g, 0)}█{RESET}"
|
|
||||||
lines.append(line)
|
|
||||||
line = "B→Y: "
|
|
||||||
for i in range(32):
|
|
||||||
b = 255 - i * 8
|
|
||||||
r = g = i * 8
|
|
||||||
line += f"{fg_rgb(r, g, b)}█{RESET}"
|
|
||||||
lines.append(line)
|
|
||||||
line = "Rainbow: "
|
|
||||||
for i in range(48):
|
|
||||||
hue = i / 48.0 * 6.0
|
|
||||||
r, g, b = _hsv_to_rgb(hue, 1.0, 1.0)
|
|
||||||
line += f"{fg_rgb(r, g, b)}━{RESET}"
|
|
||||||
lines.append(line)
|
|
||||||
for line in lines:
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_status_log(delay: float):
|
|
||||||
"""Simulate systemd-style status output."""
|
|
||||||
lines = [
|
|
||||||
f"{FG_BRIGHT['green']}[ OK ]{RESET} Started {BOLD}System Logging Service{RESET}.",
|
|
||||||
f"{FG_BRIGHT['green']}[ OK ]{RESET} Started {BOLD}Network Manager{RESET}.",
|
|
||||||
f"{FG_BRIGHT['green']}[ OK ]{RESET} Reached target {ITALIC}multi-user.target{RESET}.",
|
|
||||||
f"{FG_BRIGHT['yellow']}[ WARN ]{RESET} Failed to load kernel module {UNDERLINE}nvidia{RESET}.",
|
|
||||||
f"{FG_BRIGHT['red']}[ FAIL ]{RESET} {FG['red']}{BOLD}ssh.service{RESET}{FG['red']} failed to start.{RESET}",
|
|
||||||
f"{FG_BRIGHT['cyan']}[ INFO ]{RESET} Listening on {fg256(33)}0.0.0.0:8080{RESET}.",
|
|
||||||
f" {ITALIC}─ subject=CN=example.com{RESET}",
|
|
||||||
f" {ITALIC}─ fingerprint={FG['yellow']}SHA256:abcd1234{RESET}",
|
|
||||||
f"{FG_BRIGHT['green']}[ OK ]{RESET} Mounted {BG['blue']}{FG['white']} /var {RESET} filesystem.",
|
|
||||||
f"{FG_BRIGHT['magenta']}[STATUS]{RESET} CPU: {fg256(46)}32%{RESET} "
|
|
||||||
f"Mem: {fg256(220)}1.2G{RESET}/{fg256(33)}4.0G{RESET} "
|
|
||||||
f"Temp: {_temp_color(58)}58°C{RESET}",
|
|
||||||
]
|
|
||||||
for line in lines:
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_colored_log_stream(delay: float):
|
|
||||||
"""Continuously generate log lines cycling through themes."""
|
|
||||||
themes = [
|
|
||||||
('green', 'INFO ', "Connection accepted from 192.168.1.100"),
|
|
||||||
('cyan', 'DEBUG', "Processing frame #{}"),
|
|
||||||
('yellow', 'WARN ', "Buffer usage at {:.0f}%"),
|
|
||||||
('red', 'ERROR', "CRC mismatch on packet {}"),
|
|
||||||
('white', 'TRACE', "Entering function handle_request()"),
|
|
||||||
('magenta','AUDIT', "User admin performed action {}"),
|
|
||||||
]
|
|
||||||
counter = itertools.count(1)
|
|
||||||
while True:
|
|
||||||
color_name, level, template = random.choice(themes)
|
|
||||||
n = next(counter)
|
|
||||||
msg = template.format(n, random.uniform(60, 95), n)
|
|
||||||
ts = time.strftime("%H:%M:%S")
|
|
||||||
line = (f"{ITALIC}{ts}{RESET} "
|
|
||||||
f"{FG_BRIGHT[color_name]}{BOLD}[{level}]{RESET} "
|
|
||||||
f"{msg}")
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_rainbow_wave(delay: float):
|
|
||||||
"""Animated rainbow wave (for live testing)."""
|
|
||||||
t0 = time.time()
|
|
||||||
while True:
|
|
||||||
t = time.time() - t0
|
|
||||||
line = ""
|
|
||||||
for x in range(60):
|
|
||||||
hue = (x / 60.0 + t * 0.3) % 1.0
|
|
||||||
r, g, b = _hsv_to_rgb(hue * 6.0, 1.0, 0.8 + 0.2 * math.sin(t * 2 + x * 0.3))
|
|
||||||
line += f"{fg_rgb(r, g, b)}━{RESET}"
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_system_boot(delay: float):
|
|
||||||
"""Simulate a system boot sequence with progress."""
|
|
||||||
services = [
|
|
||||||
("udev", "Kernel Device Manager", 0.6),
|
|
||||||
("systemd-journald","Journal Service", 0.8),
|
|
||||||
("NetworkManager", "Network Manager", 0.7),
|
|
||||||
("sshd", "OpenSSH Daemon", 0.5),
|
|
||||||
("nginx", "HTTP Server", 0.9),
|
|
||||||
("postgresql", "PostgreSQL 16", 1.2),
|
|
||||||
("docker", "Docker Engine", 1.5),
|
|
||||||
("pipeview", "Pipe Data Monitor", 0.3),
|
|
||||||
]
|
|
||||||
t0 = time.time()
|
|
||||||
for name, desc, startup_time in services:
|
|
||||||
yield (f"{FG_BRIGHT['cyan']}[ .... ]{RESET} Starting {BOLD}{name}{RESET} - {desc}...",
|
|
||||||
startup_time * 0.3)
|
|
||||||
yield (f"{FG_BRIGHT['green']}[ OK ]{RESET} Started {BOLD}{name}{RESET} - {desc}.",
|
|
||||||
delay)
|
|
||||||
yield (f"\n{FG_BRIGHT['green']}{BOLD}Boot complete.{RESET} "
|
|
||||||
f"({time.time() - t0:.1f}s)", delay)
|
|
||||||
|
|
||||||
|
|
||||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]:
|
|
||||||
"""HSV → RGB, h in [0, 6), s,v in [0, 1]."""
|
|
||||||
c = v * s
|
|
||||||
x = c * (1 - abs((h % 2) - 1))
|
|
||||||
m = v - c
|
|
||||||
r, g, b = {
|
|
||||||
0: (c, x, 0), 1: (x, c, 0), 2: (0, c, x),
|
|
||||||
3: (0, x, c), 4: (x, 0, c), 5: (c, 0, x),
|
|
||||||
}[int(h) % 6]
|
|
||||||
return int((r + m) * 255), int((g + m) * 255), int((b + m) * 255)
|
|
||||||
|
|
||||||
|
|
||||||
def _temp_color(temp: float) -> str:
|
|
||||||
if temp < 50: return fg256(46)
|
|
||||||
elif temp < 70: return fg256(220)
|
|
||||||
else: return fg256(196)
|
|
||||||
|
|
||||||
|
|
||||||
# ── scenes registry ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
STATIC_SCENES = [
|
|
||||||
("16color", scene_16color, "Standard 16 foreground colors"),
|
|
||||||
("bg", scene_bg_colors, "Background colors"),
|
|
||||||
("styles", scene_styles, "Bold, italic, underline, strikethrough"),
|
|
||||||
("256ramp", scene_256_ramp, "256-color ramp"),
|
|
||||||
("truecolor", scene_truecolor, "24-bit truecolor gradients"),
|
|
||||||
("status", scene_status_log, "systemd-style status log"),
|
|
||||||
("boot", scene_system_boot, "System boot sequence"),
|
|
||||||
]
|
|
||||||
|
|
||||||
LIVE_SCENES = [
|
|
||||||
("logstream", scene_colored_log_stream, "Continuous colored log stream"),
|
|
||||||
("rainbow", scene_rainbow_wave, "Animated rainbow wave"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ── main ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="ANSI color test data generator for pipeview",
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog="Scenes:\n" + "\n".join(
|
|
||||||
f" {n:<14} {d}" for n, _, d in STATIC_SCENES + LIVE_SCENES
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument("--host", default="127.0.0.1", help="TCP bind host")
|
|
||||||
parser.add_argument("--port", type=int, default=8099, help="TCP port")
|
|
||||||
parser.add_argument("--scene", choices=[n for n, _, _ in STATIC_SCENES + LIVE_SCENES] + ["all"],
|
|
||||||
default="all", help="Scene to play")
|
|
||||||
parser.add_argument("--delay", type=float, default=0.15, help="Inter-line delay (seconds)")
|
|
||||||
parser.add_argument("--loop", action="store_true", help="Repeat the scene forever")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
scenes = STATIC_SCENES + LIVE_SCENES
|
|
||||||
if args.scene != "all":
|
|
||||||
scenes = [(n, f, d) for n, f, d in scenes if n == args.scene]
|
|
||||||
|
|
||||||
print(f"ANSI color test → {args.host}:{args.port}")
|
|
||||||
print(f"Scene: {args.scene}, delay: {args.delay}s, loop: {args.loop}")
|
|
||||||
|
|
||||||
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}")
|
|
||||||
try:
|
|
||||||
_play_scenes(sock, scenes, args.delay, args.loop)
|
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
|
||||||
pass
|
|
||||||
print("Client disconnected. Waiting for new connection...")
|
|
||||||
|
|
||||||
|
|
||||||
def _play_scenes(sock: socket.socket, scenes, delay: float, loop_scenes: bool):
|
|
||||||
first = True
|
|
||||||
while first or loop_scenes:
|
|
||||||
first = False
|
|
||||||
for name, scene_fn, _desc in scenes:
|
|
||||||
print(f" [{name}]")
|
|
||||||
for line, line_delay in scene_fn(delay):
|
|
||||||
sock.sendall((line + "\n").encode())
|
|
||||||
time.sleep(line_delay)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Generate fake drone telemetry data for testing pipeview Lua decoder."""
|
"""Generate fake drone telemetry data for testing xserial Lua decoder."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import math
|
import math
|
||||||
|
|||||||
@@ -1,17 +1,122 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""TCP server that sends plot waveform frames or text lines for pipeview GUI."""
|
"""TCP server that sends plot waveform frames or text lines for xserial GUI."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import math
|
import math
|
||||||
import socket
|
import socket
|
||||||
|
import struct
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pv_protocol
|
PLOT_ESCAPE = 0x1E
|
||||||
|
PLOT_MARKER = ord("P")
|
||||||
|
DISCONNECT_WINERRORS = {
|
||||||
|
10053, # Software caused connection abort
|
||||||
|
10054, # Connection reset by peer
|
||||||
|
10058, # Socket shutdown race on Windows
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_frame(
|
||||||
|
sample_index: int,
|
||||||
|
channels: int,
|
||||||
|
plot_format: str,
|
||||||
|
samples_per_channel: int,
|
||||||
|
amplitude: float,
|
||||||
|
frequency_hz: float,
|
||||||
|
sample_rate_hz: float,
|
||||||
|
) -> bytes:
|
||||||
|
values = []
|
||||||
|
if plot_format == "xy":
|
||||||
|
for offset in range(samples_per_channel):
|
||||||
|
t = (sample_index + offset) / sample_rate_hz
|
||||||
|
x = amplitude * math.cos(2 * math.pi * frequency_hz * t)
|
||||||
|
y = amplitude * math.sin(2 * math.pi * frequency_hz * t)
|
||||||
|
values.extend([x, y])
|
||||||
|
return struct.pack(f"<{len(values)}f", *values)
|
||||||
|
|
||||||
|
for offset in range(samples_per_channel):
|
||||||
|
t = (sample_index + offset) / sample_rate_hz
|
||||||
|
for ch in range(channels):
|
||||||
|
phase = 2 * math.pi * ch / max(channels, 1)
|
||||||
|
value = amplitude * math.sin(2 * math.pi * frequency_hz * t + phase)
|
||||||
|
values.append(value)
|
||||||
|
return struct.pack(f"<{len(values)}f", *values)
|
||||||
|
|
||||||
|
|
||||||
|
def cobs_encode(payload: bytes) -> bytes:
|
||||||
|
if not payload:
|
||||||
|
return b"\x01"
|
||||||
|
|
||||||
|
out = bytearray([0])
|
||||||
|
code_index = 0
|
||||||
|
code = 1
|
||||||
|
for byte in payload:
|
||||||
|
if byte == 0:
|
||||||
|
out[code_index] = code
|
||||||
|
code_index = len(out)
|
||||||
|
out.append(0)
|
||||||
|
code = 1
|
||||||
|
else:
|
||||||
|
out.append(byte)
|
||||||
|
code += 1
|
||||||
|
if code == 0xFF:
|
||||||
|
out[code_index] = code
|
||||||
|
code_index = len(out)
|
||||||
|
out.append(0)
|
||||||
|
code = 1
|
||||||
|
out[code_index] = code
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def build_plot_packet(
|
||||||
|
payload: bytes,
|
||||||
|
channels: int,
|
||||||
|
samples_per_channel: int,
|
||||||
|
plot_format: str,
|
||||||
|
) -> bytes:
|
||||||
|
format_id = {"interleaved": 0, "block": 1, "xy": 2}[plot_format]
|
||||||
|
header = bytearray()
|
||||||
|
header.extend(b"XP")
|
||||||
|
header.append(1) # version
|
||||||
|
header.append(format_id)
|
||||||
|
header.append(8) # f32
|
||||||
|
header.append(0) # little-endian
|
||||||
|
header.append(channels)
|
||||||
|
header.extend(struct.pack("<H", samples_per_channel))
|
||||||
|
header.extend(struct.pack("<I", len(payload)))
|
||||||
|
header.extend(payload)
|
||||||
|
return bytes(header)
|
||||||
|
|
||||||
|
|
||||||
|
def build_mixed_plot_frame(
|
||||||
|
payload: bytes,
|
||||||
|
channels: int,
|
||||||
|
samples_per_channel: int,
|
||||||
|
plot_format: str,
|
||||||
|
) -> bytes:
|
||||||
|
packet = build_plot_packet(
|
||||||
|
payload=payload,
|
||||||
|
channels=channels,
|
||||||
|
samples_per_channel=samples_per_channel,
|
||||||
|
plot_format=plot_format,
|
||||||
|
)
|
||||||
|
return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00"
|
||||||
|
|
||||||
|
|
||||||
|
def is_disconnect_error(err: OSError) -> bool:
|
||||||
|
return isinstance(
|
||||||
|
err,
|
||||||
|
(
|
||||||
|
BrokenPipeError,
|
||||||
|
ConnectionAbortedError,
|
||||||
|
ConnectionResetError,
|
||||||
|
),
|
||||||
|
) or getattr(err, "winerror", None) in DISCONNECT_WINERRORS
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="pipeview plot test server")
|
parser = argparse.ArgumentParser(description="xserial plot test server")
|
||||||
parser.add_argument("--host", default="127.0.0.1")
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
parser.add_argument("--port", type=int, default=8080)
|
parser.add_argument("--port", type=int, default=8080)
|
||||||
parser.add_argument("--channels", type=int, default=1)
|
parser.add_argument("--channels", type=int, default=1)
|
||||||
@@ -112,8 +217,8 @@ def main() -> None:
|
|||||||
else:
|
else:
|
||||||
print(" framer = Line")
|
print(" framer = Line")
|
||||||
print(" decoder = Text")
|
print(" decoder = Text")
|
||||||
print(" lua test = Lua framer crates/pipeview-client/tests/fixtures/lua_line_framer.lua")
|
print(" lua test = Lua framer tests/lua_line_framer.lua")
|
||||||
print(" Lua decoder crates/pipeview-client/tests/fixtures/lua_text_decoder.lua\n")
|
print(" Lua decoder tests/lua_text_decoder.lua\n")
|
||||||
if args.wire_format == "text":
|
if args.wire_format == "text":
|
||||||
print(f"sending text only at {args.text_interval:.3f}s intervals")
|
print(f"sending text only at {args.text_interval:.3f}s intervals")
|
||||||
print(f"sample clock: {args.rate:.1f} samples/sec\n")
|
print(f"sample clock: {args.rate:.1f} samples/sec\n")
|
||||||
@@ -163,7 +268,7 @@ def main() -> None:
|
|||||||
next_text_at += args.text_interval
|
next_text_at += args.text_interval
|
||||||
continue
|
continue
|
||||||
|
|
||||||
plot_payload = pv_protocol.build_plot_payload(
|
plot_payload = build_frame(
|
||||||
sample_index=sample_index,
|
sample_index=sample_index,
|
||||||
channels=args.channels,
|
channels=args.channels,
|
||||||
plot_format=args.format,
|
plot_format=args.format,
|
||||||
@@ -173,7 +278,7 @@ def main() -> None:
|
|||||||
sample_rate_hz=args.rate,
|
sample_rate_hz=args.rate,
|
||||||
)
|
)
|
||||||
if args.wire_format == "mixed":
|
if args.wire_format == "mixed":
|
||||||
frame = pv_protocol.build_mixed_plot_frame(
|
frame = build_mixed_plot_frame(
|
||||||
payload=plot_payload,
|
payload=plot_payload,
|
||||||
channels=args.channels,
|
channels=args.channels,
|
||||||
samples_per_channel=samples_per_channel,
|
samples_per_channel=samples_per_channel,
|
||||||
@@ -203,7 +308,7 @@ def main() -> None:
|
|||||||
if wait > 0:
|
if wait > 0:
|
||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
if not pv_protocol.is_disconnect_error(err):
|
if not is_disconnect_error(err):
|
||||||
raise
|
raise
|
||||||
if args.wire_format == "text":
|
if args.wire_format == "text":
|
||||||
print(f"[conn -] {addr} ({text_count} text lines)")
|
print(f"[conn -] {addr} ({text_count} text lines)")
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ int main(int argc, char **argv) {
|
|||||||
double frame_interval = (double)args.samples_per_channel / args.rate;
|
double frame_interval = (double)args.samples_per_channel / args.rate;
|
||||||
double frame_rate = args.rate / (double)args.samples_per_channel;
|
double frame_rate = args.rate / (double)args.samples_per_channel;
|
||||||
|
|
||||||
printf("═══ pipeview Serial Plot Generator ═══\n");
|
printf("═══ xserial Serial Plot Generator ═══\n");
|
||||||
printf("串口: %s @ %d baud\n", args.port, args.baudrate);
|
printf("串口: %s @ %d baud\n", args.port, args.baudrate);
|
||||||
printf("通道: %u 格式: %s\n", args.channels, format_name);
|
printf("通道: %u 格式: %s\n", args.channels, format_name);
|
||||||
printf("频率: %.1f Hz 振幅: %.1f\n", args.freq, args.amp);
|
printf("频率: %.1f Hz 振幅: %.1f\n", args.freq, args.amp);
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ int main(int argc, char **argv) {
|
|||||||
|
|
||||||
double interval = 1.0 / args.rate;
|
double interval = 1.0 / args.rate;
|
||||||
|
|
||||||
printf("═══ pipeview Plain Text Generator ═══\n");
|
printf("═══ xserial Plain Text Generator ═══\n");
|
||||||
printf("串口: %s @ %d baud\n", args.port, args.baudrate);
|
printf("串口: %s @ %d baud\n", args.port, args.baudrate);
|
||||||
printf("通道: %u 振幅: %.1f 频率: %.1f Hz\n", args.channels, args.amp, args.freq);
|
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("速率: %.0f lines/sec (间隔 %.3f ms)\n", args.rate, interval * 1000.0);
|
||||||
|
|||||||
Reference in New Issue
Block a user