Author SHA1 Message Date
hulk e5b2e45f57 fix: SerialMate v0.1.1 接收路径修复——时间戳改 link 线程到达时刻打戳、接收日志批量插入+5000行上限+积压折叠、通道配置缓存;自测扩至 selftest_gui 69 项,新增 ISSUE 报告与 _sim_periodic 复现脚本;重打包 2026-10-13 11:44:29 +08:00
hulk 296e220f20 fix: v0.4.3 修复长会话越跑越卡/命令无响应——日志手工 cursor 裁剪在 PySide6 6.11 必抛异常且打断 _poll_dlpc 消费;改 setMaximumBlockCount(500) 环形裁剪+逐事件兜底;新增 selftest_log 回归;重打包 2026-10-13 11:40:19 +08:00
hulk e3ef359df4 feat: 接入 gd32e230f8_firmware_merge_tool 子模块(GD32E230 固件合成+DAPLink Flash GUI,独立仓库管理);README 工具表/目录结构与 agent.md 登记子模块用法 2026-09-30 11:44:52 +08:00
hulk be32efa806 docs(agent): 记录 DLPilot v0.4.2 已发布(tag 指向 8fde79c) 2026-09-28 18:53:27 +08:00
hulk 8fde79c071 fix: v0.4.2 串口日志清空按钮并入标题行对齐;恢复光机命令快捷按钮排(开/关光机等7个, v0.4.0 重写时丢失);重打包 2026-09-28 18:51:48 +08:00
hulk b4328b8be6 docs(agent): 记录 DLPilot v0.4.1 已发布(tag 指向 4149d20) 2026-09-28 15:26:49 +08:00
hulk 4149d203b0 fix: v0.4.1 修复主题切换丢 swatch 底色与连接失败长错误串撑爆布局;新增 selftest_theme;重打包 2026-09-28 15:20:11 +08:00
hulk 264a0c74a6 docs(readme): 工具一览表增加各工具图标列;更新日期戳 2026-09-28 11:00:18 +08:00
hulk 190cf29aa5 chore: 仓库更名 LT8619C_Debug_Tools → HW-Debug-Tools
- Gitea 仓库已改名(旧地址自动重定向),git remote 同步更新
2026-09-28 10:01:16 +08:00
hulk d658fd459a feat: 图标改为家族版式绿色款(同 FlashTool 色系)并重打包
- 黑底/绿色圆角框 rgb(0,200,120)/框内黑字 Serial + 白字 Mate, 与 DLPilot/EDID-Viewer/FlashTool 同版式
2026-09-28 09:37:54 +08:00
hulk b627f79dad docs(agent): 记录 SerialMate v0.1.0 已推送并发布 Release 2026-09-27 18:58:44 +08:00
28 changed files with 808 additions and 148 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "gd32e230f8_firmware_merge_tool"]
path = gd32e230f8_firmware_merge_tool
url = https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool.git
+83 -8
View File
@@ -6,9 +6,9 @@
## 1. 项目定位 ## 1. 项目定位
仓库 `LT8619C_Debug_Tools` 是 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的**调试上位机工具集**, 仓库 `HW-Debug-Tools` 是 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的**调试上位机工具集**,
上游主仓库(固件)为 `hulk/LT8619C_DLPC3421_HDMI`(同 Gitea 实例 `gitea.hulk.wang`), 上游主仓库(固件)为 `hulk/LT8619C_DLPC3421_HDMI`(同 Gitea 实例 `gitea.hulk.wang`),
上游固件本机路径 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`;工具仓库路径 `E:\Hulk-Coding\LT8619C_Debug_Tools`。 上游固件本机路径 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`;工具仓库路径 `E:\Hulk-Coding\HW-Debug-Tools`。
DLPilot 工作在两端:HDMI **源端**(PC 显卡侧,EDID/分辨率/图案)与**光机控制端** DLPilot 工作在两端:HDMI **源端**(PC 显卡侧,EDID/分辨率/图案)与**光机控制端**
(RS485 → 板载 GD32E230 网关 → DLPC3421)。寄存器语义判读基线(0x606E 位交换 / (RS485 → 板载 GD32E230 网关 → DLPC3421)。寄存器语义判读基线(0x606E 位交换 /
0x606D 整组顺序)与光机协议规程都在上游仓库 `doc/architecture/` 与 `protocols.md`,不在本仓库。 0x606D 整组顺序)与光机协议规程都在上游仓库 `doc/architecture/` 与 `protocols.md`,不在本仓库。
@@ -42,6 +42,8 @@ DLPilot/
| `src/config.py` | 运行目录定位(BASE_DIR)与 pattern_slots.json 读写(slots/area/serial 三键) | | `src/config.py` | 运行目录定位(BASE_DIR)与 pattern_slots.json 读写(slots/area/serial 三键) |
| `src/dlpc_rs485.py` | 光机 RS485 协议模块: 帧构造/增量解析/译码表/预设命令/串口线程(DLPC485) | | `src/dlpc_rs485.py` | 光机 RS485 协议模块: 帧构造/增量解析/译码表/预设命令/串口线程(DLPC485) |
| `src/selftest_rs485.py` | 光机协议自检(对照上游实测帧向量, 无硬件回归, 改协议代码后必跑) | | `src/selftest_rs485.py` | 光机协议自检(对照上游实测帧向量, 无硬件回归, 改协议代码后必跑) |
| `src/selftest_theme.py` | 主题切换回归(离屏, swatch 底色/渲染像素/标题字号, 改样式后必跑) |
| `src/selftest_log.py` | 收发日志上限与队列消费回归(离屏, 改 _dlpc_log/_poll_dlpc 后必跑) |
| `src/build_exe.py` | PyInstaller 打包 → `release/DLPilot.exe`(onefile/noconsole, 内嵌图标+pyserial+PySide6-Fluent-Widgets) | | `src/build_exe.py` | PyInstaller 打包 → `release/DLPilot.exe`(onefile/noconsole, 内嵌图标+pyserial+PySide6-Fluent-Widgets) |
| `src/package_release.py` | 复制图案到 release/(平铺) → `release/DLPilot-v<ver>-win64.zip`(校验栏位引用完整性; release/ 与 zip 内容一致) | | `src/package_release.py` | 复制图案到 release/(平铺) → `release/DLPilot-v<ver>-win64.zip`(校验栏位引用完整性; release/ 与 zip 内容一致) |
| `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(tag 默认取 zip 文件名) | | `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(tag 默认取 zip 文件名) |
@@ -92,6 +94,13 @@ DLPilot/
五卡(显示器/模式/EDID 信息/测试图案/hex),右列光机五卡(串口/数据/电源/命令/滑条), 五卡(显示器/模式/EDID 信息/测试图案/hex),右列光机五卡(串口/数据/电源/命令/滑条),
支持的模式和 EDID 信息默认折叠;顶栏 Dark/Light/System 切换。Light/Dark 同时更新 支持的模式和 EDID 信息默认折叠;顶栏 Dark/Light/System 切换。Light/Dark 同时更新
Fluent 主题、Qt 调色板、容器样式与 Windows DWM 标题栏,勿只调用 setTheme。 Fluent 主题、Qt 调色板、容器样式与 Windows DWM 标题栏,勿只调用 setTheme。
**fluent 控件的自定义样式禁止直接 setStyleSheet**:setTheme 会用库自带 qss 重刷全部
已注册控件,直写样式随之丢失(v0.4.0 swatch 丢底色的根因)。须走
`setCustomStyleSheet(控件, lightQss, darkQss)`(qss 需覆盖 hover/pressed/disabled 态,
否则悬停瞬间被自带态样式盖回);仅 PushButton 等无 themeChanged 钩子的控件可幸存。
FluentLabelBase 系(StrongBodyLabel 等)例外:其 setTextColor 在每次切主题时整体重写
自定义槽,任何 setCustomStyleSheet 都会被冲掉——字号等改用 QFont(自带 label.qss 无
font-size 规则,QFont 不会被冲)。RGB swatch 底色回归测试见 selftest_theme.py。
PD 协议成功显示绿色,FPDO 仍独立判定。打包必须收集 `qfluentwidgets` 资源,并加入 Windows 系统 `icuuc.dll`,否则 QtCore 可能被其它 ICU DLL 解析失败。 PD 协议成功显示绿色,FPDO 仍独立判定。打包必须收集 `qfluentwidgets` 资源,并加入 Windows 系统 `icuuc.dll`,否则 QtCore 可能被其它 ICU DLL 解析失败。
## 4. 领域语义(测试值都有含义,勿"顺手改") ## 4. 领域语义(测试值都有含义,勿"顺手改")
@@ -155,6 +164,9 @@ python publish_gitea.py ../release/DLPilot-v<版本>-win64.zip --replace --insec
- GUI 与 hdmi_source.ps1 各自独立实现显示器枚举,行为可能微差(GUI 与 `-List` 序号一致, - GUI 与 hdmi_source.ps1 各自独立实现显示器枚举,行为可能微差(GUI 与 `-List` 序号一致,
但与"设置"面板序号无保证);多屏操作前先确认序号; 但与"设置"面板序号无保证);多屏操作前先确认序号;
- `pattern_slots.json` 兼容两种历史格式,写出只写新格式(详见 §3.11); - `pattern_slots.json` 兼容两种历史格式,写出只写新格式(详见 §3.11);
- `--seconds` 冒烟在已有 DLPilot 实例(含用户开着的 exe)运行时会"挂起":run_gui
检测到单实例互斥后弹模态框等点击。无头验证改用直启 `App(5)` 的等效脚本,或先关实例;
重打包同理——build_exe.py 会 taskkill 同名进程,等于替用户关掉开着的实例;
- 旧版 `app_gui.py` 仅保留作历史对照,当前入口使用 `app_fluent.py`;新增或修改界面时 - 旧版 `app_gui.py` 仅保留作历史对照,当前入口使用 `app_fluent.py`;新增或修改界面时
应遵循 PySide6-Fluent-Widgets 的控件和 Qt 布局,不要把旧版 CustomTkinter 依赖带回入口。 应遵循 PySide6-Fluent-Widgets 的控件和 Qt 布局,不要把旧版 CustomTkinter 依赖带回入口。
@@ -162,12 +174,16 @@ python publish_gitea.py ../release/DLPilot-v<版本>-win64.zip --replace --insec
1. `python -m compileall -q src`(在 DLPilot/ 下) —— 全模块可编译; 1. `python -m compileall -q src`(在 DLPilot/ 下) —— 全模块可编译;
2. `python selftest_rs485.py` —— 光机协议回归(改 dlpc_rs485.py 后必跑,向量来自上游 protocols.md); 2. `python selftest_rs485.py` —— 光机协议回归(改 dlpc_rs485.py 后必跑,向量来自上游 protocols.md);
3. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关); 3. `python selftest_theme.py` —— 主题切换回归(改 app_fluent.py 涉及样式/主题后必跑;
4. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路; 离屏渲染,Dark/Light 往返后校验 swatch 底色样式与渲染像素、标题字号);
5. `python hdmi_gui.py --dump` —— EDID 解算不抛异常; 3b. `python selftest_log.py` —— 收发日志上限/队列消费回归(改 `_dlpc_log`/`_poll_dlpc`
6. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级); 后必跑;600 行封顶 500、坏条目不中断 drain);
7. `python build_exe.py` → 双击 release/DLPilot.exe 确认图标/双栏布局/投图/光机区渲染; 4. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
8. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。 5. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
6. `python hdmi_gui.py --dump` —— EDID 解算不抛异常;
7. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级);
8. `python build_exe.py` → 双击 release/DLPilot.exe 确认图标/双栏布局/投图/光机区渲染;
9. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。
## 8. v0.4.0 进度记录(2026-09-24) ## 8. v0.4.0 进度记录(2026-09-24)
@@ -180,3 +196,62 @@ python publish_gitea.py ../release/DLPilot-v<版本>-win64.zip --replace --insec
发布使用仓库预置配置,临时打包后恢复本机文件。 发布使用仓库预置配置,临时打包后恢复本机文件。
- 关联动态:EDID-Viewer 已于 2026-09-25 从本工具的"源端 EDID 查看"需求独立成工具 - 关联动态:EDID-Viewer 已于 2026-09-25 从本工具的"源端 EDID 查看"需求独立成工具
入库并发布 v0.1.0,与本目录无代码共享;I2C-Inject 已标记暂停开发(详见根 `agent.md`)。 入库并发布 v0.1.0,与本目录无代码共享;I2C-Inject 已标记暂停开发(详见根 `agent.md`)。
## 9. v0.4.0 后修复记录
- 2026-09-28 修复"切换 Light 后 R/G/B hex 按钮底色丢失、切回 Dark 也不恢复":根因是
fluent 控件直接 `setStyleSheet` 的自定义样式在 `setTheme` 时被库重刷的自带 qss 整体
覆盖(任何方向的切换都会丢,非 Light 特有)。swatch 改走 `setCustomStyleSheet`(qss
覆盖 hover/pressed/disabled 态),同病的顶栏标题 24px 字号改用 QFont(FluentLabelBase
的自定义槽被库的 setTextColor 每次切主题重写,见 §3.12)。新增 `src/selftest_theme.py`
离屏回归(样式表存活 + 渲染像素比对),本次验证:compileall / selftest_rs485 /
selftest_theme / GUI 冒烟全部通过。
- 2026-09-28 以 v0.4.1 重新打包:APP_VERSION → 0.4.1,`build_exe.py` 生成新
release/DLPilot.exe(67.1 MB,exe `--seconds 5` 定时退出冒烟 exit 0),按 §8 约定
临时还原预置 pattern_slots.json 后 `package_release.py 0.4.1` 生成
`DLPilot-v0.4.1-win64.zip`(66.8 MB,zip 内为预置 COM15 配置,本机 COM5 运行态
已还原未入库)。Gitea Release 已发布:tag `DLPilot-v0.4.1` 指向提交 `62b325c`
(源码+exe+文档同一提交),资产 `DLPilot-v0.4.1-win64.zip`(66.8 MB),
https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases/tag/DLPilot-v0.4.1 。
发布说明用 --notes-file 传入(内置 DEFAULT_NOTES 模板还是 v0.1.0 旧内容,下次
发布记得先写说明文件)。
- 2026-09-28 第二修:"串口未选/选错点连接后窗口被拉长、刷新按钮够不到"。根因:
pyserial 原始异常串(`could not open port '': FileNotFoundError...`)整段塞进
状态 BodyLabel,QLabel 不换行时最小宽度=整行文本宽,把右列参数行最小宽度撑爆。
三层修复:①`_dlpc_toggle` 空串口直接短提示返回(不再先写 SERIAL_CFG/save_slots
再 open——旧代码这顺序还会把本机串口记忆覆写成空串,本机 COM5 记忆即这样丢失过,
已手工修回);②dlpc_rs485 open 失败消息分类为短提示(被占用/不存在/其它),
完整异常改走 on_event 进收发日志;③`lbl_dlpc_st.setWordWrap(True)` 兜底。
验证:compileall / selftest_rs485 ALL PASS / 离屏断言(空串口提示、长文本下标签
最小宽度<300 可换行)/ GUI 冒烟 / 新 exe `--seconds 5` exit 0;v0.4.1 exe(15:05)
与 zip 均已用该修复重打。
- 2026-09-28 v0.4.2 两处 UI 小修:①"串口数据 · 收发日志"卡的"清空"按钮原本自占
一行(_card 的标题行是独立 row,按钮行加在其下),与标题错位且多占一行高度——
`_card()` 增加 `trailing` 参数把按钮并入标题行右端;②v0.4.0 fluent 重写把旧版
app_gui.py 光机命令卡首行的快捷按钮排(开光机/关光机/查版本/软件重启/开TEC/
关TEC/TEC33.0°C)整行丢失,开/关光机只能走预设下拉——本次按旧版排布原样恢复,
位于电流行与预设行之间(命令直发 `_dlpc_send`,无新增协议逻辑)。
验证:compileall / selftest_rs485 / selftest_theme ALL PASS;离屏渲染断言"清空"
与标题垂直中心差 0px、7 个快捷按钮齐全;真实桌面冒烟 exit 0。APP_VERSION → 0.4.2
并重打包。v0.4.2 已发布:tag `DLPilot-v0.4.2` 指向提交 `7bd9943`(源码+exe+文档
同一提交),资产 `DLPilot-v0.4.2-win64.zip`(66.8 MB,zip 内为预置 COM15 配置,
本机 COM5 运行态已还原未入库),
https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases/tag/DLPilot-v0.4.2 。
本次发布说明已用 --notes-file 传入(临时文件,未入库)。
- 2026-10-13 v0.4.3 修复"长会话越跑越卡、命令点了没反应"(FilmForge 同案):
根因是 `_dlpc_log` 的手工 textCursor 裁剪用了**实例级枚举访问**
(`cursor.Start`/`cursor.BlockUnderCursor`),PySide6 6.11 移除该形式,收发日志
超 500 行后每次追加必抛 AttributeError;异常又逃出 `_poll_dlpc` 的 drain 循环
(旧 try 只捕获 queue.Empty),每 tick 最多消费到第一条 event,`_dlpc_log_q`
棘轮积压、日志裁剪/自动滚动全失效;电源自检/功率检测序列在 `_pd_step_next()`
之前的 `_dlpc_log` 调用处被打断,`_pd_seq` 永不清空,后续所有手动命令被
"序列进行中"拦截直到断开重连。整改:①改用 `setMaximumBlockCount(500)` 环形
裁剪(O(1),不依赖枚举写法);②`_poll_dlpc` 改逐事件兜底 try/except,单条异常
只打栈放弃该条、不再中断本轮消费;③新增 `selftest_log.py` 回归(600 行封顶 +
坏条目 drain 不中断)。验证:compileall / selftest_rs485 / selftest_theme /
selftest_log ALL PASS;GUI 冒烟 exit 0;`--dump` / `--dlpc-cmd`(无串口)正常。
APP_VERSION → 0.4.3 重打包(67.1 MB),按 §8 约定临时还原预置 pattern_slots.json
后 `package_release.py 0.4.3` 生成 `DLPilot-v0.4.3-win64.zip`(66.8 MB,zip 内
为预置 COM15 配置,本机 COM5 运行态已还原未入库)。遗留(未随本版处理):
`dlpc_rs485.py` 的 `except OSError` 把写超时(SerialTimeoutException)与设备
拔出混判,一次转换器停滞即断链误报"设备拔出"。
+2 -2
View File
@@ -4,7 +4,7 @@
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制** > HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。 > (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
> >
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) > - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。 > 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
> - **要改代码/重新打包**:见 §8、§9。 > - **要改代码/重新打包**:见 §8、§9。
> >
@@ -28,7 +28,7 @@
## 2. 下载与安装 ## 2. 下载与安装
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases),取最新的 1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases),取最新的
`DLPilot-vX.Y.Z-win64.zip`; `DLPilot-vX.Y.Z-win64.zip`;
2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边); 2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边);
3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。 3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
+2 -2
View File
@@ -4,7 +4,7 @@
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制** > HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。 > (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
> >
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) > - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。 > 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
> - **要改代码/重新打包**:见 §8、§9。 > - **要改代码/重新打包**:见 §8、§9。
> >
@@ -28,7 +28,7 @@
## 2. 下载与安装 ## 2. 下载与安装
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases),取最新的 1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases),取最新的
`DLPilot-vX.Y.Z-win64.zip`; `DLPilot-vX.Y.Z-win64.zip`;
2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边); 2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边);
3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。 3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
+48 -16
View File
@@ -13,6 +13,7 @@ import queue
import re import re
import sys import sys
import time import time
import traceback
# PyInstaller keeps Shiboken's ABI DLLs in a sibling directory. Register both # PyInstaller keeps Shiboken's ABI DLLs in a sibling directory. Register both
# Qt and Shiboken before importing QtCore; this is required by one-file builds # Qt and Shiboken before importing QtCore; this is required by one-file builds
@@ -52,7 +53,8 @@ from PySide6.QtWidgets import (
) )
from qfluentwidgets import ( from qfluentwidgets import (
BodyLabel, CardWidget, ComboBox, LineEdit, PlainTextEdit, BodyLabel, CardWidget, ComboBox, LineEdit, PlainTextEdit,
PrimaryPushButton, PushButton, Slider, StrongBodyLabel, Theme, setTheme, PrimaryPushButton, PushButton, Slider, StrongBodyLabel, Theme,
setCustomStyleSheet, setTheme,
) )
import config import config
@@ -62,8 +64,8 @@ import edid_parse
import patterns import patterns
APP_NAME = "DLPilot" APP_NAME = "DLPilot"
APP_VERSION = "0.4.0" APP_VERSION = "0.4.3"
REPO_URL = "https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools" REPO_URL = "https://gitea.hulk.wang/hulk/HW-Debug-Tools"
MONO = QFont("Consolas", 10) MONO = QFont("Consolas", 10)
CHIP_IDLE = "#666666" CHIP_IDLE = "#666666"
CHIP_OK = "#2FA574" CHIP_OK = "#2FA574"
@@ -209,7 +211,7 @@ class App(QMainWindow):
button.clicked.connect(callback) button.clicked.connect(callback)
return button return button
def _card(self, title, parent): def _card(self, title, parent, trailing=None):
card = CardWidget(parent) card = CardWidget(parent)
layout = QVBoxLayout(card) layout = QVBoxLayout(card)
layout.setContentsMargins(14, 12, 14, 14) layout.setContentsMargins(14, 12, 14, 14)
@@ -217,6 +219,8 @@ class App(QMainWindow):
row = QHBoxLayout() row = QHBoxLayout()
row.addWidget(StrongBodyLabel(title, card)) row.addWidget(StrongBodyLabel(title, card))
row.addStretch(1) row.addStretch(1)
if trailing is not None:
row.addWidget(trailing)
layout.addLayout(row) layout.addLayout(row)
return card, layout return card, layout
@@ -266,7 +270,12 @@ class App(QMainWindow):
outer.setSpacing(12) outer.setSpacing(12)
header = QHBoxLayout() header = QHBoxLayout()
title = StrongBodyLabel("DLPilot", root) title = StrongBodyLabel("DLPilot", root)
title.setStyleSheet("font-size: 24px; font-weight: 700;") # 标题字号走 QFont:FluentLabelBase 在每次主题切换时会用自带的颜色规则
# 重写自定义 qss 槽,setStyleSheet/setCustomStyleSheet 写的字号都会被冲掉
title_font = title.font()
title_font.setPixelSize(24)
title_font.setBold(True)
title.setFont(title_font)
header.addWidget(title) header.addWidget(title)
header.addWidget(BodyLabel("EDID · 测试图案 · RS485 光机控制", root)) header.addWidget(BodyLabel("EDID · 测试图案 · RS485 光机控制", root))
header.addStretch(1) header.addStretch(1)
@@ -355,7 +364,13 @@ class App(QMainWindow):
row.addWidget(BodyLabel(f"{channel}: 0x", card)) row.addWidget(BodyLabel(f"{channel}: 0x", card))
for label, color in items: for label, color in items:
button = PushButton(label, card) button = PushButton(label, card)
button.setStyleSheet(f"PushButton {{ background: {color}; color: {'black' if color == '#00FF00' else 'white'}; }}") # fluent 控件的样式须走 setCustomStyleSheet:直接 setStyleSheet 的内容
# 会在 setTheme 时被库重刷的自带 qss 冲掉;且须覆盖 hover/pressed 态,
# 否则悬停瞬间底色仍会被自带态样式盖回
fg = 'black' if color == '#00FF00' else 'white'
qss = ("PushButton, PushButton:hover, PushButton:pressed, "
f"PushButton:disabled {{ background-color: {color}; color: {fg}; border: none; }}")
setCustomStyleSheet(button, qss, qss)
button.clicked.connect(lambda checked=False, c=color: self._show_solid(c)) button.clicked.connect(lambda checked=False, c=color: self._show_solid(c))
row.addWidget(button) row.addWidget(button)
row.addStretch(1) row.addStretch(1)
@@ -414,18 +429,22 @@ class App(QMainWindow):
setattr(self, attr, combo) setattr(self, attr, combo)
params.addWidget(combo) params.addWidget(combo)
self.lbl_dlpc_st = BodyLabel("未连接", card) self.lbl_dlpc_st = BodyLabel("未连接", card)
# 状态标签允许换行兜底: 任何长错误串都不得撑大参数行最小宽度(否则窗口拉长,
# 刷新/连接按钮被挤出可视区)
self.lbl_dlpc_st.setWordWrap(True)
params.addWidget(self.lbl_dlpc_st) params.addWidget(self.lbl_dlpc_st)
params.addStretch(1) params.addStretch(1)
body.addLayout(params) body.addLayout(params)
layout.addWidget(card) layout.addWidget(card)
card, body = self._card("串口数据 · 收发日志(译码 + 原始 hex)", self) card, body = self._card("串口数据 · 收发日志(译码 + 原始 hex)", self,
row = QHBoxLayout() trailing=self._button("清空", self._dlpc_clear))
row.addStretch(1)
row.addWidget(self._button("清空", self._dlpc_clear))
body.addLayout(row)
self.txt_dlpc = self._text() self.txt_dlpc = self._text()
self.txt_dlpc.setMinimumHeight(230) self.txt_dlpc.setMinimumHeight(230)
# 收发日志环形裁剪: 超 500 行自动丢最旧(O(1))。勿改回 textCursor 手工裁剪——
# PySide6 6.11 移除实例级枚举访问, cursor.Start 必抛 AttributeError 且异常会
# 打断 _poll_dlpc 的队列消费(事件棘轮积压), 回归见 selftest_log.py
self.txt_dlpc.document().setMaximumBlockCount(500)
body.addWidget(self.txt_dlpc) body.addWidget(self.txt_dlpc)
self._dlpc_log("帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN <payload> CRC") self._dlpc_log("帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN <payload> CRC")
layout.addWidget(card) layout.addWidget(card)
@@ -457,6 +476,14 @@ class App(QMainWindow):
row.addStretch(1) row.addStretch(1)
row.addWidget(self._button("查电流", self._query_current)) row.addWidget(self._button("查电流", self._query_current))
body.addLayout(row) body.addLayout(row)
# 快捷命令行: v0.4.0 重写时从旧版 app_gui.py 丢失, 开关光机等一发命令
# 不应只能走预设下拉
quick = QHBoxLayout()
for name, cmd in (("开光机", "M730S0"), ("关光机", "M730S1"), ("查版本", "M999"),
("软件重启", "M888"), ("开TEC", "M7S0"), ("关TEC", "M7S1"),
("TEC33.0°C", "M8S330")):
quick.addWidget(self._button(name, lambda checked=False, c=cmd: self._dlpc_send(c)))
body.addLayout(quick)
preset_row = QHBoxLayout() preset_row = QHBoxLayout()
self._dlpc_presets = [(n, cmd) for n, cmd, _ in dlpc_rs485.PRESETS] self._dlpc_presets = [(n, cmd) for n, cmd, _ in dlpc_rs485.PRESETS]
self.cbo_dlpc_cmd = ComboBox(card) self.cbo_dlpc_cmd = ComboBox(card)
@@ -733,9 +760,12 @@ class App(QMainWindow):
def _query_lamp(self): self._tl_order.append('LAMP'); self._dlpc_send('M202') def _query_lamp(self): self._tl_order.append('LAMP'); self._dlpc_send('M202')
def _poll_dlpc(self): def _poll_dlpc(self):
try: while True:
while True: try:
kind, value = self._dlpc_log_q.get_nowait() kind, value = self._dlpc_log_q.get_nowait()
except queue.Empty:
break
try:
if kind == 'status': if kind == 'status':
self.lbl_dlpc_st.setText(value) self.lbl_dlpc_st.setText(value)
if value.startswith(('已断开', '串口丢失')): self._serial_reset_state() if value.startswith(('已断开', '串口丢失')): self._serial_reset_state()
@@ -744,15 +774,16 @@ class App(QMainWindow):
elif kind == 'pdframe': self._pd_step_done(value.type_code == 0xF0, value) elif kind == 'pdframe': self._pd_step_done(value.type_code == 0xF0, value)
elif kind == 'pwrframe': self._pwr_step_done(value) elif kind == 'pwrframe': self._pwr_step_done(value)
else: self._dlpc_log(value) else: self._dlpc_log(value)
except queue.Empty: pass except Exception:
# 单条事件处理异常只打栈放弃该条, 不得中断本轮消费——否则积压随事件流
# 单调增长(v0.4.3 前日志裁剪异常即此路径, 显示延迟越跑越大)
traceback.print_exc()
connected = self.dlpc.is_connected() connected = self.dlpc.is_connected()
if connected != self._dlpc_connected: if connected != self._dlpc_connected:
self._dlpc_connected = connected; self.btn_dlpc.setText("断开" if connected else "连接") self._dlpc_connected = connected; self.btn_dlpc.setText("断开" if connected else "连接")
def _dlpc_log(self, line): def _dlpc_log(self, line):
self.txt_dlpc.appendPlainText(time.strftime('%H:%M:%S ') + line) self.txt_dlpc.appendPlainText(time.strftime('%H:%M:%S ') + line)
if self.txt_dlpc.blockCount() > 500:
cursor = self.txt_dlpc.textCursor(); cursor.movePosition(cursor.Start); cursor.select(cursor.BlockUnderCursor); cursor.removeSelectedText(); cursor.deleteChar()
self.txt_dlpc.verticalScrollBar().setValue(self.txt_dlpc.verticalScrollBar().maximum()) self.txt_dlpc.verticalScrollBar().setValue(self.txt_dlpc.verticalScrollBar().maximum())
def _dlpc_clear(self): self.txt_dlpc.clear() def _dlpc_clear(self): self.txt_dlpc.clear()
@@ -770,6 +801,7 @@ class App(QMainWindow):
def _dlpc_toggle(self): def _dlpc_toggle(self):
if self.dlpc.is_connected(): self.dlpc.close(); self._serial_reset_state(); return if self.dlpc.is_connected(): self.dlpc.close(); self._serial_reset_state(); return
port = self._dlpc_selected_port() port = self._dlpc_selected_port()
if not port: self.lbl_dlpc_st.setText('未选择串口,请先选择或刷新'); return
try: baud, bytesize, stopbits = int(self.cbo_baud.currentText()), int(self.cbo_databits.currentText()), int(self.cbo_stopbits.currentText()) try: baud, bytesize, stopbits = int(self.cbo_baud.currentText()), int(self.cbo_databits.currentText()), int(self.cbo_stopbits.currentText())
except ValueError: self.lbl_dlpc_st.setText("串口参数非法"); return except ValueError: self.lbl_dlpc_st.setText("串口参数非法"); return
parity = self.cbo_parity.currentText()[:1] or 'N'; config.SERIAL_CFG.update(port=port, baud=baud, bytesize=bytesize, parity=parity, stopbits=stopbits); config.save_slots(self.slots, self.cbo_area.currentText()); self.dlpc.open(port, baud, bytesize=bytesize, parity=parity, stopbits=stopbits) parity = self.cbo_parity.currentText()[:1] or 'N'; config.SERIAL_CFG.update(port=port, baud=baud, bytesize=bytesize, parity=parity, stopbits=stopbits); config.save_slots(self.slots, self.cbo_area.currentText()); self.dlpc.open(port, baud, bytesize=bytesize, parity=parity, stopbits=stopbits)
+1 -1
View File
@@ -41,7 +41,7 @@ CHIP_ERR = '#D9534F'
APP_NAME = 'DLPilot' APP_NAME = 'DLPilot'
APP_VERSION = '0.3.6' APP_VERSION = '0.3.6'
REPO_URL = 'https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools' REPO_URL = 'https://gitea.hulk.wang/hulk/HW-Debug-Tools'
def get_icon_path(): def get_icon_path():
+13 -5
View File
@@ -326,11 +326,19 @@ class DLPC485:
self.ser = None self.ser = None
with self._lock: with self._lock:
self._target = None self._target = None
et = str(e) blob = str(e) + repr(e)
if 'PermissionError' in repr(e) or '拒绝访问' in et \ if 'PermissionError' in blob or '拒绝访问' in blob \
or 'Access is denied' in et: or 'Access is denied' in blob:
et += '(串口被占用: 检查串口助手/另一个本工具实例)' hint = '串口被占用(串口助手/另一实例?)'
self.on_status(f'连接失败: {et}') elif 'FileNotFoundError' in blob or '找不到指定的' in blob \
or 'No such file' in blob:
hint = '串口不存在,请刷新后重选'
else:
hint = '无法打开,详情见日志'
# 状态标签只放短提示(长异常串会撑爆参数行把窗口拉长),
# 完整异常走 on_event 进收发日志
self.on_status(f'连接失败: {hint}')
self.on_event(f'连接失败: {e!r}')
if self.ser is not None: if self.ser is not None:
try: try:
item = None item = None
+70
View File
@@ -0,0 +1,70 @@
# coding: utf-8
"""收发日志上限与队列消费回归自检(无窗口, 离屏渲染)。
背景: v0.4.3 之前收发日志用 textCursor 手工裁剪, 其中 `cursor.Start` /
`cursor.BlockUnderCursor` 是实例级枚举访问 —— PySide6 6.11 移除了该形式,
日志一旦超过 500 行, 每次 _dlpc_log 追加后必抛 AttributeError; 且异常会
逃出 _poll_dlpc 的 drain 循环(旧 try 只捕获 queue.Empty), 每 tick 最多
消费到第一条 event, `_dlpc_log_q` 棘轮积压、日志裁剪与自动滚动全部失效,
电源自检/功率检测序列还会被异常打断在启动处(之后所有命令被"序列进行中"
拦截)。v0.4.3 改为 setMaximumBlockCount 环形裁剪 + 逐事件异常兜底。
本测试保证:
1. 连续追加 600 行日志不抛异常, document().blockCount() 环形封顶 500, 末行在;
2. 单条异常事件(模拟未来引入的坏数据)不中断 _poll_dlpc 本轮队列消费
—— 其后的事件仍全部入日志、队列清空。
运行: python selftest_log.py (依赖 PySide6-Fluent-Widgets 与 pyserial ——
串口卡构建后 txt_dlpc 才存在)
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PySide6.QtWidgets import QApplication
from qfluentwidgets import Theme, setTheme
import app_fluent
import dlpc_rs485
def check_cap(win):
for i in range(600):
win._dlpc_log(f"cap-line-{i}")
blocks = win.txt_dlpc.document().blockCount()
has_last = "cap-line-599" in win.txt_dlpc.toPlainText()
print(f" 追加 600 行: blockCount={blocks}(封顶 500), 末行在: {has_last}")
return blocks <= 500 and has_last
def check_drain_resilience(win):
win._timer.stop() # 排除 80ms 轮询定时器干扰
win._dlpc_log_q.put(("pdframe", "poison-not-a-frame")) # 该条分发必抛异常
win._dlpc_log_q.put(("event", "after-poison-1"))
win._dlpc_log_q.put(("event", "after-poison-2"))
win._poll_dlpc() # 单次手动驱动(不进事件循环)
left = win._dlpc_log_q.qsize()
text = win.txt_dlpc.toPlainText()
drained = left == 0 and "after-poison-1" in text and "after-poison-2" in text
print(f" 坏条目后消费不中断: 残留队列={left}, 后续事件全部写入: {drained}")
return drained
def main():
if not dlpc_rs485.HAS_SERIAL:
print("FAILED 缺少 pyserial: 串口卡未构建, txt_dlpc 不存在")
return 1
app = QApplication.instance() or QApplication(sys.argv)
setTheme(Theme.AUTO)
win = app_fluent.App(0)
passed = check_cap(win)
passed &= check_drain_resilience(win)
win.close()
print("ALL PASS" if passed else "FAILED")
return 0 if passed else 1
if __name__ == "__main__":
sys.exit(main())
+82
View File
@@ -0,0 +1,82 @@
# coding: utf-8
"""主题切换回归自检(无窗口, 离屏渲染)。
背景: fluent 控件直接 setStyleSheet 的样式会在 setTheme 时被库重刷的自带 qss 冲掉
(v0.4.0 RGB swatch 按钮切主题丢底色的根因)。swatch 改走 setCustomStyleSheet、
标题字号走 QFont 后,本测试保证:
1. Dark -> Light -> Dark -> Light 往返后 9 个 swatch 按钮样式表仍带底色;
2. Light 下实际渲染出的按钮中心像素 = swatch 色值(不只样式表字符串存在);
3. 标题 24px 加粗(QFont)在往返后不变。
运行: python selftest_theme.py (依赖 PySide6-Fluent-Widgets)
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QApplication
from qfluentwidgets import PushButton, StrongBodyLabel, Theme, setTheme
import app_fluent
import patterns
def check_style_sheet(win, app):
swatches = [b for b in win.findChildren(PushButton) if b.text() in {"55", "AA", "FF"}]
assert len(swatches) == 9, f"expect 9 swatch buttons, got {len(swatches)}"
title = [l for l in win.findChildren(StrongBodyLabel) if l.text() == "DLPilot"]
assert len(title) == 1, "title label not found"
colored = sum(1 for b in swatches if "background-color: #" in b.styleSheet())
f = title[0].font()
print(f" swatch 样式表带底色: {colored}/9, 标题 24px 粗体: "
f"{f.pixelSize() == 24 and f.bold()}")
return colored == 9 and f.pixelSize() == 24 and f.bold()
def check_rendered_pixel(win, app):
"""Light 主题下逐个渲染 swatch,与目标色一致的像素须占明显多数(背景为主,
中心处是文字字形,不能只采单点)。"""
ok = True
for channel, items in patterns.SOLID_ROWS:
for label, color in items:
button = next(b for b in win.findChildren(PushButton)
if b.text() == label and f"background-color: {color}" in b.styleSheet())
image = button.grab().toImage()
target = QColor(color)
total, matched = image.width() * image.height(), 0
for y in range(image.height()):
for x in range(image.width()):
if image.pixelColor(x, y) == target:
matched += 1
ratio = matched / total
same = ratio >= 0.2
ok &= same
if not same:
print(f" 渲染不符: {channel}/{label} 期望 {color} 仅占 {ratio:.0%}")
print(f" Light 下渲染像素比对: {'一致' if ok else '存在偏差'}")
return ok
def main():
app = QApplication.instance() or QApplication(sys.argv)
setTheme(Theme.AUTO)
win = app_fluent.App(0)
passed = True
for step, theme in (("初始 Dark", Theme.DARK), ("切 Light", Theme.LIGHT),
("切回 Dark", Theme.DARK), ("再切 Light", Theme.LIGHT)):
setTheme(theme)
app.processEvents()
print(f"[{step}]")
passed &= check_style_sheet(win, app)
if theme == Theme.LIGHT:
passed &= check_rendered_pixel(win, app)
win.close()
print("ALL PASS" if passed else "FAILED")
return 0 if passed else 1
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -1,7 +1,7 @@
# EDID Viewer — 开发需求与进度 # EDID Viewer — 开发需求与进度
## 基本环境 ## 基本环境
- 主项目位置:`E:\Hulk-Coding\LT8619C_Debug_Tools\EDID-viewer`;为兼容此前约定,已同步镜像到 `E:\Hulk-Coding\LT8619C\_Debug\_Tools\EDID-viewer`。 - 主项目位置:`E:\Hulk-Coding\HW-Debug-Tools\EDID-viewer`;为兼容此前约定,已同步镜像到 `E:\Hulk-Coding\LT8619C\_Debug\_Tools\EDID-viewer`。
- Windows 10/11 x64;Python 3.14;PySide6 6.11.2(Qt 官方 Python 绑定);PySide6-Fluent-Widgets 1.11.3。 - Windows 10/11 x64;Python 3.14;PySide6 6.11.2(Qt 官方 Python 绑定);PySide6-Fluent-Widgets 1.11.3。
- 使用项目 `.venv`;依赖见 requirements.txt;GUI 入口为 `src/main.py`(版本号在 `src/main.py` 的 `APP_VERSION`)。 - 使用项目 `.venv`;依赖见 requirements.txt;GUI 入口为 `src/main.py`(版本号在 `src/main.py` 的 `APP_VERSION`)。
- 独立工具,不依赖、不修改 LT8619C 固件或 DLPilot 工程。 - 独立工具,不依赖、不修改 LT8619C 固件或 DLPilot 工程。
+1 -1
View File
@@ -2,7 +2,7 @@
Windows 10/11 显示器 EDID 查看器。使用 **PySide6 / PySide6-Fluent-Widgets**,只读,不修改显示设置。 Windows 10/11 显示器 EDID 查看器。使用 **PySide6 / PySide6-Fluent-Widgets**,只读,不修改显示设置。
> 只想使用工具:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) > 只想使用工具:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
> 下载 `EDID-Viewer-vX.Y.Z-win64.zip`,解压双击 `EDID-Viewer.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。 > 下载 `EDID-Viewer-vX.Y.Z-win64.zip`,解压双击 `EDID-Viewer.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
## 使用 ## 使用
+1 -1
View File
@@ -2,7 +2,7 @@
Windows 10/11 显示器 EDID 查看器。使用 **PySide6 / PySide6-Fluent-Widgets**,只读,不修改显示设置。 Windows 10/11 显示器 EDID 查看器。使用 **PySide6 / PySide6-Fluent-Widgets**,只读,不修改显示设置。
> 只想使用工具:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) > 只想使用工具:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
> 下载 `EDID-Viewer-vX.Y.Z-win64.zip`,解压双击 `EDID-Viewer.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。 > 下载 `EDID-Viewer-vX.Y.Z-win64.zip`,解压双击 `EDID-Viewer.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
## 使用 ## 使用
+1 -1
View File
@@ -41,7 +41,7 @@ from windows_edid import scan
APP_NAME = "EDID Viewer" APP_NAME = "EDID Viewer"
APP_VERSION = "0.1.0" APP_VERSION = "0.1.0"
REPO_URL = "https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools" REPO_URL = "https://gitea.hulk.wang/hulk/HW-Debug-Tools"
def icon_path(): def icon_path():
+17 -10
View File
@@ -1,16 +1,18 @@
# LT8619C_Debug_Tools # HW-Debug-Tools
LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工具集。 硬件调试上位机工具集,起家于 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套,
现收纳全部调试工具;原名 `LT8619C_Debug_Tools`,2026-09-28 更名(Gitea 旧地址自动重定向)。
上游主仓库(固件):[LT8619C_DLPC3421_HDMI](https://gitea.hulk.wang/hulk/LT8619C_DLPC3421_HDMI)。 上游主仓库(固件):[LT8619C_DLPC3421_HDMI](https://gitea.hulk.wang/hulk/LT8619C_DLPC3421_HDMI)。
## 工具一览 ## 工具一览
| 工具 | 用途 | 获取 | | 图标 | 工具 | 用途 | 获取 |
|---|---|---| |---|---|---|---|
| [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) | | <img src="DLPilot/src/logos/logo_dlpilot.png" width="64" alt="DLPilot 图标"/> | [DLPilot](DLPilot/) | HDMI 源端(EDID 查看/逐字节解析、分辨率切换、纯色与测试图案全屏投屏)+ 光机控制(RS485→GD32 网关→DLPC3421:开关光机/设光强/查温度电流) | 到 [Releases](https://gitea.hulk.wang/hulk/HW-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) | | <img src="EDID-viewer/assets/edid-viewer.png" width="64" alt="EDID-Viewer 图标"/> | [EDID-viewer](EDID-viewer/) | Windows 显示器 EDID 只读查看器:活动设备或离线文件的厂家/序列号/分辨率时序、CTA/HDMI/音频/HDR 能力、校验诊断;BIN/HEX 导入,BIN/JSON 报告导出 | 到 [Releases](https://gitea.hulk.wang/hulk/HW-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) | | <img src="SerialMate/assets/serial-mate.png" width="64" alt="SerialMate 图标"/> | [SerialMate](SerialMate/) | RS485/串口调试助手:收发 + 协议包解析(帧构成令牌序列/包头/板ID/LEN口径/CRC 全可定制,默认兼容 DLPilot 光机协议)、板型档案一键切换协议参数与命令预设、payload 字符串/HEX 显示、4 通道曲线(弹窗独立、时间窗滑笔回看、悬停读值、暂停渲染)、发送框输入 payload 自动包装组帧(`/` 分隔多条)、HEX 直发与周期发送 | 到 [Releases](https://gitea.hulk.wang/hulk/HW-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) | | <img src="I2C-Inject/logos/logo_i2c_orange.png" width="64" alt="I2C-Inject 图标"/> | [I2C-Inject](I2C-Inject/) | ⏸ **暂停开发**:LT8619C 寄存器在线读写(RP2040 注入器在 demo 板 I2C 总线上择机注入,避让主控轮询,写后自动读回校验)。代码与固件保留备查 | 暂不提供发布;状态与原理详见 [I2C-Inject/README.md](I2C-Inject/README.md) |
| <img src="gd32e230f8_firmware_merge_tool/FlashTool/src/logos/logo_flash.png" width="64" alt="GD32 固件工具图标"/> | [gd32e230f8_firmware_merge_tool](gd32e230f8_firmware_merge_tool/) | 🔗 **子模块**(独立仓库独立管理):GD32E230 固件工具——APP/BL 合成补齐(`_UPDATE.bin`/`_BL.bin`)+ DAPLink Flash GUI 烧录(自带 OpenOCD 工具链) | 本仓目录即快捷入口;版本更新与发布在其[原仓库](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool)(Releases 下 zip 解压即用);详见 [README](gd32e230f8_firmware_merge_tool/README.md) |
## 环境要求 ## 环境要求
@@ -30,7 +32,7 @@ LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工
仓库整体的开发需求与进度管理见根目录 `agent.md`。 仓库整体的开发需求与进度管理见根目录 `agent.md`。
``` ```
LT8619C_Debug_Tools/ HW-Debug-Tools/
├── DLPilot/ ├── DLPilot/
│ ├── src/ # 源码 + 打包/发布脚本 + 图标源 │ ├── src/ # 源码 + 打包/发布脚本 + 图标源
│ ├── assets/ # 生成的测试图案 PNG(gen_*.py 产物) │ ├── assets/ # 生成的测试图案 PNG(gen_*.py 产物)
@@ -49,6 +51,7 @@ LT8619C_Debug_Tools/
│ ├── README.md # 使用说明 │ ├── README.md # 使用说明
│ └── AGENTS.md # 工程档案 │ └── AGENTS.md # 工程档案
├── I2C-Inject/ # PC 端 GUI + RP2040 固件 + 打包 exe + 图标 + README ├── I2C-Inject/ # PC 端 GUI + RP2040 固件 + 打包 exe + 图标 + README
├── gd32e230f8_firmware_merge_tool/ # 🔗 git 子模块 → GD32E230 固件工具(独立仓库,见下方备注)
├── LICENSE ├── LICENSE
└── README.md └── README.md
``` ```
@@ -67,6 +70,10 @@ python publish_gitea.py <工具>-v<版本>-win64.zip --insecure # 上传 Gite
## 备注 ## 备注
- 源码与打包产物(exe、pattern_slots.json)均入库;发布 zip 不入库,只上传 Gitea Release - 源码与打包产物(exe、pattern_slots.json)均入库;发布 zip 不入库,只上传 Gitea Release
- `gd32e230f8_firmware_merge_tool/` 是 git 子模块([独立仓库](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool),
代码与发布均在其仓内管理,本仓只锁定指针):
新克隆后需 `git submodule update --init` 拉取内容;
跟进其最新版用 `git submodule update --remote gd32e230f8_firmware_merge_tool`,随本仓提交新指针
- 两件工具的寄存器语义(0x606E 位交换 / 0x606D 整组顺序)均经 demo 板逻辑分析仪 - 两件工具的寄存器语义(0x606E 位交换 / 0x606D 整组顺序)均经 demo 板逻辑分析仪
双色交叉实测,过程数据与规程存于上游主仓库 `doc/architecture/` 双色交叉实测,过程数据与规程存于上游主仓库 `doc/architecture/`
- 更新:2026-09-27 - 更新:2026-09-30
+68 -2
View File
@@ -6,7 +6,7 @@
## 1. 项目定位 ## 1. 项目定位
仓库 `LT8619C_Debug_Tools` 是 LT8619C→DLPC3421 投影转接板的调试上位机工具集。 仓库 `HW-Debug-Tools` 是 LT8619C→DLPC3421 投影转接板的调试上位机工具集。
SerialMate 是其中的**通用串口/RS485 调试助手**:收发 + 协议解析 + 曲线。 SerialMate 是其中的**通用串口/RS485 调试助手**:收发 + 协议解析 + 曲线。
默认帧格式与 DLPilot 光机 RS485 协议完全一致(`D5 01 LEN <payload> CRC`), 默认帧格式与 DLPilot 光机 RS485 协议完全一致(`D5 01 LEN <payload> CRC`),
可直接调试板载 GD32E230 网关;帧格式(包头/板ID/CRC 算法与覆盖范围)可定制, 可直接调试板载 GD32E230 网关;帧格式(包头/板ID/CRC 算法与覆盖范围)可定制,
@@ -54,7 +54,7 @@ SerialMate/
保证内容贴顶(卡片被拖高后富余高度收在卡底,勿删——否则拉伸被各行均分, 保证内容贴顶(卡片被拖高后富余高度收在卡底,勿删——否则拉伸被各行均分,
标题上方出现空隙);发送卡行序:板型管理行(下拉+新建+删除+配置+重载) / 标题上方出现空隙);发送卡行序:板型管理行(下拉+新建+删除+配置+重载) /
预设行(下拉+填入+直接发送) / 模式行(下拉+存为板型+存入预设) / 输入行 / 预设行(下拉+填入+直接发送) / 模式行(下拉+存为板型+存入预设) / 输入行 /
周期行;帧日志 payload 高亮走 `_append_rx_frame`(html.escape 后拼 span 周期行;帧日志 payload 高亮走 `_append_rx_frame`(`_esc_log` 转义后拼 span
背景,toPlainText 内容不变,selftest 的日志断言因此不受影响);预设下拉 背景,toPlainText 内容不变,selftest 的日志断言因此不受影响);预设下拉
显示"名称 · 命令",存入命令走 `_add_command_named`(弹窗核 `_add_command` 显示"名称 · 命令",存入命令走 `_add_command_named`(弹窗核 `_add_command`
包装,测试用核函数),预设直发走 `_send_preset_direct`(经输入框复用 包装,测试用核函数),预设直发走 `_send_preset_direct`(经输入框复用
@@ -65,6 +65,21 @@ SerialMate/
2. **回调线程边界**:SerialLink 的 on_status/on_frame/on_raw/on_tx 全在后台线程 2. **回调线程边界**:SerialLink 的 on_status/on_frame/on_raw/on_tx 全在后台线程
触发,一律 `events.put(...)` 投递,主线程 30ms QTimer 轮询 `_drain_events` 触发,一律 `events.put(...)` 投递,主线程 30ms QTimer 轮询 `_drain_events`
分发(与 DLPilot `_poll_dlpc` 同模式),禁止回调里直接碰 GUI。 分发(与 DLPilot `_poll_dlpc` 同模式),禁止回调里直接碰 GUI。
**时间戳由 link 后台线程打**(字节到达/写入串口时刻):四个回调签名均带
尾参 ts 并随事件入队(事件元组尾部即 ts,如 `('frame', fr, ts)`)——GUI
消费侧只经 `_timestamp(t)` 格式化、不现取(消费时刻打戳会随 UI 卡顿成批
挤压失真,v0.1.0 教训,见 `ISSUE_rx_timestamp_and_lag.md`)。
2b. **接收日志治理**:日志行只入 `_rx_buf`(`_append_rx`/`_append_rx_frame`
负责转义),`_drain_events` 末尾 `_flush_rx` 一次 appendHtml——每行必须包
`<p style="margin:0">`(**`<br>` 连接只算 1 个 block,会让
maximumBlockCount 裁旧行失效**,offscreen 实验验证过);转义用 `_esc_log`
(空格转 `&nbsp;`,防富文本折叠连续空格破坏 TX 行对齐);`_update_counters`
由 drain 末尾统一调一次,勿回到每帧调用。事件积压超 `FOLD_QSIZE`(200) 时
该批帧走 `_absorb_frame`(曲线照常入库、计数器照常,日志折叠为带首尾到达
时刻的计数摘要行,消化到阈值内自动恢复逐帧)。
2c. **通道配置缓存**:`_on_frame` 帧路径只读 `self._ch_cfg`(`_on_channel`
用户改动配置时经 `_collect_channels` 重建),禁止每帧遍历 UI 控件读配置
(高频时放大 UI 线程占用,v0.1.0 延迟累积的帮凶之一)。
3. **半双工节奏**:link 工作线程每个循环周期最多出队写一帧(20ms 间隔), 3. **半双工节奏**:link 工作线程每个循环周期最多出队写一帧(20ms 间隔),
RS485 总线多帧连发留应答间隙,勿改成批量 drain 一次写。 RS485 总线多帧连发留应答间隙,勿改成批量 drain 一次写。
4. **帧构成语义**:帧由 `FrameLayout` 令牌序列定义(`HDR ID LEN PAYLOAD CRC` 4. **帧构成语义**:帧由 `FrameLayout` 令牌序列定义(`HDR ID LEN PAYLOAD CRC`
@@ -181,6 +196,57 @@ tag/release 命名 `SerialMate-vX.Y.Z`,zip 命名
## 7. 进度记录 ## 7. 进度记录
### v0.1.1(2026-10-10,接收路径修复:时间戳失真 + 显示延迟累积)
背景:FilmForge 固件联调(GD32F405 RS485 115200,M2 连续温度上报 2~10 帧/s)
发现日志时间戳成批挤压、显示延迟越跑越大;固件侧二分验证 100% 出帧,问题
独立存在于 SerialMate 显示层,见 `ISSUE_rx_timestamp_and_lag.md`。
已完成:
- **根因 1**(时间戳在 GUI 消费时刻打):时间戳改在 link 后台线程打
(字节到达/写入串口时刻),`on_status/on_frame/on_raw/on_tx` 签名均追加
尾参 ts 并随事件入队;`_timestamp(t)` 变为纯格式化函数。日志时间恢复为
真实到达时间。
- **根因 2**(接收日志无限增长 → UI 递减性卡顿 → 延迟累积):
- 接收日志批量插入:`_drain_events` 把本轮日志行攒入 `_rx_buf`,末尾
`_flush_rx` 一次 appendHtml(每行一个 `<p style="margin:0">`,
maximumBlockCount 裁旧行照常生效;`_esc_log` 空格转 `&nbsp;` 防富文本
折叠连续空格);`_update_counters` 改为 drain 末尾统一一次;
- 通道配置缓存 `_ch_cfg`:`_on_channel` 时重建,帧路径不再每帧遍历控件
(提取逻辑收敛进 `_extract_channels`);
- 积压折叠:事件队列超 `FOLD_QSIZE`(200) 时该批帧走 `_absorb_frame`——
曲线照常入库、计数器照常,日志折叠为一行计数摘要(含首尾到达时刻,
如 `← 12:00:00.100~12:00:02.300 ⏳ 显示滞后, 折叠 250 帧: B=0xF0 温度
上报 ×250`),消化到阈值内自动恢复逐帧;
- 行数上限 5000(v0.1.0 末期已加的止血,本次提为常量 `RX_MAX_BLOCKS`)。
- 自检扩充:selftest_loop 增回调 ts 断言共 18 项;selftest_gui 增时间戳
格式化/帧日志用到达时刻/通道缓存同步/积压折叠 8 项共 69 项;另做端到端
模拟(600 帧积压一次性消费):日志压到 201 行、曲线 600 点全收、时间戳
单调且逐帧等于真实到达时刻。
- README 接收显示段同步时间戳/日志治理语义;exe 重建入库(v0.1.1);
zip/Release 未发布(待用户验收)。
- **真机 M2S100"卡住"排查(2026-10-10,用户复测反馈)**:用户 v0.1.1 exe
发 M2S100 后 ack 与周期帧均不显示。本机分层复现均正常——裸 pyserial+
解析器(帧 100% 到达、ack ok、100ms 间隔稳)、offscreen GUI 全链路
(60 帧/6s 一帧不丢)、桌面真渲染完整用户序列(连接→M1/M2→M2S100→帧流,
138 帧全显示)。exe 经归档解包确认含 v0.1.1 代码。未能复现,定性为
用户端环境/运行态问题,待用户按状态栏"积压"指示再复现时定位。
排查副产品:
- link.py 串口参数加 `inter_byte_timeout=0.02`(Win32
ReadIntervalTimeout)——注意 **pyserial 的 timeout 是总超时**(150ms 内
必返回,不无限攒批),此参数把周期帧的 read 粒度从 ~150ms 一批改善为
单帧粒度(帧时间戳更准);
- 状态栏计数器追加 **积压 N**(events.qsize())——常驻分层观测:数字
持续增长=事件产出快于消费(GUI 侧问题),恒 0=RX 链路断(串口/驱动/
固件侧问题);"卡住"时 UI 整体无响应则是主线程被阻塞,另查。
- 勘误:修复初期曾把 M2S100 卡住归因"read(4096) 攒批 41 秒",该论述
错误(win32 timeout 为总超时),已修正注释;loop:// 虚拟口的 read
立即返回缓冲,**测不出真串口等待行为**,链路自检覆盖不到此处。
- 探测善后:COM5 FilmForge 固件曾残留 100ms 上报态(用户上次 M2S100
生效的证据),已发 M2S0 关闭(M2 查询=OFF)。
待办 / 计划:无新增,沿用 v0.1.0 待办。
### v0.1.0(2026-09-27,首版) ### v0.1.0(2026-09-27,首版)
已完成: 已完成:
+97
View File
@@ -0,0 +1,97 @@
# SerialMate 接收路径问题报告(时间戳失真 + 显示延迟累积)
> 来源:FilmForge 固件联调(GD32F405 RS485,115200,M2 连续温度上报 2~10 帧/s)。
> 现象与固件侧二分验证结论一起整理,供后续修复。
> 日期:2026-10-10
>
> **【已修复 v0.1.1,2026-10-10】** 根因 1 与根因 2 的 1/2/3/4 全部落地,
> 实现与本文建议的差别:日志批量插入每行包 `<p style="margin:0">`(`<br>`
> 连接只算 1 个 block,会让 maximumBlockCount 失效);转义空格转 `&nbsp;`
> 防富文本折叠连续空格;通道缓存放 `_ch_cfg`(`_on_channel` 时重建)。
> 自检已覆盖(selftest_gui 69 项含积压折叠/到达时间戳用例),
> 端到端模拟 600 帧积压:日志 201 行、曲线 600 点全收、时间戳=到达时刻。
> 详见 AGENTS.md §7 v0.1.1。
## 现象(联调实测)
1. **时间戳挤压**:M2 以 500ms/100ms 周期上报时,日志里出现 13 帧"0.2~1ms 间隔"
挤在同一批(如 23:59:55.221~227),完全不是真实到达间隔。
2. **显示延迟累积**:命令 ack 延迟 120~160ms 才显示;长时间运行后越来越卡,
最终表现为"命令发了没反应"(实际帧在队列里排队,晚些时候才显示)。
3. **偶发"丢帧"观感**:与 2 叠加后,ack 迟迟不显示,上位机/人工判断为无响应。
固件侧已用调试串口镜像做过二分验证:**帧到达固件、固件发出响应均为 100%**,
SerialMate 显示层的时间戳与延迟失真是独立存在的第二层问题。
## 根因 1:时间戳在 GUI 消费时刻打,而非字节到达时刻(确凿)
- `link.py` `_dispatch_rx()` 后台线程解析出帧后回调 `on_frame(fr)`;
- `main.py:237` `self.link.on_frame = lambda fr: self.events.put(('frame', fr))`
—— **入队时不带时间戳**;
- `main.py:1291` `_on_frame()` 里才调 `_timestamp()` —— **消费时打戳**。
后台读线程 20ms 轮询、GUI QTimer 30ms drain,再叠加 UI 卡顿,一串积压帧的
时间戳全部落在消费瞬间,毫秒级挤在一起。**这不是到达时间,是显示时间。**
### 修复建议
时间戳统一在 link 后台线程产生(字节到达时刻),随事件入队:
```python
# link.py _dispatch_rx()
ts = time.time() # 或 time.perf_counter()
for fr in self.parser.feed(data):
if self.on_frame is not None:
self.on_frame(fr, ts) # 携带到达时刻
# raw 同理: self.on_raw(data, ts)
# main.py
self.link.on_frame = lambda fr, ts: self.events.put(('frame', fr, ts))
...
def _on_frame(self, fr, ts): # 显示时用 ts 格式化,不再调 _timestamp()
```
`on_raw` / `on_tx`(若也要求发送时刻)/ `on_status` 同理改造。
`_timestamp()` 改为接受 epoch 秒参数的纯格式化函数。
## 根因 2:接收日志无限增长 → UI 递减性卡顿 → 延迟累积(确凿)
- `_append_rx`/`_append_rx_frame` 对 `text_rx` 逐行 `appendPlainText/appendHtml`,
**无任何行数上限**。长跑后 QPlainTextEdit 文档越滚越大,排版/paint 成本上升,
30ms 的 `_drain_events` 开始处理不完,事件积压,显示延迟越拉越大。
- `_on_frame` 每帧调用 `_collect_channels()`(遍历全部通道 UI 控件读
isChecked/text/value),帧率高时放大 UI 线程占用。
### 修复建议
1. 行数上限:`self.text_rx.setMaximumBlockCount(5000)`(一行代码,先止血);
或定时(如每 5s)裁剪超量历史。
2. 批量插入:`_drain_events` 把本轮全部行拼成一个字符串/HTML 片段,**一次**
append,减少 N 次 UI 重排为 1 次。
3. 通道配置缓存:维护 `self._ch_cfg`,仅在 `_on_channel`(用户改动配置)时
重建;`_on_frame` 直接读缓存,不再每帧遍历控件。
4. 帧率自适应:积压超过阈值(如 events.qsize() > 200)时,帧日志折叠为
计数摘要("…+250 帧 (温度上报)"),保曲线、压日志。
## 排除项(验证过没有问题)
- `protocol.FrameParser`(前缀对齐 + LEN + CRC,失配丢 1 字节重同步)解析器
本身健壮,未发现丢帧逻辑缺陷;`src/selftest.py` 覆盖良好。
- `events = queue.Queue()` 无上限,不会丢事件,只会延迟。
- `link.py` 读线程 20ms 轮询 + `read(4096)`,常规速率下不会溢出驱动缓冲
(115200 下 20ms 最多 288 字节)。
## 优先级建议
1. 先做根因 2 的"1. 行数上限"(一行代码)——立刻止血卡顿。
2. 再做根因 1 的时间戳随事件携带——日志时间恢复可信。
3. 根因 2 的 2/3/4 与根因 1 的状态/tx 同步改造,作为一轮完整性优化。
## 复现方法
固件端(FilmForge,GD32F405,RS485 115200):
- `M2S500` 开启 500ms 周期温度上报(帧 `B5 F0 04 "25.6" CRC`);
- 连续观察 10 分钟以上,或把上报间隔调到 `M2S100` 加速日志增长。
预期:修复前时间戳随 UI 卡顿成批挤压、显示延迟随运行时间增大;
修复后时间戳单调且间隔≈设定周期,长时间运行无卡顿增长。
+4 -1
View File
@@ -36,7 +36,10 @@
| CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 | | CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 |
| CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 | | CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 |
- **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex; - **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex;
时间戳;日志保存为 txt。 时间戳为**字节到达时刻**(串口后台线程打点随帧携带,UI 繁忙时日志时间
依然可信);日志保存为 txt。接收日志自动治理:行数上限 5000(超量淘汰
最旧)、批量刷新、事件积压时高频帧日志自动折叠为计数摘要(曲线照常
采集)——长时间高频运行不累积卡顿。
- **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式 - **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式
(二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率, (二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率,
y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮, y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮,
+56 -29
View File
@@ -1,35 +1,62 @@
"""生成 SerialMate 图标(Pillow, 开发时手动运行): 蓝底白色波形 + RS485 字样""" #!/usr/bin/env python3
from pathlib import Path # -*- coding: utf-8 -*-
"""
SerialMate 图标栅格化(家族风格, 与 DLPilot/EDID-Viewer/FlashTool 同版式):
黑底 512, 绿色圆角矩形, 第一行 Serial(框内黑字), 第二行 Mate(白字)
================================================================
设计参数: 黑底 512, 绿色圆角矩形 x38 y64 w436 h186 rx34 (#00C878, 与
gd32e230f8_firmware_merge_tool 的 FlashTool 同色系), Arial Bold。
Serial 6 字母按框宽自适应字号(上限 146px, 留边距),
Mate 146px 白字(基线对齐同 DLPilot 的 Pilot)。
用法: python make_icon.py # 生成 serial-mate.png / serial-mate.ico
"""
import os
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
out = Path(__file__).resolve().parent BASE = os.path.dirname(os.path.abspath(__file__))
size = 512 FONT = r'C:\Windows\Fonts\arialbd.ttf' # Arial Bold, Windows 标配
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 指示点 def fit_font(draw, text, max_w, start):
for cx, color in ((88, '#ffd166'), (146, '#7ee081')): """从 start 字号往下找能装入 max_w 宽的字号(步进 2)"""
d.ellipse((cx - 17, 84 - 17, cx + 17, 84 + 17), fill=color) size = start
while size > 8:
f = ImageFont.truetype(FONT, size)
box = draw.textbbox((0, 0), text, font=f)
if box[2] - box[0] <= max_w:
return f
size -= 2
return ImageFont.truetype(FONT, 8)
font = 'C:/Windows/Fonts/arialbd.ttf'
d.text((256, 410), 'RS485', font=ImageFont.truetype(font, 72), def render(size=512):
anchor='mm', fill='white') s = size / 512.0
img.save(out / 'serial-mate.png') img = Image.new('RGB', (size, size), (0, 0, 0))
img.save(out / 'serial-mate.ico', d = ImageDraw.Draw(img)
sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), d.rounded_rectangle([38 * s, 64 * s, (38 + 436) * s, (64 + 186) * s],
(128, 128), (256, 256)]) radius=34 * s, fill=(0, 200, 120))
print('icon ->', out / 'serial-mate.ico') if not os.path.exists(FONT):
raise SystemExit(f'未找到字体 {FONT}, 无法栅格化')
# Serial: 黑字居中于橙色框, 按框宽自适应(留 2×18px 边距)
f1 = fit_font(d, 'Serial', 400 * s, round(146 * s))
d.text((size / 2, 64 * s + 186 * s / 2), 'Serial', font=f1,
fill=(0, 0, 0), anchor='mm')
# Mate: 白字, 与家族一致 146px(无下伸字母, 中心 = 基线421 - cap/2 ~= 368)
f2 = ImageFont.truetype(FONT, max(8, round(146 * s)))
d.text((size / 2, 368 * s), 'Mate', font=f2, fill=(255, 255, 255),
anchor='mm')
return img
if __name__ == '__main__':
img = render(512)
png = os.path.join(BASE, 'serial-mate.png')
ico = os.path.join(BASE, 'serial-mate.ico')
img.save(png)
img.save(ico, sizes=[(16, 16), (24, 24), (32, 32), (48, 48),
(64, 64), (128, 128), (256, 256)])
print(f'写出 {png}')
print(f'写出 {ico}')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+4 -1
View File
@@ -36,7 +36,10 @@
| CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 | | CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 |
| CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 | | CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 |
- **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex; - **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex;
时间戳;日志保存为 txt。 时间戳为**字节到达时刻**(串口后台线程打点随帧携带,UI 繁忙时日志时间
依然可信);日志保存为 txt。接收日志自动治理:行数上限 5000(超量淘汰
最旧)、批量刷新、事件积压时高频帧日志自动折叠为计数摘要(曲线照常
采集)——长时间高频运行不累积卡顿。
- **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式 - **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式
(二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率, (二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率,
y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮, y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮,
+49
View File
@@ -0,0 +1,49 @@
"""模拟真机 M2S100 场景: 设备 100ms 周期上报 30 帧, 验证帧按帧粒度
到达 GUI 回调(间隔≈100ms, 到达→消费延迟<300ms), 而非攒批后一次涌入。
"""
import os
import sys
import time
import threading
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from link import SerialLink
import protocol as d
got = []
cfg = d.ProtoConfig()
link = SerialLink(cfg, on_status=lambda s, ts: None)
link.on_frame = lambda fr, ts: got.append((time.time(), ts))
link.open('loop://', 115200)
deadline = time.time() + 5
while time.time() < deadline and not link.is_connected():
time.sleep(0.05)
assert link.is_connected(), 'loop:// 未连接'
resp = bytes.fromhex('B5 F0 08 6F 6B 54 4C 3A 32 34 36 48')
def feeder():
for _ in range(30):
link.ser.write(resp)
time.sleep(0.1)
t = threading.Thread(target=feeder)
t.start()
t.join()
time.sleep(0.5) # 等最后一帧走完读线程
n = len(got)
print('收到帧数:', n, '(期望 30)')
if n >= 2:
gaps = [b[0] - a[0] for a, b in zip(got, got[1:])]
lag = [cb - ts for cb, ts in got]
print('回调间隔 min/max/avg(ms): %d / %d / %d'
% (min(gaps) * 1000, max(gaps) * 1000, sum(gaps) / len(gaps) * 1000))
print('到达→消费延迟 max(ms):', round(max(lag) * 1000))
ok = n == 30 and max(gaps) < 0.5 and max(lag) < 0.3
else:
ok = False
print('RESULT:', 'PASS' if ok else 'FAIL')
link.close()
sys.exit(0 if ok else 1)
+26 -15
View File
@@ -30,11 +30,14 @@ DEFAULTS = dict(bytesize=8, parity='N', stopbits=1)
class SerialLink: class SerialLink:
"""串口工作线程。回调(后台线程触发): """串口工作线程。回调(后台线程触发, 第二参数 ts=事件发生的 epoch 秒):
on_status(str) 连接状态变化(已连接/断开/失败/设备拔出) on_status(text, ts) 连接状态变化(已连接/断开/失败/设备拔出)
on_frame(fr) 解析模式: 完整 Frame(protocol.Frame) on_frame(fr, ts) 解析模式: 完整 Frame(protocol.Frame); ts=本批字节到达时刻
on_raw(data) 透传模式: 原始接收 bytes on_raw(data, ts) 透传模式: 原始接收 bytes; ts=到达时刻
on_tx(label, data) 发送回显: label=payload 字符串(自动包装)/None(HEX直发) on_tx(label, data, ts) 发送回显: label=payload 字符串(自动包装)/None(HEX直发);
ts=写入串口时刻
时间戳必须由本模块在读线程打(字节到达时刻), GUI 消费侧再打会随 UI 卡顿
成批挤压失真——日志时间就不再是到达时间。
""" """
def __init__(self, cfg, on_status): def __init__(self, cfg, on_status):
@@ -100,7 +103,7 @@ class SerialLink:
def open(self, port, baud=DEFAULT_BAUD, **kw): def open(self, port, baud=DEFAULT_BAUD, **kw):
if not HAS_SERIAL: if not HAS_SERIAL:
self.on_status('未安装 pyserial: py -m pip install pyserial') self.on_status('未安装 pyserial: py -m pip install pyserial', time.time())
return return
params = {**DEFAULTS, **kw} params = {**DEFAULTS, **kw}
with self._lock: with self._lock:
@@ -123,7 +126,7 @@ class SerialLink:
with self._lock: with self._lock:
ser = self.ser ser = self.ser
if ser is None: if ser is None:
self.on_status('未连接串口, 命令被丢弃') self.on_status('未连接串口, 命令被丢弃', time.time())
return False return False
self.q.put((label, bytes(frame))) self.q.put((label, bytes(frame)))
return True return True
@@ -148,7 +151,7 @@ class SerialLink:
pass pass
self.ser = None self.ser = None
self._open_key = None self._open_key = None
self.on_status('已断开') self.on_status('已断开', time.time())
if target: if target:
port, baud, dbsize, parity, sbits = target port, baud, dbsize, parity, sbits = target
try: try:
@@ -159,12 +162,19 @@ class SerialLink:
parity=parities.get(parity, serial.PARITY_NONE), parity=parities.get(parity, serial.PARITY_NONE),
stopbits=stopbits.get(sbits, serial.STOPBITS_ONE), stopbits=stopbits.get(sbits, serial.STOPBITS_ONE),
timeout=0.15, timeout=0.15,
write_timeout=1.0) # 写超时: 转换器停滞时 write 会 write_timeout=1.0, # 写超时: 转换器停滞时 write 会
# 永久阻塞并卡死线程, 必须设上限 # 永久阻塞并卡死线程, 必须设上限
inter_byte_timeout=0.02) # 字节间超时(Win32
# ReadIntervalTimeout): timeout=0.15 是"总超时"
# (150ms 内必返回, 不会无限攒批), 周期上报帧下 read
# 会 ~150ms 攒 1~2 帧才返回一批; 设 20ms 后按帧粒度
# 返回, 帧到达时间戳精确到单帧。20ms 仍大于 1200 波特
# 单字节时间(~8.3ms)×2, 不误切帧
with self._lock: with self._lock:
self._open_key = target self._open_key = target
self.parser = None # 换口重建解析器(下次接收时惰性建) self.parser = None # 换口重建解析器(下次接收时惰性建)
self.on_status(f'已连接 {port} @ {baud} {dbsize}{parity}{sbits}') self.on_status(f'已连接 {port} @ {baud} {dbsize}{parity}{sbits}',
time.time())
except Exception as e: except Exception as e:
self.ser = None self.ser = None
with self._lock: with self._lock:
@@ -173,7 +183,7 @@ class SerialLink:
if 'PermissionError' in repr(e) or '拒绝访问' in et \ if 'PermissionError' in repr(e) or '拒绝访问' in et \
or 'Access is denied' in et: or 'Access is denied' in et:
et += '(串口被占用: 检查串口助手/另一个本工具实例)' et += '(串口被占用: 检查串口助手/另一个本工具实例)'
self.on_status(f'连接失败: {et}') self.on_status(f'连接失败: {et}', time.time())
if self.ser is not None: if self.ser is not None:
try: try:
item = None item = None
@@ -185,7 +195,7 @@ class SerialLink:
label, frame = item label, frame = item
self.ser.write(frame) self.ser.write(frame)
if self.on_tx is not None: if self.on_tx is not None:
self.on_tx(label, frame) self.on_tx(label, frame, time.time())
data = self.ser.read(4096) data = self.ser.read(4096)
if data: if data:
self._dispatch_rx(data) self._dispatch_rx(data)
@@ -198,10 +208,11 @@ class SerialLink:
self._open_key = None # 必须同步清除, 否则同参数重连被当作已打开而跳过 self._open_key = None # 必须同步清除, 否则同参数重连被当作已打开而跳过
with self._lock: with self._lock:
self._target = None self._target = None
self.on_status('串口丢失(设备拔出?), 请重新连接') self.on_status('串口丢失(设备拔出?), 请重新连接', time.time())
time.sleep(0.02) time.sleep(0.02)
def _dispatch_rx(self, data): def _dispatch_rx(self, data):
ts = time.time() # 字节到达时刻: 后台线程打, 随帧携带
with self._lock: with self._lock:
parse = self._parse parse = self._parse
if parse and self.parser is None: # 惰性建, 带当前 ProtoConfig if parse and self.parser is None: # 惰性建, 带当前 ProtoConfig
@@ -210,6 +221,6 @@ class SerialLink:
if parse: if parse:
for fr in self.parser.feed(data): for fr in self.parser.feed(data):
if self.on_frame is not None: if self.on_frame is not None:
self.on_frame(fr) self.on_frame(fr, ts)
elif self.on_raw is not None: elif self.on_raw is not None:
self.on_raw(data) self.on_raw(data, ts)
+111 -40
View File
@@ -49,8 +49,8 @@ from protocol import (BIN_FORMATS, CRC_ALGOS, CRC_ALGO_LABELS, CRC_SCOPE_LABELS,
from theme import ThemeManager from theme import ThemeManager
APP_NAME = 'SerialMate' APP_NAME = 'SerialMate'
APP_VERSION = '0.1.0' APP_VERSION = '0.1.1'
REPO_URL = 'https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools' REPO_URL = 'https://gitea.hulk.wang/hulk/HW-Debug-Tools'
BAUDS = (1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600) BAUDS = (1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600)
TX_MODES = ('wrap', 'hex') TX_MODES = ('wrap', 'hex')
@@ -69,10 +69,23 @@ FMT_LABELS = (('u8', 'u8'), ('s8', 's8'),
('u32be', 'u32 大端'), ('u32le', 'u32 小端'), ('u32be', 'u32 大端'), ('u32le', 'u32 小端'),
('s32be', 's32 大端'), ('s32le', 's32 小端'), ('s32be', 's32 大端'), ('s32le', 's32 小端'),
('f32be', 'f32 大端'), ('f32le', 'f32 小端')) ('f32be', 'f32 大端'), ('f32le', 'f32 小端'))
# 接收日志治理: 行数上限(超量丢最旧); 事件队列积压超阈值时, 本轮帧日志折叠为
# 计数摘要(曲线照常), 防长跑时排版成本拖垮 30ms 轮询导致显示延迟越积越大
RX_MAX_BLOCKS = 5000
FOLD_QSIZE = 200
def _timestamp(): def _esc_log(text):
t = time.time() """日志行入 HTML 缓冲前的转义: html.escape + 空格改 &nbsp;——
富文本会折叠连续空格, 日志对齐(如 TX 行 '命令 [hex]')依赖原空格"""
return html.escape(text).replace(' ', '&nbsp;')
def _timestamp(t=None):
"""epoch 秒 -> 'HH:MM:SS.mmm' 纯格式化; t=None 取当前时刻。
日志时间必须传入 link 后台线程打的到达时刻, 不能消费时现取(会随 UI 卡顿挤压失真)"""
if t is None:
t = time.time()
return time.strftime('%H:%M:%S', time.localtime(t)) + f'.{int(t * 1000) % 1000:03d}' return time.strftime('%H:%M:%S', time.localtime(t)) + f'.{int(t * 1000) % 1000:03d}'
@@ -232,14 +245,17 @@ class Window(QMainWindow):
self._plot_win = None # 曲线独立窗口(弹出后非 None) self._plot_win = None # 曲线独立窗口(弹出后非 None)
self.tx_history, self._hist_pos = [], -1 self.tx_history, self._hist_pos = [], -1
self._tx_n = self._rx_n = self._tx_b = self._rx_b = 0 self._tx_n = self._rx_n = self._tx_b = self._rx_b = 0
self._rx_buf = [] # 待写入接收日志的 HTML 行(批量 flush)
self._ch_cfg = [] # 通道配置缓存(_build_ui 后重建)
self.link = SerialLink(cfg=self._proto_from_store(), self.link = SerialLink(cfg=self._proto_from_store(),
on_status=lambda s: self.events.put(('status', s))) on_status=lambda s, ts: self.events.put(('status', s, ts)))
self.link.on_frame = lambda fr: self.events.put(('frame', fr)) self.link.on_frame = lambda fr, ts: self.events.put(('frame', fr, ts))
self.link.on_raw = lambda b: self.events.put(('raw', b)) self.link.on_raw = lambda b, ts: self.events.put(('raw', b, ts))
self.link.on_tx = lambda lb, d: self.events.put(('tx', lb, d)) self.link.on_tx = lambda lb, d, ts: self.events.put(('tx', lb, d, ts))
self._build_ui() self._build_ui()
self.plot.on_yrange_changed = self._on_plot_yrange self.plot.on_yrange_changed = self._on_plot_yrange
self.plot.on_span_changed = self._sync_time_slider self.plot.on_span_changed = self._sync_time_slider
self._ch_cfg = self._collect_channels() # 通道配置缓存(_on_channel 时重建)
self._apply_store_to_ui() self._apply_store_to_ui()
self.theme_manager.set_window(self) self.theme_manager.set_window(self)
self.ed_tx.installEventFilter(self) self.ed_tx.installEventFilter(self)
@@ -634,7 +650,8 @@ class Window(QMainWindow):
return out return out
def _on_channel(self, *_): def _on_channel(self, *_):
for i, c in enumerate(self._collect_channels()): self._ch_cfg = self._collect_channels() # 用户改配置才重建, 帧路径只读缓存
for i, c in enumerate(self._ch_cfg):
self.plot.set_channel_name(i, c['name'] if c['enabled'] else '') self.plot.set_channel_name(i, c['name'] if c['enabled'] else '')
self._update_preview() self._update_preview()
@@ -823,7 +840,7 @@ class Window(QMainWindow):
self.text_rx.setLineWrapMode(QPlainTextEdit.NoWrap) self.text_rx.setLineWrapMode(QPlainTextEdit.NoWrap)
self.text_rx.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)) self.text_rx.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
self.text_rx.setMinimumHeight(120) self.text_rx.setMinimumHeight(120)
self.text_rx.document().setMaximumBlockCount(5000) self.text_rx.document().setMaximumBlockCount(RX_MAX_BLOCKS)
lay.addWidget(self.text_rx, 1) lay.addWidget(self.text_rx, 1)
return card return card
@@ -1245,61 +1262,114 @@ class Window(QMainWindow):
# ------------------------------------------------------- 事件与回显 # ------------------------------------------------------- 事件与回显
def _drain_events(self): def _drain_events(self):
for _ in range(400): # 单次上限, 防极端速率卡 UI """30ms 轮询消费 link 事件。事件尾部携带 link 后台线程打的发生时刻 ts
(字节到达/写入串口), 日志时间因此是到达时间而非显示时间。
接收日志行先进 _rx_buf 攒批, 本轮结束 _flush_rx 一次写入——把 N 次
UI 重排压成 1 次; 队列积压超 FOLD_QSIZE 时帧日志折叠为计数摘要
(显示本批首尾到达时刻, 曲线照常入库), 防消费不逮时越积越卡。"""
n = 0
folded = None # None=未折叠; {'n':帧数,'t0/t1':首尾到达,'cnt':{描述:计数}}
for _ in range(400): # 单次上限, 防极端速率卡 UI
try: try:
ev = self.events.get_nowait() ev = self.events.get_nowait()
except queue.Empty: except queue.Empty:
return break
kind = ev[0] kind = ev[0]
ts = ev[-1]
if kind == 'status': if kind == 'status':
self._on_status(ev[1]) self._on_status(ev[1], ts)
elif kind == 'frame': elif kind == 'frame':
self._on_frame(ev[1]) if folded is None and self.events.qsize() > FOLD_QSIZE:
folded = {'n': 0, 't0': ts, 't1': ts, 'cnt': {}}
if folded is not None:
self._absorb_frame(ev[1], ts, folded)
else:
self._on_frame(ev[1], ts)
elif kind == 'raw': elif kind == 'raw':
self._on_raw(ev[1]) self._on_raw(ev[1], ts)
elif kind == 'tx': elif kind == 'tx':
self._on_tx(ev[1], ev[2]) self._on_tx(ev[1], ev[2], ts)
n += 1
if folded:
detail = ' '.join(f'{desc} ×{c}' for desc, c in folded['cnt'].items())
self._append_rx(f'← {_timestamp(folded["t0"])}~{_timestamp(folded["t1"])}'
f' ⏳ 显示滞后, 折叠 {folded["n"]} 帧: {detail}')
if n:
self._flush_rx()
self._update_counters()
def _hint(self, text): def _hint(self, text, ts=None):
self.lb_state.setText(text) self.lb_state.setText(text)
self.statusBar().showMessage(text, 8000) self.statusBar().showMessage(text, 8000)
self._append_rx(f'· {_timestamp()} {text}') self._append_rx(f'· {_timestamp(ts)} {text}')
def _on_status(self, text): def _on_status(self, text, ts=None):
"""连接状态: 同步连接按钮文案 + 提示""" """连接状态: 同步连接按钮文案 + 提示"""
if text.startswith('已连接'): if text.startswith('已连接'):
self.btn_conn.setText('断开') self.btn_conn.setText('断开')
elif text.startswith(('已断开', '连接失败', '串口丢失')): elif text.startswith(('已断开', '连接失败', '串口丢失')):
self.btn_conn.setText('连接') self.btn_conn.setText('连接')
self._hint(text) self._hint(text, ts)
def _append_rx(self, line): def _append_rx(self, line):
self.text_rx.appendPlainText(line) """接收日志行: 只入批量缓冲, 由 _flush_rx 统一写入"""
self._rx_buf.append(_esc_log(line))
def _append_rx_frame(self, prefix, core, suffix=''): def _append_rx_frame(self, prefix, core, suffix=''):
"""帧日志行: payload 核心内容垫一层浅背景色, 目光一眼锁定。 """帧日志行: payload 核心内容垫一层浅背景色, 目光一眼锁定。
用半透明 rgba —— 主题无关, 明暗切换后历史行不会残留异色""" 用半透明 rgba —— 主题无关, 明暗切换后历史行不会残留异色"""
self.text_rx.appendHtml( self._rx_buf.append(
f'{html.escape(prefix)}' f'{_esc_log(prefix)}'
f'<span style="background-color:rgba(96,125,180,0.16);">' f'<span style="background-color:rgba(96,125,180,0.16);">'
f'{html.escape(core)}</span>' f'{_esc_log(core)}</span>'
f'{html.escape(suffix)}') f'{_esc_log(suffix)}')
def _on_frame(self, fr): def _flush_rx(self):
"""批量写入接收日志: 每行一个 <p>(一个 block), maximumBlockCount
裁旧行照常生效; 一次 appendHtml 替代逐行 N 次, 长跑不再因排版
成本拖垮事件轮询"""
if not self._rx_buf:
return
self.text_rx.appendHtml(
''.join(f'<p style="margin:0">{line}</p>' for line in self._rx_buf))
self._rx_buf.clear()
def _on_frame(self, fr, ts=None):
self._rx_n += 1 self._rx_n += 1
self._rx_b += len(fr.raw) self._rx_b += len(fr.raw)
ts = f'{_timestamp()} ' if self.chk_ts.isChecked() else '' t = f'{_timestamp(ts)} ' if self.chk_ts.isChecked() else ''
disp = RX_DISPLAYS[self.combo_display.currentIndex()] disp = RX_DISPLAYS[self.combo_display.currentIndex()]
payload = hexs(fr.payload) if disp == 'hex' else \ payload = hexs(fr.payload) if disp == 'hex' else \
(clean_text(fr.payload.decode('ascii', 'replace')) or '(空)') (clean_text(fr.payload.decode('ascii', 'replace')) or '(空)')
if fr.code is None: # 布局无 ID 字段 if fr.code is None: # 布局无 ID 字段
head = f'← {ts}' head = f'← {t}'
else: else:
name = TYPE_NAMES.get(fr.code) name = TYPE_NAMES.get(fr.code)
head = f'← {ts}B=0x{fr.code:02X}' + (f' {name}' if name else '') head = f'← {t}B=0x{fr.code:02X}' + (f' {name}' if name else '')
suffix = f' [{hexs(fr.raw)}]' if self.chk_raw.isChecked() else '' suffix = f' [{hexs(fr.raw)}]' if self.chk_raw.isChecked() else ''
self._append_rx_frame(f'{head} LEN={len(fr.payload)} ', payload, suffix) self._append_rx_frame(f'{head} LEN={len(fr.payload)} ', payload, suffix)
for i, c in enumerate(self._collect_channels()): self._extract_channels(fr)
def _absorb_frame(self, fr, ts, folded):
"""积压期帧处理: 只计数 + 曲线照常入库, 日志行省略(_drain_events 汇总)"""
self._rx_n += 1
self._rx_b += len(fr.raw)
if not folded['n']:
folded['t0'] = ts
folded['n'] += 1
folded['t1'] = ts
if fr.code is None: # 布局无 ID 字段
desc = '帧'
else:
name = TYPE_NAMES.get(fr.code)
desc = f'B=0x{fr.code:02X}' + (f' {name}' if name else '')
folded['cnt'][desc] = folded['cnt'].get(desc, 0) + 1
self._extract_channels(fr)
def _extract_channels(self, fr):
"""按通道配置缓存(self._ch_cfg, _on_channel 时重建)提取数值:
曲线入库 + 最新值标签。帧路径不得遍历 UI 控件——高频时放大 UI 占用"""
for i, c in enumerate(self._ch_cfg):
if not c['enabled']: if not c['enabled']:
continue continue
v = extract_value(fr.payload, c['mode'], offset=c['offset'], v = extract_value(fr.payload, c['mode'], offset=c['offset'],
@@ -1308,29 +1378,30 @@ class Window(QMainWindow):
if v is not None: if v is not None:
self._ch_ui[i]['val'].setText(fmt_val(v)) self._ch_ui[i]['val'].setText(fmt_val(v))
self.plot.add(i, v) self.plot.add(i, v)
self._update_counters()
def _on_raw(self, data): def _on_raw(self, data, ts=None):
self._rx_n += 1 self._rx_n += 1
self._rx_b += len(data) self._rx_b += len(data)
ts = f'{_timestamp()} ' if self.chk_ts.isChecked() else '' t = f'{_timestamp(ts)} ' if self.chk_ts.isChecked() else ''
disp = RX_DISPLAYS[self.combo_display.currentIndex()] disp = RX_DISPLAYS[self.combo_display.currentIndex()]
body = hexs(data) if disp == 'hex' else clean_text(data.decode('ascii', 'replace')) body = hexs(data) if disp == 'hex' else clean_text(data.decode('ascii', 'replace'))
self._append_rx(f'← {ts}{body}') self._append_rx(f'← {t}{body}')
self._update_counters()
def _on_tx(self, label, data): def _on_tx(self, label, data, ts=None):
self._tx_n += 1 self._tx_n += 1
self._tx_b += len(data) self._tx_b += len(data)
if label is None: if label is None:
self._append_rx(f'→ {_timestamp()} [HEX] {hexs(data)}') self._append_rx(f'→ {_timestamp(ts)} [HEX] {hexs(data)}')
else: else:
self._append_rx(f'→ {_timestamp()} {label} [{hexs(data)}]') self._append_rx(f'→ {_timestamp(ts)} {label} [{hexs(data)}]')
self._update_counters()
def _update_counters(self): def _update_counters(self):
"""状态栏: 收发计数 + 事件队列积压量。正常恒为 0; 数字增长=事件
产出快于消费(GUI 卡或数据量异常), 便于用户把"不显示"现场分层"""
self.statusBar().showMessage( self.statusBar().showMessage(
f'发送 {self._tx_n} 帧 / {self._tx_b} 字节 接收 {self._rx_n} 帧 / {self._rx_b} 字节') f'发送 {self._tx_n} 帧 / {self._tx_b} 字节 '
f'接收 {self._rx_n} 帧 / {self._rx_b} 字节 '
f'积压 {self.events.qsize()}')
def _on_theme(self, idx): def _on_theme(self, idx):
self.theme_manager.set_mode(('system', 'light', 'dark')[idx]) self.theme_manager.set_mode(('system', 'light', 'dark')[idx])
+41 -1
View File
@@ -6,6 +6,7 @@
用法: python selftest_gui.py (全部通过退出码 0) 用法: python selftest_gui.py (全部通过退出码 0)
""" """
import os import os
import re
import sys import sys
os.environ['QT_QPA_PLATFORM'] = 'offscreen' os.environ['QT_QPA_PLATFORM'] = 'offscreen'
@@ -94,6 +95,13 @@ p.add(0, 43.0)
check('暂停期间丢弃', p.is_paused() and len(p._times[0]) == n1, True) check('暂停期间丢弃', p.is_paused() and len(p._times[0]) == n1, True)
p.set_paused(False) p.set_paused(False)
# 0e) 时间戳: 纯格式化函数, 传入到达时刻得确定字符串, 不传兜底当前时刻
t_fix = 1759474800 + 0.456
check('时间戳格式化(到达时刻)', m._timestamp(t_fix),
_time.strftime('%H:%M:%S', _time.localtime(t_fix)) + '.456')
check('时间戳格式化(现在兜底)',
re.match(r'\d{2}:\d{2}:\d{2}\.\d{3}$', m._timestamp()) is not None, True)
# 1) 组帧预览: 多条 '/' 分隔 # 1) 组帧预览: 多条 '/' 分隔
win.ed_tx.setText('M1/M731S0T1000') win.ed_tx.setText('M1/M731S0T1000')
check('多条预览', win.lb_preview.text(), check('多条预览', win.lb_preview.text(),
@@ -116,9 +124,10 @@ check('帧构成非法详情', 'PAYLOAD 必须恰好出现一次' in win.lb_prot
win.ed_layout.setText(d.DEFAULT_LAYOUT) win.ed_layout.setText(d.DEFAULT_LAYOUT)
check('帧构成还原预览', win.lb_preview.text(), 'D5 01 02 4D 31 81') check('帧构成还原预览', win.lb_preview.text(), 'D5 01 02 4D 31 81')
# 2) TX 回显行 # 2) TX 回显行(直接驱动处理函数, 断言前 flush 批量日志缓冲)
cfg = win._proto_from_ui() cfg = win._proto_from_ui()
win._on_tx('M1', cfg.build_frame(b'M1')) win._on_tx('M1', cfg.build_frame(b'M1'))
win._flush_rx()
check('TX日志行', 'M1 [D5 01 02 4D 31 81]' in win.text_rx.toPlainText(), True) check('TX日志行', 'M1 [D5 01 02 4D 31 81]' in win.text_rx.toPlainText(), True)
# 3) 帧事件: 日志 + 通道提取(文本数字×0.1) # 3) 帧事件: 日志 + 通道提取(文本数字×0.1)
@@ -130,14 +139,22 @@ win._ch_ui[0]['mode'].setCurrentIndex(1) # 文本数字
win._ch_ui[0]['idx'].setValue(1) win._ch_ui[0]['idx'].setValue(1)
win._ch_ui[0]['scale'].setValue(0.1) win._ch_ui[0]['scale'].setValue(0.1)
win._on_frame(fr) win._on_frame(fr)
win._flush_rx()
last = win.text_rx.toPlainText().splitlines()[-1] last = win.text_rx.toPlainText().splitlines()[-1]
check('帧日志含 TYPE 名', 'B=0xF0 成功' in last and 'LEN=8' in last check('帧日志含 TYPE 名', 'B=0xF0 成功' in last and 'LEN=8' in last
and 'okTL:246' in last, True) and 'okTL:246' in last, True)
check('通道最新值', win._ch_ui[0]['val'].text(), '24.6') check('通道最新值', win._ch_ui[0]['val'].text(), '24.6')
# 时间戳用 link 后台线程打的到达时刻, 而非 GUI 消费时刻
ft = _time.time() - 3.456
win._on_frame(fr, ft)
win._flush_rx()
check('帧日志用到达时刻', m._timestamp(ft)
in win.text_rx.toPlainText().splitlines()[-1], True)
# 4) HEX 显示切换 + 原始帧 # 4) HEX 显示切换 + 原始帧
win.combo_display.setCurrentIndex(1) win.combo_display.setCurrentIndex(1)
win._on_frame(fr) win._on_frame(fr)
win._flush_rx()
last = win.text_rx.toPlainText().splitlines()[-1] last = win.text_rx.toPlainText().splitlines()[-1]
check('HEX显示+原始帧', '6F 6B 54 4C 3A 32 34 36' in last 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) and '[B5 F0 08 6F 6B 54 4C 3A 32 34 36 48]' in last, True)
@@ -145,6 +162,7 @@ check('HEX显示+原始帧', '6F 6B 54 4C 3A 32 34 36' in last
# 5) 透传行 # 5) 透传行
win.combo_display.setCurrentIndex(1) win.combo_display.setCurrentIndex(1)
win._on_raw(b'\x11\x22') win._on_raw(b'\x11\x22')
win._flush_rx()
check('透传HEX行', win.text_rx.toPlainText().splitlines()[-1].endswith('11 22'), True) check('透传HEX行', win.text_rx.toPlainText().splitlines()[-1].endswith('11 22'), True)
# 6) 未连接发送丢弃 # 6) 未连接发送丢弃
@@ -152,6 +170,28 @@ win._send()
win._drain_events() win._drain_events()
check('未连接发送提示', '未连接串口, 命令被丢弃' in win.text_rx.toPlainText(), True) check('未连接发送提示', '未连接串口, 命令被丢弃' in win.text_rx.toPlainText(), True)
# 6b) 通道配置缓存: 帧路径读 _ch_cfg, 用户改配置时重建
win._ch_ui[1]['chk'].setChecked(True)
win._ch_ui[1]['name'].setText('乙')
check('通道缓存随配置重建', win._ch_cfg[1]['enabled']
and win._ch_cfg[1]['name'] == '乙', True)
win._ch_ui[1]['chk'].setChecked(False)
check('通道缓存关闭同步', not win._ch_cfg[1]['enabled'], True)
# 6c) 积压折叠: 队列积压超阈值时帧日志折叠为计数摘要(带首尾到达时刻), 曲线照常入库
rx_n0, plot_n0 = win._rx_n, len(p._vals[0])
now = _time.time()
for k in range(250): # 100ms 间隔的到达时刻, 模拟高频上报
win.events.put(('frame', fr, now - (250 - k) / 10.0))
win._drain_events()
log = win.text_rx.toPlainText()
check('积压折叠摘要行', '折叠 250 帧' in log, True)
check('折叠显示到达跨度', m._timestamp(now - 25.0) in log
and m._timestamp(now - 0.1) in log, True)
check('折叠保曲线', len(p._vals[0]) == plot_n0 + 250, True)
check('折叠计数器照常', win._rx_n == rx_n0 + 250, True)
check('折叠摘要含帧描述', 'B=0xF0 成功 ×250' in log, True)
# 7) 配置往返 # 7) 配置往返
store = win._store_from_ui() store = win._store_from_ui()
check('配置 proto', (store['proto']['tx_hdr'], store['proto']['crc_algo']), check('配置 proto', (store['proto']['tx_hdr'], store['proto']['crc_algo']),
+13 -8
View File
@@ -40,15 +40,16 @@ def wait_for(pred, timeout=5.0):
got = {'status': [], 'frames': [], 'tx': [], 'raw': []} got = {'status': [], 'frames': [], 'tx': [], 'raw': []}
cfg = d.ProtoConfig() cfg = d.ProtoConfig()
link = SerialLink(cfg, on_status=lambda s: got['status'].append(s)) link = SerialLink(cfg, on_status=lambda s, ts: got['status'].append((s, ts)))
link.on_frame = lambda fr: got['frames'].append(fr) link.on_frame = lambda fr, ts: got['frames'].append((fr, ts))
link.on_raw = lambda b: got['raw'].append(b) link.on_raw = lambda b, ts: got['raw'].append((b, ts))
link.on_tx = lambda lb, data: got['tx'].append((lb, data)) link.on_tx = lambda lb, data, ts: got['tx'].append((lb, data, ts))
# 1) 打开虚拟回环口 # 1) 打开虚拟回环口
link.open('loop://', 115200) link.open('loop://', 115200)
check('连接 loop://', wait_for(lambda: got['status'] and check('连接 loop://', wait_for(lambda: got['status'] and
got['status'][-1].startswith('已连接')), True) got['status'][-1][0].startswith('已连接')), True)
check('状态事件携带时刻', isinstance(got['status'][-1][1], float), True)
# 2) 自动包装发送: TX 回显 + 回环字节 # 2) 自动包装发送: TX 回显 + 回环字节
payload = 'M731S0T1000' payload = 'M731S0T1000'
@@ -58,6 +59,7 @@ check('发送回显', wait_for(lambda: len(got['tx']) >= 1), True)
if got['tx']: if got['tx']:
check('TX label', got['tx'][0][0], payload) check('TX label', got['tx'][0][0], payload)
check('TX frame', got['tx'][0][1], frame) check('TX frame', got['tx'][0][1], frame)
check('TX 携带写入时刻', isinstance(got['tx'][0][2], float), True)
# 3) 模拟设备应答: okTL:246(温度 24.6°C) → 解析 + 曲线提取 # 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 resp = bytes.fromhex('B5 F0 08 6F 6B 54 4C 3A 32 34 36 48') # sum(F0..36)=0x48
@@ -65,7 +67,9 @@ check('响应帧向量自校验', cfg.verify_frame(resp), True)
link.ser.write(resp) link.ser.write(resp)
check('应答解析', wait_for(lambda: len(got['frames']) >= 1), True) check('应答解析', wait_for(lambda: len(got['frames']) >= 1), True)
if got['frames']: if got['frames']:
fr = got['frames'][0] fr, fts = got['frames'][0]
check('帧携带到达时间戳', isinstance(fts, float)
and 0 < time.time() - fts < 60, True)
check('TYPE 码', fr.code, 0xF0) check('TYPE 码', fr.code, 0xF0)
check('payload', fr.payload, b'okTL:246') check('payload', fr.payload, b'okTL:246')
v = d.extract_value(fr.payload, 'num', index=1, scale=0.1) v = d.extract_value(fr.payload, 'num', index=1, scale=0.1)
@@ -77,7 +81,8 @@ raw = bytes([0x11, 0x22, 0xB5, 0x00])
link.ser.write(raw) link.ser.write(raw)
check('透传原始字节', wait_for(lambda: len(got['raw']) >= 1), True) check('透传原始字节', wait_for(lambda: len(got['raw']) >= 1), True)
if got['raw']: if got['raw']:
check('透传内容', got['raw'][0], raw) check('透传内容', got['raw'][0][0], raw)
check('透传携带到达时间戳', isinstance(got['raw'][0][1], float), True)
# 5) 切回解析模式, 缓冲已清空, 正常再收一帧 # 5) 切回解析模式, 缓冲已清空, 正常再收一帧
link.set_parse(True) link.set_parse(True)
@@ -85,7 +90,7 @@ link.ser.write(bytes.fromhex('B5 F0 02 6F 6B CC'))
check('切回解析再收帧', wait_for(lambda: len(got['frames']) >= 2), True) check('切回解析再收帧', wait_for(lambda: len(got['frames']) >= 2), True)
link.close() link.close()
check('断开', wait_for(lambda: got['status'] and got['status'][-1] == '已断开'), True) check('断开', wait_for(lambda: got['status'] and got['status'][-1][0] == '已断开'), True)
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS') print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
sys.exit(1 if fails else 0) sys.exit(1 if fails else 0)
+13 -3
View File
@@ -7,8 +7,11 @@
## 1. 项目定位与约定 ## 1. 项目定位与约定
- 本仓库是 **LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板**的调试上位机工具集, - 本仓库是**全部调试工具的收纳仓**(起家于 **LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板**
服务上游固件仓 `hulk/LT8619C_DLPC3421_HDMI`(本机路径 调试配套)。原名 `LT8619C_Debug_Tools`,2026-09-28 更名 `HW-Debug-Tools`:Gitea 已改名
(旧地址自动重定向)、本地目录与 remote 同步更名;Gitea Release 同日清理为各已发布工具
仅保留最新版(DLPilot v0.4.0 / EDID-Viewer v0.1.0 / SerialMate v0.1.0),
旧 release 删除但 tag 与提交历史保留。服务上游固件仓 `hulk/LT8619C_DLPC3421_HDMI`(本机路径
`E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`,同 Gitea 实例 gitea.hulk.wang)。 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`,同 Gitea 实例 gitea.hulk.wang)。
光机 RS485 协议**唯一权威**是上游仓 `protocols.md`,本仓不维护协议副本。 光机 RS485 协议**唯一权威**是上游仓 `protocols.md`,本仓不维护协议副本。
- 工具链(全仓统一):Python 3.14;GUI 用 PySide6 + PySide6-Fluent-Widgets,解析/协议 - 工具链(全仓统一):Python 3.14;GUI 用 PySide6 + PySide6-Fluent-Widgets,解析/协议
@@ -26,7 +29,12 @@
自动包装发送。 自动包装发送。
- **I2C-Inject**:RP2040 固件 + PC GUI,demo 板 I2C 总线择机注入读写寄存器。 - **I2C-Inject**:RP2040 固件 + PC GUI,demo 板 I2C 总线择机注入读写寄存器。
**2026-09-25 起暂停开发**,代码与固件保留备查。 **2026-09-25 起暂停开发**,代码与固件保留备查。
- 镜像约定:`EDID-viewer` 主目录(`E:\Hulk-Coding\LT8619C_Debug_Tools\EDID-viewer`) - 子模块引用:`gd32e230f8_firmware_merge_tool/`(2026-09-30 接入,git submodule 指向
`hulk/gd32e230f8_firmware_merge_tool`)——GD32E230 APP/BL 合成补齐
(`_UPDATE.bin`/`_BL.bin`)+ DAPLink Flash GUI 烧录。代码、版本与发布均在原仓库
独立管理,本仓只锁指针;新克隆 `git submodule update --init` 拉取,跟进上游
`git submodule update --remote gd32e230f8_firmware_merge_tool` 后随本仓提交新指针。
- 镜像约定:`EDID-viewer` 主目录(`E:\Hulk-Coding\HW-Debug-Tools\EDID-viewer`)
改动后需 robocopy /MIR 同步到 `E:\Hulk-Coding\LT8619C\_Debug\_Tools\EDID-viewer` 改动后需 robocopy /MIR 同步到 `E:\Hulk-Coding\LT8619C\_Debug\_Tools\EDID-viewer`
(排除 .venv/build/__pycache__/.git,zip 不同步)。 (排除 .venv/build/__pycache__/.git,zip 不同步)。
- 发布约定:源码与打包产物(exe、运行配置)入库;发布 zip 不入库,只上传 Gitea Release。 - 发布约定:源码与打包产物(exe、运行配置)入库;发布 zip 不入库,只上传 Gitea Release。
@@ -62,6 +70,8 @@
- **更名**:原名 SerialAssistant(目录/exe/文档/配置同步更名;旧配置 - **更名**:原名 SerialAssistant(目录/exe/文档/配置同步更名;旧配置
serial_assistant.json 首次启动自动迁移为 serial_mate.json;图标与名字 serial_assistant.json 首次启动自动迁移为 serial_mate.json;图标与名字
无绑定为波形+RS485 母题)。用户确认 "SerialMate 吧"(2026-09-27)。 无绑定为波形+RS485 母题)。用户确认 "SerialMate 吧"(2026-09-27)。
- **已发布**:commit d133906 推送 main 并打 tag `SerialMate-v0.1.0`,
Release 附件 `SerialMate-v0.1.0-win64.zip`(63.7 MB)已上传核对。
- 协议:**帧构成令牌序列可定制**(`HDR ID LEN PAYLOAD CRC` + 十六进制字面量 - 协议:**帧构成令牌序列可定制**(`HDR ID LEN PAYLOAD CRC` + 十六进制字面量
同步头,收发共用布局仅包头分方向;支持字段换序/无板ID/多字节同步头,LEN 同步头,收发共用布局仅包头分方向;支持字段换序/无板ID/多字节同步头,LEN
口径 payload 长度/到帧尾/整帧三种)、5 种 CRC 算法 × 3 种覆盖范围,默认与 口径 payload 长度/到帧尾/整帧三种)、5 种 CRC 算法 × 3 种覆盖范围,默认与