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 0000000..2927067 Binary files /dev/null and b/SerialMate/assets/serial-mate.ico differ diff --git a/SerialMate/assets/serial-mate.png b/SerialMate/assets/serial-mate.png new file mode 100644 index 0000000..4284b3c Binary files /dev/null and b/SerialMate/assets/serial-mate.png differ 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/release/SerialMate.exe b/SerialMate/release/SerialMate.exe new file mode 100644 index 0000000..4e9b4be Binary files /dev/null and b/SerialMate/release/SerialMate.exe differ 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/`;