Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e902503807 | ||
|
|
108ff64624 | ||
|
|
9810809fd9 | ||
|
|
d133906d32 | ||
|
|
d5566714ff | ||
|
|
f90ac0d8e9 | ||
|
|
7d15d31d74 | ||
|
|
4dddcd00c9 | ||
|
|
51b560d840 | ||
|
|
68d70497d4 | ||
|
|
74b64dcfd5 | ||
|
|
cf61916a9a | ||
|
|
e3544239af | ||
|
|
6c56fac5dc | ||
|
|
9b29b6afd6 | ||
|
|
30bc224710 | ||
|
|
3f36ead3fe | ||
|
|
e87bf14aaa | ||
|
|
c8ea5be031 | ||
|
|
2b7d103312 | ||
|
|
9585fae947 | ||
|
|
8e6107b490 | ||
|
|
9772a2bf83 | ||
|
|
cd14ed2ca4 | ||
|
|
4cc418f863 | ||
|
|
040b628d54 | ||
|
|
51e2f1c51c | ||
|
|
8497b1979a | ||
|
|
05e4f3ae92 | ||
|
|
059ce6a540 | ||
|
|
b167ce3401 | ||
|
|
a6721415a6 | ||
|
|
f0f8073afc | ||
|
|
3f35ad2c21 | ||
|
|
c1a5712f76 | ||
|
|
8e8811a4a0 | ||
|
|
b70cf102fd | ||
|
|
b36dd3911f | ||
|
|
cda1e72f17 | ||
|
|
744fbfd298 | ||
|
|
90433064ec | ||
|
|
68c7909922 | ||
|
|
e131646156 | ||
|
|
96df14caea | ||
|
|
d92156e0cd | ||
|
|
f549ddf48b | ||
|
|
84838e3e74 | ||
|
|
1a9927d9bb | ||
|
|
22d0969569 | ||
|
|
d9ecff4e49 | ||
|
|
2459b33162 | ||
|
|
efaf4d658f | ||
|
|
a14e3e7621 | ||
|
|
a1f6fdd34a | ||
|
|
0977b275ee | ||
|
|
736b43efe4 | ||
|
|
d0cefeebf3 |
@@ -1,3 +0,0 @@
|
|||||||
[submodule "gd32e230f8_firmware_merge_tool"]
|
|
||||||
path = gd32e230f8_firmware_merge_tool
|
|
||||||
url = https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool.git
|
|
||||||
+6
-81
@@ -42,8 +42,6 @@ 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 文件名) |
|
||||||
@@ -94,13 +92,6 @@ 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. 领域语义(测试值都有含义,勿"顺手改")
|
||||||
@@ -164,9 +155,6 @@ 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 依赖带回入口。
|
||||||
|
|
||||||
@@ -174,16 +162,12 @@ 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 selftest_theme.py` —— 主题切换回归(改 app_fluent.py 涉及样式/主题后必跑;
|
3. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
|
||||||
离屏渲染,Dark/Light 往返后校验 swatch 底色样式与渲染像素、标题字号);
|
4. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
|
||||||
3b. `python selftest_log.py` —— 收发日志上限/队列消费回归(改 `_dlpc_log`/`_poll_dlpc`
|
5. `python hdmi_gui.py --dump` —— EDID 解算不抛异常;
|
||||||
后必跑;600 行封顶 500、坏条目不中断 drain);
|
6. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级);
|
||||||
4. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
|
7. `python build_exe.py` → 双击 release/DLPilot.exe 确认图标/双栏布局/投图/光机区渲染;
|
||||||
5. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
|
8. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。
|
||||||
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)
|
||||||
|
|
||||||
@@ -196,62 +180,3 @@ 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)与设备
|
|
||||||
拔出混判,一次转换器停滞即断链误报"设备拔出"。
|
|
||||||
|
|||||||
Binary file not shown.
+15
-47
@@ -13,7 +13,6 @@ 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
|
||||||
@@ -53,8 +52,7 @@ 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,
|
PrimaryPushButton, PushButton, Slider, StrongBodyLabel, Theme, setTheme,
|
||||||
setCustomStyleSheet, setTheme,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
import config
|
import config
|
||||||
@@ -64,7 +62,7 @@ import edid_parse
|
|||||||
import patterns
|
import patterns
|
||||||
|
|
||||||
APP_NAME = "DLPilot"
|
APP_NAME = "DLPilot"
|
||||||
APP_VERSION = "0.4.3"
|
APP_VERSION = "0.4.0"
|
||||||
REPO_URL = "https://gitea.hulk.wang/hulk/HW-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"
|
||||||
@@ -211,7 +209,7 @@ class App(QMainWindow):
|
|||||||
button.clicked.connect(callback)
|
button.clicked.connect(callback)
|
||||||
return button
|
return button
|
||||||
|
|
||||||
def _card(self, title, parent, trailing=None):
|
def _card(self, title, parent):
|
||||||
card = CardWidget(parent)
|
card = CardWidget(parent)
|
||||||
layout = QVBoxLayout(card)
|
layout = QVBoxLayout(card)
|
||||||
layout.setContentsMargins(14, 12, 14, 14)
|
layout.setContentsMargins(14, 12, 14, 14)
|
||||||
@@ -219,8 +217,6 @@ 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
|
||||||
|
|
||||||
@@ -270,12 +266,7 @@ class App(QMainWindow):
|
|||||||
outer.setSpacing(12)
|
outer.setSpacing(12)
|
||||||
header = QHBoxLayout()
|
header = QHBoxLayout()
|
||||||
title = StrongBodyLabel("DLPilot", root)
|
title = StrongBodyLabel("DLPilot", root)
|
||||||
# 标题字号走 QFont:FluentLabelBase 在每次主题切换时会用自带的颜色规则
|
title.setStyleSheet("font-size: 24px; font-weight: 700;")
|
||||||
# 重写自定义 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)
|
||||||
@@ -364,13 +355,7 @@ 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)
|
||||||
# fluent 控件的样式须走 setCustomStyleSheet:直接 setStyleSheet 的内容
|
button.setStyleSheet(f"PushButton {{ background: {color}; color: {'black' if color == '#00FF00' else 'white'}; }}")
|
||||||
# 会在 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)
|
||||||
@@ -429,22 +414,18 @@ 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)
|
||||||
trailing=self._button("清空", self._dlpc_clear))
|
row = QHBoxLayout()
|
||||||
|
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)
|
||||||
@@ -476,14 +457,6 @@ 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)
|
||||||
@@ -760,12 +733,9 @@ 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):
|
||||||
while True:
|
try:
|
||||||
try:
|
while True:
|
||||||
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()
|
||||||
@@ -774,16 +744,15 @@ 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 Exception:
|
except queue.Empty: pass
|
||||||
# 单条事件处理异常只打栈放弃该条, 不得中断本轮消费——否则积压随事件流
|
|
||||||
# 单调增长(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()
|
||||||
@@ -801,7 +770,6 @@ 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)
|
||||||
|
|||||||
@@ -326,19 +326,11 @@ class DLPC485:
|
|||||||
self.ser = None
|
self.ser = None
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._target = None
|
self._target = None
|
||||||
blob = str(e) + repr(e)
|
et = str(e)
|
||||||
if 'PermissionError' in blob or '拒绝访问' in blob \
|
if 'PermissionError' in repr(e) or '拒绝访问' in et \
|
||||||
or 'Access is denied' in blob:
|
or 'Access is denied' in et:
|
||||||
hint = '串口被占用(串口助手/另一实例?)'
|
et += '(串口被占用: 检查串口助手/另一个本工具实例)'
|
||||||
elif 'FileNotFoundError' in blob or '找不到指定的' in blob \
|
self.on_status(f'连接失败: {et}')
|
||||||
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
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
# 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())
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# 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())
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -6,13 +6,12 @@
|
|||||||
|
|
||||||
## 工具一览
|
## 工具一览
|
||||||
|
|
||||||
| 图标 | 工具 | 用途 | 获取 |
|
| 工具 | 用途 | 获取 |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
| <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) |
|
| [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) |
|
||||||
| <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) |
|
| [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) |
|
||||||
| <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) |
|
| [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) |
|
||||||
| <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) |
|
| [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) |
|
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|
||||||
@@ -51,7 +50,6 @@ HW-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
|
||||||
```
|
```
|
||||||
@@ -70,10 +68,6 @@ 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-30
|
- 更新:2026-09-27
|
||||||
|
|||||||
+1
-67
@@ -54,7 +54,7 @@ SerialMate/
|
|||||||
保证内容贴顶(卡片被拖高后富余高度收在卡底,勿删——否则拉伸被各行均分,
|
保证内容贴顶(卡片被拖高后富余高度收在卡底,勿删——否则拉伸被各行均分,
|
||||||
标题上方出现空隙);发送卡行序:板型管理行(下拉+新建+删除+配置+重载) /
|
标题上方出现空隙);发送卡行序:板型管理行(下拉+新建+删除+配置+重载) /
|
||||||
预设行(下拉+填入+直接发送) / 模式行(下拉+存为板型+存入预设) / 输入行 /
|
预设行(下拉+填入+直接发送) / 模式行(下拉+存为板型+存入预设) / 输入行 /
|
||||||
周期行;帧日志 payload 高亮走 `_append_rx_frame`(`_esc_log` 转义后拼 span
|
周期行;帧日志 payload 高亮走 `_append_rx_frame`(html.escape 后拼 span
|
||||||
背景,toPlainText 内容不变,selftest 的日志断言因此不受影响);预设下拉
|
背景,toPlainText 内容不变,selftest 的日志断言因此不受影响);预设下拉
|
||||||
显示"名称 · 命令",存入命令走 `_add_command_named`(弹窗核 `_add_command`
|
显示"名称 · 命令",存入命令走 `_add_command_named`(弹窗核 `_add_command`
|
||||||
包装,测试用核函数),预设直发走 `_send_preset_direct`(经输入框复用
|
包装,测试用核函数),预设直发走 `_send_preset_direct`(经输入框复用
|
||||||
@@ -65,21 +65,6 @@ 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`
|
|
||||||
(空格转 ` `,防富文本折叠连续空格破坏 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`
|
||||||
@@ -196,57 +181,6 @@ 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` 空格转 ` ` 防富文本
|
|
||||||
折叠连续空格);`_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,首版)
|
||||||
|
|
||||||
已完成:
|
已完成:
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
# 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 失效);转义空格转 ` `
|
|
||||||
> 防富文本折叠连续空格;通道缓存放 `_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 卡顿成批挤压、显示延迟随运行时间增大;
|
|
||||||
修复后时间戳单调且间隔≈设定周期,长时间运行无卡顿增长。
|
|
||||||
@@ -36,10 +36,7 @@
|
|||||||
| 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;
|
||||||
时间戳为**字节到达时刻**(串口后台线程打点随帧携带,UI 繁忙时日志时间
|
时间戳;日志保存为 txt。
|
||||||
依然可信);日志保存为 txt。接收日志自动治理:行数上限 5000(超量淘汰
|
|
||||||
最旧)、批量刷新、事件积压时高频帧日志自动折叠为计数摘要(曲线照常
|
|
||||||
采集)——长时间高频运行不累积卡顿。
|
|
||||||
- **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式
|
- **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式
|
||||||
(二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率,
|
(二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率,
|
||||||
y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮,
|
y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮,
|
||||||
|
|||||||
@@ -36,10 +36,7 @@
|
|||||||
| 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;
|
||||||
时间戳为**字节到达时刻**(串口后台线程打点随帧携带,UI 繁忙时日志时间
|
时间戳;日志保存为 txt。
|
||||||
依然可信);日志保存为 txt。接收日志自动治理:行数上限 5000(超量淘汰
|
|
||||||
最旧)、批量刷新、事件积压时高频帧日志自动折叠为计数摘要(曲线照常
|
|
||||||
采集)——长时间高频运行不累积卡顿。
|
|
||||||
- **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式
|
- **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式
|
||||||
(二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率,
|
(二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率,
|
||||||
y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮,
|
y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮,
|
||||||
|
|||||||
Binary file not shown.
@@ -1,49 +0,0 @@
|
|||||||
"""模拟真机 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)
|
|
||||||
+15
-26
@@ -30,14 +30,11 @@ DEFAULTS = dict(bytesize=8, parity='N', stopbits=1)
|
|||||||
|
|
||||||
|
|
||||||
class SerialLink:
|
class SerialLink:
|
||||||
"""串口工作线程。回调(后台线程触发, 第二参数 ts=事件发生的 epoch 秒):
|
"""串口工作线程。回调(后台线程触发):
|
||||||
on_status(text, ts) 连接状态变化(已连接/断开/失败/设备拔出)
|
on_status(str) 连接状态变化(已连接/断开/失败/设备拔出)
|
||||||
on_frame(fr, ts) 解析模式: 完整 Frame(protocol.Frame); ts=本批字节到达时刻
|
on_frame(fr) 解析模式: 完整 Frame(protocol.Frame)
|
||||||
on_raw(data, ts) 透传模式: 原始接收 bytes; ts=到达时刻
|
on_raw(data) 透传模式: 原始接收 bytes
|
||||||
on_tx(label, data, ts) 发送回显: label=payload 字符串(自动包装)/None(HEX直发);
|
on_tx(label, data) 发送回显: label=payload 字符串(自动包装)/None(HEX直发)
|
||||||
ts=写入串口时刻
|
|
||||||
时间戳必须由本模块在读线程打(字节到达时刻), GUI 消费侧再打会随 UI 卡顿
|
|
||||||
成批挤压失真——日志时间就不再是到达时间。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, cfg, on_status):
|
def __init__(self, cfg, on_status):
|
||||||
@@ -103,7 +100,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', time.time())
|
self.on_status('未安装 pyserial: py -m pip install pyserial')
|
||||||
return
|
return
|
||||||
params = {**DEFAULTS, **kw}
|
params = {**DEFAULTS, **kw}
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -126,7 +123,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('未连接串口, 命令被丢弃', time.time())
|
self.on_status('未连接串口, 命令被丢弃')
|
||||||
return False
|
return False
|
||||||
self.q.put((label, bytes(frame)))
|
self.q.put((label, bytes(frame)))
|
||||||
return True
|
return True
|
||||||
@@ -151,7 +148,7 @@ class SerialLink:
|
|||||||
pass
|
pass
|
||||||
self.ser = None
|
self.ser = None
|
||||||
self._open_key = None
|
self._open_key = None
|
||||||
self.on_status('已断开', time.time())
|
self.on_status('已断开')
|
||||||
if target:
|
if target:
|
||||||
port, baud, dbsize, parity, sbits = target
|
port, baud, dbsize, parity, sbits = target
|
||||||
try:
|
try:
|
||||||
@@ -162,19 +159,12 @@ 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:
|
||||||
@@ -183,7 +173,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}', time.time())
|
self.on_status(f'连接失败: {et}')
|
||||||
if self.ser is not None:
|
if self.ser is not None:
|
||||||
try:
|
try:
|
||||||
item = None
|
item = None
|
||||||
@@ -195,7 +185,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, time.time())
|
self.on_tx(label, frame)
|
||||||
data = self.ser.read(4096)
|
data = self.ser.read(4096)
|
||||||
if data:
|
if data:
|
||||||
self._dispatch_rx(data)
|
self._dispatch_rx(data)
|
||||||
@@ -208,11 +198,10 @@ 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('串口丢失(设备拔出?), 请重新连接', time.time())
|
self.on_status('串口丢失(设备拔出?), 请重新连接')
|
||||||
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
|
||||||
@@ -221,6 +210,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, ts)
|
self.on_frame(fr)
|
||||||
elif self.on_raw is not None:
|
elif self.on_raw is not None:
|
||||||
self.on_raw(data, ts)
|
self.on_raw(data)
|
||||||
|
|||||||
+39
-110
@@ -49,7 +49,7 @@ 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.1'
|
APP_VERSION = '0.1.0'
|
||||||
REPO_URL = 'https://gitea.hulk.wang/hulk/HW-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)
|
||||||
@@ -69,23 +69,10 @@ 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 _esc_log(text):
|
def _timestamp():
|
||||||
"""日志行入 HTML 缓冲前的转义: html.escape + 空格改 ——
|
t = time.time()
|
||||||
富文本会折叠连续空格, 日志对齐(如 TX 行 '命令 [hex]')依赖原空格"""
|
|
||||||
return html.escape(text).replace(' ', ' ')
|
|
||||||
|
|
||||||
|
|
||||||
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}'
|
||||||
|
|
||||||
|
|
||||||
@@ -245,17 +232,14 @@ 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, ts: self.events.put(('status', s, ts)))
|
on_status=lambda s: self.events.put(('status', s)))
|
||||||
self.link.on_frame = lambda fr, ts: self.events.put(('frame', fr, ts))
|
self.link.on_frame = lambda fr: self.events.put(('frame', fr))
|
||||||
self.link.on_raw = lambda b, ts: self.events.put(('raw', b, ts))
|
self.link.on_raw = lambda b: self.events.put(('raw', b))
|
||||||
self.link.on_tx = lambda lb, d, ts: self.events.put(('tx', lb, d, ts))
|
self.link.on_tx = lambda lb, d: self.events.put(('tx', lb, d))
|
||||||
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)
|
||||||
@@ -650,8 +634,7 @@ class Window(QMainWindow):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
def _on_channel(self, *_):
|
def _on_channel(self, *_):
|
||||||
self._ch_cfg = self._collect_channels() # 用户改配置才重建, 帧路径只读缓存
|
for i, c in enumerate(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()
|
||||||
|
|
||||||
@@ -840,7 +823,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(RX_MAX_BLOCKS)
|
self.text_rx.document().setMaximumBlockCount(5000)
|
||||||
lay.addWidget(self.text_rx, 1)
|
lay.addWidget(self.text_rx, 1)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
@@ -1262,114 +1245,61 @@ class Window(QMainWindow):
|
|||||||
# ------------------------------------------------------- 事件与回显
|
# ------------------------------------------------------- 事件与回显
|
||||||
|
|
||||||
def _drain_events(self):
|
def _drain_events(self):
|
||||||
"""30ms 轮询消费 link 事件。事件尾部携带 link 后台线程打的发生时刻 ts
|
for _ in range(400): # 单次上限, 防极端速率卡 UI
|
||||||
(字节到达/写入串口), 日志时间因此是到达时间而非显示时间。
|
|
||||||
接收日志行先进 _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:
|
||||||
break
|
return
|
||||||
kind = ev[0]
|
kind = ev[0]
|
||||||
ts = ev[-1]
|
|
||||||
if kind == 'status':
|
if kind == 'status':
|
||||||
self._on_status(ev[1], ts)
|
self._on_status(ev[1])
|
||||||
elif kind == 'frame':
|
elif kind == 'frame':
|
||||||
if folded is None and self.events.qsize() > FOLD_QSIZE:
|
self._on_frame(ev[1])
|
||||||
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], ts)
|
self._on_raw(ev[1])
|
||||||
elif kind == 'tx':
|
elif kind == 'tx':
|
||||||
self._on_tx(ev[1], ev[2], ts)
|
self._on_tx(ev[1], ev[2])
|
||||||
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, ts=None):
|
def _hint(self, text):
|
||||||
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(ts)} {text}')
|
self._append_rx(f'· {_timestamp()} {text}')
|
||||||
|
|
||||||
def _on_status(self, text, ts=None):
|
def _on_status(self, text):
|
||||||
"""连接状态: 同步连接按钮文案 + 提示"""
|
"""连接状态: 同步连接按钮文案 + 提示"""
|
||||||
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, ts)
|
self._hint(text)
|
||||||
|
|
||||||
def _append_rx(self, line):
|
def _append_rx(self, line):
|
||||||
"""接收日志行: 只入批量缓冲, 由 _flush_rx 统一写入"""
|
self.text_rx.appendPlainText(line)
|
||||||
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._rx_buf.append(
|
|
||||||
f'{_esc_log(prefix)}'
|
|
||||||
f'<span style="background-color:rgba(96,125,180,0.16);">'
|
|
||||||
f'{_esc_log(core)}</span>'
|
|
||||||
f'{_esc_log(suffix)}')
|
|
||||||
|
|
||||||
def _flush_rx(self):
|
|
||||||
"""批量写入接收日志: 每行一个 <p>(一个 block), maximumBlockCount
|
|
||||||
裁旧行照常生效; 一次 appendHtml 替代逐行 N 次, 长跑不再因排版
|
|
||||||
成本拖垮事件轮询"""
|
|
||||||
if not self._rx_buf:
|
|
||||||
return
|
|
||||||
self.text_rx.appendHtml(
|
self.text_rx.appendHtml(
|
||||||
''.join(f'<p style="margin:0">{line}</p>' for line in self._rx_buf))
|
f'{html.escape(prefix)}'
|
||||||
self._rx_buf.clear()
|
f'<span style="background-color:rgba(96,125,180,0.16);">'
|
||||||
|
f'{html.escape(core)}</span>'
|
||||||
|
f'{html.escape(suffix)}')
|
||||||
|
|
||||||
def _on_frame(self, fr, ts=None):
|
def _on_frame(self, fr):
|
||||||
self._rx_n += 1
|
self._rx_n += 1
|
||||||
self._rx_b += len(fr.raw)
|
self._rx_b += len(fr.raw)
|
||||||
t = f'{_timestamp(ts)} ' if self.chk_ts.isChecked() else ''
|
ts = f'{_timestamp()} ' 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'← {t}'
|
head = f'← {ts}'
|
||||||
else:
|
else:
|
||||||
name = TYPE_NAMES.get(fr.code)
|
name = TYPE_NAMES.get(fr.code)
|
||||||
head = f'← {t}B=0x{fr.code:02X}' + (f' {name}' if name else '')
|
head = f'← {ts}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)
|
||||||
self._extract_channels(fr)
|
for i, c in enumerate(self._collect_channels()):
|
||||||
|
|
||||||
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'],
|
||||||
@@ -1378,30 +1308,29 @@ 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, ts=None):
|
def _on_raw(self, data):
|
||||||
self._rx_n += 1
|
self._rx_n += 1
|
||||||
self._rx_b += len(data)
|
self._rx_b += len(data)
|
||||||
t = f'{_timestamp(ts)} ' if self.chk_ts.isChecked() else ''
|
ts = f'{_timestamp()} ' 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'← {t}{body}')
|
self._append_rx(f'← {ts}{body}')
|
||||||
|
self._update_counters()
|
||||||
|
|
||||||
def _on_tx(self, label, data, ts=None):
|
def _on_tx(self, label, data):
|
||||||
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(ts)} [HEX] {hexs(data)}')
|
self._append_rx(f'→ {_timestamp()} [HEX] {hexs(data)}')
|
||||||
else:
|
else:
|
||||||
self._append_rx(f'→ {_timestamp(ts)} {label} [{hexs(data)}]')
|
self._append_rx(f'→ {_timestamp()} {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} 字节 '
|
f'发送 {self._tx_n} 帧 / {self._tx_b} 字节 接收 {self._rx_n} 帧 / {self._rx_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])
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
用法: 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'
|
||||||
@@ -95,13 +94,6 @@ 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(),
|
||||||
@@ -124,10 +116,9 @@ 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 回显行(直接驱动处理函数, 断言前 flush 批量日志缓冲)
|
# 2) TX 回显行
|
||||||
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)
|
||||||
@@ -139,22 +130,14 @@ 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)
|
||||||
@@ -162,7 +145,6 @@ 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) 未连接发送丢弃
|
||||||
@@ -170,28 +152,6 @@ 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']),
|
||||||
|
|||||||
@@ -40,16 +40,15 @@ 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, ts: got['status'].append((s, ts)))
|
link = SerialLink(cfg, on_status=lambda s: got['status'].append(s))
|
||||||
link.on_frame = lambda fr, ts: got['frames'].append((fr, ts))
|
link.on_frame = lambda fr: got['frames'].append(fr)
|
||||||
link.on_raw = lambda b, ts: got['raw'].append((b, ts))
|
link.on_raw = lambda b: got['raw'].append(b)
|
||||||
link.on_tx = lambda lb, data, ts: got['tx'].append((lb, data, ts))
|
link.on_tx = lambda lb, data: got['tx'].append((lb, data))
|
||||||
|
|
||||||
# 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][0].startswith('已连接')), True)
|
got['status'][-1].startswith('已连接')), True)
|
||||||
check('状态事件携带时刻', isinstance(got['status'][-1][1], float), True)
|
|
||||||
|
|
||||||
# 2) 自动包装发送: TX 回显 + 回环字节
|
# 2) 自动包装发送: TX 回显 + 回环字节
|
||||||
payload = 'M731S0T1000'
|
payload = 'M731S0T1000'
|
||||||
@@ -59,7 +58,6 @@ 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
|
||||||
@@ -67,9 +65,7 @@ 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, fts = got['frames'][0]
|
fr = 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)
|
||||||
@@ -81,8 +77,7 @@ 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][0], raw)
|
check('透传内容', got['raw'][0], raw)
|
||||||
check('透传携带到达时间戳', isinstance(got['raw'][0][1], float), True)
|
|
||||||
|
|
||||||
# 5) 切回解析模式, 缓冲已清空, 正常再收一帧
|
# 5) 切回解析模式, 缓冲已清空, 正常再收一帧
|
||||||
link.set_parse(True)
|
link.set_parse(True)
|
||||||
@@ -90,7 +85,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][0] == '已断开'), True)
|
check('断开', wait_for(lambda: got['status'] and got['status'][-1] == '已断开'), 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)
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
自动包装发送。
|
自动包装发送。
|
||||||
- **I2C-Inject**:RP2040 固件 + PC GUI,demo 板 I2C 总线择机注入读写寄存器。
|
- **I2C-Inject**:RP2040 固件 + PC GUI,demo 板 I2C 总线择机注入读写寄存器。
|
||||||
**2026-09-25 起暂停开发**,代码与固件保留备查。
|
**2026-09-25 起暂停开发**,代码与固件保留备查。
|
||||||
- 子模块引用:`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`)
|
- 镜像约定:`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 不同步)。
|
||||||
|
|||||||
Submodule gd32e230f8_firmware_merge_tool deleted from d776979a5a
Reference in New Issue
Block a user