From c26d2cdbaa33f17040e429f2ab6f64745186d21a Mon Sep 17 00:00:00 2001 From: hulk Date: Sun, 27 Sep 2026 18:52:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20SerialMate=20?= =?UTF-8?q?=E4=B8=B2=E5=8F=A3=E8=B0=83=E8=AF=95=E5=8A=A9=E6=89=8B=20v0.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 协议帧构成令牌序列可定制(包头/板ID/LEN口径/5种CRC x 3种覆盖), 默认兼容 DLPilot 光机协议 --- .gitignore | 8 + README.md | 18 +- SerialMate/AGENTS.md | 227 +++++ SerialMate/README.md | 130 +++ SerialMate/assets/make_icon.py | 35 + SerialMate/assets/serial-mate.ico | Bin 0 -> 51008 bytes SerialMate/assets/serial-mate.png | Bin 0 -> 12440 bytes SerialMate/release/README.md | 130 +++ SerialMate/src/build_exe.py | 108 +++ SerialMate/src/config.py | 147 +++ SerialMate/src/link.py | 215 +++++ SerialMate/src/main.py | 1458 +++++++++++++++++++++++++++++ SerialMate/src/package_release.py | 61 ++ SerialMate/src/plot.py | 405 ++++++++ SerialMate/src/protocol.py | 450 +++++++++ SerialMate/src/publish_gitea.py | 157 ++++ SerialMate/src/selftest.py | 228 +++++ SerialMate/src/selftest_gui.py | 223 +++++ SerialMate/src/selftest_loop.py | 91 ++ SerialMate/src/theme.py | 129 +++ agent.md | 47 +- 21 files changed, 4258 insertions(+), 9 deletions(-) create mode 100644 SerialMate/AGENTS.md create mode 100644 SerialMate/README.md create mode 100644 SerialMate/assets/make_icon.py create mode 100644 SerialMate/assets/serial-mate.ico create mode 100644 SerialMate/assets/serial-mate.png create mode 100644 SerialMate/release/README.md create mode 100644 SerialMate/src/build_exe.py create mode 100644 SerialMate/src/config.py create mode 100644 SerialMate/src/link.py create mode 100644 SerialMate/src/main.py create mode 100644 SerialMate/src/package_release.py create mode 100644 SerialMate/src/plot.py create mode 100644 SerialMate/src/protocol.py create mode 100644 SerialMate/src/publish_gitea.py create mode 100644 SerialMate/src/selftest.py create mode 100644 SerialMate/src/selftest_gui.py create mode 100644 SerialMate/src/selftest_loop.py create mode 100644 SerialMate/src/theme.py diff --git a/.gitignore b/.gitignore index 11a98d3..4aa6006 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,17 @@ dist/ # 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release DLPilot/release/*.zip EDID-viewer/release/*.zip +SerialMate/release/*.zip # 源码模式运行时的配置写入(config.BASE_DIR=src/), 与 release/ 下入库的预置栏位区分 DLPilot/src/pattern_slots.json +# SerialMate 无预置栏位, 运行态配置(最近串口/通道等)一律不入库 +SerialMate/src/serial_mate.json +SerialMate/**/serial_assistant.json +SerialMate/release/serial_mate.json +# 板型档案(board_profiles.json)是用户本机数据, 首次运行自动生成默认值 +SerialMate/src/board_profiles.json +SerialMate/release/board_profiles.json # ZCode 客户端本地会话数据(计划留档等), 非项目配置 .zcode/ diff --git a/README.md b/README.md index 9fe1b84..f0f2765 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工 |---|---|---| | [DLPilot](DLPilot/) | HDMI 源端(EDID 查看/逐字节解析、分辨率切换、纯色与测试图案全屏投屏)+ 光机控制(RS485→GD32 网关→DLPC3421:开关光机/设光强/查温度电流) | 到 [Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) 下 `DLPilot-vX.Y.Z-win64.zip`,解压即用;详见 [DLPilot/README.md](DLPilot/README.md) | | [EDID-viewer](EDID-viewer/) | Windows 显示器 EDID 只读查看器:活动设备或离线文件的厂家/序列号/分辨率时序、CTA/HDMI/音频/HDR 能力、校验诊断;BIN/HEX 导入,BIN/JSON 报告导出 | 到 [Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) 下 `EDID-Viewer-vX.Y.Z-win64.zip`,解压即用;详见 [EDID-viewer/README.md](EDID-viewer/README.md) | +| [SerialMate](SerialMate/) | RS485/串口调试助手:收发 + 协议包解析(帧构成令牌序列/包头/板ID/LEN口径/CRC 全可定制,默认兼容 DLPilot 光机协议)、板型档案一键切换协议参数与命令预设、payload 字符串/HEX 显示、4 通道曲线(弹窗独立、时间窗滑笔回看、悬停读值、暂停渲染)、发送框输入 payload 自动包装组帧(`/` 分隔多条)、HEX 直发与周期发送 | 到 [Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) 下 `SerialMate-vX.Y.Z-win64.zip`,解压即用;详见 [SerialMate/README.md](SerialMate/README.md) | | [I2C-Inject](I2C-Inject/) | ⏸ **暂停开发**:LT8619C 寄存器在线读写(RP2040 注入器在 demo 板 I2C 总线上择机注入,避让主控轮询,写后自动读回校验)。代码与固件保留备查 | 暂不提供发布;状态与原理详见 [I2C-Inject/README.md](I2C-Inject/README.md) | ## 环境要求 @@ -17,8 +18,10 @@ LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工 - 源码运行:Python ≥3.10;DLPilot 命令行/EDID 纯标准库,GUI 需 `py -m pip install PySide6-Fluent-Widgets`(自定义图片 JPG/BMP 可选 Pillow、光机控制可选 pyserial); EDID-viewer GUI 需 `py -m pip install -r EDID-viewer/requirements.txt`(PySide6 + PySide6-Fluent-Widgets); + SerialMate GUI 需 `py -m pip install PySide6 PySide6-Fluent-Widgets pyserial`; I2C-Inject 需 `py -m pip install pyserial` -- 重新打包:`DLPilot/src/build_exe.py` 与 `EDID-viewer/src/build_exe.py`(均自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py` +- 重新打包:`DLPilot/src/build_exe.py`、`EDID-viewer/src/build_exe.py` 与 + `SerialMate/src/build_exe.py`(均自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py` ## 目录结构 @@ -40,25 +43,30 @@ LT8619C_Debug_Tools/ │ ├── release/ # EDID-Viewer.exe + README(入库);发布 zip(不入库) │ ├── README.md # 使用说明 │ └── AGENTS.md # 工程档案 +├── SerialMate/ +│ ├── src/ # 协议/串口线程/曲线控件/GUI + 自检 + 打包/发布脚本 +│ ├── release/ # SerialMate.exe + README(入库);发布 zip(不入库) +│ ├── README.md # 使用说明 +│ └── AGENTS.md # 工程档案 ├── I2C-Inject/ # PC 端 GUI + RP2040 固件 + 打包 exe + 图标 + README ├── LICENSE └── README.md ``` -## 发布流程(DLPilot / EDID-Viewer 同一套流水线) +## 发布流程(DLPilot / EDID-Viewer / SerialMate 同一套流水线) ```powershell -cd DLPilot/src # 或 EDID-viewer/src +cd DLPilot/src # 或 EDID-viewer/src、SerialMate/src python build_exe.py # 打包 → ../release/<工具>.exe python package_release.py <版本> # → ../release/<工具>-v<版本>-win64.zip python publish_gitea.py <工具>-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN) ``` -每个工具独立打标签发布(`DLPilot-v*` / `EDID-Viewer-v*`),Release 列表按时间倒序混排于同一页面。 +每个工具独立打标签发布(`DLPilot-v*` / `EDID-Viewer-v*` / `SerialMate-v*`),Release 列表按时间倒序混排于同一页面。 ## 备注 - 源码与打包产物(exe、pattern_slots.json)均入库;发布 zip 不入库,只上传 Gitea Release - 两件工具的寄存器语义(0x606E 位交换 / 0x606D 整组顺序)均经 demo 板逻辑分析仪 双色交叉实测,过程数据与规程存于上游主仓库 `doc/architecture/` -- 更新:2026-09-25 +- 更新:2026-09-27 diff --git a/SerialMate/AGENTS.md b/SerialMate/AGENTS.md new file mode 100644 index 0000000..650eced --- /dev/null +++ b/SerialMate/AGENTS.md @@ -0,0 +1,227 @@ +# AGENTS.md — SerialMate 工程档案(开发约束与进度) + +> 人类文档 [README.md](README.md) 回答"这是什么、怎么用";本文记录"代码怎么组织、 +> 怎么构建发布、改动时哪些约束不能破坏",兼作本工具的进度记录。 +> 修改本目录代码前先读完本文。仓库整体的开发需求与进度管理见根目录 `agent.md`。 + +## 1. 项目定位 + +仓库 `LT8619C_Debug_Tools` 是 LT8619C→DLPC3421 投影转接板的调试上位机工具集。 +SerialMate 是其中的**通用串口/RS485 调试助手**:收发 + 协议解析 + 曲线。 +默认帧格式与 DLPilot 光机 RS485 协议完全一致(`D5 01 LEN CRC`), +可直接调试板载 GD32E230 网关;帧格式(包头/板ID/CRC 算法与覆盖范围)可定制, +适配任意"包头+板ID+LEN(=payload长度)+payload+CRC"五段式协议。 +协议语义权威同 DLPilot:上游固件仓 `protocols.md`,本仓不维护副本。 + +## 2. 目录契约 + +``` +SerialMate/ +├── src/ 源代码区 —— 只有这里可以手改 +├── release/ 产物区 —— SerialMate.exe + README.md(入库), +│ *-win64.zip(gitignore,只上传 Release) +├── assets/ 图标等生成资产(暂无;build_exe.py 已预留 assets/serial-mate.ico) +├── README.md 人类文档 +└── AGENTS.md 本文 +``` + +### 文件角色速查 + +| 文件 | 角色 | +|---|---| +| `src/main.py` | Fluent GUI 入口(含 `--seconds N` 冒烟参数),argparse 仅此一处 | +| `src/protocol.py` | 协议模块(纯标准库):FrameLayout 帧构成令牌、ProtoConfig 组帧/校验、FrameParser 增量解析、CRC 算法表、extract_value 数值提取 | +| `src/link.py` | 串口工作线程 SerialLink(pyserial 只准在这里 import),支持 `loop://` 虚拟回环 | +| `src/plot.py` | 滚动曲线控件(纯 QPainter,刻意不引 matplotlib/pyqtgraph) | +| `src/config.py` | BASE_DIR 定位与 serial_mate.json / board_profiles.json 读写(缺键补默认,读容错) | +| `src/theme.py` | 主题三件套同步(Fluent setTheme + QPalette/QSS + DWM 标题栏),与 EDID-Viewer 同源 | +| `src/selftest.py` | 协议自检(无依赖、无硬件,改 protocol.py 后必跑) | +| `src/selftest_loop.py` | 链路自检(loop:// 回环验证 组帧→发送→接收→解析→提取,缺 pyserial 跳过) | +| `src/selftest_gui.py` | GUI 功能自检(offscreen 驱动事件处理函数:预览/回显/提取/配置往返,改 main.py 后建议重跑) | +| `src/build_exe.py` | PyInstaller onefile 打包 → `release/SerialMate.exe` | +| `src/package_release.py` | exe+README → `release/SerialMate-v<版本>-win64.zip` | +| `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(`GITEA_TOKEN`,需 `--insecure`) | + +## 3. 代码不变量(改代码必须维持) + +1. **依赖分层**:protocol.py / config.py 纯标准库;pyserial 只准在 link.py + import(main.py 经 `SerialLink` 间接使用);plot.py 只依赖 PySide6; + GUI 硬依赖 PySide6-Fluent-Widgets + pyserial(串口工具缺 pyserial 即无意义, + 与 DLPilot 的"缺失降级"策略不同,只保留 try-import 提示)。 +1a. **卡片布局约定**:`_card(side=True)` 把短副标题放标题右侧省一行(串口/ + 协议/发送卡);右列是纵向 QSplitter(接收/发送高度用户可拖拽,拉伸余量 + 默认给接收、发送卡保持自然高度防空腔);发送卡末尾 `lay.addStretch(1)` + 保证内容贴顶(卡片被拖高后富余高度收在卡底,勿删——否则拉伸被各行均分, + 标题上方出现空隙);发送卡行序:板型管理行(下拉+新建+删除+配置+重载) / + 预设行(下拉+填入+直接发送) / 模式行(下拉+存为板型+存入预设) / 输入行 / + 周期行;帧日志 payload 高亮走 `_append_rx_frame`(html.escape 后拼 span + 背景,toPlainText 内容不变,selftest 的日志断言因此不受影响);预设下拉 + 显示"名称 · 命令",存入命令走 `_add_command_named`(弹窗核 `_add_command` + 包装,测试用核函数),预设直发走 `_send_preset_direct`(经输入框复用 + 组帧/历史链路)。 +1b. **窗口最小高度**:首帧布局后 `_apply_minimum_size` 按内容实际最小高度 + 抬高窗口下限(高度拉过矮时布局约束无法满足,曲线卡绘图区会被压塌并与 + 通道行互相叠压——用户实测);勿删, 调整曲线卡行数/高度后该值自动跟随。 +2. **回调线程边界**:SerialLink 的 on_status/on_frame/on_raw/on_tx 全在后台线程 + 触发,一律 `events.put(...)` 投递,主线程 30ms QTimer 轮询 `_drain_events` + 分发(与 DLPilot `_poll_dlpc` 同模式),禁止回调里直接碰 GUI。 +3. **半双工节奏**:link 工作线程每个循环周期最多出队写一帧(20ms 间隔), + RS485 总线多帧连发留应答间隙,勿改成批量 drain 一次写。 +4. **帧构成语义**:帧由 `FrameLayout` 令牌序列定义(`HDR ID LEN PAYLOAD CRC` + 令牌 + 十六进制字面量,收发共用布局、仅包头值分方向);约束:LEN/PAYLOAD/CRC + 恰好各一次、CRC 必须最后、LEN 前至少一个 HDR 或字面量(同步定界)、ID 最多一次。 + LEN 口径(`LEN_SEMS`):payload 长度 / 到帧尾(payload+CRC) / 整帧; + CRC 算法/覆盖范围见 protocol.CRC_ALGOS/CRC_SCOPES,覆盖按**帧内位置**定义 + (with_header=第1字节起 / no_header=跳过第1字节 / payload_only=仅载荷), + 任意布局通用;2 字节 CRC 线上字节序 MODBUS 低前、其余高前,按算法约定单序 + 校验(改动需同步 selftest 向量)。 +5. **解析重同步策略**:前缀令牌逐位对齐(HDR/字面量须匹配、ID 通配),LEN 值经 + 口径换算为 payload 长度,负值视为伪帧头逐出;CRC 失配丢 1 字节重同步—— + 通用工具不预设合法 LEN 区间(与 DLPilot 的 LEN 范围预判不同)。 +6. **ID 位不校验只透出**:`Frame.code` 原样上抛(布局无 ID 时为 None,GUI 日志 + 与 selftest 均需兼容 None),TYPE_NAMES 仅用于显示译码(DLPilot 语义)。 +7. **BASE_DIR 语义**(config.py):frozen 时 =exe 所在目录,源码运行时 =src/; + 设置平铺存 `serial_mate.json`,不建子目录。`tx.periodic` 不随配置 + 自动启动(防误连发),其余配置启动恢复。 +8. **曲线数据流**:每帧对每个启用通道调 `extract_value`(失败静默跳过), + `PlotWidget.add` 内部 deque(maxlen=3000) 滚动丢弃;数值格式统一 + `fmt_val`(4 位有效数字)。 +9. **主题**:theme.py 三件套同步(见 EDID-Viewer 教训:只 setTheme 会黑块); + plot 曲线颜色固定 4 色,文字/网格色取自 QPalette 自动跟随明暗。 +9a. **曲线时间窗**:PlotWidget 持有 `_span`(窗宽秒, None=跟随全部) 与 + `_offset`(右端距最新秒数, 0=实时);横轴域换算在 `visible_domain`(纯计算, + selftest 有断言)。**窗口不向数据收敛**(用户实测反馈):选 5min 而数据只有 + 23s 时显示完整 5min 轴、曲线贴右端随采集填满(示波器式),右下角标注 + "已采集 Xs / 窗口 Ns";滞后超出全部历史 = 空窗保宽。弹窗内 时间窗下拉+ + 滑条 只改这两个状态;滑条 0..1000 线性映射 (1000-v)/1000×maxpan,实时 + 跟随=拉到最右(1000);poll 定时器 `_sync_time_slider` 回写位置(用户拖动中 + 跳过);嵌回卡片时强制重置为跟随全部(卡内无滑条, 防困在滞后窗里)。纵轴 + 数值刻度画在每条横向参考线上。 + 悬停读值:mouseTracking 开启, mouseMove 只记录 `_hover_pos` 并请求重绘; + 读值内容由 paintEvent **每帧**经 `_hover_lines_at(_hover_pos)` 重算(光标 + 时间 T 经 `_plot_geometry` 换算, `_nearest` 二分取 ±10px 容差内最近采样), + 画在顶部居中反色小框(`_hover_box`)——鼠标停住不动时数值也随实时数据持续 + 刷新(勿改回 mouseMove 里一次性算好/或 QToolTip, 那样不动鼠标就不更新—— + 用户实测反馈)。`_nearest`/`_hover_lines_at` 有单测。绘图区内边距统一用 + `M_LEFT/TOP/RIGHT/BOTTOM` 类常量(绘制与鼠标换算共用, 勿在 paint 里另写)。 +9b. **暂停(两级语义)**:主界面"暂停"= `_paused`, add() 丢弃暂停期间的数据; + 弹窗"暂停渲染"= `_frozen`, 数据照常入库但跳过重绘——冻结是**绝对状态**: + PlotWidget 覆写 `update()` 直接吞掉一切重绘请求(任何代码路径都无法在 + 冻结期打破), 只有 `set_frozen(False)` 恢复并一次性渲染积累;冻结时画面 + 中央显示"已冻结渲染(采集继续)"角标让状态可见。两者互不干扰, 均有 + selftest 断言。 +9c. **弹窗曲线交互**:自动Y(勾选按钮, 与主界面 chk_auto_y 双向同步, 关闭时 + 以 `auto_yrange()` 初始化手动量程);图上左键拖动 = 平移手动 Y 量程 + (`on_yrange_changed` 回调同步主界面 Ymin/Ymax 输入框, 阻断信号防回环); + 滚轮 = 缩放时间窗 2~900s(滞后按比例缩放, 经 `on_span_changed` 回调让 + `_sync_time_slider` 把时间窗下拉同步为匹配档位/自定义(-1));"实时"按钮 + = offset 归零跳回最新。自动 Y 量程抽取为 `auto_yrange()`(纯计算, 有单测)。 +11a. **曲线弹出窗口**:绘图区在曲线卡留有固定槽位布局 `_plot_slot`,弹出 = + `setParent(None)` 后装入顶层窗口(通道/暂停/Y 轴控件留在主界面控制同一 + PlotWidget 实例);关掉弹出窗口(eventFilter 捕 Close)或主窗 closeEvent + 都会走 `_dock_plot()` 嵌回 —— `_dock_plot` 必须先摘 `self._plot_win` 引用 + 再 `win.close()`(防 Close 递归),且 `setParent(None)` 后必须补 `show()` + (重挂布局不会自动恢复可见)。 +10. **多实例**:有意不加单实例锁(双口双开的合法场景),串口冲突靠 + pyserial 报错提示("串口被占用"文案在 link.py,勿删)。 +11. **板型档案**:`board_profiles.json`(BASE_DIR 平铺,用户本机数据不入库, + 首次运行自动生成默认"DLPilot光机")。档案 = name + proto(7 个帧协议字段, + **不含 parse** —— 解析开关是查看偏好) + commands[[名,命令]..];切换板型 + 应用协议参数并装载命令表,"存入"命令以命令文本为名去重;启动恢复选中 + 板型时**只装载命令表不重复应用协议参数**(协议以上次会话 store 为准); + GUI 增删后经 config.save_profiles 落盘,"配置/重载"支持手改 JSON。 + +## 4. 构建与发布(全部在 src/ 下执行) + +```powershell +python -m compileall -q . # 全模块可编译 +python selftest.py # 协议回归(47 项, 改 protocol.py 后必跑) +python selftest_loop.py # 链路回环(需 pyserial, 改 link.py 后必跑) +python selftest_gui.py # GUI 功能回归(offscreen, 改 main.py 后建议重跑) +python main.py --seconds 5 # GUI 冒烟(源码); offscreen: set QT_QPA_PLATFORM=offscreen +python build_exe.py # → ../release/SerialMate.exe(缺 PyInstaller 自动装) +python package_release.py <版本> # → ../release/SerialMate-v<版本>-win64.zip +python publish_gitea.py ../release/SerialMate-v<版本>-win64.zip --replace --insecure +``` + +发布约定与 Gitea 实例怪癖(TLS 抽风加 `--insecure`、assets 两种形状兼容、 +token 读 `GITEA_TOKEN` 用户级环境变量)同 DLPilot,详见 `DLPilot/AGENTS.md` §5; +tag/release 命名 `SerialMate-vX.Y.Z`,zip 命名 +`SerialMate-vX.Y.Z-win64.zip`(publish_gitea.py 从文件名推 tag)。 + +## 5. 已知坑(本工具踩过/继承的) + +- `qfluentwidgets.CaptionLabel` 无 `setDim` 方法(本机版本),灰字用 + `CaptionLabel` 本身即可;`ComboBox` 不要用 `currentData`(qfluentwidgets + 自绘下拉 userData 支持不稳),一律 currentIndex + 平行元组映射。 +- **qfluentwidgets 控件默认水平 Expanding**:紧凑行不设 stretch 项时空白会被 + CheckBox/Label 等瓜分出大空隙 —— 一律走 `Window._row()`(行尾默认补 + stretch;Qt 规则:有 stretch 因子时额外空间只按因子分配),需要自管拉伸的 + 行传 `trailing=False`。 +- **qfluentwidgets SpinBox/DoubleSpinBox 设固定宽后内部行编辑器会塌缩** + (minimumSizeHint 142/193 只是参考,强设小于它的宽度时行编辑器只剩几像素, + 数值被裁成残字——整数 0 在 96px 时渲染成 "]"):整数 SpinBox ≥110px、 + DoubleSpinBox ≥130px 才能完整显示数值;曲线卡偏移/序号/倍率/Y 量程框的 + 宽度有 selftest 断言把守, 调布局时勿低于阈值。 +- 组帧/解析回路自测时必须让 `tx_hdr == rx_hdr`(收发方向包头本就允许不同, + D5 组的帧 B5 解析器看不见——selftest 的 fuzz/回路用例已按此约定)。 +- offscreen 冒烟报 `QFontDatabase: Cannot find font directory` 属 Qt offscreen + 平台噪音,桌面运行无此问题,勿当 bug 修。 +- onefile 运行时定位 exe 用 `sys.executable`(`__file__` 在 `_MEIPASS`), + config.py 已按此实现。 + +## 6. 验证清单(改完代码后) + +1. `python -m compileall -q .` —— 全模块可编译; +2. `python selftest.py` —— 协议回归(改 protocol.py 后必跑); +3. `python selftest_loop.py` —— 链路回环(改 link.py 后必跑); +4. `python main.py --seconds 5` —— GUI 冒烟自动开关; +5. `python build_exe.py` → 双击 release/SerialMate.exe 确认界面渲染; +6. (有硬件时)USB-RS485 接光机发 M1/M201/M731 验证真机收发与曲线。 + +## 7. 进度记录 + +### v0.1.0(2026-09-27,首版) + +已完成: +- 协议模块:**帧构成令牌序列可定制**(`HDR ID LEN PAYLOAD CRC` 令牌 + 十六进制 + 字面量同步字节,收发共用布局,支持字段换序/无板ID/多字节同步头;LEN 口径三种: + payload 长度 / 到帧尾 / 整帧)、5 种 CRC 算法 × 3 种覆盖范围(按帧内位置定义, + 任意布局通用)、增量解析(前缀逐位对齐 + CRC 失败逐字节重同步)、 + 二进制/文本/正则三种曲线数值提取;自测 64 项全 PASS(含 DLPilot 实测帧向量、 + CRC 标准校验值 check("123456789")、布局换序/字面量/LEN 口径向量与布局模糊往返)。 +- 串口链路:参数热切换、发送队列(半双工友好 + 未连接丢弃)、解析/透传双模式 + 热切、`loop://` 虚拟回环自检 13 项全 PASS(组帧→发送→接收→解析→曲线提取)。 +- GUI(PySide6-Fluent-Widgets):串口/协议/曲线/接收/发送五卡,帧构成与 LEN 口径 + 可视化编辑(非法输入即时提示),payload 字符串/HEX 切换(帧日志的 payload + 段垫半透明底色高亮——主题无关 rgba, 明暗切换不残留, 经 appendHtml 实现)、 + 原始帧 hex、时间戳、日志保存;发送:自动包装(`/` 分隔多条)、HEX 直发、 + 周期发送、组帧实时预览、历史回溯(↑↓)、回车发送;主题跟随系统。 +- 曲线(plot.py + 弹窗,2026-09-27 实机反馈多轮迭代):4 通道三种提取方式 + + 倍率,自动/手动 Y 量程;绘图区可**弹出独立窗口**(嵌回自动重置视图); + 纵轴每条参考线标数值刻度;时间窗(跟随/10s~15min, 示波器式不向数据收敛, + 右下角"已采集/窗口"标注)+ 横向滑条回看历史;悬停读值框固定顶部、鼠标 + 不动也随数据持续刷新;**暂停渲染**(冻结显示继续收数, 与主界面"暂停" + 两级语义, 冻结有中央角标);自动Y 开关、图上拖动自定义 Y 轴(同步主界面 + 输入框)、滚轮缩放时间窗(2~900s)、实时按钮;窗口最小高度按内容自适应 + (防高度拉小时通道行叠压绘图区)。 +- 板型档案:发送卡"板型"下拉一键切换协议参数 + 命令预设;**新建板型**(协议 + 沿用当前、命令预设空白,防遗留其他板子的命令)/存为板型(同名覆盖)/ + **删除板型**(确认弹窗,至少保留一个,删除后切第一个板型且协议参数不动)/ + 存入预设命令(弹窗自定义显示名称)/配置=内嵌表格编辑器(**CommandTable**: + 第 0 列 ≡ 手柄按住上下拖动整行排序——内部 QDrag,move_row 整行搬移; + 网格线/隔行底色按 palette 明暗自适配,空行清晰可见;+加行 −删行;空命令与 + 重复命令保存时忽略/去重,写回 board_profiles.json)/重载;预设下拉 + "名称 · 命令"同显。 +- 发布流水线:build_exe/package_release/publish_gitea 与全仓一致;exe 已重建 + 入库,zip/Release 未发布(待用户验收)。 + +待办 / 计划: +- 【无图标】assets/serial-mate.ico 未生成(build_exe.py 已预留路径, + 参照 DLPilot logos/make_ico.py 流程补)。 +- 【真机已过】用户已实机联调(COM15 真机收发/曲线提取/多板型档案实际使用, + 2026-09-27 多轮界面反馈即来自真机数据);温度曲线量纲(倍率取值)待用户 + 按传感器手册确认。 +- 【计划】发送区多条 payload 的"批处理脚本"化(延时/循环/条件等待应答)。 +- 【计划】曲线数据导出 CSV;接收日志按日期自动分文件。 +- 【考虑】CRC 解析侧双字节序兼容开关(当前按算法约定单序)。 diff --git a/SerialMate/README.md b/SerialMate/README.md new file mode 100644 index 0000000..42802de --- /dev/null +++ b/SerialMate/README.md @@ -0,0 +1,130 @@ +# SerialMate — RS485/串口调试助手 + +> LT8619C 调试工具集配套工具:串口收发 + 协议包解析 + 曲线绘制。 +> 帧格式(包头/板ID/LEN/CRC)全可定制,默认与 [DLPilot](../DLPilot) 光机 +> RS485 协议一致,可用来直接调试板载 GD32E230 网关或任意同构协议设备。 + +## 功能 + +- **串口收发**:端口自动枚举、波特率 1200~921600、数据位/校验/停止位可选, + 参数热切换;半双工 RS485 友好(每周期最多写一帧,留应答间隙)。 +- **协议解析**(可关,关=原始透传): + 增量字节流定界 + CRC 校验,失败逐字节重同步,噪声自动丢弃; + 接收帧 ID 位按 DLPilot 语义译码显示(0xF0=成功、0xFD=参数越界…)。 +- **帧构成定制**(协议卡,发送/接收共用同一布局,仅包头值分方向): + 帧构成用**令牌序列**定义,默认 `HDR ID LEN PAYLOAD CRC`(即 DLPilot 五段式): + + | 令牌 | 含义 | + |---|---| + | `HDR` | 包头(值取 TX包头 / RX包头 字段) | + | `ID` | 板ID(RX 侧多为响应 TYPE 码,不校验仅透出显示) | + | `LEN` | 长度字节,口径可选:**payload 长度(默认)** / 到帧尾(payload+CRC) / 整帧 | + | `PAYLOAD` | 协议载荷(发送框内容,别名 DATA) | + | `CRC` | 校验(算法/覆盖范围见下表,必须在末尾) | + | 十六进制字面量 | 固定字节,如 `AA 55` 双字节同步头 | + + 示例:`AA 55 LEN PAYLOAD CRC`(AA55 同步头、无板ID)· + `HDR LEN PAYLOAD ID CRC`(板ID 在载荷后)· `HDR LEN ID PAYLOAD CRC`(板ID 在长度后)。 + 约束:LEN/PAYLOAD/CRC 恰好各一次,LEN 前至少一个 HDR 或字面量作同步头。 + + | 项 | 说明 | 默认 | + |---|---|---| + | TX 包头 | HDR 令牌在命令帧的值 | `D5` | + | RX 包头 | HDR 令牌在响应帧的值 | `B5` | + | 板ID | ID 令牌的值(TX 用;RX 侧仅透出) | `01` | + | LEN 口径 | payload 长度 / 到帧尾(payload+CRC) / 整帧 | payload 长度 | + | CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 | + | CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 | +- **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex; + 时间戳;日志保存为 txt。 +- **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式 + (二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率, + y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮, + 关掉窗口即嵌回),通道配置与暂停等控件留在主界面照常控制。弹出窗口内: + 纵轴每条参考线标注数值刻度;**时间窗**可选宽度(跟随/10s/30s/1min/5min/ + 15min),配合横向滑条回看历史总体趋势或缩小窗口细看局部走势,拉到最右 + 恢复实时跟随(冻结查看请配合"暂停");窗口宽度不向数据收敛——数据不足时 + 显示完整的所选时间轴、曲线贴右端随采集逐渐填满(示波器式),右下角标注 + "已采集 Xs / 窗口 Ns";**鼠标悬停**在曲线上,绘图区顶部的小读数框持续显示 + 光标处时间点 T 与各通道在该时刻的 Y 轴数值(±10px 容差取最近采样); + 弹出窗口工具栏:**暂停渲染**(只冻结画面方便查看,采集继续入库,点"继续 + 渲染"后把积累的数据一次性画出来;画面中央会显示"已冻结渲染"角标;注意 + 与主界面"暂停"的区别:那个会丢弃暂停期间的数据)、**自动Y**(开/关自动 + 纵轴,关闭后可在图上按住左键上下拖动自定义 Y 轴范围)、**实时**(取消 + 滞后跳回最新)、**滚轮**在图上缩放时间窗(2s~15min)。 + 实例:回包 payload `01 AE 75 6D`(前 2 字节固定、第 3~4 字节是 16 位大端 + 采样值 0x7444~0x7696)→ 勾选通道1,方式=二进制,偏移=2,格式=u16 大端, + 倍率按传感器量纲填(如 0.01);最新值显示在通道行尾并进入曲线。 + 注意偏移是 payload 内的字节序号(0=第 1 字节),选错位宽/偏移会把多个字段 + 拼成一个数(如 u32@偏移0 会把整包读成 28,210,029);数值显示为千分位普通 + 小数,≥10 亿才用科学计数。 +- **板型档案**:不同板子的"协议参数 + 命令预设表"存为命名档案,保存在 exe + 同目录的 `board_profiles.json`。发送卡选中板型即一键套用其协议参数 + (包头/板ID/帧构成/LEN口径/CRC)并切换命令预设;**新建板型**按当前协议参数 + 建档、命令预设从空白开始;**存为板型**把当前配置存成档案(同名覆盖); + **删除板型**带确认(至少保留一个);"存入预设"把当前输入的命令加入当前 + 板型(可自定义显示名称);**配置**打开表格编辑器(名称/命令两列,+加行 + −删行,保存写回档案);预设下拉以"名称 · 命令"同显。 +- **发送**: + - **自动包装**:输入协议载荷(如 `M731S0T1000`)自动组帧加包头/板ID/LEN/CRC; + 多条用 `/` 分隔依次发送(如 `M1/M731S0T1000`); + - **HEX 直发**:输入完整帧 hex 原样发出; + - 周期发送(100ms~60s)、输入框实时组帧预览、预设命令一键填入、 + 命令历史 ↑↓ 回溯、回车即发送。 + +## 运行 + +- 直接运行 `release/SerialMate.exe`(免安装,Windows 10/11 x64); +- 或源码运行:`py -3 -m pip install PySide6 PySide6-Fluent-Widgets pyserial`, + 然后 `cd src && python main.py`。 + +## 界面速览 + +``` +┌ SerialMate ─────────────────────────────────────────────────┐ +│ 串口卡 协议卡 │ 接收卡(payload 字符串/HEX) │ +│ COMx 115200 8N1 D5/B5/01 │ ← 12:00:00 B=0xF0 成功 LEN=2 ok │ +│ 曲线卡(4通道) │ ┌───────── 滚动曲线 ─────────┐ │ +│ ☑ DMD温度 文本数字 第1个 ×0.1 │ └────────────────────────────┘ │ +│ │ 发送卡: [M1/M731S0T1000] 发送 │ +└────────────────────────────────┴──────────────────────────────────┘ +``` + +## 典型用法:调光机 RS485 + +1. 串口卡选 USB-RS485 适配器,115200 8N1,连接; +2. 发送框输入 `M1` 回车——自动包装为 `D5 01 02 4D 31 81` 发出, + 接收区回 `ok`(协议卡默认即 DLPilot 协议); +3. 查温度:发 `M201`,回包 `okTL:246` 译码显示"成功",曲线卡勾选通道 + 设为"文本数字·第1个·×0.1",周期发送勾上,即可看温度曲线; +4. 换任何自定义协议:改协议卡的**帧构成**(字段顺序/有无板ID/字面量同步头)、 + 包头/板ID/LEN 口径/CRC 即可,收发解析同步生效;调好后点"存为板型", + 以后一键切换。 + +## 目录 + +``` +SerialMate/ +├── src/ 源代码(protocol 协议 / link 串口线程 / main GUI / selftest 自检) +├── assets/ serial-mate.ico 图标(assets/make_icon.py 生成) +├── release/ SerialMate.exe + README(发布文件夹,zip 只上传 Release) +└── README.md 本文 +``` + +运行时用户数据与 exe 同目录平铺:`serial_mate.json`(串口/协议/通道记忆)、 +`board_profiles.json`(板型档案,首次运行自动生成默认"DLPilot光机")。 + +## 开发 / 发布 + +```powershell +cd src +python selftest.py # 协议回归(无依赖, 改 protocol.py 后必跑) +python selftest_loop.py # 链路回环自检(loop:// 虚拟口, 需 pyserial) +python main.py # 源码运行 +python build_exe.py # → ../release/SerialMate.exe +python package_release.py <版本> # → release/SerialMate-v<版本>-win64.zip +python publish_gitea.py ../release/SerialMate-v<版本>-win64.zip --replace --insecure +``` + +工程约束与进度见 [AGENTS.md](AGENTS.md);协议语义唯一权威是上游固件仓 +`protocols.md`(本仓 DLPilot 目录有引用说明)。 diff --git a/SerialMate/assets/make_icon.py b/SerialMate/assets/make_icon.py new file mode 100644 index 0000000..18a2302 --- /dev/null +++ b/SerialMate/assets/make_icon.py @@ -0,0 +1,35 @@ +"""生成 SerialMate 图标(Pillow, 开发时手动运行): 蓝底白色波形 + RS485 字样""" +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +out = Path(__file__).resolve().parent +size = 512 +img = Image.new('RGBA', (size, size), (0, 0, 0, 0)) +d = ImageDraw.Draw(img) +d.rounded_rectangle((24, 24, 488, 488), radius=96, fill='#0f6cbd') +d.rounded_rectangle((24, 24, 488, 488), radius=96, outline='#5ab0f2', width=10) + +# 示波器式白色波形(锯齿+衰减循环, 手绘点位) +wave = [(56, 268)] +segs = ((46, -96), (40, 84), (52, -52), (38, 66), (60, -120), (44, 78), + (50, -40), (42, 56), (56, -88), (44, 60)) +x, y = 56, 268 +for half, dy in segs: + d.line((x, y, x + half // 2, y + dy), fill='white', width=20) + d.line((x + half // 2, y + dy, x + half, y), fill='white', width=20) + x, y = x + half, y +d.ellipse((x - 10, y - 10, x + 10, y + 10), fill='white') + +# 顶部 TX/RX 指示点 +for cx, color in ((88, '#ffd166'), (146, '#7ee081')): + d.ellipse((cx - 17, 84 - 17, cx + 17, 84 + 17), fill=color) + +font = 'C:/Windows/Fonts/arialbd.ttf' +d.text((256, 410), 'RS485', font=ImageFont.truetype(font, 72), + anchor='mm', fill='white') +img.save(out / 'serial-mate.png') +img.save(out / 'serial-mate.ico', + sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), + (128, 128), (256, 256)]) +print('icon ->', out / 'serial-mate.ico') diff --git a/SerialMate/assets/serial-mate.ico b/SerialMate/assets/serial-mate.ico new file mode 100644 index 0000000000000000000000000000000000000000..2927067a03d18e7fa716fa5a7c6650abfce501eb GIT binary patch literal 51008 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 z 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 Jq+tH?e*k9Hk!=6~ literal 0 HcmV?d00001 diff --git a/SerialMate/release/README.md b/SerialMate/release/README.md new file mode 100644 index 0000000..42802de --- /dev/null +++ b/SerialMate/release/README.md @@ -0,0 +1,130 @@ +# SerialMate — RS485/串口调试助手 + +> LT8619C 调试工具集配套工具:串口收发 + 协议包解析 + 曲线绘制。 +> 帧格式(包头/板ID/LEN/CRC)全可定制,默认与 [DLPilot](../DLPilot) 光机 +> RS485 协议一致,可用来直接调试板载 GD32E230 网关或任意同构协议设备。 + +## 功能 + +- **串口收发**:端口自动枚举、波特率 1200~921600、数据位/校验/停止位可选, + 参数热切换;半双工 RS485 友好(每周期最多写一帧,留应答间隙)。 +- **协议解析**(可关,关=原始透传): + 增量字节流定界 + CRC 校验,失败逐字节重同步,噪声自动丢弃; + 接收帧 ID 位按 DLPilot 语义译码显示(0xF0=成功、0xFD=参数越界…)。 +- **帧构成定制**(协议卡,发送/接收共用同一布局,仅包头值分方向): + 帧构成用**令牌序列**定义,默认 `HDR ID LEN PAYLOAD CRC`(即 DLPilot 五段式): + + | 令牌 | 含义 | + |---|---| + | `HDR` | 包头(值取 TX包头 / RX包头 字段) | + | `ID` | 板ID(RX 侧多为响应 TYPE 码,不校验仅透出显示) | + | `LEN` | 长度字节,口径可选:**payload 长度(默认)** / 到帧尾(payload+CRC) / 整帧 | + | `PAYLOAD` | 协议载荷(发送框内容,别名 DATA) | + | `CRC` | 校验(算法/覆盖范围见下表,必须在末尾) | + | 十六进制字面量 | 固定字节,如 `AA 55` 双字节同步头 | + + 示例:`AA 55 LEN PAYLOAD CRC`(AA55 同步头、无板ID)· + `HDR LEN PAYLOAD ID CRC`(板ID 在载荷后)· `HDR LEN ID PAYLOAD CRC`(板ID 在长度后)。 + 约束:LEN/PAYLOAD/CRC 恰好各一次,LEN 前至少一个 HDR 或字面量作同步头。 + + | 项 | 说明 | 默认 | + |---|---|---| + | TX 包头 | HDR 令牌在命令帧的值 | `D5` | + | RX 包头 | HDR 令牌在响应帧的值 | `B5` | + | 板ID | ID 令牌的值(TX 用;RX 侧仅透出) | `01` | + | LEN 口径 | payload 长度 / 到帧尾(payload+CRC) / 整帧 | payload 长度 | + | CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 | + | CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 | +- **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex; + 时间戳;日志保存为 txt。 +- **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式 + (二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率, + y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮, + 关掉窗口即嵌回),通道配置与暂停等控件留在主界面照常控制。弹出窗口内: + 纵轴每条参考线标注数值刻度;**时间窗**可选宽度(跟随/10s/30s/1min/5min/ + 15min),配合横向滑条回看历史总体趋势或缩小窗口细看局部走势,拉到最右 + 恢复实时跟随(冻结查看请配合"暂停");窗口宽度不向数据收敛——数据不足时 + 显示完整的所选时间轴、曲线贴右端随采集逐渐填满(示波器式),右下角标注 + "已采集 Xs / 窗口 Ns";**鼠标悬停**在曲线上,绘图区顶部的小读数框持续显示 + 光标处时间点 T 与各通道在该时刻的 Y 轴数值(±10px 容差取最近采样); + 弹出窗口工具栏:**暂停渲染**(只冻结画面方便查看,采集继续入库,点"继续 + 渲染"后把积累的数据一次性画出来;画面中央会显示"已冻结渲染"角标;注意 + 与主界面"暂停"的区别:那个会丢弃暂停期间的数据)、**自动Y**(开/关自动 + 纵轴,关闭后可在图上按住左键上下拖动自定义 Y 轴范围)、**实时**(取消 + 滞后跳回最新)、**滚轮**在图上缩放时间窗(2s~15min)。 + 实例:回包 payload `01 AE 75 6D`(前 2 字节固定、第 3~4 字节是 16 位大端 + 采样值 0x7444~0x7696)→ 勾选通道1,方式=二进制,偏移=2,格式=u16 大端, + 倍率按传感器量纲填(如 0.01);最新值显示在通道行尾并进入曲线。 + 注意偏移是 payload 内的字节序号(0=第 1 字节),选错位宽/偏移会把多个字段 + 拼成一个数(如 u32@偏移0 会把整包读成 28,210,029);数值显示为千分位普通 + 小数,≥10 亿才用科学计数。 +- **板型档案**:不同板子的"协议参数 + 命令预设表"存为命名档案,保存在 exe + 同目录的 `board_profiles.json`。发送卡选中板型即一键套用其协议参数 + (包头/板ID/帧构成/LEN口径/CRC)并切换命令预设;**新建板型**按当前协议参数 + 建档、命令预设从空白开始;**存为板型**把当前配置存成档案(同名覆盖); + **删除板型**带确认(至少保留一个);"存入预设"把当前输入的命令加入当前 + 板型(可自定义显示名称);**配置**打开表格编辑器(名称/命令两列,+加行 + −删行,保存写回档案);预设下拉以"名称 · 命令"同显。 +- **发送**: + - **自动包装**:输入协议载荷(如 `M731S0T1000`)自动组帧加包头/板ID/LEN/CRC; + 多条用 `/` 分隔依次发送(如 `M1/M731S0T1000`); + - **HEX 直发**:输入完整帧 hex 原样发出; + - 周期发送(100ms~60s)、输入框实时组帧预览、预设命令一键填入、 + 命令历史 ↑↓ 回溯、回车即发送。 + +## 运行 + +- 直接运行 `release/SerialMate.exe`(免安装,Windows 10/11 x64); +- 或源码运行:`py -3 -m pip install PySide6 PySide6-Fluent-Widgets pyserial`, + 然后 `cd src && python main.py`。 + +## 界面速览 + +``` +┌ SerialMate ─────────────────────────────────────────────────┐ +│ 串口卡 协议卡 │ 接收卡(payload 字符串/HEX) │ +│ COMx 115200 8N1 D5/B5/01 │ ← 12:00:00 B=0xF0 成功 LEN=2 ok │ +│ 曲线卡(4通道) │ ┌───────── 滚动曲线 ─────────┐ │ +│ ☑ DMD温度 文本数字 第1个 ×0.1 │ └────────────────────────────┘ │ +│ │ 发送卡: [M1/M731S0T1000] 发送 │ +└────────────────────────────────┴──────────────────────────────────┘ +``` + +## 典型用法:调光机 RS485 + +1. 串口卡选 USB-RS485 适配器,115200 8N1,连接; +2. 发送框输入 `M1` 回车——自动包装为 `D5 01 02 4D 31 81` 发出, + 接收区回 `ok`(协议卡默认即 DLPilot 协议); +3. 查温度:发 `M201`,回包 `okTL:246` 译码显示"成功",曲线卡勾选通道 + 设为"文本数字·第1个·×0.1",周期发送勾上,即可看温度曲线; +4. 换任何自定义协议:改协议卡的**帧构成**(字段顺序/有无板ID/字面量同步头)、 + 包头/板ID/LEN 口径/CRC 即可,收发解析同步生效;调好后点"存为板型", + 以后一键切换。 + +## 目录 + +``` +SerialMate/ +├── src/ 源代码(protocol 协议 / link 串口线程 / main GUI / selftest 自检) +├── assets/ serial-mate.ico 图标(assets/make_icon.py 生成) +├── release/ SerialMate.exe + README(发布文件夹,zip 只上传 Release) +└── README.md 本文 +``` + +运行时用户数据与 exe 同目录平铺:`serial_mate.json`(串口/协议/通道记忆)、 +`board_profiles.json`(板型档案,首次运行自动生成默认"DLPilot光机")。 + +## 开发 / 发布 + +```powershell +cd src +python selftest.py # 协议回归(无依赖, 改 protocol.py 后必跑) +python selftest_loop.py # 链路回环自检(loop:// 虚拟口, 需 pyserial) +python main.py # 源码运行 +python build_exe.py # → ../release/SerialMate.exe +python package_release.py <版本> # → release/SerialMate-v<版本>-win64.zip +python publish_gitea.py ../release/SerialMate-v<版本>-win64.zip --replace --insecure +``` + +工程约束与进度见 [AGENTS.md](AGENTS.md);协议语义唯一权威是上游固件仓 +`protocols.md`(本仓 DLPilot 目录有引用说明)。 diff --git a/SerialMate/src/build_exe.py b/SerialMate/src/build_exe.py new file mode 100644 index 0000000..453ff09 --- /dev/null +++ b/SerialMate/src/build_exe.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +把 src/main.py 打包成单文件 exe(工具名: SerialMate) +================================================ +与 EDID-Viewer/src/build_exe.py 同一套路: onefile + --noconsole, Python 解释器、 +PySide6 和 PySide6-Fluent-Widgets 全部打进一个 exe, 目标机器无需安装依赖。 + +用法: + python build_exe.py 自动安装 PyInstaller(如缺)并打包 + python build_exe.py --debug 额外打一个带控制台的变体 + +产物: release/SerialMate.exe +spec 由本脚本生成到 src/build/(不入库), 调整打包逻辑直接改这里的模板。 +""" +import os +import subprocess +import sys + +# 本脚本位于 src/, 打包产物输出到工具根的 release/ +SRC = os.path.dirname(os.path.abspath(__file__)) +BASE = os.path.dirname(SRC) +RELEASE = os.path.join(BASE, 'release') +WORK = os.path.join(SRC, 'build') +ICO = os.path.join(BASE, 'assets', 'serial-mate.ico') + +SPEC_TEMPLATE = """\ +# -*- coding: utf-8 -*- +# 由 build_exe.py 生成, 不要手改; 调整打包逻辑请改 src/build_exe.py。 +from PyInstaller.utils.hooks import collect_all + +qfluent_datas, qfluent_binaries, qfluent_hidden = collect_all('qfluentwidgets') +a = Analysis( + [r'{entry}'], pathex=[r'{src}'], binaries=qfluent_binaries, + datas=qfluent_datas + {icon_datas}, hiddenimports=qfluent_hidden, + noarchive=False) +# Qt 6.11 使用 Windows 非版本化 ICU 导出; PATH 上 Poppler/Conda 的版本化 +# ICU(icuuc.dll/icudt78.dll)会被 PyInstaller 误收, 令 QtCore.pyd 启动报 +# WinError 127 —— 打包时剔除, 运行时用系统自带 ICU。 +a.binaries = [b for b in a.binaries if b[0].lower() not in ('icuuc.dll', 'icudt78.dll')] +pyz = PYZ(a.pure) +exe = EXE(pyz, a.scripts, a.binaries, a.datas, [], name='{name}', + debug=False, strip=False, upx=False, console={console}{icon_clause}) +""" + + +def ensure_pyinstaller(): + try: + import PyInstaller # noqa: F401 + return + except ImportError: + pass + print('PyInstaller 未安装, 正在安装...') + subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller']) + + +def remove_stale(exe_path): + """删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试""" + if not os.path.exists(exe_path): + return + try: + os.remove(exe_path) + return + except PermissionError: + pass + import time + print(f'{exe_path} 被占用, 结束残留进程后重试...') + subprocess.run(['taskkill', '/F', '/IM', os.path.basename(exe_path)], + capture_output=True) + time.sleep(1.0) + os.remove(exe_path) + + +def build(name, console): + remove_stale(os.path.join(RELEASE, name + '.exe')) + if os.path.exists(ICO): + icon_datas = f"[(r'{ICO}', 'assets')]" + icon_clause = f", icon=r'{ICO}'" + else: + print('警告: 未找到 assets/serial-mate.ico, 本次打包不带图标') + icon_datas, icon_clause = '[]', '' + spec = SPEC_TEMPLATE.format(entry=os.path.join(SRC, 'main.py'), src=SRC, + icon_datas=icon_datas, name=name, + console=console, icon_clause=icon_clause) + os.makedirs(WORK, exist_ok=True) + spec_path = os.path.join(WORK, name + '.spec') + with open(spec_path, 'w', encoding='utf-8') as f: + f.write(spec) + cmd = [sys.executable, '-m', 'PyInstaller', '--noconfirm', '--clean', + '--distpath', RELEASE, '--workpath', WORK, spec_path] + print('\n>>', ' '.join(cmd)) + subprocess.check_call(cmd) + out = os.path.join(RELEASE, name + '.exe') + print(f'完成: {out} ({os.path.getsize(out) / 1024 / 1024:.1f} MB)') + + +def main(): + build_console = '--debug' in sys.argv + ensure_pyinstaller() + # 常规版: 双击即用, 无黑框 + build('SerialMate', 'False') + if build_console: + # 调试版: 带控制台, 异常栈可见 + build('SerialMate-console', 'True') + + +if __name__ == '__main__': + main() diff --git a/SerialMate/src/config.py b/SerialMate/src/config.py new file mode 100644 index 0000000..1b94438 --- /dev/null +++ b/SerialMate/src/config.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +运行目录定位与设置持久化(BASE_DIR 语义与 DLPilot config.py 一致) +================================================================== +frozen(PyInstaller onefile)时 BASE_DIR=exe 所在目录(sys.executable), +源码运行时=config.py 所在目录(即 src/)。设置写/读自 BASE_DIR 平铺的 +serial_mate.json, 不建子目录。 + +读容错: 文件缺失/损坏/缺键一律回落默认值, 并保证返回的 dict 键全齐, +GUI 无需判空。 +""" + +import json +import os +import shutil +import sys + +if getattr(sys, 'frozen', False): + BASE_DIR = os.path.dirname(os.path.abspath(sys.executable)) +else: + BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +CONFIG_FILE = os.path.join(BASE_DIR, 'serial_mate.json') +LEGACY_CONFIG = os.path.join(BASE_DIR, 'serial_assistant.json') # 更名前的配置 +PROFILES_FILE = os.path.join(BASE_DIR, 'board_profiles.json') + + +def _migrate_legacy_config(): + """SerialAssistant -> SerialMate 更名迁移: 旧配置搬为新名(一次性, 已存在则跳过)""" + if os.path.exists(CONFIG_FILE) or not os.path.exists(LEGACY_CONFIG): + return + try: + shutil.copy2(LEGACY_CONFIG, CONFIG_FILE) + except OSError: + pass + +DEFAULTS = { + 'serial': {'port': '', 'baud': 115200, 'bytesize': 8, 'parity': 'N', 'stopbits': 1}, + 'proto': {'tx_hdr': 'D5', 'rx_hdr': 'B5', 'board': '01', + 'crc_algo': 'SUM8', 'crc_scope': 'no_header', 'parse': True, + 'layout': 'HDR ID LEN PAYLOAD CRC', 'len_sem': 'payload'}, + 'tx': {'mode': 'wrap', 'periodic': False, 'interval_ms': 1000, 'profile': ''}, + 'rx': {'display': 'text', 'show_raw': True}, + 'channels': [ + {'enabled': False, 'name': '通道1', 'mode': 'num', 'offset': 0, + 'fmt': 'u16be', 'index': 1, 'pattern': '(?<=:)\\d+', 'scale': 1.0}, + {'enabled': False, 'name': '通道2', 'mode': 'bin', 'offset': 0, + 'fmt': 's16be', 'index': 1, 'pattern': '(?<=:)\\d+', 'scale': 1.0}, + {'enabled': False, 'name': '通道3', 'mode': 'bin', 'offset': 0, + 'fmt': 'u16be', 'index': 1, 'pattern': '(?<=:)\\d+', 'scale': 1.0}, + {'enabled': False, 'name': '通道4', 'mode': 'bin', 'offset': 0, + 'fmt': 'u16be', 'index': 1, 'pattern': '(?<=:)\\d+', 'scale': 1.0}, + ], +} + +# 板型档案的帧协议字段(不含 parse —— 解析开关是查看偏好, 不随板型走) +_PROFILE_PROTO_KEYS = ('tx_hdr', 'rx_hdr', 'board', 'layout', 'len_sem', + 'crc_algo', 'crc_scope') + +# 首个板型的命令预设(DLPilot 光机命令集, 全表见上游 protocols.md) +DEFAULT_COMMANDS = ( + ('握手', 'M1'), ('查版本', 'M999'), + ('开光机', 'M730S0'), ('关光机', 'M730S1'), + ('设光强', 'M731S100'), ('查电流', 'M732'), + ('查DMD温度', 'M201'), ('查LAMP温度', 'M202'), + ('TEC自动控温开', 'M7S0'), ('TEC自动控温关', 'M7S1'), + ('凝露点33.0°C', 'M8S330'), ('软件重启', 'M888'), +) + + +def default_profiles(): + """首个板型档案: DLPilot 光机(协议字段取默认值)""" + return [{'name': 'DLPilot光机', + 'proto': {k: DEFAULTS['proto'][k] for k in _PROFILE_PROTO_KEYS}, + 'commands': [list(c) for c in DEFAULT_COMMANDS]}] + + +def _merge(base, over): + """递归覆盖: over 缺键用 base 补齐, over 多余键保留(向前兼容旧配置)""" + out = dict(base) + for k, v in over.items(): + if k in base and isinstance(base[k], dict) and isinstance(v, dict): + out[k] = _merge(base[k], v) + else: + out[k] = v + return out + + +def load(): + _migrate_legacy_config() + """读设置; 任何异常回落默认(键全齐), 不抛出""" + try: + with open(CONFIG_FILE, encoding='utf-8') as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError('配置根不是对象') + except Exception: + return json.loads(json.dumps(DEFAULTS)) # 深拷贝默认值 + return _merge(DEFAULTS, data) + + +def save(cfg): + """写设置; 失败静默(只读目录等场景不阻塞退出)""" + try: + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(cfg, f, ensure_ascii=False, indent=1) + except Exception: + pass + + +def load_profiles(): + """读板型档案(board_profiles.json); 缺失/损坏回落默认并落盘。 + 档案结构: {'profiles': [{'name', 'proto'{7字段}, 'commands'[[名,命令]..]}..]}""" + try: + with open(PROFILES_FILE, encoding='utf-8') as f: + data = json.load(f) + profiles = data.get('profiles') if isinstance(data, dict) else data + if isinstance(profiles, list) and profiles: + out = [] + for p in profiles: + if not isinstance(p, dict) or not p.get('name'): + continue + out.append({'name': str(p['name']), + 'proto': {k: p.get('proto', {}).get(k, + DEFAULTS['proto'][k]) + for k in _PROFILE_PROTO_KEYS}, + 'commands': [[str(n), str(c)] + for n, c in p.get('commands', []) + if isinstance(n, str) and isinstance(c, str)]}) + if out: + return out + except Exception: + pass + profiles = default_profiles() + save_profiles(profiles) + return profiles + + +def save_profiles(profiles): + """写板型档案; 失败静默并返回 False""" + try: + with open(PROFILES_FILE, 'w', encoding='utf-8') as f: + json.dump({'profiles': profiles}, f, ensure_ascii=False, indent=1) + return True + except Exception: + return False diff --git a/SerialMate/src/link.py b/SerialMate/src/link.py new file mode 100644 index 0000000..aeb83e3 --- /dev/null +++ b/SerialMate/src/link.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +串口链路线程: 端口/波特率热切换 + 发送队列 + 接收分发 +====================================================== +与 DLPilot 的 DLPC485 同一套路(D I2C-Inject Injector 同源), 差异点: + - 帧格式参数(ProtoConfig)可运行中热替换, 解析器配置随之切换并清残余半帧; + - 支持协议解析(Frame 回调)与透传(原始 bytes 回调)两种接收模式, 运行中切换; + - 发送一律传完整帧 bytes(组帧由 GUI 侧按 ProtoConfig 完成, 本模块不关心格式)。 + +回调全在后台线程触发, GUI 侧必须自行投递回主线程(见 main.py 的轮询队列)。 +pyserial 为本模块独占依赖(缺失只影响串口功能)。 +""" + +import collections +import queue +import re +import threading +import time + +try: + import serial + import serial.tools.list_ports + HAS_SERIAL = True +except ImportError: + HAS_SERIAL = False + +DEFAULT_BAUD = 115200 +DEFAULTS = dict(bytesize=8, parity='N', stopbits=1) + + +class SerialLink: + """串口工作线程。回调(后台线程触发): + on_status(str) 连接状态变化(已连接/断开/失败/设备拔出) + on_frame(fr) 解析模式: 完整 Frame(protocol.Frame) + on_raw(data) 透传模式: 原始接收 bytes + on_tx(label, data) 发送回显: label=payload 字符串(自动包装)/None(HEX直发) + """ + + def __init__(self, cfg, on_status): + self.on_status = on_status + self.on_frame = None + self.on_raw = None + self.on_tx = None + self.q = queue.Queue() + self._cfg = cfg + self._parse = True + self._target = None # 意图 (port, baud, bytesize, parity, stopbits) + self._open_key = None # 当前已打开串口的参数元组 + self.ser = None + self.parser = None # 惰性建: 协议模块无 pyserial 依赖, 此处才用到 + self._lock = threading.Lock() + threading.Thread(target=self._run, daemon=True).start() + + # --- 配置与模式(线程安全) --- + + def set_config(self, cfg): + """热替换帧格式; 解析器半帧缓冲随之清空, 避免新旧格式混接""" + with self._lock: + self._cfg = cfg + if self.parser is not None: + self.parser.cfg = cfg + self.parser.clear() + + def set_parse(self, enabled): + """切换 解析协议帧 / 原始透传; 切换时清解析缓冲""" + with self._lock: + self._parse = bool(enabled) + if self.parser is not None: + self.parser.clear() + + # --- 串口管理 --- + + @staticmethod + def list_ports_info(): + """枚举串口, 返回 [(设备名, 显示标签), ...]。 + + 标签 = "COMx 设备描述"(描述里的 "(COMx)" 尾巴去掉), CH344 这类多路 + 适配器每一路描述不同(serial-A/B/C/D), 依此区分; 多口描述完全相同时 + 追加物理位置/序列号消歧。 + """ + if not HAS_SERIAL: + return [] + items = [] + for p in serial.tools.list_ports.comports(): + desc = re.sub(r'\s*\((COM\d+)\)\s*$', '', (p.description or '').strip(), + flags=re.I) + if desc.upper() == p.device.upper(): + desc = '' + label = f'{p.device} {desc}'.rstrip() + tail = p.location or p.serial_number or '' + items.append((p.device, label, tail)) + counts = collections.Counter(label for _d, label, _t in items) + out = [] + for dev, label, tail in items: + if counts[label] > 1 and tail: + label = f'{label} [{tail}]' + out.append((dev, label)) + return out + + def open(self, port, baud=DEFAULT_BAUD, **kw): + if not HAS_SERIAL: + self.on_status('未安装 pyserial: py -m pip install pyserial') + return + params = {**DEFAULTS, **kw} + with self._lock: + self._target = (port, int(baud), int(params['bytesize']), + str(params['parity']).upper(), int(params['stopbits'])) + + def close(self): + with self._lock: + self._target = None + + def is_connected(self): + with self._lock: + return self.ser is not None + + # --- 发送(队列化, 后台线程写串口) --- + + def send_frame(self, frame, label=''): + """发送一个完整帧 bytes; label 用于回显(自动包装时为 payload 文本)。 + 未连接时直接丢弃并提示(防积压到重连后一起发出, DLPilot 同策略)""" + with self._lock: + ser = self.ser + if ser is None: + self.on_status('未连接串口, 命令被丢弃') + return False + self.q.put((label, bytes(frame))) + return True + + # --- 工作线程 --- + + def _run(self): + bytesizes = {8: serial.EIGHTBITS, 7: serial.SEVENBITS, + 6: serial.SIXBITS, 5: serial.FIVEBITS} + parities = {'N': serial.PARITY_NONE, 'E': serial.PARITY_EVEN, + 'O': serial.PARITY_ODD} + stopbits = {1: serial.STOPBITS_ONE, 2: serial.STOPBITS_TWO} + while True: + with self._lock: + target = self._target + cur = self._open_key + if target != cur: + if self.ser is not None: + try: + self.ser.close() + except Exception: + pass + self.ser = None + self._open_key = None + self.on_status('已断开') + if target: + port, baud, dbsize, parity, sbits = target + try: + maker = (serial.serial_for_url if '://' in port + else serial.Serial) # '://' 支持 loop:// 等虚拟端口(自检用) + self.ser = maker( + port, baud, bytesize=bytesizes.get(dbsize, serial.EIGHTBITS), + parity=parities.get(parity, serial.PARITY_NONE), + stopbits=stopbits.get(sbits, serial.STOPBITS_ONE), + timeout=0.15, + write_timeout=1.0) # 写超时: 转换器停滞时 write 会 + # 永久阻塞并卡死线程, 必须设上限 + with self._lock: + self._open_key = target + self.parser = None # 换口重建解析器(下次接收时惰性建) + self.on_status(f'已连接 {port} @ {baud} {dbsize}{parity}{sbits}') + except Exception as e: + self.ser = None + with self._lock: + self._target = None + et = str(e) + if 'PermissionError' in repr(e) or '拒绝访问' in et \ + or 'Access is denied' in et: + et += '(串口被占用: 检查串口助手/另一个本工具实例)' + self.on_status(f'连接失败: {et}') + if self.ser is not None: + try: + item = None + try: + item = self.q.get_nowait() + except queue.Empty: + pass + if item is not None: + label, frame = item + self.ser.write(frame) + if self.on_tx is not None: + self.on_tx(label, frame) + data = self.ser.read(4096) + if data: + self._dispatch_rx(data) + except OSError: + try: + self.ser.close() + except Exception: + pass + self.ser = None + self._open_key = None # 必须同步清除, 否则同参数重连被当作已打开而跳过 + with self._lock: + self._target = None + self.on_status('串口丢失(设备拔出?), 请重新连接') + time.sleep(0.02) + + def _dispatch_rx(self, data): + with self._lock: + parse = self._parse + if parse and self.parser is None: # 惰性建, 带当前 ProtoConfig + from protocol import FrameParser + self.parser = FrameParser(self._cfg) + if parse: + for fr in self.parser.feed(data): + if self.on_frame is not None: + self.on_frame(fr) + elif self.on_raw is not None: + self.on_raw(data) diff --git a/SerialMate/src/main.py b/SerialMate/src/main.py new file mode 100644 index 0000000..bed8e13 --- /dev/null +++ b/SerialMate/src/main.py @@ -0,0 +1,1458 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +SerialMate — RS485/串口调试助手(Fluent UI) +================================================== +帧格式五段式可定制: 包头(TX/RX) + 板ID + LEN(=payload 长度) + payload + CRC +(算法/覆盖范围可选), 默认与 DLPilot 光机 RS485 协议一致(D5/B5/01/SUM8/不含包头)。 + +功能: + - 串口收发, 参数热切换; 半双工 RS485 友好(每个工作周期最多写一帧, 留应答间隙) + - 接收: 协议解析(包头+LEN 定界+CRC 校验, 坏帧重同步) / 原始透传 两模式切换 + - payload 显示 字符串/HEX 切换, 原始帧 hex 可选, 日志可保存 + - 曲线: 4 通道, 二进制定长 / 文本数字 / 正则 三种提取方式 + 倍率 + - 发送: 自动包装(payload 即协议载荷, '/' 分隔多条依次组帧) / HEX 直发, + 支持周期发送; 命令历史 ↑↓ 回溯 + +用法: + python main.py 打开 GUI + python main.py --seconds N N 秒后自动退出(冒烟测试) +""" + +import argparse +import html +import queue +import os +import re +import sys +import time + +from PySide6.QtCore import QEvent, QMimeData, QRegularExpression, Qt, QTimer +from PySide6.QtGui import (QFontDatabase, QIcon, QDrag, QPalette, + QRegularExpressionValidator) +from PySide6.QtWidgets import (QApplication, QDialog, QFileDialog, QHBoxLayout, + QHeaderView, QInputDialog, QMainWindow, QMessageBox, + QPlainTextEdit, QSlider, QSplitter, QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget) +from qfluentwidgets import (BodyLabel, CaptionLabel, CardWidget, CheckBox, ComboBox, + DoubleSpinBox, EditableComboBox, FluentIcon, LineEdit, + PlainTextEdit, PrimaryPushButton, PushButton, SpinBox, + StrongBodyLabel, SubtitleLabel) + +import config +from link import SerialLink +from plot import PlotWidget, fmt_val +from protocol import (BIN_FORMATS, CRC_ALGOS, CRC_ALGO_LABELS, CRC_SCOPE_LABELS, + DEFAULT_LAYOUT, LEN_MAX, LEN_SEM_LABELS, LEN_SEMS, + ProtoConfig, TYPE_NAMES, clean_text, extract_value, + hexs, FrameLayout) +from theme import ThemeManager + +APP_NAME = 'SerialMate' +APP_VERSION = '0.1.0' +REPO_URL = 'https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools' + +BAUDS = (1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600) +TX_MODES = ('wrap', 'hex') +TX_MODE_LABELS = ('自动包装 (ASCII payload)', 'HEX 直发') +RX_DISPLAYS = ('text', 'hex') +RX_DISPLAY_LABELS = ('字符串', 'HEX') +CH_MODES = ('bin', 'num', 're') +CH_MODE_LABELS = ('二进制', '文本数字', '正则') +# 弹出曲线窗口的时间窗宽选项 (显示文本, 秒数; None=跟随全部) +PLOT_SPANS = (('跟随(全部)', None), ('最近 10s', 10), ('最近 30s', 30), + ('最近 1min', 60), ('最近 5min', 300), ('最近 15min', 900)) +# fmt -> 显示文本(顺序即下拉顺序) +FMT_LABELS = (('u8', 'u8'), ('s8', 's8'), + ('u16be', 'u16 大端'), ('u16le', 'u16 小端'), + ('s16be', 's16 大端'), ('s16le', 's16 小端'), + ('u32be', 'u32 大端'), ('u32le', 'u32 小端'), + ('s32be', 's32 大端'), ('s32le', 's32 小端'), + ('f32be', 'f32 大端'), ('f32le', 'f32 小端')) + + +def _timestamp(): + t = time.time() + return time.strftime('%H:%M:%S', time.localtime(t)) + f'.{int(t * 1000) % 1000:03d}' + + +class CommandTable(QTableWidget): + """命令预设表格: 第 0 列为拖动手柄(≡), 按住上下拖动整行排序; + 网格线/隔行底色随明暗主题, 空行也清晰可见""" + + def __init__(self, commands, parent=None): + super().__init__(len(commands), 3, parent) + self.setColumnCount(3) + self.setHorizontalHeaderLabels(['', '显示名称', '命令内容']) + self.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed) + self.setColumnWidth(0, 36) + self.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + self.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) + self.verticalHeader().setVisible(False) + self.verticalHeader().setDefaultSectionSize(34) + self.setShowGrid(True) + self.setGridStyle(Qt.SolidLine) + self.setAlternatingRowColors(True) + dark = QApplication.palette().color(QPalette.Base).lightness() < 128 + grid, alt = ('#4d4d4d', '#303438') if dark else ('#c9cfd6', '#f3f5f8') + self.setStyleSheet(f'QTableWidget {{ gridline-color: {grid}; ' + f'alternate-background-color: {alt}; }}') + self.setToolTip('行首 ≡ 按住上下拖动调整顺序; 双击单元格编辑') + self.setAcceptDrops(True) + self._drag_row = None + for r, (name, cmd) in enumerate(commands): + self._set_handle(r) + self.setItem(r, 1, QTableWidgetItem(name)) + self.setItem(r, 2, QTableWidgetItem(cmd)) + + def _set_handle(self, row): + it = QTableWidgetItem('≡') + it.setFlags(Qt.ItemIsEnabled) # 手柄列不可编辑不可选 + it.setTextAlignment(Qt.AlignCenter) + it.setToolTip('按住拖动调整顺序') + self.setItem(row, 0, it) + + def _add_row(self): + r = self.rowCount() + self.insertRow(r) + self._set_handle(r) + self.setCurrentCell(r, 1) + + def _del_row(self): + for r in sorted({i.row() for i in self.selectedIndexes()}, + reverse=True): + self.removeRow(r) + + def move_row(self, src, dst): + """整行搬移(含手柄列); 越界/原地不动静默""" + n = self.rowCount() + if not (0 <= src < n) or not (0 <= dst < n) or src == dst: + return + items = [self.takeItem(src, c) for c in range(self.columnCount())] + self.removeRow(src) + self.insertRow(dst) + for c, it in enumerate(items): + self.setItem(dst, c, it) + self.setCurrentCell(dst, 1) + + # --- 行首手柄发起的内部拖拽 --- + + def mousePressEvent(self, ev): + if ev.button() == Qt.LeftButton: + pos = ev.position().toPoint() + if self.columnAt(pos.x()) == 0: + self._drag_row = self.rowAt(pos.y()) + if self._drag_row >= 0: + drag = QDrag(self) + mime = QMimeData() + mime.setText(str(self._drag_row)) + drag.setMimeData(mime) + drag.exec(Qt.MoveAction) + self._drag_row = None + return + super().mousePressEvent(ev) + + def _own_drag(self, e): + return e.source() is self and self._drag_row is not None + + def dragEnterEvent(self, ev): + if self._own_drag(ev): + ev.accept() + + def dragMoveEvent(self, ev): + if self._own_drag(ev): + ev.accept() + + def dropEvent(self, ev): + if not self._own_drag(ev): + return + ev.setDropAction(Qt.MoveAction) + ev.accept() + target = self.rowAt(ev.position().toPoint().y()) + if target < 0: + target = self.rowCount() - 1 + self.move_row(self._drag_row, target) + self._drag_row = None + + def commands(self): + """收集表格 -> [[名称, 命令]..]; 空命令行忽略, 命令文本去重""" + out = [] + for r in range(self.rowCount()): + it1, it2 = self.item(r, 1), self.item(r, 2) + name = (it1.text() if it1 else '').strip() + cmd = (it2.text() if it2 else '').strip() + if cmd and not any(c[1] == cmd for c in out): + out.append([name or cmd, cmd]) + return out + + +class ProfileEditDialog(QDialog): + """板型命令预设表编辑器: CommandTable 显示 名称/命令, +加行 −删行, 保存写回""" + + def __init__(self, profile, parent=None): + super().__init__(parent) + self.setWindowTitle(f'编辑命令预设 · {profile["name"]}') + self.resize(540, 440) + lay = QVBoxLayout(self) + self.table = CommandTable(profile['commands'], self) + lay.addWidget(self.table) + btns = QHBoxLayout() + btn_add = PushButton('+ 添加行') + btn_add.clicked.connect(self.table._add_row) + btn_del = PushButton('− 删除选中行') + btn_del.clicked.connect(self.table._del_row) + btns.addWidget(btn_add) + btns.addWidget(btn_del) + btns.addStretch(1) + btn_save = PrimaryPushButton('保存') + btn_save.clicked.connect(self.accept) + btn_cancel = PushButton('取消') + btn_cancel.clicked.connect(self.reject) + btns.addWidget(btn_save) + btns.addWidget(btn_cancel) + lay.addLayout(btns) + + def commands(self): + return self.table.commands() + + +class Window(QMainWindow): + + def __init__(self, theme_manager=None, seconds=0): + super().__init__() + self.setWindowTitle(f'{APP_NAME} · 串口/RS485 调试助手 v{APP_VERSION}') + self.resize(1380, 860) + self.setMinimumSize(1060, 640) + self.theme_manager = theme_manager or ThemeManager(QApplication.instance()) + self.cfg_store = config.load() + self.events = queue.Queue() + self._proto_err = '' + self.profiles = config.load_profiles() # 板型档案(协议参数+命令预设) + self._commands = [] + self._plot_win = None # 曲线独立窗口(弹出后非 None) + self.tx_history, self._hist_pos = [], -1 + self._tx_n = self._rx_n = self._tx_b = self._rx_b = 0 + self.link = SerialLink(cfg=self._proto_from_store(), + on_status=lambda s: self.events.put(('status', s))) + self.link.on_frame = lambda fr: self.events.put(('frame', fr)) + self.link.on_raw = lambda b: self.events.put(('raw', b)) + self.link.on_tx = lambda lb, d: self.events.put(('tx', lb, d)) + self._build_ui() + self.plot.on_yrange_changed = self._on_plot_yrange + self.plot.on_span_changed = self._sync_time_slider + self._apply_store_to_ui() + self.theme_manager.set_window(self) + self.ed_tx.installEventFilter(self) + self._periodic = QTimer(self) + self._periodic.timeout.connect(self._send) + poll = QTimer(self) + poll.timeout.connect(self._drain_events) + poll.timeout.connect(self._sync_time_slider) + poll.start(30) + QTimer.singleShot(0, self._apply_minimum_size) # 首次布局后抬高窗口下限 + if seconds: + QTimer.singleShot(int(seconds * 1000), self.close) + + def _apply_minimum_size(self): + """按内容实际最小高度抬高窗口下限: 窗口拉得过矮时布局约束无法满足, + 曲线卡绘图区会被压塌并与通道行互相叠压(用户实测), 故禁止进入该区间""" + hint = self.centralWidget().minimumSizeHint() + self.setMinimumHeight(max(660, hint.height())) + + # ------------------------------------------------------------------ UI + + def _build_ui(self): + root = QWidget(self) + root.setObjectName('root') + outer = QVBoxLayout(root) + outer.setContentsMargins(18, 12, 18, 12) + outer.setSpacing(10) + + head = QHBoxLayout() + box = QVBoxLayout() + box.setSpacing(1) + box.addWidget(SubtitleLabel(APP_NAME)) + box.addWidget(CaptionLabel(f'RS485/串口调试助手 · 帧格式可定制 · v{APP_VERSION}')) + head.addLayout(box) + head.addStretch(1) + btn_about = PushButton('关于') + btn_about.clicked.connect(self.show_about) + head.addWidget(btn_about) + self.combo_theme = ComboBox() + for m in ('跟随系统', '浅色', '深色'): + self.combo_theme.addItem(m) + self.combo_theme.setCurrentIndex(0) + self.combo_theme.currentIndexChanged.connect(self._on_theme) + head.addWidget(BodyLabel('主题')) + head.addWidget(self.combo_theme) + outer.addLayout(head) + + splitter = QSplitter(Qt.Horizontal) + left, right = QWidget(), QWidget() + lcol, rcol = QVBoxLayout(left), QVBoxLayout(right) + lcol.setContentsMargins(0, 0, 0, 0) + rcol.setContentsMargins(0, 0, 0, 0) + lcol.setSpacing(10) + rcol.setSpacing(10) + self._build_serial_card(lcol) + self._build_proto_card(lcol) + self._build_plot_card(lcol) + rx_card = self._build_rx_card() + tx_card = self._build_tx_card() + lcol.addStretch(1) + rsplit = QSplitter(Qt.Vertical) # 接收/发送高度用户可拖拽 + rsplit.setHandleWidth(6) + rsplit.setChildrenCollapsible(False) + rsplit.addWidget(rx_card) + rsplit.addWidget(tx_card) + rsplit.setStretchFactor(0, 1) # 拉伸余量默认给接收区 + rsplit.setStretchFactor(1, 0) # 发送卡保持自然高度(防空腔) + rsplit.setSizes((600, 270)) + rcol.addWidget(rsplit) + self._on_tx_mode() + splitter.addWidget(left) + splitter.addWidget(right) + splitter.setSizes((460, 900)) + outer.addWidget(splitter, 1) + self.setCentralWidget(root) + self.statusBar().showMessage('未连接') + + def _card(self, title, subtitle=None, side=False): + """Fluent 卡片; side=True 时副标题小字放标题右侧(省一行高度, 适合短副标题)""" + card = CardWidget(self) + lay = QVBoxLayout(card) + lay.setContentsMargins(14, 12, 14, 14) + lay.setSpacing(8) + if subtitle and side: + h = QHBoxLayout() + h.setSpacing(10) + h.addWidget(StrongBodyLabel(title, card)) + h.addStretch(1) + h.addWidget(CaptionLabel(subtitle, card)) + lay.addLayout(h) + return card, lay + lay.addWidget(StrongBodyLabel(title, card)) + if subtitle: + c = CaptionLabel(subtitle, card) + c.setWordWrap(True) + lay.addWidget(c) + return card, lay + + @staticmethod + def _row(lay, *widgets, trailing=True): + """紧凑行: qfluentwidgets 控件默认水平 Expanding, 不给 stretch 项时 + 空白会被它们瓜分出大空隙 —— 行尾默认补 stretch(Qt 规则: 有 stretch + 因子时只按因子分配额外空间); 需要自管拉伸的行传 trailing=False。""" + h = QHBoxLayout() + h.setSpacing(6) + for w in widgets: + h.addWidget(w) + if trailing: + h.addStretch(1) + lay.addLayout(h) + return h + + # --- 串口卡 --- + + def _build_serial_card(self, col): + card, lay = self._card('串口', 'RS485 半双工常用 115200 8N1', side=True) + self.combo_port = ComboBox() + self.combo_port.setMinimumWidth(150) + self._port_devs = [] + self.btn_refresh = PushButton('刷新') + self.btn_refresh.setIcon(FluentIcon.SYNC) + self.btn_refresh.clicked.connect(self._refresh_ports) + self.combo_baud = EditableComboBox() + for b in BAUDS: + self.combo_baud.addItem(str(b)) + self.btn_conn = PrimaryPushButton('连接') + self.btn_conn.clicked.connect(self._toggle_connect) + h = self._row(lay, self.combo_port, self.btn_refresh, self.combo_baud, + self.btn_conn, trailing=False) + h.setStretch(0, 1) # 端口下拉吃掉行内剩余宽度 + + self.combo_databits, self.combo_parity, self.combo_stop = ComboBox(), ComboBox(), ComboBox() + for v in ('8', '7', '6', '5'): + self.combo_databits.addItem(v) + for v in ('N 无校验', 'E 偶校验', 'O 奇校验'): + self.combo_parity.addItem(v) + for v in ('1', '2'): + self.combo_stop.addItem(v) + self.lb_state = BodyLabel('未连接') + self._row(lay, CaptionLabel('数据位'), self.combo_databits, + CaptionLabel('校验'), self.combo_parity, + CaptionLabel('停止位'), self.combo_stop, self.lb_state) + col.addWidget(card) + self._refresh_ports() + + def _refresh_ports(self): + self._port_devs = SerialLink.list_ports_info() + combo = self.combo_port + combo.clear() + for _dev, label in self._port_devs: + combo.addItem(label) + if not self._port_devs: + combo.addItem('未检测到串口') + + def _toggle_connect(self): + if self.link.is_connected(): + self.link.close() + self.btn_conn.setText('连接') + return + if not self._port_devs: + self._hint('未检测到串口, 请插入适配器后刷新') + return + idx = self.combo_port.currentIndex() + if not 0 <= idx < len(self._port_devs): + self._hint('请选择串口') + return + dev = self._port_devs[idx][0] + try: + baud = int(self.combo_baud.currentText() or '115200') + except ValueError: + self._hint('波特率非法') + return + self.link.open(dev, baud, bytesize=int(self.combo_databits.currentText()), + parity=self.combo_parity.currentText()[0], + stopbits=int(self.combo_stop.currentText())) + self.btn_conn.setText('断开') + + # --- 协议卡 --- + + def _build_proto_card(self, col): + card, lay = self._card('协议帧格式', + '帧构成令牌序列可定制, LEN/PAYLOAD/CRC 各一次且 CRC 在末尾', + side=True) + hexval = QRegularExpressionValidator(QRegularExpression('[0-9A-Fa-f]{0,2}')) + self.ed_tx_hdr, self.ed_rx_hdr, self.ed_board = LineEdit(), LineEdit(), LineEdit() + for ed, w in ((self.ed_tx_hdr, 46), (self.ed_rx_hdr, 46), (self.ed_board, 46)): + ed.setValidator(hexval) + ed.setFixedWidth(46) + ed.setAlignment(Qt.AlignCenter) + ed.textChanged.connect(self._apply_proto) + self._row(lay, CaptionLabel('TX包头'), self.ed_tx_hdr, + CaptionLabel('RX包头'), self.ed_rx_hdr, + CaptionLabel('板ID'), self.ed_board) + + # 帧构成(发送/接收共用布局): HDR ID LEN PAYLOAD CRC 或十六进制字面量 + self.ed_layout = LineEdit() + self.ed_layout.setText(DEFAULT_LAYOUT) + self.ed_layout.setFixedWidth(240) + self.ed_layout.textChanged.connect(self._apply_proto) + self.combo_len_sem = ComboBox() + for label in LEN_SEM_LABELS.values(): + self.combo_len_sem.addItem(label) + self.combo_len_sem.currentIndexChanged.connect(self._apply_proto) + self._row(lay, CaptionLabel('帧构成'), self.ed_layout, + CaptionLabel('LEN口径'), self.combo_len_sem) + + self.combo_crc, self.combo_scope = ComboBox(), ComboBox() + for label in CRC_ALGO_LABELS.values(): + self.combo_crc.addItem(label) + for label in CRC_SCOPE_LABELS.values(): + self.combo_scope.addItem(label) + self.combo_crc.currentIndexChanged.connect(self._apply_proto) + self.combo_scope.currentIndexChanged.connect(self._apply_proto) + self.chk_parse = CheckBox('解析协议帧(收)') + self.chk_parse.stateChanged.connect(self._on_parse_toggle) + self._row(lay, CaptionLabel('CRC'), self.combo_crc, + CaptionLabel('覆盖'), self.combo_scope, self.chk_parse) + self.lb_proto = CaptionLabel( + '令牌: HDR=包头 ID=板ID LEN=长度 PAYLOAD=载荷 CRC=校验, ' + '十六进制字面量(如 AA 55)为固定同步字节') + self.lb_proto.setWordWrap(True) + lay.addWidget(self.lb_proto) + col.addWidget(card) + + def _proto_from_store(self): + """启动时从配置构建 ProtoConfig(只调用一次, 其后以 UI 为准)""" + p = self.cfg_store['proto'] + try: + return ProtoConfig(int(p['tx_hdr'], 16), int(p['rx_hdr'], 16), + int(p['board'], 16), p['crc_algo'], p['crc_scope'], + FrameLayout.parse(p.get('layout', DEFAULT_LAYOUT)), + p.get('len_sem', 'payload')) + except (ValueError, KeyError): + return ProtoConfig() + + def _proto_from_ui(self): + """读协议卡 -> ProtoConfig; 输入非法返回 None, 错误文本存 _proto_err""" + try: + return ProtoConfig(int(self.ed_tx_hdr.text() or 'D5', 16), + int(self.ed_rx_hdr.text() or 'B5', 16), + int(self.ed_board.text() or '01', 16), + tuple(CRC_ALGOS)[self.combo_crc.currentIndex()], + tuple(CRC_SCOPE_LABELS)[self.combo_scope.currentIndex()], + FrameLayout.parse(self.ed_layout.text()), + LEN_SEMS[self.combo_len_sem.currentIndex()]) + except ValueError as e: + self._proto_err = str(e) + return None + + def _apply_proto(self, *_): + cfg = self._proto_from_ui() + if cfg is not None: + self.link.set_config(cfg) + self._update_preview() + + def _on_parse_toggle(self, state): + self.link.set_parse(bool(state)) + + # --- 曲线卡 --- + + def _build_plot_card(self, col): + card, lay = self._card('曲线', '每帧有效数据按通道提取一个值: 二进制=从偏移字节' + '起按格式解; 文本数字=ASCII 第 N 个数字; 正则=第 1 捕获组;' + ' 结果×倍率后画线') + self.plot = PlotWidget(4) + self._plot_slot = QHBoxLayout() # 绘图区固定槽位, 弹出/嵌回来回放 + self._plot_slot.addWidget(self.plot) + lay.addLayout(self._plot_slot, 1) + + self.btn_pause = PushButton('暂停') + self.btn_pause.clicked.connect(self._toggle_pause) + self.btn_plot_clear = PushButton('清空') + self.btn_plot_clear.clicked.connect(self._clear_plot) + self.btn_pop = PushButton('弹出') + self.btn_pop.setToolTip('把绘图区弹成独立窗口(关掉窗口即嵌回)') + self.btn_pop.clicked.connect(self._toggle_plot_window) + self.chk_auto_y = CheckBox('自动Y') + self.chk_auto_y.setChecked(True) + self.chk_auto_y.stateChanged.connect(self._on_yrange) + self.spin_ylo, self.spin_yhi = DoubleSpinBox(), DoubleSpinBox() + for s, v in ((self.spin_ylo, 0.0), (self.spin_yhi, 100.0)): + s.setRange(-1e9, 1e9) + s.setDecimals(1) + s.setValue(v) + s.setEnabled(False) + s.setFixedWidth(140) + s.valueChanged.connect(self._on_yrange) + self._row(lay, self.btn_pause, self.btn_plot_clear, self.btn_pop, + self.chk_auto_y, + CaptionLabel('Ymin'), self.spin_ylo, CaptionLabel('Ymax'), self.spin_yhi) + + # 4 个通道行: [使能][名称][提取方式][参数(随方式切换)][倍率][最新值] + self._ch_ui = [] + for i in range(4): + ui = {} + ui['chk'] = CheckBox() + ui['chk'].toggled.connect(self._on_channel) + ui['name'] = LineEdit() + ui['name'].setFixedWidth(64) + ui['name'].setToolTip('通道名称(图例显示)') + ui['name'].textChanged.connect(self._on_channel) + ui['mode'] = ComboBox() + for label in CH_MODE_LABELS: + ui['mode'].addItem(label) + ui['mode'].setFixedWidth(100) + ui['mode'].setToolTip('提取方式: 二进制=按字节位置解数值; 文本数字=' + '取 ASCII 文本里第 N 个数字; 正则=自定义表达式') + ui['mode'].currentIndexChanged.connect(lambda _i, u=ui: self._show_mode(u)) + ui['mode'].currentIndexChanged.connect(self._on_channel) + + ui['w_bin'] = QWidget() + hb = QHBoxLayout(ui['w_bin']) + hb.setContentsMargins(0, 0, 0, 0) + hb.setSpacing(4) + ui['off'] = SpinBox() + ui['off'].setRange(0, 250) + ui['off'].setFixedWidth(110) + ui['off'].setToolTip('起始偏移: 从 payload 第几个字节开始解(0=第 1 字节)') + ui['off'].valueChanged.connect(self._on_channel) + ui['fmt'] = ComboBox() + for key, label in FMT_LABELS: + ui['fmt'].addItem(label) + ui['fmt'].setFixedWidth(108) + ui['fmt'].setToolTip('解码格式: 位宽 / 有无符号 / 大小端') + ui['fmt'].currentIndexChanged.connect(self._on_channel) + hb.addWidget(CaptionLabel('偏移')) + hb.addWidget(ui['off']) + hb.addWidget(ui['fmt']) + + ui['w_num'] = QWidget() + hb = QHBoxLayout(ui['w_num']) + hb.setContentsMargins(0, 0, 0, 0) + hb.setSpacing(4) + ui['idx'] = SpinBox() + ui['idx'].setRange(1, 64) + ui['idx'].setFixedWidth(110) + ui['idx'].setToolTip('取 payload 文本中第几个数字(1 起)') + ui['idx'].valueChanged.connect(self._on_channel) + hb.addWidget(CaptionLabel('第')) + hb.addWidget(ui['idx']) + hb.addWidget(CaptionLabel('个数字')) + + ui['w_re'] = QWidget() + hb = QHBoxLayout(ui['w_re']) + hb.setContentsMargins(0, 0, 0, 0) + hb.setSpacing(4) + ui['pat'] = LineEdit() + ui['pat'].setFixedWidth(150) + ui['pat'].setToolTip('正则表达式, 取第 1 捕获组(无组则取整体)') + ui['pat'].textChanged.connect(self._on_channel) + hb.addWidget(ui['pat']) + + ui['scale'] = DoubleSpinBox() + ui['scale'].setRange(0.001, 1000000) + ui['scale'].setDecimals(3) + ui['scale'].setSingleStep(0.1) + ui['scale'].setValue(1.0) + ui['scale'].setFixedWidth(130) + ui['scale'].setToolTip('倍率: 提取值 × 倍率 = 曲线值(单位换算用)') + ui['scale'].valueChanged.connect(self._on_channel) + ui['val'] = BodyLabel('—') + ui['val'].setFixedWidth(78) + ui['val'].setAlignment(Qt.AlignRight | Qt.AlignVCenter) + ui['val'].setToolTip('通道最新值') + + h = self._row(lay, ui['chk'], ui['name'], ui['mode'], + ui['w_bin'], ui['w_num'], ui['w_re'], ui['scale']) + h.addWidget(ui['val']) # 最新值贴行尾, 其余控件靠左 + self._show_mode(ui) + self._ch_ui.append(ui) + col.addWidget(card) + + def _show_mode(self, ui): + mode = CH_MODES[ui['mode'].currentIndex()] + ui['w_bin'].setVisible(mode == 'bin') + ui['w_num'].setVisible(mode == 'num') + ui['w_re'].setVisible(mode == 're') + + def _collect_channels(self): + out = [] + for ui in self._ch_ui: + out.append({ + 'enabled': ui['chk'].isChecked(), + 'name': ui['name'].text().strip() or f"通道{len(out) + 1}", + 'mode': CH_MODES[ui['mode'].currentIndex()], + 'offset': ui['off'].value(), + 'fmt': FMT_LABELS[ui['fmt'].currentIndex()][0], + 'index': ui['idx'].value(), + 'pattern': ui['pat'].text() or '(?<=:)\\d+', + 'scale': ui['scale'].value(), + }) + return out + + def _on_channel(self, *_): + for i, c in enumerate(self._collect_channels()): + self.plot.set_channel_name(i, c['name'] if c['enabled'] else '') + self._update_preview() + + def _toggle_pause(self): + paused = not self.plot.is_paused() + self.plot.set_paused(paused) + self.btn_pause.setText('继续' if paused else '暂停') + + # --- 曲线独立窗口 --- + + def _toggle_plot_window(self): + if self._plot_win is None: + self._pop_plot() + else: + self._dock_plot() + + def _pop_plot(self): + """绘图区弹成独立窗口; 通道/暂停/Y 轴控件留在主界面照常控制同一实例; + 窗口底部带时间窗宽下拉与横向滑条(回看历史, 拉回最右=实时)""" + self.plot.setParent(None) + self._plot_win = QWidget(None, Qt.Window) + self._plot_win.setWindowTitle(f'{APP_NAME} · 曲线') + self._plot_win.resize(880, 580) + self._plot_win.installEventFilter(self) # 捕获用户关窗 -> 自动嵌回 + lay = QVBoxLayout(self._plot_win) + lay.setContentsMargins(10, 10, 10, 10) + lay.setSpacing(6) + lay.addWidget(self.plot, 1) + self.btn_freeze = PushButton('暂停渲染') + self.btn_freeze.setToolTip('冻结画面查看/悬停读值; 采集继续, 恢复后一次性渲染积累的数据') + self.btn_freeze.clicked.connect(self._toggle_freeze) + self.btn_auto_y = PushButton('自动Y') + self.btn_auto_y.setCheckable(True) + self.btn_auto_y.setToolTip('自动缩放Y轴; 关闭后可在图上按住拖动自定义Y轴范围') + self.btn_auto_y.clicked.connect(self._on_pop_auto_y) + self.btn_live = PushButton('实时') + self.btn_live.setToolTip('取消滞后, 跳回最新数据') + self.btn_live.clicked.connect(self._go_live) + self.combo_span = ComboBox() + for label, _sec in PLOT_SPANS: + self.combo_span.addItem(label) + self.combo_span.setCurrentIndex(0) + self.combo_span.setToolTip('可见时间窗宽度(图上滚轮也可缩放)') + self.combo_span.currentIndexChanged.connect(self._on_span_change) + self.slider_time = QSlider(Qt.Horizontal) + self.slider_time.setRange(0, 1000) + self.slider_time.setValue(1000) + self.slider_time.setToolTip('左右拖动回看历史窗口; 拉到最右 = 实时跟随') + self.slider_time.valueChanged.connect(self._on_time_slider) + h = self._row(lay, self.btn_freeze, self.btn_auto_y, self.btn_live, + CaptionLabel('时间窗'), self.combo_span, trailing=False) + h.setStretch(4, 0) + h.addWidget(self.slider_time, 1) + self.btn_auto_y.setChecked(self.chk_auto_y.isChecked()) + self._plot_win.show() + self.btn_pop.setText('嵌回') + self._sync_time_slider() + + def _toggle_freeze(self): + frozen = not self.plot.is_frozen() + self.plot.set_frozen(frozen) + self.btn_freeze.setText('继续渲染' if frozen else '暂停渲染') + + def _on_pop_auto_y(self, checked): + if checked: + self.chk_auto_y.setChecked(True) # 触发 _on_yrange -> 自动 + else: + self._set_manual_yrange(self.plot.auto_yrange()) + + def _set_manual_yrange(self, yr): + """切到手动 Y 量程并同步主界面控件(阻断信号防回环)""" + self.chk_auto_y.blockSignals(True) + self.chk_auto_y.setChecked(False) + self.chk_auto_y.blockSignals(False) + for s, v in ((self.spin_ylo, yr[0]), (self.spin_yhi, yr[1])): + s.blockSignals(True) + s.setValue(v) + s.blockSignals(False) + self.spin_ylo.setEnabled(True) + self.spin_yhi.setEnabled(True) + self.plot.set_yrange(yr) + + def _on_plot_yrange(self, yr): + """图上拖动改 Y 量程 -> 同步主界面 Ymin/Ymax 输入框""" + self._set_manual_yrange(yr) + + def _go_live(self): + self.plot.set_offset(0) + self._sync_time_slider() + + def _on_span_change(self, *_): + idx = self.combo_span.currentIndex() + if idx < 0: # 滚轮缩放产生的自定义窗宽 + return + span = PLOT_SPANS[idx][1] + self.plot.set_view(span) + if span is None: # 跟随模式无滞后可言 + self.plot.set_offset(0) + self.slider_time.setEnabled(span is not None) + self._sync_time_slider() + + def _on_time_slider(self, value): + _tmin, _tmax, maxpan = self.plot.view_range() + if maxpan <= 1e-6: + return + self.plot.set_offset((1000 - value) / 1000 * maxpan) + + def _sync_time_slider(self): + """滚轮/回看/实时后同步 时间窗下拉与滑条 位置(用户拖动中不打扰)""" + if self._plot_win is None: + return + span = self.plot.view_span() + idx = next((i for i, (_l, sec) in enumerate(PLOT_SPANS) + if sec is not None and span is not None + and abs(sec - span) < 0.5), + -1 if span is not None else 0) + if self.combo_span.currentIndex() != idx: + self.combo_span.blockSignals(True) + self.combo_span.setCurrentIndex(idx) + self.combo_span.blockSignals(False) + self.slider_time.setEnabled(span is not None) + if self.slider_time.isSliderDown() or not self.slider_time.isEnabled(): + return + _tmin, _tmax, maxpan = self.plot.view_range() + frac = 1.0 if maxpan <= 1e-6 \ + else 1.0 - min(self.plot.view_offset() / maxpan, 1.0) + value = int(round(frac * 1000)) + if self.slider_time.value() != value: + self.slider_time.blockSignals(True) + self.slider_time.setValue(value) + self.slider_time.blockSignals(False) + + def _dock_plot(self): + """把绘图区放回曲线卡槽位; 弹出窗口不存在时静默。 + 嵌回时重置为跟随全部——卡片内没有滑条, 避免困在滞后的时间窗里""" + if self._plot_win is None: + return + win, self._plot_win = self._plot_win, None # 先摘引用, 防关窗递归 + self.plot.set_frozen(False) # 嵌回恢复渲染 + self.plot.set_view(None) + self.plot.set_offset(0) + self.plot.setParent(None) # 脱离窗口布局再回槽位 + self._plot_slot.addWidget(self.plot) + self.plot.show() + win.close() + self.btn_pop.setText('弹出') + + def _clear_plot(self): + self.plot.clear() + for ui in self._ch_ui: + ui['val'].setText('—') + + def _on_yrange(self, *_): + if self.chk_auto_y.isChecked(): + self.plot.set_yrange(None) + self.spin_ylo.setEnabled(False) + self.spin_yhi.setEnabled(False) + else: + self.plot.set_yrange((self.spin_ylo.value(), self.spin_yhi.value())) + self.spin_ylo.setEnabled(True) + self.spin_yhi.setEnabled(True) + + # --- 接收卡 --- + + def _build_rx_card(self): + card, lay = self._card('接收') + self.combo_display = ComboBox() + for label in RX_DISPLAY_LABELS: + self.combo_display.addItem(label) + self.chk_raw = CheckBox('原始帧hex') + self.chk_ts = CheckBox('时间戳') + self.chk_ts.setChecked(True) + btn_clear = PushButton('清屏') + btn_clear.clicked.connect(lambda: self.text_rx.clear()) + self.btn_save = PushButton('保存日志') + self.btn_save.setIcon(FluentIcon.SAVE) + self.btn_save.clicked.connect(self._save_log) + h = self._row(lay, CaptionLabel('payload显示'), self.combo_display, + self.chk_raw, self.chk_ts) + h.addStretch(1) + h.addWidget(btn_clear) + h.addWidget(self.btn_save) + + self.text_rx = PlainTextEdit() + self.text_rx.setReadOnly(True) + self.text_rx.setLineWrapMode(QPlainTextEdit.NoWrap) + self.text_rx.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)) + self.text_rx.setMinimumHeight(120) + self.text_rx.document().setMaximumBlockCount(5000) + lay.addWidget(self.text_rx, 1) + return card + + def _save_log(self): + path, _ = QFileDialog.getSaveFileName(self, '保存接收日志', + 'serial_log.txt', '文本文件 (*.txt)') + if not path: + return + try: + with open(path, 'w', encoding='utf-8') as f: + f.write(self.text_rx.toPlainText()) + self._hint(f'日志已保存: {path}') + except OSError as e: + self._hint(f'保存失败: {e}') + + # --- 发送卡 --- + + def _build_tx_card(self): + card, lay = self._card('发送', '自动包装: 输入协议载荷, 多条用 / 分隔依次组帧', + side=True) + + # 板型管理行: 一键切换 / 新建 / 存档 / 表格编辑 / 重载 + self.combo_profile = ComboBox() + self.combo_profile.currentIndexChanged.connect(self._on_profile) + self.combo_profile.blockSignals(True) # 构建期不触发切换(此时 ed_tx 未建) + for p in self.profiles: + self.combo_profile.addItem(p['name']) + self.combo_profile.blockSignals(False) + btn_new_profile = PushButton('新建板型') + btn_new_profile.setToolTip('按当前协议参数新建板型, 命令预设从空白开始') + btn_new_profile.clicked.connect(self._on_new_profile) + btn_del_profile = PushButton('删除板型') + btn_del_profile.setToolTip('删除当前选中的板型(至少保留一个)') + btn_del_profile.clicked.connect(self._on_delete_profile) + btn_edit_cfg = PushButton('配置') + btn_edit_cfg.setIcon(FluentIcon.EDIT) + btn_edit_cfg.setToolTip('表格编辑当前板型的命令预设(名称/命令, +加行 −删行)') + btn_edit_cfg.clicked.connect(self._edit_profile_commands) + btn_reload_cfg = PushButton('重载') + btn_reload_cfg.setIcon(FluentIcon.SYNC) + btn_reload_cfg.setToolTip('重新读取 board_profiles.json') + btn_reload_cfg.clicked.connect(self._reload_profiles) + h = self._row(lay, CaptionLabel('板型'), self.combo_profile, trailing=False) + h.setStretch(1, 1) + h.addWidget(btn_new_profile) + h.addWidget(btn_del_profile) + h.addWidget(btn_edit_cfg) + h.addWidget(btn_reload_cfg) + + # 预设行: 选中即直接发送 + self.combo_preset = ComboBox() + self.btn_fill = PushButton('填入') + self.btn_fill.clicked.connect(self._fill_preset) + self.btn_quick_send = PushButton('直接发送') + self.btn_quick_send.setToolTip('把当前选中的预设命令立即发送(等效 填入+发送)') + self.btn_quick_send.clicked.connect(self._send_preset_direct) + h = self._row(lay, CaptionLabel('预设'), self.combo_preset, trailing=False) + h.setStretch(1, 1) + h.addWidget(self.btn_fill) + h.addWidget(self.btn_quick_send) + + # 模式行: 右侧是两个保存类操作 + self.combo_tx_mode = ComboBox() + for label in TX_MODE_LABELS: + self.combo_tx_mode.addItem(label) + self.combo_tx_mode.currentIndexChanged.connect(self._on_tx_mode) + self.btn_save_profile = PushButton('存为板型') + self.btn_save_profile.setToolTip('把当前协议参数与命令预设表保存为一个板型(同名覆盖)') + self.btn_save_profile.clicked.connect(self._on_save_profile_as) + self.btn_add_cmd = PushButton('存入预设') + self.btn_add_cmd.setToolTip('把当前输入框的命令加入当前板型的预设表') + self.btn_add_cmd.clicked.connect(self._add_command) + h = self._row(lay, CaptionLabel('模式'), self.combo_tx_mode, trailing=False) + h.addStretch(1) + h.addWidget(self.btn_save_profile) + h.addWidget(self.btn_add_cmd) + + self.ed_tx = LineEdit() + self.ed_tx.returnPressed.connect(self._send) + self.ed_tx.textChanged.connect(self._update_preview) + self.btn_send = PrimaryPushButton('发送') + self.btn_send.setIcon(FluentIcon.SEND) + self.btn_send.clicked.connect(self._send) + self._row(lay, self.ed_tx, self.btn_send, trailing=False) + + self.chk_periodic = CheckBox('周期发送') + self.chk_periodic.toggled.connect(self._on_periodic) + self.spin_interval = SpinBox() + self.spin_interval.setRange(100, 60000) + self.spin_interval.setSingleStep(100) + self.spin_interval.setValue(1000) + self.spin_interval.setSuffix(' ms') + self.spin_interval.valueChanged.connect(self._on_periodic) + self.lb_preview = CaptionLabel('') + h = self._row(lay, self.chk_periodic, self.spin_interval, self.lb_preview, + trailing=False) + h.setStretch(2, 1) # 组帧预览吃掉行内剩余宽度 + lay.addStretch(1) # 内容贴顶: 富余高度收拢在卡底 + return card + + def _on_tx_mode(self): + wrap = TX_MODES[self.combo_tx_mode.currentIndex()] == 'wrap' + self.ed_tx.setPlaceholderText( + '如 M1 或 M731S100 · 多条用 / 分隔: M1/M731S0T1000' if wrap + else '如 D5 01 02 4D 31 81 或 D50102 4D3181(空白分隔可省)') + self.combo_preset.setEnabled(wrap) + self.btn_fill.setEnabled(wrap) + self.btn_quick_send.setEnabled(wrap) + self.btn_add_cmd.setEnabled(wrap) + self._update_preview() + + # --- 板型档案 --- + + def _on_profile(self, idx, apply_proto=True): + """切换板型: 应用其协议参数(启动恢复时不重复应用)并装载命令预设表""" + if not 0 <= idx < len(self.profiles): + return + prof = self.profiles[idx] + if apply_proto: + pr = prof['proto'] + try: + self.ed_tx_hdr.setText(str(pr['tx_hdr'])) + self.ed_rx_hdr.setText(str(pr['rx_hdr'])) + self.ed_board.setText(str(pr['board'])) + self.ed_layout.setText(str(pr['layout'])) + self.combo_len_sem.setCurrentIndex(LEN_SEMS.index(pr['len_sem'])) + self.combo_crc.setCurrentIndex(tuple(CRC_ALGOS).index(pr['crc_algo'])) + self.combo_scope.setCurrentIndex( + tuple(CRC_SCOPE_LABELS).index(pr['crc_scope'])) + self._hint(f'已切换板型: {prof["name"]}(协议参数已应用)') + except (ValueError, KeyError): + self._hint(f'板型 "{prof["name"]}" 协议字段非法, 仅加载命令表') + self._commands = [list(c) for c in prof['commands']] + self._reload_preset_combo() + + def _reload_preset_combo(self): + self.combo_preset.clear() + for name, cmd in self._commands: + self.combo_preset.addItem(f'{name} · {cmd}') # 名称与命令同显 + self.combo_preset.setCurrentIndex(0 if self._commands else -1) + + def _fill_preset(self): + idx = self.combo_preset.currentIndex() + if 0 <= idx < len(self._commands): + self.ed_tx.setText(self._commands[idx][1]) + + def _send_preset_direct(self): + """选中预设 -> 立即发送(经输入框, 保留历史与组帧预览一致性)""" + idx = self.combo_preset.currentIndex() + if not 0 <= idx < len(self._commands): + self._hint('未选择预设命令') + return + self.ed_tx.setText(self._commands[idx][1]) + self._send() + + def _add_command_named(self, name, cmd): + """命令加入当前板型预设表(按命令文本去重, 名称可自定义); 返回是否新增""" + if any(c[1] == cmd for c in self._commands): + self._hint(f'命令 {cmd} 已在预设表中') + return False + idx = self.combo_profile.currentIndex() + if not 0 <= idx < len(self.profiles): + self._hint('无有效板型, 无法存入') + return False + self._commands.append([name, cmd]) + self.profiles[idx]['commands'] = [list(c) for c in self._commands] + config.save_profiles(self.profiles) + self._reload_preset_combo() + self.combo_preset.setCurrentIndex(len(self._commands) - 1) + self._hint(f'已存入板型 "{self.profiles[idx]["name"]}": {name} · {cmd}') + return True + + def _add_command(self): + text = self.ed_tx.text().strip() + if not text: + self._hint('输入框为空, 无命令可存入') + return + if TX_MODES[self.combo_tx_mode.currentIndex()] != 'wrap': + self._hint('HEX 直发模式下不能存入预设') + return + name, ok = QInputDialog.getText(self, '存入预设命令', + f'显示名称(命令: {text}):', text=text) + if not ok: + return + self._add_command_named(name.strip() or text, text) + + def _current_proto_dict(self): + """当前协议卡参数 -> 档案 proto 字典; 非法返回 None(错误文本在 _proto_err)""" + cfg = self._proto_from_ui() + if cfg is None: + return None + return {'tx_hdr': f'{cfg.tx_hdr:02X}', 'rx_hdr': f'{cfg.rx_hdr:02X}', + 'board': f'{cfg.board:02X}', + 'layout': self.ed_layout.text().strip() or DEFAULT_LAYOUT, + 'len_sem': LEN_SEMS[self.combo_len_sem.currentIndex()], + 'crc_algo': cfg.crc_algo, 'crc_scope': cfg.crc_scope} + + def _save_profile_as(self, name): + """当前协议参数 + 当前命令预设表 -> 保存为板型(同名覆盖); 返回是否成功""" + name = (name or '').strip() + if not name: + self._hint('板型名称为空, 未保存') + return False + proto = self._current_proto_dict() + if proto is None: + self._hint(f'协议参数非法, 未保存: {self._proto_err}') + return False + entry = {'name': name, 'proto': proto, + 'commands': [list(c) for c in self._commands]} + idx = next((i for i, p in enumerate(self.profiles) + if p['name'] == name), None) + if idx is None: + self.profiles.append(entry) + else: + self.profiles[idx] = entry + config.save_profiles(self.profiles) + idx = self._refresh_profile_combo(name) + self._on_profile(idx, apply_proto=False) # 装载该板型命令表 + self._hint(f'板型已保存: {name}({len(self._commands)} 条命令)') + return True + + def _on_save_profile_as(self): + text, ok = QInputDialog.getText(self, '存为板型', '板型名称:', + text=self.combo_profile.currentText()) + if ok: + self._save_profile_as(text) + + def _new_profile_named(self, name): + """按当前协议参数新建板型, 命令预设从空白开始(不继承其他板型的命令)""" + name = (name or '').strip() + if not name: + self._hint('板型名称为空, 未新建') + return False + if any(p['name'] == name for p in self.profiles): + self._hint(f'板型 "{name}" 已存在, 覆盖请用 存为板型') + return False + proto = self._current_proto_dict() + if proto is None: + self._hint(f'协议参数非法, 未新建: {self._proto_err}') + return False + self.profiles.append({'name': name, 'proto': proto, 'commands': []}) + config.save_profiles(self.profiles) + idx = self._refresh_profile_combo(name) + self._on_profile(idx, apply_proto=False) + self._hint(f'已新建板型: {name}(命令预设为空, 用 存入/编辑 添加)') + return True + + def _on_new_profile(self): + text, ok = QInputDialog.getText(self, '新建板型', '板型名称:') + if ok: + self._new_profile_named(text) + + def _delete_profile(self, name): + """删除板型(连同其命令预设); 至少保留一个, 删除后切到第一个板型""" + if len(self.profiles) <= 1: + self._hint('至少保留一个板型, 不能删除') + return False + idx = next((i for i, p in enumerate(self.profiles) + if p['name'] == name), None) + if idx is None: + self._hint(f'板型 "{name}" 不存在') + return False + del self.profiles[idx] + config.save_profiles(self.profiles) + idx = self._refresh_profile_combo(self.profiles[0]['name']) + self._on_profile(idx, apply_proto=False) # 装载新板型命令表, 协议参数不动 + self._hint(f'板型已删除: {name}') + return True + + def _on_delete_profile(self): + name = self.combo_profile.currentText() + if QMessageBox.question( + self, '删除板型', + f'确定删除板型 "{name}"?\n其命令预设将一并删除。', + QMessageBox.Yes | QMessageBox.No) == QMessageBox.Yes: + self._delete_profile(name) + + def _refresh_profile_combo(self, select_name=''): + """重建板型下拉(阻断信号, 由调用方决定是否触发切换), 返回选中索引""" + self.combo_profile.blockSignals(True) + self.combo_profile.clear() + for p in self.profiles: + self.combo_profile.addItem(p['name']) + idx = next((i for i, p in enumerate(self.profiles) if p['name'] == select_name), + 0 if self.profiles else -1) + self.combo_profile.setCurrentIndex(idx) + self.combo_profile.blockSignals(False) + return idx + + def _edit_profile_commands(self): + """弹窗表格编辑当前板型的命令预设(名称/命令两列, +加行 −删行)""" + idx = self.combo_profile.currentIndex() + if not 0 <= idx < len(self.profiles): + self._hint('无有效板型, 无法编辑') + return + dlg = ProfileEditDialog(self.profiles[idx], self) + if dlg.exec() != QDialog.Accepted: + return + cmds = dlg.commands() + self.profiles[idx]['commands'] = cmds + config.save_profiles(self.profiles) + self._commands = [list(c) for c in cmds] + self._reload_preset_combo() + self._hint(f'板型 "{self.profiles[idx]["name"]}" 命令预设已保存({len(cmds)} 条)') + + def _reload_profiles(self): + cur = self.combo_profile.currentText() + self.profiles = config.load_profiles() + idx = self._refresh_profile_combo(cur) + self._on_profile(idx, apply_proto=False) + self._hint(f'板型档案已重载(共 {len(self.profiles)} 个)') + + def _on_periodic(self, *_): + if self.chk_periodic.isChecked(): + self._periodic.start(self.spin_interval.value()) + else: + self._periodic.stop() + + def _hex_input(self, text): + raw = re.sub(r'[^0-9A-Fa-f]', '', text) + if not raw or len(raw) % 2: + raise ValueError('HEX 直发需要偶数个十六进制字符') + return bytes.fromhex(raw) + + def _update_preview(self, *_): + text = self.ed_tx.text().strip() + if TX_MODES[self.combo_tx_mode.currentIndex()] == 'hex': + try: + self.lb_preview.setText(f'→ {hexs(self._hex_input(text))}') + except ValueError as e: + self.lb_preview.setText(f'⚠ {e}') + return + cfg = self._proto_from_ui() + if cfg is None: + self.lb_preview.setText(f'⚠ 协议参数: {self._proto_err}') + self.lb_proto.setText(f'⚠ {self._proto_err}') + return + self.lb_proto.setText( + '令牌: HDR=包头 ID=板ID LEN=长度 PAYLOAD=载荷 CRC=校验, ' + '十六进制字面量(如 AA 55)为固定同步字节') + if not text: + self.lb_preview.setText('') + return + try: + frames = [cfg.build_frame(s.strip().encode('ascii')) + for s in text.split('/') if s.strip()] + except (ValueError, UnicodeEncodeError) as e: + self.lb_preview.setText(f'⚠ {e}') + return + self.lb_preview.setText(' | '.join(hexs(f) for f in frames)) + + def _send(self): + text = self.ed_tx.text().strip() + if not text: + return + if TX_MODES[self.combo_tx_mode.currentIndex()] == 'hex': + try: + data = self._hex_input(text) + except ValueError as e: + self._hint(f'发送失败: {e}') + return + self.link.send_frame(data, label=None) + else: + cfg = self._proto_from_ui() + if cfg is None: + self._hint('协议参数非法, 无法组帧') + return + try: + items = [] + for seg in text.split('/'): + seg = seg.strip() + if not seg: + raise ValueError('存在空段(检查 / 分隔符)') + payload = seg.encode('ascii') + if len(payload) > LEN_MAX: + raise ValueError(f'"{seg[:8]}…" 超长(>{LEN_MAX} 字节)') + items.append((seg, cfg.build_frame(payload))) + except (ValueError, UnicodeEncodeError) as e: + self._hint(f'发送失败: {e}') + return + for seg, frame in items: + self.link.send_frame(frame, label=seg) + if text not in self.tx_history or not self.tx_history \ + or self.tx_history[-1] != text: + self.tx_history.append(text) + if len(self.tx_history) > 100: + self.tx_history.pop(0) + self._hist_pos = -1 + + def eventFilter(self, obj, ev): + if obj is self._plot_win and ev.type() == QEvent.Close: + self._dock_plot() # 关掉曲线窗口 = 嵌回主界面 + return True + if obj is self.ed_tx and ev.type() == QEvent.KeyPress: + if ev.key() == Qt.Key_Up: + self._hist_step(-1) + return True + if ev.key() == Qt.Key_Down: + self._hist_step(1) + return True + return super().eventFilter(obj, ev) + + def _hist_step(self, d): + """↑ 回溯更早的历史, ↓ 退回更新的(越过末尾清空输入框)""" + if not self.tx_history: + return + if d < 0: + self._hist_pos = len(self.tx_history) - 1 if self._hist_pos == -1 \ + else max(0, self._hist_pos - 1) + self.ed_tx.setText(self.tx_history[self._hist_pos]) + elif self._hist_pos != -1: + self._hist_pos += 1 + if self._hist_pos >= len(self.tx_history): + self._hist_pos = -1 + self.ed_tx.clear() + else: + self.ed_tx.setText(self.tx_history[self._hist_pos]) + + # ------------------------------------------------------- 事件与回显 + + def _drain_events(self): + for _ in range(400): # 单次上限, 防极端速率卡 UI + try: + ev = self.events.get_nowait() + except queue.Empty: + return + kind = ev[0] + if kind == 'status': + self._on_status(ev[1]) + elif kind == 'frame': + self._on_frame(ev[1]) + elif kind == 'raw': + self._on_raw(ev[1]) + elif kind == 'tx': + self._on_tx(ev[1], ev[2]) + + def _hint(self, text): + self.lb_state.setText(text) + self.statusBar().showMessage(text, 8000) + self._append_rx(f'· {_timestamp()} {text}') + + def _on_status(self, text): + """连接状态: 同步连接按钮文案 + 提示""" + if text.startswith('已连接'): + self.btn_conn.setText('断开') + elif text.startswith(('已断开', '连接失败', '串口丢失')): + self.btn_conn.setText('连接') + self._hint(text) + + def _append_rx(self, line): + self.text_rx.appendPlainText(line) + + def _append_rx_frame(self, prefix, core, suffix=''): + """帧日志行: payload 核心内容垫一层浅背景色, 目光一眼锁定。 + 用半透明 rgba —— 主题无关, 明暗切换后历史行不会残留异色""" + self.text_rx.appendHtml( + f'{html.escape(prefix)}' + f'' + f'{html.escape(core)}' + f'{html.escape(suffix)}') + + def _on_frame(self, fr): + self._rx_n += 1 + self._rx_b += len(fr.raw) + ts = f'{_timestamp()} ' if self.chk_ts.isChecked() else '' + disp = RX_DISPLAYS[self.combo_display.currentIndex()] + payload = hexs(fr.payload) if disp == 'hex' else \ + (clean_text(fr.payload.decode('ascii', 'replace')) or '(空)') + if fr.code is None: # 布局无 ID 字段 + head = f'← {ts}' + else: + name = TYPE_NAMES.get(fr.code) + head = f'← {ts}B=0x{fr.code:02X}' + (f' {name}' if name else '') + suffix = f' [{hexs(fr.raw)}]' if self.chk_raw.isChecked() else '' + self._append_rx_frame(f'{head} LEN={len(fr.payload)} ', payload, suffix) + for i, c in enumerate(self._collect_channels()): + if not c['enabled']: + continue + v = extract_value(fr.payload, c['mode'], offset=c['offset'], + fmt=c['fmt'], index=c['index'], + pattern=c['pattern'], scale=c['scale']) + if v is not None: + self._ch_ui[i]['val'].setText(fmt_val(v)) + self.plot.add(i, v) + self._update_counters() + + def _on_raw(self, data): + self._rx_n += 1 + self._rx_b += len(data) + ts = f'{_timestamp()} ' if self.chk_ts.isChecked() else '' + disp = RX_DISPLAYS[self.combo_display.currentIndex()] + body = hexs(data) if disp == 'hex' else clean_text(data.decode('ascii', 'replace')) + self._append_rx(f'← {ts}{body}') + self._update_counters() + + def _on_tx(self, label, data): + self._tx_n += 1 + self._tx_b += len(data) + if label is None: + self._append_rx(f'→ {_timestamp()} [HEX] {hexs(data)}') + else: + self._append_rx(f'→ {_timestamp()} {label} [{hexs(data)}]') + self._update_counters() + + def _update_counters(self): + self.statusBar().showMessage( + f'发送 {self._tx_n} 帧 / {self._tx_b} 字节 接收 {self._rx_n} 帧 / {self._rx_b} 字节') + + def _on_theme(self, idx): + self.theme_manager.set_mode(('system', 'light', 'dark')[idx]) + + def show_about(self): + QMessageBox.about( + self, + f'关于 {APP_NAME}', + f'{APP_NAME} v{APP_VERSION}\n\n' + 'RS485/串口调试助手 · 收发 + 协议解析 + 曲线绘制\n' + '帧构成/包头/板ID/LEN口径/CRC 全可定制, 默认兼容 DLPilot 光机协议\n' + 'PySide6-Fluent-Widgets + pyserial\n\n' + f'{REPO_URL}') + + # ------------------------------------------------------- 配置进出 + + def _apply_store_to_ui(self): + s, p, t, r = (self.cfg_store['serial'], self.cfg_store['proto'], + self.cfg_store['tx'], self.cfg_store['rx']) + self.combo_baud.setCurrentText(str(s['baud'])) + self.combo_databits.setCurrentIndex( + ('8', '7', '6', '5').index(str(s['bytesize']))) + self.combo_parity.setCurrentIndex('NEO'.index(s['parity'][0].upper())) + self.combo_stop.setCurrentIndex(('1', '2').index(str(s['stopbits']))) + self.ed_tx_hdr.setText(p['tx_hdr']) + self.ed_rx_hdr.setText(p['rx_hdr']) + self.ed_board.setText(p['board']) + self.ed_layout.setText(p.get('layout', DEFAULT_LAYOUT)) + self.combo_len_sem.setCurrentIndex( + LEN_SEMS.index(p.get('len_sem', 'payload'))) + self.combo_crc.setCurrentIndex(tuple(CRC_ALGOS).index(p['crc_algo'])) + self.combo_scope.setCurrentIndex(tuple(CRC_SCOPE_LABELS).index(p['crc_scope'])) + self.chk_parse.setChecked(bool(p['parse'])) + self.combo_tx_mode.setCurrentIndex(TX_MODES.index(t['mode'])) + self.chk_periodic.setChecked(False) # 周期发送不随配置自动启动 + self.spin_interval.setValue(int(t['interval_ms'])) + self.combo_display.setCurrentIndex(RX_DISPLAYS.index(r['display'])) + self.chk_raw.setChecked(bool(r['show_raw'])) + # 板型: 命中记忆的板型则装载其命令表(协议参数以上方 store 为准, 不重复应用) + name = t.get('profile', '') + idx = next((i for i, p in enumerate(self.profiles) if p['name'] == name), + 0 if self.profiles else -1) + self.combo_profile.blockSignals(True) + self.combo_profile.setCurrentIndex(idx) + self.combo_profile.blockSignals(False) + self._on_profile(idx, apply_proto=False) + for i, c in enumerate(self.cfg_store['channels']): + ui = self._ch_ui[i] + ui['chk'].setChecked(bool(c['enabled'])) + ui['name'].setText(c['name']) + ui['mode'].setCurrentIndex(CH_MODES.index(c['mode'])) + ui['off'].setValue(int(c['offset'])) + ui['fmt'].setCurrentIndex( + [k for k, _ in FMT_LABELS].index(c['fmt'])) + ui['idx'].setValue(int(c['index'])) + ui['pat'].setText(c['pattern']) + ui['scale'].setValue(float(c['scale'])) + if s['port']: + for i, (dev, _label) in enumerate(self._port_devs): + if dev == s['port']: + self.combo_port.setCurrentIndex(i) + break + self._apply_proto() + + def _store_from_ui(self): + idx = self.combo_port.currentIndex() + port = self._port_devs[idx][0] if 0 <= idx < len(self._port_devs) else '' + try: + baud = int(self.combo_baud.currentText() or '115200') + except ValueError: + baud = 115200 + proto = self._proto_from_ui() + p = self.cfg_store['proto'] + if proto is not None: + p.update(tx_hdr=f'{proto.tx_hdr:02X}', rx_hdr=f'{proto.rx_hdr:02X}', + board=f'{proto.board:02X}', crc_algo=proto.crc_algo, + crc_scope=proto.crc_scope, + layout=self.ed_layout.text().strip() or DEFAULT_LAYOUT, + len_sem=LEN_SEMS[self.combo_len_sem.currentIndex()]) + p['parse'] = self.chk_parse.isChecked() + return { + 'serial': {'port': port, 'baud': baud, + 'bytesize': int(self.combo_databits.currentText()), + 'parity': self.combo_parity.currentText()[0], + 'stopbits': int(self.combo_stop.currentText())}, + 'proto': p, + 'tx': {'mode': TX_MODES[self.combo_tx_mode.currentIndex()], + 'periodic': self.chk_periodic.isChecked(), + 'interval_ms': self.spin_interval.value(), + 'profile': self.combo_profile.currentText()}, + 'rx': {'display': RX_DISPLAYS[self.combo_display.currentIndex()], + 'show_raw': self.chk_raw.isChecked()}, + 'channels': self._collect_channels(), + } + + def closeEvent(self, ev): + self._periodic.stop() + self._dock_plot() # 曲线弹出窗口一并收拢, 保证应用退出 + self.link.close() + config.save(self._store_from_ui()) + super().closeEvent(ev) + + +def icon_path(): + """应用图标: exe(onefile)在 _MEIPASS/assets, 源码运行在工具根 assets/""" + if hasattr(sys, '_MEIPASS'): + return os.path.join(sys._MEIPASS, 'assets', 'serial-mate.ico') + return os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', 'assets', 'serial-mate.ico') + + +def main(): + ap = argparse.ArgumentParser(description='SerialMate 串口/RS485 调试助手') + ap.add_argument('--seconds', type=float, default=0, + help='N 秒后自动退出(冒烟测试)') + _args = ap.parse_args() + app = QApplication(sys.argv) + app.setWindowIcon(QIcon(icon_path())) # 主窗/弹窗/任务栏统一图标 + win = Window(seconds=_args.seconds) + win.show() + sys.exit(app.exec()) + + +if __name__ == '__main__': + main() diff --git a/SerialMate/src/package_release.py b/SerialMate/src/package_release.py new file mode 100644 index 0000000..bd0d736 --- /dev/null +++ b/SerialMate/src/package_release.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +组装 SerialMate 发布包: exe + README → 一个 zip(解压即用) +================================================ +把主 README 复制进 release/ 后, 与单文件 exe 一起压成 zip —— 两者内容一致。 + +zip 解压后根目录为 SerialMate/: + SerialMate.exe 主程序(先运行 build_exe.py 打包) + README.md 使用说明 + +用法: + python package_release.py 版本号自动取最近 git tag(SerialMate-v*) + python package_release.py 0.1.0 手动指定版本号 +产物: release/SerialMate-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release) +""" + +import os +import shutil +import subprocess +import sys +import zipfile + +SRC = os.path.dirname(os.path.abspath(__file__)) +BASE = os.path.dirname(SRC) +RELEASE = os.path.join(BASE, 'release') + + +def detect_version(): + """从最近的 SerialMate-v* tag 推断版本号, 无 tag 则报错提示手动指定""" + try: + out = subprocess.check_output( + ['git', 'describe', '--tags', '--abbrev=0', '--match', 'SerialMate-v*'], + cwd=BASE, stderr=subprocess.DEVNULL) + return out.decode().strip().removeprefix('SerialMate-v') + except Exception: + sys.exit('未能从 git tag 推断版本号, 请手动指定: python package_release.py <版本号>') + + +def main(): + version = (sys.argv[1] if len(sys.argv) > 1 else detect_version()).lstrip('v') + exe = os.path.join(RELEASE, 'SerialMate.exe') + readme = os.path.join(BASE, 'README.md') + if not os.path.exists(exe): + sys.exit('缺少 release/SerialMate.exe, 先运行: python build_exe.py') + if not os.path.exists(readme): + sys.exit(f'缺少 {readme}') + + shutil.copy2(readme, os.path.join(RELEASE, 'README.md')) + zip_name = f'SerialMate-v{version}-win64.zip' + zip_path = os.path.join(RELEASE, zip_name) + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z: + z.write(exe, f'SerialMate/{os.path.basename(exe)}') + z.write(os.path.join(RELEASE, 'README.md'), 'SerialMate/README.md') + print(f'发布文件夹: {RELEASE} (exe + README)') + print(f'发布包: {zip_path} ({os.path.getsize(zip_path) / 1024 / 1024:.1f} MB)') + print('下一步上传: python publish_gitea.py', zip_name) + + +if __name__ == '__main__': + main() diff --git a/SerialMate/src/plot.py b/SerialMate/src/plot.py new file mode 100644 index 0000000..8c4bc0a --- /dev/null +++ b/SerialMate/src/plot.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +轻量滚动曲线控件(QPainter 绘制, 不引入 matplotlib/pyqtgraph) +================================================================ +N 通道时间序列: add(ch, value) 追加点(内部取相对时间), deque 限长滚动丢弃; +y 轴自动缩放(全平数据回落 ±1)或 set_yrange 固定量程; 颜色/文字取自当前 +palette, 明暗主题自动跟随。每通道 >1000 点时抽稀绘制, 3000 点 × 4 通道 +重绘开销可控。 +""" + +import bisect +import time +from collections import deque + +from PySide6.QtCore import QPointF, QRect, Qt +from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen, QPalette +from PySide6.QtWidgets import QSizePolicy, QWidget + + +def fmt_val(v): + """数值可读显示: 普通小数带千分位(最多 4 位小数, 去尾零), 达 10 亿量级 + 才退回科学计数 —— 调试现场 2.821e+07 这类写法不直观""" + if v != v or v in (float('inf'), float('-inf')): + return str(v) + if abs(v) >= 1e9: + return f'{v:.4g}' + s = f'{v:,.4f}' if abs(v) < 1 else f'{v:,.3f}' + return s.rstrip('0').rstrip('.') or '0' + + +class PlotWidget(QWidget): + + COLORS = ('#0f6cbd', '#d83b01', '#107c10', '#8764b8') + MAXLEN = 3000 # 每通道缓冲点数(超限滚动丢弃) + M_LEFT, M_TOP, M_RIGHT, M_BOTTOM = 52, 10, 12, 22 # 绘图区内边距 + + def __init__(self, channels=4, parent=None): + super().__init__(parent) + n = channels + self._times = [deque(maxlen=self.MAXLEN) for _ in range(n)] + self._vals = [deque(maxlen=self.MAXLEN) for _ in range(n)] + self._names = [''] * n + self._latest = [None] * n + self._paused = False + self._yrange = None # None=自动 或 (min, max) + self._span = None # 可见时间窗宽(秒); None=跟随全部 + self._offset = 0.0 # 右端距最新数据的秒数(0=实时) + self._frozen = False # 暂停渲染: 继续收数但不重绘 + self._drag_y = None # Y 轴拖动基准(仅手动量程) + self.on_yrange_changed = None # 拖动改 Y 量程回调(主界面同步控件) + self.on_span_changed = None # 滚轮改时间窗回调(主界面同步下拉) + self._hover_pos = None # 悬停光标位置(None=不在绘图区) + self._hover_lines = [] # 悬停读值(顶部小框内容) + self._t0 = time.perf_counter() + self.setMinimumHeight(150) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.setMouseTracking(True) # 无按键也接收 mouseMove(悬停读值) + + # --- 数据接口(主线程调用) --- + + def set_channel_name(self, ch, name): + self._names[ch] = name + self._touch() + + def set_yrange(self, yr): + self._yrange = yr + self._touch() + + def set_view(self, span): + """设置可见时间窗宽(秒); None=跟随全部数据""" + self._span = None if span is None else max(float(span), 1.0) + self._touch() + + def set_offset(self, offset): + """可见窗右端距最新数据的秒数(0=实时跟随最新)""" + self._offset = max(float(offset), 0.0) + self._touch() + + def view_offset(self): + return self._offset + + def view_span(self): + return self._span + + def view_range(self): + """(全部数据 tmin, tmax, 可回看的最大滞后秒数) —— 供时间滑条换算""" + t_all = [t for ts in self._times if ts for t in (ts[0], ts[-1])] + tmin = min(t_all) if t_all else 0.0 + tmax = max(t_all) if t_all else tmin + 5.0 + span = self._span or max(tmax - tmin, 5.0) + return tmin, tmax, max(tmax - tmin - span, 0.0) + + def visible_domain(self, tmin, tmax): + """按时间窗/滞后换算可见横轴域(纯计算, 供单测)。 + 不做数据边界收敛: 选 5min 窗而数据只有 23s 时, 显示完整的 5min 轴, + 数据贴右端随采集逐渐填满(示波器式); 滞后超出全部历史则为空窗""" + if self._span is None: + return tmin, tmax + right = tmax - self._offset + left = right - self._span + if right <= left: # 滞后超出全部历史 -> 空窗保宽 + right = left + self._span + return left, right + + def auto_yrange(self): + """自动 Y 量程: 可见数据的 min/max 加 8% 边距(全平数据回落 ±1)""" + visible = [v for ch in range(len(self._vals)) for v in self._vals[ch]] + if not visible: + return -1.0, 1.0 + ylo, yhi = min(visible), max(visible) + if ylo == yhi: + return ylo - 1.0, yhi + 1.0 + pad = (yhi - ylo) * 0.08 + return ylo - pad, yhi + pad + + def set_paused(self, paused): + self._paused = bool(paused) + + def is_paused(self): + return self._paused + + def set_frozen(self, frozen): + """暂停渲染(弹窗用): 继续接收入库但不重绘; 解冻后一次性渲染全部积累。 + 冻结是绝对状态: 覆写 update() 吞掉一切重绘请求, 任何代码路径都无法 + 在冻结期打破(悬停/滑条等), 只有 set_frozen(False) 恢复""" + self._frozen = bool(frozen) + if not self._frozen: + super().update() + + def is_frozen(self): + return self._frozen + + def update(self): + if self._frozen: + return + super().update() + + def _touch(self): + self.update() + + def add(self, ch, value): + if self._paused or value is None: + return + self._times[ch].append(time.perf_counter() - self._t0) + self._vals[ch].append(float(value)) + self._latest[ch] = float(value) + self._touch() + + def clear(self): + for t in self._times: + t.clear() + for v in self._vals: + v.clear() + self._latest = [None] * len(self._latest) + self._touch() + + # --- 悬停读值 --- + + def _nearest(self, ch, t, tol): + """通道 ch 在时刻 t 附近(±tol 秒)最近的采样点 -> (名称, 值) 或 None""" + ts, vs = self._times[ch], self._vals[ch] + if not ts: + return None + lst = list(ts) # deque 无随机 bisect, 拷贝代价可接受 + i = bisect.bisect_left(lst, t) + best = None + for j in (i - 1, i): + if 0 <= j < len(lst) and abs(lst[j] - t) <= tol: + if best is None or abs(lst[j] - t) < abs(lst[best] - t): + best = j + if best is None: + return None + return self._names[ch] or f'CH{ch + 1}', vs[best] + + def _plot_geometry(self): + """绘图区几何与可见横轴域: (left, top, pw, ph, tmin, tmax)""" + left, top = self.M_LEFT, self.M_TOP + pw = self.width() - left - self.M_RIGHT + ph = self.height() - top - self.M_BOTTOM + t_all = [t for ts in self._times if ts for t in (ts[0], ts[-1])] + if t_all: + tmin, tmax = min(t_all), max(t_all) + else: + tmin, tmax = 0.0, 5.0 + tmin, tmax = self.visible_domain(tmin, tmax) + return left, top, pw, ph, tmin, tmax + + def wheelEvent(self, ev): + """滚轮缩放时间窗: 上滚放大(窗宽变短), 下滚缩小; 滞后按比例缩放""" + delta = ev.angleDelta().y() + if delta: + factor = 0.8 if delta > 0 else 1.25 + old = self._span + if old is None: # 跟随模式 -> 以当前数据跨度为基准 + _tmin, tmax, _mp = self.view_range() + old = max(tmax - _tmin, 5.0) + new = max(min(old * factor, 900.0), 2.0) + if self._offset and old: + self._offset = self._offset * new / old + self.set_view(new) + if self.on_span_changed is not None: + self.on_span_changed() + super().wheelEvent(ev) + + def mousePressEvent(self, ev): + if ev.button() == Qt.LeftButton and self._yrange is not None: + self._drag_y = ev.position().toPoint().y() + super().mousePressEvent(ev) + + def mouseReleaseEvent(self, ev): + if ev.button() == Qt.LeftButton: + self._drag_y = None + super().mouseReleaseEvent(ev) + + def mouseMoveEvent(self, ev): + pos = ev.position().toPoint() + if self._drag_y is not None and self._yrange is not None: + _left, _top, _pw, ph, *_rest = self._plot_geometry() + if ph > 40: + ylo, yhi = self._yrange + shift = (yhi - ylo) * (pos.y() - self._drag_y) / ph + yr = (ylo - shift, yhi - shift) # 下拖数据下移, 上拖上移 + self.set_yrange(yr) + if self.on_yrange_changed is not None: + self.on_yrange_changed(yr) + self._drag_y = pos.y() + self._hover_pos = pos + self.update() + super().mouseMoveEvent(ev) + + def leaveEvent(self, ev): + self._hover_pos = None + self.update() + super().leaveEvent(ev) + + def _hover_lines_at(self, pos): + """光标位置 -> 读值框内容; 不在绘图区/无命中返回空列表。 + 由 paintEvent 每帧调用(而非仅在鼠标移动时), 鼠标停住不动, + 实时数据到来时读值依然持续刷新(用户实测反馈)""" + left, top, pw, ph, tmin, tmax = self._plot_geometry() + span = max(tmax - tmin, 0.001) + if pw < 40 or ph < 40 or not (left <= pos.x() <= left + pw) \ + or not (top <= pos.y() <= top + ph): + return [] + t = tmin + (pos.x() - left) / pw * span + tol = span / max(pw, 1) * 10 # 光标 ±10px 内的采样算命中 + lines = [f'T = {t:.2f}s'] + for ch in range(len(self._vals)): + got = self._nearest(ch, t, tol) + if got: + lines.append(f'{got[0]} = {fmt_val(got[1])}') + return lines if len(lines) > 1 else [] + + # --- 绘制 --- + + def paintEvent(self, _ev): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + rect = self.rect() + pal = self.palette() + p.fillRect(rect, pal.color(QPalette.Base)) + text = pal.color(QPalette.WindowText) + dim = QColor(text) + dim.setAlpha(110) + grid = QColor(text) + grid.setAlpha(38) + + left, top = self.M_LEFT, self.M_TOP + right, bottom = self.M_RIGHT, self.M_BOTTOM + pw, ph = rect.width() - left - right, rect.height() - top - bottom + if pw < 40 or ph < 40: + return + + p.setFont(QFont(self.font().family(), 8)) + p.setPen(dim) + + # y 量程: 固定 或 自动(auto_yrange: 8% 边距, 全平数据回落 ±1) + if self._yrange is not None: + ylo, yhi = self._yrange + if ylo >= yhi: + yhi = ylo + 1.0 + else: + ylo, yhi = self.auto_yrange() + + def my(y): # 值 -> 像素 y + return top + (yhi - y) / (yhi - ylo) * ph + + # 横向参考线(4 等分)与每条线上的纵轴数值刻度 + p.setPen(QPen(grid, 1, Qt.DashLine)) + for i in range(1, 4): + y = top + ph * i / 4 + p.drawLine(left, int(y), left + pw, int(y)) + for i in range(1, 6): + x = left + pw * i / 6 + p.drawLine(int(x), top, int(x), top + ph) + p.setPen(dim) + for i in range(5): + v = yhi - (yhi - ylo) * i / 4 + y = top + ph * i / 4 + p.drawText(QRect(2, int(y) - 9, left - 8, 18), + Qt.AlignRight | Qt.AlignVCenter, fmt_val(v)) + + # x 范围: 时间窗(span)内、按滞后(offset)取可见域; 无窗宽则跟随全部数据 + t_all = [t for ch in range(len(self._times)) if self._times[ch] + for t in (self._times[ch][0], self._times[ch][-1])] + if t_all: + tmin, tmax = min(t_all), max(t_all) + else: + tmin, tmax = 0.0, 5.0 + data_span = tmax - tmin + tmin, tmax = self.visible_domain(tmin, tmax) + span = max(tmax - tmin, 0.001) + + def mx(t): # 时间 -> 像素 x + return left + (t - tmin) / span * pw + + empty = True + for ch in range(len(self._vals)): + ts, vs = self._times[ch], self._vals[ch] + n = len(vs) + if n == 0: + continue + empty = False + color = QColor(self.COLORS[ch % len(self.COLORS)]) + p.setPen(QPen(color, 1.6)) + if n == 1: + p.drawEllipse(QPointF(mx(ts[0]), my(vs[0])), 2.2, 2.2) + continue + step = max(1, n // 1000) + path = QPainterPath(QPointF(mx(ts[0]), my(vs[0]))) + for i in range(step, n, step): + path.lineTo(mx(ts[i]), my(vs[i])) + path.lineTo(mx(ts[-1]), my(vs[-1])) + p.drawPath(path) + + p.setPen(dim) + tag = '窗口' if self._span is not None else '最近' + if self._span is not None and data_span < self._span - 0.5: + label = f'已采集 {data_span:.0f}s / 窗口 {self._span:.0f}s' + else: + label = f'{tag} {span:.0f}s' if span >= 10 else f'{tag} {span:.1f}s' + p.drawText(rect.adjusted(0, rect.height() - 18, -4, 0), + Qt.AlignRight | Qt.AlignVCenter, label) + if self._hover_pos is not None: + self._hover_lines = self._hover_lines_at(self._hover_pos) + if self._hover_lines: + self._hover_box(p) + if empty: + p.drawText(rect, Qt.AlignCenter, '等待数据…(勾选通道并使能接收)') + elif self._paused: + p.drawText(rect.adjusted(0, top, 0, 0), Qt.AlignHCenter, '已暂停') + elif self._frozen: + p.drawText(rect.adjusted(0, top, 0, 0), Qt.AlignHCenter, + '已冻结渲染(采集继续)') + self._legend(p, rect, text) + + def _hover_box(self, p): + """顶部居中的悬停读值小框(反色底), 内容在 mouseMove 里持续刷新""" + p.setFont(QFont(self.font().family(), 9)) + fm = p.fontMetrics() + w = max(fm.horizontalAdvance(s) for s in self._hover_lines) + 16 + h = fm.height() * len(self._hover_lines) + 10 + x = self.M_LEFT + (self.width() - self.M_LEFT - self.M_RIGHT - w) // 2 + y = self.M_TOP + 6 + bg = QColor(self.palette().color(QPalette.WindowText)) + bg.setAlpha(210) + p.setPen(Qt.NoPen) + p.setBrush(bg) + p.drawRoundedRect(x, y, w, h, 4, 4) + p.setPen(self.palette().color(QPalette.Base)) + for i, s in enumerate(self._hover_lines): + p.drawText(x + 8, y + 5 + i * fm.height(), w - 16, fm.height(), + Qt.AlignLeft | Qt.AlignVCenter, s) + p.setBrush(Qt.NoBrush) + + def _legend(self, p, rect, text): + """右上角图例: 色块 + 通道名 + 最新值""" + lines = [] + for ch in range(len(self._vals)): + if self._latest[ch] is None: + continue + name = self._names[ch] or f'CH{ch + 1}' + lines.append((ch, f'{name} = {fmt_val(self._latest[ch])}')) + if not lines: + return + p.setFont(QFont(self.font().family(), 8)) + fm = p.fontMetrics() + w = max(fm.horizontalAdvance(s) for _c, s in lines) + 26 + h = fm.height() * len(lines) + 8 + x1, y1 = rect.width() - 16 - w, 8 + bg = QColor(text) + bg.setAlpha(18) + p.setPen(Qt.NoPen) + p.setBrush(bg) + p.drawRoundedRect(x1, y1, w, h, 4, 4) + p.setBrush(Qt.NoBrush) + for i, (ch, s) in enumerate(lines): + y = y1 + 4 + i * fm.height() + p.setPen(Qt.NoPen) + p.setBrush(QColor(self.COLORS[ch % len(self.COLORS)])) + p.drawRect(x1 + 6, y + fm.ascent() - 8, 8, 8) + p.setPen(self.palette().color(QPalette.WindowText)) + p.drawText(x1 + 20, y, w - 22, fm.height(), + Qt.AlignLeft | Qt.AlignVCenter, s) diff --git a/SerialMate/src/protocol.py b/SerialMate/src/protocol.py new file mode 100644 index 0000000..bec61b9 --- /dev/null +++ b/SerialMate/src/protocol.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +SerialAssistant 串口协议模块(纯标准库, 帧构成可定制) +====================================================== +帧由"构成令牌序列"定义(默认与 DLPilot 光机 RS485 协议完全一致): + HDR ID LEN PAYLOAD CRC -> 包头 + 板ID + LEN + payload + CRC + LEN = payload 字节数(口径可选, 见 LEN_SEMS), 默认即"包长对应 payload 长度" + 令牌: HDR=包头 ID=板ID LEN=长度 PAYLOAD=载荷 CRC=校验(必须最后), + 其余 1~2 位十六进制(如 AA 55)为固定字面量字节(双字节同步头)。 + 约束: LEN/PAYLOAD/CRC 恰好各一次; LEN 前至少一个 HDR 或字面量(同步定界用)。 + 同一布局收发共用, 仅包头值分方向(TX=tx_hdr / RX=rx_hdr); ID 在 RX 侧 + 按上游语义多为响应 TYPE 码, 解析时不校验、原样透出(Frame.code, 可为 None)。 + +CRC 算法(2 字节算法在线上的字节序: MODBUS 低位在前, 其余高位在前): + SUM8 累加和取低 8 位(1 字节) + CRC8 poly 0x07 init 0x00(1 字节, 标准校验值 check("123456789")=0xF4) + CRC16_MODBUS poly 0x8005 反射 init 0xFFFF(2 字节, check=0x4B37) + CRC16_XMODEM poly 0x1021 init 0x0000(2 字节, check=0x31C3) + CRC16_CCITT poly 0x1021 init 0xFFFF(2 字节, check=0x29B1) + +CRC 覆盖范围(CRC_SCOPES, 按帧内位置定义, 任意布局通用): + with_header 包头(第1字节)起至 CRC 前 + no_header 第 2 字节起至 CRC 前(DLPilot 默认) + payload_only 仅 payload + +仅标准库; pyserial 只允许出现在 link.py。 +""" + +import collections +import re +import struct + +LEN_MAX = 255 # LEN 为单字节, payload 上限 255 + +TYPE_NAMES = { # DLPilot/上游 protocols.md 的响应 TYPE 码语义, 用于 RX 译码显示 + 0xF0: '成功', + 0xF1: 'CRC 校验错', + 0xF2: '包头错', + 0xF3: '板类型错', + 0xF4: '格式/缺参数', + 0xFD: '参数越界/读回复核不一致', + 0xFE: '未知命令', + 0xFF: '通用错误(含就绪门禁拒绝)', + 0xFA: '开光机通信失败', + 0xFB: '关光机通信失败', + 0xFC: '读回复核通信失败', +} + + +# --- CRC 实现(查表, 供高波特率下增量解析用) --------------------------------- + +def _table_crc8(): + tbl = [] + for b in range(256): + c = b + for _ in range(8): + c = ((c << 1) ^ 0x07) & 0xFF if c & 0x80 else (c << 1) & 0xFF + tbl.append(c) + return tuple(tbl) + + +def _table_crc16_msb(): + """MSB-first 表(poly 0x1021, 用于 XMODEM/CCITT)""" + tbl = [] + for b in range(256): + c = (b << 8) & 0xFFFF + for _ in range(8): + c = ((c << 1) ^ 0x1021) & 0xFFFF if c & 0x8000 else (c << 1) & 0xFFFF + tbl.append(c) + return tuple(tbl) + + +def _table_crc16_reflected(): + """反射表(poly 0x8005 → 0xA001, 用于 MODBUS)""" + tbl = [] + for b in range(256): + c = b + for _ in range(8): + c = (c >> 1) ^ 0xA001 if c & 1 else c >> 1 + tbl.append(c) + return tuple(tbl) + + +_TAB_CRC8 = _table_crc8() +_TAB_CRC16_MSB = _table_crc16_msb() +_TAB_CRC16_REF = _table_crc16_reflected() + + +def _sum8(data): + return sum(data) & 0xFF + + +def _crc8(data, _t=_TAB_CRC8): + c = 0 + for b in data: + c = _t[c ^ b] + return c + + +def _crc16_msb(data, init, _t=_TAB_CRC16_MSB): + c = init + for b in data: + c = ((c << 8) & 0xFFFF) ^ _t[((c >> 8) ^ b) & 0xFF] + return c + + +def _crc16_xmodem(data): + return _crc16_msb(data, 0x0000) + + +def _crc16_ccitt(data): + return _crc16_msb(data, 0xFFFF) + + +def _crc16_modbus(data, _t=_TAB_CRC16_REF): + c = 0xFFFF + for b in data: + c = (c >> 8) ^ _t[(c ^ b) & 0xFF] + return c + + +# name -> (CRC 字节数, 计算函数, 线上字节序: 'big'=高字节在前 / 'little'=低字节在前) +CRC_ALGOS = { + 'SUM8': (1, _sum8, 'big'), + 'CRC8': (1, _crc8, 'big'), + 'CRC16_MODBUS': (2, _crc16_modbus, 'little'), + 'CRC16_XMODEM': (2, _crc16_xmodem, 'big'), + 'CRC16_CCITT': (2, _crc16_ccitt, 'big'), +} +CRC_ALGO_LABELS = { # 下拉框显示文本 + 'SUM8': 'SUM8 累加和', + 'CRC8': 'CRC8 (poly 07)', + 'CRC16_MODBUS': 'CRC16 MODBUS', + 'CRC16_XMODEM': 'CRC16 XMODEM', + 'CRC16_CCITT': 'CRC16 CCITT-FALSE', +} +CRC_SCOPES = ('with_header', 'no_header', 'payload_only') +CRC_SCOPE_LABELS = { + 'with_header': '含包头(整帧不含CRC)', + 'no_header': '不含包头(跳过第1字节, DLPilot)', + 'payload_only': '仅 payload', +} +LEN_SEMS = ('payload', 'body', 'total') +LEN_SEM_LABELS = { # LEN 字节的计数口径 + 'payload': 'payload 长度(默认)', + 'body': '到帧尾(payload+CRC)', + 'total': '整帧(含包头)', +} + +DEFAULT_LAYOUT = 'HDR ID LEN PAYLOAD CRC' +_FRAME_TOKEN_RE = re.compile(r'^(?:0x)?([0-9A-Fa-f]{1,2})$') +_KEYWORDS = {'HDR': 'hdr', 'ID': 'id', 'BOARD': 'id', 'LEN': 'len', + 'PAYLOAD': 'payload', 'DATA': 'payload', 'CRC': 'crc'} + + +class FrameLayout: + """帧构成: 令牌序列(parse 文本而来), 供组帧与解析共用。 + + tokens 元素: 'hdr'/'id'/'len'/'payload'/'crc' 或 ('lit', 字节值)。 + 约束(parse 内校验): LEN/PAYLOAD/CRC 恰好各一次; CRC 必须最后; + LEN 之前至少一个 HDR 或字面量(否则无同步定界); ID 最多一次。 + payload 之前的令牌都是 1 字节(CRC 在最后), 故 payload 字节偏移=payload_pos。 + """ + + def __init__(self, tokens): + self.tokens = tuple(tokens) + counts = collections.Counter( + t if isinstance(t, str) else 'lit' for t in self.tokens) + for key, cn in (('len', 'LEN'), ('payload', 'PAYLOAD'), ('crc', 'CRC')): + if counts[key] != 1: + raise ValueError(f'{cn} 必须恰好出现一次(当前 {counts[key]} 次)') + if self.tokens[-1] != 'crc': + raise ValueError('CRC 必须是最后一个令牌') + if counts['id'] > 1: + raise ValueError('ID 最多出现一次') + self.len_pos = self.tokens.index('len') + self.payload_pos = self.tokens.index('payload') + id_positions = [i for i, t in enumerate(self.tokens) if t == 'id'] + self.id_pos = id_positions[0] if id_positions else None + if not any(isinstance(t, tuple) or t == 'hdr' + for t in self.tokens[:self.len_pos]): + raise ValueError('LEN 之前需要至少一个 HDR 或十六进制字面量作同步头') + + @classmethod + def parse(cls, text): + """'AA 55 ID LEN PAYLOAD CRC' 之类 -> FrameLayout; 非法抛 ValueError""" + parts = [p for p in re.split(r'[\s,,;;]+', (text or '').strip()) if p] + if not parts: + raise ValueError('帧构成为空') + tokens = [] + for p in parts: + kw = _KEYWORDS.get(p.upper()) + if kw: + tokens.append(kw) + continue + m = _FRAME_TOKEN_RE.match(p) + if not m: + raise ValueError(f'未知令牌 "{p}"(可用: HDR ID LEN PAYLOAD CRC ' + f'或 0~FF 十六进制字面量)') + tokens.append(('lit', int(m.group(1), 16))) + return cls(tokens) + + # --- 尺寸(依赖 CRC 字节数, 由 ProtoConfig 传参) --- + + def rest_size(self, crc_size): + """payload 之后的字节数(ID/HDR/字面量 + CRC)""" + return crc_size + len(self.tokens) - self.payload_pos - 2 + + def total_size(self, payload_len, crc_size): + # payload 之前的令牌都是 1 字节, payload 字节偏移即 payload_pos + return self.payload_pos + payload_len + self.rest_size(crc_size) + + def len_extra(self, len_sem, crc_size): + """LEN 字节值 = payload_len + 该口径下的附加字节数""" + if len_sem == 'body': + return self.rest_size(crc_size) + if len_sem == 'total': + return self.payload_pos + self.rest_size(crc_size) + return 0 + + +Frame = collections.namedtuple('Frame', 'code payload raw') +"""code=ID 位置字节(RX 侧多为响应 TYPE 码; 布局无 ID 时为 None), +payload=原始字节, raw=整帧含 CRC""" + + +class ProtoConfig: + """协议配置: 帧构成(FrameLayout) + 包头(TX/RX)/板ID/LEN 口径/CRC 算法与覆盖""" + + def __init__(self, tx_hdr=0xD5, rx_hdr=0xB5, board=0x01, + crc_algo='SUM8', crc_scope='no_header', + layout=None, len_sem='payload'): + for name, v in (('tx_hdr', tx_hdr), ('rx_hdr', rx_hdr), ('board', board)): + if not 0 <= int(v) <= 0xFF: + raise ValueError(f'{name} 超出 0~255: {v}') + if crc_algo not in CRC_ALGOS: + raise ValueError(f'未知 CRC 算法: {crc_algo}') + if crc_scope not in CRC_SCOPES: + raise ValueError(f'未知 CRC 覆盖范围: {crc_scope}') + if len_sem not in LEN_SEMS: + raise ValueError(f'未知 LEN 口径: {len_sem}') + self.tx_hdr, self.rx_hdr, self.board = int(tx_hdr), int(rx_hdr), int(board) + self.crc_algo, self.crc_scope = crc_algo, crc_scope + self.len_sem = len_sem + self.layout = layout if layout is not None else FrameLayout.parse(DEFAULT_LAYOUT) + + @property + def crc_size(self): + return CRC_ALGOS[self.crc_algo][0] + + # --- CRC --- + + def compute_crc(self, nocrc_frame, payload_len): + """对不含 CRC 的整帧按覆盖范围切片算 CRC 值(切片按位置定义, 任意布局通用)""" + _size, fn, _order = CRC_ALGOS[self.crc_algo] + if self.crc_scope == 'no_header': + region = nocrc_frame[1:] + elif self.crc_scope == 'payload_only': + po = self.layout.payload_pos + region = nocrc_frame[po:po + payload_len] + else: + region = nocrc_frame + return fn(region) + + def encode_crc(self, value): + """CRC 值 -> 线上字节(2 字节算法按算法约定字节序)""" + size, _fn, order = CRC_ALGOS[self.crc_algo] + if size == 1: + return bytes((value & 0xFF,)) + hi, lo = (value >> 8) & 0xFF, value & 0xFF + return bytes((hi, lo)) if order == 'big' else bytes((lo, hi)) + + # --- 组帧 --- + + def build_frame(self, payload): + """payload bytes -> 完整帧 bytes; 空载荷允许(LEN=0), 超限抛 ValueError""" + payload = bytes(payload) + if len(payload) > LEN_MAX: + raise ValueError(f'payload 超长(>{LEN_MAX} 字节)') + cs = self.crc_size + len_val = len(payload) + self.layout.len_extra(self.len_sem, cs) + if len_val > LEN_MAX: + raise ValueError(f'LEN 字节溢出(={len_val}>255), 检查帧构成与 LEN 口径') + parts = [] + for tok in self.layout.tokens: + if tok == 'crc': + continue # CRC 最后, 跳过待拼 + if tok == 'hdr': + parts.append(bytes((self.tx_hdr,))) + elif tok == 'id': + parts.append(bytes((self.board,))) + elif tok == 'len': + parts.append(bytes((len_val,))) + elif tok == 'payload': + parts.append(payload) + else: + parts.append(bytes((tok[1],))) + nocrc = b''.join(parts) + return nocrc + self.encode_crc(self.compute_crc(nocrc, len(payload))) + + # --- 校验与拆帧 --- + + def _plen_of(self, raw): + """从整帧 LEN 字节解出 payload 长度; 非法(负值)返回 None""" + plen = raw[self.layout.len_pos] - self.layout.len_extra(self.len_sem, self.crc_size) + return plen if plen >= 0 else None + + def verify_frame(self, raw): + """整帧(含 CRC)校验: 长度与 LEN 口径自洽 + CRC 匹配""" + cs = self.crc_size + if len(raw) < self.layout.len_pos + 1 + cs: + return False + plen = self._plen_of(raw) + if plen is None or len(raw) != self.layout.total_size(plen, cs): + return False + return self.encode_crc(self.compute_crc(raw[:-cs], plen)) == raw[-cs:] + + def parse_frame(self, raw): + """整帧 -> Frame(不校验 CRC, 调用方先用 verify_frame); 长度不符抛 ValueError""" + cs = self.crc_size + plen = self._plen_of(raw) + if plen is None or len(raw) != self.layout.total_size(plen, cs): + raise ValueError('帧长与 LEN 口径不符') + po = self.layout.payload_pos + code = raw[self.layout.id_pos] if self.layout.id_pos is not None else None + return Frame(code, raw[po:po + plen], bytes(raw)) + + +class FrameParser: + """增量字节流 -> 完整帧: 前缀令牌逐位对齐(帧头/字面量, ID 为通配) + LEN 定长 + + CRC 校验; 任一处失配丢 1 字节重新同步。LEN 值经口径换算后即 payload 长度, + 负值视为伪帧头逐出 —— 伪帧头最多滞留一帧即被逐出重同步, 对调试工具可接受。 + """ + + def __init__(self, cfg): + self.cfg = cfg + lay = cfg.layout + cs = cfg.crc_size + # LEN 之前的前缀令牌: (帧内位置, 期望字节|None=通配); hdr 按 RX 包头匹配 + self._prefix = [] + for pos, tok in enumerate(lay.tokens[:lay.len_pos]): + exp = None + if tok == 'hdr': + exp = cfg.rx_hdr + elif isinstance(tok, tuple): + exp = tok[1] + self._prefix.append((pos, exp)) + self._len_pos = lay.len_pos + self._extra = lay.len_extra(cfg.len_sem, cs) + self._buf = bytearray() + + def clear(self): + self._buf.clear() + + def _aligned(self): + """把缓冲对齐到前缀匹配; 字节不足且已匹配部分则保持等待(返回 True), + 失配丢 1 字节重试, 缓冲耗尽返回 False""" + buf = self._buf + while True: + for pos, exp in self._prefix: + if pos >= len(buf): + return True # 已匹配部分全对, 等更多字节 + if exp is not None and buf[pos] != exp: + break # 失配 -> 丢 1 字节 + else: + return True + del buf[:1] + if not buf: + return False + + def feed(self, data): + """喂入新收到的字节, 返回本次解析出的完整 Frame 列表""" + self._buf += bytes(data) + cfg, frames = self.cfg, [] + while self._aligned() and self._buf: + if len(self._buf) < self._len_pos + 1: + break # LEN 字节未到齐 + plen = self._buf[self._len_pos] - self._extra + if plen < 0: # LEN 为负 -> 伪帧头, 逐出重同步 + del self._buf[:1] + continue + total = cfg.layout.total_size(plen, cfg.crc_size) + if len(self._buf) < total: + break # 整帧未收齐 + raw = bytes(self._buf[:total]) + if cfg.verify_frame(raw): + frames.append(cfg.parse_frame(raw)) + del self._buf[:total] + else: + del self._buf[:1] # CRC 失配 -> 丢帧头重同步 + return frames + + +# --- payload 数值提取(曲线通道用) -------------------------------------------- + +BIN_FORMATS = { # fmt -> (struct 格式, 字节数) + 'u8': ('B', 1), 's8': ('b', 1), + 'u16be': ('>H', 2), 'u16le': ('h', 2), 's16le': ('I', 4), 'u32le': ('i', 4), 's32le': ('f', 4), 'f32le': (' 246 + re 正则第 1 捕获组(无组则整体), 如 '(?<=:)(\\d+)' + """ + payload = bytes(payload) + try: + if mode == 'bin': + if fmt not in BIN_FORMATS: + return None + sfmt, size = BIN_FORMATS[fmt] + if offset < 0 or len(payload) < offset + size: + return None + value = struct.unpack(sfmt, payload[offset:offset + size])[0] + elif mode == 'num': + nums = NUM_RE.findall(payload.decode('ascii', 'replace')) + if not 1 <= index <= len(nums): + return None + value = float(nums[index - 1]) + elif mode == 're': + m = re.search(pattern, payload.decode('ascii', 'replace')) + if not m: + return None + value = float(m.group(1) if m.groups() else m.group(0)) + else: + return None + except (ValueError, re.error): + return None + return value * scale + + +def hexs(data): + """bytes -> 'AA BB CC' 大写 hex, 日志统一格式""" + return bytes(data).hex(' ').upper() + + +def clean_text(s): + """控制字符 -> '·': 二进制按 ASCII 解出的 NUL 等会污染日志行, + 且 Windows 剪贴板遇 NUL 直接截断复制内容(DLPilot 同源教训)""" + return ''.join(c if ord(c) >= 0x20 and ord(c) != 0x7F else '·' for c in s) diff --git a/SerialMate/src/publish_gitea.py b/SerialMate/src/publish_gitea.py new file mode 100644 index 0000000..13bed7e --- /dev/null +++ b/SerialMate/src/publish_gitea.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖) +================================================================ +用法: + python publish_gitea.py SerialMate-v0.1.0-win64.zip + python publish_gitea.py --notes-file release/notes-v0.1.0.md + python publish_gitea.py --tag SerialMate-v0.1.0 --replace + +行为: + - tag 与版本默认取 zip 文件名(SerialMate-vX.Y.Z-win64.zip → SerialMate-vX.Y.Z) + - release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag); + 已存在则复用, 仅上传/更新资产 + - release 上已有同名资产时: 默认跳过, --replace 删旧再传 + - API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限) + - 本实例 TLS 间歇抽风: 已内置连接级重试, 实际发布请加 --insecure +""" + +import argparse +import json +import os +import re +import ssl +import subprocess +import sys +import time +import urllib.error +import urllib.request +import uuid + +DEFAULT_NOTES = """## SerialMate {version} + +RS485/串口调试助手(LT8619C 调试工具集配套): 串口收发 + 协议包解析, +帧格式(包头/板ID/LEN/CRC 算法与覆盖范围)全可定制, 默认与 DLPilot 光机 +RS485 协议一致; payload 支持字符串/HEX 显示、4 通道曲线绘制, 发送框输入 +payload 自动包装组帧(多条用 / 分隔), 也可 HEX 直发与周期发送。 + +### 安装 +下载 `SerialMate-v{version}-win64.zip`, 解压后双击 `SerialMate.exe` +(Windows 10/11 x64, 免安装; 串口功能需系统可访问串口, 程序自带 pyserial)。 + +### 包内容 +- `SerialMate.exe` — 主程序 +- `README.md` — 完整使用说明 +""" + + +def git_remote_info(): + """从 origin 远程地址解析 API base 与 owner/repo(ssh 端口不影响 https API)""" + url = subprocess.check_output(['git', 'remote', 'get-url', 'origin']).decode().strip() + m = re.match(r'(?:ssh://git@|git@)([\w.-]+)(?::\d+)?[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?$', url) + if not m: + sys.exit(f'无法从 origin 解析 Gitea 地址: {url}') + host, owner, repo = m.groups() + return f'https://{host}', owner, repo + + +class Api: + def __init__(self, base, owner, repo, token, insecure): + self.api = f'{base}/api/v1/repos/{owner}/{repo}' + self.headers = {'Authorization': f'token {token}'} + self.ctx = ssl._create_unverified_context() if insecure else None + + def request(self, method, path, data=None, headers=None, retries=4): + """发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试""" + last = None + for attempt in range(retries): + req = urllib.request.Request(self.api + path, data=data, method=method, + headers={**self.headers, **(headers or {})}) + try: + with urllib.request.urlopen(req, context=self.ctx) as resp: + body = resp.read() + return resp.status, json.loads(body) if body else None + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read() or b'{}') + except (urllib.error.URLError, ssl.SSLError, OSError) as e: + last = e + time.sleep(1.0 + attempt) + raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}') + + def get_json(self, path): + status, data = self.request('GET', path) + return data if status == 200 else None + + def upload_asset(self, release_id, filepath, replace): + name = os.path.basename(filepath) + status, rel = self.request('GET', f'/releases/{release_id}') + if status != 200: + sys.exit(f'读取 release {release_id} 失败: {status} {rel}') + # Gitea 版本差异: assets 可能是 {items:[...]} 或纯数组 + assets = rel.get('assets') or [] + if isinstance(assets, dict): + assets = assets.get('items', []) + for asset in assets: + if asset['name'] == name: + if not replace: + print(f'资产 {name} 已存在, 跳过(覆盖请加 --replace)') + return + self.request('DELETE', f"/releases/{release_id}/assets/{asset['id']}") + print(f'已删除旧资产 {name}') + boundary = uuid.uuid4().hex + payload = open(filepath, 'rb').read() + body = (f'--{boundary}\r\n' + f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n' + f'Content-Type: application/octet-stream\r\n\r\n').encode() + payload + \ + f'\r\n--{boundary}--\r\n'.encode() + status, data = self.request( + 'POST', f'/releases/{release_id}/assets?name={name}', body, + {'Content-Type': f'multipart/form-data; boundary={boundary}'}) + if status != 201: + sys.exit(f'上传资产失败: {status} {data}') + print(f'已上传资产: {name} ({len(payload) / 1024 / 1024:.1f} MB)') + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('zipfile_path', help='发布 zip(release/ 下)') + ap.add_argument('--tag', help='release 标签, 默认取 zip 文件名去掉 -win64.zip') + ap.add_argument('--notes-file', help='release 说明 markdown 文件, 默认用内置模板') + ap.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), help='API token') + ap.add_argument('--replace', action='store_true', help='覆盖已存在的同名资产') + ap.add_argument('--insecure', action='store_true', help='跳过 TLS 证书校验(自签证书)') + args = ap.parse_args() + + if not os.path.exists(args.zipfile_path): + sys.exit(f'找不到 {args.zipfile_path}') + if not args.token: + sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN') + + tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path)) + version = tag.removeprefix('SerialMate-v') + notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \ + else DEFAULT_NOTES.format(version=version) + + base, owner, repo = git_remote_info() + api = Api(base, owner, repo, args.token, args.insecure) + print(f'目标: {base}/{owner}/{repo} tag={tag}') + + rel = api.get_json(f'/releases/tags/{tag}') + if rel: + print(f'release 已存在(id={rel["id"]}), 复用') + else: + status, rel = api.request('POST', '/releases', json.dumps({ + 'tag_name': tag, + 'name': f'SerialMate v{version}', + 'body': notes, + }).encode(), {'Content-Type': 'application/json'}) + if status != 201: + sys.exit(f'创建 release 失败: {status} {rel}') + print(f'release 已创建(id={rel["id"]})') + api.upload_asset(rel['id'], args.zipfile_path, args.replace) + print(f'完成: {rel.get("html_url", "") or f"{base}/{owner}/{repo}/releases/tag/{tag}"}') + + +if __name__ == '__main__': + main() diff --git a/SerialMate/src/selftest.py b/SerialMate/src/selftest.py new file mode 100644 index 0000000..3212168 --- /dev/null +++ b/SerialMate/src/selftest.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""protocol.py 协议自检(无硬件): DLPilot 实测帧向量回归 + CRC 标准校验值 + 解析同步 + +用法: python selftest.py (全部通过退出码 0) +""" +import random +import sys + +import protocol as d + +fails = [] + + +def check(name, got, want): + ok = got == want + print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})') + if not ok: + fails.append(name) + + +def check_close(name, got, want): + ok = isinstance(got, float) and abs(got - want) < 1e-6 + print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})') + if not ok: + fails.append(name) + + +cfg = d.ProtoConfig() # 默认即 DLPilot: D5/B5/01/SUM8/不含包头 + +# 1) 帧构造: DLPilot selftest_rs485.py 的实测向量(上游 protocols.md) +for cmd, want in ( + ('M730S0T2000', 'D5 01 0B 4D 37 33 30 53 30 54 32 30 30 30 8C'), + ('M303', 'D5 01 04 4D 33 30 33 E8'), + ('M304', 'D5 01 04 4D 33 30 34 E9'), + ('M305', 'D5 01 04 4D 33 30 35 EA'), +): + check(f'build_frame({cmd})', cfg.build_frame(cmd.encode('ascii')).hex(' ').upper(), want) + +# 2) CRC 算法实现对照公开标准校验值 check("123456789") +check('crc8 check', d._crc8(b'123456789'), 0xF4) +check('crc16_modbus check', d._crc16_modbus(b'123456789'), 0x4B37) +check('crc16_xmodem check', d._crc16_xmodem(b'123456789'), 0x31C3) +check('crc16_ccitt check', d._crc16_ccitt(b'123456789'), 0x29B1) + +# 3) CRC 覆盖范围(手算向量: 包头 5A 板ID 11, payload 01 02) +for scope, want in (('payload_only', '5A 11 02 01 02 03'), + ('no_header', '5A 11 02 01 02 16'), + ('with_header', '5A 11 02 01 02 70')): + c = d.ProtoConfig(tx_hdr=0x5A, board=0x11, crc_scope=scope) + check(f'coverage {scope}', c.build_frame(b'\x01\x02').hex(' ').upper(), want) + +# 4) CRC16 帧往返 + 坏帧拒绝(组帧/解析同头才能回路, 收发方向包头本就允许不同) +c16 = d.ProtoConfig(tx_hdr=0x55, rx_hdr=0x55, crc_algo='CRC16_MODBUS', crc_scope='with_header') +f = c16.build_frame(b'ABC') +check('crc16 frame len', len(f), 3 + 3 + 2) +check('crc16 verify', c16.verify_frame(f), True) +bad = bytearray(f) +bad[4] ^= 0xFF +check('crc16 bad rejected', c16.verify_frame(bytes(bad)), False) +frames = d.FrameParser(c16).feed(f) +check('crc16 parse payload', (frames[0].payload, frames[0].code) if frames else None, (b'ABC', 0x01)) + +# 5) LEN=0 空载荷帧(心跳类): D5 01 00 + sum(01)=01 +check('empty payload frame', cfg.build_frame(b'').hex(' ').upper(), 'D5 01 00 01') +loop = d.ProtoConfig(tx_hdr=0xB5) # tx/rx 同头以便回路 +check('empty payload parse', d.FrameParser(loop).feed(loop.build_frame(b''))[0].payload, b'') + +# 6) 超长 payload 拒绝 +try: + cfg.build_frame(b'\x00' * 256) + fails.append('oversize-not-rejected') +except ValueError: + print('PASS 超长 payload 拒绝') + +# 7) 增量解析: ok 应答逐字节喂入(拆到最碎) + 前导噪声 +good = bytes.fromhex('B5 F0 02 6F 6B CC') +p = d.FrameParser(cfg) +out = [] +for i in range(len(good)): + out += p.feed(good[i:i + 1]) +check('byte-wise feed frames', len(out), 1) +check('code', out[0].code, 0xF0) +check('payload', out[0].payload, b'ok') +check('raw', d.hexs(out[0].raw), 'B5 F0 02 6F 6B CC') +out = d.FrameParser(cfg).feed(b'\x01\x02' + good) +check('noise+frame count', len(out), 1) + +# 8) CRC 坏帧被丢弃后继续同步到下一帧 +bad = bytearray(good) +bad[-1] ^= 0xFF +frames = d.FrameParser(cfg).feed(bytes(bad) + good) +check('bad-crc then good', len(frames), 1) +check('recovered code', frames[0].code, 0xF0) + +# 9) 伪帧头与帧头字节入载荷 +# 9a 小 LEN 伪帧头(B5 AA 01 55,CRC 应为 00 而实为 01)失败后逐字节重同步到真帧 +frames = d.FrameParser(cfg).feed(b'\xB5\xAA\x01\x55\x01' + good) +check('pseudo-header resync', len(frames), 1) +check('resync payload', frames[0].payload, b'ok') +# 9b payload 内含 0xB5(帧头字节)的合法帧: B5 F0 02 B5 F0 + sum(F0 02 B5 F0)=0x297→97 +frames = d.FrameParser(cfg).feed(bytes.fromhex('B5 F0 02 B5 F0 97') + good) +check('rx-hdr-in-payload', len(frames), 2) +check('rx-hdr-in-payload payload', frames[0].payload, b'\xB5\xF0') + +# 10) 自定义包头/板ID 往返(非 DLPilot 值) +cx = d.ProtoConfig(tx_hdr=0xC3, rx_hdr=0xC3, board=0x7A) +frames = d.FrameParser(cx).feed(cx.build_frame(b'PING')) +check('custom hdr frame count', len(frames), 1) +check('custom hdr code', frames[0].code, 0x7A) +check('custom hdr payload', frames[0].payload, b'PING') + +# 11) 数值提取(曲线通道) +check_close('bin u16be', d.extract_value(bytes.fromhex('00 34'), 'bin', fmt='u16be'), 52.0) +check_close('bin s16le 负值', d.extract_value(bytes.fromhex('FE FF'), 'bin', fmt='s16le'), -2.0) +check_close('bin f32be', d.extract_value(bytes.fromhex('3F 80 00 00'), 'bin', fmt='f32be'), 1.0) +check_close('bin offset', d.extract_value(bytes.fromhex('AA 00 34'), 'bin', offset=1, fmt='u16be'), 52.0) +check_close('num TL:246', d.extract_value(b'okTL:246', 'num'), 246.0) +check_close('num scale 0.1', d.extract_value(b'okTL:246', 'num', scale=0.1), 24.6) +check_close('num 第3个', d.extract_value(b'okR12G34B56', 'num', index=3), 56.0) +check_close('re 捕获组', d.extract_value(b'okTL:246', 're'), 246.0) +check('num 缺数字', d.extract_value(b'ok', 'num'), None) +check('bin 越界', d.extract_value(b'\x01', 'bin', offset=2), None) +check('re 非法表达式', d.extract_value(b'x', 're', pattern='('), None) +check('未知模式', d.extract_value(b'x', 'bogus'), None) + +# 12) 随机往返模糊: 三种算法 × 三种覆盖范围, 整喂/乱拆两种方式 +rng = random.Random(20260927) +for algo in ('SUM8', 'CRC8', 'CRC16_MODBUS'): + for scope in d.CRC_SCOPES: + cf = d.ProtoConfig(tx_hdr=0xB5, crc_algo=algo, crc_scope=scope) # tx=rx 回路 + for _ in range(20): + pl = bytes(rng.randrange(256) for _ in range(rng.randrange(41))) + frame = cf.build_frame(pl) + fp = d.FrameParser(cf) + if rng.random() < 0.5: + got = fp.feed(frame) + else: + cut = rng.randrange(len(frame) + 1) + got = fp.feed(frame[:cut]) + fp.feed(frame[cut:]) + ok = len(got) == 1 and got[0].payload == pl and got[0].raw == frame + if not ok: + check(f'fuzz {algo}/{scope}', ok, True) + break +print(f'模糊往返 3 算法 × 3 覆盖 × 20 帧: {"PASS" if not fails else "见上"}') + +# 13) 日志行安全: clean_text 把控制字符换成 '·' +check('clean_text NUL', d.clean_text('a\x00b\x7f'), 'a·b·') + +# 14) 帧构成定制(发送/接收同一布局, 收发方向仅包头值不同; 回路用例 rx_hdr=D5 +# 以便解析器接受 TX 包头, 字面量同步头布局则与包头值无关) +# 14a 双字节字面量同步头 + 无板ID: AA 55 LEN payload CRC +cl = d.FrameLayout.parse('AA 55 LEN PAYLOAD CRC') +c = d.ProtoConfig(layout=cl) +check('layout 字面量组帧', c.build_frame(b'\x01\x02').hex(' ').upper(), 'AA 55 02 01 02 5A') +frames = d.FrameParser(c).feed(c.build_frame(b'\x01\x02')) +check('layout 字面量解析', (len(frames), frames[0].code, frames[0].payload) if frames else None, + (1, None, b'\x01\x02')) + +# 14b 自定义字段顺序: HDR LEN ID PAYLOAD CRC(板ID 在 LEN 后) +c = d.ProtoConfig(rx_hdr=0xD5, board=0x2A, + layout=d.FrameLayout.parse('HDR LEN ID PAYLOAD CRC')) +check('layout 换序组帧', c.build_frame(b'OK').hex(' ').upper(), 'D5 02 2A 4F 4B C6') +frames = d.FrameParser(c).feed(c.build_frame(b'OK')) +check('layout 换序解析', (frames[0].code, frames[0].payload) if frames else None, (0x2A, b'OK')) + +# 14c 无板ID布局: HDR LEN PAYLOAD CRC, Frame.code 应为 None +c = d.ProtoConfig(rx_hdr=0xD5, layout=d.FrameLayout.parse('HDR LEN PAYLOAD CRC')) +check('layout 无ID组帧', c.build_frame(b'M1').hex(' ').upper(), 'D5 02 4D 31 80') +frames = d.FrameParser(c).feed(c.build_frame(b'M1')) +check('layout 无ID解析', (frames[0].code, frames[0].payload) if frames else None, + (None, b'M1')) + +# 14d LEN 口径: body(payload+CRC) / total(整帧) —— M303 载荷 4 字节 +c = d.ProtoConfig(rx_hdr=0xD5, len_sem='body') +check('LEN 口径 body', c.build_frame(b'M303').hex(' ').upper(), 'D5 01 05 4D 33 30 33 E9') +frames = d.FrameParser(c).feed(c.build_frame(b'M303')) +check('LEN 口径 body 解析', frames[0].payload if frames else None, b'M303') +c = d.ProtoConfig(rx_hdr=0xD5, len_sem='total') +check('LEN 口径 total', c.build_frame(b'M303').hex(' ').upper(), 'D5 01 08 4D 33 30 33 EC') +frames = d.FrameParser(c).feed(c.build_frame(b'M303')) +check('LEN 口径 total 解析', frames[0].payload if frames else None, b'M303') + +# 14e 仅 payload 覆盖 + 板ID 在 payload 后: 'HDR LEN PAYLOAD ID CRC' +c = d.ProtoConfig(rx_hdr=0xD5, board=0x7B, crc_scope='payload_only', + layout=d.FrameLayout.parse('HDR LEN PAYLOAD ID CRC')) +check('layout ID在payload后', c.build_frame(b'\x01').hex(' ').upper(), 'D5 01 01 7B 01') +frames = d.FrameParser(c).feed(c.build_frame(b'\x01')) +check('layout ID在payload后解析', (frames[0].code, frames[0].payload) if frames else None, + (0x7B, b'\x01')) + +# 14f 非法帧构成一律拒绝 +for bad in ('HDR PAYLOAD CRC', # 缺 LEN + 'HDR LEN CRC PAYLOAD', # CRC 不在末尾 + 'LEN PAYLOAD CRC', # 无同步头 + 'HDR ID ID LEN PAYLOAD CRC', # ID 重复 + 'HDR ID LEN FOO CRC', # 未知令牌 + '', # 空 + 'HDR ID LEN PAYLOAD'): # 缺 CRC + try: + d.FrameLayout.parse(bad) + fails.append(f'layout-not-rejected:{bad}') + print('FAIL 帧构成未拒绝 ->', bad) + except ValueError: + pass +print('非法帧构成拒绝: PASS') +check('大小写/逗号容错', d.FrameLayout.parse('hdr, id, len, payload, crc').tokens, + d.FrameLayout.parse(d.DEFAULT_LAYOUT).tokens) + +# 15) 自定义布局模糊往返(乱拆喂入; 有 HDR 令牌的布局回路需 rx_hdr=tx_hdr) +rng2 = random.Random(9527) +for spec in ('AA 55 LEN PAYLOAD CRC', 'HDR LEN ID PAYLOAD CRC', + 'HDR LEN PAYLOAD ID CRC'): + cf = d.ProtoConfig(rx_hdr=0xD5, layout=d.FrameLayout.parse(spec)) + for _ in range(10): + pl = bytes(rng2.randrange(256) for _ in range(rng2.randrange(41))) + frame = cf.build_frame(pl) + fp = d.FrameParser(cf) + cut = rng2.randrange(len(frame) + 1) + got = fp.feed(frame[:cut]) + fp.feed(frame[cut:]) + ok = len(got) == 1 and got[0].payload == pl and got[0].raw == frame + if not ok: + check(f'layout fuzz {spec}', ok, True) + break +print(f'布局模糊往返 3 布局 × 10 帧: {"PASS" if not fails else "见上"}') + +print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS') +sys.exit(1 if fails else 0) diff --git a/SerialMate/src/selftest_gui.py b/SerialMate/src/selftest_gui.py new file mode 100644 index 0000000..df03f2c --- /dev/null +++ b/SerialMate/src/selftest_gui.py @@ -0,0 +1,223 @@ +"""GUI 功能自检(offscreen 无窗口): 组帧预览/TX回显/帧日志/通道提取/显示切换/ +未连接丢弃/配置往返 +==================================================================== +不依赖硬件与串口, 直接驱动 Window 的事件处理函数; 改 main.py 后建议重跑。 + +用法: python selftest_gui.py (全部通过退出码 0) +""" +import os +import sys + +os.environ['QT_QPA_PLATFORM'] = 'offscreen' +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from PySide6.QtWidgets import QApplication + +import config +import main as m +import protocol as d +from plot import fmt_val + +fails = [] + + +def check(name, got, want): + ok = got == want + print(('PASS' if ok else 'FAIL'), name, '->', repr(got)[:120], + '' if ok else f'(期望 {want!r})') + if not ok: + fails.append(name) + + +# 0) 数值显示格式: 普通小数千分位(科学计数只留给 10 亿以上) +check('fmt_val 千分位', fmt_val(28210029), '28,210,029') +check('fmt_val 小数', fmt_val(24.6), '24.6') +check('fmt_val 负小数', fmt_val(-0.25), '-0.25') +check('fmt_val 零', fmt_val(0), '0') + +app = QApplication(sys.argv) +win = m.Window() +win.show() # offscreen 显示, isVisible 类断言前提 +check('倍率/偏移/序号框宽度(防数值裁切回归)', + win._ch_ui[0]['scale'].width() >= 130 + and win._ch_ui[0]['off'].width() >= 110 + and win._ch_ui[0]['idx'].width() >= 110, True) +win._apply_minimum_size() +check('窗口最小高度(防曲线卡叠压)', win.minimumHeight() >= 800, True) + +# 0b) 曲线时间窗换算(纯计算; 窗口不向数据收敛——选 5min 就显示 5min 宽的轴) +p = win.plot +p.set_view(10) +p.set_offset(0) +check('时间窗跟随右缘', p.visible_domain(0, 100) == (90.0, 100.0), True) +p.set_offset(30) +check('时间窗滞后回看', p.visible_domain(0, 100) == (60.0, 70.0), True) +p.set_offset(90) +check('时间窗滑到最旧', p.visible_domain(0, 100) == (0.0, 10.0), True) +p.set_offset(500) +check('滞后超史为空窗(保宽)', p.visible_domain(0, 100) == (-410.0, -400.0), True) +p.set_view(300) +p.set_offset(0) +check('数据不足窗宽不收敛', p.visible_domain(0, 23) == (-277.0, 23.0), True) +p.set_view(None) +p.set_offset(0) +check('跟随全部', p.visible_domain(0, 100) == (0.0, 100.0), True) + +# 0c) 悬停读值: 最近采样命中/容差外未命中 +import time as _time +base = _time.perf_counter() +p._times[0].clear() +p._vals[0].clear() +p._names[0] = '甲' +for tt, vv in ((0.0, 10.0), (1.0, 20.0), (2.0, 30.0)): + p._times[0].append(base + tt) + p._vals[0].append(vv) +check('悬停命中最近点', p._nearest(0, base + 1.9, 0.2) == ('甲', 30.0), True) +check('悬停容差外未命中', p._nearest(0, base + 1.4, 0.2) is None, True) +lo, hi = p.auto_yrange() +check('自动Y量程', abs(lo - 8.4) < 1e-9 and abs(hi - 31.6) < 1e-9, True) +from PySide6.QtCore import QPoint +p.resize(600, 300) # 未走布局, 手动给几何 +lines = p._hover_lines_at(QPoint(320, 150)) # x=绘图区正中 → t=base+1.0 +check('悬停框内容(位置固定也随数据刷新)', + lines[:2] == [f'T = {base + 1.0:.2f}s', '甲 = 20'], True) + +# 0d) 暂停渲染 vs 暂停采集: 冻结期间继续收数, 解冻后数据都在 +p.set_frozen(True) +n0 = len(p._times[0]) +p.add(0, 42.0) +check('冻结期间继续收数', p.is_frozen() and len(p._times[0]) == n0 + 1, True) +p.set_frozen(False) +p.set_paused(True) +n1 = len(p._times[0]) +p.add(0, 43.0) +check('暂停期间丢弃', p.is_paused() and len(p._times[0]) == n1, True) +p.set_paused(False) + +# 1) 组帧预览: 多条 '/' 分隔 +win.ed_tx.setText('M1/M731S0T1000') +check('多条预览', win.lb_preview.text(), + 'D5 01 02 4D 31 81 | D5 01 0B 4D 37 33 31 53 30 54 31 30 30 30 8C') +win.combo_tx_mode.setCurrentIndex(1) +win.ed_tx.setText('D5 01 02 4D 31 81') +check('HEX预览', win.lb_preview.text(), '→ D5 01 02 4D 31 81') +win.combo_tx_mode.setCurrentIndex(0) +win.ed_tx.setText('M1') + +# 1b) 帧构成定制: 无板ID布局 + LEN 口径切换 + 非法输入提示 +win.ed_layout.setText('HDR LEN PAYLOAD CRC') +check('帧构成无ID预览', win.lb_preview.text(), 'D5 02 4D 31 80') +win.combo_len_sem.setCurrentIndex(1) # 到帧尾(payload+CRC) +check('帧构成LEN口径预览', win.lb_preview.text(), 'D5 03 4D 31 81') +win.combo_len_sem.setCurrentIndex(0) +win.ed_layout.setText('HDR LEN') # 缺 PAYLOAD/CRC -> 拒绝 +check('帧构成非法提示', win.lb_preview.text().startswith('⚠'), True) +check('帧构成非法详情', 'PAYLOAD 必须恰好出现一次' in win.lb_proto.text(), True) +win.ed_layout.setText(d.DEFAULT_LAYOUT) +check('帧构成还原预览', win.lb_preview.text(), 'D5 01 02 4D 31 81') + +# 2) TX 回显行 +cfg = win._proto_from_ui() +win._on_tx('M1', cfg.build_frame(b'M1')) +check('TX日志行', 'M1 [D5 01 02 4D 31 81]' in win.text_rx.toPlainText(), True) + +# 3) 帧事件: 日志 + 通道提取(文本数字×0.1) +win.combo_display.setCurrentIndex(0) +fr = d.FrameParser(d.ProtoConfig()).feed( + bytes.fromhex('B5 F0 08 6F 6B 54 4C 3A 32 34 36 48'))[0] +win._ch_ui[0]['chk'].setChecked(True) +win._ch_ui[0]['mode'].setCurrentIndex(1) # 文本数字 +win._ch_ui[0]['idx'].setValue(1) +win._ch_ui[0]['scale'].setValue(0.1) +win._on_frame(fr) +last = win.text_rx.toPlainText().splitlines()[-1] +check('帧日志含 TYPE 名', 'B=0xF0 成功' in last and 'LEN=8' in last + and 'okTL:246' in last, True) +check('通道最新值', win._ch_ui[0]['val'].text(), '24.6') + +# 4) HEX 显示切换 + 原始帧 +win.combo_display.setCurrentIndex(1) +win._on_frame(fr) +last = win.text_rx.toPlainText().splitlines()[-1] +check('HEX显示+原始帧', '6F 6B 54 4C 3A 32 34 36' in last + and '[B5 F0 08 6F 6B 54 4C 3A 32 34 36 48]' in last, True) + +# 5) 透传行 +win.combo_display.setCurrentIndex(1) +win._on_raw(b'\x11\x22') +check('透传HEX行', win.text_rx.toPlainText().splitlines()[-1].endswith('11 22'), True) + +# 6) 未连接发送丢弃 +win._send() +win._drain_events() +check('未连接发送提示', '未连接串口, 命令被丢弃' in win.text_rx.toPlainText(), True) + +# 7) 配置往返 +store = win._store_from_ui() +check('配置 proto', (store['proto']['tx_hdr'], store['proto']['crc_algo']), + ('D5', 'SUM8')) +check('配置帧构成', (store['proto']['layout'], store['proto']['len_sem']), + (d.DEFAULT_LAYOUT, 'payload')) +check('配置通道1', (store['channels'][0]['enabled'], store['channels'][0]['scale']), + (True, 0.1)) + +# 8) 板型档案: 保存/切换应用协议参数/存入命令/清理 +check('板型档案加载', win.combo_profile.count() >= 1, True) +win.ed_board.setText('03') +check('存为板型', win._save_profile_as('测试板'), True) +names = [win.combo_profile.itemText(i) for i in range(win.combo_profile.count())] +check('板型入列', '测试板' in names, True) +pidx = names.index('测试板') +win.combo_profile.setCurrentIndex(pidx) +check('切换板型应用板ID', win.ed_board.text(), '03') +win.combo_profile.setCurrentIndex(0) +check('切回默认板ID', win.ed_board.text(), '01') +win.ed_tx.setText('M1') +win._add_command_named('M1', 'M1') +check('存入命令去重', any(c[1] == 'M1' for c in win._commands) + and len([c for c in win._commands if c[1] == 'M1']) == 1, True) +check('预设显示名命令', win.combo_preset.itemText(0) == '握手 · M1', True) +# 10) 新建板型(命令预设从空白开始) + 命令预设表编辑器 +check('新建板型', win._new_profile_named('新建测试板'), True) +check('重名新建拒绝', win._new_profile_named('新建测试板'), False) +check('新建板型命令为空', win._commands == [], True) +dlg = m.ProfileEditDialog(win.profiles[win.combo_profile.currentIndex()]) +from PySide6.QtWidgets import QTableWidgetItem +for r, (n, c) in enumerate((('甲', 'A1'), ('乙', 'B2'), ('丙', 'C3'))): + dlg.table._add_row() + dlg.table.setItem(r, 1, QTableWidgetItem(n)) + dlg.table.setItem(r, 2, QTableWidgetItem(c)) +check('编辑器收集', dlg.commands() == [['甲', 'A1'], ['乙', 'B2'], ['丙', 'C3']], + True) +dlg.table.move_row(0, 2) # 拖动排序: 首行挪到末尾 +check('拖动换序', dlg.commands() == [['乙', 'B2'], ['丙', 'C3'], ['甲', 'A1']], + True) +check('手柄列存在', dlg.table.item(0, 0).text() == '≡', True) +dlg.table._add_row() # 重复命令与空命令行均被忽略 +dlg.table.setItem(3, 1, QTableWidgetItem('重复')) +dlg.table.setItem(3, 2, QTableWidgetItem('B2')) +check('编辑器去重空行', dlg.commands() == [['乙', 'B2'], ['丙', 'C3'], ['甲', 'A1']], + True) +# 11) 删除板型: 核函数删除/不存在拒绝/至少保留一个 +check('删除板型', win._delete_profile('新建测试板'), True) +check('删除后不在列', all(p['name'] != '新建测试板' for p in win.profiles), True) +check('删除不存在拒绝', win._delete_profile('不存在的板型'), False) +# 清理运行态测试板型 +win.profiles[:] = [p for p in win.profiles if p['name'] != '测试板'] +config.save_profiles(win.profiles) +win._reload_profiles() +check('清理测试板型', all(p['name'] != '测试板' for p in win.profiles), True) + +# 9) 曲线弹出/嵌回 +win._toggle_plot_window() +check('曲线弹出', win._plot_win is not None + and win.plot.window() is win._plot_win, True) +win._toggle_plot_window() +check('曲线嵌回', win._plot_win is None and win.plot.parent() is not None, True) +win._pop_plot() +win._dock_plot() +check('弹出后关窗嵌回', win._plot_win is None and win.plot.isVisible(), True) + +win.link.close() +print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS') +sys.exit(1 if fails else 0) diff --git a/SerialMate/src/selftest_loop.py b/SerialMate/src/selftest_loop.py new file mode 100644 index 0000000..2064431 --- /dev/null +++ b/SerialMate/src/selftest_loop.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""链路自检(无硬件): pyserial loop:// 虚拟回环验证 组帧→发送→接收→解析→曲线提取 +==================================================================== +缺 pyserial 时跳过(退出码 0)。loop:// 回环口写即读, 发出的帧同样回到 +接收侧, 但 TX 帧头(D5)不是 RX 帧头(B5), 会被解析器当噪声丢弃, 不影响判定。 + +用法: python selftest_loop.py (全部通过退出码 0) +""" +import sys +import time + +try: + import serial # noqa: F401 +except ImportError: + print('未安装 pyserial, 跳过回环自检') + sys.exit(0) + +import protocol as d +from link import SerialLink + +fails = [] + + +def check(name, got, want): + ok = got == want + print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})') + if not ok: + fails.append(name) + + +def wait_for(pred, timeout=5.0): + deadline = time.time() + timeout + while time.time() < deadline: + if pred(): + return True + time.sleep(0.05) + return False + + +got = {'status': [], 'frames': [], 'tx': [], 'raw': []} +cfg = d.ProtoConfig() +link = SerialLink(cfg, on_status=lambda s: got['status'].append(s)) +link.on_frame = lambda fr: got['frames'].append(fr) +link.on_raw = lambda b: got['raw'].append(b) +link.on_tx = lambda lb, data: got['tx'].append((lb, data)) + +# 1) 打开虚拟回环口 +link.open('loop://', 115200) +check('连接 loop://', wait_for(lambda: got['status'] and + got['status'][-1].startswith('已连接')), True) + +# 2) 自动包装发送: TX 回显 + 回环字节 +payload = 'M731S0T1000' +frame = cfg.build_frame(payload.encode('ascii')) +link.send_frame(frame, label=payload) +check('发送回显', wait_for(lambda: len(got['tx']) >= 1), True) +if got['tx']: + check('TX label', got['tx'][0][0], payload) + check('TX frame', got['tx'][0][1], frame) + +# 3) 模拟设备应答: okTL:246(温度 24.6°C) → 解析 + 曲线提取 +resp = bytes.fromhex('B5 F0 08 6F 6B 54 4C 3A 32 34 36 48') # sum(F0..36)=0x48 +check('响应帧向量自校验', cfg.verify_frame(resp), True) +link.ser.write(resp) +check('应答解析', wait_for(lambda: len(got['frames']) >= 1), True) +if got['frames']: + fr = got['frames'][0] + check('TYPE 码', fr.code, 0xF0) + check('payload', fr.payload, b'okTL:246') + v = d.extract_value(fr.payload, 'num', index=1, scale=0.1) + check('曲线提取(×0.1)', v is not None and abs(v - 24.6) < 1e-9, True) + +# 4) 透传模式切换: 关解析后收到原始字节 +link.set_parse(False) +raw = bytes([0x11, 0x22, 0xB5, 0x00]) +link.ser.write(raw) +check('透传原始字节', wait_for(lambda: len(got['raw']) >= 1), True) +if got['raw']: + check('透传内容', got['raw'][0], raw) + +# 5) 切回解析模式, 缓冲已清空, 正常再收一帧 +link.set_parse(True) +link.ser.write(bytes.fromhex('B5 F0 02 6F 6B CC')) +check('切回解析再收帧', wait_for(lambda: len(got['frames']) >= 2), True) + +link.close() +check('断开', wait_for(lambda: got['status'] and got['status'][-1] == '已断开'), True) + +print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS') +sys.exit(1 if fails else 0) diff --git a/SerialMate/src/theme.py b/SerialMate/src/theme.py new file mode 100644 index 0000000..0f64637 --- /dev/null +++ b/SerialMate/src/theme.py @@ -0,0 +1,129 @@ +"""Theme persistence and Fluent Widgets integration.""" + +import ctypes +import os + +from PySide6.QtCore import QObject, QSettings, Qt +from PySide6.QtGui import QColor, QPalette +from qfluentwidgets import Theme, setTheme, setThemeColor + +# Fluent widgets restyle themselves on setTheme(); QMainWindow, the native +# QTabWidget/QStatusBar and the Windows title bar keep the OS palette unless +# the palette, an app stylesheet and the DWM title bar attributes follow suit +# (same approach as DLPilot app_fluent.py). +_SURFACES = { + False: {"window": "#f4f6f8", "base": "#ffffff", "text": "#1f2328", + "dim": "#57606a", "border": "#d8dee4", "selected_text": "#ffffff"}, + True: {"window": "#202020", "base": "#2b2b2b", "text": "#f5f5f5", + "dim": "#9a9a9a", "border": "#3a3a3a", "selected_text": "#1f2328"}, +} + + +class ThemeManager(QObject): + """Keep the appearance choice persistent and synchronized with Qt.""" + + MODES = {"system", "light", "dark", "dark1", "dark2"} + _THEMES = { + "system": Theme.AUTO, + "light": Theme.LIGHT, + "dark": Theme.DARK, + "dark1": Theme.DARK, + "dark2": Theme.DARK, + } + _ACCENTS = { + "light": "#0f6cbd", + "dark": "#f2c94c", + "dark1": "#5aa9e6", + "dark2": "#35c6c8", + } + + def __init__(self, app): + super().__init__(app) + self.app = app + self.window = None + self.settings = QSettings("LT8619C-Tools", "SerialMate") + mode = self.settings.value("appearance/theme", "system") + self.mode = mode if mode in self.MODES else "system" + self.apply() + app.styleHints().colorSchemeChanged.connect(self.apply) + + def set_window(self, window): + """Register the main window so the native title bar follows the theme.""" + self.window = window + self._apply_titlebar() + + def set_mode(self, mode): + if mode not in self.MODES: + raise ValueError(f"Unknown theme: {mode}") + self.mode = mode + self.settings.setValue("appearance/theme", mode) + self.apply() + + def is_dark(self): + if self.mode in {"dark", "dark1", "dark2"}: + return True + return self.mode == "system" and self.app.styleHints().colorScheme() == Qt.ColorScheme.Dark + + def apply(self, *_): + is_dark = self.is_dark() + setTheme(self._THEMES[self.mode]) + setThemeColor(self._ACCENTS.get(self.mode, self._ACCENTS["light"])) + self._apply_palette(is_dark) + self._apply_titlebar() + + def _apply_palette(self, is_dark): + """Give native Qt surfaces the same colors as the Fluent widgets.""" + surface = _SURFACES[is_dark] + accent = QColor(self._ACCENTS.get(self.mode, self._ACCENTS["light"])) + palette = QPalette() + for role, color in ( + (QPalette.Window, surface["window"]), + (QPalette.Base, surface["base"]), + (QPalette.AlternateBase, surface["base"]), + (QPalette.Text, surface["text"]), + (QPalette.WindowText, surface["text"]), + (QPalette.Button, surface["base"]), + (QPalette.ButtonText, surface["text"]), + (QPalette.PlaceholderText, surface["dim"]), + (QPalette.Highlight, accent), + (QPalette.HighlightedText, surface["selected_text"]), + (QPalette.ToolTipBase, surface["base"]), + (QPalette.ToolTipText, surface["text"]), + ): + palette.setColor(role, QColor(color)) + self.app.setPalette(palette) + self.app.setStyleSheet(f""" + QMainWindow, QWidget#root {{ background-color: {surface['window']}; color: {surface['text']}; }} + QTabWidget::pane {{ background: {surface['base']}; border: 1px solid {surface['border']}; border-radius: 6px; }} + QTabBar {{ qproperty-drawBase: 0; }} + QTabBar::tab {{ background: transparent; color: {surface['dim']}; padding: 6px 16px; + border: 1px solid transparent; border-top-left-radius: 6px; + border-top-right-radius: 6px; }} + QTabBar::tab:selected {{ background: {surface['base']}; color: {surface['text']}; + border: 1px solid {surface['border']}; }} + QTabBar::tab:hover:!selected {{ background: {surface['base']}; }} + QSplitter::handle {{ background: {surface['window']}; }} + QStatusBar {{ background: {surface['window']}; color: {surface['dim']}; + border-top: 1px solid {surface['border']}; }} + QToolTip {{ background-color: {surface['base']}; color: {surface['text']}; + border: 1px solid {surface['border']}; }} + """) + + def _apply_titlebar(self): + """Ask DWM for a title bar in the current surface color (Win10/11).""" + if self.window is None or os.name != "nt" or not hasattr(ctypes, "windll"): + return + try: + hwnd = int(self.window.winId()) + dwm = ctypes.windll.dwmapi + dark = ctypes.c_int(1 if self.is_dark() else 0) + dwm.DwmSetWindowAttribute(hwnd, 20, ctypes.byref(dark), ctypes.sizeof(dark)) + surface = _SURFACES[self.is_dark()] + dwm.DwmSetWindowAttribute(hwnd, 35, ctypes.byref(self._colorref(QColor(surface["window"]))), 4) + dwm.DwmSetWindowAttribute(hwnd, 36, ctypes.byref(self._colorref(QColor(surface["text"]))), 4) + except Exception: + pass + + @staticmethod + def _colorref(color): + return ctypes.c_int(color.red() | (color.green() << 8) | (color.blue() << 16)) diff --git a/agent.md b/agent.md index 87f15e0..59e5e08 100644 --- a/agent.md +++ b/agent.md @@ -16,11 +16,14 @@ publish_gitea(`GITEA_TOKEN` 用户级环境变量;实例 TLS 间歇抽风,实际请求加 `--insecure`,详见 `DLPilot/AGENTS.md` §5)。 - 结构约定:每个工具三区目录(**src/ 人写 / assets/ 可再生 / release/ 交付**)+ 双文档 - (README 使用说明 / AGENTS.md 工程约束与进度)。三个工具独立开发、独立打标签发布, + (README 使用说明 / AGENTS.md 工程约束与进度)。各工具独立开发、独立打标签发布, Release 列表按时间倒序混排于同一页面: - **DLPilot**(原 HDMI-Tool)="3D 光固化光机调试台":HDMI 源端(EDID/分辨率/ 测试图案投屏)+ 光机控制(RS485→GD32E230 网关→DLPC3421)+ 电源检查。 - **EDID-viewer**:Windows 显示器 EDID 只读查看器(Fluent UI)。 + - **SerialMate**:RS485/串口调试助手,帧格式(包头/板ID/LEN/CRC)可定制, + 默认兼容 DLPilot 光机协议;payload 字符串/HEX 显示、4 通道曲线、payload + 自动包装发送。 - **I2C-Inject**:RP2040 固件 + PC GUI,demo 板 I2C 总线择机注入读写寄存器。 **2026-09-25 起暂停开发**,代码与固件保留备查。 - 镜像约定:`EDID-viewer` 主目录(`E:\Hulk-Coding\LT8619C_Debug_Tools\EDID-viewer`) @@ -55,7 +58,31 @@ - 工程化:目录与发布流水线对齐 DLPilot(onefile exe 入库);标签 `EDID-Viewer-v0.1.0` 与 Release 附件(66,483,477 字节)已核对。 -### 2.3 I2C-Inject — ⏸ 暂停开发(2026-09-25) +### 2.3 SerialMate — v0.1.0(2026-09-27,首版) +- **更名**:原名 SerialAssistant(目录/exe/文档/配置同步更名;旧配置 + serial_assistant.json 首次启动自动迁移为 serial_mate.json;图标与名字 + 无绑定为波形+RS485 母题)。用户确认 "SerialMate 吧"(2026-09-27)。 +- 协议:**帧构成令牌序列可定制**(`HDR ID LEN PAYLOAD CRC` + 十六进制字面量 + 同步头,收发共用布局仅包头分方向;支持字段换序/无板ID/多字节同步头,LEN + 口径 payload 长度/到帧尾/整帧三种)、5 种 CRC 算法 × 3 种覆盖范围,默认与 + DLPilot 光机 RS485 协议一致(D5/B5/01/SUM8/不含包头);增量解析前缀逐位 + 对齐 + CRC 失败逐字节重同步;自测 64 项全 PASS(DLPilot 实测帧向量 + + CRC 标准校验值 + 布局换序/LEN 口径向量 + 布局模糊往返)。 +- 链路:串口参数热切换、半双工友好发送队列(未连接丢弃)、解析/透传双模式 + 热切、`loop://` 虚拟回环自检 13 项全 PASS(组帧→发送→接收→解析→曲线提取)。 +- GUI(PySide6-Fluent-Widgets,用户指定沿用 Qt 方案):帧构成/LEN 口径可视化 + 编辑,payload 字符串/HEX 切换(半透明底色高亮)、原始帧 hex、时间戳、日志 + 保存;4 通道曲线(三种提取 + 倍率,**弹窗独立**:时间窗滑笔回看、纵轴刻度、 + 悬停读值、暂停渲染/自动Y/滚轮缩放/拖动 Y 轴);发送自动包装(输入 payload + 自动组帧,`/` 分隔多条依次发)、HEX 直发、周期发送、组帧实时预览、历史 + 回溯、回车发送;主题跟随系统;标题栏版本号 + 关于弹窗 + 图标(波形 RS485)。 +- 板型档案(2026-09-27 实机反馈追加):不同板子的"协议参数+命令预设表"存为 + 命名档案(board_profiles.json,不入库,首次运行生成默认 DLPilot 板型), + 发送卡一键切换/新建/存为/删除/表格编辑/重载。 +- 发布:SerialMate.exe 已入库(随帧构成功能重建),zip/Release 未发布 + (待用户验收后走 package_release + publish_gitea)。 + +### 2.4 I2C-Inject — ⏸ 暂停开发(2026-09-25) - 已完成:RP2040 注入器固件(MicroPython,内置 viper 总线嗅探)、PC 端 GUI、 demo 板寄存器语义实测(0x606E bit5=镜像 D[7:0]、0x606D 整组顺序,逻辑分析仪 双色交叉验证)。 @@ -92,10 +119,15 @@ - 实机验收缺口:HDMI 热插拔、多屏、跨屏 DPI、最大化/恢复与导入导出完整交互 (见 `EDID-viewer/VALIDATION.md`)。 -### 3.3 I2C-Inject +### 3.3 SerialMate +- 【待真机】光机实机收发与温度曲线实测(M201 ×0.1 提取),当前仅回环验证。 +- 【无图标】assets/serial-mate.ico 未生成(build_exe.py 已预留路径)。 +- 发送批处理脚本化(延时/循环/等待应答);曲线数据导出 CSV。 + +### 3.4 I2C-Inject - 暂停开发期间不列计划;恢复时以其 README 现状为准。 -### 3.4 明确不做(决策,非待办) +### 3.5 明确不做(决策,非待办) - **0x606E 的 0x40/0x80 位交换不再单独实测**(2026-09-25 用户确认:当前行为正常、 符合预期,bit5 实测基线已够用;原【待硬件】项关闭)。 - TI 原始 I2C 命令透传不做:GD32 网关固件只收 ASCII M 命令集(v0.2.0 显式决策)。 @@ -162,6 +194,13 @@ cd EDID-viewer/src python -m unittest # 9 项解析测试(改 edid.py 后必跑) python build_exe.py ; python package_release.py 0.1.0 # 打包 + 组装 zip python publish_gitea.py ../release/EDID-Viewer-v0.1.0-win64.zip --insecure + +# SerialMate(完整清单:SerialMate/AGENTS.md) +cd SerialMate/src +python selftest.py ; python selftest_loop.py # 协议回归 + 链路回环(改协议/链路后必跑) +python main.py --seconds 5 # GUI 冒烟 +python build_exe.py ; python package_release.py 0.1.0 # 打包 + 组装 zip +python publish_gitea.py ../release/SerialMate-v0.1.0-win64.zip --insecure ``` 上游协议/寄存器语义问题先查上游仓 `protocols.md` 与 `doc/architecture/`;